Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a684dbd
add: schema.prismaに投票機能に必要なテーブルを追加
river0525 Mar 12, 2026
068e6b9
fix: relationの設定不足&マイグレーション忘れを修正
river0525 Mar 15, 2026
8f45a8d
add: 投票用ドロップダウンを出す機能を追加
river0525 Mar 19, 2026
0646e35
fix: 未ログイン時にドロップダウンのレイアウトが崩れる問題を修正
river0525 Mar 20, 2026
d608c59
add: 選択したグレードをコンソールに出力する機能を追加
river0525 Mar 20, 2026
afa66b5
add: 票をデータベースに送信する機能を実装
river0525 Mar 20, 2026
0f595cf
add: 投票したグレードにチェックマークを表示する機能を追加
river0525 Mar 21, 2026
cc06b0c
add: 票の統計データ化を実装
river0525 Mar 22, 2026
9c8e40c
add: ページロード時に投票済み中央値グレードを反映
river0525 Mar 24, 2026
71d3d56
fix: グレード付与済み問題への投票を制限
river0525 Mar 24, 2026
7a240ed
add: 投票関連ページを追加
river0525 Mar 24, 2026
2ee029d
fix: /(admin)/votes と /votes のルート衝突を解消
river0525 Mar 24, 2026
a673e76
add: 投票機能のUI改善
river0525 Mar 24, 2026
0cf12da
fix: 投票詳細ページの説明文を投票後も表示
river0525 Mar 24, 2026
c6ed2ca
fix: 詳細ページへの遷移をドロップダウンの横幅に合う表記に変更
river0525 Mar 24, 2026
5c795b5
add: 投票管理ページの改善と投票制限の解除
river0525 Mar 25, 2026
8fb1f25
refactor: 投票機能のコード品質改善
river0525 Mar 25, 2026
57bee46
test: vote_crud.ts の単体テストを追加
river0525 Mar 25, 2026
f672086
refactor: vote_crud.ts を vote_grade / vote_statistics に分割し型・依存を整理
river0525 Mar 25, 2026
3374c3a
test: vote_grade のテストにインクリメント確認と統計未更新の assertion を追加
river0525 Mar 25, 2026
1179387
fix: CodeRabbit potential_issue の対応 (7件)
river0525 Mar 25, 2026
024f9bb
fix: lint修正・e2eテスト追加・バリデーションルール追記
river0525 Mar 26, 2026
74b5c7f
fix: .gitignoreとERD図に関する指摘を修正
river0525 Mar 26, 2026
c11fe23
fix: address CodeRabbit review findings on PR #3316
river0525 Mar 26, 2026
61d6892
fix: constrain GradeLabel outer div to content width to prevent D6 bo…
river0525 Mar 26, 2026
8a15fce
fix: address remaining CodeRabbit/Copilot findings on PR #3316
river0525 Mar 26, 2026
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
29 changes: 29 additions & 0 deletions .claude/rules/sveltekit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,3 @@ prisma/.fabbrica

# Directory for playwright test results
test-results

178 changes: 178 additions & 0 deletions e2e/votes.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
});
});
});
});
37 changes: 37 additions & 0 deletions prisma/ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
```
45 changes: 45 additions & 0 deletions prisma/migrations/20260315003958_add_vote_table/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading