Multilocation Database Ownership
Every business resource now has a column for the business that owns it and a table for the locations it is available at. New rows are filled as they are created. Existing rows are filled by a per-location backfill script.
Status
| Part | State |
|---|---|
Schema: business_id on 22 tables, 9 log tables, 11 mapping tables | Pushed. Migration 0620. |
Schema: business_id on staff.members and its log table | Pushed. Migration 0621. |
Schema: business_id on gift_cards.gift_card_purchases | Pushed. Migration 0622. |
| Owner resolver | Pushed. |
| Writers for all 24 tables | Pushed. |
| Audit of every table in the database | Done. 818 tables classified. |
| Migrations applied to a database | Preview VM only, 2026-10-05. Not local, not production. |
| Backfill of existing rows | Script written. Run on the preview VM for both seeded locations. Not run anywhere else. |
| Web APIs that read by business | 24 read endpoints built and running on the preview VM. See Multilocation Business APIs. |
| UI | Not started for this work. |
Model
A resource has four separate facts.
business_id, a nullable uuid holding the business entity id. Null means no single owner resolved.entity_id column. It keeps the value it held before. On most tables that is the location the row was created at.Why business_id and not entity_id
We audited 68 tables before choosing. The existing entity_id could not be reused as the owner.
- Authorization reads a row's
entity_idas the location in 17 lookups. Forms and offers reject any other entity type. - About 274 query sites filter these tables by a location id. Clients alone has about 110. A missed reader returns an empty list, not an error.
- Seven unique indexes are scoped to
entity_id. Two locations of one business holding the same add-on source id, product SKU, tag name or phrase would collide. entity_idisNOT NULL. It cannot represent an unresolved owner.- Sales and other events keep the location in
entity_idin any design. One name would then mean business on a coupon and location on its usage row.
vector.brain_memory_quality_scores already pairs entity_id with business_id this way.
Tables
Owner column and mapping table
| Resource table | Mapping table |
|---|---|
services.services | services.services_entities (existing) |
services.categories | services.categories_entities (existing) |
offers.offers | offers.offer_entities (existing) |
staff.members | staff.member_locations (existing) |
services.bundles | services.bundle_locations |
services.addons | services.addon_locations |
services.addon_groups | services.addon_group_locations |
packages.package_templates | packages.package_template_locations |
gift_cards.gift_card_designs | gift_cards.gift_card_design_locations |
clients.clients | clients.client_locations |
scheduling.coupons | scheduling.coupon_locations |
scheduling.forms | scheduling.form_locations |
scheduling.form_tags | scheduling.form_tag_locations |
scheduling.dot_phrases | scheduling.dot_phrase_locations |
inventory.products | inventory.product_locations |
The 11 new mapping tables share one shape: id, the resource id, location_entity_id, is_active, audit columns, and a unique constraint on (resource id, location).
Owner column only
| Table | Reason for no mapping table |
|---|---|
scheduling.resources, scheduling.resource_groups | A room is at one location. entity_id is its placement. |
clients.client_note_labels, scheduling.auto_coupon_configs | Settings-like rows with one location. |
scheduling.sales | A sale happened at one location. |
packages.purchased_packages, packages.package_subscriptions | Sold at one location. Redemption scope is unchanged. |
gift_cards.gift_cards, gift_cards.gift_card_purchases | Bought and issued at one location. Redemption stays per location. |
Not changed
Every other table keeps its columns. The full audit below gives the verdict for each group.
- Child and event rows take the owner from their parent: variations, line items, payments, refunds, coupon and offer usages, form submissions, stock on hand, stock movements, client notes and contacts.
- Per-location settings stay per location: gift card settings, commission settings, tax rates, booking rules and policies.
scheduling.form_templateshas no merchant rows today. Itsentity_idwill mean the business when the first merchant writer ships.- Three tables already hold the business in
entity_id:scheduling.onboarding,scheduling.platform_migrationsandstaff.invites. They pair it with a location column and are left as they are.
Owner resolver
EntityOwnershipService.resolveOwningBusinessId(entityId) in libs/entity decides the owner for every writer. It runs one query and never throws.
| Entity | Owner |
|---|---|
| A business | Itself |
| A location with exactly one business parent | That business |
| A location with no parent, or several | None |
| A booking playground | None. Its parent edge points at the real merchant. |
| Unknown entity, any other type, no entity | None |
A failed lookup is logged and stored as no owner. It never blocks a create.
Writers
Each writer resolves the owner once before its transaction, stores it on the new row, and inserts the mapping row with the same location the row is created for.
| Area | Paths covered |
|---|---|
| Catalogue | Dashboard create, scrape sync, Mindbody add-on sync, platform import |
| Packages and gift cards | Template create and duplicate, design create and catalog seeding, purchase, renewal, issuance |
| Clients | Single create, bulk create, walk-in client in quick sale. Bulk create covers imports, Square, Booksy and CSV. |
| Promotions | Offer create, draft and duplicate, website editor publish, coupon create, auto-generated coupons, auto-coupon config |
| Forms and resources | Form, tag, phrase, resource and group create, platform form migration |
| Inventory and sales | Product create (the single insert all paths use), booking sales, quick sale, package purchase, recurring series, migrated appointments |
| Staff | Member create, bulk create, invite send and accept, website roster import |
| Platform import | Every imported row in these tables. Derived row ids are unchanged. |
Stored without an owner by design
- Categories created without an entity: the mobile
POST /services/category, the batch endpoint, and GBP sync. - Everything a booking playground creates.
- Global Zoca gift card designs and system note labels.
- Test fixture seeds in
scripts/.
Mobile compatibility
Mobile routes are tagged App. Their responses must not gain a key. A new column leaks wherever a whole row is returned, so these reads now select explicit columns or drop the key.
| Route or path | Fix |
|---|---|
GET /website/section-media (services) | Explicit 17-column list |
| Cart and booking responses that include bundles and add-ons | Read projections exclude business_id |
| Offers: get, draft create and update, status list, search | Key removed at the repository boundary |
GET bookings/:id (attached sale) | Sales reads select every column except business_id |
| Staff lists, including the website data route | Staff reads select every column except business_id |
| Clients | No change needed. Rows are rebuilt through the Client model. |
Remark. Web responses for resources, resource groups, coupons, auto-coupon configs, gift card designs, subscriptions and the staff service-provider toggle now include businessId. No mobile route returns them.
Full table audit
The first audit covered the tables behind the Your business sidebar. A second pass classified every table. The database has 818 tables plus 170 log twins. 311 carry an entity or location column.
The audit read code, not data. Each verdict comes from what the writers store and what the readers filter on. Seven tables whose names end in log were outside the 818; none has an owner column.
| Verdict | Tables (approx.) | Meaning |
|---|---|---|
| Internal | 314 | Zoca operations, sales CRM, provider mirrors, pipelines, reference data |
| Child | 170 | Reaches the business through a parent row |
| No reader or writer in the repo | 137 | Includes all of scp.*, stripe.*, googleads.*. Some may be fed from outside the repo. |
| Location by nature | 71 | A Google listing, a platform connection, a phone number, a room's blocked time |
| Per-location setting | 26 | Correct per location today |
| Already owned | 24 + 11 mappings | This work |
Candidates
| Table | What it is | Recommendation |
|---|---|---|
gift_cards.gift_card_purchases | The purchase that an issued card hangs off. | Done. Migration 0622. |
campaigns.journeys | A loyalty programme the merchant defines. Authored per location today. | Owner and mapping if a programme should be defined once. Product decision. |
website.booking_enquiry_text_templates | Canned replies for the inbox and leads. Same kind as dot phrases. | Owner and mapping if replies should be shared. Product decision. |
staff.time_off_types | A merchant-authored list of leave types. Same kind as form tags. | Owner and mapping if the list should be shared. Product decision. |
app.onboarding | For a location joining a business, the target business exists only in a JSON field. | Owner column. Product sign-off. |
scheduling.bookings | Appointments. | Leave. Every booking has a 1:1 sale that carries the owner. Add only for a business-wide appointment list outside Calendar. |
website.booking_enquiries | Leads. | Leave. Business-wide lists already work on the location column. |
Confirmed with no change
- Front desk. Every configuration row is per location. Calls, callbacks and front desk bookings are events. Business-wide inbox, enquiries and call reports already read through the location column.
- Content queue. Location-only by decision. Every content table is keyed to a location; the business calendar is a view.
- Media. An asset has no owner.
media_entitiesmaps it to locations, and the business gallery reads through that. - Reviews. One Google listing, one location.
- Roles and permissions. Already business-scoped with a per-location lane.
- Brain. Memories accept a business or a location. Parked.
- Campaign broadcasts and requests. Sends to one location's clients, not definitions.
Business defaults, not owner columns
These are per-location settings where "set once for the business" would be a fallback step in a resolver.
entities.preferences: resolves location, then cohort, then default. No business step.- Booking rules and policies: one row per location in practice. A business mapping row is allowed but unused.
- Booking message templates, automated lead messages, follow-up rules: location, then global.
- Gift card settings, commission settings, review-invite templates, FAQs, front desk agent context.
Design issues the audit found
business_id on that location's rows keeps the old business, as do the Stripe account, roles, permissions and contracts.- Service tax.
service_tax_mappingshas no location. A service shared by two locations shares one taxable flag and one tax item, written by whichever location saved last. - Staff hours. Working hours are keyed by staff member with no location. A member at two locations has one weekly schedule for both.
- Wallets and saved cards. A wallet follows the client row. Stripe customers are one per client row, so one person at two locations has two customers on the same account.
- Tip presets. Stored per location, pushed to the account's single Terminal configuration. Sibling locations overwrite each other.
- Billing customer. Code creates one Chargebee customer per location. The intended model is one per business. The mapping table can already hold a business row; the resolver cannot.
- Unused override table.
services.location_variation_overrideshas no reader or writer in the application.
Backfill
scripts/ops/backfill-business-ownership.sh fills existing rows for one or more locations. Each location runs in its own transaction.
# Dry run: prints the per-table report, writes nothing.
scripts/ops/backfill-business-ownership.sh <location-uuid>...
# Write the changes.
scripts/ops/backfill-business-ownership.sh --apply <location-uuid>...
# Leave sales, purchased packages, subscriptions and gift cards untouched.
scripts/ops/backfill-business-ownership.sh --apply --definitions-only <location-uuid>...
| Rule | Behaviour |
|---|---|
| Owner | The location's single business parent, the same rule the application uses. |
| Refuses to run | The id is not a location, is a playground, or has no single business parent. Nothing is written. |
| Fills | Only rows whose business_id is empty. entity_id is never written. |
| Row owned by another business | Counted and left alone. |
| Service, category or offer also linked to another business | Left unowned and counted. |
| Mapping rows | Inserted from each row's current location, marked ownership-backfill. |
| Second run | Changes nothing. |
Events are included by default. That stamps a location's past sales with its current business. Use --definitions-only for a location that has changed business.
Preview VM result, 2026-10-05. Bloom & Co and Mirror & Muse: 61 clients, 123 sales, 14 staff, 28 services and 6 categories filled; 61 client location rows written; no conflicts. A second run filled nothing. A business id and an unknown id were both refused.
Verification
| Check | Result |
|---|---|
| Test suites of the 14 touched libraries | About 11,600 tests pass |
| Staff suite after the staff change | 593 tests pass |
| Failing tests | 3, in answer-write-contract-matrix.spec.ts. They compare form widget slots with the web app and read no file this work changed. Not confirmed on a clean checkout. |
| Type check of the API server | No error on a changed line. 404 errors overall, on lines this work did not touch. |
| Migration validator | 0 errors for 0620, 0621 and 0622 |
| Real database | Preview VM. 34 business_id columns and 11 mapping tables present; 623 migrations applied. |
| Writes on a real database | 17 create paths run through the API on the VM. Each stored the business as owner, kept its location, and wrote one mapping row where a mapping table exists. |
| Mobile responses on the VM | No mobile-tagged response exposed businessId: client create, client reads, offer draft. |
| Updates on the VM | Updates on clients, products, sales, services, categories, offers, package templates and staff succeed and write a log row carrying the owner. |
| Server boot | All 13 services start on the preview VM with the new module imports. |
| Not yet exercised on a real database | Gift card issuance (disabled at the test location), gift card designs (needs an uploaded image), purchased packages, subscriptions, bookings. |
Behaviour changes
- Creating a bundle, add-on, add-on group, coupon, product or form now runs in a transaction with its mapping row.
- A product whose mapping insert fails is rolled back, so no product exists without a location row.
- The
client_locationsinsert uses Knex. Client creation already runs in a Knex transaction, and a Drizzle statement cannot join it. - Archiving or soft-deleting a resource leaves its mapping rows active. The resource's own flag hides it, so a reader must check both.
- Hard-deleting a product removes its location rows in the same transaction. The VM test found they were being left behind.
Remaining work
Decide the audit candidates
Gift card purchases is done. Journeys, inbox text templates, time-off types and onboarding need a product decision.Finish the VM writer test
Still to run: gift card issuance, gift card designs, purchased packages, subscriptions, bookings.Audit production
Count locations with no business or several, and duplicate keys across sibling locations. Only local data has been measured.Backfill production
Run the backfill per location, dry run first. It takes one transaction per location, so large locations need a check on run time and log volume before use.Re-stamp on re-parenting
When a location moves to another business, updatebusiness_id on its rows. Nothing does this today. Web APIs
The 24 list endpoints are built. Writes by business are not designed. Mobile APIs stay as they are.UI
Move pages onto those APIs.Decisions made
| Decision | Detail |
|---|---|
| Owner column name | business_id. The existing entity_id is never repurposed. |
| Staff | staff.members gets business_id like every other table. Its entity_id is not changed. |
| Content queue | Location-only. No owner column on content tables. |
| Calendar and website editor | Single location. |
| Brain | Parked. |
| Mobile APIs | Request and response shapes do not change. |
| Unresolved owner | Stored as null and left out of business-wide lists. |
| Order of work | Database, then APIs, then UI. |
Open decisions
- Historical sales when a location changes business. Sales, purchased packages, subscriptions and gift cards created before this stay unowned until we decide which business keeps them.
- Rows with no resolvable owner. Local data: 803 of 2,898 services have one clean owner candidate. The current rule leaves the rest null and out of business-wide lists.
- Offers relinked to another business. An offer later attached to a location of a different business keeps its first owner.
- Forms contract. The scheduling forms pack says not to add a per-location association table.
form_locationsnow exists. The pack and an ADR need updating. - Inventory invariant. The inventory pack locks location to equal entity. Shared products need the v2 ADR before any reader uses
product_locations. - Audit candidates. Whether loyalty journeys, inbox text templates and time-off types are defined once per business, and whether onboarding gets an owner column.
- Location changes business. What re-stamps
business_id, and whether roles, contracts and the Stripe account move with the location. - Service tax for a shared service. One tax setting per service, or one per location.
- Staff hours across locations. One weekly schedule per member, or one per location.
- Business defaults. Whether preferences, booking rules and message templates get a business step before the global default.
- Billing customer. One Chargebee customer per business needs a change to the customer resolver, not a column.
Risks
business_id to mobile payloads.- The old
entity_idstays on every table. A reader that still filters on it keeps working and will under-report a record shared to a second location. Nothing forces that reader to be fixed. staff.members.entity_idholds a location on most rows and a business on some. No new code should depend on it.business_idis the owner.- PR #23863 conflicts with
developand is 21 commits behind.