ガイド 第29

予約 & キャパシティ

@event/booking

@event/booking の予約系(reservation.ts)は、xdeal-lp のワークショップ予約(room/sessionId/foursSlug 固有)を汎用資源モデルへ一般化したもの。roomresourceKeysessionIdslotId定員を数える単位)、foursSlugidentityId + 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(定員の単位)・statusreserved / 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 }

SlotAvailabilityremaining = max(0, capacity − reserved)full = reserved >= capacity。残席表示はこの型をそのまま描けばよい。集計は countBySlot()(slot 単位)と countByResource()(resource 単位)が status 別 group で返す(cancelled は母集団から外れる)。

冪等 dedup と partial-unique index backstop

二重予約・定員水増しを二段で防ぐ:

  1. アプリ層の冪等 dedupcreateReservation(input) は insert 前に findActiveDuplicate(slotId, email) を引く。同一 slot × 同一メール(大小無視 = lower(email))の有効予約(reserved/waitlist)が既にあれば、新規 insert せず既存行を { reservation, deduped: true } で返す
  2. 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 非依存)。処理順は固定:

  1. kill-switch — closed なら何も保存せず reason:"closed"
  2. honeypot — bot が埋めるダミー欄(既定 website)に値があれば reason:"honeypot"(呼び出し側は 200 を返す想定 = bot に成否を悟らせない)
  3. 入力検証createReservationSchema(zod)。失敗は reason:"invalid" + フィールド別 errors
  4. 本人確認verify があれば返り値(VerifiedIdentity)で name/email/identityId/company を上書きする(自己申告を信用しない)。null 返却で reason:"unverified"
  5. レート制限countRecentByIp(ip, windowSec) が上限に達していれば reason:"rate_limited"
  6. 予約作成 — 定員 → 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操作を予約一覧に並べる。