From a05606e6d110feae1174cce0b58c08181126eba1 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Mon, 25 May 2026 08:27:29 +0900 Subject: [PATCH 1/3] feat(workbooks): enable voting from grade icons on workbook detail page Replace GradeLabel with VotableGrade so users can vote directly from the grade icon on the workbook detail page. Fetches vote statistics in parallel with task results and adds the voteAbsoluteGrade form action. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/workbooks/[slug]/+page.server.ts | 5 ++++ src/routes/workbooks/[slug]/+page.svelte | 28 ++++++--------------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/routes/workbooks/[slug]/+page.server.ts b/src/routes/workbooks/[slug]/+page.server.ts index 599213f9b..226fcbd60 100644 --- a/src/routes/workbooks/[slug]/+page.server.ts +++ b/src/routes/workbooks/[slug]/+page.server.ts @@ -6,6 +6,7 @@ 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 { isAdmin, canRead } from '$lib/utils/authorship'; import { getLoggedInUser } from '$features/auth/services/session'; @@ -44,6 +45,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 +58,7 @@ export const actions = { const operationLog = 'workbook -> actions -> update'; return await action.updateTaskResult({ request, locals }, operationLog); }, + voteAbsoluteGrade: async ({ request, locals }) => { + return await voteAbsoluteGradeAction({ request, locals }); + }, } 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} -
+
From 5e050543efdffae6184c24f7e38b6cb9ac4bc589 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 27 May 2026 13:08:33 +0900 Subject: [PATCH 2/3] fix: fix coderabbit review --- prisma/seed.ts | 2 +- src/features/votes/actions/vote_actions.ts | 30 ++++--------------- src/features/votes/zod/schema.ts | 11 +++++++ .../stores/replenishment_workbook.test.ts | 4 +-- src/lib/types/auth_forms.ts | 6 ++-- src/lib/utils/auth_forms.ts | 5 +--- src/routes/problems/+page.server.ts | 12 ++++++-- src/routes/votes/[slug]/+page.server.ts | 12 ++++++-- src/routes/workbooks/[slug]/+page.server.ts | 11 +++++-- 9 files changed, 53 insertions(+), 40 deletions(-) create mode 100644 src/features/votes/zod/schema.ts 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..e836107a5 --- /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().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..ecccd1b0e 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 = { [key: string]: SchemaShape }; + /** * 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/lib/utils/auth_forms.ts b/src/lib/utils/auth_forms.ts index 3e387b3f4..22ab0f715 100644 --- a/src/lib/utils/auth_forms.ts +++ b/src/lib/utils/auth_forms.ts @@ -164,10 +164,7 @@ const createBaseAuthForm = () => ({ pattern: '(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\\d)[a-zA-Z\\d]{8,128}', }, }, - shape: { - username: { type: 'string' }, - password: { type: 'string' }, - }, + shape: {}, }); /** 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 226fcbd60..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'; @@ -7,6 +9,7 @@ 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'; @@ -59,6 +62,10 @@ export const actions = { return await action.updateTaskResult({ request, locals }, operationLog); }, voteAbsoluteGrade: async ({ request, locals }) => { - return await voteAbsoluteGradeAction({ 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; From b04ba39f2f9134660330d075005b6749f4ffeb1a Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 27 May 2026 23:38:10 +0900 Subject: [PATCH 3/3] fix: address additional coderabbit review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .trim() to taskId in voteAbsoluteGradeSchema to reject whitespace-only strings - Fix SchemaShape type from recursive self-reference to Record so leaf values like { type: 'string' } are representable - Restore shape fallback in createBaseAuthForm to { username: { type: 'string' }, password: { type: 'string' } } — was emptied when recursive type was introduced Co-Authored-By: Claude Sonnet 4.6 --- src/features/votes/zod/schema.ts | 2 +- src/lib/types/auth_forms.ts | 2 +- src/lib/utils/auth_forms.ts | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/features/votes/zod/schema.ts b/src/features/votes/zod/schema.ts index e836107a5..c800967a8 100644 --- a/src/features/votes/zod/schema.ts +++ b/src/features/votes/zod/schema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { TaskGrade } from '@prisma/client'; export const voteAbsoluteGradeSchema = z.object({ - taskId: z.string().min(1), + taskId: z.string().trim().min(1), grade: z .nativeEnum(TaskGrade) .refine((val) => val !== TaskGrade.PENDING, { message: 'Cannot vote for PENDING grade' }), diff --git a/src/lib/types/auth_forms.ts b/src/lib/types/auth_forms.ts index ecccd1b0e..7cecc61fc 100644 --- a/src/lib/types/auth_forms.ts +++ b/src/lib/types/auth_forms.ts @@ -19,7 +19,7 @@ export type AuthFormConstraints = { password?: FieldConstraints; }; -type SchemaShape = { [key: string]: SchemaShape }; +type SchemaShape = Record; /** * Represents the state and data structure for authentication forms. diff --git a/src/lib/utils/auth_forms.ts b/src/lib/utils/auth_forms.ts index 22ab0f715..3e387b3f4 100644 --- a/src/lib/utils/auth_forms.ts +++ b/src/lib/utils/auth_forms.ts @@ -164,7 +164,10 @@ const createBaseAuthForm = () => ({ pattern: '(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\\d)[a-zA-Z\\d]{8,128}', }, }, - shape: {}, + shape: { + username: { type: 'string' }, + password: { type: 'string' }, + }, }); /**