テーマ
Design: データモデル
要件は requirements.md を参照。本ドキュメントは要件で示された 3 系統(コンテンツ管理基盤・取引データストア・デプロイ環境設定)を、それぞれ microCMS / Neon (PostgreSQL + Prisma 7) / Vercel 環境変数 に対応付け、コレクション/スキーマ/集計クエリ/越境参照規約まで実装直前の解像度で定義する。
Overview
Purpose: 投票サイトで扱う 7 エンティティ(Candidate / CandidateImage / CreditPackage / Vote / Purchase / PurchaseItem / VotingPeriod)の保持先・スキーマ・整合性ルール・集計ロジックを、ベンダー名と契約レベルまで決定する。
Users: 他 spec(home / candidate-detail / candidate-ranking / voting)が本 spec の Components を 唯一の正 として参照する。実装タスクは本 spec の File Structure Plan から起こす。
Impact: 過去 design(2026-05 以前)の「全エンティティを Prisma で持ち、自前画像配信エンドポイントを保有し、AdminUser テーブルで NextAuth Credentials Provider を運営」する構成を撤回し、コンテンツ系を microCMS、設定値を Vercel 環境変数、取引系のみ Neon に集約する 3 系統構成に置き換える。
Goals
- 取引データストアに保持するモデルを Vote / Purchase / PurchaseItem の 3 つに最小化 する
- Vote の有効/無効を 由来 Purchase.status の JOIN フィルタ で導出することで Vote 自身を完全イミュータブルにする
- CreditPackage を microCMS のロール/フィールド権限で完全イミュータブル化し、PurchaseItem に snapshot を持たせない
- 投票期間をシングルトン DB レコードから Vercel 環境変数に移し、起動時バリデーションでデータ層の状態数を 1 減らす
- 越境参照(取引データストア → microCMS)の整合性吸収戦略を 集計時に公開済みコンテンツと照合 する単一規約に統一する
Non-Goals
- 投票期間内/外の業務判定(
steering/product.md委譲) - 投票成立フロー全体・PaymentIntent 作成・Webhook ハンドラ実装(
voting/design.md委譲) - 画面ごとのレンダリング/UI 状態管理(
home/candidate-detail/candidate-ranking各 design 委譲) - microCMS 上のメンバー個別ロール定義・運営者監査 UI の具体細目(microCMS 管理 UI 委譲)
- 自前管理画面・運営者認証・自前画像配信エンドポイントの再導入(完全に Out of Boundary)
Boundary Commitments
This Spec Owns
- 取引データストア(Neon)上の Vote / Purchase / PurchaseItem のスキーマ・enum・UNIQUE 制約・インデックス
- microCMS の
candidates/creditPackagesコレクションスキーマ(フィールド型・必須・公開状態・ロール/フィールド権限の論理定義) - Vercel 環境変数
VOTING_PERIOD_START/VOTING_PERIOD_ENDの保持規約(キー名・値書式・起動時バリデーション) - 越境参照規約(取引データストアは microCMS のコンテンツ識別子を文字列で保持し、外部キー制約は持たない)
- ニックネーム正規化規則(
normalizeNickname関数の仕様)。Server / Client 両方から import 可能な純関数として export する(本 spec が単一所有)。 - 集計クエリの正規定義(候補者得票数・応援者ランキング)
- 共有クライアントインスタンス(
lib/prisma.ts/lib/microcms.ts)と投票期間ユーティリティ(lib/voting-period.ts)の契約 - microCMS 取得関数の Request Memoization 契約:
candidateService.getCandidates/getCandidate(id)/creditPackageService.getActiveCreditPackages/getCreditPackage(id)は Next.jsfetchを直接利用するか Reactcache()でラップし、同一 Request 内では 1 回に dedupe されることを保証する(generateMetadataと Server Component の二重取得を抑止)
Out of Boundary
- 投票期間内/外の 業務判定(受付可否のサーバーガード文言・UI 抑止)
- PaymentIntent 作成・Stripe Webhook ハンドラ・Confetti 演出など投票成立フロー本体
- 画面別レンダリング・カードコンポーネント・ランキング上位ハイライト等の UI 構成
- microCMS 上の 運営者メンバーロール の個別定義(field permission の 論理要件 は本 spec が定義するが、具体的なロール名・割り当ては運営オペレーション)
- 監査ログ収集・KPI 集計・データウェアハウス連携
Allowed Dependencies
- microCMS: 公開 API(読み取り専用キー)、画像 CDN
- Neon: PostgreSQL 17 互換、Prisma Migrate でマイグレーション管理
- Vercel ENV:
VOTING_PERIOD_*のみ、再デプロイ更新運用 - ライブラリ:
microcms-js-sdk、@prisma/client@^7、@prisma/adapter-pg@^7、zod@^4(入力検証は steering 共通方針に従う) - steering:
tech.md(技術選定)、product.md(投票期間業務ルール)、structure.md(ディレクトリ規約)
依存方向: steering(契約) → data-model(本 spec) → 他 spec。上流(steering)へ書き戻すロジックは持たない。
Revalidation Triggers
- microCMS コレクションスキーマ(
candidates/creditPackagesのフィールド)の追加・削除・型変更 - Neon Prisma schema(Vote / Purchase / PurchaseItem)のカラム・enum 変更
PaymentStatusenum 値の追加・削除- 越境参照規約変更(例: 外部キーの導入、識別子の型変更)
- 環境変数キー名・値書式の変更
- ニックネーム正規化規則の変更
- 集計クエリの正規定義(対象範囲・除外条件)の変更
これらが発生したら、home / candidate-detail / candidate-ranking / voting の design に対し再検証が必須となる。
Architecture
3 系統の責務分離
mermaid
graph TB
subgraph CMS[microCMS - コンテンツ管理基盤]
Candidates[candidates collection]
CreditPackages[creditPackages collection]
CDN[microCMS Image CDN]
end
subgraph DB[Neon - 取引データストア]
Vote[Vote]
Purchase[Purchase]
PurchaseItem[PurchaseItem]
end
subgraph ENV[Vercel Environment]
VotingPeriodEnv[VOTING_PERIOD_START / VOTING_PERIOD_END]
end
subgraph App[Next.js App - 本サイト]
CmsService[features cms services]
VoteAgg[features candidates services voteAggregationService]
VotingPeriodLib[lib voting-period]
NicknameService[features voting services nicknameService]
PrismaClient[lib prisma]
MicrocmsClient[lib microcms]
end
CmsService -->|read-only API| Candidates
CmsService -->|read-only API| CreditPackages
Candidates -->|image URL| CDN
VoteAgg -->|Prisma| Vote
VoteAgg -->|JOIN Purchase status| Purchase
Purchase --> PurchaseItem
Purchase -->|越境参照 candidate id 文字列| CmsService
PurchaseItem -->|越境参照 creditPackage id 文字列| CmsService
Vote -->|越境参照 candidate id 文字列| CmsService
PrismaClient --> Vote
MicrocmsClient --> CmsService
VotingPeriodLib --> VotingPeriodEnvArchitecture Pattern & Boundary Map
- Pattern: 「データソースごとに責務を分離した 3 系統」 + 「越境参照は識別子保持 + 取得時 enrichment」
- Boundary Map:
- microCMS 側: 運営者編集・公開ワークフロー・画像配信・イミュータブル制約(ロール/フィールド権限による強制)
- Neon 側: 決済 + 投票の単一トランザクション境界、決済プロバイダ取引識別子の UNIQUE 制約、集計クエリ
- 環境変数側: 投票期間値の保持と起動時バリデーション
- Steering 整合:
tech.mdの「データレイヤー」3 系統表に 1:1 対応。structure.mdの「保有しないパス」(app/admin/,auth.ts,app/api/images/[id]/)を保持。
Technology Stack
| Layer | Choice / Version | Role | Notes |
|---|---|---|---|
| コンテンツ管理基盤 | microCMS(SaaS) | candidates / creditPackages の保持、画像 CDN、運営者認証・権限 | API キーは読み取り専用権限のみアプリへ供給 |
| ORM | Prisma 7.8+ | Neon に対する型安全な ORM、マイグレーション管理 | @prisma/adapter-pg@^7 で接続 |
| 取引データストア | Neon (PostgreSQL 17 互換) | Vote / Purchase / PurchaseItem の永続化 | Branching 機能で PR 単位の独立環境 |
| ローカル DB | postgres:17 (docker-compose) | 開発時の DB | 既存 docker-compose.yml を踏襲 |
| デプロイ環境設定 | Vercel Environment Variables | VOTING_PERIOD_START / VOTING_PERIOD_END | ISO 8601(タイムゾーン付き)文字列 |
| microCMS SDK | microcms-js-sdk(最新) | Server Component / Server Action からの読み取り | Next.js Data Cache(60〜180 秒)に乗せる |
| 入力検証 | zod ^4 | サーバー側の文字列バリデーション | steering 共通方針に準拠 |
Prisma 7 /
@prisma/adapter-pgの選定根拠は steeringtech.md。本 spec ではすでにpackage.jsonに存在する版を採用し、新規追加はmicrocms-js-sdkのみ。
File Structure Plan
Directory Structure
prisma/
├── schema.prisma # Vote / Purchase / PurchaseItem + PaymentStatus enum
├── migrations/ # Prisma Migrate 出力
│ └── <timestamp>_init/migration.sql # 初期スキーマ + 制約
└── seed.ts # 取引データは無投入(プレースホルダ)
lib/
├── prisma.ts # Neon 用 PrismaClient 共有インスタンス
├── microcms.ts # microCMS Client 共有インスタンス
└── voting-period.ts # 環境変数読み出し + バリデーション
features/
├── cms/
│ ├── services/
│ │ ├── candidateService.ts # getCandidates / getCandidate
│ │ └── creditPackageService.ts # getActiveCreditPackages / getCreditPackage
│ └── types.ts # Candidate / CandidateImage / CreditPackage 型
├── candidates/
│ └── services/
│ └── voteAggregationService.ts # 得票数集計 / 応援者ランキング集計
└── voting/
└── services/
└── nicknameService.ts # normalizeNickname
next.config.ts # images.remotePatterns に microCMS 画像ホスト追加(modified)
package.json # microcms-js-sdk 依存追加(modified)Modified Files
next.config.ts—images.remotePatternsに microCMS の画像配信ホストパターンを追加(候補者画像をnext/imageで読み込めるようにする)package.json—microcms-js-sdkをdependenciesに追加prisma.config.ts— 既存ファイル、変更なし(prisma/schema.prismaとprisma/seed.tsを指したまま)
Out-of-scope(再導入禁止)
app/admin/**/components/admin/**/features/admin/**auth.ts/app/api/auth/[...nextauth]/**app/api/images/[id]/**- Prisma 上の
Candidate/CandidateImage/CreditPackage/VotingPeriod/AdminUserモデル VoteStatusenum、Purchase.total* カラム、PurchaseItem.*Snapshot カラム
System Flows
Flow 1: 候補者一覧表示時のデータ合成
mermaid
sequenceDiagram
participant Page as app/page.tsx (Server)
participant CmsSvc as features/cms/services/candidateService
participant CmsAPI as microCMS API
participant VoteAgg as voteAggregationService
participant Db as Neon
Page->>CmsSvc: getCandidates()
CmsSvc->>CmsAPI: GET /candidates (next.revalidate=120)
CmsAPI-->>CmsSvc: Candidate[]
Page->>VoteAgg: getVoteCountsByCandidateIds(ids)
VoteAgg->>Db: SELECT candidateId, COUNT(*) FROM Vote v INNER JOIN Purchase p ON v.purchaseId = p.id WHERE p.status='SUCCEEDED' AND v.candidateId IN (...) GROUP BY v.candidateId
Db-->>VoteAgg: Map<candidateId, count>
Page-->>Page: merge Candidate + voteCount (公開済み Candidate のみ表示)Key Decision: 集計は Vote × Purchase の INNER JOIN + status='SUCCEEDED' フィルタ で行い、Vote 側に status カラムを持たない。
Flow 2: 決済成功時の Vote 生成
mermaid
sequenceDiagram
participant Webhook as Stripe Webhook handler
participant Tx as Neon Transaction
participant CmsSvc as creditPackageService
participant CmsAPI as microCMS API
Webhook->>CmsSvc: getCreditPackages(items[*].creditPackageId)
CmsSvc->>CmsAPI: GET /creditPackages?ids=...
CmsAPI-->>CmsSvc: CreditPackage[] (credits / priceJpy 含む)
Webhook->>Tx: BEGIN
Tx->>Tx: UPDATE Purchase SET status='SUCCEEDED' WHERE id=:id
Tx->>Tx: INSERT INTO Vote (candidateId, purchaseId, nickname) x sum(credits * quantity)
Tx->>Tx: COMMITKey Decision: 合計票数は PurchaseItem.quantity × microCMS から取得した CreditPackage.credits で計算する。Purchase に totalCredits カラムを持たないため、Webhook ハンドラは集計するために microCMS を一度参照する。CreditPackage は完全イミュータブルなので、購入時と Webhook 受信時で値が変わらないことが保証される。
Flow 3: 払戻時の集計除外
mermaid
stateDiagram-v2
[*] --> PROCESSING: Purchase 作成
PROCESSING --> SUCCEEDED: 決済成功 Webhook + Vote 一括生成
PROCESSING --> FAILED: 決済失敗 Vote 生成しない
SUCCEEDED --> REFUNDED: 払戻 Webhook
REFUNDED --> SUCCEEDED: 不可
FAILED --> [*]
REFUNDED --> [*]Key Decision: REFUNDED 遷移時は Purchase.status のみを更新する。Vote レコードは削除も変更もせず、集計クエリの WHERE 条件(p.status='SUCCEEDED')が自動的に対象外にする。
Requirements Traceability
| Req | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | 表示名必須・空文字不可 | microCMS candidates コレクション | 必須フィールド宣言 | — |
| 1.2 | 趣味・特技/資格をリスト型 | microCMS candidates コレクション | 複数選択フィールド | — |
| 1.3 | PR メッセージで改行保持 | microCMS candidates コレクション | テキストエリアフィールド | — |
| 1.4 | 0 件以上の画像関連付け | microCMS candidates(画像フィールド多値) | フィールド宣言 | — |
| 1.5 | 公開状態で表示 | candidateService | getCandidates が公開済みのみ返す | Flow 1 |
| 1.6 | 下書き・停止状態で非表示 | candidateService | 同上 | Flow 1 |
| 1.7 | 物理削除を運用上禁止 | microCMS ロール/フィールド権限 + 運用ルール | ロール定義要件 | — |
| 2.1 | 画像の必須属性 | microCMS 画像フィールド | フィールド宣言 | — |
| 2.2 | MIME 型限定 (JPEG/PNG/WebP) | microCMS 画像フィールド設定 | アップロード制約 | — |
| 2.3 | 表示順重複禁止 | microCMS 画像フィールドの並び順管理 | 運用ルール | — |
| 2.4 | 先頭をメイン画像扱い | candidateService | レスポンス順を保持 | Flow 1 |
| 2.5 | 親子削除連動 | microCMS 親子関係 | プラットフォーム機能 | — |
| 2.6 | サイズ上限 | microCMS 画像フィールド設定 | 5 MB / 枚 | — |
| 2.7 | バイナリを自前で持たない | next.config.ts + candidateService | 画像 URL を CDN URL のまま使用 | Flow 1 |
| 3.1 | 票数は 1 以上整数 | microCMS creditPackages フィールド | 数値フィールド + 最小値 1 | — |
| 3.2 | 価格は 0 以上整数 | 同上 | 数値フィールド + 最小値 0 | — |
| 3.3 | 名称・票数・価格・表示順をイミュータブル | microCMS フィールド権限 | 編集ロックロール | — |
| 3.4 | 物理削除不可 | microCMS フィールド権限 | 削除ロックロール | — |
| 3.5 | アクティブ状態のみ更新可 | microCMS フィールド権限 | active のみ書き込み可 | — |
| 3.6 | active=true のみ提示 | creditPackageService | getActiveCreditPackages のフィルタ | — |
| 3.7 | 価格改定は新規追加で対応 | 運用ルール | — | — |
| 4.1 | Vote の必須属性 | Prisma Vote model | candidateId / purchaseId / nickname NOT NULL | — |
| 4.2 | ニックネーム長 + トリム | nicknameService | normalizeNickname | — |
| 4.3 | Vote は必ず Purchase 由来 | Prisma Vote.purchaseId NOT NULL | スキーマ + Webhook ハンドラ | Flow 2 |
| 4.4 | Vote イミュータブル + 有効性は Purchase 由来 | Prisma + voteAggregationService | JOIN + Purchase.status='SUCCEEDED' | Flow 3 |
| 4.5 | 総得票数集計対象 | voteAggregationService | getVoteCountsByCandidateIds | Flow 1 |
| 4.6 | 応援者ランキング集計 | voteAggregationService | getTopVotersByCandidateId | — |
| 4.7 | 払戻時の集計除外 | voteAggregationService | JOIN フィルタ | Flow 3 |
| 4.8 | コンテンツ停止時の取扱 | candidateService + voteAggregationService | 公開状態と集計の独立 | — |
| 5.1 | 取引識別子 UNIQUE | Prisma Purchase | UNIQUE 制約 | — |
| 5.2 | Purchase は PurchaseItem を 1 件以上 | アプリ層トランザクション | Prisma $transaction で同時生成 | Flow 2 |
| 5.3 | 決済成功時に Vote 一括生成 | Webhook ハンドラ + Prisma $transaction | — | Flow 2 |
| 5.4 | 失敗時 Vote を生成しない | Webhook ハンドラ | — | Flow 2 |
| 5.5 | 払戻時 Vote を変更しない | Webhook ハンドラ | UPDATE Purchase.status のみ | Flow 3 |
| 5.6 | 対象候補者が CMS 側で消えても孤立保持 | Prisma Purchase.candidateId は外部キー無し | 越境参照規約 | — |
| 6.1 | PurchaseItem 必須属性 | Prisma PurchaseItem | NOT NULL 制約 | — |
| 6.2 | 数量 ≥ 1 | Prisma + アプリ層検証 | CHECK 制約 | — |
| 6.3 | 同 Purchase 内でパッケージ重複禁止 | Prisma UNIQUE(purchaseId, creditPackageId) | UNIQUE 制約 | — |
| 6.4 | Purchase 削除で連鎖削除 | Prisma onDelete: Cascade | スキーマ宣言 | — |
| 6.5 | PurchaseItem イミュータブル | アプリ層書き込み禁止規約 | サービスに UPDATE を実装しない | — |
| 7.1 | 開始・終了を ISO 8601 で保持 | Vercel ENV | VOTING_PERIOD_START / _END | — |
| 7.2 | 終了 > 開始の検証 | lib/voting-period.ts | 起動時/初回参照時バリデーション | — |
| 7.3 | 唯一値として全画面に提供 | lib/voting-period.ts | getVotingPeriod() | — |
| 7.4 | 不正値はエラー扱い | lib/voting-period.ts | InvalidVotingPeriodError を throw | — |
| 7.5 | 変更は再デプロイで反映 | Vercel ENV 運用 | プラットフォーム機能 | — |
共通属性ルール(識別子・作成日時・更新日時)は Feature 4-6 の全 Prisma model に標準適用される。本表では各モデルの定義により暗黙的に網羅される。
Components and Interfaces
Summary
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
microCMS candidates コレクション | CMS | 候補者と画像の保持 | 1.1-1.7, 2.1-2.7 | microCMS プラットフォーム(P0) | State |
microCMS creditPackages コレクション | CMS | 票数パッケージの保持・イミュータブル制約 | 3.1-3.7 | microCMS プラットフォーム(P0)、ロール/フィールド権限(P0) | State |
Prisma Vote model | DB | 1 票単位の記録、イミュータブル | 4.1-4.8 | Neon(P0)、Prisma 7(P0) | State |
Prisma Purchase model | DB | 決済記録、Vote 有効性の源 | 5.1-5.6 | Neon(P0)、Stripe paymentIntent(P1) | State |
Prisma PurchaseItem model | DB | 購入明細、CreditPackage への越境参照 | 6.1-6.5 | Neon(P0) | State |
lib/voting-period.ts | Config | 環境変数から { startsAt, endsAt } を提供 | 7.1-7.5 | Vercel ENV(P0) | Service |
lib/prisma.ts | Infra | PrismaClient 共有インスタンス | 4-6 系統 | @prisma/client@7(P0) | Service |
lib/microcms.ts | Infra | microCMS Client 共有インスタンス | 1-3 系統 | microcms-js-sdk(P0) | Service |
features/cms/services/candidateService.ts | Service | candidates の取得ラッパー | 1.5, 1.6, 2.4, 2.7 | microCMS client(P0)、Next.js Data Cache(P1) | Service |
features/cms/services/creditPackageService.ts | Service | creditPackages の取得ラッパー | 3.6 | microCMS client(P0) | Service |
features/candidates/services/voteAggregationService.ts | Service | 得票数 / 応援者ランキングの集計 | 4.4-4.8 | Prisma(P0) | Service |
features/voting/services/nicknameService.ts | Service | ニックネーム正規化 | 4.2 | (純関数) | Service |
越境参照(Vote/Purchase/PurchaseItem → microCMS)は DB レベルの外部キーを持たない契約 を全モデルで採用。整合性は (a) microCMS 側で物理削除を運用上禁止、(b) 集計クエリで microCMS 公開済みコンテンツとの照合(
features/cms/services経由)で吸収する。
microCMS コレクション層
candidates コレクション
| Field | Detail |
|---|---|
| Intent | 候補者および候補者画像の正本 |
| 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, 2.7 |
Schema 定義(microCMS フィールド)
| API スキーマ名 | フィールド名 | 型 | 必須 | 補足 |
|---|---|---|---|---|
id | 識別子 | 自動 | ✓ | microCMS 発行 |
displayName | 表示名 | textField | ✓ | 1〜50 文字 |
region | 出身地 | textField | ✓ | 1〜30 文字 |
age | 年齢 | number | ✓ | 0〜120 |
height | 身長(cm) | number | ✓ | 50〜250 |
occupation | 職業 | textField | ✓ | 1〜50 文字 |
hobbies | 趣味・特技 | repeater(text) | — | 0 件以上 |
certifications | 資格・大会実績 | repeater(text) | — | 0 件以上 |
motto | 座右の銘 | textField | — | 0〜100 文字 |
dream | 夢 | textField | — | 0〜200 文字 |
message | PR メッセージ | textArea | — | 改行保持、0〜2000 文字 |
displayOrder | 表示順 | number | ✓ | 候補者一覧の安定順、ID 昇順タイブレーク |
images | 画像一覧 | repeater(image) | — | 各要素は { image: media, mime: select(JPEG/PNG/WebP) }。先頭がメイン画像 |
公開状態モデル: microCMS 標準の「公開」「下書き」「停止(非公開)」を使用。candidateService.getCandidates は 公開済みのみ を返す。
ロール/フィールド権限:
- 運営者ロールに対し、レコードの 物理削除を禁止(削除権限ロックロール)
- それ以外のフィールドは編集可能
アップロード制約:
- 画像フィールドの MIME 型を JPEG / PNG / WebP に限定(microCMS の画像フィールド設定)
- ファイルサイズ上限: 1 枚 5 MB(microCMS 画像フィールド設定で強制)
Implementation Notes
- Integration:
candidateService.ts経由でのみアクセスする。Server Component / Server Action から直接microcms-js-sdkを呼ばない - Validation: フィールド単位の整合性は microCMS 側で強制(必須・型・最大長)。アプリ側は受信したオブジェクトを
features/cms/types.tsの型で受ける - Risks: microCMS スキーマと TypeScript 型のドリフト →
features/cms/types.tsを一次情報として手動同期(steering 方針により Zod 検証は不採用)
creditPackages コレクション
| Field | Detail |
|---|---|
| Intent | 票数パッケージのマスタ(完全イミュータブル) |
| Requirements | 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7 |
Schema 定義
| API スキーマ名 | フィールド名 | 型 | 必須 | 補足 |
|---|---|---|---|---|
id | 識別子 | 自動 | ✓ | microCMS 発行 |
name | 名称 | textField | ✓ | 1〜50 文字 |
credits | 票数 | number | ✓ | 1 以上 |
priceJpy | 価格(税込円) | number | ✓ | 0 以上 |
displayOrder | 表示順 | number | ✓ | 一覧の安定順 |
isActive | アクティブ状態 | boolean | ✓ | true=販売中 / false=廃止 |
ロール/フィールド権限:
name,credits,priceJpy,displayOrderを 作成後は編集不可(field permission による書き込みロック)- レコードの 物理削除を禁止
isActiveのみ運営者が更新可能
公開状態モデル: microCMS 公開済みかつ isActive=true のレコードを 新規購入画面で提示。isActive=false でも creditPackageService.getCreditPackage(id) は引き続き取得可能(過去 PurchaseItem の参照解決のため)。
Implementation Notes
- Integration:
creditPackageService.getActiveCreditPackages()はfilters: 'isActive[equals]true'でフィルタ。getCreditPackage(id)は ID で単一取得(過去明細の解決用、isActive 状態に関わらず取得可) - Validation: 編集ロックは microCMS 側に委譲。アプリ側で値検証は行わない
- Risks: ロール権限の設定ミスにより
credits/priceJpyが改変された場合、過去 PurchaseItem の合計票数計算が変わる → 監査ログによる検出は microCMS 側に依存
Neon Prisma schema 層
Vote model
| Field | Detail |
|---|---|
| Intent | 候補者へ投じられた 1 票の不変記録 |
| Requirements | 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8 |
Contracts: State [x]
Service Interface (集計のみ。書き込みは Webhook ハンドラから prisma.vote.createMany)
typescript
// features/candidates/services/voteAggregationService.ts
export interface VoteAggregationService {
getVoteCountsByCandidateIds(
candidateIds: ReadonlyArray<string>
): Promise<Map<string, number>>;
getTopVotersByCandidateId(
candidateId: string,
limit?: number // 既定値 5(要件 4.6: 候補者詳細画面の応援者ランキング上位 5 件)
): Promise<ReadonlyArray<{ nickname: string; voteCount: number }>>;
}- Preconditions: 引数の
candidateIdsは microCMS のコンテンツ ID 文字列のリスト。limit省略時は 5 件(要件 4.6 由来) - Postconditions: 戻り値は Purchase.status='SUCCEEDED' に限定された Vote の集計値。voteCount 降順 + nickname 昇順タイブレークで安定
- Invariants:
candidateIdが microCMS 上で公開済みでない場合でも集計は実行する(画面側で除外)。Vote レコード自体は変更しない
State Management:
- 1 レコード = 1 票。
createManyのみ。update/deleteを呼ばない(アプリ層規約として禁止) - 同一 Purchase 由来の Vote 群は一括生成、同一 Transaction でコミット
Dependencies
- Outbound: Prisma
purchaseテーブル(JOIN 対象、P0) - External: Neon PostgreSQL(P0)
Implementation Notes
- Integration: Webhook ハンドラから
prisma.$transaction内でvote.createManyを呼ぶ(具体実装はvoting/design.md) - Validation:
nicknameは事前にnicknameService.normalizeNicknameを通した値のみ保存 - Risks:
createManyの件数が多い場合(大量パッケージ購入)に長時間トランザクションになる → Webhook はサーバーレス時間制限内に収まる前提(本サイトでは合計票数 ≤ 数千を想定)
Purchase model
| Field | Detail |
|---|---|
| Intent | 1 決済の結果と決済ステータスの正本 |
| Requirements | 5.1, 5.2, 5.3, 5.4, 5.5, 5.6 |
Contracts: State [x]
State Transition: PROCESSING → SUCCEEDED | FAILED、SUCCEEDED → REFUNDED。逆遷移なし。
Dependencies
- Inbound: Webhook ハンドラ(P0)
- Outbound: Prisma
purchaseItemテーブル(P0)、Prismavoteテーブル(P0、status='SUCCEEDED' 遷移時に一括生成) - External: Stripe
paymentIntent.id(P0、UNIQUE 制約のキー)
Implementation Notes
- Integration: PaymentIntent 作成時に
status=PROCESSINGで INSERT。Webhook でstatusを遷移 - Validation:
candidateIdの越境参照妥当性は 挿入時点では検証しない(microCMS 取得の race を避ける)。集計時に未公開のものは画面側で除外 - Risks: Webhook 配送遅延・重複 →
paymentIntentIdUNIQUE +status状態機械で冪等化(voting/design.mdで詳述)
PurchaseItem model
| Field | Detail |
|---|---|
| Intent | Purchase の構成要素(パッケージ × 数量) |
| Requirements | 6.1, 6.2, 6.3, 6.4, 6.5 |
Contracts: State [x]
Invariants:
- 同一
purchaseId内でcreditPackageIdは UNIQUE(同じパッケージは数量で表現) quantity >= 1(CHECK 制約)- 作成後の
quantity/creditPackageId変更を禁止(アプリ層書き込み規約)
Dependencies
- Inbound: Purchase 作成トランザクション(P0)
- Outbound: microCMS
creditPackages(越境参照、P0、決済成功時に取得して票数計算)
Implementation Notes
- Integration:
creditPackageIdは microCMS 上のコンテンツ ID 文字列。Prisma スキーマ上はString型でユニーク制約のみ - Validation: 挿入時にアプリ層で「
creditPackageIdが microCMS に存在しisActive=trueであること」を確認(PaymentIntent 作成側の責務、voting/design.md) - Risks: 越境参照の整合性は集計時に解消(購入時点の値は CreditPackage が完全イミュータブルなので時間経過で変化しない)
投票期間設定層
lib/voting-period.ts
| Field | Detail |
|---|---|
| Intent | Vercel 環境変数から投票期間値を提供 |
| Requirements | 7.1, 7.2, 7.3, 7.4, 7.5 |
Contracts: Service [x]
Service Interface
typescript
// lib/voting-period.ts
export type VotingPeriod = Readonly<{
startsAt: Date;
endsAt: Date;
}>;
export class InvalidVotingPeriodError extends Error {
readonly cause: 'MISSING' | 'INVALID_FORMAT' | 'INVERTED_RANGE';
constructor(cause: 'MISSING' | 'INVALID_FORMAT' | 'INVERTED_RANGE', message: string);
}
export function getVotingPeriod(): VotingPeriod;- Preconditions:
VOTING_PERIOD_START/VOTING_PERIOD_END環境変数が ISO 8601(タイムゾーン付き)で定義済み - Postconditions: 妥当な
{ startsAt, endsAt }を返す。プロセス内でメモ化(モジュールスコープでキャッシュ可) - Errors:
- 値未設定 →
InvalidVotingPeriodError('MISSING', ...) - パース不能 →
InvalidVotingPeriodError('INVALID_FORMAT', ...) endsAt <= startsAt→InvalidVotingPeriodError('INVERTED_RANGE', ...)
- 値未設定 →
Implementation Notes
- Integration:
home/voting側は本関数経由でのみ投票期間を参照する。直接process.env.VOTING_PERIOD_*を読まない - Validation: 初回呼び出し時にバリデーション。以後はキャッシュ
- Risks: 期間変更後の旧キャッシュ → 再デプロイで Lambda が再生成されるため実害なし
ニックネーム正規化
features/voting/services/nicknameService.ts
| Field | Detail |
|---|---|
| Intent | 投票者ニックネームの正規化と長さ制約 |
| Requirements | 4.2 |
Contracts: Service [x]
Service Interface
typescript
// features/voting/services/nicknameService.ts
export const NICKNAME_MAX_LENGTH = 32;
export class InvalidNicknameError extends Error {
readonly cause: 'EMPTY' | 'TOO_LONG';
}
export function normalizeNickname(raw: string): string;
// 戻り値は正規化済み文字列。無効入力は InvalidNicknameError を throw正規化規則
| ステップ | 内容 |
|---|---|
| Unicode NFKC | 全角/半角ゆれ吸収 |
| 内部空白を単一スペースに圧縮 | replace(/\s+/g, ' ') |
| 前後空白除去 | trim() |
| 長さ検証 | 1 文字以上、NICKNAME_MAX_LENGTH(32)以下(UTF-16 コードユニット長) |
Implementation Notes
- Integration: PaymentIntent 作成 Server Action と Webhook ハンドラの両方で必ず通す(クライアント側は UX のために事前適用、サーバー側で再適用)
- Client/Server 両用 export:
normalizeNickname/NICKNAME_MAX_LENGTH/InvalidNicknameErrorは'use client'を含まない純関数モジュールとして実装し、Server Components / Server Actions / Client Components / Webhook ハンドラのいずれからも import 可能とする(voting/candidate-detail等の Client Component が同一関数で事前バリデーションできる) - Risks: NFKC によって異なるユーザーが同一ニックネームに収束する場合がある → 仕様上「同一文字列のニックネームは同一支援者として扱う」ため許容
Data Models
Domain Model
mermaid
erDiagram
Candidate ||--o{ CandidateImage : "親子(microCMS)"
Candidate ||--o{ Vote : "越境参照"
Candidate ||--o{ Purchase : "越境参照"
CreditPackage ||--o{ PurchaseItem : "越境参照"
Purchase ||--|{ PurchaseItem : "1..*"
Purchase ||--|{ Vote : "1..* (status=SUCCEEDED 時のみ生成)"
Candidate {
string id PK "microCMS"
string displayName
string region
number age
number height
string occupation
list hobbies
list certifications
string motto
string dream
text message
number displayOrder
list images
enum publishState "draft|published|stopped"
}
CandidateImage {
string url "microCMS CDN URL"
string mime "JPEG|PNG|WebP"
number order
}
CreditPackage {
string id PK "microCMS"
string name "immutable"
number credits "immutable"
number priceJpy "immutable"
number displayOrder "immutable"
boolean isActive "mutable"
}
Vote {
uuid id PK
string candidateId "microCMS id 越境参照"
uuid purchaseId FK
string nickname "normalized"
timestamptz createdAt
timestamptz updatedAt
}
Purchase {
uuid id PK
string candidateId "microCMS id 越境参照"
string nickname "normalized"
string paymentProvider
string paymentIntentId UK
enum status "PROCESSING|SUCCEEDED|FAILED|REFUNDED"
timestamptz createdAt
timestamptz updatedAt
}
PurchaseItem {
uuid id PK
uuid purchaseId FK
string creditPackageId "microCMS id 越境参照"
number quantity
timestamptz createdAt
timestamptz updatedAt
}
VotingPeriod {
string startsAt "ENV ISO8601"
string endsAt "ENV ISO8601"
}Logical Data Model
取引データストア(Neon):
- Vote / Purchase / PurchaseItem の 3 model のみ
- 共通属性:
id(uuid PK)、createdAt(timestamptz、default now())、updatedAt(timestamptz、@updatedAt) - 越境参照カラム(
Vote.candidateId,Purchase.candidateId,PurchaseItem.creditPackageId)はString型で外部キー無し - Purchase ↔ PurchaseItem、Purchase ↔ Vote は通常の外部キー(同一 DB 内)
コンテンツ管理基盤(microCMS):
candidates/creditPackagesの 2 コレクション- 識別子・作成日時・更新日時は microCMS が自動付与
デプロイ環境設定(Vercel ENV):
VOTING_PERIOD_START/VOTING_PERIOD_ENDの 2 値のみ
Cascade ルール:
Purchase→PurchaseItem:onDelete: Cascade(明細は親 Purchase なしで存在しない)Purchase→Vote:onDelete: Restrict(履歴消失防止)- 越境参照: DB レベルの Cascade を持たない
Physical Data Model (Neon)
Prisma schema(prisma/schema.prisma)
prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum PaymentStatus {
PROCESSING
SUCCEEDED
FAILED
REFUNDED
}
model Vote {
id String @id @default(uuid()) @db.Uuid
candidateId String @db.Text // microCMS id 越境参照
purchaseId String @db.Uuid
nickname String @db.Text // 正規化済み
createdAt DateTime @default(now()) @db.Timestamptz()
updatedAt DateTime @updatedAt @db.Timestamptz()
purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Restrict)
@@index([candidateId])
@@index([candidateId, nickname])
@@index([purchaseId])
@@map("votes")
}
model Purchase {
id String @id @default(uuid()) @db.Uuid
candidateId String @db.Text // microCMS id 越境参照
nickname String @db.Text // 正規化済み
paymentProvider String @db.Text
paymentIntentId String @db.Text
status PaymentStatus @default(PROCESSING)
createdAt DateTime @default(now()) @db.Timestamptz()
updatedAt DateTime @updatedAt @db.Timestamptz()
items PurchaseItem[]
votes Vote[]
@@unique([paymentIntentId])
@@index([candidateId])
@@index([status])
@@map("purchases")
}
model PurchaseItem {
id String @id @default(uuid()) @db.Uuid
purchaseId String @db.Uuid
creditPackageId String @db.Text // microCMS id 越境参照
quantity Int
createdAt DateTime @default(now()) @db.Timestamptz()
updatedAt DateTime @updatedAt @db.Timestamptz()
purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Cascade)
@@unique([purchaseId, creditPackageId])
@@index([purchaseId])
@@map("purchase_items")
}追加の生 SQL 制約
Prisma で表現できない制約は、初期マイグレーション内で CREATE EXTENSION IF NOT EXISTS の後に追加する:
sql
-- PurchaseItem.quantity >= 1
ALTER TABLE purchase_items
ADD CONSTRAINT purchase_items_quantity_positive
CHECK (quantity >= 1);Indexes
| Index | Purpose |
|---|---|
votes (candidateId) | 得票数集計のグループキー |
votes (candidateId, nickname) | 応援者ランキング集計 |
votes (purchaseId) | Purchase ↔ Vote JOIN |
purchases (paymentIntentId) UNIQUE | 決済重複防止・Webhook 冪等性 |
purchases (candidateId) | 候補者別購入履歴 |
purchases (status) | 集計時の SUCCEEDED フィルタ |
purchase_items (purchaseId, creditPackageId) UNIQUE | 同一購入内でパッケージ重複禁止 |
purchase_items (purchaseId) | 明細取得 |
Vote × Purchase の集計クエリでは Postgres プランナが
votes.purchaseIdインデックスとpurchases.statusインデックスを順に使う想定。Explain を CI で監視する。
Data Contracts & Integration
microCMS API(クライアント側で受ける型、features/cms/types.ts)
typescript
export type CandidateImageRef = Readonly<{
url: string; // microCMS CDN
mime: 'image/jpeg' | 'image/png' | 'image/webp';
}>;
export type Candidate = Readonly<{
id: string;
displayName: string;
region: string;
age: number;
height: number;
occupation: string;
hobbies: ReadonlyArray<string>;
certifications: ReadonlyArray<string>;
motto: string;
dream: string;
message: string;
displayOrder: number;
images: ReadonlyArray<CandidateImageRef>;
}>;
export type CreditPackage = Readonly<{
id: string;
name: string;
credits: number;
priceJpy: number;
displayOrder: number;
isActive: boolean;
}>;microCMS 取得規約
| 取得関数 | エンドポイント | フィルタ | キャッシュ |
|---|---|---|---|
candidateService.getCandidates() | GET /candidates | publish status = published | next.revalidate = 120 + Request Memoization(同一 Request 内 1 回) |
candidateService.getCandidate(id) | GET /candidates/:id | publish status = published | next.revalidate = 120 + Request Memoization(同一 Request 内 1 回) |
creditPackageService.getActiveCreditPackages() | GET /creditPackages | filters: 'isActive[equals]true' | next.revalidate = 120 + Request Memoization(同一 Request 内 1 回) |
creditPackageService.getCreditPackage(id) | GET /creditPackages/:id | (フィルタ無し、past 明細解決用) | next.revalidate = 120 + Request Memoization(同一 Request 内 1 回) |
Request Memoization の実装方針: 各取得関数は内部で
fetchを直接呼び出すか、関数全体を Reactcache()でラップする。これによりgenerateMetadataとpage.tsxの Server Component 描画で同一 ID を取得しても microCMS への HTTP は 1 回に統合される(candidate-detail spec の Performance 前提)。
越境参照規約
- 取引データストアのモデルは
candidateId/creditPackageIdを microCMS のコンテンツ ID 文字列 で保持する - 集計時は microCMS 公開済みコンテンツとの照合 で整合性を吸収する(画面側で公開済みコンテンツ ID のホワイトリストでフィルタ)
- DB レベルの外部キー制約は持たない
Error Handling
Error Strategy
| エラー種別 | 検出位置 | 応答 |
|---|---|---|
| microCMS API 障害 | candidateService / creditPackageService | 例外を throw、画面側でエラー表示(再試行はユーザー操作で) |
| 投票期間 ENV 未設定/不正 | lib/voting-period.ts 初回参照 | InvalidVotingPeriodError を throw、ログ + 画面エラー |
| ニックネーム無効 | nicknameService.normalizeNickname | InvalidNicknameError を throw、Server Action で 400 応答 |
| 越境参照解決失敗(microCMS 側で削除済み等) | 集計時 | 集計結果から該当候補者を除外、警告ログ |
| Vote createMany 部分失敗 | Webhook ハンドラの $transaction | トランザクション全体をロールバック、Webhook を 5xx 応答(Stripe 再送) |
paymentIntentId UNIQUE 違反 | Webhook ハンドラ | 既に成功済みとみなし 200 応答(冪等) |
quantity CHECK 違反 | PaymentIntent 作成時 | 400 応答、UI 側で入力バリデーションを通せば到達しない |
Monitoring
本 spec 固有: なし(steering/tech.md のセキュリティポリシー「監査ログ」に従う)。
Testing Strategy
| カテゴリ | 項目 | 検証要件との対応 |
|---|---|---|
| Unit Tests | normalizeNickname の NFKC・空白圧縮・長さ境界(0/1/32/33 文字) | 4.2 |
getVotingPeriod の未設定 / 不正書式 / endsAt <= startsAt で InvalidVotingPeriodError を throw | 7.1, 7.2, 7.4 | |
voteAggregationService.getVoteCountsByCandidateIds が Purchase.status='SUCCEEDED' のみ集計し、PROCESSING / FAILED / REFUNDED を除外 | 4.4, 4.5, 4.7 | |
voteAggregationService.getTopVotersByCandidateId がニックネーム単位でグループ化し降順 5 件で返す | 4.6 | |
| Integration Tests | Purchase + PurchaseItem + Vote 一括生成トランザクションのロールバック確認(Vote createMany 失敗時) | 5.3, 5.4 |
paymentIntentId UNIQUE 違反時に冪等に振る舞う | 5.1 | |
purchase_items の (purchaseId, creditPackageId) UNIQUE が同一パッケージの 2 明細を拒否 | 6.3 | |
| Purchase 削除で PurchaseItem が Cascade 削除、Vote は Restrict で残る | 6.4 | |
| 払戻時に Purchase.status のみ更新、Vote 件数は不変、集計結果からは消える | 4.7, 5.5 | |
| E2E Tests | candidateService.getCandidates が microCMS の draft / stopped を返さない(microCMS テスト環境向け) | 1.5, 1.6 |
getActiveCreditPackages が isActive=false を返さない | 3.6 | |
| Schema/Migration | 初期マイグレーション適用後、Prisma Introspect で差分が出ない | 全体 |
Security Considerations
本 spec 固有のみ記載(全画面共通は steering/tech.md「セキュリティポリシー」参照)。
- API キー分離: 公開サイトに渡す microCMS API キーは 読み取り専用権限のみ とし、書き込み・運営者操作用キーはアプリケーション環境に投入しない(
MICROCMS_API_KEY_READONLYのみ参照) - 越境参照の改竄耐性:
Purchase.candidateId/PurchaseItem.creditPackageIdはクライアントから渡される ID 文字列。PaymentIntent 作成 Server Action 内で「microCMS に存在し公開済みかつisActive=true」を検証する責務はvoting/design.md側に置く(本 spec はスキーマ規約のみ定義) - ニックネームの XSS 防止: 表示は React のデフォルトエスケープに依存。
nicknameはText型で保存し、HTML として解釈しない
Performance & Scalability
本 spec 固有のみ。
- 集計クエリ:
votes (candidateId)/votes (candidateId, nickname)複合インデックスで GROUP BY をカバー。候補者 10 名前後・総票数 ≤ 数十万を想定し、unstable_cacheでキャッシュしない方針(集計のリアルタイム性を優先) - microCMS Data Cache: 60〜180 秒の short TTL に統一(
steering/tech.md準拠)。Webhook 連動の即時 revalidate は採用しない - Webhook 一括 INSERT:
vote.createManyで 1 回の SQL に集約。prepared statementのサイズ制限内に収まるよう、想定最大票数(数千)で先行検証する - 越境参照解決の N+1: candidate / creditPackage の取得は
microcms-js-sdkのgetListでバッチ化し、PurchaseItem の集計時は ID リストで一括 fetch する
Migration Strategy
mermaid
flowchart TD
A[現状: prisma/ 未作成、microCMS 未配線] --> B[1. microCMS にコレクション作成 candidates / creditPackages]
B --> C[2. 環境変数を Vercel に設定 VOTING_PERIOD_*, MICROCMS_*, DATABASE_URL]
C --> D[3. package.json に microcms-js-sdk 追加]
D --> E[4. prisma/schema.prisma + 初期 migration 生成]
E --> F[5. lib/prisma.ts, lib/microcms.ts, lib/voting-period.ts 実装]
F --> G[6. features/cms/services 実装]
G --> H[7. features/voting/services/nicknameService 実装]
H --> I[8. features/candidates/services/voteAggregationService 実装]
I --> J[9. 統合テスト + Explain 検証]
J --> K[完了: 他 spec が本 spec を参照可能な状態]- ロールバックトリガ: Vote 集計クエリで P99 レイテンシ > 500 ms / microCMS API レート上限到達 /
InvalidVotingPeriodErrorが本番で発火 - 検証チェックポイント: 各ステップ後に該当する Testing Strategy 項目を通すこと
Supporting References
- microCMS 公式: ロール/フィールド権限機能、画像フィールドの MIME/サイズ制約、
microcms-js-sdkのcustomRequestInitへのnext.revalidate連携 - Prisma 7 公式:
@prisma/adapter-pg、@updatedAt、onDelete: Restrict / Cascade、UNIQUE/CHECK 制約の生 SQL 適用 - 本サイト steering:
tech.md(技術選定根拠)、product.md(投票期間業務ルール)、structure.md(ディレクトリ規約と保有しないパス) - 他 spec:
voting/design.md(購入フロー実装、Webhook 詳細)、home/candidate-detail/candidate-ranking(本 spec の Components を参照)