Docs

Engineering · Report · Running on the preview VM, not in production

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.

24 owned tables11 mapping tables818 tables audited3 migrationsapplied on preview VM
branch codex/multilocation-ui @ a000887f25 plus one fix · PR #23863 (draft) · measured 2026-10-05

Status

PartState
Schema: business_id on 22 tables, 9 log tables, 11 mapping tablesPushed. Migration 0620.
Schema: business_id on staff.members and its log tablePushed. Migration 0621.
Schema: business_id on gift_cards.gift_card_purchasesPushed. Migration 0622.
Owner resolverPushed.
Writers for all 24 tablesPushed.
Audit of every table in the databaseDone. 818 tables classified.
Migrations applied to a databasePreview VM only, 2026-10-05. Not local, not production.
Backfill of existing rowsScript written. Run on the preview VM for both seeded locations. Not run anywhere else.
Web APIs that read by business24 read endpoints built and running on the preview VM. See Multilocation Business APIs.
UINot started for this work.

Model

A resource has four separate facts.

Owner
business_id, a nullable uuid holding the business entity id. Null means no single owner resolved.
Location reach
One row per (resource, location) in a mapping table. A resource with no row is available nowhere.
Origin location
The existing entity_id column. It keeps the value it held before. On most tables that is the location the row was created at.
Access
Unchanged. An owner or a mapping row grants no permission.

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_id as 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_id is NOT NULL. It cannot represent an unresolved owner.
  • Sales and other events keep the location in entity_id in 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 tableMapping table
services.servicesservices.services_entities (existing)
services.categoriesservices.categories_entities (existing)
offers.offersoffers.offer_entities (existing)
staff.membersstaff.member_locations (existing)
services.bundlesservices.bundle_locations
services.addonsservices.addon_locations
services.addon_groupsservices.addon_group_locations
packages.package_templatespackages.package_template_locations
gift_cards.gift_card_designsgift_cards.gift_card_design_locations
clients.clientsclients.client_locations
scheduling.couponsscheduling.coupon_locations
scheduling.formsscheduling.form_locations
scheduling.form_tagsscheduling.form_tag_locations
scheduling.dot_phrasesscheduling.dot_phrase_locations
inventory.productsinventory.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

TableReason for no mapping table
scheduling.resources, scheduling.resource_groupsA room is at one location. entity_id is its placement.
clients.client_note_labels, scheduling.auto_coupon_configsSettings-like rows with one location.
scheduling.salesA sale happened at one location.
packages.purchased_packages, packages.package_subscriptionsSold at one location. Redemption scope is unchanged.
gift_cards.gift_cards, gift_cards.gift_card_purchasesBought 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_templates has no merchant rows today. Its entity_id will mean the business when the first merchant writer ships.
  • Three tables already hold the business in entity_id: scheduling.onboarding, scheduling.platform_migrations and staff.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.

EntityOwner
A businessItself
A location with exactly one business parentThat business
A location with no parent, or severalNone
A booking playgroundNone. Its parent edge points at the real merchant.
Unknown entity, any other type, no entityNone

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.

AreaPaths covered
CatalogueDashboard create, scrape sync, Mindbody add-on sync, platform import
Packages and gift cardsTemplate create and duplicate, design create and catalog seeding, purchase, renewal, issuance
ClientsSingle create, bulk create, walk-in client in quick sale. Bulk create covers imports, Square, Booksy and CSV.
PromotionsOffer create, draft and duplicate, website editor publish, coupon create, auto-generated coupons, auto-coupon config
Forms and resourcesForm, tag, phrase, resource and group create, platform form migration
Inventory and salesProduct create (the single insert all paths use), booking sales, quick sale, package purchase, recurring series, migrated appointments
StaffMember create, bulk create, invite send and accept, website roster import
Platform importEvery 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 pathFix
GET /website/section-media (services)Explicit 17-column list
Cart and booking responses that include bundles and add-onsRead projections exclude business_id
Offers: get, draft create and update, status list, searchKey removed at the repository boundary
GET bookings/:id (attached sale)Sales reads select every column except business_id
Staff lists, including the website data routeStaff reads select every column except business_id
ClientsNo 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.

VerdictTables (approx.)Meaning
Internal314Zoca operations, sales CRM, provider mirrors, pipelines, reference data
Child170Reaches the business through a parent row
No reader or writer in the repo137Includes all of scp.*, stripe.*, googleads.*. Some may be fed from outside the repo.
Location by nature71A Google listing, a platform connection, a phone number, a room's blocked time
Per-location setting26Correct per location today
Already owned24 + 11 mappingsThis work

Candidates

TableWhat it isRecommendation
gift_cards.gift_card_purchasesThe purchase that an issued card hangs off.Done. Migration 0622.
campaigns.journeysA 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_templatesCanned replies for the inbox and leads. Same kind as dot phrases.Owner and mapping if replies should be shared. Product decision.
staff.time_off_typesA merchant-authored list of leave types. Same kind as form tags.Owner and mapping if the list should be shared. Product decision.
app.onboardingFor a location joining a business, the target business exists only in a JSON field.Owner column. Product sign-off.
scheduling.bookingsAppointments.Leave. Every booking has a 1:1 sale that carries the owner. Add only for a business-wide appointment list outside Calendar.
website.booking_enquiriesLeads.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_entities maps 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

Affects this workA location can move to another business. Only the link moves. business_id on that location's rows keeps the old business, as do the Stripe account, roles, permissions and contracts.

  1. Service tax. service_tax_mappings has no location. A service shared by two locations shares one taxable flag and one tax item, written by whichever location saved last.
  2. Staff hours. Working hours are keyed by staff member with no location. A member at two locations has one weekly schedule for both.
  3. 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.
  4. Tip presets. Stored per location, pushed to the account's single Terminal configuration. Sibling locations overwrite each other.
  5. 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.
  6. Unused override table. services.location_variation_overrides has 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>...
RuleBehaviour
OwnerThe location's single business parent, the same rule the application uses.
Refuses to runThe id is not a location, is a playground, or has no single business parent. Nothing is written.
FillsOnly rows whose business_id is empty. entity_id is never written.
Row owned by another businessCounted and left alone.
Service, category or offer also linked to another businessLeft unowned and counted.
Mapping rowsInserted from each row's current location, marked ownership-backfill.
Second runChanges 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

CheckResult
Test suites of the 14 touched librariesAbout 11,600 tests pass
Staff suite after the staff change593 tests pass
Failing tests3, 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 serverNo error on a changed line. 404 errors overall, on lines this work did not touch.
Migration validator0 errors for 0620, 0621 and 0622
Real databasePreview VM. 34 business_id columns and 11 mapping tables present; 623 migrations applied.
Writes on a real database17 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 VMNo mobile-tagged response exposed businessId: client create, client reads, offer draft.
Updates on the VMUpdates on clients, products, sales, services, categories, offers, package templates and staff succeed and write a log row carrying the owner.
Server bootAll 13 services start on the preview VM with the new module imports.
Not yet exercised on a real databaseGift 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_locations insert 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

1

Decide the audit candidates

Gift card purchases is done. Journeys, inbox text templates, time-off types and onboarding need a product decision.

4 tables

2

Finish the VM writer test

Still to run: gift card issuance, gift card designs, purchased packages, subscriptions, bookings.

17 of 22 paths done

3

Audit production

Count locations with no business or several, and duplicate keys across sibling locations. Only local data has been measured.

read-only

4

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.

script ready

4b

Re-stamp on re-parenting

When a location moves to another business, update business_id on its rows. Nothing does this today.

new requirement

5

Web APIs

The 24 list endpoints are built. Writes by business are not designed. Mobile APIs stay as they are.

24 reads done

6

UI

Move pages onto those APIs.

per page

Decisions made

DecisionDetail
Owner column namebusiness_id. The existing entity_id is never repurposed.
Staffstaff.members gets business_id like every other table. Its entity_id is not changed.
Content queueLocation-only. No owner column on content tables.
Calendar and website editorSingle location.
BrainParked.
Mobile APIsRequest and response shapes do not change.
Unresolved ownerStored as null and left out of business-wide lists.
Order of workDatabase, then APIs, then UI.

Open decisions

  1. 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.
  2. 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.
  3. Offers relinked to another business. An offer later attached to a location of a different business keeps its first owner.
  4. Forms contract. The scheduling forms pack says not to add a per-location association table. form_locations now exists. The pack and an ADR need updating.
  5. Inventory invariant. The inventory pack locks location to equal entity. Shared products need the v2 ADR before any reader uses product_locations.
  6. Audit candidates. Whether loyalty journeys, inbox text templates and time-off types are defined once per business, and whether onboarding gets an owner column.
  7. Location changes business. What re-stamps business_id, and whether roles, contracts and the Stripe account move with the location.
  8. Service tax for a shared service. One tax setting per service, or one per location.
  9. Staff hours across locations. One weekly schedule per member, or one per location.
  10. Business defaults. Whether preferences, booking rules and message templates get a business step before the global default.
  11. Billing customer. One Chargebee customer per business needs a change to the customer resolver, not a column.

Risks

Deploy togetherThe migrations and the mobile response fixes are one release. A migration deployed alone adds business_id to mobile payloads.

  • The old entity_id stays 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_id holds a location on most rows and a business on some. No new code should depend on it. business_id is the owner.
  • PR #23863 conflicts with develop and is 21 commits behind.