diff --git a/prisma/seed.ts b/prisma/seed.ts index f0269fa22..03070452b 100755 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -178,7 +178,7 @@ async function addTask( ) { // Note: Task-Tag relationships are handled separately via TaskTag table await taskFactory.create({ - contest_type: classifyContest(task.contest_id), + contest_type: classifyContest(task.contest_id) ?? undefined, contest_id: task.contest_id, task_table_index: task.problem_index, task_id: task.id, diff --git a/src/features/votes/actions/vote_actions.ts b/src/features/votes/actions/vote_actions.ts index 4c3c4abc5..e273b89c1 100644 --- a/src/features/votes/actions/vote_actions.ts +++ b/src/features/votes/actions/vote_actions.ts @@ -1,25 +1,22 @@ import { fail } from '@sveltejs/kit'; -import { TaskGrade } from '@prisma/client'; +import type { TaskGrade } from '@prisma/client'; import { upsertVoteGradeTables } from '$features/votes/services/vote_grade'; import { - BAD_REQUEST, FORBIDDEN, INTERNAL_SERVER_ERROR, UNAUTHORIZED, } from '$lib/constants/http-response-status-codes'; -// Non-votable grades that must be excluded from valid vote submissions. -const NON_VOTABLE_GRADES = new Set([TaskGrade.PENDING]); +import type { VoteAbsoluteGradeInput } from '$features/votes/zod/schema'; export const voteAbsoluteGrade = async ({ - request, locals, + data, }: { - request: Request; locals: App.Locals; + data: VoteAbsoluteGradeInput; }) => { - const formData = await request.formData(); const session = await locals.auth.validate(); if (!session || !session.user || !session.user.userId) { @@ -34,25 +31,8 @@ export const voteAbsoluteGrade = async ({ }); } - const userId = session.user.userId; - const taskIdRaw = formData.get('taskId'); - const gradeRaw = formData.get('grade'); - - if ( - typeof taskIdRaw !== 'string' || - !taskIdRaw || - typeof gradeRaw !== 'string' || - !(Object.values(TaskGrade) as string[]).includes(gradeRaw) || - NON_VOTABLE_GRADES.has(gradeRaw) - ) { - return fail(BAD_REQUEST, { message: 'Invalid request parameters.' }); - } - - const taskId = taskIdRaw; - const grade = gradeRaw as TaskGrade; - try { - await upsertVoteGradeTables(userId, taskId, grade); + await upsertVoteGradeTables(session.user.userId, data.taskId, data.grade as TaskGrade); return { success: true as const }; } catch (error) { console.error('Failed to vote absolute grade: ', error); diff --git a/src/features/votes/zod/schema.ts b/src/features/votes/zod/schema.ts new file mode 100644 index 000000000..c800967a8 --- /dev/null +++ b/src/features/votes/zod/schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { TaskGrade } from '@prisma/client'; + +export const voteAbsoluteGradeSchema = z.object({ + taskId: z.string().trim().min(1), + grade: z + .nativeEnum(TaskGrade) + .refine((val) => val !== TaskGrade.PENDING, { message: 'Cannot vote for PENDING grade' }), +}); + +export type VoteAbsoluteGradeInput = z.infer; diff --git a/src/features/workbooks/stores/replenishment_workbook.test.ts b/src/features/workbooks/stores/replenishment_workbook.test.ts index 12151c3e9..8dcbff159 100644 --- a/src/features/workbooks/stores/replenishment_workbook.test.ts +++ b/src/features/workbooks/stores/replenishment_workbook.test.ts @@ -1,4 +1,4 @@ -import { expect, test, vi } from 'vitest'; +import { expect, test, vi, type Mock } from 'vitest'; import { replenishmentWorkBooksStore } from '$features/workbooks/stores/replenishment_workbook.svelte'; @@ -47,7 +47,7 @@ describe('Replenishment workbooks store', () => { // Note: This test is skipped because it is not possible to mock localStorage in JSDOM. test.skip('persists state in localStorage', () => { - (mockLocalStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify(false)); + (mockLocalStorage.getItem as Mock).mockReturnValue(JSON.stringify(false)); replenishmentWorkBooksStore.toggleView(); diff --git a/src/lib/types/auth_forms.ts b/src/lib/types/auth_forms.ts index 39e3b6c86..7cecc61fc 100644 --- a/src/lib/types/auth_forms.ts +++ b/src/lib/types/auth_forms.ts @@ -19,6 +19,8 @@ export type AuthFormConstraints = { password?: FieldConstraints; }; +type SchemaShape = Record; + /** * Represents the state and data structure for authentication forms. * @@ -31,7 +33,7 @@ export type AuthFormConstraints = { * @property {string} data.password - The password field value * @property {Record} errors - Collection of validation errors keyed by field name * @property {AuthFormConstraints} [constraints] - Optional validation constraints for the form - * @property {Record} [shape] - Optional form schema or structure definition + * @property {SchemaShape} [shape] - Optional schema shape for nested error mapping * @property {string} message - General message associated with the form (success, error, etc.) */ export type AuthForm = { @@ -41,7 +43,7 @@ export type AuthForm = { data: { username: string; password: string }; errors: Record; constraints?: AuthFormConstraints; - shape?: Record; + shape?: SchemaShape; message: string; }; diff --git a/src/routes/problems/+page.server.ts b/src/routes/problems/+page.server.ts index e0fa882e0..7c237cca6 100644 --- a/src/routes/problems/+page.server.ts +++ b/src/routes/problems/+page.server.ts @@ -1,4 +1,6 @@ -import { type Actions } from '@sveltejs/kit'; +import { fail, type Actions } from '@sveltejs/kit'; +import { superValidate } from 'sveltekit-superforms'; +import { zod4 } from 'sveltekit-superforms/adapters'; import * as task_crud from '$lib/services/task_results'; import { getVoteGradeStatistics } from '$features/votes/services/vote_statistics'; @@ -6,6 +8,8 @@ import type { TaskResults } from '$lib/types/task'; import { Roles } from '$lib/types/user'; import { updateTaskResult } from '$lib/actions/update_task_result'; import { voteAbsoluteGrade } from '@/features/votes/actions/vote_actions'; +import { voteAbsoluteGradeSchema } from '$features/votes/zod/schema'; +import { BAD_REQUEST } from '$lib/constants/http-response-status-codes'; // 一覧表ページは、ログインしていなくても閲覧できるようにする export async function load({ locals, url }) { @@ -53,6 +57,10 @@ export const actions = { return await updateTaskResult({ request, locals }, operationLog); }, voteAbsoluteGrade: async ({ request, locals }) => { - return await voteAbsoluteGrade({ request, locals }); + const form = await superValidate(request, zod4(voteAbsoluteGradeSchema)); + if (!form.valid) { + return fail(BAD_REQUEST, { form }); + } + return await voteAbsoluteGrade({ locals, data: form.data }); }, } satisfies Actions; diff --git a/src/routes/votes/[slug]/+page.server.ts b/src/routes/votes/[slug]/+page.server.ts index 181179028..d52796cbc 100644 --- a/src/routes/votes/[slug]/+page.server.ts +++ b/src/routes/votes/[slug]/+page.server.ts @@ -1,4 +1,6 @@ -import { error } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; +import { superValidate } from 'sveltekit-superforms'; +import { zod4 } from 'sveltekit-superforms/adapters'; import type { Actions, PageServerLoad } from './$types'; import { getTask } from '$lib/services/tasks'; @@ -8,6 +10,8 @@ import { getVoteStatsByTaskId, } from '$features/votes/services/vote_statistics'; import { voteAbsoluteGrade } from '$features/votes/actions/vote_actions'; +import { voteAbsoluteGradeSchema } from '$features/votes/zod/schema'; +import { BAD_REQUEST } from '$lib/constants/http-response-status-codes'; export const load: PageServerLoad = async ({ locals, params }) => { const session = await locals.auth.validate(); @@ -44,6 +48,10 @@ export const load: PageServerLoad = async ({ locals, params }) => { export const actions: Actions = { voteAbsoluteGrade: async ({ request, locals }) => { - return await voteAbsoluteGrade({ request, locals }); + const form = await superValidate(request, zod4(voteAbsoluteGradeSchema)); + if (!form.valid) { + return fail(BAD_REQUEST, { form }); + } + return await voteAbsoluteGrade({ locals, data: form.data }); }, }; diff --git a/src/routes/workbooks/[slug]/+page.server.ts b/src/routes/workbooks/[slug]/+page.server.ts index 599213f9b..336ed9684 100644 --- a/src/routes/workbooks/[slug]/+page.server.ts +++ b/src/routes/workbooks/[slug]/+page.server.ts @@ -1,4 +1,6 @@ -import { error, type Actions } from '@sveltejs/kit'; +import { error, fail, type Actions } from '@sveltejs/kit'; +import { superValidate } from 'sveltekit-superforms'; +import { zod4 } from 'sveltekit-superforms/adapters'; import { Roles } from '$lib/types/user'; @@ -6,6 +8,8 @@ import * as taskResultsCrud from '$lib/services/task_results'; import { getWorkbookWithAuthor } from '$features/workbooks/services/workbooks'; import * as action from '$lib/actions/update_task_result'; import { getVoteGradeStatisticsForTaskIds } from '$features/votes/services/vote_statistics'; +import { voteAbsoluteGrade as voteAbsoluteGradeAction } from '$features/votes/actions/vote_actions'; +import { voteAbsoluteGradeSchema } from '$features/votes/zod/schema'; import { isAdmin, canRead } from '$lib/utils/authorship'; import { getLoggedInUser } from '$features/auth/services/session'; @@ -44,6 +48,7 @@ export async function load({ locals, params, url }) { return { isLoggedIn: loggedInUser !== null, + isAtCoderVerified: locals.user?.is_validated === true, loggedInAsAdmin: loggedInAsAdmin, ...workbookWithAuthor, taskResults: taskResults, @@ -56,4 +61,11 @@ export const actions = { const operationLog = 'workbook -> actions -> update'; return await action.updateTaskResult({ request, locals }, operationLog); }, + voteAbsoluteGrade: async ({ request, locals }) => { + const form = await superValidate(request, zod4(voteAbsoluteGradeSchema)); + if (!form.valid) { + return fail(BAD_REQUEST, { form }); + } + return await voteAbsoluteGradeAction({ locals, data: form.data }); + }, } satisfies Actions; diff --git a/src/routes/workbooks/[slug]/+page.svelte b/src/routes/workbooks/[slug]/+page.svelte index 3cfbe519e..9863ee84d 100644 --- a/src/routes/workbooks/[slug]/+page.svelte +++ b/src/routes/workbooks/[slug]/+page.svelte @@ -14,18 +14,15 @@ import HeadingOne from '$lib/components/HeadingOne.svelte'; import UpdatingModal from '$lib/components/SubmissionStatus/UpdatingModal.svelte'; import SubmissionStatusImage from '$lib/components/SubmissionStatus/SubmissionStatusImage.svelte'; - import GradeLabel from '$lib/components/GradeLabel.svelte'; import ExternalLinkWrapper from '$lib/components/ExternalLinkWrapper.svelte'; + import VotableGrade from '$features/votes/components/VotableGrade.svelte'; import PublicationStatusLabel from '$features/workbooks/components/shared/PublicationStatusLabel.svelte'; import CommentAndHint from '$features/workbooks/components/detail/CommentAndHint.svelte'; - import RelativeEvaluationBadge from '$features/votes/components/RelativeEvaluationBadge.svelte'; - import { getBackgroundColorFrom } from '$lib/services/submission_status'; import { addContestNameToTaskIndex } from '$lib/utils/contest'; import { getTaskUrl, removeTaskIndexFromTitle } from '$lib/utils/task'; - import { TaskGrade } from '$lib/types/task'; import type { TaskResult } from '$lib/types/task'; import type { WorkBookTaskBase } from '$features/workbooks/types/workbook'; @@ -37,16 +34,13 @@ let voteStatisticsMap = $derived(data.voteStatisticsMap); let isLoggedIn = data.isLoggedIn; + let isAtCoderVerified = $derived(data.isAtCoderVerified); // TODO: 関数をutilへ移動させる const getTaskResult = (taskId: string): TaskResult => { return taskResults?.get(taskId) as TaskResult; }; - const getTaskGrade = (taskId: string): TaskGrade => { - return getTaskResult(taskId)?.grade ?? TaskGrade.PENDING; - }; - const getContestIdFrom = (taskId: string): string => { return getTaskResult(taskId)?.contest_id as string; }; @@ -159,8 +153,6 @@ {#each workBookTasks as workBookTask (workBookTask.taskId)} - {@const taskGrade = getTaskGrade(workBookTask.taskId)} - {@const statsEntry = voteStatisticsMap?.get(workBookTask.taskId)}
-
- - {#if taskGrade !== TaskGrade.PENDING && statsEntry} - - {/if} -
+