Build-in-Public Day 20: How We Finished Visit-Type Business Phase 1 in One Day — Meeting → Design → Implementation → QA
A record of running from an in-person meeting on the afternoon of 5/13 → design discussion → implementation → migration → QA deploy → verification, all in one session. Includes the on-the-ground feel of one extra fix commit caused by a class-transformer @Expose omission trap, plus Phase 1.5/1.6.
On the afternoon of 5/13, we had an in-person meeting with the owner of a visit-type men's wellness spa, and completed Phase 1 of tasteck's business-type branch (visit type / delivery type) all the way to QA verification within the same day.
This article shares the flow of that one session — meeting → design discussion → implementation → migration → deploy → verification → the @Expose omission trap — and the decision axes for retrofitting a business-type branch.
0. Background
tasteck was originally built as a SaaS for the dispatch-type (delivery-type) nightlife industry. Recently, an owner who runs a visit-type men's wellness spa asked us, "Could tasteck work for my venue too?", so we decided to add a business-type branch.
Beforehand, the BusinessType enum (delivery / visit) and the companies.business_type column had already been added via migration (default delivery). But no UI branching was implemented at all.
1. The 6 Points Settled in the Meeting and Design Discussion
After the meeting, the owner and the operator settled the following 6 points through discussion.
Point 1: How to propagate businessType
- A: deliver it via
account.staff.company.companyGroup.business_typein the auth response (the same pattern as the existingplan) - B: a new hook + Context
- C: redux top level
→ A adopted (reuse the existing pattern).
Point 2: The condition for hiding tabs
Should the dispatch / driver-shift tabs be ANDed with the existing StaffRole conditions (notelMaster / clientMaster, etc.), or should the business type be a primary filter?
→ Primary filter adopted. As the owner put it, "visit type and dispatch type differ at a level as fundamental as whether you have a physical venue or not," so for visit the tabs are simply hidden regardless of role.
const isVisit = businessType === "visit";
if (hasManagerRole) {
links = [...links, { label: "分析", value: "/analytics" }];
if (!isVisit) {
links = [
...links,
{ label: "ドライバーシフト", value: "/driverShift" },
{ label: "配車", value: "/driverSchedule" },
];
}
}
The "Analytics" tab is needed for visit as well, so the condition was split (kept even for visit).
Point 3: Route guard for direct URL access
What happens when someone at a visit company types /driverSchedule directly into the URL?
→ No route guard. When an existing company switches to visit, that will be handled in a separate phase via an "application flow + fee linkage," so in Phase 1 just removing the tab is enough (on the assumption it will not be used).
Point 4: cast-app / driver-app
- cast-app: hide pickup/drop-off related UI for visit
- driver-app: on the assumption that visit companies have no drivers, leave it completely untouched (no guard)
Point 5: Phase 2 visit-only features
One line from the owner was excellent:
"The dispatch concept means the rooms exist first, as state. When a cast applies for a shift, you put them into 'unassigned', and by distributing them you get the room allocation."
= Just replacing the structure of the existing dispatch UI from "vehicles → rooms" gives you a visit-only schedule. The 2,178 lines of code in the dispatch screen are almost entirely reusable.
| delivery | → | visit |
|---|---|---|
| row = driver + vehicle | → | row = room (fixed asset) |
| green bar = driver's actual working hours | → | green bar = cast availability info |
| customer badge | → | customer badge (unchanged) |
| unassigned = orders waiting for dispatch | → | unassigned = casts with shift applied, not yet allocated |
Point 6: 1 companyGroup = 1 business type, fixed (no mixing)
- Allow delivery + visit to mix within the same group?
- → Not allowed, guaranteed at the DB level
→ Drop companies.business_type → consolidate into company_groups.business_type (option B1). Existing companies data is backfilled with "if any company in the group is visit, then visit; otherwise delivery."
2. Implementation and the @Expose Omission Trap
After the design discussion, implementation started:
- Add a
businessTypecolumn toserver/src/entity/CompanyGroup.ts - Remove
businessTypefromserver/src/entity/Company.ts - Create
server/src/migrations/<ts>-moveBusinessTypeToCompanyGroups.ts(3 steps: add column to companyGroups → backfill → remove column from companies) - Change the save destination in
server/src/api/public/signup/service.tsfrom companies to companyGroups - Add a
company.companyGroupjoin inserver/src/query/dangerous/CastDangerousQuery.ts - Add the visit primary filter to
staff-app/src/components/Tabs.tsx - Add the businessType type to
staff-app/src/types/res/companyGroup/CompanyGroupRes.ts - Same for
cast-app/src/types/res/companyGroup/CompanyGroupRes.ts
yarn tsc passed in all 3 environments, pushed as commit d115c4f8, then ran through QA deploy → migration execution → PM2 restart.
The trap appears (during QA verification)
Logging into the QA staff-app as Shin Natural (already switched to visit), the "Dispatch" tab would not disappear.
Looking at localStorage in DevTools, the persisted state's companyGroup had plan, but the businessType field was completely missing:
"companyGroup": {
"companyGroupId": 64,
"name": "タカシ株式会社",
"plan": "free",
// businessType が無い ← BUG
}
Fetching the API directly:
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);
→ The API response also had no businessType field at all.
Cause: in server/src/types/res/companyGroup/CompanyGroupRes.ts, @Expose() had not been added:
export default class CompanyGroupRes {
@Expose() readonly companyGroupId!: number;
@Expose() readonly name!: string;
@Expose() readonly plan!: PlanType; // ← OK
readonly businessType!: BusinessType; // ← @Expose 漏れ、response から除外
}
class-transformer runs in excludeExtraneousValues mode, so any field without @Expose() is completely removed by plainToClass (not null/undefined — the field itself disappears).
Fix commit 6e3000de added the single @Expose() line; QA redeploy resolved it.
Lesson
Do not feel safe just because "the column was added to the entity." If the framework uses class-transformer + Res classes to shape responses, the matching @Expose on the Res class is mandatory.
Turned into a checklist:
-
server/src/entity/<Entity>.ts(add @Column) -
server/src/types/res/<entity>/<Entity>Res.ts(add @Expose) ← easy to forget - Add the matching type to the frontend Res
- Create the migration
3. Phase 1.5 — The Rest of the UI Hiding
Tabs alone still leave situations where staff/casts at visit companies see dispatch-related UI. Follow-up work:
- cast-app NewOrder: skip the
DriverSettingBookEmailfetch anddriverBookEmailBodygeneration for visit - cast-app Order/components: hide the "drop-off" and "pickup" entries (pickup/drop-off driver vehicle info) on the schedule table for visit
- cast-app InOut/OrderInfo: hide the "drop-off" and "pickup" rows in the check-in/check-out info for visit
- staff-app Order/OrderPrice: hide the
OrderDriver/OrderSendMailblocks in the order detail for visit
{!isVisit && (
<>
<OrderDriver ... />
<OrderSendMail ... />
</>
)}
Commit 16e32c29: 4 files / 82 lines added / -41 lines.
4. Phase 1.6 — Following the Super Admin Company Switch
Discovered during QA verification: when a notelMaster (super admin) uses "switch login company" to move between a visit company and a delivery company, the Tabs do not follow.
Cause: Tabs.tsx was reading state.account.staff.company.companyGroup.businessType, but that is a fixed value from login time.
Fix approach: look up the company matching the current staff.companyId from state.company (the list of all companies), and read that company's 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 file / +13 lines.
QA verification: switching between Shin Natural (visit) and Honjo Azur (delivery), confirmed with Playwright that the tabs follow dynamically ✅.
5. QA Verification Summary
| Scenario | Result |
|---|---|
| Visit company login → dispatch/driver tabs hidden + Analytics tab kept | ✅ |
| delivery regression (Honjo Azur) → all tabs restored | ✅ |
| Super admin company switch: visit ⇔ delivery → tabs follow dynamically | ✅ |
| staff-app order detail: OrderDriver/OrderSendMail hidden for visit | ✅ |
| cast-app companyGroup.businessType retrieval + pickup/drop-off UI hidden | ✅ |
| DB migration applied | ✅ |
6. Outlook for Phase 2
The visit-only features themselves will be implemented in Phase 2:
- New
roomstable (room master) - Add a
casts.cleaning_interval_mincolumn (per-cast cleaning time) - Visit-only schedule screen (room × time timeline, DnD)
- Mouseover tooltip (cast schedule + cast notes)
We have already analyzed that the 2,178 lines of the existing driverSchedule screen are almost entirely reusable. With the groups + items structure of the react-calendar-timeline library, we expect to handle it by changing the mapping: "vehicles → rooms" and "driver shifts → cast shifts."
Estimated effort for Phase 2 overall is 2-3 weeks:
- Phase 2.1 (DB + master screens): 3-4 days
- Phase 2.2 (schedule screen skeleton): 1 week
- Phase 2.3 (DnD logic + cleaning time): 1 week
7. Conclusion — 3 Decision Axes for Retrofitting a Business-Type Branch
- DB-level guarantee > rule dependence: guarantee "no mixing" via the column's location (per companyGroup), not via an operational rule
- Primary filter > AND condition: keep the business-type branch orthogonal to permission roles to simplify the logic, so you never have to untangle the existing role conditions
- entity ≠ response: with class-transformer-style frameworks, watch out for
@Exposeomissions inRes.ts— do not feel safe after adding only the entity
Being able to run from meeting → design discussion → implementation → migration → QA in one day was the result of a good match between AI-driven development (Claude Code Orchestration v3) and "an owner who can provide the business knowledge of an industry-specific micro SaaS." We hope that putting this into words through Build-in-Public will also be useful to other operators of industry-specific SaaS.
Build-in-Public posts, including this article, are published at tasteck.tech/blog — worth watching if you are implementing AI-autonomous PRs.
tasteck — the Build-in-Public journey of a customer management SaaS for the nightlife industry.
Related articles
Bar Management Software Buyer's Guide: 15 Key Features to Compare
The definitive buyer's guide for bar management software: 15 evaluation criteria, comparison framework, and buyer decision process for bar owners and hospitality operators.
AI-Powered Nightlife Business Analytics: 7 Metrics That Reveal Hidden Value
How AI-powered analytics reveal 7 critical metrics for nightlife venues: seat yield, staff-adjusted delta, cover-to-spend, bottle keep utilization, shift margin, cohort retention, commission drift.
AI Diagnostic Tools for Nightlife SaaS: The 2026 Selection Guide
How AI-powered diagnostic tools help nightlife venue operators select the right SaaS. Covers 8 evaluation criteria + composite case pattern.
Map out your operations in 5 minutes
Eight questions cover reservations, customer management, shifts, and settlement. Results shown instantly with industry benchmark. Sales emails only if you request them.
Your answers are not stored. The assessment runs entirely in your browser.
Try tasteck free for 30 days
No credit card required. Full access to reservations, cast shifts, dispatch, and analytics.
- No card required
- Free data migration support
- All features unlocked for 30 days