diff --git a/.claude/rules/sveltekit.md b/.claude/rules/sveltekit.md index 8b086db7b..d7f8bf00b 100644 --- a/.claude/rules/sveltekit.md +++ b/.claude/rules/sveltekit.md @@ -44,6 +44,35 @@ goto(resolve(url.pathname + url.search)); replaceState(resolve(url.pathname + url.search + url.hash), state); ``` +## Server-side Form Data Validation + +`formData.get()` returns `string | File | null`. Never cast directly with `as string` or `as TaskGrade` — always validate first: + +```typescript +// Bad — unsafe cast, null reaches the DB layer +const taskId = data.get('taskId') as string; +const grade = data.get('grade') as TaskGrade; + +// Good — validate before use +const taskId = data.get('taskId'); +const grade = data.get('grade'); +if (typeof taskId !== 'string' || !taskId || typeof grade !== 'string') { + return { success: false }; +} +// taskId and grade are now string, safe to pass onward +``` + +For enum fields, add a membership check after the type guard: + +```typescript +if (!(Object.values(TaskGrade) as string[]).includes(gradeRaw)) { + return fail(BAD_REQUEST, { message: 'Invalid grade value.' }); +} +const grade = gradeRaw as TaskGrade; +``` + +The same pattern applies to `url.searchParams.get()` in `+server.ts` handlers. + ## Page Component Props SvelteKit page components (`+page.svelte`) accept only `data` and `form` as props (`svelte/valid-prop-names-in-kit-pages`). Commented-out features that reference other props are not "dead code" — remove only the violating prop declaration, preserve the feature code. diff --git a/.gitignore b/.gitignore index dbe68c82b..3b24d23ad 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,3 @@ prisma/.fabbrica # Directory for playwright test results test-results - diff --git a/e2e/votes.spec.ts b/e2e/votes.spec.ts new file mode 100644 index 000000000..a00298a0d --- /dev/null +++ b/e2e/votes.spec.ts @@ -0,0 +1,178 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { loginAsAdmin, loginAsUser } from './helpers/auth'; + +const TIMEOUT = 60 * 1000; +const VOTES_LIST_URL = '/votes'; +const VOTE_MANAGEMENT_URL = '/vote_management'; + +// --------------------------------------------------------------------------- +// Votes list page (/votes) +// --------------------------------------------------------------------------- + +test.describe('votes list page (/votes)', () => { + test('unauthenticated user can view the page without redirect', async ({ page }) => { + await page.goto(VOTES_LIST_URL); + await expect(page).toHaveURL(VOTES_LIST_URL, { timeout: TIMEOUT }); + await expect(page.getByRole('heading', { name: 'グレード投票' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('task table is visible to unauthenticated user', async ({ page }) => { + await page.goto(VOTES_LIST_URL); + await expect(page.getByRole('columnheader', { name: '問題' })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('columnheader', { name: 'コンテスト' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('logged-in user can view the page', async ({ page }) => { + await loginAsUser(page); + await page.goto(VOTES_LIST_URL); + await expect(page).toHaveURL(VOTES_LIST_URL, { timeout: TIMEOUT }); + await expect(page.getByRole('heading', { name: 'グレード投票' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('search input filters tasks by title', async ({ page }) => { + await page.goto(VOTES_LIST_URL); + const searchInput = page.getByPlaceholder('問題名・問題ID・コンテストIDで検索'); + await expect(searchInput).toBeVisible({ timeout: TIMEOUT }); + + // Type a string unlikely to match any task to get 0 results + await searchInput.fill('__no_match_expected__'); + await expect(page.getByText('該当する問題が見つかりませんでした')).toBeVisible({ + timeout: TIMEOUT, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Vote detail page (/votes/[slug]) +// --------------------------------------------------------------------------- + +test.describe('vote detail page (/votes/[slug])', () => { + /** + * Navigates to the first task in the vote list. + * Assumes at least one task exists in the DB. + */ + async function navigateToFirstVoteDetailPage(page: Page): Promise { + await page.goto(VOTES_LIST_URL); + await expect(page.getByRole('columnheader', { name: '問題' })).toBeVisible({ + timeout: TIMEOUT, + }); + // Click the first task title link in the table + await page.locator('table').getByRole('link').first().click(); + await expect(page).toHaveURL(/\/votes\/.+/, { timeout: TIMEOUT }); + } + + test.describe('unauthenticated user', () => { + test('can view the task detail page without redirect', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + // Should stay on /votes/[slug], not redirected + await expect(page).toHaveURL(/\/votes\/.+/, { timeout: TIMEOUT }); + }); + + test('sees login prompt instead of vote buttons', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + await expect(page.getByText('投票するにはログインが必要です')).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('link', { name: 'ログイン' })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('link', { name: 'アカウント作成' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('does not see vote grade buttons', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + // Grade buttons are only rendered inside the vote form (not shown when logged out) + await expect(page.locator('form[action="?/voteAbsoluteGrade"]')).not.toBeAttached(); + }); + + test('breadcrumb link navigates back to /votes', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + await page.getByRole('link', { name: 'グレード投票' }).click(); + await expect(page).toHaveURL(VOTES_LIST_URL, { timeout: TIMEOUT }); + }); + }); + + test.describe('logged-in user', () => { + test.beforeEach(async ({ page }) => { + await loginAsUser(page); + }); + + test('can view the task detail page', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + await expect(page).toHaveURL(/\/votes\/.+/, { timeout: TIMEOUT }); + }); + + test('sees vote grade buttons', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + // Vote form with grade buttons is rendered for logged-in users + await expect(page.locator('form[action="?/voteAbsoluteGrade"]')).toBeVisible({ + timeout: TIMEOUT, + }); + // The grade buttons should include Q11 (11Q) + await expect(page.getByRole('button', { name: '11Q' })).toBeVisible({ timeout: TIMEOUT }); + }); + + test('does not see login prompt', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + await expect(page.getByText('投票するにはログインが必要です')).not.toBeVisible(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Vote management page (/vote_management) — admin only +// --------------------------------------------------------------------------- + +test.describe('vote management page (/vote_management)', () => { + test('unauthenticated user is redirected to /login', async ({ page }) => { + await page.goto(VOTE_MANAGEMENT_URL); + await expect(page).toHaveURL('/login', { timeout: TIMEOUT }); + }); + + test('non-admin user is redirected to /login', async ({ page }) => { + await loginAsUser(page); + await page.goto(VOTE_MANAGEMENT_URL); + await expect(page).toHaveURL('/login', { timeout: TIMEOUT }); + }); + + test.describe('admin user', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('can access the page', async ({ page }) => { + await page.goto(VOTE_MANAGEMENT_URL); + await expect(page).toHaveURL(VOTE_MANAGEMENT_URL, { timeout: TIMEOUT }); + await expect(page.getByRole('heading', { name: '投票管理' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('sees the vote management table with expected columns', async ({ page }) => { + await page.goto(VOTE_MANAGEMENT_URL); + await expect(page.getByRole('columnheader', { name: '問題' })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('columnheader', { name: 'DBグレード' })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('columnheader', { name: '中央値グレード' })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('columnheader', { name: '票数' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + }); +}); diff --git a/prisma/ERD.md b/prisma/ERD.md index d60e4edc5..6aae9fc99 100644 --- a/prisma/ERD.md +++ b/prisma/ERD.md @@ -237,6 +237,36 @@ ANALYSIS ANALYSIS DateTime updatedAt } + + "votegrade" { + String id + String userId "🗝️" + String taskId "🗝️" + TaskGrade grade + DateTime createdAt + DateTime updatedAt + } + + + "votedgradecounter" { + String id + String taskId "🗝️" + TaskGrade grade "🗝️" + Int count + DateTime createdAt + DateTime updatedAt + } + + + "votedgradestatistics" { + String id "🗝️" + String taskId + TaskGrade grade + Boolean isExperimental + DateTime createdAt + DateTime updatedAt + } + "user" |o--|| "Roles" : "enum:role" "session" }o--|| user : "user" "key" }o--|| user : "user" @@ -255,4 +285,11 @@ ANALYSIS ANALYSIS "workbookplacement" |o--|| workbook : "workBook" "workbooktask" }o--|| workbook : "workBook" "workbooktask" }o--|| task : "task" + "votegrade" |o--|| "TaskGrade" : "enum:grade" + "votegrade" }o--|| user : "user" + "votegrade" }o--|| task : "task" + "votedgradecounter" |o--|| "TaskGrade" : "enum:grade" + "votedgradecounter" }o--|| task : "task" + "votedgradestatistics" |o--|| "TaskGrade" : "enum:grade" + "votedgradestatistics" |o--|| task : "task" ``` diff --git a/prisma/migrations/20260315003958_add_vote_table/migration.sql b/prisma/migrations/20260315003958_add_vote_table/migration.sql new file mode 100644 index 000000000..f4ee8829b --- /dev/null +++ b/prisma/migrations/20260315003958_add_vote_table/migration.sql @@ -0,0 +1,45 @@ +-- CreateTable +CREATE TABLE "votegrade" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "grade" "TaskGrade" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "votegrade_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "votedgradecounter" ( + "id" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "grade" "TaskGrade" NOT NULL, + "count" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "votedgradecounter_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "votedgradestatistics" ( + "id" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "grade" "TaskGrade" NOT NULL, + "isExperimental" BOOLEAN NOT NULL DEFAULT false, + "isApproved" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "votedgradestatistics_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "votegrade_userId_taskId_key" ON "votegrade"("userId", "taskId"); + +-- AddForeignKey +ALTER TABLE "votegrade" ADD CONSTRAINT "votegrade_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "votegrade" ADD CONSTRAINT "votegrade_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "task"("task_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260320143157_change_vote_grade_table/migration.sql b/prisma/migrations/20260320143157_change_vote_grade_table/migration.sql new file mode 100644 index 000000000..9e8776c49 --- /dev/null +++ b/prisma/migrations/20260320143157_change_vote_grade_table/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - The primary key for the `votegrade` table will be changed. If it partially fails, the table could be left without primary key constraint. + - A unique constraint covering the columns `[id]` on the table `votegrade` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "votegrade" DROP CONSTRAINT "votegrade_pkey", +ADD CONSTRAINT "votegrade_pkey" PRIMARY KEY ("userId", "taskId"); + +-- CreateIndex +CREATE UNIQUE INDEX "votegrade_id_key" ON "votegrade"("id"); diff --git a/prisma/migrations/20260321164035_change_voted_grade_counter/migration.sql b/prisma/migrations/20260321164035_change_voted_grade_counter/migration.sql new file mode 100644 index 000000000..28895b8fc --- /dev/null +++ b/prisma/migrations/20260321164035_change_voted_grade_counter/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - The primary key for the `votedgradecounter` table will be changed. If it partially fails, the table could be left without primary key constraint. + - A unique constraint covering the columns `[id]` on the table `votedgradecounter` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[taskId,grade]` on the table `votedgradecounter` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "votedgradecounter" DROP CONSTRAINT "votedgradecounter_pkey", +ADD CONSTRAINT "votedgradecounter_pkey" PRIMARY KEY ("taskId", "grade"); + +-- CreateIndex +CREATE UNIQUE INDEX "votedgradecounter_id_key" ON "votedgradecounter"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "votedgradecounter_taskId_grade_key" ON "votedgradecounter"("taskId", "grade"); diff --git a/prisma/migrations/20260322043315_change_some_vote_tables_decorator/migration.sql b/prisma/migrations/20260322043315_change_some_vote_tables_decorator/migration.sql new file mode 100644 index 000000000..39b32642a --- /dev/null +++ b/prisma/migrations/20260322043315_change_some_vote_tables_decorator/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[taskId]` on the table `votedgradestatistics` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "votedgradecounter_taskId_grade_key"; + +-- DropIndex +DROP INDEX "votegrade_userId_taskId_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "votedgradestatistics_taskId_key" ON "votedgradestatistics"("taskId"); diff --git a/prisma/migrations/20260325000000_remove_is_approved_from_vote_statistics/migration.sql b/prisma/migrations/20260325000000_remove_is_approved_from_vote_statistics/migration.sql new file mode 100644 index 000000000..b41b9332d --- /dev/null +++ b/prisma/migrations/20260325000000_remove_is_approved_from_vote_statistics/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `isApproved` on the `votedgradestatistics` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "votedgradestatistics" DROP COLUMN "isApproved"; diff --git a/prisma/migrations/20260326140100_add_task_fk_cascade_to_vote_tables/migration.sql b/prisma/migrations/20260326140100_add_task_fk_cascade_to_vote_tables/migration.sql new file mode 100644 index 000000000..ea41b622e --- /dev/null +++ b/prisma/migrations/20260326140100_add_task_fk_cascade_to_vote_tables/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "votegrade" DROP CONSTRAINT "votegrade_taskId_fkey"; + +-- AddForeignKey +ALTER TABLE "votegrade" ADD CONSTRAINT "votegrade_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "task"("task_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "votedgradecounter" ADD CONSTRAINT "votedgradecounter_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "task"("task_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "votedgradestatistics" ADD CONSTRAINT "votedgradestatistics_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "task"("task_id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a0edd4aee..3ce3b9eb9 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -55,6 +55,7 @@ model User { key Key[] taskAnswer TaskAnswer[] workBooks WorkBook[] + voteGrade VoteGrade[] @@map("user") } @@ -101,9 +102,12 @@ model Task { created_at DateTime @default(now()) updated_at DateTime @updatedAt - tags TaskTag[] - task_answers TaskAnswer[] - workBookTasks WorkBookTask[] + tags TaskTag[] + task_answers TaskAnswer[] + workBookTasks WorkBookTask[] + voteGrade VoteGrade[] + votedGradeCounters VotedGradeCounter[] + votedGradeStatistics VotedGradeStatistics? @@map("task") } @@ -236,6 +240,48 @@ model WorkBookTask { @@map("workbooktask") } +model VoteGrade { + id String @unique + userId String + taskId String + grade TaskGrade + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(references: [id], fields: [userId]) + task Task @relation(references: [task_id], fields: [taskId], onDelete: Cascade) + + @@id([userId, taskId]) + @@map("votegrade") +} + +model VotedGradeCounter { + id String @unique + taskId String + grade TaskGrade + count Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + task Task @relation(references: [task_id], fields: [taskId], onDelete: Cascade) + + @@id([taskId, grade]) + @@map("votedgradecounter") +} + +model VotedGradeStatistics { + id String @id + taskId String @unique + grade TaskGrade + isExperimental Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + task Task @relation(references: [task_id], fields: [taskId], onDelete: Cascade) + + @@map("votedgradestatistics") +} + enum ContestType { ABC // AtCoder Beginner Contest APG4B // C++入門 AtCoder Programming Guide for beginners diff --git a/src/features/tasks/components/contest-table/TaskTable.svelte b/src/features/tasks/components/contest-table/TaskTable.svelte index 037161d4d..1276e11fd 100644 --- a/src/features/tasks/components/contest-table/TaskTable.svelte +++ b/src/features/tasks/components/contest-table/TaskTable.svelte @@ -19,6 +19,7 @@ ContestTableMetaData, } from '$features/tasks/types/contest-table/contest_table_provider'; import type { ContestTaskPairKey } from '$lib/types/contest_task_pair'; + import type { VoteStatisticsMap } from '$features/votes/types/vote_result'; import TaskTableBodyCell from './TaskTableBodyCell.svelte'; @@ -36,9 +37,10 @@ interface Props { taskResults: TaskResults; isLoggedIn: boolean; + voteResults: VoteStatisticsMap; } - let { taskResults, isLoggedIn }: Props = $props(); + let { taskResults, isLoggedIn, voteResults }: Props = $props(); // Prepare contest table provider based on the active contest type. let activeContestType: ContestTableProviderGroups = $derived(activeContestTypeStore.get()); @@ -281,6 +283,7 @@ handleUpdateTaskResult(updatedTask)} /> diff --git a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte index 662f74948..684ad5fc7 100644 --- a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte +++ b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte @@ -1,7 +1,8 @@
{#snippet taskGradeLabel(taskResult: TaskResult)} -
- -
+ {/snippet} {#snippet taskTitleAndExternalLink(taskResult: TaskResult, isShownTaskIndex: boolean)} diff --git a/src/features/votes/actions/vote_actions.ts b/src/features/votes/actions/vote_actions.ts new file mode 100644 index 000000000..af9e206e0 --- /dev/null +++ b/src/features/votes/actions/vote_actions.ts @@ -0,0 +1,53 @@ +import { fail } from '@sveltejs/kit'; +import { TaskGrade } from '@prisma/client'; + +import { upsertVoteGradeTables } from '$features/votes/services/vote_grade'; +import { + BAD_REQUEST, + 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]); + +export const voteAbsoluteGrade = async ({ + request, + locals, +}: { + request: Request; + locals: App.Locals; +}) => { + const formData = await request.formData(); + const session = await locals.auth.validate(); + + if (!session || !session.user || !session.user.userId) { + return fail(UNAUTHORIZED, { + message: 'ログインしていないか、もしくは、ログイン情報が不正です。', + }); + } + + 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); + } catch (error) { + console.error('Failed to vote absolute grade: ', error); + return fail(INTERNAL_SERVER_ERROR, { message: 'Failed to record vote.' }); + } +}; diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte new file mode 100644 index 000000000..4f8757e6d --- /dev/null +++ b/src/features/votes/components/VotableGrade.svelte @@ -0,0 +1,213 @@ + + + + + + +{#if isLoggedIn} + + {#each nonPendingGrades as grade (grade)} + handleClick(grade)} class="rounded-md"> +
+ {getTaskGradeLabel(grade)} + {#if votedGrade === grade} + + {/if} +
+
+ {/each} + + 詳細 +
+{:else} + + アカウント作成 + + ログイン + +{/if} + +{#if showForm && selectedVoteGrade} + {@render voteGradeForm(taskResult, selectedVoteGrade)} +{/if} + +{#snippet voteGradeForm(selectedTaskResult: TaskResult, voteGrade: TaskGrade)} +
+ + + + + + + + +{/snippet} diff --git a/src/features/votes/services/vote_grade.test.ts b/src/features/votes/services/vote_grade.test.ts new file mode 100644 index 000000000..23eb3f5dc --- /dev/null +++ b/src/features/votes/services/vote_grade.test.ts @@ -0,0 +1,196 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { TaskGrade } from '@prisma/client'; + +import { getVoteGrade, upsertVoteGradeTables } from './vote_grade'; + +vi.mock('$lib/server/database', () => ({ + default: { + voteGrade: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + votedGradeCounter: { + findMany: vi.fn(), + update: vi.fn(), + upsert: vi.fn(), + }, + votedGradeStatistics: { + upsert: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +import prisma from '$lib/server/database'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Type aliases +// --------------------------------------------------------------------------- + +type PrismaVoteGrade = Awaited>; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +function mockVoteGradeFindUnique(value: PrismaVoteGrade) { + vi.mocked(prisma.voteGrade.findUnique).mockResolvedValue(value); +} + +/** Creates a mock transaction client and wires prisma.$transaction to execute the callback with it. */ +function setupTransaction() { + const mockTx = { + voteGrade: { findUnique: vi.fn(), upsert: vi.fn() }, + votedGradeCounter: { updateMany: vi.fn(), upsert: vi.fn(), findMany: vi.fn() }, + votedGradeStatistics: { upsert: vi.fn() }, + }; + vi.mocked(prisma.$transaction).mockImplementation(async (callback: unknown) => + (callback as (tx: typeof mockTx) => Promise)(mockTx), + ); + return mockTx; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('getVoteGrade', () => { + test('returns voted: false and grade: null when no vote record exists', async () => { + mockVoteGradeFindUnique(null); + + const result = await getVoteGrade('user-1', 'abc001_a'); + + expect(result).toEqual({ voted: false, grade: null }); + }); + + test('returns voted: true and the stored grade when a vote record exists', async () => { + mockVoteGradeFindUnique({ + id: 'vote-1', + userId: 'user-1', + taskId: 'abc001_a', + grade: TaskGrade.Q5, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await getVoteGrade('user-1', 'abc001_a'); + + expect(result).toEqual({ voted: true, grade: TaskGrade.Q5 }); + }); + + test('queries with the correct userId and taskId', async () => { + mockVoteGradeFindUnique(null); + + await getVoteGrade('user-42', 'abc123_d'); + + expect(prisma.voteGrade.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_taskId: { userId: 'user-42', taskId: 'abc123_d' } }, + }), + ); + }); +}); + +describe('upsertVoteGradeTables', () => { + test('exits early without any writes when vote grade is unchanged (idempotency)', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue({ grade: TaskGrade.Q5 }); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(tx.votedGradeCounter.updateMany).not.toHaveBeenCalled(); + expect(tx.voteGrade.upsert).not.toHaveBeenCalled(); + expect(tx.votedGradeCounter.upsert).not.toHaveBeenCalled(); + }); + + test('decrements the old grade counter when the user changes their vote', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue({ grade: TaskGrade.Q4 }); + tx.voteGrade.upsert.mockResolvedValue({}); + tx.votedGradeCounter.upsert.mockResolvedValue({}); + tx.votedGradeCounter.findMany.mockResolvedValue([ + { grade: TaskGrade.Q5, count: 1 }, + { grade: TaskGrade.Q4, count: 0 }, + ]); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + // Q4 (old grade) should be decremented + expect(tx.votedGradeCounter.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { taskId: 'abc001_a', grade: TaskGrade.Q4, count: { gt: 0 } }, + data: { count: { decrement: 1 } }, + }), + ); + // Q5 (new grade) should be incremented — confirms post-decrement state: Q5=1, Q4=0 + expect(tx.votedGradeCounter.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { taskId_grade: { taskId: 'abc001_a', grade: TaskGrade.Q5 } }, + update: { count: { increment: 1 } }, + }), + ); + // Total = 1 vote (Q5:1 + Q4:0), below MIN_VOTES_FOR_STATISTICS — statistics must not be updated + expect(tx.votedGradeStatistics.upsert).not.toHaveBeenCalled(); + }); + + test('upserts VoteGrade and increments counter for the new grade', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue(null); + tx.voteGrade.upsert.mockResolvedValue({}); + tx.votedGradeCounter.upsert.mockResolvedValue({}); + tx.votedGradeCounter.findMany.mockResolvedValue([{ grade: TaskGrade.Q5, count: 1 }]); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(tx.voteGrade.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_taskId: { userId: 'user-1', taskId: 'abc001_a' } }, + update: { grade: TaskGrade.Q5 }, + }), + ); + expect(tx.votedGradeCounter.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { taskId_grade: { taskId: 'abc001_a', grade: TaskGrade.Q5 } }, + update: { count: { increment: 1 } }, + }), + ); + // Total = 1 vote (Q5:1), below MIN_VOTES_FOR_STATISTICS — statistics must not be updated + expect(tx.votedGradeStatistics.upsert).not.toHaveBeenCalled(); + }); + + test('upserts VotedGradeStatistics when total votes reaches 3', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue(null); + tx.voteGrade.upsert.mockResolvedValue({}); + tx.votedGradeCounter.upsert.mockResolvedValue({}); + // 3 votes all on Q5 → median = Q5 + tx.votedGradeCounter.findMany.mockResolvedValue([{ grade: TaskGrade.Q5, count: 3 }]); + tx.votedGradeStatistics.upsert.mockResolvedValue({}); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(tx.votedGradeStatistics.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { taskId: 'abc001_a' }, + update: { grade: TaskGrade.Q5 }, + }), + ); + }); + + test('does not upsert VotedGradeStatistics when total votes is below 3', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue(null); + tx.voteGrade.upsert.mockResolvedValue({}); + tx.votedGradeCounter.upsert.mockResolvedValue({}); + tx.votedGradeCounter.findMany.mockResolvedValue([{ grade: TaskGrade.Q5, count: 2 }]); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(tx.votedGradeStatistics.upsert).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/votes/services/vote_grade.ts b/src/features/votes/services/vote_grade.ts new file mode 100644 index 000000000..0e1f7ea1c --- /dev/null +++ b/src/features/votes/services/vote_grade.ts @@ -0,0 +1,142 @@ +import { default as prisma } from '$lib/server/database'; +import { TaskGrade } from '@prisma/client'; +import { sha256 } from '$lib/utils/hash'; +import type { VoteGradeResult } from '$features/votes/types/vote_result'; +import { computeMedianGrade } from '$features/votes/utils/median'; + +export async function getVoteGrade(userId: string, taskId: string): Promise { + const voteRecord = await prisma.voteGrade.findUnique({ + where: { + userId_taskId: { userId: userId, taskId: taskId }, + }, + }); + let voted = false; + let grade = null; + if (voteRecord !== null) { + voted = true; + grade = voteRecord.grade; + } + return { + voted: voted, + grade: grade, + }; +} + +// Minimum number of votes required to compute and store the median grade. +// Below this threshold the distribution is too sparse to be meaningful. +const MIN_VOTES_FOR_STATISTICS = 3; + +// 概念実装(読み込み→処理を同一トランザクション内で行う) +export async function upsertVoteGradeTables( + userId: string, + taskId: string, + grade: TaskGrade, +): Promise { + await prisma.$transaction(async (tx) => { + const existing = await tx.voteGrade.findUnique({ + where: { userId_taskId: { userId, taskId } }, + }); + + // 冪等性: 既に同じグレードなら何もしない + if (existing?.grade === grade) { + return; + } + + if (existing) { + await decrementOldGradeCounter(tx, taskId, existing.grade); + } + await upsertVoteRecord(tx, userId, taskId, grade); + await incrementNewGradeCounter(tx, taskId, grade); + + const latestCounters = await tx.votedGradeCounter.findMany({ + where: { taskId }, + orderBy: { grade: 'asc' }, + }); + + const total = latestCounters.reduce((sum, counter) => sum + counter.count, 0); + if (total < MIN_VOTES_FOR_STATISTICS) { + return; + } + + await updateVoteStatistics(tx, taskId, latestCounters); + }); +} + +// --------------------------------------------------------------------------- +// Transaction helpers +// --------------------------------------------------------------------------- + +type TxClient = Parameters[0]>[0]; +type GradeCounter = { grade: TaskGrade; count: number }; + +async function decrementOldGradeCounter( + tx: TxClient, + taskId: string, + oldGrade: TaskGrade, +): Promise { + // updateMany does not throw if the row is missing (data inconsistency guard). + // count is also guarded to never go below zero. + await tx.votedGradeCounter.updateMany({ + where: { taskId, grade: oldGrade, count: { gt: 0 } }, + data: { count: { decrement: 1 } }, + }); +} + +async function upsertVoteRecord( + tx: TxClient, + userId: string, + taskId: string, + grade: TaskGrade, +): Promise { + const voteId = await sha256(taskId + userId); + await tx.voteGrade.upsert({ + where: { userId_taskId: { userId, taskId } }, + update: { grade }, + create: { id: voteId, userId, taskId, grade, createdAt: new Date(), updatedAt: new Date() }, + }); +} + +async function incrementNewGradeCounter( + tx: TxClient, + taskId: string, + grade: TaskGrade, +): Promise { + const counterId = await sha256(taskId + grade); + await tx.votedGradeCounter.upsert({ + where: { taskId_grade: { taskId, grade } }, + update: { count: { increment: 1 } }, + create: { + id: counterId, + taskId, + grade, + count: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +async function updateVoteStatistics( + tx: TxClient, + taskId: string, + counters: GradeCounter[], +): Promise { + const medianGrade = computeMedianGrade(counters); + if (medianGrade === null) { + return; + } + + const statsId = await sha256(taskId + 'stats'); + await tx.votedGradeStatistics.upsert({ + where: { taskId }, + update: { grade: medianGrade }, + create: { + id: statsId, + taskId, + grade: medianGrade, + isExperimental: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} diff --git a/src/features/votes/services/vote_statistics.test.ts b/src/features/votes/services/vote_statistics.test.ts new file mode 100644 index 000000000..6a9fd1fce --- /dev/null +++ b/src/features/votes/services/vote_statistics.test.ts @@ -0,0 +1,249 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { TaskGrade } from '@prisma/client'; + +import { + getVoteGradeStatistics, + getAllTasksWithVoteInfo, + getVoteCountersByTaskId, + getVoteStatsByTaskId, + getAllVoteStatisticsAsArray, + getAllVoteCounters, +} from './vote_statistics'; + +vi.mock('$lib/server/database', () => ({ + default: { + voteGrade: { + findUnique: vi.fn(), + }, + votedGradeCounter: { + findMany: vi.fn(), + }, + votedGradeStatistics: { + findMany: vi.fn(), + findFirst: vi.fn(), + }, + task: { + findMany: vi.fn(), + }, + }, +})); + +import prisma from '$lib/server/database'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Type aliases +// --------------------------------------------------------------------------- + +type PrismaVotedGradeStatistics = Awaited< + ReturnType +>[number]; +type PrismaVotedGradeCounter = Awaited< + ReturnType +>[number]; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +function mockVotedGradeStatisticsFindMany(value: PrismaVotedGradeStatistics[]) { + vi.mocked(prisma.votedGradeStatistics.findMany).mockResolvedValue( + value as unknown as Awaited>, + ); +} + +function mockVotedGradeCounterFindMany(value: PrismaVotedGradeCounter[]) { + vi.mocked(prisma.votedGradeCounter.findMany).mockResolvedValue( + value as unknown as Awaited>, + ); +} + +function mockTaskFindMany(value: object[]) { + vi.mocked(prisma.task.findMany).mockResolvedValue( + value as unknown as Awaited>, + ); +} + +function makeStatisticsRecord( + overrides: Partial = {}, +): PrismaVotedGradeStatistics { + return { + id: 'stats-abc001_a', + taskId: 'abc001_a', + grade: TaskGrade.Q5, + isExperimental: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as unknown as PrismaVotedGradeStatistics; +} + +function makeCounterRecord( + overrides: Partial = {}, +): PrismaVotedGradeCounter { + return { + id: 'counter-abc001_a-Q5', + taskId: 'abc001_a', + grade: TaskGrade.Q5, + count: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as unknown as PrismaVotedGradeCounter; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('getVoteGradeStatistics', () => { + test('returns an empty Map when no statistics exist', async () => { + mockVotedGradeStatisticsFindMany([]); + + const result = await getVoteGradeStatistics(); + + expect(result.size).toBe(0); + }); + + test('maps each taskId to its statistics record', async () => { + const stat = makeStatisticsRecord({ taskId: 'abc001_a', grade: TaskGrade.Q5 }); + mockVotedGradeStatisticsFindMany([stat]); + + const result = await getVoteGradeStatistics(); + + expect(result.get('abc001_a')?.grade).toBe(TaskGrade.Q5); + }); + + test('builds a Map entry per statistics record', async () => { + const records = [ + makeStatisticsRecord({ taskId: 'abc001_a', grade: TaskGrade.Q5 }), + makeStatisticsRecord({ id: 'stats-abc002_a', taskId: 'abc002_a', grade: TaskGrade.D1 }), + ]; + mockVotedGradeStatisticsFindMany(records); + + const result = await getVoteGradeStatistics(); + + expect(result.size).toBe(2); + expect(result.get('abc002_a')?.grade).toBe(TaskGrade.D1); + }); +}); + +describe('getAllTasksWithVoteInfo', () => { + test('attaches estimatedGrade from statistics when available', async () => { + mockTaskFindMany([ + { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + ]); + mockVotedGradeStatisticsFindMany([ + makeStatisticsRecord({ taskId: 'abc001_a', grade: TaskGrade.Q5 }), + ]); + mockVotedGradeCounterFindMany([]); + + const result = await getAllTasksWithVoteInfo(); + + expect(result[0].estimatedGrade).toBe(TaskGrade.Q5); + }); + + test('returns null estimatedGrade when no statistics exist for the task', async () => { + mockTaskFindMany([ + { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + ]); + mockVotedGradeStatisticsFindMany([]); + mockVotedGradeCounterFindMany([]); + + const result = await getAllTasksWithVoteInfo(); + + expect(result[0].estimatedGrade).toBeNull(); + }); + + test('aggregates voteTotal across all grade counters for the task', async () => { + mockTaskFindMany([ + { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + ]); + mockVotedGradeStatisticsFindMany([]); + mockVotedGradeCounterFindMany([ + makeCounterRecord({ taskId: 'abc001_a', grade: TaskGrade.Q5, count: 2 }), + makeCounterRecord({ id: 'c2', taskId: 'abc001_a', grade: TaskGrade.Q4, count: 3 }), + ]); + + const result = await getAllTasksWithVoteInfo(); + + expect(result[0].voteTotal).toBe(5); + }); + + test('returns 0 voteTotal when no counters exist for the task', async () => { + mockTaskFindMany([ + { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + ]); + mockVotedGradeStatisticsFindMany([]); + mockVotedGradeCounterFindMany([]); + + const result = await getAllTasksWithVoteInfo(); + + expect(result[0].voteTotal).toBe(0); + }); +}); + +describe('getVoteCountersByTaskId', () => { + test('queries with the correct taskId filter and grade ascending order', async () => { + mockVotedGradeCounterFindMany([]); + + await getVoteCountersByTaskId('abc001_a'); + + expect(prisma.votedGradeCounter.findMany).toHaveBeenCalledWith({ + where: { taskId: 'abc001_a' }, + orderBy: { grade: 'asc' }, + }); + }); +}); + +describe('getVoteStatsByTaskId', () => { + test('queries with the correct taskId filter', async () => { + vi.mocked(prisma.votedGradeStatistics.findFirst).mockResolvedValue( + makeStatisticsRecord() as unknown as Awaited< + ReturnType + >, + ); + + await getVoteStatsByTaskId('abc001_a'); + + expect(prisma.votedGradeStatistics.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { taskId: 'abc001_a' } }), + ); + }); + + test('returns null when no statistics exist for the task', async () => { + vi.mocked(prisma.votedGradeStatistics.findFirst).mockResolvedValue(null); + + const result = await getVoteStatsByTaskId('abc001_a'); + + expect(result).toBeNull(); + }); +}); + +describe('getAllVoteStatisticsAsArray', () => { + test('queries with taskId ascending order', async () => { + mockVotedGradeStatisticsFindMany([]); + + await getAllVoteStatisticsAsArray(); + + expect(prisma.votedGradeStatistics.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { taskId: 'asc' } }), + ); + }); +}); + +describe('getAllVoteCounters', () => { + test('returns all vote counters without filters', async () => { + const counters = [makeCounterRecord(), makeCounterRecord({ id: 'c2', taskId: 'abc002_a' })]; + mockVotedGradeCounterFindMany(counters); + + const result = await getAllVoteCounters(); + + expect(result).toHaveLength(2); + expect(prisma.votedGradeCounter.findMany).toHaveBeenCalledWith(); + }); +}); diff --git a/src/features/votes/services/vote_statistics.ts b/src/features/votes/services/vote_statistics.ts new file mode 100644 index 000000000..b7516ae1a --- /dev/null +++ b/src/features/votes/services/vote_statistics.ts @@ -0,0 +1,63 @@ +import { default as prisma } from '$lib/server/database'; +import type { VotedGradeStatistics, VotedGradeCounter, TaskGrade } from '@prisma/client'; + +/** A task row enriched with estimated grade and total vote count. */ +export type TaskWithVoteInfo = { + task_id: string; + contest_id: string; + title: string; + estimatedGrade: TaskGrade | null; + voteTotal: number; +}; + +export async function getVoteGradeStatistics(): Promise> { + const allStats = await prisma.votedGradeStatistics.findMany(); + const gradesMap = new Map(); + + allStats.forEach((stat) => { + gradesMap.set(stat.taskId, stat); + }); + return gradesMap; +} + +export async function getAllTasksWithVoteInfo(): Promise { + const [allTasks, stats, counters] = await Promise.all([ + prisma.task.findMany({ orderBy: { task_id: 'asc' } }), + prisma.votedGradeStatistics.findMany(), + prisma.votedGradeCounter.findMany(), + ]); + + const statsMap = new Map(stats.map((s) => [s.taskId, s])); + const totalsMap = new Map(); + for (const c of counters) { + totalsMap.set(c.taskId, (totalsMap.get(c.taskId) ?? 0) + c.count); + } + + return allTasks.map((task) => ({ + task_id: task.task_id, + contest_id: task.contest_id, + title: task.title, + estimatedGrade: statsMap.get(task.task_id)?.grade ?? null, + voteTotal: totalsMap.get(task.task_id) ?? 0, + })); +} + +export async function getVoteCountersByTaskId(taskId: string): Promise { + return prisma.votedGradeCounter.findMany({ + where: { taskId }, + orderBy: { grade: 'asc' }, + }); +} + +/** Fetches all vote counters at once, for use when aggregating across many tasks. */ +export async function getAllVoteCounters(): Promise { + return prisma.votedGradeCounter.findMany(); +} + +export async function getVoteStatsByTaskId(taskId: string): Promise { + return prisma.votedGradeStatistics.findFirst({ where: { taskId } }); +} + +export async function getAllVoteStatisticsAsArray(): Promise { + return prisma.votedGradeStatistics.findMany({ orderBy: { taskId: 'asc' } }); +} diff --git a/src/features/votes/types/vote_result.ts b/src/features/votes/types/vote_result.ts new file mode 100644 index 000000000..696bd24c7 --- /dev/null +++ b/src/features/votes/types/vote_result.ts @@ -0,0 +1,13 @@ +import type { TaskGrade } from '$lib/types/task'; + +/** Result of fetching a single user's vote for a task. */ +export type VoteGradeResult = { + voted: boolean; + grade: TaskGrade | null; +}; + +/** A single entry in the vote statistics map, keyed by taskId. */ +export type VoteStatisticsEntry = { grade: TaskGrade }; + +/** Map from taskId to its vote statistics entry. */ +export type VoteStatisticsMap = Map; diff --git a/src/features/votes/utils/grade_options.test.ts b/src/features/votes/utils/grade_options.test.ts new file mode 100644 index 000000000..0e76702b0 --- /dev/null +++ b/src/features/votes/utils/grade_options.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { TaskGrade, taskGradeValues } from '$lib/types/task'; + +import { nonPendingGrades } from './grade_options'; + +describe('nonPendingGrades', () => { + it('does not include PENDING', () => { + expect(nonPendingGrades).not.toContain(TaskGrade.PENDING); + }); + + it('contains all grades except PENDING', () => { + const expected = taskGradeValues.filter((grade) => grade !== TaskGrade.PENDING); + expect(nonPendingGrades).toEqual(expected); + }); + + it('starts with Q11 and ends with D6', () => { + expect(nonPendingGrades[0]).toBe(TaskGrade.Q11); + expect(nonPendingGrades[nonPendingGrades.length - 1]).toBe(TaskGrade.D6); + }); +}); diff --git a/src/features/votes/utils/grade_options.ts b/src/features/votes/utils/grade_options.ts new file mode 100644 index 000000000..57f443244 --- /dev/null +++ b/src/features/votes/utils/grade_options.ts @@ -0,0 +1,4 @@ +import { taskGradeValues, TaskGrade } from '$lib/types/task'; + +/** All grade values except PENDING, used for vote buttons and distribution display. */ +export const nonPendingGrades = taskGradeValues.filter((grade) => grade !== TaskGrade.PENDING); diff --git a/src/features/votes/utils/median.test.ts b/src/features/votes/utils/median.test.ts new file mode 100644 index 000000000..588ff6ec1 --- /dev/null +++ b/src/features/votes/utils/median.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { TaskGrade } from '@prisma/client'; + +import { computeMedianGrade } from './median'; + +describe('computeMedianGrade', () => { + it('returns null when total votes is below minVotes (default 3)', () => { + const counters = [ + { grade: TaskGrade.Q5, count: 1 }, + { grade: TaskGrade.Q4, count: 1 }, + ]; + expect(computeMedianGrade(counters)).toBeNull(); + }); + + it('returns null when counters are empty', () => { + expect(computeMedianGrade([])).toBeNull(); + }); + + it('returns null when total is exactly minVotes - 1', () => { + const counters = [{ grade: TaskGrade.Q3, count: 2 }]; + expect(computeMedianGrade(counters)).toBeNull(); + }); + + it('returns the median for an odd total (single grade, 3 votes)', () => { + const counters = [{ grade: TaskGrade.Q5, count: 3 }]; + expect(computeMedianGrade(counters)).toBe(TaskGrade.Q5); + }); + + it('returns the median for an odd total (multiple grades)', () => { + // votes: Q11, Q5, Q5 → sorted: Q11(1), Q5(2) → median is 2nd = Q5 + const counters = [ + { grade: TaskGrade.Q11, count: 1 }, + { grade: TaskGrade.Q5, count: 2 }, + ]; + expect(computeMedianGrade(counters)).toBe(TaskGrade.Q5); + }); + + it('returns the lower-rounded median for an even total', () => { + // votes: Q7(2), Q5(2) → positions 2=Q7, 3=Q5 → avg order → rounds to Q6 + const counters = [ + { grade: TaskGrade.Q7, count: 2 }, + { grade: TaskGrade.Q5, count: 2 }, + ]; + expect(computeMedianGrade(counters)).toBe(TaskGrade.Q6); + }); + + it('returns same grade when all votes are the same', () => { + const counters = [{ grade: TaskGrade.D1, count: 5 }]; + expect(computeMedianGrade(counters)).toBe(TaskGrade.D1); + }); + + it('handles votes concentrated at the extremes (Q11 and D6)', () => { + // 3 votes for Q11, 3 votes for D6 → order avg of (1+17)/2 = 9 = Q3 + const counters = [ + { grade: TaskGrade.Q11, count: 3 }, + { grade: TaskGrade.D6, count: 3 }, + ]; + expect(computeMedianGrade(counters)).toBe(TaskGrade.Q3); + }); + + it('respects a custom minVotes threshold', () => { + const counters = [{ grade: TaskGrade.Q5, count: 2 }]; + expect(computeMedianGrade(counters, 2)).toBe(TaskGrade.Q5); + }); +}); diff --git a/src/features/votes/utils/median.ts b/src/features/votes/utils/median.ts new file mode 100644 index 000000000..29e8bb334 --- /dev/null +++ b/src/features/votes/utils/median.ts @@ -0,0 +1,69 @@ +import { TaskGrade } from '@prisma/client'; +import { getGradeOrder, taskGradeOrderInfinity } from '$lib/utils/task'; + +/** Maps grade order (1=Q11 … 17=D6) back to the corresponding TaskGrade enum value. */ +const ORDER_TO_TASK_GRADE: Map = new Map([ + [1, TaskGrade.Q11], + [2, TaskGrade.Q10], + [3, TaskGrade.Q9], + [4, TaskGrade.Q8], + [5, TaskGrade.Q7], + [6, TaskGrade.Q6], + [7, TaskGrade.Q5], + [8, TaskGrade.Q4], + [9, TaskGrade.Q3], + [10, TaskGrade.Q2], + [11, TaskGrade.Q1], + [12, TaskGrade.D1], + [13, TaskGrade.D2], + [14, TaskGrade.D3], + [15, TaskGrade.D4], + [16, TaskGrade.D5], + [17, TaskGrade.D6], + [taskGradeOrderInfinity, TaskGrade.PENDING], +]); + +type GradeCounter = { grade: TaskGrade; count: number }; + +/** + * Computes the median grade from a list of grade counters. + * Returns `null` when the total vote count is below the minimum threshold. + * + * @param counters - Grade counters sorted by grade ascending. + * @param minVotes - Minimum votes required to compute a median. Defaults to 3. + * @returns The median TaskGrade, or `null` if there are fewer than `minVotes` total votes. + */ +export function computeMedianGrade(counters: GradeCounter[], minVotes = 3): TaskGrade | null { + const total = counters.reduce((sum, counter) => sum + counter.count, 0); + if (total < minVotes) { + return null; + } + + const getGradeOrderAtPosition = (target: number): number => { + let cumulative = 0; + for (const counter of counters) { + cumulative += counter.count; + if (cumulative >= target) { + return getGradeOrder(counter.grade); + } + } + throw new RangeError( + `getGradeOrderAtPosition: position ${target} is out of range (total=${total})`, + ); + }; + + let medianOrder: number; + if (total % 2 !== 0) { + medianOrder = getGradeOrderAtPosition(Math.ceil(total / 2)); + } else { + const lower = getGradeOrderAtPosition(total / 2); + const upper = getGradeOrderAtPosition(total / 2 + 1); + medianOrder = Math.round((lower + upper) / 2); + } + + const result = ORDER_TO_TASK_GRADE.get(medianOrder); + if (result === undefined) { + throw new RangeError(`computeMedianGrade: no grade mapped to order ${medianOrder}`); + } + return result; +} diff --git a/src/lib/components/GradeLabel.svelte b/src/lib/components/GradeLabel.svelte index 9d18b765d..04a6f3f5c 100644 --- a/src/lib/components/GradeLabel.svelte +++ b/src/lib/components/GradeLabel.svelte @@ -45,7 +45,7 @@ } -
+
{ + await validateAdminAccess(locals); + + const [allStats, tasksMap, allCounters] = await Promise.all([ + getAllVoteStatisticsAsArray(), + getTasksByTaskId(), + getAllVoteCounters(), + ]); + + const voteTotalsMap = new Map(); + for (const counter of allCounters) { + voteTotalsMap.set(counter.taskId, (voteTotalsMap.get(counter.taskId) ?? 0) + counter.count); + } + + const statsWithInfo = allStats.map((stat) => { + const task = tasksMap.get(stat.taskId); + return { + taskId: stat.taskId, + title: task?.title ?? stat.taskId, + contestId: task?.contest_id ?? '', + dbGrade: task?.grade ?? 'PENDING', + estimatedGrade: stat.grade, + voteTotal: voteTotalsMap.get(stat.taskId) ?? 0, + }; + }); + + return { stats: statsWithInfo }; +}; + +export const actions: Actions = { + setTaskGrade: async ({ request, locals }) => { + await validateAdminAccess(locals); + const data = await request.formData(); + const taskId = data.get('taskId'); + const grade = data.get('grade'); + if ( + typeof taskId !== 'string' || + !taskId || + typeof grade !== 'string' || + !(Object.values(TaskGradeEnum) as string[]).includes(grade) + ) { + return { success: false }; + } + await updateTask(taskId, grade as TaskGrade); + return { success: true }; + }, +}; diff --git a/src/routes/(admin)/vote_management/+page.svelte b/src/routes/(admin)/vote_management/+page.svelte new file mode 100644 index 000000000..399426bc9 --- /dev/null +++ b/src/routes/(admin)/vote_management/+page.svelte @@ -0,0 +1,85 @@ + + +
+ + +

+ 集計済み統計一覧(3票以上で暫定グレードが算出されます) +

+ + + + 問題 + コンテスト + DBグレード + 中央値グレード + 票数 + + + {#each data.stats as stat (stat.taskId)} + + + + {stat.title} + + + {stat.contestId} + +
+ + + +
+ + + + {stat.voteTotal} +
+ {/each} + {#if data.stats.length === 0} + + + 集計データがありません + + + {/if} +
+
+
diff --git a/src/routes/problems/+page.server.ts b/src/routes/problems/+page.server.ts index ef4738c6e..70924b383 100644 --- a/src/routes/problems/+page.server.ts +++ b/src/routes/problems/+page.server.ts @@ -1,9 +1,11 @@ import { type Actions } from '@sveltejs/kit'; -import * as crud from '$lib/services/task_results'; +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 * as action from '$lib/actions/update_task_result'; +import { updateTaskResult } from '$lib/actions/update_task_result'; +import { voteAbsoluteGrade } from '@/features/votes/actions/vote_actions'; // 一覧表ページは、ログインしていなくても閲覧できるようにする export async function load({ locals, url }) { @@ -15,15 +17,28 @@ export async function load({ locals, url }) { // TODO: utilに移動させる const isLoggedIn: boolean = session !== null; + // Degrade gracefully if vote stats are unavailable — the problems page must remain accessible. + let voteResults = new Map(); + try { + voteResults = await getVoteGradeStatistics(); + } catch (error) { + console.error('Failed to load vote statistics:', error); + } + if (tagIds != null) { return { - taskResults: (await crud.getTasksWithTagIds(tagIds, session?.user.userId)) as TaskResults, + taskResults: (await task_crud.getTasksWithTagIds( + tagIds, + session?.user.userId, + )) as TaskResults, + voteResults, isAdmin: isAdmin, isLoggedIn: isLoggedIn, }; } else { return { - taskResults: (await crud.getTaskResults(session?.user.userId)) as TaskResults, + taskResults: (await task_crud.getTaskResults(session?.user.userId)) as TaskResults, + voteResults, isAdmin: isAdmin, isLoggedIn: isLoggedIn, }; @@ -33,6 +48,9 @@ export async function load({ locals, url }) { export const actions = { update: async ({ request, locals }) => { const operationLog = 'problems -> actions -> update'; - return await action.updateTaskResult({ request, locals }, operationLog); + return await updateTaskResult({ request, locals }, operationLog); + }, + voteAbsoluteGrade: async ({ request, locals }) => { + return await voteAbsoluteGrade({ request, locals }); }, } satisfies Actions; diff --git a/src/routes/problems/+page.svelte b/src/routes/problems/+page.svelte index 45d0c0153..a3689daf8 100644 --- a/src/routes/problems/+page.svelte +++ b/src/routes/problems/+page.svelte @@ -24,6 +24,7 @@ let isAdmin: boolean = data.isAdmin; let isLoggedIn: boolean = data.isLoggedIn; + let voteResults = data.voteResults; function isActiveTab(currentTab: ActiveProblemListTab): boolean { return currentTab === activeProblemListTabStore.get(); @@ -59,7 +60,7 @@ {/snippet} {#snippet contestTable()} - + {/snippet} {#snippet listByGrade()} diff --git a/src/routes/problems/getMedianVote/+server.ts b/src/routes/problems/getMedianVote/+server.ts new file mode 100644 index 000000000..393a5643d --- /dev/null +++ b/src/routes/problems/getMedianVote/+server.ts @@ -0,0 +1,26 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { getVoteStatsByTaskId } from '$features/votes/services/vote_statistics'; +import { getVoteGrade } from '$features/votes/services/vote_grade'; + +export const GET: RequestHandler = async ({ url, locals }) => { + const taskId = url.searchParams.get('taskId'); + if (!taskId) return json({ error: 'taskId required' }, { status: 400 }); + + const session = await locals.auth.validate(); + if (!session || !session.user || !session.user.userId) { + return json({ error: 'unauthorized' }, { status: 401 }); + } + + try { + // Only return vote statistics to users who have already cast a vote for this task. + const voteRecord = await getVoteGrade(session.user.userId, taskId); + if (!voteRecord.voted) return json({ grade: null }); + + const stats = await getVoteStatsByTaskId(taskId); + if (stats && stats.grade) return json({ grade: stats.grade }); + return json({ grade: null }); + } catch (err) { + console.error('getMedianVote failed', err); + return json({ error: 'internal error' }, { status: 500 }); + } +}; diff --git a/src/routes/problems/getMyVote/+server.ts b/src/routes/problems/getMyVote/+server.ts new file mode 100644 index 000000000..e0a5a20b2 --- /dev/null +++ b/src/routes/problems/getMyVote/+server.ts @@ -0,0 +1,18 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { getVoteGrade } from '$features/votes/services/vote_grade'; + +export const GET: RequestHandler = async ({ url, locals }) => { + const taskId = url.searchParams.get('taskId'); + if (!taskId) return json({ error: 'taskId required' }, { status: 400 }); + + const session = await locals.auth.validate(); + if (!session || !session.user || !session.user.userId) + return json({ error: 'unauthorized' }, { status: 401 }); + + try { + const res = await getVoteGrade(session.user.userId, taskId); + return json(res); + } catch { + return json({ error: 'internal error' }, { status: 500 }); + } +}; diff --git a/src/routes/votes/+page.server.ts b/src/routes/votes/+page.server.ts new file mode 100644 index 000000000..8633f1238 --- /dev/null +++ b/src/routes/votes/+page.server.ts @@ -0,0 +1,12 @@ +import type { PageServerLoad } from './$types'; +import { getAllTasksWithVoteInfo } from '$features/votes/services/vote_statistics'; + +export const load: PageServerLoad = async ({ locals }) => { + const session = await locals.auth.validate(); + const tasks = await getAllTasksWithVoteInfo(); + + return { + tasks, + isLoggedIn: session !== null, + }; +}; diff --git a/src/routes/votes/+page.svelte b/src/routes/votes/+page.svelte new file mode 100644 index 000000000..8646f0e12 --- /dev/null +++ b/src/routes/votes/+page.svelte @@ -0,0 +1,82 @@ + + +
+ + +
+ +
+ + + + 問題 + コンテスト + 暫定グレード + 票数 + + + {#each filteredTasks as task (task.task_id)} + + + + {task.title} + + + {task.contest_id} + + {#if task.estimatedGrade} + + {:else} + - + {/if} + + {task.voteTotal} + + {/each} + {#if filteredTasks.length === 0} + + + 該当する問題が見つかりませんでした + + + {/if} + +
+
diff --git a/src/routes/votes/[slug]/+page.server.ts b/src/routes/votes/[slug]/+page.server.ts new file mode 100644 index 000000000..27e6db013 --- /dev/null +++ b/src/routes/votes/[slug]/+page.server.ts @@ -0,0 +1,48 @@ +import { error } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +import { getTask } from '$lib/services/tasks'; +import { getVoteGrade } from '$features/votes/services/vote_grade'; +import { + getVoteCountersByTaskId, + getVoteStatsByTaskId, +} from '$features/votes/services/vote_statistics'; +import { voteAbsoluteGrade } from '$features/votes/actions/vote_actions'; + +export const load: PageServerLoad = async ({ locals, params }) => { + const session = await locals.auth.validate(); + const taskId = params.slug; + + const tasks = await getTask(taskId); + const task = tasks[0]; + if (!task) throw error(404, 'Task not found'); + + let myVote = null; + if (session?.user.userId) { + myVote = await getVoteGrade(session.user.userId, taskId); + } + + // 先入観の影響を低減させるため、投票するまで統計情報は表示しない + let counters = null; + let stats = null; + if (myVote?.voted) { + [counters, stats] = await Promise.all([ + getVoteCountersByTaskId(taskId), + getVoteStatsByTaskId(taskId), + ]); + } + + return { + task, + myVote, + counters, + stats, + isLoggedIn: session !== null, + }; +}; + +export const actions: Actions = { + voteAbsoluteGrade: async ({ request, locals }) => { + return await voteAbsoluteGrade({ request, locals }); + }, +}; diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte new file mode 100644 index 000000000..6d92afe52 --- /dev/null +++ b/src/routes/votes/[slug]/+page.svelte @@ -0,0 +1,159 @@ + + +
+ + + + + + +
+ + +
+ +

+ ※ 3票以上集まると中央値が暫定グレードとして一覧表に反映されます。 +

+ + + {#if data.myVote?.voted} + +
+

+ + 投票済み:{data.myVote.grade ? getTaskGradeLabel(data.myVote.grade) : ''} +

+ + {#if data.stats} +

+ 暫定グレード:{getTaskGradeLabel(data.stats.grade)}({totalVotes}票) +

+ {/if} + + +
+ {#each nonPendingGrades as grade (grade)} + {@const count = getCount(grade)} + {@const pct = getPct(grade)} + {@const isMyVote = data.myVote?.grade === grade} +
+ + {getTaskGradeLabel(grade)} + +
+
+
+ + {count}票 ({pct}%) + +
+ {/each} +
+
+ + +
+ + 投票を変更する + +
+ {@render voteForm()} +
+
+ {:else if data.isLoggedIn} + +

+ この問題のグレードを投票してください。投票後に集計結果を確認できます。 +

+ {@render voteForm()} + {:else} + +

投票するにはログインが必要です。

+
+ + +
+ {/if} +
+ +{#snippet voteForm()} +
+ +
+ {#each nonPendingGrades as grade (grade)} + + {/each} +
+
+{/snippet}