Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
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;
28 changes: 8 additions & 20 deletions src/routes/workbooks/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
};
Expand Down Expand Up @@ -159,25 +153,19 @@
</TableHead>
<TableBody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each workBookTasks as workBookTask (workBookTask.taskId)}
{@const taskGrade = getTaskGrade(workBookTask.taskId)}
{@const statsEntry = voteStatisticsMap?.get(workBookTask.taskId)}
<TableBodyRow
id={getUniqueIdUsing(workBookTask.taskId)}
class={getBackgroundColorFrom(getTaskResult(workBookTask.taskId).status_name)}
>
<!-- 問題のグレード -->
<TableBodyCell class="justify-center w-16 px-0.5 xs:px-3">
<div class="flex items-center justify-center min-w-[54px] max-w-[54px]">
<div class="relative">
<GradeLabel {taskGrade} />
{#if taskGrade !== TaskGrade.PENDING && statsEntry}
<RelativeEvaluationBadge
officialGrade={taskGrade}
medianGrade={statsEntry.grade}
badgeId="rel-eval-{getUniqueIdUsing(workBookTask.taskId)}"
/>
{/if}
</div>
<VotableGrade
taskResult={getTaskResult(workBookTask.taskId)}
{isLoggedIn}
{isAtCoderVerified}
estimatedGrade={voteStatisticsMap?.get(workBookTask.taskId)?.grade ?? null}
/>
</div>
</TableBodyCell>

Expand Down
Loading