Skip to content

データモデル

LiGA リニューアルのドメインモデル。Master (低頻度編集) と Business (トランザクション) の責務境界、Publish-time Snapshot 方針。CMS は導入せず、全テーブルを Drizzle (packages/db) で一元管理する。

✅ 決定事項

採用方針

  • DB 構成: Postgres 1個・同一スキーマ・テーブル prefix なし。Better Auth の規定テーブル + 自前 master / business が同居
  • 責務境界 (テーブルの "性格" による論理分類):
    • Master / Template / Media = 低頻度編集、運営が /manage/masters/* の自作 admin UI から管理
    • Business (Tournament / Registration / Payment) = 状態遷移 / トランザクション / 集計 / 通知トリガー、Server Action または Hono API から書く
    • Auth = Better Auth が所有
  • Publish-time Snapshot: Tournament 公開時に master を凍結し、後から master が変わっても過去大会は不変
    • 各 master 参照を *_id (live FK) + *_snapshot (frozen JSONB) の両持ちにする
    • draft 中は live FK、公開後は snapshot を表示
  • 割引の階層:
    • タイムセール (period / fcfs) は プラン単位 (tournament_plans.timesale_template_id) — プランごとに異なる割引を設定可能
    • 学割 (student) は 大会全体 (tournaments.student_template_id) — 1大会1つまで
    • キャンペーン (campaigns) は 大会全体 (snapshot のみ、live は campaign_ids を別カラムで保持予定)
  • FK 制約: 全テーブルが同一 Drizzle スキーマなので DB レベルの外部キー制約を張れる。orphan 検出はアプリ層ではなく PG の制約で防ぐ (master の delete は ON DELETE RESTRICT、または論理削除)

ストレージ概観

─── Auth (Better Auth) ────────────────────────
  user, session, account, verification

─── Master / Template / Media (運営編集) ──────
  levels, sports, venues, prizes, organizers, campaigns
  discount_templates
  regulation_templates, judging_templates, rule_templates
  media                        -- 画像アップロード (sharp で 3サイズ生成)

─── Business (トランザクション) ───────────────
  tournaments                  -- 大会本体 (snapshot 込み)
  tournament_plans             -- プラン (子テーブル + snapshot)
  tournament_sub_prizes        -- 副賞品 (中間テーブル + snapshot)
  registrations                -- 申込
  payments                     -- (Phase 2) 決済
  notifications                -- (Phase 2) 通知ログ

Tournament スキーマ (Drizzle)

主テーブル

sql
CREATE TABLE tournaments (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  status          TEXT NOT NULL DEFAULT 'draft',
                  -- draft | scheduled | recruiting | full | done | cancelled
  published_at    TIMESTAMPTZ,                       -- 凍結時刻 (null = draft)

  /* ── 自前データ (常にここが Source of Truth) ── */
  title                  TEXT NOT NULL,
  subtitle               TEXT,
  date                   DATE NOT NULL,
  start_time             TEXT NOT NULL,              -- HH:MM
  end_time               TEXT NOT NULL,
  capacity               INT  NOT NULL,
  day_payment_enabled    BOOLEAN NOT NULL DEFAULT true,
  day_payment_surcharge  INT     NOT NULL DEFAULT 2000,
  description            TEXT,
  highlights             TEXT,                       -- 1行1項目
  regulation_override    TEXT,
  judging_override       TEXT,
  regulation_note        TEXT,
  judging_note           TEXT,
  rules_note             TEXT,
  shoe_note              TEXT,
  venue_note             TEXT,

  /* ── マスタ参照: live FK + frozen snapshot ── */
  level_id               UUID REFERENCES levels(id) ON DELETE RESTRICT,
  level_snapshot         JSONB,                       -- 公開時に embed
  sport_id               UUID REFERENCES sports(id) ON DELETE RESTRICT,
  sport_snapshot         JSONB,
  venue_id               UUID REFERENCES venues(id) ON DELETE RESTRICT,
  venue_snapshot         JSONB,
  organizer_id           UUID REFERENCES organizers(id) ON DELETE RESTRICT,
  organizer_snapshot     JSONB,

  /* ── 画像 ── */
  hero_image_id          UUID REFERENCES media(id) ON DELETE SET NULL,
  hero_image_url         TEXT,                       -- 凍結済 URL

  /* ── テンプレ凍結 (override/note を embed 済) ── */
  regulation_snapshot    JSONB,
  judging_snapshot       JSONB,
  rule_snapshot          JSONB,

  /* ── 割引 / キャンペーン (大会全体に効くもののみ) ── */
  /*    タイムセール (period / fcfs) はプランごとに独立に持つので tournament_plans 側 */
  student_template_id    UUID REFERENCES discount_templates(id) ON DELETE SET NULL,
  student_snapshot       JSONB,
  campaigns_snapshot     JSONB,                       -- [{ id, label, kind, amount }, ...]

  /* ── 表示制御 ── */
  show_level_banner      BOOLEAN DEFAULT true,
  show_regulation        BOOLEAN DEFAULT true,
  show_judging           BOOLEAN DEFAULT true,
  show_rules             BOOLEAN DEFAULT true,
  show_shoe_chart        BOOLEAN DEFAULT true,
  show_venue_intro       BOOLEAN DEFAULT true,

  created_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at             TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tournaments_status_date ON tournaments(status, date);
CREATE INDEX idx_tournaments_organizer   ON tournaments(organizer_id);

子テーブル

sql
CREATE TABLE tournament_plans (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tournament_id         UUID NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
  position              INT  NOT NULL,                   -- 表示順
  name                  TEXT NOT NULL,
  price                 INT  NOT NULL,
  prize_id              UUID REFERENCES prizes(id) ON DELETE RESTRICT,
  prize_snapshot        JSONB,                            -- 公開時 embed

  /* 代替商品 (ポイント等メイン以外で受け取り可)。1行1項目テキスト */
  alternative_rewards   TEXT,

  /* ── プラン単位のタイムセール (period / fcfs) ── */
  timesale_template_id  UUID REFERENCES discount_templates(id) ON DELETE SET NULL,
  timesale_snapshot     JSONB                             -- { kind: "period" | "fcfs", ... }
);

CREATE TABLE tournament_sub_prizes (
  tournament_id   UUID NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
  prize_id        UUID NOT NULL REFERENCES prizes(id) ON DELETE RESTRICT,
  prize_snapshot  JSONB,
  PRIMARY KEY (tournament_id, prize_id)
);

Snapshot の中身 (TypeScript 型)

各 JSONB は「画面表示に必要な最小限」だけ embed する:

ts
type LevelSnapshot = {
  code:        string;            // "Lv.2"
  name:        string;            // "REAL ENJOY CUP"
  label:       string;            // "エンジョイ志向"
  description: string;
  banner?: {
    catchphrase: string;
    tagline:     string;
    description: string;
    baseColor:   string;
    accentColor: string;
    shadowColor: string;
    watermark:   string;
  };
};

type SportSnapshot = {
  code:           string;         // "futsal_5"
  name:           string;
  playersPerSide: number;
};

type VenueSnapshot = {
  name:        string;
  prefecture:  string;
  area?:       string;
  address?:    string;
  access?:     string;
  type?:       string;            // コート種別推定用
};

type OrganizerSnapshot = {
  name:       string;
  shortName?: string;
  kind:       "self" | "partner";
  color?:     string;
  bannerUrl?: string;
};

type PrizeSnapshot = {
  name:       string;
  kind:       "uniform" | "cash" | "gift" | "experience" | "point" | "food";
  imageUrl?:  string;
  note?:      string;
};

type RegulationSnapshot = {
  format:        string;
  minGuaranteed: string;
  matchCount:    string;
  override?:     string;          // Tournament.regulation_override が入る
  note?:         string;          // Tournament.regulation_note が入る
};

type JudgingSnapshot = {
  refereeing:    string;
  qualification: string;
  notes?:        string;
  override?:     string;
  note?:         string;
};

type RuleSnapshot = {
  label: string;
  lines: string[];
  note?: string;
};

type DiscountSnapshot =
  | { kind: "period"; label: string; periods: { daysBefore: number; discount: number }[] }
  | { kind: "fcfs";   label: string; slotDiscounts: number[] }
  | { kind: "student"; label: string; discount: number };

公開ワークフロー

ts
/**
 * 公開時: master テーブルを読みに行き、JSONB に snapshot として凍結する
 * draft → scheduled に遷移
 */
async function publishTournament(id: string): Promise<void> {
  await db.transaction(async (tx) => {
    const t = await tx.query.tournaments.findFirst({
      where: eq(tournaments.id, id),
    });
    if (!t) throw new Error("not found");

    // 1. master を並列取得 (FK が張ってあるので joinAlias でも書けるが、
    //    snapshot 用に表示フィールドだけ抜き出すため個別 select が読みやすい)
    const [level, sport, venue, organizer, heroMedia] = await Promise.all([
      t.levelId     ? tx.query.levels.findFirst({     where: eq(levels.id,     t.levelId)     }) : null,
      t.sportId     ? tx.query.sports.findFirst({     where: eq(sports.id,     t.sportId)     }) : null,
      t.venueId     ? tx.query.venues.findFirst({     where: eq(venues.id,     t.venueId)     }) : null,
      t.organizerId ? tx.query.organizers.findFirst({ where: eq(organizers.id, t.organizerId) }) : null,
      t.heroImageId ? tx.query.media.findFirst({      where: eq(media.id,      t.heroImageId) }) : null,
    ]);

    // 2. sport + level の組でテンプレを解決
    const [regTpl, judgeTpl, ruleTpl] = await Promise.all([
      sport && level ? findTemplate(tx, regulationTemplates, sport.id, level.id) : null,
      sport && level ? findTemplate(tx, judgingTemplates,    sport.id, level.id) : null,
      sport && level ? findTemplate(tx, ruleTemplates,       sport.id, level.id) : null,
    ]);

    // 3. キャンペーン / 学割 (大会全体) も snapshot
    //    タイムセールはプランごとなので 5 で個別処理
    const campaigns = t.campaignIds?.length
      ? await tx.select().from(campaignsTable).where(inArray(campaignsTable.id, t.campaignIds))
      : [];
    const student   = t.studentTemplateId
      ? await tx.query.discountTemplates.findFirst({ where: eq(discountTemplates.id, t.studentTemplateId) })
      : null;

    // 4. 主テーブルを更新
    await tx.update(tournaments).set({
      levelSnapshot:      level     ? pickLevelDisplay(level)         : null,
      sportSnapshot:      sport     ? pickSportDisplay(sport)         : null,
      venueSnapshot:      venue     ? pickVenueDisplay(venue)         : null,
      organizerSnapshot:  organizer ? pickOrganizerDisplay(organizer) : null,
      heroImageUrl:       heroMedia?.url ?? null,
      regulationSnapshot: regTpl ? {
        ...pickRegulationDisplay(regTpl),
        override: t.regulationOverride || undefined,
        note:     t.regulationNote     || undefined,
      } : null,
      judgingSnapshot: judgeTpl ? {
        ...pickJudgingDisplay(judgeTpl),
        override: t.judgingOverride || undefined,
        note:     t.judgingNote     || undefined,
      } : null,
      ruleSnapshot: ruleTpl ? {
        ...pickRuleDisplay(ruleTpl),
        note: t.rulesNote || undefined,
      } : null,
      studentSnapshot:   student  ? pickDiscountDisplay(student)  : null,
      campaignsSnapshot: campaigns.map(pickCampaignDisplay),
      status:            "scheduled",
      publishedAt:       sql`NOW()`,
      updatedAt:         sql`NOW()`,
    }).where(eq(tournaments.id, id));

    // 5. プラン子テーブルにも snapshot (賞品 + プラン単位タイムセール)
    const plans = await tx.query.tournamentPlans.findMany({
      where: eq(tournamentPlans.tournamentId, id),
    });
    for (const plan of plans) {
      const prize = plan.prizeId
        ? await tx.query.prizes.findFirst({
            where: eq(prizes.id, plan.prizeId),
            with: { image: true },              // FK 経由で media を expand
          })
        : null;
      const timesale = plan.timesaleTemplateId
        ? await tx.query.discountTemplates.findFirst({
            where: eq(discountTemplates.id, plan.timesaleTemplateId),
          })
        : null;

      await tx.update(tournamentPlans).set({
        prizeSnapshot: prize ? {
          name:     prize.name,
          kind:     prize.kind,
          imageUrl: prize.image?.url,
          note:     prize.note,
        } : null,
        timesaleSnapshot: timesale ? pickDiscountDisplay(timesale) : null,
      }).where(eq(tournamentPlans.id, plan.id));
    }

    // 6. 副賞品も同様 (省略)
  });
}

表示ロジック

ts
/**
 * 表示時: 公開済なら snapshot 優先、draft なら live を引く
 */
async function resolveTournamentForDisplay(t: TournamentRow) {
  if (t.publishedAt) {
    // 凍結済 — master を引かずに snapshot だけで構築
    // タイムセールはプランごとなので、別途 tournament_plans を引いて plan.timesaleSnapshot を表示
    return {
      level:      t.levelSnapshot,
      sport:      t.sportSnapshot,
      venue:      t.venueSnapshot,
      organizer:  t.organizerSnapshot,
      heroImage:  t.heroImageUrl,
      regulation: t.regulationSnapshot,
      judging:    t.judgingSnapshot,
      rules:      t.ruleSnapshot,
      campaigns:  t.campaignsSnapshot,
      student:    t.studentSnapshot,
    };
  }
  // draft — 編集中はマスタ変更が即反映する旨味があるので live ref (FK joinAlias で1クエリ)
  return await loadLiveTournament(t);
}

ステータス状態遷移

        ┌──────┐  publish()
        │draft │ ──────────────┐
        └──────┘               │

                       ┌──────────────┐  open()    ┌────────────┐
                       │  scheduled   │ ─────────▶ │ recruiting │
                       └──────┬───────┘            └─────┬──────┘
                              │                          │
                              │ 受付期間に到達           │ capacity 到達
                              ▼                          ▼
                       (auto: open)               ┌──────────┐
                                                  │   full   │
                                                  └────┬─────┘
                                                       │ 大会日経過

                                                  ┌─────────┐
                                                  │  done   │
                                                  └─────────┘

      cancel() → cancelled  (どの状態からでも遷移可)
      publish() を再度呼ぶ → 「再公開」 = snapshot 再取得 + status 維持

再公開 (Re-Snapshot)

公開後にマスタを変更したい場合:

  1. 運営が /manage/masters/* で master を更新
  2. 該当 Tournament の管理画面で 「マスタを再取り込み」 ボタンを押す
  3. publishTournament(id) を再実行 (status は scheduled のまま、published_at は更新)
  4. snapshot が新しい値で上書きされる

申込が既に入っている Tournament を再公開する時は警告を出す (参加者に表示されている内容が変わる)。

編集可能性の制約

status編集可能なフィールド
draft全項目
scheduled全項目 (申込前なので影響少。ただし価格変更は警告)
recruiting補足テキスト系のみ (価格・プラン・date は不可、申込者に既に見せた情報)
full同上
done不可 (履歴のみ)
cancelled不可

参照整合性 / orphan 対策

全テーブルが同一 Drizzle スキーマに乗っているので DB レベルの FK 制約をそのまま張れる:

  • Master の delete: ON DELETE RESTRICT。Tournament が参照中なら DB が拒否 → admin UI で「使用中のため削除できません」と表示
  • Tournament の delete: 子テーブル (tournament_plans, tournament_sub_prizes) は ON DELETE CASCADE
  • Media の delete: tournaments.hero_image_idON DELETE SET NULL (画像が消えても Tournament 自体は残す。snapshot 側に URL が凍結されているので公開済は影響なし)
  • 公開済 Tournament: snapshot が残るので表示は不変。FK が live 参照を守るので orphan が発生しない
  • draft Tournament: master を間違って ID 指定したら insert/update 時に FK エラー → editor で再選択を促す

Master / Template / Media

/manage/masters/* の自作 admin UI から運営が編集する。全テーブル packages/db で定義:

分類テーブル名件数主な責務
Masterlevels5レベル (Lv.1〜4 / Cクラス)
sports5競技
venues23+会場
prizes30+賞品カタログ
organizers5前後主催者
campaigns数件キャンペーンバッジ
Templatediscount_templates8前後割引テンプレ (period/fcfs/student を kind で分岐)
regulation_templates12規定 (sport×level)
judging_templates12審査 (sport×level)
rule_templates12ルール (sport×level)
Mediamedia画像アップロード (本番: R2 / dev: 公開ディレクトリ)

補足

📂 旧案: Tournament を Payload に置く (採用前の検討)

設計初期は Tournament を Payload Collection で持ち、Registration だけ Hono+DB で進めていた。それを以下の理由で改訂し、最終的には Payload 自体を撤去:

  • Tournament は時間が経つほど CMS よりトランザクション要素が支配的
    • ステータス遷移 (draft → scheduled → recruiting → full → done)
    • 状態変化時の副作用 (通知 / 締切 / 振替案内)
    • 集計・レポート (主催者別売上 / 期間別)
  • 大会作成 UI (/admin/tournaments/new) は既に Next.js 側で自作してあり、Payload auto-admin の旨味が少ない
  • 公開後の master 変更が過去大会に波及する問題が看過できない (Snapshot で解決可能だが、それなら最初から自前 DB に置く方が筋が通る)
  • master 自体も数十行スケールなので、Payload の auto-admin より自作 UI のほうがブランドが揃う

最終形は 全テーブル Drizzle 一元管理 に集約。Payload 撤去の経緯は サーバー構成 末尾の補足も参照。

領域配置 (初期 B 案)配置 (中間案)配置 (採用 C 案)
Master / Template / MediaPayloadPayloadDrizzle (/manage/masters/* 自作 UI)
TournamentPayloadHono+DBDrizzle (Server Action から書く / Hono からも参照)
RegistrationHono+DBHono+DBDrizzle (Hono が書く)
📂 Snapshot vs Live ref のトレードオフ
観点Snapshot (採用)Live ref のみ
master 変更の波及◎ 影響なし× 過去大会も変わる
履歴の整合性×
サイズ△ (JSONB に embed で増える)
クエリ複雑度◎ (1回で完結)△ (毎回 join)
マスタ変更の即時反映 (draft)△ (再 publish 必要)

draft 中だけ live、公開後は snapshot、というハイブリッド採用で両得。

📂 4分類の旧整理 (master / template / transaction / registration)

データを以下の4分類で見ていた時期があった。今は 全テーブル Drizzle 一元管理 に集約しており、分類は論理的な責務 (低頻度編集 vs トランザクション) のラベルに留める:

  • MASTER: Level, Sport, Venue, Prize, Organizer, Campaign
  • TEMPLATE: DiscountTemplate, Regulation/Judging/RuleTemplate
  • TRANSACTION: Tournament (Server Action / Hono が書く)
  • REGISTRATION: Registration (Hono が書く)
📂 Tournament フィールド対応表 (UI ←→ Drizzle)

/tournaments/new (Next 自作 UI) で消費されているフィールドの対応:

フィールドカラムrequired補足
titletitleTEXT
subtitlesubtitleTEXT
heroImageSrchero_image_id + hero_image_urlTEXTupload → media に保存
levelIdlevel_id (+ snapshot)TEXT
sportIdsport_id (+ snapshot)TEXT
venueIdvenue_id (+ snapshot)TEXT
organizerIdorganizer_id (+ snapshot)TEXT
date / startTime / endTimedate / start_time / end_timeDATE / TEXT
capacitycapacityINT上限のみ。残枠は派生
dayPaymentEnabled / dayPaymentSurchargeday_payment_*BOOLEAN / INT
plans[]tournament_plans (子)プラン単位の timesale も子側に持つ
plans[].timesaletournament_plans.timesale_template_id (+ snapshot)UUID + JSONBプラン単位 (期間 / 先着)
subPrizeIdstournament_sub_prizes (中間)
campaignIds(未列、campaigns_snapshot に入る)JSONB大会全体
studentDiscountstudent_template_id (+ snapshot)UUID + JSONB大会全体
highlights / descriptionhighlights / descriptionTEXT
regulationOverride / judgingOverrideregulation_override / judging_overrideTEXT
regulationNote / judgingNote / rulesNote / shoeNote / venueNote*_noteTEXT
showLevelBanner / showRegulation / showJudging / showRules / showShoeChart / showVenueIntroshow_*BOOLEAN
📂 Registration スキーマ (Drizzle) — 既決
sql
CREATE TABLE registrations (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tournament_id   UUID NOT NULL REFERENCES tournaments(id)        ON DELETE RESTRICT,
  plan_id         UUID NOT NULL REFERENCES tournament_plans(id)   ON DELETE RESTRICT,
  user_id         TEXT NOT NULL REFERENCES "user"(id)             ON DELETE RESTRICT,
  team_name       TEXT NOT NULL,
  captain_email   TEXT NOT NULL,
  status          TEXT NOT NULL DEFAULT 'pending',
                  -- pending | confirmed | cancelled | waitlist
  payment_method  TEXT,           -- online | on_day | unpaid
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  cancelled_at    TIMESTAMPTZ
);

CREATE INDEX idx_registrations_tournament ON registrations(tournament_id, status);
CREATE INDEX idx_registrations_user       ON registrations(user_id);

/availability?ids=...SELECT capacity - COUNT(active registrations) で集計。

user_id は Better Auth の user テーブル (text PK) を参照。マイページの「自分の申込」表示や、申込キャンセル時の本人確認に使う。