Feature/absolute evaluation voting#3316
Conversation
- voteResults(Map)を +page.svelte → TaskTable → TaskTableBodyCell → VotableGrade に伝達 - VotableGrade の displayGrade 初期値を estimatedGrade ?? taskResult.grade に変更 - 投票成功後に votedGrade をローカル状態で即時更新(チェックマーク反映) - vote_table_manager.ts を削除し vote_crud.ts に統合 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
grade が PENDING(未確定)の問題のみ投票ドロップダウンを表示し、 公式グレードが付与済みの問題は静的なグレードラベルのみ表示する。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- /votes: PENDING問題一覧ページ(テキスト検索対応) - /votes/[slug]: 投票詳細ページ(先入観低減のため投票後に統計表示) - /(admin)/votes: 管理者用投票統計一覧ページ - vote_crud.ts に getPendingTasksWithVoteInfo / getVoteCountersByTaskId / getVoteStatsByTaskId / getAllVoteStatisticsAsArray を追加 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
/(admin)/votes は URL が /votes になり公開ルートと衝突するため、 /(admin)/vote_management(URL: /vote_management)にリネーム。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ナビバーに「グレード投票」(/votes)を追加 - 管理画面ドロップダウンに「投票管理」(/vote_management)を追加 - 投票詳細ページに「3票以上で一覧表に反映」の説明を追記 - VotableGrade ドロップダウン末尾に詳細ページへのリンクを追加 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
「3票以上集まると一覧表に反映される」の説明を {#if} ブロック外に移動し、
投票前・投票後・未ログインのいずれの状態でも常に表示されるようにした。
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 投票管理ページのステータス列を削除 - DBからisApprovedフィールドを削除(マイグレーション追加) - DBグレード列をプルダウンに変更し運営が直接グレードを設定可能に - グレード付与済み問題への投票を解禁(PENDING制限を撤廃) - 表示はDBグレードを中央値より優先 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- vote_crud.ts: 変数名修正 (res→voteRecord, all_data→allStats) - vote_actions.ts: 変数名修正 (response→formData)、不要な operationLog パラメータ削除 - getVoteGradeStatistics: .map()→.forEach() 修正、Map に型パラメータ付与 - VoteGradeResult 型を types/vote_result.ts に定義 - nonPendingGrades 定数を utils/grade_options.ts に抽出 (重複解消) - 中央値計算を computeMedianGrade() として utils/median.ts に抽出しテスト追加 - 投票管理ページの N+1 クエリを getAllVoteCounters() 一括取得に変更 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
全 8 関数 (getVoteGrade, getVoteGradeStatistics, getAllTasksWithVoteInfo, getVoteCountersByTaskId, getVoteStatsByTaskId, getAllVoteStatisticsAsArray, getAllVoteCounters, upsertVoteGradeTables) をカバーする 20 件のテストを追加。 $transaction はコールバック実行方式でモックし、 冪等性・カウンタ増減・中央値統計の条件分岐を検証。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- vote_crud.ts を vote_grade.ts(書き込み)と vote_statistics.ts(読み込み)に分割 - vote_crud.test.ts も対応する2ファイルに分割 - upsertVoteGradeTables を小関数に分解、MIN_VOTES_FOR_STATISTICS を定数化 - grade を string から TaskGrade に型強化 (vote_actions.ts) - VoteStatisticsMap / VoteStatisticsEntry の named type を追加 - vote_statistics.ts の全関数に明示的な戻り値型を追加 - validateAdminAccess を _utils/auth.ts から再利用 (vote_management) - getMedianVote をサービス層 (getVoteStatsByTaskId) 経由に変更 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
デクリメントテスト: Q4の減算だけでなくQ5への増分と統計未更新も検証 インクリメントテスト: 合計1票のため統計が更新されないことを明示的に確認 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- problems/+page.server.ts: voteAbsoluteGrade アクションに return を追加 - getMyVote/+server.ts: getVoteGrade 呼び出しを try/catch で保護 - vote_actions.ts: formData.get() の null チェックと TaskGrade enum バリデーションを追加 - vote_management/+page.server.ts: formData.get() の null チェックを追加 - votes/+page.svelte: toLowerCase() 呼び出しに null 安全ガードを追加 - VotableGrade.svelte: Math.random() → crypto.randomUUID() でSSR/Hydration不一致を解消 - VotableGrade.svelte: フォームIDをcomponentId付きにしてフォームID衝突バグを修正 - median.ts: console.error (副作用) を RangeError の throw に変更 スキップした旨: migration の unique constraint 削除は @@id 複合PKで保護済み, median.ts / grade_options.ts のテスト欠如指摘はすでに対応済み Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthrough投票機能を追加:DB(vote系テーブル)とマイグレーション、サーバーAPI/アクション、サービス層(集計・中央値算出・統合アップサート)、UI(一覧・詳細・管理・VotableGrade)、ユニット/E2Eテスト、SvelteKitフォーム検証ルールを導入。 Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Votable as VotableGrade
participant ClientForm as Form
participant Action as voteAbsoluteGrade
participant Service as VoteService
participant DB as Database
participant Stats as VoteStatistics
User->>Votable: グレード選択
Votable->>Votable: /problems/getMyVote を取得
Votable->>ClientForm: フォーム送信(taskId, grade)
ClientForm->>Action: POST ?/voteAbsoluteGrade
Action->>Action: formData の型/存在チェック、認可検証
Action->>Service: upsertVoteGradeTables(userId, taskId, grade)
Service->>DB: トランザクション開始・既存票取得・カウンター更新・voteGrade upsert
Service->>DB: カウンター集計 → total >= MIN_VOTES ? median算出して votedgradestatistics upsert
DB-->>Service: 更新結果
Service-->>Action: 完了
Action-->>ClientForm: 200 OK
ClientForm-->>Votable: 更新通知
Votable->>Stats: /problems/getMedianVote を取得(必要時)
Votable-->>User: UI更新(チェック表示/中央値反映)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
絶対評価方式のグレード投票機能を追加し、問題一覧上からの投票・問題別投票ページ・集計/DBグレード反映の管理画面までを一通り提供するPRです。
Changes:
- 投票一覧
/votesと問題別投票ページ/votes/[slug]を追加 - 一覧表(
/problems)のグレード表示を投票可能ドロップダウンに置き換え、投票後に中央値を反映 - Prismaに投票関連テーブルを追加し、カウンタ/中央値統計の算出・参照サービスとテストを追加(管理画面も追加)
Reviewed changes
Copilot reviewed 31 out of 32 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/routes/votes/[slug]/+page.svelte | 問題別の投票UI(投票済みなら分布/暫定中央値表示)を追加 |
| src/routes/votes/[slug]/+page.server.ts | 問題取得・自分の投票取得・(投票済み時のみ)統計取得、投票actionを追加 |
| src/routes/votes/+page.svelte | 投票対象タスクの検索付き一覧テーブルを追加 |
| src/routes/votes/+page.server.ts | 投票情報付きタスク一覧の取得を追加 |
| src/routes/problems/getMyVote/+server.ts | 一覧表ドロップダウン用に「自分の投票」取得APIを追加 |
| src/routes/problems/getMedianVote/+server.ts | 一覧表ドロップダウン用に「中央値」取得APIを追加 |
| src/routes/problems/+page.svelte | TaskTableに voteResults を渡すよう拡張 |
| src/routes/problems/+page.server.ts | voteResults取得と voteAbsoluteGrade action を追加、既存import整理 |
| src/routes/(admin)/vote_management/+page.svelte | 管理者向け投票状況一覧とDBグレード更新UIを追加 |
| src/routes/(admin)/vote_management/+page.server.ts | 管理者向け統計/カウンタ集約と setTaskGrade action を追加 |
| src/lib/constants/navbar-links.ts | ナビバーに「グレード投票」、管理者メニューに「投票管理」を追加 |
| src/features/votes/utils/median.ts | カウンタから中央値グレードを算出するユーティリティを追加 |
| src/features/votes/utils/median.test.ts | medianユーティリティのテストを追加 |
| src/features/votes/utils/grade_options.ts | 投票対象グレード(PENDING除外)定義を追加 |
| src/features/votes/utils/grade_options.test.ts | grade_options のテストを追加 |
| src/features/votes/types/vote_result.ts | 投票結果/統計の型定義を追加 |
| src/features/votes/services/vote_statistics.ts | 統計・カウンタ取得、投票情報付きタスク一覧取得のサービスを追加 |
| src/features/votes/services/vote_statistics.test.ts | vote_statistics サービスのテストを追加 |
| src/features/votes/services/vote_grade.ts | 投票取得と、投票/カウンタ/統計のトランザクション更新を追加 |
| src/features/votes/services/vote_grade.test.ts | vote_grade サービスのテストを追加 |
| src/features/votes/components/VotableGrade.svelte | 一覧表のグレードアイコンから投票できるドロップダウンUIを追加 |
| src/features/votes/actions/vote_actions.ts | 投票action(絶対評価)を追加 |
| src/features/tasks/components/contest-table/TaskTableBodyCell.svelte | GradeLabelをVotableGradeに置換し、中央値表示用の推定グレードを注入 |
| src/features/tasks/components/contest-table/TaskTable.svelte | voteResults をPropsに追加して子へ伝搬 |
| prisma/schema.prisma | VoteGrade / VotedGradeCounter / VotedGradeStatistics を追加、User/Taskにrelation追加 |
| prisma/migrations/20260315003958_add_vote_table/migration.sql | 投票関連テーブル初期追加 |
| prisma/migrations/20260320143157_change_vote_grade_table/migration.sql | votegrade 主キー/unique調整 |
| prisma/migrations/20260321164035_change_voted_grade_counter/migration.sql | votedgradecounter 主キー/unique調整 |
| prisma/migrations/20260322043315_change_some_vote_tables_decorator/migration.sql | votedgradestatistics の unique 追加など調整 |
| prisma/migrations/20260325000000_remove_is_approved_from_vote_statistics/migration.sql | votedgradestatistics の isApproved 削除 |
| prisma/ERD.md | ERDに投票テーブルを追記 |
| .gitignore | 開発用メモファイルをignore追加 |
| const taskId = data.get('taskId'); | ||
| const grade = data.get('grade'); | ||
| if (typeof taskId !== 'string' || !taskId || typeof grade !== 'string') { | ||
| return { success: false }; | ||
| } | ||
| await updateTask(taskId, grade as TaskGrade); | ||
| return { success: true }; |
There was a problem hiding this comment.
setTaskGrade で grade を TaskGrade として検証せずに型アサーションしているため、不正な文字列が来た場合に updateTask 側で例外/不正データにつながります。Object.values(TaskGrade) でのチェック等、許容値の検証を入れてから更新してください(adminでもフォーム改ざんは可能です)。
| import { getVoteStatsByTaskId } from '$features/votes/services/vote_statistics'; | ||
|
|
||
| export const GET: RequestHandler = async ({ url }) => { | ||
| const taskId = url.searchParams.get('taskId'); | ||
| if (!taskId) return json({ error: 'taskId required' }, { status: 400 }); | ||
|
|
||
| try { | ||
| const stats = await getVoteStatsByTaskId(taskId); | ||
| if (stats && stats.grade) return json({ grade: stats.grade }); |
There was a problem hiding this comment.
getMedianVote がログイン/投票状況に関係なく中央値を返すため、votes/[slug] 側で意図している「投票するまで統計情報は表示しない」という制約をAPI経由で回避できます。ログイン必須にし、少なくとも当該taskIdに投票済みのユーザーにのみ中央値を返す(未投票なら grade: null)などのガードを入れてください。
| import { getVoteStatsByTaskId } from '$features/votes/services/vote_statistics'; | |
| export const GET: RequestHandler = async ({ url }) => { | |
| const taskId = url.searchParams.get('taskId'); | |
| if (!taskId) return json({ error: 'taskId required' }, { status: 400 }); | |
| try { | |
| const stats = await getVoteStatsByTaskId(taskId); | |
| if (stats && stats.grade) return json({ grade: stats.grade }); | |
| export const GET: RequestHandler = async ({ url }) => { | |
| const taskId = url.searchParams.get('taskId'); | |
| if (!taskId) return json({ error: 'taskId required' }, { status: 400 }); | |
| try { | |
| // For security reasons, do not expose median vote statistics via this endpoint. | |
| // Always return a null grade to avoid bypassing UI-level "no stats before voting" constraints. |
|
Caution Review failedAn error occurred during the review process. Please try again later. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
- pnpm format で Prettier フォーマットを修正 (vote_grade.ts, vote_statistics.ts, median.ts) - svelte/no-navigation-without-resolve エラーを修正: 投票関連3ページの <a href> を resolve() from '$app/paths' でラップ (votes/+page.svelte, votes/[slug]/+page.svelte, vote_management/+page.svelte) - e2e/votes.spec.ts を追加: 投票一覧・詳細・管理ページの認証状態別アクセス制御を確認 (未認証/一般ユーザ/管理者ごとに表示内容・リダイレクト・UI要素を検証) - .claude/rules/sveltekit.md にサーバーサイドバリデーションルールを追記: formData.get() の null/型チェックと enum バリデーションのパターンを明文化 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 18
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.gitignore:
- Line 161: Replace the single-file ignore entry
("docs/dev-notes/2025-03-23/plan.md") with a pattern-based rule to cover all
current and future notes; edit .gitignore and change that line to a glob such as
"docs/dev-notes/**/*.md" (or "docs/dev-notes/*.md" if you only need one level)
so all markdown files under docs/dev-notes are ignored going forward, then save
and commit the change.
In `@e2e/votes.spec.ts`:
- Line 63: The helper function navigateToFirstVoteDetailPage has an incorrect
parameter type; replace the page parameter type Parameters<typeof test>[1] with
Playwright's Page type and ensure Page is imported from `@playwright/test` (or the
existing Playwright test import is updated) so the function signature becomes
navigateToFirstVoteDetailPage(page: Page): Promise<void>, preserving type safety
for all callers.
In `@prisma/ERD.md`:
- Around line 261-269: Remove the obsolete isApproved field from the
"votedgradestatistics" block in ERD.md so the documented model matches the
prisma VotedGradeStatistics model (the field was dropped in migration
20260325000000); locate the "votedgradestatistics" section and delete the line
"Boolean isApproved" and then confirm the ERD.md block reflects the remaining
fields (id, taskId, grade, isExperimental, createdAt, updatedAt).
In `@prisma/migrations/20260315003958_add_vote_table/migration.sql`:
- Around line 26-39: The migration is missing a UNIQUE constraint on
votedgradestatistics.taskId which causes upsert({ where: { taskId } }) to fail;
add a unique constraint or unique index for the taskId column (e.g., a UNIQUE
INDEX like votedgradestatistics_taskId_key or a UNIQUE(...) table constraint) so
the votedgradestatistics table enforces taskId uniqueness to match the schema
and the upsert behavior in vote_grade.ts.
In `@prisma/schema.prisma`:
- Around line 256-276: Add proper foreign-key relations from VotedGradeCounter
and VotedGradeStatistics to Task: replace the plain taskId String usage by
declaring a relation field on each model (VotedGradeCounter.task and
VotedGradeStatistics.task) that uses `@relation`(fields: [taskId], references:
[task_id], onDelete: Cascade) so the DB enforces referential integrity and
cascades deletions; also add the corresponding reverse relation fields on the
Task model (e.g., votedGradeCounters: VotedGradeCounter[] and
votedGradeStatistics: VotedGradeStatistics?) to complete the bidirectional
relation.
In `@src/features/votes/actions/vote_actions.ts`:
- Around line 39-43: The current catch block around
upsertVoteGradeTables(userId, taskId, grade) incorrectly maps all failures to
BAD_REQUEST; change the handling so only known input/validation errors produce a
4xx (keep existing validation earlier or detect specific error types/messages
thrown by upsertVoteGradeTables) and map unexpected DB/consistency failures to a
5xx (e.g., return fail(INTERNAL_SERVER_ERROR, ...)); if you need task existence
to be a 4xx, perform an explicit existence check before calling
upsertVoteGradeTables (e.g., call a findTaskById or similar) and return
BAD_REQUEST/NOT_FOUND for that case, otherwise log the unexpected error and
return a server error instead of BAD_REQUEST.
In `@src/features/votes/components/VotableGrade.svelte`:
- Around line 165-176: The {`#each` nonPendingGrades as grade} block lacks a key
which can cause rendering/rehydration issues; update the Svelte each to include
a stable key (e.g. use a unique property like grade.id or, if grade is a
primitive/value, use grade itself) so the list items (DropdownItem, the inner
Check and rendered label from getTaskGradeLabel) are tracked correctly; modify
the each header for nonPendingGrades to include the appropriate key expression
and ensure any tests or usages of votedGrade/handleClick still reference the
same grade items.
- Line 33: The component currently uses crypto.randomUUID() to set componentId
which produces different values between server and client and can cause SSR
hydration mismatches; replace that with a deterministic ID derived from the task
identifier passed into the component (e.g., use the existing task_id prop/prop
name in VotableGrade.svelte to build componentId like a stable `${task_id}-...`
string), update any usages of componentId accordingly, and only fall back to a
client-only random ID if task_id is not provided (ensure the fallback is guarded
so it doesn't run during SSR).
- Around line 67-76: The current setTimeout + document.querySelector approach
for submitting the form (looking up `#voteGradeForm-${componentId}` and clicking
`submitButton`) is fragile; instead, add a local form ref (e.g., `let formRef`)
via Svelte's bind:this on the form rendered by the `VotableGrade` component and
replace the setTimeout/querySelector block with a direct call to
`formRef.requestSubmit()` (or `formRef.submit()` if requestSubmit isn't
available), removing the timeout and DOM query logic and updating any code that
referenced `submitButton` to use the form ref.
In `@src/features/votes/services/vote_grade.ts`:
- Around line 72-81: The update in decrementOldGradeCounter may throw if the
votedGradeCounter row doesn't exist; replace the unsafe update with a safe
upsert (use tx.votedGradeCounter.upsert with where: { taskId_grade: { taskId,
grade: oldGrade } }, create: { taskId, grade: oldGrade, count: 0 } and update: {
count: { decrement: 1 } }) or, if you prefer minimal change, wrap the existing
tx.votedGradeCounter.update call in a try-catch and swallow/handle the "record
not found" error (e.g., Prisma P2025) so the function no longer throws on
missing counters.
In `@src/features/votes/types/vote_result.ts`:
- Around line 9-13: The VoteStatisticsEntry type currently defines grade as
string; change it to use the TaskGrade type to match Prisma's
VotedGradeStatistics.grade and the existing VoteGradeResult typing. Update the
declaration of VoteStatisticsEntry to { grade: TaskGrade } and ensure TaskGrade
is imported or referenced in this file, keeping VoteStatisticsMap as Map<string,
VoteStatisticsEntry>; this will preserve type safety and consistency with
VoteGradeResult and the DB layer.
In `@src/features/votes/utils/median.ts`:
- Around line 23-24: The median calculation can produce undefined lookups when
TaskGrade.PENDING appears in an even-numbered vote set (e.g., D6:2 + PENDING:2
yields medianOrder 5008), so update the median logic in median.ts (functions
computing medianOrder and performing the final map lookup) to either filter out
or reject TaskGrade.PENDING before computing the median, or validate the lookup
result and map it to a safe value/error; ensure all places that consume vote
grades (votedgradecounter.grade, votedgradestatistics.grade) treat TaskGrade as
the canonical domain and never rely on PENDING as a computable grade, and add a
regression test that covers an even count containing PENDING to assert the
function returns a deterministic, non-undefined result (or throws a documented
error).
In `@src/routes/`(admin)/vote_management/+page.server.ts:
- Around line 46-49: Validate that the incoming grade is one of the allowed
TaskGrade enum values before calling updateTask: check using the TaskGrade enum
(e.g. Object.values(TaskGrade).includes(grade as TaskGrade) or an
isValidTaskGrade helper) and if it is not a valid TaskGrade return { success:
false } (or handle error) instead of blindly casting; update the code around the
grade/type checks and the call to updateTask(taskId, grade as TaskGrade) to
enforce this validation.
In `@src/routes/`(admin)/vote_management/+page.svelte:
- Around line 57-61: Add a key to the Svelte each block to silence the linter
and ensure stable identity for items; change the `{`#each` taskGradeValues as
grade}` block to include a key (use the `grade` value since it is unique) so it
becomes `{`#each` taskGradeValues as grade (grade)}` while keeping the existing
references to `stat.dbGrade` and `getTaskGradeLabel(grade)`.
In `@src/routes/problems/`+page.server.ts:
- Around line 22-33: The current code calls getVoteGradeStatistics() directly so
any failure bubbles up and returns HTTP 500, preventing the tasks UI from
rendering; wrap the getVoteGradeStatistics() call in a try/catch and on error
set voteResults to an empty Map() (or other empty-structure expected by the
consumer) so task_crud.getTasksWithTagIds(...) and task_crud.getTaskResults(...)
remain decoupled from vote statistics failures; update both branches where
getVoteGradeStatistics() is used and keep the existing isAdmin/isLoggedIn logic
unchanged.
In `@src/routes/votes/`[slug]/+page.svelte:
- Line 83: The Svelte {`#each` nonPendingGrades as grade} block is missing a key
which can cause inefficient DOM updates; add a keyed each like {`#each`
nonPendingGrades as grade (grade.id)} using a stable unique property on grade
(e.g., grade.id or grade.userId), and do the same for the other {`#each` ... as
...} block mentioned (around the other occurrence at line ~138) to ensure both
lists use stable keys for rendering.
- Line 72: data.myVote.grade は TaskGrade | null なので as 演算子で無理にキャストせず null
チェックを入れてから getTaskGradeLabel を呼ぶように修正してください — 例えば getTaskGradeLabel を直接呼ぶ前に
data.myVote?.grade が null でないことを確認する分岐(または条件付きレンダリング)を追加し、grade が null
の場合は適切なフォールバック文字列を表示するようにして、不要な型キャストを削除してください(参照識別子: data.myVote.grade,
getTaskGradeLabel, TaskGrade, voted)。
In `@src/routes/votes/`+page.svelte:
- Around line 20-29: Extract the inline filtering logic from the +page.svelte
script into a reusable helper like filterVoteTasks in utils/; implement
filterVoteTasks(task, search) to perform the null-coalescing and
case-insensitive checks currently repeated for t.title, t.task_id, and
t.contest_id, export it, then import and use it inside the existing
filteredTasks $derived call (i.e., replace the inline predicate on
data.tasks.filter with a call to filterVoteTasks) so the component only composes
the helper and the helper can be unit-tested.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: ff3b4148-6d7e-4afb-97ad-31c5ecac5e34
📒 Files selected for processing (34)
.claude/rules/sveltekit.md.gitignoree2e/votes.spec.tsprisma/ERD.mdprisma/migrations/20260315003958_add_vote_table/migration.sqlprisma/migrations/20260320143157_change_vote_grade_table/migration.sqlprisma/migrations/20260321164035_change_voted_grade_counter/migration.sqlprisma/migrations/20260322043315_change_some_vote_tables_decorator/migration.sqlprisma/migrations/20260325000000_remove_is_approved_from_vote_statistics/migration.sqlprisma/schema.prismasrc/features/tasks/components/contest-table/TaskTable.sveltesrc/features/tasks/components/contest-table/TaskTableBodyCell.sveltesrc/features/votes/actions/vote_actions.tssrc/features/votes/components/VotableGrade.sveltesrc/features/votes/services/vote_grade.test.tssrc/features/votes/services/vote_grade.tssrc/features/votes/services/vote_statistics.test.tssrc/features/votes/services/vote_statistics.tssrc/features/votes/types/vote_result.tssrc/features/votes/utils/grade_options.test.tssrc/features/votes/utils/grade_options.tssrc/features/votes/utils/median.test.tssrc/features/votes/utils/median.tssrc/lib/constants/navbar-links.tssrc/routes/(admin)/vote_management/+page.server.tssrc/routes/(admin)/vote_management/+page.sveltesrc/routes/problems/+page.server.tssrc/routes/problems/+page.sveltesrc/routes/problems/getMedianVote/+server.tssrc/routes/problems/getMyVote/+server.tssrc/routes/votes/+page.server.tssrc/routes/votes/+page.sveltesrc/routes/votes/[slug]/+page.server.tssrc/routes/votes/[slug]/+page.svelte
| const filteredTasks = $derived( | ||
| search === '' | ||
| ? data.tasks | ||
| : data.tasks.filter( | ||
| (t) => | ||
| (t.title ?? '').toLowerCase().includes(search.toLowerCase()) || | ||
| (t.task_id ?? '').toLowerCase().includes(search.toLowerCase()) || | ||
| (t.contest_id ?? '').toLowerCase().includes(search.toLowerCase()), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
検索フィルタは utils/ に切り出してください。
この条件式がページ内に残ると、再利用と単体テストがしづらいです。filterVoteTasks() のようなヘルパーへ寄せて、このコンポーネントでは $derived から呼ぶだけにしたほうが保守しやすいです。
As per coding guidelines, "Business logic belongs in utils/, not inside <script> blocks."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/routes/votes/`+page.svelte around lines 20 - 29, Extract the inline
filtering logic from the +page.svelte script into a reusable helper like
filterVoteTasks in utils/; implement filterVoteTasks(task, search) to perform
the null-coalescing and case-insensitive checks currently repeated for t.title,
t.task_id, and t.contest_id, export it, then import and use it inside the
existing filteredTasks $derived call (i.e., replace the inline predicate on
data.tasks.filter with a call to filterVoteTasks) so the component only composes
the helper and the helper can be unit-tested.
- Add Task FK with onDelete: Cascade to VotedGradeCounter and VotedGradeStatistics (high)
- Change VoteGrade task FK from RESTRICT to Cascade
- Fix catch block to return INTERNAL_SERVER_ERROR for DB errors, not BAD_REQUEST (potential_issue)
- Reject PENDING grade in voteAbsoluteGrade action (potential_issue)
- Replace crypto.randomUUID() with deterministic task_id to fix SSR/hydration mismatch (potential_issue)
- Replace setTimeout+querySelector with tick()+bind:this+requestSubmit() (potential_issue)
- Use updateMany in decrementOldGradeCounter to avoid throw on missing row (potential_issue)
- Narrow VoteStatisticsEntry.grade from string to TaskGrade (potential_issue)
- Guard ORDER_TO_TASK_GRADE.get() against undefined in computeMedianGrade (potential_issue)
- Add TaskGrade enum validation before cast in vote_management setTaskGrade action (potential_issue)
- Wrap getVoteGradeStatistics in try/catch to prevent problems page failure on stats error (potential_issue)
- Add null-safety for data.myVote.grade in votes/[slug] page (potential_issue)
- Fix Parameters<typeof test>[1] to use Page type in e2e/votes.spec.ts (potential_issue)
- Add {#each} key expressions across vote components (nitpick)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rder overflow The outer wrapper div was a block element, expanding to full cell width. For D6 the amber border color is visible, causing an oversized box. Adding w-fit limits it to the badge's natural size. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 7
♻️ Duplicate comments (1)
src/features/votes/utils/median.ts (1)
58-67:⚠️ Potential issue | 🟡 Minor
PENDING票が混入した偶数集計でRangeErrorが発生する
PENDING(order=9999) と通常グレードが混在する偶数票の場合、平均 order が 1-17 の範囲外になりRangeErrorが投げられます。例: D6(2票) + PENDING(2票) →
(17+9999)/2 = 5008→ マップに存在せずエラー現状、
voteAbsoluteGradeでPENDING投票は拒否されているため実運用では発生しにくいですが、防御的に事前フィルタを追加することを推奨します。🛡️ 修正案
export function computeMedianGrade(counters: GradeCounter[], minVotes = 3): TaskGrade | null { + // Filter out PENDING grades as they cannot be meaningfully averaged + const validCounters = counters.filter(c => c.grade !== TaskGrade.PENDING); - const total = counters.reduce((sum, counter) => sum + counter.count, 0); + const total = validCounters.reduce((sum, counter) => sum + counter.count, 0); if (total < minVotes) { return null; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/votes/utils/median.ts` around lines 58 - 67, The median calculation can produce out-of-range medianOrder when PENDING (order 9999) is included; before computing total/positions call getGradeOrderAtPosition, filter out any orders equal to the PENDING sentinel (9999) from the votes array (or from whatever source used to compute total/getGradeOrderAtPosition), recompute total (and handle the empty-after-filter case by returning a sensible fallback or throwing a clear error), then proceed to compute medianOrder and lookup ORDER_TO_TASK_GRADE; update the logic around medianOrder/getGradeOrderAtPosition/ORDER_TO_TASK_GRADE (e.g., in computeMedianGrade) to operate on the filtered list so no 9999 values are averaged into the result.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@prisma/schema.prisma`:
- Around line 243-283: Add DB-level CHECK constraints to enforce that
VoteGrade.grade, VotedGradeCounter.grade, and VotedGradeStatistics.grade cannot
be 'PENDING' and that VotedGradeCounter.count is non-negative: update the Prisma
schema by adding @@map or raw SQL migration steps that create CHECKs for the
three models (VoteGrade.grade, VotedGradeCounter.grade,
VotedGradeStatistics.grade) and for VotedGradeCounter.count >= 0 in the
generated migration.sql, then run migrate to apply; finally document the exact
CHECK SQL you added in prisma/ERD.md and note that TaskGrade mapping treats
'PENDING' as non-votable so the constraints reflect that.
In `@src/features/votes/actions/vote_actions.ts`:
- Around line 47-53: The function currently calls await
upsertVoteGradeTables(userId, taskId, grade) but returns nothing on success;
after the await, add an explicit success return (e.g., return { success: true }
or return { message: 'Vote recorded' }) so the client can detect success; keep
the existing catch branch that uses fail(INTERNAL_SERVER_ERROR, ...) unchanged
and ensure the explicit return is placed immediately after the
upsertVoteGradeTables(...) call.
In `@src/features/votes/components/VotableGrade.svelte`:
- Around line 44-57: The async race causes stale GET/POST responses to overwrite
the displayed grade because shared state selectedVoteGrade/votedGrade is reused;
modify onTriggerClick (and the other handlers referenced at 65-71, 81-99,
186-206) to capture the grade into a local variable (e.g., localGrade) before
awaiting tick()/fetch/POST and attach a per-request sentinel (incrementing
requestId or a unique token stored on the component) for each network call; when
each response arrives, verify the response's token matches the latest requestId
before mutating shared state (votedGrade/selectedVoteGrade) so older responses
are ignored and each POST uses the localGrade captured at send time.
- Around line 44-127: Extract all network and business logic out of the
component: move the fetch/getMyVote logic from onTriggerClick into a new utility
function (e.g., fetchMyVote(taskId): Promise<{grade?: string}>), move the POST
submission and related success/error handling from handleSubmit into a utility
(e.g., submitVote(formData: FormData): Promise<void> or return response), and
move the median refresh logic into fetchMedianVote(taskId): Promise<{grade?:
TaskGrade}>; then update onTriggerClick to call fetchMyVote and set votedGrade
from its result, and replace the inline fetch in handleSubmit with a call to
submitVote followed by fetchMedianVote to update displayGrade, with utilities
responsible for throwing or returning structured errors so the component only
handles UI state (isOpening, showForm,
errorMessageStore.setAndClearAfterTimeout, resetDropdown); ensure functions
names referenced (onTriggerClick, handleSubmit, handleClick, resetDropdown,
getTaskGrade) remain and import the new utils.
In `@src/features/votes/services/vote_grade.ts`:
- Around line 35-49: The code reads the existing VoteGrade then updates
counters, which races on concurrent requests; fix by acquiring a row-level lock
for the (userId, taskId) key at the start of the transaction (e.g. run a SELECT
... FOR UPDATE for that VoteGrade via prisma transaction raw SQL like
tx.$queryRaw`SELECT id, grade FROM "VoteGrade" WHERE "userId" = ${userId} AND
"taskId" = ${taskId} FOR UPDATE`), then re-read the locked row (or insert a
placeholder row if none to lock the key) and only then call
decrementOldGradeCounter(tx,...), upsertVoteRecord(tx, userId, taskId, grade)
and incrementNewGradeCounter(tx,...); this serializes concurrent updates to the
same (userId, taskId) and prevents double-counting while keeping the existing
helper functions.
In `@src/routes/`(admin)/vote_management/+page.server.ts:
- Line 54: The call to updateTask(taskId, grade) may let Prisma throw
RecordNotFound for non-existent taskIds; wrap the update in a presence check or
try/catch: either call isExistsTask(taskId) first and respond with a 404-style
error if false, or catch errors from updateTask and convert RecordNotFound into
a 404 response (rethrow or return a controlled error) so the admin page returns
a proper 404 instead of a 500; reference updateTask and isExistsTask when
applying the change.
In `@src/routes/votes/`[slug]/+page.svelte:
- Around line 62-64: Replace the hardcoded "3" in the UI text with the shared
vote-threshold constant used by the vote logic; import the constant exported
from src/features/votes/services/vote_grade.ts (the threshold symbol defined
around the median-vote logic) into src/routes/votes/[slug]/+page.svelte and
interpolate that constant into the paragraph so the displayed threshold always
matches the implementation's constant (use the existing constant name from
vote_grade.ts when importing).
---
Duplicate comments:
In `@src/features/votes/utils/median.ts`:
- Around line 58-67: The median calculation can produce out-of-range medianOrder
when PENDING (order 9999) is included; before computing total/positions call
getGradeOrderAtPosition, filter out any orders equal to the PENDING sentinel
(9999) from the votes array (or from whatever source used to compute
total/getGradeOrderAtPosition), recompute total (and handle the
empty-after-filter case by returning a sensible fallback or throwing a clear
error), then proceed to compute medianOrder and lookup ORDER_TO_TASK_GRADE;
update the logic around medianOrder/getGradeOrderAtPosition/ORDER_TO_TASK_GRADE
(e.g., in computeMedianGrade) to operate on the filtered list so no 9999 values
are averaged into the result.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: a59527a6-c376-49d7-9d32-a35b17e96be5
📒 Files selected for processing (14)
e2e/votes.spec.tsprisma/ERD.mdprisma/migrations/20260326140100_add_task_fk_cascade_to_vote_tables/migration.sqlprisma/schema.prismasrc/features/votes/actions/vote_actions.tssrc/features/votes/components/VotableGrade.sveltesrc/features/votes/services/vote_grade.test.tssrc/features/votes/services/vote_grade.tssrc/features/votes/types/vote_result.tssrc/features/votes/utils/median.tssrc/routes/(admin)/vote_management/+page.server.tssrc/routes/(admin)/vote_management/+page.sveltesrc/routes/problems/+page.server.tssrc/routes/votes/[slug]/+page.svelte
| 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") | ||
| } |
There was a problem hiding this comment.
投票系テーブルの不変条件を DB でも強制してください。
VoteGrade.grade/VotedGradeCounter.grade/VotedGradeStatistics.gradeにPENDINGを保存できるVotedGradeCounter.countが負数でも DB は拒否しない
いまは action/service のバリデーションだけに依存しているので、別経路の write や将来の回帰で集計が壊れます。migration に CHECK 制約を追加し、追加した制約は prisma/ERD.md にも反映してください。
As per coding guidelines, ensure TaskGrade-typed values map consistently across these three tables (including treating PENDING as non-votable in vote actions where applicable); CHECK constraints added manually to migration.sql must be documented in prisma/ERD.md.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@prisma/schema.prisma` around lines 243 - 283, Add DB-level CHECK constraints
to enforce that VoteGrade.grade, VotedGradeCounter.grade, and
VotedGradeStatistics.grade cannot be 'PENDING' and that VotedGradeCounter.count
is non-negative: update the Prisma schema by adding @@map or raw SQL migration
steps that create CHECKs for the three models (VoteGrade.grade,
VotedGradeCounter.grade, VotedGradeStatistics.grade) and for
VotedGradeCounter.count >= 0 in the generated migration.sql, then run migrate to
apply; finally document the exact CHECK SQL you added in prisma/ERD.md and note
that TaskGrade mapping treats 'PENDING' as non-votable so the constraints
reflect that.
| 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.' }); | ||
| } | ||
| }; |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
成功時の明示的な戻り値がありません
upsertVoteGradeTables 成功後、関数が return なしで終了しています。SvelteKit では暗黙的に undefined が返りますが、クライアント側で成功を明示的に判定できるよう戻り値を追加することを推奨します。
♻️ 修正案
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.' });
}
+
+ return { success: true };
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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.' }); | |
| } | |
| }; | |
| 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.' }); | |
| } | |
| return { success: true }; | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/features/votes/actions/vote_actions.ts` around lines 47 - 53, The
function currently calls await upsertVoteGradeTables(userId, taskId, grade) but
returns nothing on success; after the await, add an explicit success return
(e.g., return { success: true } or return { message: 'Vote recorded' }) so the
client can detect success; keep the existing catch branch that uses
fail(INTERNAL_SERVER_ERROR, ...) unchanged and ensure the explicit return is
placed immediately after the upsertVoteGradeTables(...) call.
| async function onTriggerClick() { | ||
| if (isOpening) return; | ||
| isOpening = true; | ||
| try { | ||
| const res = await fetch( | ||
| `/problems/getMyVote?taskId=${encodeURIComponent(taskResult.task_id)}`, | ||
| { | ||
| headers: { Accept: 'application/json' }, | ||
| }, | ||
| ); | ||
| if (res.ok) { | ||
| const data = await res.json(); | ||
| votedGrade = data.grade; | ||
| } |
There was a problem hiding this comment.
非同期競合で別のグレードが送信・表示されます。
selectedVoteGrade を共有状態のまま tick() 後の送信と POST 成功後の反映に使っており、さらに 44-57 行の GET も votedGrade を上書きします。連打や再選択が重なると、最初にクリックした値とは別の grade が送信されたり、後から返った古い応答でチェック表示が巻き戻ります。送信ごとに grade をローカルに保持し、古い GET/POST 応答は無視する必要があります。
Also applies to: 65-71, 81-99, 186-206
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/features/votes/components/VotableGrade.svelte` around lines 44 - 57, The
async race causes stale GET/POST responses to overwrite the displayed grade
because shared state selectedVoteGrade/votedGrade is reused; modify
onTriggerClick (and the other handlers referenced at 65-71, 81-99, 186-206) to
capture the grade into a local variable (e.g., localGrade) before awaiting
tick()/fetch/POST and attach a per-request sentinel (incrementing requestId or a
unique token stored on the component) for each network call; when each response
arrives, verify the response's token matches the latest requestId before
mutating shared state (votedGrade/selectedVoteGrade) so older responses are
ignored and each POST uses the localGrade captured at send time.
| async function onTriggerClick() { | ||
| if (isOpening) return; | ||
| isOpening = true; | ||
| try { | ||
| const res = await fetch( | ||
| `/problems/getMyVote?taskId=${encodeURIComponent(taskResult.task_id)}`, | ||
| { | ||
| headers: { Accept: 'application/json' }, | ||
| }, | ||
| ); | ||
| if (res.ok) { | ||
| const data = await res.json(); | ||
| votedGrade = data.grade; | ||
| } | ||
| } catch (err) { | ||
| console.error(err); | ||
| } finally { | ||
| isOpening = false; | ||
| } | ||
| } | ||
|
|
||
| async function handleClick(voteGrade: string): Promise<void> { | ||
| selectedVoteGrade = getTaskGrade(voteGrade); | ||
| showForm = true; | ||
| // Wait for Svelte to render the form, then submit via the enhance directive. | ||
| await tick(); | ||
| formElement?.requestSubmit(); | ||
| } | ||
|
|
||
| type EnhanceForVote = { | ||
| formData: FormData; | ||
| action: URL; | ||
| cancel: () => void; | ||
| }; | ||
|
|
||
| const FAILED_TO_UPDATE_VOTE_STATUS = '投票状況の更新に失敗しました。もう一度試してください。'; | ||
|
|
||
| const handleSubmit = () => { | ||
| return ({ formData, action, cancel }: EnhanceForVote) => { | ||
| // Cancel the default form submission. | ||
| cancel(); | ||
|
|
||
| // Submit data manually using fetch API. | ||
| fetch(action, { | ||
| method: 'POST', | ||
| body: formData, | ||
| headers: { | ||
| Accept: 'application/json', | ||
| }, | ||
| }) | ||
| .then(async (res) => { | ||
| if (!res.ok) throw new Error('vote failed'); | ||
|
|
||
| // 投票したグレードをローカル状態に反映(チェックマーク更新) | ||
| votedGrade = selectedVoteGrade ?? null; | ||
|
|
||
| // 成功したらサーバから最新の中央値を取得して表示を更新 | ||
| try { | ||
| const taskId = formData.get('taskId') as string; | ||
| const medianRes = await fetch( | ||
| `/problems/getMedianVote?taskId=${encodeURIComponent(taskId)}`, | ||
| { headers: { Accept: 'application/json' } }, | ||
| ); | ||
| if (medianRes.ok) { | ||
| const data = await medianRes.json(); | ||
| // DBグレード付与済みはそちらを優先表示するため更新しない | ||
| if (data?.grade && taskResult.grade === TaskGrade.PENDING) displayGrade = data.grade; | ||
| } | ||
| } catch (err) { | ||
| console.error('Failed to fetch median after vote', err); | ||
| } | ||
| }) | ||
| .catch((error) => { | ||
| console.error('Failed to update submission status: ', error); | ||
| errorMessageStore.setAndClearAfterTimeout(FAILED_TO_UPDATE_VOTE_STATUS, 10000); | ||
| }) | ||
| .finally(() => { | ||
| resetDropdown(); | ||
| }); | ||
|
|
||
| // Do not change anything in SvelteKit. | ||
| return () => {}; | ||
| }; | ||
| }; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
投票フローの I/O は utils/ へ切り出してください。
投票取得・送信・中央値再取得・エラーハンドリングまでこの <script> に入っており、UI state と業務ロジックが密結合です。コンポーネント側は表示制御に寄せたほうが、テスト性と再利用性が上がります。
As per coding guidelines, Business logic belongs in utils/, not inside <script> blocks.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/features/votes/components/VotableGrade.svelte` around lines 44 - 127,
Extract all network and business logic out of the component: move the
fetch/getMyVote logic from onTriggerClick into a new utility function (e.g.,
fetchMyVote(taskId): Promise<{grade?: string}>), move the POST submission and
related success/error handling from handleSubmit into a utility (e.g.,
submitVote(formData: FormData): Promise<void> or return response), and move the
median refresh logic into fetchMedianVote(taskId): Promise<{grade?: TaskGrade}>;
then update onTriggerClick to call fetchMyVote and set votedGrade from its
result, and replace the inline fetch in handleSubmit with a call to submitVote
followed by fetchMedianVote to update displayGrade, with utilities responsible
for throwing or returning structured errors so the component only handles UI
state (isOpening, showForm, errorMessageStore.setAndClearAfterTimeout,
resetDropdown); ensure functions names referenced (onTriggerClick, handleSubmit,
handleClick, resetDropdown, getTaskGrade) remain and import the new utils.
| 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); |
There was a problem hiding this comment.
同一ユーザーの並行投票でカウンタが壊れます。
ここは existing を読んでから旧 grade の減算・VoteGrade upsert・新 grade の加算に進むため、同じ (userId, taskId) への POST が同時に来ると双方が同じ旧状態を前提に更新できます。新規投票なら VoteGrade は 1 行でも counter だけ 2 増、再投票なら旧 grade の減算や新 grade の加算が二重化し得るので、同一キーの更新は直列化が必要です。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/features/votes/services/vote_grade.ts` around lines 35 - 49, The code
reads the existing VoteGrade then updates counters, which races on concurrent
requests; fix by acquiring a row-level lock for the (userId, taskId) key at the
start of the transaction (e.g. run a SELECT ... FOR UPDATE for that VoteGrade
via prisma transaction raw SQL like tx.$queryRaw`SELECT id, grade FROM
"VoteGrade" WHERE "userId" = ${userId} AND "taskId" = ${taskId} FOR UPDATE`),
then re-read the locked row (or insert a placeholder row if none to lock the
key) and only then call decrementOldGradeCounter(tx,...), upsertVoteRecord(tx,
userId, taskId, grade) and incrementNewGradeCounter(tx,...); this serializes
concurrent updates to the same (userId, taskId) and prevents double-counting
while keeping the existing helper functions.
| ) { | ||
| return { success: false }; | ||
| } | ||
| await updateTask(taskId, grade as TaskGrade); |
There was a problem hiding this comment.
updateTask が存在しない taskId で例外を投げる可能性あり
updateTask は内部で存在確認を行わず、Prisma の update がそのまま実行されます(context snippet 3 参照)。存在しない taskId の場合、Prisma が RecordNotFound を投げ、クライアントには 500 が返ります。
- 管理者専用ページのため重大度は低いが、UX 向上のため try/catch で 404 相当を返すか、事前に
isExistsTaskで確認することを推奨
🛡️ 修正案
+ try {
await updateTask(taskId, grade as TaskGrade);
+ } catch (error) {
+ console.error('Failed to update task grade:', error);
+ return { success: false };
+ }
return { success: true };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| await updateTask(taskId, grade as TaskGrade); | |
| try { | |
| await updateTask(taskId, grade as TaskGrade); | |
| } catch (error) { | |
| console.error('Failed to update task grade:', error); | |
| return { success: false }; | |
| } | |
| return { success: true }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/routes/`(admin)/vote_management/+page.server.ts at line 54, The call to
updateTask(taskId, grade) may let Prisma throw RecordNotFound for non-existent
taskIds; wrap the update in a presence check or try/catch: either call
isExistsTask(taskId) first and respond with a 404-style error if false, or catch
errors from updateTask and convert RecordNotFound into a 404 response (rethrow
or return a controlled error) so the admin page returns a proper 404 instead of
a 500; reference updateTask and isExistsTask when applying the change.
| <p class="text-sm text-gray-500 dark:text-gray-400 mb-4"> | ||
| ※ 3票以上集まると中央値が暫定グレードとして一覧表に反映されます。 | ||
| </p> |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
統計反映の閾値 3 は共有定数にしてください。
同じ条件が src/features/votes/services/vote_grade.ts の Line 27 にもあります。ここだけ文言が取り残されると UI と実装がずれるので、共有定数から表示したほうが安全です。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/routes/votes/`[slug]/+page.svelte around lines 62 - 64, Replace the
hardcoded "3" in the UI text with the shared vote-threshold constant used by the
vote logic; import the constant exported from
src/features/votes/services/vote_grade.ts (the threshold symbol defined around
the median-vote logic) into src/routes/votes/[slug]/+page.svelte and interpolate
that constant into the paragraph so the displayed threshold always matches the
implementation's constant (use the existing constant name from vote_grade.ts
when importing).
- Skip getMyVote API call when user is not logged in to avoid unnecessary 401s - Use resolve() for /votes/[slug] href in dropdown to satisfy no-navigation-without-resolve - Guard getMedianVote endpoint: require auth and verify user has voted before returning stats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/features/votes/components/VotableGrade.svelte`:
- Around line 88-94: fetch 呼び出しで credentials オプションが未指定のためセッション Cookie
が送信されない可能性があります。VotableGrade.svelte の該当する fetch 呼び出し(現在のブロックおよびファイル内の他の fetch
呼び出し、例えばコンポーネント内の最初の fetch と最後の fetch)に credentials: 'same-origin'
を追加して認証情報を明示的に送るように修正してください。fetch のオプションオブジェクトに headers の横に credentials:
'same-origin' を追加し、既存のメソッドや body の構成はそのまま維持してください。
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: ab5afad1-af89-4b79-b826-14d23980c144
📒 Files selected for processing (2)
src/features/votes/components/VotableGrade.sveltesrc/routes/problems/getMedianVote/+server.ts
| fetch(action, { | ||
| method: 'POST', | ||
| body: formData, | ||
| headers: { | ||
| Accept: 'application/json', | ||
| }, | ||
| }) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
fetch に credentials オプションが未指定
認証情報(セッションCookie)を送信するため、credentials: 'same-origin' を明示的に指定することを推奨。ブラウザのデフォルト動作に依存すると環境差異でセッションが送られない可能性がある。
♻️ 修正案
fetch(action, {
method: 'POST',
body: formData,
headers: {
Accept: 'application/json',
},
+ credentials: 'same-origin',
})Line 49-54 と Line 104-107 の fetch にも同様に追加:
const res = await fetch(
`/problems/getMyVote?taskId=${encodeURIComponent(taskResult.task_id)}`,
{
headers: { Accept: 'application/json' },
+ credentials: 'same-origin',
},
); const medianRes = await fetch(
`/problems/getMedianVote?taskId=${encodeURIComponent(taskId)}`,
- { headers: { Accept: 'application/json' } },
+ { headers: { Accept: 'application/json' }, credentials: 'same-origin' },
);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| fetch(action, { | |
| method: 'POST', | |
| body: formData, | |
| headers: { | |
| Accept: 'application/json', | |
| }, | |
| }) | |
| fetch(action, { | |
| method: 'POST', | |
| body: formData, | |
| headers: { | |
| Accept: 'application/json', | |
| }, | |
| credentials: 'same-origin', | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/features/votes/components/VotableGrade.svelte` around lines 88 - 94,
fetch 呼び出しで credentials オプションが未指定のためセッション Cookie
が送信されない可能性があります。VotableGrade.svelte の該当する fetch 呼び出し(現在のブロックおよびファイル内の他の fetch
呼び出し、例えばコンポーネント内の最初の fetch と最後の fetch)に credentials: 'same-origin'
を追加して認証情報を明示的に送るように修正してください。fetch のオプションオブジェクトに headers の横に credentials:
'same-origin' を追加し、既存のメソッドや body の構成はそのまま維持してください。
KATO-Hiro
left a comment
There was a problem hiding this comment.
遅くなりましたが、LGTMです
マージします
投票機能(絶対評価)を実装しました。
主に以下の機能を追加しました。
レビューの方よろしくお願いいたします。
Summary by CodeRabbit