Build-in-Public Day 20: 来店型業態 Phase 1 を 1 日で完成させた話 — 打ち合わせ→設計→実装→QA
5/13 午後の対面打ち合わせ → 設計議論 → 実装 → migration → QA デプロイ → 動作確認まで 1 セッションで完走した記録。class-transformer @Expose 漏れトラップで 1 commit 追加 fix の現場感、Phase 1.5/1.6 まで含む。
5/13 午後、来店型メンエスのオーナーと対面打ち合わせがあり、tasteck の業態分岐 (来店型 / デリバリー型) Phase 1 を その日のうちに QA 検証まで 完走しました。
本記事は 打ち合わせ → 設計議論 → 実装 → migration → デプロイ → 動作確認 → @Expose 漏れトラップ までの 1 セッションの流れと、後付けで業態分岐を導入する時の判断軸を共有します。
0. 前提
tasteck は元々派遣型 (デリバリー型) ナイトレジャー業界 SaaS として作られていました。最近、来店型メンエス店舗を運営するオーナーから「うちの店でも tasteck 使えるようにならない?」 という相談を受けて、業態分岐を入れることに。
事前に BusinessType enum (delivery / visit) と companies.business_type カラムは migration で追加済 (default delivery)。ただ UI 分岐は一切実装されてない状態。
1. 打ち合わせと設計議論で固めた 6 論点
打ち合わせ後、オーナーと運営者の議論で以下 6 論点を確定。
論点 1: businessType の伝播方法
- A: auth レスポンスの
account.staff.company.companyGroup.business_typeで配信 (既存planと同じ pattern) - B: 新規 hook + Context
- C: redux top level
→ A 採用 (既存 pattern 流用)。
論点 2: タブ非表示の条件
配車 / ドライバーシフト タブを既存 StaffRole 条件 (notelMaster / clientMaster 等) と AND するか、業態を一次フィルタにするか。
→ 一次フィルタ採用。「来店型と派遣型は店舗を持つか持たないかぐらい根本的に違う」 (オーナー曰く) なので、ロールに関係なく visit はバッサリ非表示。
const isVisit = businessType === "visit";
if (hasManagerRole) {
links = [...links, { label: "分析", value: "/analytics" }];
if (!isVisit) {
links = [
...links,
{ label: "ドライバーシフト", value: "/driverShift" },
{ label: "配車", value: "/driverSchedule" },
];
}
}
「分析」 タブは visit でも必要なので条件式を分割 (visit でも維持)。
論点 3: URL 直アクセスの route guard
visit 企業の人が /driverSchedule を URL 直打ちした時の挙動。
→ route guard なし。既存企業が visit に切り替える時は別 Phase の「申請式 + 手数料連動」 で対応する方針なので、Phase 1 ではタブだけ消せば十分 (使われない前提)。
論点 4: cast-app / driver-app
- cast-app: 送迎関連 UI を visit で非表示
- driver-app: visit 企業のドライバーは存在しない前提で完全放置 (ガードなし)
論点 5: Phase 2 visit 専用機能
オーナーの一言が秀逸でした:
「配車概念というのは、部屋が先に状態としてあるわけです。キャストがシフトを申請したら未アサインに入れることで、それを振り分けることで部屋割りができる」
= 既存配車 UI の構造を「車 → 部屋」 に置換するだけで visit 専用スケジュールができる。配車画面の 2,178 行のコードはほぼ流用可能。
| delivery | → | visit |
|---|---|---|
| 行 = ドライバー + 車両 | → | 行 = 部屋 (固定資産) |
| 緑バー = ドライバー実出勤 | → | 緑バー = キャスト稼働情報 |
| 顧客バッジ | → | 顧客バッジ (変わらず) |
| 未アサイン = 配車待ち受注 | → | 未アサイン = シフト申請済 未割当キャスト |
論点 6: 1 companyGroup = 1 業態固定 (混在不可)
- 同 group 内に delivery + visit 混在を許すか?
- → 不可、DB レベルで保証
→ companies.business_type を廃止 → company_groups.business_type に一本化 (案 B1)。既存 companies のデータは「グループ内に visit があれば visit、なければ delivery」 で backfill。
2. 実装と @Expose 漏れトラップ
設計議論後、実装着手:
server/src/entity/CompanyGroup.tsにbusinessTypeカラム追加server/src/entity/Company.tsからbusinessType削除server/src/migrations/<ts>-moveBusinessTypeToCompanyGroups.ts作成 (3 ステップ: companyGroups にカラム追加 → backfill → companies のカラム削除)server/src/api/public/signup/service.tsの保存先を companies → companyGroups に変更server/src/query/dangerous/CastDangerousQuery.tsにcompany.companyGroupの join 追加staff-app/src/components/Tabs.tsxに visit 一次フィルタ追加staff-app/src/types/res/companyGroup/CompanyGroupRes.tsに businessType 型追加cast-app/src/types/res/companyGroup/CompanyGroupRes.tsも同様
3 環境とも yarn tsc 通過、commit d115c4f8 で push、QA デプロイ → migration 実行 → PM2 restart まで完走。
トラップ発生 (QA 動作確認時)
QA staff-app で新ナチュラル (visit 切替済) でログインして「配車」 タブが消えない。
DevTools で localStorage を覗くと、persisted state の companyGroup には plan は来てるのに businessType フィールドが完全欠落:
"companyGroup": {
"companyGroupId": 64,
"name": "タカシ株式会社",
"plan": "free",
// businessType が無い ← BUG
}
直接 API を fetch:
const token = JSON.parse(JSON.parse(localStorage.getItem('persist:react-template')).account).accessToken;
fetch('https://qa.api.staff.no-tel.com/v1/api/staff/companyGroup/findOne/64/74', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.json()).then(console.log);
→ API レスポンスにも businessType フィールド自体無し。
原因: server/src/types/res/companyGroup/CompanyGroupRes.ts で @Expose() を付けていなかった:
export default class CompanyGroupRes {
@Expose() readonly companyGroupId!: number;
@Expose() readonly name!: string;
@Expose() readonly plan!: PlanType; // ← OK
readonly businessType!: BusinessType; // ← @Expose 漏れ、response から除外
}
class-transformer は excludeExtraneousValues モードで動いてて、@Expose() ない field は plainToClass で完全除外される (null/undefined ではなく field 自体消失)。
修正 commit 6e3000de で @Expose() 1 行追加、QA 再デプロイで解決。
学び
「entity に column を追加した」 で安心しない。framework が response 整形に class-transformer + Res クラスを使ってるなら、Res クラスにも対応する @Expose 必須。
checklist 化:
-
server/src/entity/<Entity>.ts(@Column 追加) -
server/src/types/res/<entity>/<Entity>Res.ts(@Expose 追加) ← 忘れがち - フロント側 Res に対応する型追加
- migration 作成
3. Phase 1.5 — UI 非表示の残り
タブだけじゃ visit 企業のスタッフ/キャストが配車関連 UI を依然見る場面が残ってる。後追いで:
- cast-app NewOrder: visit 時
DriverSettingBookEmailfetch とdriverBookEmailBody生成を skip - cast-app Order/components: スケジュール表の「送り」「迎え」 (送迎ドライバー車情報) を visit 時非表示
- cast-app InOut/OrderInfo: 入退店情報の「送り」「迎え」 行を visit 時非表示
- staff-app Order/OrderPrice: 受注詳細の
OrderDriver/OrderSendMailブロックを visit 時非表示
{!isVisit && (
<>
<OrderDriver ... />
<OrderSendMail ... />
</>
)}
commit 16e32c29 で 4 ファイル / 82 行 / -41 行。
4. Phase 1.6 — super admin 切替の追従
QA 検証中に発覚: notelMaster (super admin) が「ログイン企業切り替え」 で visit 企業 ⇔ delivery 企業を切り替えた時、Tabs が追従しない。
原因: Tabs.tsx が state.account.staff.company.companyGroup.businessType を見てたが、これは login 時の固定値。
修正案: state.company (全社一覧) から current staff.companyId の company を引いて、その companyGroup.businessType を見る。
const currentCompanyId = useSelector((state) => state.account.staff.companyId);
const companies = useSelector((state) => state.company);
const loginCompanyBusinessType = useSelector(
(state) => state.account.staff.company.companyGroup.businessType
);
const currentCompany = companies?.find(c => c.companyId === currentCompanyId);
const businessType = currentCompany?.companyGroup?.businessType ?? loginCompanyBusinessType;
const isVisit = businessType === "visit";
commit 6a4000cd で 1 ファイル / +13 行。
QA 検証: 新ナチュラル (visit) ⇔ 本庄アズール (delivery) を切り替え、タブが動的追従することを Playwright で確認 ✅。
5. QA 動作確認まとめ
| シナリオ | 結果 |
|---|---|
| visit 企業ログイン → 配車/ドライバー系タブ非表示 + 分析タブ維持 | ✅ |
| delivery regression (本庄アズール) → 全タブ復活 | ✅ |
| super admin 企業切替: visit ⇔ delivery → タブ動的追従 | ✅ |
| staff-app 受注詳細: visit 時 OrderDriver/OrderSendMail 非表示 | ✅ |
| cast-app companyGroup.businessType 取得 + 送迎 UI 非表示 | ✅ |
| DB migration 適用 | ✅ |
6. Phase 2 の見通し
visit 専用機能本体は Phase 2 で実装:
roomsテーブル新規 (部屋マスター)casts.cleaning_interval_minカラム追加 (キャスト個別の清掃時間)- visit 専用スケジュール画面 (部屋 × 時間 タイムライン、DnD)
- mouseover ツールチップ (キャストスケジュール + キャスト備考)
既存 driverSchedule 画面の 2,178 行はほぼ流用可能と分析済。react-calendar-timeline ライブラリの groups + items 構造で「車 → 部屋」 「ドライバーシフト → キャストシフト」 のマッピング変更で対応できる見込み。
Phase 2 全体の工数感は 2-3 週間:
- Phase 2.1 (DB + マスター画面): 3-4 日
- Phase 2.2 (スケジュール画面骨格): 1 週間
- Phase 2.3 (DnD ロジック + 清掃時間): 1 週間
7. まとめ — 業態分岐を後付けで入れる時の判断軸 3 つ
- DB レベル保証 > ルール依存: 「混在不可」 を運用ルールじゃなく column 位置 (companyGroup 単位) で保証する
- 一次フィルタ > AND 条件: 業態分岐は権限ロールと直交させてロジック単純化、既存ロール条件を読み解かなくて済む
- entity ≠ response: class-transformer 系では
Res.tsの@Expose漏れに注意、entity だけ追加して安心しない
1 日で打ち合わせ → 設計議論 → 実装 → migration → QA まで完走できたのは、AI 駆動開発 (Claude Code Orchestration v3) と「業界特化マイクロ SaaS の業務知識を提供できるオーナー」 の組み合わせがハマった結果。Build-in-Public で言語化していくことで、同じ業界特化 SaaS の運営者にも参考になればと思います。
Build-in-Public は本記事を含め tasteck.tech/blog で公開、AI 自律 PR を実装している方は要 watch。
tasteck — ナイトレジャー業界向け顧客管理 SaaS の Build-in-Public ジャーニー。