2026.01

TOKOSEKI

座席管理システム

Skills

「どこにいる?」をなくすために 座席表ではなく座席運用システムを作った話

TOKOSEKI は、オフィス、イベント会場、教育機関、店舗などで使える座席管理システムです。

ひとことで説明すると「どこにいる?」をなくすためのサービスです。座席表を作るだけではなく、誰がどこにいるのか、どの席が使われているのか、どの部屋が予約されているのか、どの運用ルールが適用されるのかを、ひとつのワークスペース上で扱えるようにしています。

この記事では、TOKOSEKI を作り始めた背景と、実装していく中で設計上どこが難しかったのかを書きます。プロダクト紹介というより、座席という一見シンプルな対象を業務システムとしてどうモデル化したか、Next.js App Router のアプリとしてどう分割したか、リアルタイム性や権限、予約、運用をどう扱ったかに焦点を当てます。

作り始めたきっかけ

座席管理は、一見すると単純です。

座席表があり、席に番号があり、誰が座っているかが分かればよさそうに見えます。しかし実際の運用では、それだけでは足りません。

たとえば、フリーアドレスのオフィスでは「今空いている席」だけでなく、「その席を使ってよい人」「予約されている時間帯」「長時間占有を避けるルール」「同じ席を連続して使ってよいか」「特定の設備があるか」まで問題になります。

イベント会場や教育機関では、座席表はさらに別の意味を持ちます。参加者の配置、受付、代理操作、会場内の部屋、時間ごとの予約、出欠やステータス、後から確認できる履歴などが必要になります。

つまり、座席は単なる UI 上の四角形ではありません。

座席は、場所、メンバー、予約、権限、時間、履歴が交差する状態管理の中心です。

TOKOSEKI を作り始めた動機は、この「座席表だけでは現場の運用を支えきれない」という違和感でした。既存の表計算や手作りの座席図では、最初の作成はできても、日々変わる状態を共有し続けるのが難しい。現場では「最新の情報はどれか」「誰が更新したのか」「どのルールで運用しているのか」が曖昧になりがちです。

そこで、座席を静的な図としてではなく、運用データの入口として扱うシステムを作ることにしました。

TOKOSEKI の構成

コードベースは pnpm workspace のモノレポです。主なアプリケーションは次のように分かれています。

  • apps/lp: 公開 LP とプラン情報の配信
  • apps/admin: ワークスペース運営者、管理者向けの管理画面
  • apps/app: ワークスペースメンバー向けの利用者アプリ
  • apps/support: 運営、サポート担当者向けの内部管理画面
  • apps/cron: 定期処理専用の Next.js API route
  • apps/docs: ドキュメントサイト
  • apps/signage: サイネージ向け画面

共通パッケージは次のように分けています。

  • packages/common: 共通 UI、型、定数、ユーティリティ
  • packages/message: 翻訳 JSON
  • packages/db: PostgreSQL / Drizzle ORM の schema、repository、migration、seed
  • packages/service: 業務ロジック
  • packages/email: メールテンプレート

フロントエンドは Next.js App Router、React、TypeScript を中心にしています。状態管理には Jotai と TanStack Query、UI には Tailwind CSS と daisyUI、マップ編集や表示には React Konva、組織図などには XY Flow を使っています。国際化は next-intl と packages/message に寄せています。

ここで意識したのは、画面ごとの便利実装に寄せすぎないことです。

TOKOSEKI は、管理者が座席やレイアウトを作り、利用者がその状態をリアルタイムに見るプロダクトです。つまり、同じデータを違う権限、違う UI、違う頻度で扱います。そこで page や component から DB を直接触るのではなく、基本的に service 層を通す構成にしています。

管理者アプリと利用者アプリを分けた理由

TOKOSEKI には大きく分けて 2 つの利用者がいます。

ひとつは、ワークスペースを設計して運用する管理者です。管理者はレイアウト、座席、部屋、メンバー、組織、役職、設備、タグ、予約、通知、監査ログ、プラン契約などを扱います。

もうひとつは、日々そのワークスペースを使うメンバーです。利用者は自分の座席を探し、着席、離席、移動、予約、プロフィール更新、メンバー検索、通知確認などを行います。

この 2 つは似ているようで、画面の責務が違います。

管理者画面では、誤操作を防ぎながら大量の設定を編集できることが重要です。利用者画面では、スマートフォンからでも今すぐ座席や予約を確認できることが重要です。

そのため apps/adminapps/app を分けています。共通のドメインロジックは packages/service に置き、UI とアプリ固有の薄い補助処理だけを各 app に持たせます。

この分割は、開発中の迷いを減らす効果もありました。

たとえば、座席の予約可否や利用時間制限はドメインルールなので service に置く。一方で、管理者向けの編集フォームや利用者向けの座席操作 UI は各 app に置く。この境界を守ることで、同じルールを複数の画面で再実装しないようにしています。

座席をどうモデル化したか

TOKOSEKI の中心にあるのは workspace_seat です。ただし、このテーブルは単に seat_number を持つだけではありません。

実際には、座席は次のような属性を持ちます。

  • どのワークスペースに属するか
  • どのレイアウト上に配置されているか
  • どのノードとして描画されるか
  • どの部屋に含まれるか
  • どの座席タグが付いているか
  • 座席番号や座席コード
  • 利用可能か
  • 予約可能か
  • 予約中でも使用できるか
  • 同じメンバーの連続利用を許可するか
  • 利用時間制限を持つか
  • 利用時間超過時に自動で空席化するか
  • ランダム着席やシャッフルの対象に含めるか

コード上でも、allowTakeSeatallowReservationallowUseWhenReservedallowConsecutiveUseusageTimeLimitMinutesautoVacateOnUsageLimitExceededincludeInShuffle のようなフィールドとして表現しています。

この時点で、座席は「配置された図形」ではなくなります。

座席は、現場ルールを持つリソースです。

さらに、座席へのアクセス制御もメンバー、組織、役職の 3 系統で持っています。個別メンバーに許可やブロックを設定でき、組織や役職にも同じような制御を持たせられます。役職や組織には階層があるため、単純な ID 一致だけではなく、親子関係も見ながら判定する必要があります。

ここが座席管理の難しいところです。

「この席は空いているか」だけなら簡単です。しかし、実際に必要なのは「このメンバーは、今、この席を、現在の予約状態と運用ルールのもとで使ってよいか」です。

予約は座席と部屋の両方にある

TOKOSEKI では、座席予約と部屋予約を扱います。

座席は個人の利用に近いリソースですが、部屋は会議室や休憩室のように、複数人や目的単位で予約されることがあります。どちらも時間範囲を持ち、重複を避ける必要があります。

DB では、予約テーブルに startAtendAt を持たせ、開始時刻が終了時刻より前であることを制約として置いています。座席予約については、同一座席の重複予約を防ぐ EXCLUDE 制約も migration 側で管理しています。

また、予約には代理予約があります。

自分の予約だけでなく、受付担当者や管理者が他のメンバーの予約を作成するケースがあります。TOKOSEKI では、代理予約を無効、全メンバー許可、特定役職のみ許可のように設定できるようにしています。

この判定は reservationProxyService に寄せています。操作メンバーと予約対象メンバーが同じなら許可し、違う場合はワークスペースの代理予約設定を確認する。役職制限がある場合は、メンバーに割り当てられた役職と役職階層を使って判定する。

UI だけで「管理者ならできる」と雑に決めるのではなく、service 層で予約の権限を判定することで、Route Handler や画面が増えても同じルールを使えるようにしています。

リアルタイム性を master live mutation に分ける

座席管理でリアルタイム同期は重要です。

誰かが席を使い始めたら、他の人の画面にもすぐ反映されてほしい。予約が作成されたら、地図上の見え方も変わってほしい。メンバーのステータスや一言メッセージも、できれば画面更新なしで反映したい。

ただし、すべてのデータを同じ扱いにすると破綻します。

TOKOSEKI では workspace data を masterlivemutation の 3 レーンに分けています。

  • master: レイアウト、組織、役職、場所、部屋、座席タグ、設備、ウィジェットなど、低頻度で変わる構造や設定
  • live: メンバーの現在状態、座席の現在利用、当日予約など、高頻度で変わる現況
  • mutation: DB 更新と更新通知の入口

master は generation 付き Redis cache に載せます。ワークスペースの構造が変わったら、同じ DB transaction 内で workspace_generation を上げます。cache key に generation を含めることで、古い key の削除に正しさを依存しないようにしています。

一方で live は cache に載せません。現在値や当日状態は古い情報が UX を壊しやすいので、DB を source of truth とし、更新後に Ably または Redis SSE で通知します。

開発環境では Redis SSE、本番相当では Ably を前提にしています。transport は違っても、event payload の契約は同じにする方針です。

この分離はかなり重要でした。

「座席一覧を速くしたい」からといって何でも cache に載せると、現在着席している人や当日の予約まで古くなります。逆に、すべてを毎回 DB から取ると、低頻度の構造データまで毎回取り直すことになります。

座席管理では、構造と現況を分けることが、性能と整合性の両方に効きます。

利用者画面の状態管理

利用者アプリでは、protected 配下の多くの画面が同じ Jotai store を共有しています。

最初に (protected)/layout.tsx で、全画面共通の最小ベースラインだけを hydrate します。たとえば、ワークスペースとプラン、レイアウト一覧、場所、カスタムステータス、通知未読数、座席利用時間制限の snapshot などです。

一方で、メンバー一覧、組織、座席エンティティ、予約行など、画面ごとに必要なデータは最初から全部は持ちません。

各画面が「この画面にはこの target が必要」と宣言し、まだ loaded でない target または invalidated 済み target だけを /api/data から補完します。realtime 更新を受けたときも、更新された target と今の画面に必要な target の交差だけを再取得します。

これにより、過去に一度見た画面の履歴ではなく、今の画面に必要なデータを基準に fetch を制御できます。

ただし、mapseats は例外です。

座席マップは、メンバー、組織、役職、座席、座席アクセス、設備、部屋、ノード、ウィジェット、当日予約など、多数の依存が揃わないと UI として成立しません。そのため、初回表示では server 側で viewer snapshot をまとめて作り、Jotai store に置き換えます。

すべてを同じ抽象に押し込まず、軽量画面と重い viewer 画面で読み込み方を分けています。

セキュリティとセッション

TOKOSEKI は日常的に使われる業務系のサービスなので、認証とセッション管理も重要です。

認証は Auth.js の JWT セッションを使いながら、web_session テーブルも併用しています。

JWT だけでは、サーバー側で「どのブラウザが現在有効か」を追跡したり、個別セッションを revoke したりするのが難しくなります。そこで、ログイン時に web_session を作成し、その publicId を JWT に持たせています。

セッション参照時には、JWT 内の publicSessionId と principal を使って web_session を検証します。revokedAt が空で、有効期限内であれば有効とし、lastUsedAtexpiresAt を更新します。ログアウトや強制ログアウトでは web_session を revoke します。

また、管理者アプリと利用者アプリでは Passkey も扱います。利用者アプリでは、ワークスペース ID と auth ID を使う member credentials 認証に加えて Passkey を使えるようにしています。

レート制限はアプリ内 middleware ではなく Vercel WAF に寄せています。対象も全 API ではなく、認証ページ、認証 API、破壊的変更 API に絞っています。アプリ側では Redis を rate limit に使わず、Redis は cache、SSE、Pub/Sub の用途に集中させています。

定期処理

TOKOSEKI の定期処理は apps/cron に集約しています。

主な cron は次の 3 つです。

  • 定期リセットルールの実行
  • 座席利用時間制限を超えた利用の自動解除
  • 期限切れセッション、期限切れ招待、期限切れ token、期限切れ下書き画像などの cleanup

定期リセットは、毎時実行されます。条件に一致する座席利用を DB 上で解除し、必要に応じて realtime で再取得イベントを送ります。

座席利用時間制限は 10 分ごとに実行します。usageTimeLimitMinutes を超えた座席利用を batch で解除し、更新があったワークスペースに通知します。

cleanup は即時性が不要なので、深夜にまとめて実行します。

ここでも意識しているのは、ユーザーに見える状態へ影響するものだけを高頻度にすることです。すべての cleanup を高頻度に回すのではなく、状態反映に必要な処理と、遅れても問題ない処理を分けています。

ストレステスト

座席管理システムでは、同時更新に耐えられるかが重要です。

TOKOSEKI には pnpm db:stress というストレステストがあります。HTTP API やブラウザは経由せず、既存 service 層を Node CLI から直接呼び出します。

検証対象は、PostgreSQL への更新負荷、Drizzle / service 層の transaction 整合性、workspace data cache invalidation、realtime event 発火、pool 挙動です。

デフォルトでは 500 owner から 999 ワークスペース規模を作り、各ワークスペースに Premium プラン、3 レイアウト、300 席、約 300 メンバー、組織、役職、座席タグ、場所を用意します。その上で、座席の着席、移動、解放、メンバーステータス変更、所在地変更、ひとこと更新、組織や役職やタグの CRUD を継続的に発生させます。

これは UI のテストではありません。

DB と service 層の整合性、transaction の粒度、cache invalidation と realtime publish の境界を確認するためのテストです。

座席管理は、表示よりも先に状態更新が正しくなければ成り立ちません。UI が正しく見えても、同時更新で seat と member と log の整合性が崩れたら、現場では使えません。

国際化とプロダクト文言

TOKOSEKI は、日本語、英語、韓国語、中国語簡体字、中国語繁体字、ベトナム語に対応しています。

翻訳は各 app に散らさず、packages/message/messages に集約しています。LP、管理者画面、利用者画面、メール、プラン説明などの文言を shared package 側で持ちます。

多言語対応で難しいのは、単語の置換ではなく、業務上の意味を保つことです。

たとえば「購読」という一般的な言葉ではなく、プラン契約の文脈では「新規契約」とした方が自然な場合があります。こうした文言は UI の小さな差に見えますが、業務システムではかなり重要です。

また、LP では「座席管理をシンプルに」「運用に必要な情報を一元管理」という表現を使っていますが、実装を見ると、これは単なるコピーではありません。座席、部屋、メンバー、組織、役職、予約、履歴、統計、検索、通知を同じワークスペース上で扱う構造そのものが、この文言の根拠になっています。

実装して学んだこと

1. 座席は master data ではなく runtime data でもある

最初は、座席はレイアウト上の固定データに見えます。

しかし実際には、座席には現在の利用者、最終利用時刻、予約状態、利用時間制限、連続利用判定、ランダム着席対象可否などが絡みます。

つまり、座席には master data と live data の両方の性質があります。

これを最初から完全に分離するのは難しいですが、少なくとも cache や realtime を設計するときには、何が構造で何が現況かを分けて考える必要があります。

2. 画面の都合で DB を触らない

Next.js App Router は page や route handler で処理を書きやすいですが、TOKOSEKI では page / component から repository を直接触らない方針にしています。

同じ座席操作が、管理者画面、利用者画面、cron、stress test から呼ばれる可能性があります。そのため、業務ルールは service 層に寄せた方が再利用しやすく、テストもしやすくなります。

3. 不正値を fallback で隠さない

実装では、不正値を result ?? 1 のように握りつぶさない方針を強く意識しています。

たとえば、ワークスペース ID が正の整数でなければ throw する。public ID が空なら throw する。realtime provider が設定されていなければ、呼び出し時に明示的にエラーを投げる。

業務システムでは、曖昧な fallback は後から調査しづらい不整合になります。デフォルト値で一見動いてしまうより、原因が分かる形で止まる方が安全な場面が多いです。

4. すべてを最初に hydrate しない

利用者画面では、layout で全データを hydrate するのではなく、共通ベースラインだけを入れ、各画面が必要 target を宣言して不足分だけ取る方式にしています。

一方で、座席マップや座席一覧のように viewer snapshot が必要な画面では、server 側でまとめて作ります。

つまり、ルールは統一しつつ、例外も明示することが重要でした。すべてを同じ形に揃えようとすると、かえって責務が曖昧になります。

5. 運用機能は後回しにすると後で苦しい

監査ログ、セッション revoke、cron cleanup、health check、stress test、rate limit、プラン制限は、派手な機能ではありません。

しかし、座席管理のように日常的に使われるサービスでは、運用機能がないと安心して使えません。

最初から完璧に作る必要はありませんが、後から入れられるように責務を分けておくことは重要です。

これから改善したいこと

TOKOSEKI は現在も改善中です。

今後改善したいこととしては、たとえば次のようなものがあります。

  • seat master と seat runtime のさらなる分離
  • レイアウト編集体験の改善
  • 大規模ワークスペースでの viewer snapshot 最適化
  • 予約 UX の改善
  • サイネージや通知まわりの強化
  • 導入時のセットアップ支援
  • 実利用に基づいたプラン制限や権限設計の見直し

特に seat master / runtime の分離は、技術的にも重要です。現在も master / live の考え方はありますが、座席は構造と現況の両方を持ちやすいリソースです。ここをより明確に分けられると、cache と realtime の設計がさらに扱いやすくなるはずです。

おわりに

TOKOSEKI は、座席表を作るためだけのアプリではありません。

座席を中心に、人、場所、予約、権限、履歴、状態、運用ルールを扱うためのシステムです。

作ってみて分かったのは、座席管理は見た目以上に状態管理の問題だということです。

席の配置を描くことは出発点にすぎません。現場で本当に必要なのは、その席が今どういう状態で、誰が使えて、どんな制約があり、いつ予約され、誰が変更し、あとからどう確認できるかです。

「どこにいる?」をなくすには、座席表をきれいにするだけでは足りません。

運用そのものをデータモデルに落とし、状態変化を正しく扱い、必要な人に必要な情報が届くようにする必要があります。

TOKOSEKI は、そのための座席運用システムとして作っています。

まだベータ版として改善中ですが、実際の現場で使える形に近づけるため、これからもコードと運用の両方を見直していきます。