diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..1e105cd36 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# AtCoder affiliation confirmation API endpoint +# Set this to the actual endpoint URL in your .env file (do not commit real URLs here) +CONFIRM_API_URL=https://your-confirm-api-endpoint.example.com/confirm diff --git a/compose.yaml b/compose.yaml index 481d09d77..ca78cccf6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,7 +12,7 @@ services: - NODE_ENV=development - DATABASE_URL=postgresql://db_user:db_password@db:5432/test_db?pgbouncer=true&connection_limit=10&connect_timeout=60&statement_timeout=60000 # Note: Local server cannot start if port is set to db:6543. - DIRECT_URL=postgresql://db_user:db_password@db:5432/test_db - - CONFIRM_API_URL=https://prettyhappy.sakura.ne.jp/php_curl/index.php + - CONFIRM_API_URL=${CONFIRM_API_URL:?CONFIRM_API_URL environment variable is required} # AtCoder affiliation confirmation API endpoint command: sleep infinity depends_on: - db diff --git a/docs/dev-notes/2026-03-27/atcoder-account-model/plan.md b/docs/dev-notes/2026-03-27/atcoder-account-model/plan.md new file mode 100644 index 000000000..d66bfb847 --- /dev/null +++ b/docs/dev-notes/2026-03-27/atcoder-account-model/plan.md @@ -0,0 +1,495 @@ +# AtCoder アカウントモデル分割・コード集約 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** + +1. `User` モデルから AtCoder 関連フィールドを `AtCoderAccount` モデルに切り出す +2. アカウント関連のコードを `src/features/account` に集約する + +**Architecture:** + +- `AtCoderAccount` は `User` との 1 対 0-or-1 リレーション。`userId` を PK として使い、独立した ID フィールドを持たない(結合キーが重複しないため) +- `hooks.server.ts` は `getUser` の戻り値に含まれる `atCoderAccount` を参照して `is_validated` を設定する +- サービス層は `db.atCoderAccount.upsert` を使う(AtCoder 未登録ユーザーのレコードが存在しない可能性があるため) +- 認証用 URL(`https://prettyhappy.sakura.ne.jp/...`)はこのタスクでは変更しない(認証サーバー移行は別 issue) + +**Tech Stack:** SvelteKit 2 + Svelte 5 Runes, TypeScript, Prisma, Vitest (unit) + +--- + +## ファイル変更一覧 + +| 操作 | ファイル | 変更内容 | +| ---- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| 修正 | `prisma/schema.prisma` | `AtCoderAccount` モデル追加、`User` から AtCoder フィールド削除 | +| 新規 | `prisma/migrations/YYYYMMDD_split_atcoder_account/migration.sql` | テーブル作成・データ移行・旧カラム削除 | +| 修正 | `src/lib/services/users.ts` | `getUser` / `getUserById` に `include: { atCoderAccount: true }` 追加、`updateValicationCode` 削除 | +| 修正 | `src/hooks.server.ts` | `user.atCoderAccount?.isValidated` を参照するよう更新 | +| 新規 | `src/features/account/services/atcoder_verification.ts` | `validateApiService.ts` の移行先(AtCoderAccount CRUD に書き換え) | +| 削除 | `src/lib/services/validateApiService.ts` | 移行後に削除 | +| 修正 | `src/routes/users/edit/+page.server.ts` | import パスを新 service に更新、load 戻り値を AtCoderAccount 参照に変更 | +| 新規 | `src/features/account/components/settings/AtCoderVerificationForm.svelte` | `AtCoderUserValidationForm.svelte` の移行先 | +| 削除 | `src/lib/components/AtCoderUserValidationForm.svelte` | 移行後に削除 | +| 新規 | `src/features/account/components/delete/AccountDeletionForm.svelte` | `UserAccountDeletionForm.svelte` の移行先 | +| 新規 | `src/features/account/components/delete/WarningMessageOnDeletingAccount.svelte` | `WarningMessageOnDeletingAccount.svelte` の移行先 | +| 削除 | `src/lib/components/UserAccountDeletionForm.svelte` | 移行後に削除 | +| 削除 | `src/lib/components/WarningMessageOnDeletingAccount.svelte` | 移行後に削除 | +| 修正 | `src/routes/users/edit/+page.svelte` | import パスを新コンポーネントに更新 | +| 修正 | `src/test/lib/utils/test_cases/account_transfer.ts` | AtCoder フィールド参照を `atCoderAccount` に更新 | + +--- + +## フェーズ概要 + +| フェーズ | 内容 | リスク | +| -------- | -------------------------------------------------------------- | ---------------------------------- | +| Phase 1 | `AtCoderAccount` モデル追加・マイグレーション | 高(DB スキーマ変更・データ移行) | +| Phase 2 | サービス層の更新(users.ts / hooks.server.ts) | 中(全リクエストに影響する hooks) | +| Phase 3 | `atcoder_verification.ts` サービス実装と `page.server.ts` 更新 | 中(外部 API 連携) | +| Phase 4 | コンポーネント移行と import パス更新 | 低(純粋な移動) | + +--- + +## 設計上の判断と却下した代替案 + +### AtCoderAccount の PK 設計 + +- **採用**: `userId String @id`(User の id をそのまま PK として使う) +- **却下**: `id String @id @default(cuid())` + `userId String @unique` → 冗長。1:1 リレーションでは userId を PK にする方がシンプル + +### データ移行方針 + +- **採用**: Migration SQL 内で `INSERT INTO atcoder_account SELECT ... FROM user WHERE atcoder_username != ''`(既存データのある行のみ移行) +- **却下**: アプリケーション側でシード処理 → マイグレーションとデータ移行が分離し、Prisma の冪等性保証が崩れる + +### `getUser` での AtCoderAccount 取得 + +- **採用**: `include: { atCoderAccount: true }` を常に付ける(hooks.server.ts で毎リクエスト呼ぶ関数に統一) +- **却下**: AtCoderAccount を別クエリで取得 → N+1 問題。JOIN の方が効率的 + +### `validateApiService.ts` の upsert + +- **採用**: `db.atCoderAccount.upsert({ where: { userId }, create: {...}, update: {...} })` → ユーザーが未登録でも create が走る +- **却下**: `create` / `update` を条件分岐 → 複雑になる + +### コンポーネントのリネーム + +- `AtCoderUserValidationForm` → `AtCoderVerificationForm`(`User` という語を除去しドメインを明確化) +- `UserAccountDeletionForm` → `AccountDeletionForm`(同上) + +--- + +## Phase 1: `AtCoderAccount` モデル追加・マイグレーション + +**Files:** + +- Modify: `prisma/schema.prisma` +- Create: migration SQL + +### スキーマ変更 + +- [ ] **Step 1: `prisma/schema.prisma` を修正する** + +```prisma +// 追加 +model AtCoderAccount { + userId String @id + handle String @default("") + isValidated Boolean @default(false) + validationCode String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("atcoder_account") +} + +// User モデルを修正 +model User { + id String @id @unique + username String @unique + role Roles @default(USER) + // atcoder_* フィールドをすべて削除 + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + auth_session Session[] + key Key[] + taskAnswer TaskAnswer[] + workBooks WorkBook[] + voteGrade VoteGrade[] + atCoderAccount AtCoderAccount? // 追加 + + @@map("user") +} +``` + +- [ ] **Step 2: マイグレーションを作成する** + +```bash +docker exec atcodernovisteps-web-1 pnpm exec prisma migrate dev --name split_atcoder_account +``` + +Prisma が自動生成するマイグレーション SQL に **データ移行 SQL を手動で追記する**: + +```sql +-- 既存の AtCoder データを移行(atcoder_username が空でないユーザーのみ) +INSERT INTO "atcoder_account" ("userId", "handle", "isValidated", "validationCode", "createdAt", "updatedAt") +SELECT + "id", + "atcoder_username", + COALESCE("atcoder_validation_status", false), + "atcoder_validation_code", + NOW(), + NOW() +FROM "user" +WHERE "atcoder_username" != ''; +``` + +注意: Prisma の自動生成 SQL は `DROP COLUMN` を最後に置くので、`INSERT INTO` は `DROP COLUMN` の前に手動挿入すること。 + +- [ ] **Step 3: Prisma クライアントを再生成する** + +```bash +docker exec atcodernovisteps-web-1 pnpm exec prisma generate +``` + +- [ ] **Step 4: lint・型チェックを実行する** + +```bash +docker exec atcodernovisteps-web-1 pnpm lint +docker exec atcodernovisteps-web-1 pnpm check +``` + +期待: エラーなし(まだ services/hooks が旧フィールドを参照しているため型エラーが出る可能性あり。次フェーズで解消) + +- [ ] **Step 5: コミットする** + +```bash +git add prisma/schema.prisma prisma/migrations/ +git commit -m "feat: add AtCoderAccount model and migrate data from User" +``` + +--- + +## Phase 2: サービス層の更新(users.ts / hooks.server.ts) + +**Files:** + +- Modify: `src/lib/services/users.ts` +- Modify: `src/hooks.server.ts` + +### users.ts の変更 + +`getUser` と `getUserById` に `include: { atCoderAccount: true }` を追加し、`updateValicationCode` を削除する(`validateApiService.ts` で AtCoderAccount を直接更新するため不要)。 + +- [ ] **Step 1: `src/lib/services/users.ts` を修正する** + +```typescript +import { default as db } from '$lib/server/database'; + +export async function getUser(username: string) { + return await db.user.findUnique({ + where: { username }, + include: { atCoderAccount: true }, + }); +} + +export async function getUserById(userId: string) { + return await db.user.findUnique({ + where: { id: userId }, + include: { atCoderAccount: true }, + }); +} + +export async function deleteUser(username: string) { + return await db.user.delete({ where: { username } }); +} +``` + +### hooks.server.ts の変更 + +`user.atcoder_validation_status` → `user.atCoderAccount?.isValidated` に変更。 + +- [ ] **Step 2: `src/hooks.server.ts` を修正する** + +```typescript +event.locals.user = { + id: user.id, + name: user.username, + role: user.role, + atcoder_name: user.atCoderAccount?.handle ?? '', + is_validated: user.atCoderAccount?.isValidated ?? null, +}; +``` + +- [ ] **Step 3: lint・型チェック・unit tests を実行する** + +```bash +docker exec atcodernovisteps-web-1 pnpm lint +docker exec atcodernovisteps-web-1 pnpm check +docker exec atcodernovisteps-web-1 pnpm test:unit +``` + +期待: エラーなし、全テスト通過 + +- [ ] **Step 4: コミットする** + +```bash +git add src/lib/services/users.ts src/hooks.server.ts +git commit -m "refactor: update users.ts and hooks.server.ts to use AtCoderAccount relation" +``` + +--- + +## Phase 3: `atcoder_verification.ts` サービス実装と `page.server.ts` 更新 + +**Files:** + +- Create: `src/features/account/services/atcoder_verification.ts` +- Delete: `src/lib/services/validateApiService.ts` +- Modify: `src/routes/users/edit/+page.server.ts` +- Modify: `src/test/lib/utils/test_cases/account_transfer.ts`(AtCoder フィールド参照の更新) + +### atcoder_verification.ts の実装 + +`validateApiService.ts` の実装を `AtCoderAccount` を使うよう書き換える。 + +- [ ] **Step 1: `src/features/account/services/atcoder_verification.ts` を作成する** + +```typescript +import { default as db } from '$lib/server/database'; +import { sha256 } from '$lib/utils/hash'; + +const CONFIRM_API_URL = 'https://prettyhappy.sakura.ne.jp/php_curl/index.php'; + +/** Calls the external API to check if the validation code appears in the user's AtCoder affiliation. */ +async function confirmWithExternalApi(handle: string, validationCode: string): Promise { + const url = `${CONFIRM_API_URL}?user=${handle}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Network response was not ok.'); + } + + const jsonData = await response.json(); + return jsonData.contents?.some((item: string) => item === validationCode) ?? false; +} + +/** + * Generates a SHA256 validation code, stores it in AtCoderAccount, and returns the code. + * Creates the AtCoderAccount record if it does not exist yet. + */ +export async function generate(username: string, handle: string): Promise { + const date = new Date().toISOString(); + const validationCode = await sha256(username + date); + + const user = await db.user.findUniqueOrThrow({ where: { username } }); + + await db.atCoderAccount.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + handle, + validationCode, + isValidated: false, + }, + update: { + handle, + validationCode, + isValidated: false, + }, + }); + + return validationCode; +} + +/** + * Checks the external API and, if confirmed, marks the AtCoderAccount as validated. + * @returns true if validation succeeded, false otherwise. + */ +export async function validate(username: string): Promise { + const user = await db.user.findUniqueOrThrow({ + where: { username }, + include: { atCoderAccount: true }, + }); + + if (!user.atCoderAccount) { + return false; + } + + const confirmed = await confirmWithExternalApi( + user.atCoderAccount.handle, + user.atCoderAccount.validationCode, + ); + + if (!confirmed) { + return false; + } + + await db.atCoderAccount.update({ + where: { userId: user.id }, + data: { validationCode: '', isValidated: true }, + }); + + return true; +} + +/** Deletes the AtCoderAccount record, effectively resetting the verification state. */ +export async function reset(username: string): Promise { + const user = await db.user.findUniqueOrThrow({ where: { username } }); + + await db.atCoderAccount.deleteMany({ where: { userId: user.id } }); +} +``` + +- [ ] **Step 2: `src/routes/users/edit/+page.server.ts` の import と load 戻り値を更新する** + +```typescript +import * as verificationService from '$features/account/services/atcoder_verification'; + +// load 関数の戻り値 +return { + // ... + atcoder_username: user?.atCoderAccount?.handle ?? '', + atcoder_validationcode: user?.atCoderAccount?.validationCode ?? '', + is_validated: user?.atCoderAccount?.isValidated ?? false, + // ... +}; +``` + +reset action の戻り値: + +```typescript +// reset action: void を返すので validationCode の戻り値が不要になる +reset: async ({ request }) => { + const formData = await request.formData(); + const username = formData.get('username')?.toString() as string; + const atcoder_username = formData.get('atcoder_username')?.toString() as string; + + await verificationService.reset(username); + + return { + success: true, + username, + atcoder_username, + atcoder_validationcode: '', + message_type: 'green', + message: 'Successfully reset.', + }; +}, +``` + +- [ ] **Step 3: `src/lib/services/validateApiService.ts` を削除する** + +```bash +git rm src/lib/services/validateApiService.ts +``` + +- [ ] **Step 4: `src/test/lib/utils/test_cases/account_transfer.ts` を更新する** + +`atcoder_username`・`atcoder_validation_code`・`atcoder_validation_status` フィールドの参照を確認し、`AtCoderAccount` の構造(`atCoderAccount.handle` 等)に合わせて更新する。 + +- [ ] **Step 5: lint・型チェック・unit tests を実行する** + +```bash +docker exec atcodernovisteps-web-1 pnpm lint +docker exec atcodernovisteps-web-1 pnpm check +docker exec atcodernovisteps-web-1 pnpm test:unit +``` + +期待: エラーなし、全テスト通過 + +- [ ] **Step 6: コミットする** + +```bash +git add src/features/account/services/atcoder_verification.ts \ + src/routes/users/edit/+page.server.ts \ + src/lib/services/validateApiService.ts \ + src/test/lib/utils/test_cases/account_transfer.ts +git commit -m "refactor: move AtCoder verification logic to src/features/account/services" +``` + +--- + +## Phase 4: コンポーネント移行と import パス更新 + +**Files:** + +- Create: `src/features/account/components/settings/AtCoderVerificationForm.svelte` +- Create: `src/features/account/components/delete/AccountDeletionForm.svelte` +- Create: `src/features/account/components/delete/WarningMessageOnDeletingAccount.svelte` +- Delete: `src/lib/components/AtCoderUserValidationForm.svelte` +- Delete: `src/lib/components/UserAccountDeletionForm.svelte` +- Delete: `src/lib/components/WarningMessageOnDeletingAccount.svelte` +- Modify: `src/routes/users/edit/+page.svelte` + +- [ ] **Step 1: ディレクトリを作成してコンポーネントをコピーする** + +```bash +mkdir -p src/features/account/components/settings +mkdir -p src/features/account/components/delete +``` + +ファイルをコピー後、内部の import パスを更新する: + +- `AtCoderUserValidationForm.svelte` → `AtCoderVerificationForm.svelte` + - 内部に自身の import はなし(変更不要) +- `UserAccountDeletionForm.svelte` → `AccountDeletionForm.svelte` + - `$lib/components/WarningMessageOnDeletingAccount` → `./WarningMessageOnDeletingAccount` +- `WarningMessageOnDeletingAccount.svelte` → そのままコピー(外部 import なし) + +- [ ] **Step 2: `src/routes/users/edit/+page.svelte` の import パスを更新する** + +```svelte +import AtCoderVerificationForm from +'$features/account/components/settings/AtCoderVerificationForm.svelte'; import AccountDeletionForm +from '$features/account/components/delete/AccountDeletionForm.svelte'; +``` + +(現状 AtCoderUserValidationForm はコメントアウトされているが、import 文もコメントアウトのため、コメント内パスも更新する) + +- [ ] **Step 3: 旧コンポーネントを削除する** + +```bash +git rm src/lib/components/AtCoderUserValidationForm.svelte +git rm src/lib/components/UserAccountDeletionForm.svelte +git rm src/lib/components/WarningMessageOnDeletingAccount.svelte +``` + +- [ ] **Step 4: lint・型チェック・unit tests を実行する** + +```bash +docker exec atcodernovisteps-web-1 pnpm lint +docker exec atcodernovisteps-web-1 pnpm check +docker exec atcodernovisteps-web-1 pnpm test:unit +``` + +期待: エラーなし、全テスト通過 + +- [ ] **Step 5: コミットする** + +```bash +git add src/features/account/ src/routes/users/edit/+page.svelte +git commit -m "refactor: move account components to src/features/account" +``` + +--- + +## 動作確認チェックリスト + +- [ ] `/users/edit` ページが正常に表示される +- [ ] AtCoder ID 入力 → 「文字列を生成」ボタンが機能する(DB に AtCoderAccount レコードが作成される) +- [ ] AtCoder 所属欄にコードを入力 → 「本人確認」ボタンが機能する(外部 API 呼び出し) +- [ ] 「リセット」ボタンで AtCoderAccount が削除される +- [ ] hooks.server.ts が `is_validated` を正しく設定する +- [ ] 既存ユーザーデータのマイグレーションが正常に完了している(DB 確認) + +--- + +## 注意事項 + +- **認証サーバー移行**(`https://prettyhappy.sakura.ne.jp/...` の変更)は別 issue として管理。このブランチでは URL を変更しない +- `feature/atcoder-verified-voting` ブランチが先にマージされる場合、このブランチでマージコンフリクトが発生する可能性がある(`hooks.server.ts`・`+page.server.ts` 等)。その場合は `is_validated` の参照先を `atCoderAccount?.isValidated` に保ちながら解消すること diff --git a/docs/dev-notes/2026-03-27/atcoder-verified-voting/plan.md b/docs/dev-notes/2026-03-27/atcoder-verified-voting/plan.md new file mode 100644 index 000000000..92372d3c2 --- /dev/null +++ b/docs/dev-notes/2026-03-27/atcoder-verified-voting/plan.md @@ -0,0 +1,738 @@ +# AtCoder 認証済みユーザーのみ投票可能にする 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** AtCoderアカウント認証済みのユーザーのみグレード投票を行えるようにし、未認証ユーザーをプロフィール編集ページへ誘導する。 + +**Architecture:** 既存の `locals.user.is_validated`(`hooks.server.ts` で注入済み)を活用してサーバーサイドで投票を拒否し、クライアント側 UI でも認証状態に応じた表示に切り替える。未認証時に表示する「認証が必要」プロンプトは投票に関わる 2 箇所(`/votes/[slug]` 詳細ページ・`/problems` 一覧の `VotableGrade` ドロップダウン)に追加する。さらに無効化されていた AtCoder 認証タブを `/users/edit` ページで再度有効化する。 + +**Tech Stack:** SvelteKit 2 + Svelte 5 Runes, TypeScript, Flowbite Svelte, Vitest (unit), Playwright (e2e) + +--- + +## ファイル変更一覧 + +| 操作 | ファイル | 変更内容 | +| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------ | +| 修正 | `src/lib/constants/navbar-links.ts` | `EDIT_PROFILE_PAGE` 定数を追加 | +| 修正 | `src/lib/components/AtCoderUserValidationForm.svelte` | `$bindable` を除去、editable input を local `$state` で管理 | +| 修正 | `src/routes/users/edit/+page.svelte` | AtCoder タブを再有効化、タブ選択を `$derived` で制御 | +| 修正 | `src/features/votes/actions/vote_actions.ts` | `locals.user?.is_validated` チェックを追加 (FORBIDDEN 403) | +| 修正 | `src/routes/votes/[slug]/+page.server.ts` | `isAtCoderVerified` を load 戻り値に追加 | +| 修正 | `src/routes/votes/[slug]/+page.svelte` | 未認証ユーザーへの誘導 UI を追加 | +| 修正 | `src/routes/problems/+page.server.ts` | `isAtCoderVerified` を load 戻り値に追加 | +| 修正 | `src/routes/problems/+page.svelte` | `isAtCoderVerified` を `TaskTable` に渡す | +| 修正 | `src/features/tasks/components/contest-table/TaskTable.svelte` | `isAtCoderVerified` prop を追加し `TaskTableBodyCell` に渡す | +| 修正 | `src/features/tasks/components/contest-table/TaskTableBodyCell.svelte` | `isAtCoderVerified` prop を追加し `VotableGrade` に渡す | +| 修正 | `src/features/votes/components/VotableGrade.svelte` | `isAtCoderVerified` prop を追加し未認証時に誘導 UI を表示 | +| 修正 | `src/features/votes/services/vote_grade.test.ts` | 未認証ユーザーの投票を拒否するテストを追加 | + +--- + +## フェーズ概要 + +| フェーズ | 内容 | リスク | +| -------- | ----------------------------------------------------------------------- | -------------------- | +| Phase 1 | `EDIT_PROFILE_PAGE` 定数追加 | 低 | +| Phase 2 | `AtCoderUserValidationForm` のリファクタリングと認証タブの再有効化 | 中(既存 UI 修正) | +| Phase 3 | `vote_actions.ts` にサーバーサイド認証チェックを追加 | 低 | +| Phase 4 | `/votes/[slug]` ページへの認証チェックと UI 追加 | 低 | +| Phase 5 | `/problems` ページ・コンポーネントチェーンへの `isAtCoderVerified` 伝播 | 中(多ファイル変更) | + +--- + +## 設計上の判断と却下した代替案 + +### `is_validated` の取得方法 + +- **採用**: `locals.user?.is_validated`(`hooks.server.ts` で DB から取得済みの値を再利用) +- **却下**: `vote_actions.ts` で直接 DB を呼ぶ → 不要な DB 呼び出しが増える + +### AtCoderUserValidationForm の `$bindable` 除去 + +- **採用**: 各入力値は hidden input + `value={prop}` で送信し、編集可能なフィールドのみ child の local `$state` で管理する +- **却後**: 親で `$effect` を使って `$state` を data に同期させる → AGENTS.md で `$state` + `$effect` より `$derived` を優先することが明示されているため不採用 +- **却後**: 全フィールドを `$derived` にする → フォーム入力中に書き換えができなくなる + +### タブ選択の制御方法 + +- **採用**: サーバー状態(`data.atcoder_username`, `data.atcoder_validationcode`, `data.is_validated`)から `$derived` でタブ開閉を決定する → フォーム送信後も正しいタブが表示される +- **却後**: `form?.is_tab_atcoder` フラグのみに依存する → ページリロード後に状態が失われる + +### 未認証ユーザーへの誘導 + +- **採用**: クライアント UI で投票 UI を「認証が必要です」プロンプトに置き換え、`/users/edit` へのリンクを表示 +- **却後**: 投票試行時にサーバーエラーのみを表示する → UX が悪い +- **却後**: `/users/edit` へ自動リダイレクト → 意図しないナビゲーションになる + +--- + +## Phase 1: `EDIT_PROFILE_PAGE` 定数を追加 + +**Files:** + +- Modify: `src/lib/constants/navbar-links.ts` + +- [ ] **Step 1: 定数を追加する** + +```typescript +// src/lib/constants/navbar-links.ts に追加 +export const EDIT_PROFILE_PAGE = `/users/edit`; +``` + +- [ ] **Step 2: lint を実行して問題がないことを確認する** + +```bash +docker exec atcodernovisteps-web-1 pnpm lint +``` + +期待: エラーなし + +- [ ] **Step 3: コミットする** + +```bash +git add src/lib/constants/navbar-links.ts +git commit -m "feat: add EDIT_PROFILE_PAGE constant" +``` + +--- + +## Phase 2: AtCoder 認証タブの修正と再有効化 + +**Files:** + +- Modify: `src/lib/components/AtCoderUserValidationForm.svelte` +- Modify: `src/routes/users/edit/+page.svelte` + +### 背景 + +`AtCoderUserValidationForm` の `$bindable` props がフォーム送信後のタブリセット不具合の原因。サーバーが権威的なデータソースなので、hidden input は `value={prop}` で送信すれば十分。編集可能な AtCoder ID フィールドのみ子コンポーネントの local `$state` で管理する。 + +### AtCoderUserValidationForm の変更 + +`username`・`atcoder_validationcode` の `$bindable()` を除去し、`atcoder_username` の編集可能部分は local `$state` で管理する。 + +- [ ] **Step 1: AtCoderUserValidationForm を修正する** + +```svelte + + + +{#if status === 'nothing'} + + +

+ 本人確認の準備中 +

+

AtCoder IDを入力し、本人確認用の文字列を生成してください。

+ + + + +
+
+{:else if status === 'generated'} + + +

本人確認中

+

+ AtCoderの所属欄に生成した文字列を貼り付けてから、「本人確認」ボタンを押してください。 +

+ + + + + + + +
+ + + + + +
+{:else if status === 'validated'} + + +

本人確認済

+ + + + + +
+
+{/if} +``` + +- [ ] **Step 2: `+page.svelte` を修正して AtCoder タブを再有効化する** + +変更ポイント: + +- `form` prop を追加(`svelte/valid-prop-names-in-kit-pages` では `data` と `form` のみ許可) +- `AtCoderUserValidationForm` のインポートをコメントアウト解除 +- `status`・`shouldOpenAtCoderTab` を `$derived` で導出 +- AtCoder タブの `open` prop を `shouldOpenAtCoderTab` で制御 + +```svelte + + + +{#if message_type === 'default'} + + Message: + {message} + +{:else if message_type === 'green'} + + Success alert! + Change a few things up and try submitting again. + +{/if} + +
+ + + + {#snippet titleSlot()} + 基本情報 + {/snippet} + + + + + + + + + + {#snippet titleSlot()} + AtCoder IDを設定 + {/snippet} + + + + + {#if isGeneralUser(role, username)} + + {#snippet titleSlot()} + アカウント削除 + {/snippet} + + + {/if} + +
+``` + +- [ ] **Step 3: lint と型チェックを実行する** + +```bash +docker exec atcodernovisteps-web-1 pnpm lint +docker exec atcodernovisteps-web-1 pnpm check +``` + +期待: エラーなし + +- [ ] **Step 4: コミットする** + +```bash +git add src/lib/components/AtCoderUserValidationForm.svelte src/routes/users/edit/+page.svelte +git commit -m "feat: re-enable AtCoder verification tab on /users/edit" +``` + +--- + +## Phase 3: サーバーサイド認証チェックを vote_actions に追加 + +**Files:** + +- Modify: `src/features/votes/actions/vote_actions.ts` +- Modify: `src/features/votes/services/vote_grade.test.ts` (テストのモック更新は不要 — actions テストは e2e で担う) + +### 判断 + +`locals.user` は `hooks.server.ts` が DB から取得し注入する。`is_validated` は `boolean | null` で、`true` 以外はすべて未認証として扱う。 + +- [ ] **Step 1: `vote_actions.ts` に検証チェックを追加する** + +```typescript +// セッションチェックの直後に追記 +if (!locals.user?.is_validated) { + return fail(FORBIDDEN, { + message: 'AtCoderアカウントの認証が必要です。', + }); +} +``` + +完成形: + +```typescript +import { fail } from '@sveltejs/kit'; +import { TaskGrade } from '@prisma/client'; + +import { upsertVoteGradeTables } from '$features/votes/services/vote_grade'; +import { + BAD_REQUEST, + FORBIDDEN, + INTERNAL_SERVER_ERROR, + UNAUTHORIZED, +} from '$lib/constants/http-response-status-codes'; + +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: 'ログインしていないか、もしくは、ログイン情報が不正です。', + }); + } + + if (!locals.user?.is_validated) { + return fail(FORBIDDEN, { + message: 'AtCoderアカウントの認証が必要です。', + }); + } + + 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.' }); + } +}; +``` + +- [ ] **Step 2: unit test を追加する** + +`vote_actions.ts` のサービス層に直接テストを書くのは難しい(`locals` の mock が複雑なため)が、`vote_grade.ts` の service 層テストには影響しない。action レベルは e2e でカバーする。unit test は不要。 + +- [ ] **Step 3: lint・型チェック・unit tests を実行する** + +```bash +docker exec atcodernovisteps-web-1 pnpm lint +docker exec atcodernovisteps-web-1 pnpm test:unit +``` + +期待: エラーなし、全テスト通過 + +- [ ] **Step 4: コミットする** + +```bash +git add src/features/votes/actions/vote_actions.ts +git commit -m "feat: require AtCoder verification to cast a grade vote" +``` + +--- + +## Phase 4: `/votes/[slug]` ページに認証状態チェックと誘導 UI を追加 + +**Files:** + +- Modify: `src/routes/votes/[slug]/+page.server.ts` +- Modify: `src/routes/votes/[slug]/+page.svelte` + +- [ ] **Step 1: `+page.server.ts` に `isAtCoderVerified` を追加する** + +```typescript +// load 戻り値に追加 +return { + task, + myVote, + counters, + stats, + isLoggedIn: session !== null, + isAtCoderVerified: locals.user?.is_validated === true, +}; +``` + +- [ ] **Step 2: `+page.svelte` に未認証ユーザー向け誘導 UI を追加する** + +`{:else if data.isLoggedIn}` (未投票・ログイン済み) のブロックを分割し、AtCoder 未認証の場合は認証への誘導を表示する。 + +`EDIT_PROFILE_PAGE` は `$lib/constants/navbar-links` からインポートし、`