Skip to content

Implementation Plan

本タスクは requirements.mddesign.md(特に File Structure Plan / Components and Interfaces / Data Models)を実装直前まで翻案したものである。各タスクは原則 1〜3 時間の作業単位で、design.md で定義された境界を越えない単一責務に留める。

  • [ ] 1. プロジェクト依存・設定の追加

  • [x] 1.1 (P) microCMS SDK を依存に追加

    • microcms-js-sdkpackage.jsondependencies に追加し、lockfile を更新する
    • インストール後に node_modules/microcms-js-sdk が解決され、TypeScript で import { createClient } from 'microcms-js-sdk' が型解決できることを確認する
    • Requirements: 1.5, 1.6, 3.6
    • Boundary: package.json
  • [x] 1.2 (P) Next.js 画像最適化に microCMS 配信ホストを許可

    • next.config.tsimages.remotePatterns に microCMS の画像 CDN ホストパターンを追加する
    • 候補者画像 URL を next/image で読み込んだ際に "Invalid src" エラーが発生しないことを確認する
    • 自前画像配信エンドポイントの再導入は禁止(app/api/images/[id]/** を作らない)
    • Requirements: 2.7
    • Boundary: next.config.ts
  • [ ] 2. microCMS コレクション・権限の構築(運用基盤)

  • [ ] 2.1 候補者コレクション candidates を microCMS に構築

    • displayName / region / age / height / occupation / hobbies / certifications / motto / dream / message / displayOrder / images を design.md のスキーマ表どおりに作成する
    • images は repeater 構造で image(media)+ mime(select: JPEG/PNG/WebP)を持ち、サイズ上限 5 MB を強制する
    • 公開状態モデル(公開 / 下書き / 停止)を有効化し、運営者ロールに対し物理削除を不可とするロール設定を行う
    • microCMS 管理画面でテスト用候補者を 1 件作成し、公開/下書き/停止の各状態で API レスポンスから出現/消失することを目視確認する
    • Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6
    • Boundary: microCMS candidates collection
    • Blocked: 外部 SaaS (microCMS) ダッシュボードでの手動オペレーション。コード実装ではなく運営者による管理画面操作が必要なため autonomous mode では完遂不可。運用担当者が microCMS 管理画面でスキーマ定義・ロール権限・公開状態モデルを設定後にチェックを入れる
  • [ ] 2.2 票数パッケージコレクション creditPackages を microCMS に構築

    • name / credits(1 以上)/ priceJpy(0 以上)/ displayOrder / isActive を作成する
    • name / credits / priceJpy / displayOrder を作成後編集不可のフィールド権限に、レコード物理削除を不可に設定する(isActive のみ更新可)
    • テスト用パッケージを 1 件作成し、管理画面から credits / priceJpy を編集しようとするとロールによってブロックされることを確認する
    • Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.7
    • Boundary: microCMS creditPackages collection
    • Blocked: 外部 SaaS (microCMS) ダッシュボードでの手動オペレーション。コード実装ではなく運営者による管理画面操作 (スキーマ定義 + フィールド権限 + ロール設定) が必要なため autonomous mode では完遂不可
  • [ ] 2.3 公開サイト用の読み取り専用 API キーを発行

    • candidates / creditPackages の GET のみを許可した API キーを発行し、書き込み権限が無いことを確認する
    • 発行したキーは MICROCMS_API_KEY_READONLY として Vercel 環境変数(および .env.local)に投入する手順を運用ドキュメントに残す
    • キーで GET /candidates が成功し、POST /candidates が 401/403 で拒否されることを curl で確認する
    • Requirements: 1.5, 3.6
    • Boundary: microCMS API Keys
    • Blocked: 外部 SaaS (microCMS) ダッシュボードでの手動オペレーション。API キーの発行・権限設定・環境変数投入は運用担当者の作業。autonomous mode では完遂不可
  • [ ] 3. Neon Prisma schema と初期マイグレーション

  • [x] 3.1 Prisma schema を作成

    • prisma/schema.prisma を生成し、generator client / datasource db (postgresql, env("DATABASE_URL")) を宣言する
    • PaymentStatus enum(PROCESSING / SUCCEEDED / FAILED / REFUNDED)を定義する
    • Vote / Purchase / PurchaseItem モデルを design.md の物理データモデル節どおり(カラム型・PK・FK・@updatedAt・onDelete: Restrict/Cascade)に定義する
    • 越境参照カラム(Vote.candidateId / Purchase.candidateId / PurchaseItem.creditPackageId)は String で外部キーを持たせない
    • @@unique([paymentIntentId]) / @@unique([purchaseId, creditPackageId]) を含む UNIQUE / INDEX を全て付与する
    • prisma validate が成功し、Prisma が Candidate / CandidateImage / CreditPackage / VotingPeriod / AdminUser モデルを保持していないことを確認する
    • Requirements: 4.1, 4.3, 5.1, 5.2, 5.6, 6.1, 6.3, 6.4
    • Boundary: prisma/schema.prisma
  • [x] 3.2 初期マイグレーションを生成し CHECK 制約を追加

    • prisma migrate dev --name init で初期マイグレーション SQL を生成する
    • 同マイグレーション内に ALTER TABLE purchase_items ADD CONSTRAINT purchase_items_quantity_positive CHECK (quantity >= 1); を追記する
    • ローカル DB(docker-compose の postgres:17)にマイグレーションを適用し、\d+ votes / \d+ purchases / \d+ purchase_items で全テーブル・制約・インデックスが期待通り存在することを確認する
    • Requirements: 6.2
    • Boundary: prisma/migrations
    • Depends: 3.1
  • [ ] 4. 共有クライアントと期間設定の実装

  • [x] 4.1 (P) Prisma 共有クライアント

    • lib/prisma.ts を作成し、@prisma/client@prisma/adapter-pg を組み合わせた PrismaClient シングルトンを export する
    • 開発時 HMR で複数インスタンスが生成されないよう globalThis キャッシュを採用する
    • 任意の Server Component / Server Action から import { prisma } from '@/lib/prisma' で同一インスタンスを取得できることを確認する
    • Requirements: 4.1, 4.4, 5.1, 5.2, 5.3, 5.5, 6.1, 6.4
    • Boundary: lib/prisma.ts
    • Depends: 3.2
  • [x] 4.2 (P) microCMS 共有クライアント

    • lib/microcms.ts を作成し、createClient({ serviceDomain, apiKey: process.env.MICROCMS_API_KEY_READONLY }) で生成した Client を export する
    • API キー未設定時にモジュールロード時点で明示的にエラーを throw する(読み取り専用キーの未配線を早期検出する)
    • lib/microcms.ts 経由でない直接 microcms-js-sdk 呼び出しは禁止であることをコメントで明記する
    • Requirements: 1.5, 3.6
    • Boundary: lib/microcms.ts
    • Depends: 1.1
  • [x] 4.3 (P) 投票期間ユーティリティ

    • lib/voting-period.tsVotingPeriod 型、InvalidVotingPeriodError(cause: MISSING / INVALID_FORMAT / INVERTED_RANGE)、getVotingPeriod() を実装する
    • VOTING_PERIOD_START / VOTING_PERIOD_END を ISO 8601(タイムゾーン付き)でパースし、未設定・不正書式・endsAt <= startsAt で対応する InvalidVotingPeriodError を throw する
    • モジュールスコープで結果をメモ化し、同一プロセス内では 2 回目以降の getVotingPeriod() が再パースを行わないことを確認する
    • process.env.VOTING_PERIOD_* を直接読む経路を本ファイル以外に作らないことをコメントで明記する
    • Requirements: 7.1, 7.2, 7.3, 7.4, 7.5
    • Boundary: lib/voting-period.ts
  • [ ] 5. CMS サービス層の実装

  • [x] 5.1 CMS ドメイン型の宣言

    • features/cms/types.tsCandidateImageRef / Candidate / CreditPackage を design.md と一致する Readonly 型として定義する
    • mime'image/jpeg' | 'image/png' | 'image/webp' のリテラル合併で固定する
    • 他 spec(home / candidate-detail / candidate-ranking / voting)が import type { Candidate } from '@/features/cms/types' で参照できることを確認する
    • Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.4, 3.1, 3.2, 3.5
    • Boundary: features/cms/types.ts
  • [x] 5.2 (P) 候補者取得サービス

    • features/cms/services/candidateService.tsgetCandidates() / getCandidate(id) を実装する
    • microCMS から取得した images 配列を displayOrder 順に保持し、先頭をメイン画像扱いできる順序で返す
    • 公開済みコンテンツのみが返り、下書き・停止状態のコンテンツが除外されることをテスト用 microCMS で確認する
    • fetch 直接呼び出し or React cache() ラップにより、同一 Request 内で同一引数の呼び出しが 1 回の microCMS HTTP に dedupe される(Request Memoization 契約)
    • next.revalidate = 120 のキャッシュを設定する
    • Requirements: 1.5, 1.6, 2.4
    • Boundary: features/cms/services/candidateService.ts
    • Depends: 4.2, 5.1
  • [x] 5.3 (P) 票数パッケージ取得サービス

    • features/cms/services/creditPackageService.tsgetActiveCreditPackages()filters: 'isActive[equals]true')と getCreditPackage(id)(フィルタなし、過去明細解決用)を実装する
    • getActiveCreditPackages()isActive=false のパッケージを返さないこと、getCreditPackage(id)isActive=false でも取得できることをテスト用 microCMS で確認する
    • Request Memoization 契約(同一 Request 内 1 回)と next.revalidate = 120candidateService と同じ規約で適用する
    • Requirements: 3.6, 3.7
    • Boundary: features/cms/services/creditPackageService.ts
    • Depends: 4.2, 5.1
  • [ ] 6. 投票関連サービスの実装

  • [x] 6.1 (P) ニックネーム正規化サービス

    • features/voting/services/nicknameService.tsNICKNAME_MAX_LENGTH = 32InvalidNicknameError(cause: EMPTY / TOO_LONG)、normalizeNickname(raw) を実装する
    • 正規化手順: Unicode NFKC → replace(/\s+/g, ' ') で内部空白圧縮 → trim() → 長さ 1〜32(UTF-16 コードユニット)検証
    • 空または最大長超過で InvalidNicknameError を throw する
    • 'use client' を含めず純関数モジュールとして実装し、Client Component / Server Component / Server Action / Webhook ハンドラのいずれからも import 可能であることをコメントで明記する
    • Requirements: 4.2
    • Boundary: features/voting/services/nicknameService.ts
  • [x] 6.2 (P) 投票集計サービス

    • features/candidates/services/voteAggregationService.tsgetVoteCountsByCandidateIds(candidateIds) / getTopVotersByCandidateId(candidateId, limit?) を実装する
    • getVoteCountsByCandidateIdsVote INNER JOIN Purchase ON v.purchaseId = p.id WHERE p.status='SUCCEEDED' AND v.candidateId IN (...) で GROUP BY し、Map<candidateId, count> を返す
    • getTopVotersByCandidateId は同条件でニックネーム単位に GROUP BY し、ORDER BY voteCount DESC, nickname ASClimit(既定 5)件を返す
    • PROCESSING / FAILED / REFUNDED の Purchase 由来の Vote が集計に含まれないことを単体テストで確認する
    • 戻り値が ReadonlyArray<...> / Map<...> 型契約に一致することを確認する
    • Requirements: 4.4, 4.5, 4.6, 4.7, 4.8
    • Boundary: features/candidates/services/voteAggregationService.ts
    • Depends: 4.1
  • [ ] 7. 検証

  • [x] 7.1 (P) ユニットテスト

    • normalizeNickname の境界(NFKC、全角空白、複数空白、trim、0 文字、1 文字、32 文字、33 文字)で正規化値および InvalidNicknameError を検証する
    • getVotingPeriod の未設定 / 不正書式 / endsAt <= startsAtInvalidVotingPeriodError、正常系で { startsAt, endsAt } が返ることを検証する
    • voteAggregationService の SUCCEEDED 集計、PROCESSING/FAILED/REFUNDED 除外、応援者ランキングのタイブレーク(voteCount 降順 + nickname 昇順)を検証する
    • vitest でテストが全件 green になる
    • Requirements: 4.2, 4.4, 4.5, 4.6, 4.7, 7.1, 7.2, 7.4
    • Boundary: features/, lib/
    • Depends: 4.3, 6.1, 6.2
  • [x] 7.2 (P) 統合テスト(取引データストア)

    • Purchase + PurchaseItem + Vote の同一 $transaction 内一括生成が、Vote createMany 失敗時に全てロールバックされることを確認する
    • paymentIntentId UNIQUE 違反時に冪等に振る舞う(INSERT が失敗しても既存 Purchase が壊れない)ことを確認する
    • (purchaseId, creditPackageId) UNIQUE が同一パッケージの 2 明細を拒否することを確認する
    • Purchase 削除で PurchaseItem は Cascade 削除、Vote は onDelete: Restrict で削除がブロックされることを確認する
    • 払戻時に Purchase.status のみ更新し Vote 件数が不変、集計結果から消えることを確認する
    • Requirements: 4.7, 5.1, 5.3, 5.4, 5.5, 6.3, 6.4
    • Boundary: prisma/**, features/candidates/services/voteAggregationService.ts
    • Depends: 3.2, 6.2
  • [x] 7.3 スキーマ・マイグレーション整合検証

    • prisma migrate deploy がクリーン DB に適用でき、prisma db pull(Introspect)で schema.prisma との差分が出ないことを確認する
    • \d+ 系でインデックス(votes (candidateId) / votes (candidateId, nickname) / votes (purchaseId) / purchases (paymentIntentId) UNIQUE / purchases (candidateId) / purchases (status) / purchase_items (purchaseId, creditPackageId) UNIQUE / purchase_items (purchaseId))が全て存在することを確認する
    • 集計クエリの EXPLAIN で意図したインデックスが使われていることを確認する
    • Requirements: 4.1, 4.4, 4.5, 4.6, 5.1, 5.2, 6.1, 6.3
    • Boundary: prisma/**
    • Depends: 3.2