Appearance
データモデル
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 が所有
- Master / Template / Media = 低頻度編集、運営が
- Publish-time Snapshot: Tournament 公開時に master を凍結し、後から master が変わっても過去大会は不変
- 各 master 参照を
*_id(live FK) +*_snapshot(frozen JSONB) の両持ちにする - draft 中は live FK、公開後は snapshot を表示
- 各 master 参照を
- 割引の階層:
- タイムセール (
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)
公開後にマスタを変更したい場合:
- 運営が
/manage/masters/*で master を更新 - 該当 Tournament の管理画面で 「マスタを再取り込み」 ボタンを押す
publishTournament(id)を再実行 (status はscheduledのまま、published_atは更新)- 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_idはON 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 で定義:
| 分類 | テーブル名 | 件数 | 主な責務 |
|---|---|---|---|
| Master | levels | 5 | レベル (Lv.1〜4 / Cクラス) |
sports | 5 | 競技 | |
venues | 23+ | 会場 | |
prizes | 30+ | 賞品カタログ | |
organizers | 5前後 | 主催者 | |
campaigns | 数件 | キャンペーンバッジ | |
| Template | discount_templates | 8前後 | 割引テンプレ (period/fcfs/student を kind で分岐) |
regulation_templates | 12 | 規定 (sport×level) | |
judging_templates | 12 | 審査 (sport×level) | |
rule_templates | 12 | ルール (sport×level) | |
| Media | media | — | 画像アップロード (本番: 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 / Media | Payload | Payload | Drizzle (/manage/masters/* 自作 UI) |
| Tournament | Payload | Hono+DB | Drizzle (Server Action から書く / Hono からも参照) |
| Registration | Hono+DB | Hono+DB | Drizzle (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 | 補足 |
|---|---|---|---|---|
| title | title | TEXT | ✓ | |
| subtitle | subtitle | TEXT | ||
| heroImageSrc | hero_image_id + hero_image_url | TEXT | upload → media に保存 | |
| levelId | level_id (+ snapshot) | TEXT | ✓ | |
| sportId | sport_id (+ snapshot) | TEXT | ✓ | |
| venueId | venue_id (+ snapshot) | TEXT | ✓ | |
| organizerId | organizer_id (+ snapshot) | TEXT | ✓ | |
| date / startTime / endTime | date / start_time / end_time | DATE / TEXT | ✓ | |
| capacity | capacity | INT | ✓ | 上限のみ。残枠は派生 |
| dayPaymentEnabled / dayPaymentSurcharge | day_payment_* | BOOLEAN / INT | ||
| plans[] | tournament_plans (子) | — | ✓ | プラン単位の timesale も子側に持つ |
| plans[].timesale | tournament_plans.timesale_template_id (+ snapshot) | UUID + JSONB | プラン単位 (期間 / 先着) | |
| subPrizeIds | tournament_sub_prizes (中間) | — | ||
| campaignIds | (未列、campaigns_snapshot に入る) | JSONB | 大会全体 | |
| studentDiscount | student_template_id (+ snapshot) | UUID + JSONB | 大会全体 | |
| highlights / description | highlights / description | TEXT | ||
| regulationOverride / judgingOverride | regulation_override / judging_override | TEXT | ||
| regulationNote / judgingNote / rulesNote / shoeNote / venueNote | *_note | TEXT | ||
| showLevelBanner / showRegulation / showJudging / showRules / showShoeChart / showVenueIntro | show_* | 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) を参照。マイページの「自分の申込」表示や、申込キャンセル時の本人確認に使う。