Skip to content
2 changes: 1 addition & 1 deletion prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,15 @@
</div>

{#snippet taskGradeLabel(taskResult: TaskResult)}
<VotableGrade {taskResult} {isLoggedIn} {isAtCoderVerified} {estimatedGrade} />
<VotableGrade
{taskResult}
{isLoggedIn}
{isAtCoderVerified}
{estimatedGrade}
defaultPadding={0.25}
defaultWidth={6}
reducedWidth={6}
/>
{/snippet}

{#snippet taskTitleAndExternalLink(taskResult: TaskResult, isShownTaskIndex: boolean)}
Expand Down
30 changes: 5 additions & 25 deletions src/features/votes/actions/vote_actions.ts
Original file line number Diff line number Diff line change
@@ -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<string>([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) {
Expand All @@ -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);
Expand Down
15 changes: 13 additions & 2 deletions src/features/votes/components/VotableGrade.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,20 @@
// undefined means the prop was not passed — treat as verified to maintain backward compatibility.
isAtCoderVerified?: boolean;
estimatedGrade?: TaskGrade | null;
defaultPadding?: number;
defaultWidth?: number;
reducedWidth?: number;
}

let { taskResult, isLoggedIn, isAtCoderVerified, estimatedGrade }: Props = $props();
let {
taskResult,
isLoggedIn,
isAtCoderVerified,
estimatedGrade,
defaultPadding = 1,
defaultWidth = 10,
reducedWidth = 8,
}: Props = $props();

// 表示用のグレード(投票後に画面リロードなしで差し替えるためのローカル状態)
// PENDING かつ estimatedGrade(集計済み中央値)があればそれを優先表示。
Expand Down Expand Up @@ -185,7 +196,7 @@
: ''}{isProvisional ? ', provisional' : ''}
</span>

<GradeLabel taskGrade={displayGrade} defaultPadding={0.25} defaultWidth={6} reducedWidth={6} />
<GradeLabel taskGrade={displayGrade} {defaultPadding} {defaultWidth} {reducedWidth} />

{#if taskResult.grade !== TaskGrade.PENDING && latestMedianGrade}
<RelativeEvaluationBadge
Expand Down
80 changes: 80 additions & 0 deletions src/features/votes/zod/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, test } from 'vitest';
import { TaskGrade } from '@prisma/client';

import { voteAbsoluteGradeSchema } from '$features/votes/zod/schema';

describe('voteAbsoluteGradeSchema', () => {
function validate(data: unknown): boolean {
return voteAbsoluteGradeSchema.safeParse(data).success;
}

describe('valid inputs', () => {
test('ABC task with Q-grade', () => {
expect(validate({ taskId: 'abc408_a', grade: TaskGrade.Q7 })).toBe(true);
});

test('ARC task with D-grade', () => {
expect(validate({ taskId: 'arc188_c', grade: TaskGrade.D3 })).toBe(true);
});

test('ABC G problem with D6 grade', () => {
expect(validate({ taskId: 'abc399_g', grade: TaskGrade.D6 })).toBe(true);
});

test('AOJ 4-digit task ID', () => {
expect(validate({ taskId: '1001', grade: TaskGrade.Q5 })).toBe(true);
});

test('taskId with surrounding whitespace is trimmed', () => {
expect(validate({ taskId: ' abc408_a ', grade: TaskGrade.Q7 })).toBe(true);
});
});

describe('boundary values', () => {
test('taskId of exactly 1 character is accepted', () => {
expect(validate({ taskId: 'a', grade: TaskGrade.Q5 })).toBe(true);
});

test('Q11 (first valid grade) is accepted', () => {
expect(validate({ taskId: 'abc408_a', grade: TaskGrade.Q11 })).toBe(true);
});

test('D6 (last valid grade) is accepted', () => {
expect(validate({ taskId: 'abc399_g', grade: TaskGrade.D6 })).toBe(true);
});
});

describe('invalid inputs', () => {
test('empty taskId is rejected', () => {
expect(validate({ taskId: '', grade: TaskGrade.Q5 })).toBe(false);
});

test('whitespace-only taskId is rejected after trimming', () => {
expect(validate({ taskId: ' ', grade: TaskGrade.Q5 })).toBe(false);
});

test('PENDING grade is rejected', () => {
expect(validate({ taskId: 'abc408_a', grade: TaskGrade.PENDING })).toBe(false);
});

test('unknown grade string is rejected', () => {
expect(validate({ taskId: 'abc408_a', grade: 'INVALID' })).toBe(false);
});

test('missing grade is rejected', () => {
expect(validate({ taskId: 'abc408_a' })).toBe(false);
});

test('missing taskId is rejected', () => {
expect(validate({ grade: TaskGrade.Q5 })).toBe(false);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('null taskId is rejected', () => {
expect(validate({ taskId: null, grade: TaskGrade.Q5 })).toBe(false);
});

test('null grade is rejected', () => {
expect(validate({ taskId: 'abc408_a', grade: null })).toBe(false);
});
});
});
11 changes: 11 additions & 0 deletions src/features/votes/zod/schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof voteAbsoluteGradeSchema>;
4 changes: 2 additions & 2 deletions src/features/workbooks/stores/replenishment_workbook.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();

Expand Down
6 changes: 4 additions & 2 deletions src/lib/types/auth_forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export type AuthFormConstraints = {
password?: FieldConstraints;
};

type SchemaShape = Record<string, unknown>;

/**
* Represents the state and data structure for authentication forms.
*
Expand All @@ -31,7 +33,7 @@ export type AuthFormConstraints = {
* @property {string} data.password - The password field value
* @property {Record<string, string[]>} errors - Collection of validation errors keyed by field name
* @property {AuthFormConstraints} [constraints] - Optional validation constraints for the form
* @property {Record<string, unknown>} [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 = {
Expand All @@ -41,7 +43,7 @@ export type AuthForm = {
data: { username: string; password: string };
errors: Record<string, string[]>;
constraints?: AuthFormConstraints;
shape?: Record<string, unknown>;
shape?: SchemaShape;
message: string;
};

Expand Down
12 changes: 10 additions & 2 deletions src/routes/problems/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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';
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 }) {
Expand Down Expand Up @@ -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;
12 changes: 10 additions & 2 deletions src/routes/votes/[slug]/+page.server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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 });
},
};
14 changes: 13 additions & 1 deletion src/routes/workbooks/[slug]/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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';

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';
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} satisfies Actions;
Loading
Loading