コンテンツにスキップ

店舗作成・初期設定

Document ID: SRS-TEN-001 Parent: SRS-ROOT-001 v0.3 Version: 0.3 Status: Approved Last Updated: 2026-04-25 Depends on: なし(最初に実装する基盤SRS) 依存される: SRS-RES-002, SRS-RES-004(store_settings の状態機械関連設定), SRS-RES-005, ほか Phase 1 全般

本書は SRS-ROOT-001 v0.3 に従う。


システム内に「店舗(Store)」エンティティを作成し、マルチテナントの起点となる初期データ(設定・プリセットロール・権限紐付け)を一括で生成する。

Phase 1 では管理画面・サインアップ UI は持たず、seed スクリプト(CLI)で店舗を作成する。これにより認証フローの設計負債を抱えずに済み、サインアップ画面は Phase 2 以降で丁寧に作れる。

本SRSはあわせて Phase 0 scaffold で広く配られていた app ロールの DEFAULT PRIVILEGES を default-deny に絞る基盤修正 を同梱する(親SRS §7.1 と整合させるため)。


  • As a 開発者(自分), I want to seed スクリプトで店舗と初期データを一発で作成する, so that 他の全機能の開発に着手できる
  • As a 将来のサロンオーナー, I want to 店舗を作成したら必要な設定とロールが揃っている, so that すぐに運用を始められる

3.1 主シナリオ(seed スクリプトによる店舗作成)

Section titled “3.1 主シナリオ(seed スクリプトによる店舗作成)”
  1. 開発者が docker compose run --rm api bun run db:seed を実行
  2. seed スクリプトが環境変数から店舗の入力(SEED_STORE_NAME, SEED_STORE_SLUG, SEED_STORE_TIMEZONE?)を読み込む
  3. Step 1: permission UPSERT(同一スクリプト内、PERMISSIONS 定数から)
  4. Step 2: 1 トランザクションで以下を作成: a. slug で既存 store を検索 → あればスキップ(冪等) b. store(UUIDv7 + Base64url 11文字の code を自動生成) c. store_settings(デフォルト値で 1 行) d. プリセット role × 4(owner / manager / staff / receptionist)、各 UUIDv7 e. role_permissionPRESET_ROLE_PERMISSIONS × 各 role の初期紐付け、store_id 冗長保持)
  5. トランザクション成功 → 作成した store.id, store.slug, store.code をコンソールに出力
  • AF-1. 既存店舗がある状態で再実行(冪等): slug で既存判定 → 何もせず終了。store.code は乱数なので冪等キーには使えず、slug を使う
  • AF-2. 複数店舗の作成: 環境変数を変えて複数回実行(Phase 1 では同時複数作成は対応しない)
  • EF-1. DB 未起動 / 接続エラー: seed スクリプトがエラーメッセージを出して exit 1
  • EF-2. permission マスタ未投入: Step 1 で UPSERT するため発生しない(同一スクリプト内で順序保証)
  • EF-3. store.code 衝突(天文学的確率): UNIQUE 制約違反 → リトライ(最大 3 回、別の code を再生成)。3 回失敗したら exit 1
  • EF-4. slug バリデーション違反: ^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$ を満たさない場合 exit 1
  • EF-5. timezone バリデーション違反: Asia/Tokyo 以外 → exit 1(Phase 1 は Asia/Tokyo のみ)

Phase 1 ではなし。店舗作成は CLI のみ。

将来的に管理画面で店舗設定を編集する UI が必要になるが、それは別 SRS(仮: SRS-TEN-004)で扱う。


Phase 1 では店舗作成 API は公開しない。seed スクリプトが packages/db を直接呼ぶ。

5.1 将来の API(参考、Phase 1 スコープ外)

Section titled “5.1 将来の API(参考、Phase 1 スコープ外)”
MethodPath用途備考
POST/api/admin/stores店舗作成Phase 2 以降、サインアップ連携時
GET/api/admin/stores/:storeId店舗情報取得Phase 1 後半で追加検討
PATCH/api/admin/stores/:storeId/settings店舗設定更新Phase 1 後半で追加検討

5.2 seed スクリプトの入力インターフェース

Section titled “5.2 seed スクリプトの入力インターフェース”

環境変数で受ける(最軽量、複数店舗対応はYAGNI):

変数名必須デフォルト説明
SEED_STORE_NAME店舗名(例: "Hair Salon Tokyo"
SEED_STORE_SLUGURL用slug。グローバルUNIQUE。冪等キーとして使用。^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$
SEED_STORE_TIMEZONEAsia/TokyoIANAタイムゾーン名。Phase 1 は Asia/Tokyo のみ許可
DATABASE_URL_MIGRATORsuperuser 接続文字列(既存の bootstrap と同じ)

将来 JSON ファイル入力に切り替える場合は packages/db/src/seed/store.ts の入力 IF を変えるだけ(ロジック非依存)。


store(業務実体、UUIDv7)

カラム制約説明
iduuidPKUUIDv7、アプリ層で生成
slugvarchar(50)UNIQUE, NOT NULL, CHECKグローバル一意のURL slug。^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$ を DB CHECK で強制。冪等seedキー兼公開URL slug(親SRS §7.2.3)
codevarchar(11)UNIQUE, NOT NULLBase64url 11 文字(8 バイト → Base64url)。乱数の短い識別子。冪等性には使わない(衝突時リトライで別値が生成されるため)
namevarchar(200)NOT NULL店舗名
timezonevarchar(50)NOT NULL, DEFAULT 'Asia/Tokyo'IANA タイムゾーン名。Phase 1 は Asia/Tokyo のみアプリ層で許可
planvarchar(20)NOT NULL, DEFAULT 'free''free' | 'standard' | 'premium'。Phase 1 は 'free' 固定
created_attimestamptzNOT NULL, DEFAULT now()
updated_attimestamptzNOT NULL, DEFAULT now()

store_settings(店舗設定、1:1)

カラム制約説明
store_iduuidPK, FK → store(id)
reservation_slot_minutessmallintNOT NULL, DEFAULT 30予約枠の刻み(5 / 10 / 15 / 30)
max_concurrent_reservations_per_staffsmallintNOT NULL, DEFAULT 1スタッフ並列予約上限(SRS-RES-005 §6.4)
tentative_expire_hourssmallintNOT NULL, DEFAULT 24仮予約の自動失効までの時間(時間単位)。SRS-RES-004 §7.6
no_show_grace_minutessmallintNOT NULL, DEFAULT 30no_show マーク可能になる予約開始時刻からの猶予(分)。SRS-RES-004 §7.3 (†1)
customer_required_fieldsjsonbNOT NULL, DEFAULT '["name"]'顧客登録の必須フィールド。Phase 1 は固定(UI 編集不可)
cancel_policyjsonbNOT NULL, DEFAULT '{}'キャンセルポリシー。Phase 2 以降で構造化
updated_attimestamptzNOT NULL, DEFAULT now()

permission(システム共通マスタ、bigserial

カラム制約説明
idbigserialPK
keyvarchar(100)UNIQUE, NOT NULL{scope}:{resource}:{action} 形式
descriptiontextNOT NULL DEFAULT ''人間向け説明
created_attimestamptzNOT NULL, DEFAULT now()

親 SRS §7.7.2 の例外:permission は全テナント共通マスタのため store_id を持たない。RLS なし。app ロールには SELECT のみ GRANT(INSERT/UPDATE/DELETE は migrator のみ)。

role(店舗スコープ、UUIDv7)

カラム制約説明
iduuidPKUUIDv7
store_iduuidFK → store(id), NOT NULL
keyvarchar(50)NOT NULLowner / manager / staff / receptionist
namevarchar(100)NOT NULL表示名(例: "オーナー"
is_presetbooleanNOT NULL, DEFAULT trueプリセットロールか
created_attimestamptzNOT NULL, DEFAULT now()
UNIQUE(store_id, key)同一店舗内でキーは一意
UNIQUE(store_id, id)role_permission の複合FK参照先(Postgresの複合FK要件)

role_permission(中間テーブル、独立IDなし、複合PK)

カラム制約説明
store_iduuidNOT NULLrole.store_id と一致(複合FKで保証)
role_iduuidNOT NULL
permission_idbigintNOT NULL
PRIMARY KEY(role_id, permission_id)独立IDなし。親SRS §7.2.2「中間テーブルは複合PKで最小構成を優先」
FK(store_id, role_id) → role(store_id, id)role 側の UNIQUE(store_id, id) を参照する複合FK
FK(permission_id) → permission(id)permissionstore_id を持たないため単独FK

store_id は冗長保持(親SRS §7.1.1「全テーブル store_id 例外なし」)。複合FKで role.store_id との整合をDB保証する。RLSもこのカラムで効く。

BC-TEN 初回マイグレーションで以下を 2 ファイルに分割して実施する:

  • 0001_initial.sql:drizzle-kit generate 出力(テーブル定義、UNIQUE、複合FK)
  • 0002_enable_rls.sql:drizzle-kit generate --custom で空ファイルを作り、手書きで RLS + GRANT を記述

分割理由:drizzle-kit はスキーマ DSL から RLS/GRANT を生成できないため。手書き SQL は journal で追跡される(drizzle-kit migrate 連続適用)。

6.2.1 0002_enable_rls.sql の内容(要点)

Section titled “6.2.1 0002_enable_rls.sql の内容(要点)”
-- store
ALTER TABLE store ENABLE ROW LEVEL SECURITY;
ALTER TABLE store FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_select ON store FOR SELECT
USING (id = current_setting('app.current_store_id')::uuid);
CREATE POLICY tenant_isolation_modify ON store FOR ALL
USING (id = current_setting('app.current_store_id')::uuid)
WITH CHECK (id = current_setting('app.current_store_id')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON store TO app;
-- store_settings (store_id ベース)
ALTER TABLE store_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE store_settings FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_select ON store_settings FOR SELECT
USING (store_id = current_setting('app.current_store_id')::uuid);
CREATE POLICY tenant_isolation_modify ON store_settings FOR ALL
USING (store_id = current_setting('app.current_store_id')::uuid)
WITH CHECK (store_id = current_setting('app.current_store_id')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON store_settings TO app;
-- role (store_id ベース)
-- 同様
-- role_permission (store_id ベース、冗長保持カラム)
-- 同様
-- permission: RLS なし、SELECTのみ
GRANT SELECT ON permission TO app;
GRANT USAGE, SELECT ON SEQUENCE permission_id_seq TO app;

6.2.2 default-deny への bootstrap 修正

Section titled “6.2.2 default-deny への bootstrap 修正”

infra/sql/bootstrap/001_roles_and_extensions.sql を以下に変更:

ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app;
-- INSERT/UPDATE/DELETE はテーブル作成時に明示 GRANT する(migration の責務)
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO app;

infra/sql/policies/tenant.sql テンプレに「テーブル作成時に必ず GRANT SELECT, INSERT, UPDATE, DELETE ON {{table}} TO app; を明示する」旨を追記する。

db:seed (DATABASE_URL_MIGRATOR = postgres superuser で接続)
├─ env validate (SEED_STORE_NAME, SEED_STORE_SLUG, SEED_STORE_TIMEZONE?)
├─ Step 1: permission UPSERT(packages/auth の PERMISSIONS 定数から、ON CONFLICT(key) DO UPDATE SET description)
└─ Step 2: BEGIN
├─ SELECT 1 FROM store WHERE slug = $1 → 既存ならスキップして COMMIT
├─ generateStoreCode() リトライループ(最大3回)
├─ INSERT store
├─ INSERT store_settings
├─ INSERT role × 4 (UUIDv7 アプリ層生成)
└─ INSERT role_permission(PRESET_ROLE_PERMISSIONS から、store_id 冗長保持)
COMMIT → store.id, store.slug, store.code を stdout に出力

seed は postgres superuser で実行する。RLS が FORCE されていても superuser はバイパスする(Postgres 仕様)。


import { randomBytes } from 'node:crypto'
export function generateStoreCode(): string {
return randomBytes(8).toString('base64url').slice(0, 11)
}
  • 8 バイト(64bit)の暗号論的乱数を Base64url エンコード → 11 文字
  • 文字種: A-Za-z0-9_-(64 種、大文字小文字区別)
  • キースペース: 2^64 ≈ 1.8 × 10^19(衝突はまず起きない)
  • DB の UNIQUE 制約で最終保証、衝突時はリトライ(最大 3 回)

7.2 Permission キー初期セット(TEN-001 範囲)

Section titled “7.2 Permission キー初期セット(TEN-001 範囲)”

TEN-001 が責務を持つキーのみを定義する。後続SRS(TEN-002, MST-, RES- 等)が新 permission を追加するときは、親SRS §7.7.7 の backfill 規約に従う。

packages/auth/src/permissions.ts
export const PERMISSIONS = {
ADMIN_STORE_READ: 'admin:store:read',
ADMIN_STORE_UPDATE: 'admin:store:update',
ADMIN_STORE_SETTINGS_READ: 'admin:store_settings:read',
ADMIN_STORE_SETTINGS_UPDATE:'admin:store_settings:update',
ADMIN_ROLE_READ: 'admin:role:read',
} as const

PRESET_ROLE_PERMISSIONS(TEN-001 範囲のみ):

RolePermissions
owner全部(5件)
manager全部(5件、TEN-001 範囲では owner と同集合)
staffadmin:store:read, admin:store_settings:read, admin:role:read
receptionistadmin:store:read, admin:store_settings:read, admin:role:read

: TEN-001 の permission 範囲では managerowner の差、staffreceptionist の差は表現できない。これは仕様の不足ではなく範囲の問題で、後続SRSで追加される permission(admin:operator:invite, admin:reservation:cancel, admin:register:operate 等)によって初めて差が出る。SRS-TEN-002 以降で PRESET_ROLE_PERMISSIONS を分化させる前提。

設定デフォルト根拠
reservation_slot_minutes30美容室の一般的な刻み
max_concurrent_reservations_per_staff1安全側。並列施術を許可する店舗は手動で変更
tentative_expire_hours24一般的な「翌日まで返事がなければ自動取消」運用に合わせる
no_show_grace_minutes30開始時刻から30分過ぎたら no-show マークを許可(早押し誤操作の防止)
customer_required_fields["name"]親 SRS §7.13
cancel_policy{}Phase 2 以降で構造化

Phase 1 では Asia/Tokyo のみ許可:

export const ALLOWED_TIMEZONES = ['Asia/Tokyo'] as const
export type StoreTimezone = (typeof ALLOWED_TIMEZONES)[number]

将来の多タイムゾーン対応時にこの配列を拡張するだけ。

Phase 1 では 'free' 固定。カラムのみ置き、課金ロジックは実装しない:

export const STORE_PLANS = ['free', 'standard', 'premium'] as const
export type StorePlan = (typeof STORE_PLANS)[number]

正規表現 ^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$

  • 小文字英数字とハイフンのみ(公開URL のため)
  • 長さ 3〜50(先頭末尾のハイフン禁止)
  • アプリ層(zod or 自作 validator)+ DB CHECK の二重ガード

TEN-001 範囲のテーブル(store, store_settings, permission, role, role_permission)には deleted_at を追加しない

  • 親SRS §7.5「物理削除はしない、必要な箇所のみ deleted_at」に従い、Phase 1 では削除運用を想定しない
  • 店舗の論理削除が必要になった時点(Phase 3+)で別 migration で追加する
  • preset role の論理削除は preset 定義そのものの変更なので別の運用(permission 粒度変更規約 §7.7.5)で扱う

親 SRS §6 に従う。特記事項:

  • seed スクリプトの実行時間: 1 店舗あたり 1 秒以内(permission UPSERT + store 作成)
  • 冪等性: 同じ SEED_STORE_SLUG で再実行しても安全(既存ならスキップ)

店舗作成は seed スクリプト(postgres superuser)で行うため、アプリ層の認可チェックは不要。

将来 API 化する場合の permission キー(参考):

key用途備考
system:store:create店舗作成システム管理者のみ。通常のプリセットロールには付与しない

§6.2 参照。store テーブルの RLS ポリシーは id = current_setting('app.current_store_id')::uuid、その他は store_id = ...permission のみ RLS なし(共通マスタ)。

seed 実行時は postgres superuser なので RLS をバイパスする(FORCE は非 superuser に対して適用される)。

seed の実行は親SRS §7.8.1 に従い operator_action_log 記録対象外(CLI ログで履歴を残す)。

親SRS §7.1 を踏まえ、本SRSは bootstrap の DEFAULT PRIVILEGES を default-deny(SELECT のみ)に絞る修正を同梱する(§6.2.2)。各テーブル migration で GRANT SELECT, INSERT, UPDATE, DELETE ON {{table}} TO app; を明示する規律を infra/sql/policies/tenant.sql テンプレに追加する。


10. 受け入れ基準(Given-When-Then)

Section titled “10. 受け入れ基準(Given-When-Then)”
  • GWT-1 基本作成: Given DB が空 / When seed を実行 / Then store 1 行、store_settings 1 行、role 4 行、role_permission 14 行(owner 5 + manager 5 + staff 3 + receptionist 3 = 16… ※ permission 構成変更時はマトリクス再計算)が作成される
  • GWT-2 store.code 形式: Given seed 実行 / When 作成された store.code を確認 / Then Base64url 11 文字(/^[A-Za-z0-9_-]{11}$/)である
  • GWT-3 store.slug 形式: Given SEED_STORE_SLUG=my-shop-1 / When seed 実行 / Then DB の slug がそのまま保存される
  • GWT-4 冪等性(slug ベース): Given 同じ slug で seed 済み / When 同じ slug で再実行 / Then 既存スキップ、データ重複なし
  • GWT-5 1 トランザクション: Given seed 実行中に途中エラー / When ロールバック / Then storerole も作成されていない
  • GWT-6 RLS テナント分離(store): Given 店舗 A, B が seed 済み / When app ユーザーで withTenant(A.id) 内で SELECT * FROM store / Then 店舗 A のみ返る
  • GWT-7 RLS role 分離: Given 店舗 A, B が seed 済み / When app ユーザーで withTenant(A.id) 内で SELECT * FROM role / Then 店舗 A のロール 4 件のみ返る
  • GWT-8 permission はテナント非依存: Given 店舗 A を current_store_id に設定 / When SELECT * FROM permission / Then 全 permission が返る(RLS なし)
  • GWT-9 timezone バリデーション: Given SEED_STORE_TIMEZONE=America/New_York / When seed 実行 / Then exit 1(Phase 1 は Asia/Tokyo のみ)
  • GWT-10 slug バリデーション: Given SEED_STORE_SLUG=Invalid_Slug / When seed 実行 / Then exit 1
  • GWT-11 store.code 衝突リトライ: Given UNIQUE 制約違反が発生 / When 生成ロジック / Then 別の code で最大 3 回リトライし、成功する
  • GWT-12 RLS current_store_id 未設定: Given app ユーザーで current_store_id を設定せずに SELECT * FROM store / Then エラー(current_setting がデフォルト値なし)または 0 行
  • GWT-13 default-deny 確認: Given bootstrap 実行後 / When 新規テーブルを migration なしで作成(テスト用) / Then app ロールから INSERT/UPDATE/DELETE ができない(SELECT のみ)
  • GWT-14 全 tenant table RLS 検査: Given migration 完了後 / When pg_class から RLS ENABLE のテーブル列挙 / Then permission 以外の本SRS追加テーブルすべてが含まれる、各テーブルに tenant_isolation_selecttenant_isolation_modify ポリシーが存在する

  • Unit (packages/domain/test/store/):
    • generateStoreCode() が 11 文字の Base64url を返す
    • timezone validator(許可 / 拒否)
    • plan validator
    • slug validator(正規表現)
  • Integration (packages/db/test/):
    • GWT-1〜14 を網羅
    • Compose ベース runnerdocker-compose.test.yml で test-db を別ポートで立てる、もしくは既存 db に分離スキーマ)
    • RLS 統合検査(pg_class/pg_policies メタデータ走査)
    • withTenant() 経由のテナント分離検証(SET LOCAL がトランザクション境界で正しく効くこと)
  • Contract: Phase 1 では API がないため不要

Phase 1 ではなし


#内容結論
OQ-TEN-001-01role_permissionstore_id を冗長に持たせるべきかClosed v0.2: 持たせる。bigserial id を廃止し複合PK (role_id, permission_id) + 複合FK (store_id, role_id) → role(store_id, id) で整合をDB保証
OQ-TEN-001-02seed 入力形式Closed v0.2: 環境変数(SEED_STORE_NAME 必須、SEED_STORE_SLUG 必須=冪等キー、SEED_STORE_TIMEZONE 任意)

VersionDateAuthorChange
0.12026-04-23yudai初版起票(Draft)。store.code は Base64url 11 文字、Phase 1 は seed スクリプトのみ(API/UI なし)。店舗作成 + store_settings + プリセット role + role_permission を 1 トランザクションで生成する方式
0.22026-04-25yudaiコードレビュー反映。store.slug を NOT NULL UNIQUE で追加(DB CHECK + アプリ正規表現)し冪等キーに採用。role_permission を bigserial 廃止 → 複合PK (role_id, permission_id) + store_id 冗長保持 + 複合FK 化(roleUNIQUE(store_id, id) 追加)。permission 初期セットを TEN-001 範囲の5件に限定し、後続SRSでの追加は親SRS §7.7.7 backfill 規約に従う。bootstrap の DEFAULT PRIVILEGES を default-deny に修正する基盤改修を同梱(§6.2.2)。drizzle-kit --custom0002_enable_rls.sql を journal 連携。seed/bootstrap は親SRS §7.8.1 に従い監査例外。deleted_at は本SRS範囲のテーブルでは追加しない方針を §7.7 に明記。受け入れ基準を GWT-1〜14 に拡張(slug バリデーション、default-deny、RLS メタデータ検査、current_store_id 未設定時の挙動を追加)。OQ-TEN-001-01/02 を Closed。Status を Approved に
0.32026-04-25yudaiSRS-RES-004 v0.2 と同期改訂store_settingstentative_expire_hours(既定 24)と no_show_grace_minutes(既定 30)を追加(§6.1, §7.3)。SRS-RES-004 の状態機械(仮予約自動失効 / no_show 猶予)から参照される。実装は別 migration(BC-RES 連動)で追加