Skip to content

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.js fetch を直接利用するか React cache() でラップし、同一 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@^7zod@^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 変更
  • PaymentStatus enum 値の追加・削除
  • 越境参照規約変更(例: 外部キーの導入、識別子の型変更)
  • 環境変数キー名・値書式の変更
  • ニックネーム正規化規則の変更
  • 集計クエリの正規定義(対象範囲・除外条件)の変更

これらが発生したら、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 --> VotingPeriodEnv

Architecture 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

LayerChoice / VersionRoleNotes
コンテンツ管理基盤microCMS(SaaS)candidates / creditPackages の保持、画像 CDN、運営者認証・権限API キーは読み取り専用権限のみアプリへ供給
ORMPrisma 7.8+Neon に対する型安全な ORM、マイグレーション管理@prisma/adapter-pg@^7 で接続
取引データストアNeon (PostgreSQL 17 互換)Vote / Purchase / PurchaseItem の永続化Branching 機能で PR 単位の独立環境
ローカル DBpostgres:17 (docker-compose)開発時の DB既存 docker-compose.yml を踏襲
デプロイ環境設定Vercel Environment VariablesVOTING_PERIOD_START / VOTING_PERIOD_ENDISO 8601(タイムゾーン付き)文字列
microCMS SDKmicrocms-js-sdk(最新)Server Component / Server Action からの読み取りNext.js Data Cache(60〜180 秒)に乗せる
入力検証zod ^4サーバー側の文字列バリデーションsteering 共通方針に準拠

Prisma 7 / @prisma/adapter-pg の選定根拠は steering tech.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.tsimages.remotePatterns に microCMS の画像配信ホストパターンを追加(候補者画像を next/image で読み込めるようにする)
  • package.jsonmicrocms-js-sdkdependencies に追加
  • prisma.config.ts — 既存ファイル、変更なし(prisma/schema.prismaprisma/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 モデル
  • VoteStatus enum、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: COMMIT

Key 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

ReqSummaryComponentsInterfacesFlows
1.1表示名必須・空文字不可microCMS candidates コレクション必須フィールド宣言
1.2趣味・特技/資格をリスト型microCMS candidates コレクション複数選択フィールド
1.3PR メッセージで改行保持microCMS candidates コレクションテキストエリアフィールド
1.40 件以上の画像関連付けmicroCMS candidates(画像フィールド多値)フィールド宣言
1.5公開状態で表示candidateServicegetCandidates が公開済みのみ返すFlow 1
1.6下書き・停止状態で非表示candidateService同上Flow 1
1.7物理削除を運用上禁止microCMS ロール/フィールド権限 + 運用ルールロール定義要件
2.1画像の必須属性microCMS 画像フィールドフィールド宣言
2.2MIME 型限定 (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.6active=true のみ提示creditPackageServicegetActiveCreditPackages のフィルタ
3.7価格改定は新規追加で対応運用ルール
4.1Vote の必須属性Prisma Vote modelcandidateId / purchaseId / nickname NOT NULL
4.2ニックネーム長 + トリムnicknameServicenormalizeNickname
4.3Vote は必ず Purchase 由来Prisma Vote.purchaseId NOT NULLスキーマ + Webhook ハンドラFlow 2
4.4Vote イミュータブル + 有効性は Purchase 由来Prisma + voteAggregationServiceJOIN + Purchase.status='SUCCEEDED'Flow 3
4.5総得票数集計対象voteAggregationServicegetVoteCountsByCandidateIdsFlow 1
4.6応援者ランキング集計voteAggregationServicegetTopVotersByCandidateId
4.7払戻時の集計除外voteAggregationServiceJOIN フィルタFlow 3
4.8コンテンツ停止時の取扱candidateService + voteAggregationService公開状態と集計の独立
5.1取引識別子 UNIQUEPrisma PurchaseUNIQUE 制約
5.2Purchase は PurchaseItem を 1 件以上アプリ層トランザクションPrisma $transaction で同時生成Flow 2
5.3決済成功時に Vote 一括生成Webhook ハンドラ + Prisma $transactionFlow 2
5.4失敗時 Vote を生成しないWebhook ハンドラFlow 2
5.5払戻時 Vote を変更しないWebhook ハンドラUPDATE Purchase.status のみFlow 3
5.6対象候補者が CMS 側で消えても孤立保持Prisma Purchase.candidateId は外部キー無し越境参照規約
6.1PurchaseItem 必須属性Prisma PurchaseItemNOT NULL 制約
6.2数量 ≥ 1Prisma + アプリ層検証CHECK 制約
6.3同 Purchase 内でパッケージ重複禁止Prisma UNIQUE(purchaseId, creditPackageId)UNIQUE 制約
6.4Purchase 削除で連鎖削除Prisma onDelete: Cascadeスキーマ宣言
6.5PurchaseItem イミュータブルアプリ層書き込み禁止規約サービスに UPDATE を実装しない
7.1開始・終了を ISO 8601 で保持Vercel ENVVOTING_PERIOD_START / _END
7.2終了 > 開始の検証lib/voting-period.ts起動時/初回参照時バリデーション
7.3唯一値として全画面に提供lib/voting-period.tsgetVotingPeriod()
7.4不正値はエラー扱いlib/voting-period.tsInvalidVotingPeriodError を throw
7.5変更は再デプロイで反映Vercel ENV 運用プラットフォーム機能

共通属性ルール(識別子・作成日時・更新日時)は Feature 4-6 の全 Prisma model に標準適用される。本表では各モデルの定義により暗黙的に網羅される。


Components and Interfaces

Summary

ComponentDomain/LayerIntentReq CoverageKey DependenciesContracts
microCMS candidates コレクションCMS候補者と画像の保持1.1-1.7, 2.1-2.7microCMS プラットフォーム(P0)State
microCMS creditPackages コレクションCMS票数パッケージの保持・イミュータブル制約3.1-3.7microCMS プラットフォーム(P0)、ロール/フィールド権限(P0)State
Prisma Vote modelDB1 票単位の記録、イミュータブル4.1-4.8Neon(P0)、Prisma 7(P0)State
Prisma Purchase modelDB決済記録、Vote 有効性の源5.1-5.6Neon(P0)、Stripe paymentIntent(P1)State
Prisma PurchaseItem modelDB購入明細、CreditPackage への越境参照6.1-6.5Neon(P0)State
lib/voting-period.tsConfig環境変数から { startsAt, endsAt } を提供7.1-7.5Vercel ENV(P0)Service
lib/prisma.tsInfraPrismaClient 共有インスタンス4-6 系統@prisma/client@7(P0)Service
lib/microcms.tsInframicroCMS Client 共有インスタンス1-3 系統microcms-js-sdk(P0)Service
features/cms/services/candidateService.tsServicecandidates の取得ラッパー1.5, 1.6, 2.4, 2.7microCMS client(P0)、Next.js Data Cache(P1)Service
features/cms/services/creditPackageService.tsServicecreditPackages の取得ラッパー3.6microCMS client(P0)Service
features/candidates/services/voteAggregationService.tsService得票数 / 応援者ランキングの集計4.4-4.8Prisma(P0)Service
features/voting/services/nicknameService.tsServiceニックネーム正規化4.2(純関数)Service

越境参照(Vote/Purchase/PurchaseItem → microCMS)は DB レベルの外部キーを持たない契約 を全モデルで採用。整合性は (a) microCMS 側で物理削除を運用上禁止、(b) 集計クエリで microCMS 公開済みコンテンツとの照合(features/cms/services 経由)で吸収する。


microCMS コレクション層

candidates コレクション

FieldDetail
Intent候補者および候補者画像の正本
Requirements1.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表示名textField1〜50 文字
region出身地textField1〜30 文字
age年齢number0〜120
height身長(cm)number50〜250
occupation職業textField1〜50 文字
hobbies趣味・特技repeater(text)0 件以上
certifications資格・大会実績repeater(text)0 件以上
motto座右の銘textField0〜100 文字
dreamtextField0〜200 文字
messagePR メッセージ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 コレクション

FieldDetail
Intent票数パッケージのマスタ(完全イミュータブル)
Requirements3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7

Schema 定義

API スキーマ名フィールド名必須補足
id識別子自動microCMS 発行
name名称textField1〜50 文字
credits票数number1 以上
priceJpy価格(税込円)number0 以上
displayOrder表示順number一覧の安定順
isActiveアクティブ状態booleantrue=販売中 / 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

FieldDetail
Intent候補者へ投じられた 1 票の不変記録
Requirements4.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

FieldDetail
Intent1 決済の結果と決済ステータスの正本
Requirements5.1, 5.2, 5.3, 5.4, 5.5, 5.6

Contracts: State [x]

State Transition: PROCESSING → SUCCEEDED | FAILEDSUCCEEDED → REFUNDED。逆遷移なし。

Dependencies

  • Inbound: Webhook ハンドラ(P0)
  • Outbound: Prisma purchaseItem テーブル(P0)、Prisma vote テーブル(P0、status='SUCCEEDED' 遷移時に一括生成)
  • External: Stripe paymentIntent.id(P0、UNIQUE 制約のキー)

Implementation Notes

  • Integration: PaymentIntent 作成時に status=PROCESSING で INSERT。Webhook で status を遷移
  • Validation: candidateId の越境参照妥当性は 挿入時点では検証しない(microCMS 取得の race を避ける)。集計時に未公開のものは画面側で除外
  • Risks: Webhook 配送遅延・重複 → paymentIntentId UNIQUE + status 状態機械で冪等化(voting/design.md で詳述)

PurchaseItem model

FieldDetail
IntentPurchase の構成要素(パッケージ × 数量)
Requirements6.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

FieldDetail
IntentVercel 環境変数から投票期間値を提供
Requirements7.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 <= startsAtInvalidVotingPeriodError('INVERTED_RANGE', ...)

Implementation Notes

  • Integration: home / voting 側は本関数経由でのみ投票期間を参照する。直接 process.env.VOTING_PERIOD_* を読まない
  • Validation: 初回呼び出し時にバリデーション。以後はキャッシュ
  • Risks: 期間変更後の旧キャッシュ → 再デプロイで Lambda が再生成されるため実害なし

ニックネーム正規化

features/voting/services/nicknameService.ts

FieldDetail
Intent投票者ニックネームの正規化と長さ制約
Requirements4.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 ルール:

  • PurchasePurchaseItem: onDelete: Cascade(明細は親 Purchase なしで存在しない)
  • PurchaseVote: 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

IndexPurpose
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 /candidatespublish status = publishednext.revalidate = 120 + Request Memoization(同一 Request 内 1 回)
candidateService.getCandidate(id)GET /candidates/:idpublish status = publishednext.revalidate = 120 + Request Memoization(同一 Request 内 1 回)
creditPackageService.getActiveCreditPackages()GET /creditPackagesfilters: '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 を直接呼び出すか、関数全体を React cache() でラップする。これにより generateMetadatapage.tsx の Server Component 描画で同一 ID を取得しても microCMS への HTTP は 1 回に統合される(candidate-detail spec の Performance 前提)。

越境参照規約

  • 取引データストアのモデルは candidateId / creditPackageIdmicroCMS のコンテンツ ID 文字列 で保持する
  • 集計時は microCMS 公開済みコンテンツとの照合 で整合性を吸収する(画面側で公開済みコンテンツ ID のホワイトリストでフィルタ)
  • DB レベルの外部キー制約は持たない

Error Handling

Error Strategy

エラー種別検出位置応答
microCMS API 障害candidateService / creditPackageService例外を throw、画面側でエラー表示(再試行はユーザー操作で)
投票期間 ENV 未設定/不正lib/voting-period.ts 初回参照InvalidVotingPeriodError を throw、ログ + 画面エラー
ニックネーム無効nicknameService.normalizeNicknameInvalidNicknameError を 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 TestsnormalizeNickname の NFKC・空白圧縮・長さ境界(0/1/32/33 文字)4.2
getVotingPeriod の未設定 / 不正書式 / endsAt <= startsAtInvalidVotingPeriodError を throw7.1, 7.2, 7.4
voteAggregationService.getVoteCountsByCandidateIdsPurchase.status='SUCCEEDED' のみ集計し、PROCESSING / FAILED / REFUNDED を除外4.4, 4.5, 4.7
voteAggregationService.getTopVotersByCandidateId がニックネーム単位でグループ化し降順 5 件で返す4.6
Integration TestsPurchase + 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 TestscandidateService.getCandidates が microCMS の draft / stopped を返さない(microCMS テスト環境向け)1.5, 1.6
getActiveCreditPackagesisActive=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 のデフォルトエスケープに依存。nicknameText 型で保存し、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-sdkgetList でバッチ化し、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-sdkcustomRequestInit への next.revalidate 連携
  • Prisma 7 公式: @prisma/adapter-pg@updatedAtonDelete: Restrict / Cascade、UNIQUE/CHECK 制約の生 SQL 適用
  • 本サイト steering: tech.md(技術選定根拠)、product.md(投票期間業務ルール)、structure.md(ディレクトリ規約と保有しないパス)
  • 他 spec: voting/design.md(購入フロー実装、Webhook 詳細)、home / candidate-detail / candidate-ranking(本 spec の Components を参照)