@event/booking の予約系(reservation.ts)は、xdeal-lp のワークショップ予約(room/sessionId/foursSlug 固有)を汎用資源モデルへ一般化したもの。room → resourceKey、sessionId → slotId(定員を数える単位)、foursSlug → identityId + verify コールバック注入、WORKSHOP_RESERVATIONS_OPEN 定数 → BookingConfig の kill-switch 注入。server-only lib(UI 非同梱)で、drizzle Postgres driver(neon-http / node-postgres 両対応)を注入する。
テーブル factory — defineReservationTable / reservationTableSql
defineReservationTable("event_reservation") が drizzle の予約テーブルを返す。主要カラムは resourceKey(booth/seat/table/session…)・slotId(定員の単位)・status(reserved / waitlist / cancelled)・name・任意の email / company / identityId / ip。
内部パッケージはビルド成果物を作らない(TS を直接 transpile する)方針なので、DDL は各アプリが手書き migration で適用する。table 定義と DB の真実源をずらさないため、同じ構造の CREATE 文を reservationTableSql(tableName) が文字列で返す(冪等・非破壊 = IF NOT EXISTS)。これを migration ファイルに落として第15章の apply スクリプトで流す。
定員 & キャンセル待ちの判定
思想は xdeal-lp を「そのまま」踏襲する: 当該 slotId の reserved 件数が capacity に達していれば waitlist、未満なら reserved をサーバ側で決定する。client の自己申告は使わない。
const store = createReservationStore({ db, table, capacity: 40 });
const a = await store.getSlotAvailability("2025-06-11-AM");
// → { reserved, waitlist, capacity, remaining, full }
SlotAvailability の remaining = max(0, capacity − reserved)、full = reserved >= capacity。残席表示はこの型をそのまま描けばよい。集計は countBySlot()(slot 単位)と countByResource()(resource 単位)が status 別 group で返す(cancelled は母集団から外れる)。
冪等 dedup と partial-unique index backstop
二重予約・定員水増しを二段で防ぐ:
- アプリ層の冪等 dedup —
createReservation(input)は insert 前にfindActiveDuplicate(slotId, email)を引く。同一 slot × 同一メール(大小無視 =lower(email))の有効予約(reserved/waitlist)が既にあれば、新規 insert せず既存行を{ reservation, deduped: true }で返す - DB 層の最終防波堤 — テーブルには
partial UNIQUE (slot_id, lower(email)) WHERE status IN ('reserved','waitlist') AND email IS NOT NULLが張ってある。同時 insert 競合でアプリ層のカウントをすり抜けても、DB がユニーク制約で弾く。createReservationは insert が制約違反で throw したらfindActiveDuplicateを引き直して既存行を返す(例外を握って冪等結果に変換する)
WHERE status IN (...) の部分インデックスなのがミソ: cancelled を除外するので、同じ人が「取消 → 再予約」できる。email 非 NULL 条件で、メール未入力の当日枠は制約対象外にする。
競合は「厳密なロックはしない・単純カウントで許容し partial unique を最終防波堤にする」設計。高頻度の同時 insert で稀に定員 +1 を許容し得るが、二重課金・二重席の実害が出るのは同一人物の重複であり、それは unique index が確実に止める。
kill-switch — defineBookingConfig / BookingConfig
公開サーフェス共通の受付フラグ(旧 WORKSHOP_RESERVATIONS_OPEN)。defineBookingConfig(open, closedMessage?) が { open, closedMessage } を返す。open=false のとき、公開フォームは受付終了案内のみを表示し、server action も { ok:false, reason:"closed" } を返す backstop になる。DB / admin は非破壊で残す。再開は open=true だけ。
env の boolean で倒す運用が典型(process.env.BOOKING_OPEN !== "0")。BookingActionsOptions.config は関数でも渡せる(() => BookingConfig | Promise<BookingConfig>)ので、remote flag / 時間帯開閉にも対応する(毎回評価される)。
createBookingActions — 送信の一気通貫
createBookingActions({ store, config, rateLimit?, verify?, honeypotField? }) の submit(raw) を server action / route handler の冒頭でそのまま呼べる(Next 非依存)。処理順は固定:
- kill-switch — closed なら何も保存せず
reason:"closed" - honeypot — bot が埋めるダミー欄(既定
website)に値があればreason:"honeypot"(呼び出し側は 200 を返す想定 = bot に成否を悟らせない) - 入力検証 —
createReservationSchema(zod)。失敗はreason:"invalid"+ フィールド別errors - 本人確認 —
verifyがあれば返り値(VerifiedIdentity)で name/email/identityId/company を上書きする(自己申告を信用しない)。null 返却でreason:"unverified" - レート制限 —
countRecentByIp(ip, windowSec)が上限に達していればreason:"rate_limited" - 予約作成 — 定員 → reserved/waitlist を store が決定・冪等 dedup
成功時は { ok:true, deduped, status, reservation }。status を見て「ご予約が確定しました / キャンセル待ちで承りました」を出し分ける。BookingSubmitResult は成功と各失敗理由の判別可能ユニオンなので、UI は reason で網羅的に分岐できる。
cancel / reinstate / delete
cancelReservation(id) は status='cancelled' にするだけ(席は解放され reserved 集計から外れる。監査のため物理削除はしない)。reinstateReservation(id) は運用判断で reserved に戻す(定員チェックはしない)。deleteReservation(id) は物理削除。admin はこの3操作を予約一覧に並べる。