Platform Business Directory
FIXX staff list of every business row, including the operator business. It is a read model on top of businesses, memberships, and the latest operator billing account. It does not provision accounts, change status, or edit tenants.
PWA: Platform Billing → Businesses (/platform-billing/businesses). Who can call it: Access Control.
Layers
| Layer | What it owns |
|---|---|
| Domain | IPlatformBusinessDirectoryRepository and the list/detail shapes in domain/ports/platform-business-directory-repository.domain.ts. Filter unions (none, account statuses, creating clients) live here. |
| Application | PlatformBusinessDirectoryService lists and loads a detail, and throws NotFoundException when the id is missing. LastAppOpenBackfillService enqueues and runs the stamp fill. |
| Infrastructure | PlatformBusinessDirectoryRepository (Kysely). The Bull processor on queue platform-billing runs job last-app-open-backfill. |
| Interfaces | PlatformBusinessDirectoryController under platform-billing/businesses, guarded by PlatformAdminGuard and @SkipLocationAccess(). The query DTO validates page, size, search, filters, and sort. |
The creating-client value is a column on business. Parsing X-Flowpos-Client is an HTTP concern in apps/backend/src/businesses/interfaces/created-client.header.ts. Services receive CreatedClient | null and never read the header.
List
GET /platform-billing/businesses
Offset page (currentPage, pages, results, totalRecordsCount). Defaults: page 1, size 20. size max is 100. search max length is 100.
| Query | Meaning |
|---|---|
search | Case-insensitive substring on business name, legal name, tax ID, any location name, and any member's full name or email. %, _, and \ in the term are matched literally. |
billingStatus | none (no operator billing account) or a PlatformAccountStatus: trialing, active, past_due, suspended, cancelled. |
createdClient | pwa, mobile, desktop, or none (column is null). |
sortBy | name, legalName, taxId, isActive, createdAt, locationCount, userCount, lastAppOpenedAt, createdClient, billingStatus. |
sortOrder | asc or desc. |
Omitted sortBy sorts by name. Omitted sortOrder is desc. The PWA does not rely on that: it always sends sortBy=name and sortOrder=asc on first paint. Clicking another column uses the defaults in defaultSortOrder on PlatformBillingBusinessesPage: text columns (name, legalName, taxId, createdClient, billingStatus) start A to Z; createdAt, locationCount, userCount, lastAppOpenedAt, and isActive start with the newest, the largest count, or active rows. A second click on the same column flips the direction.
legalName, taxId, lastAppOpenedAt, createdClient, and billingStatus sort nulls last in both directions. Ties break on business.id ascending.
Each row:
| Field | Source |
|---|---|
Identity, type, segment, isActive, onboardingStatus, createdAt, createdClient, isPlatformOperator | business |
locationCount | Count of location rows, active and inactive |
userCount | Distinct business_user.user_id, active and inactive. Two roles for one person count once. |
lastAppOpenedAt | max(user.last_app_opened_at) among active memberships only |
platformAccountId, platformAccountStatus | Latest platform_account whose customer represents_business_id is this business and whose customer belongs to the operator (is_platform_operator). Latest means updated_at desc, then created_at desc. No such row → both null, which is what billingStatus=none selects. |
userCount and lastAppOpenedAt are not the same population. A deactivated member still counts as a user and can still match search, and their stamp does not move the business's last-open time.
Detail
GET /platform-billing/businesses/:businessId
400 when businessId is not a UUID. 404 with Business <id> not found when the row is missing.
The body is { id, name, locations, users }. It does not repeat billing, counts, or createdClient — those stay on the list row the PWA already has. Expanding a row is what calls this route.
Locations are every location for the business, ordered by name then id. Users are one row per person: if they have more than one membership, the active one wins, then the oldest membership. The list is then sorted by full name. Each user includes role, isActive, membershipCreatedAt, and that person's lastAppOpenedAt.
Last app open
The stamp is per user, not per business. Someone in two businesses updates both directory rows, because each row takes the max among its own active members. It is not "last opened this business."
POST /auth/app-open (Firebase bearer, no body) sets user.last_app_opened_at to now() for the user on the token. 204 when the write runs or when it is skipped. 404 when that Firebase uid has no user row. The repository writes only when the column is null or older than 15 minutes.
The PWA calls it once from AuthContext, after a token and a db_user_id claim are both present. The call is fire-and-forget: a failure is swallowed and is not retried until that provider mounts again.
Backfill
POST /platform-billing/businesses/last-app-open/backfill returns 202 { jobId } and enqueues last-app-open-backfill on the platform-billing queue. It is not on the daily close cron. Posting again enqueues another job; that is safe.
The job walks users whose last_app_opened_at is null and who have a Firebase uid, in id order, 100 at a time. It copies Firebase lastSignInTime. It never overwrites a stamp: the pending query and the update both require null. A user with no lastSignInTime, or an unparseable one, stays null. The worker logs examined and stamped counts.
Created in
business.created_client is pwa, mobile, or desktop, or null. The check constraint ck_business_created_client matches CREATED_CLIENTS in packages/global/enums/business.enums.ts. Adding a client needs a migration that replaces the constraint, plus the enum.
It is written once, on insert, from header X-Flowpos-Client:
| Call | When the column is set |
|---|---|
POST /users/me/onboarding/business | Only the insert branch of upsertOnboardingBusiness. Resuming a business already in creating does not send the field. |
POST /businesses | The create handler passes the parsed header into insertBusinessWithFel. |
PATCH /businesses/:id cannot change it. No business DTO declares createdClient, so the global validation pipe strips it from bodies. The header parser returns null for a missing header, a repeated header, a value longer than 16 characters, or anything that is not exactly pwa, mobile, or desktop after trim and lowercase. It does not throw and it does not log the raw value.
The PWA sends the header on every request from apps/frontend-pwa/src/lib/api.ts. The value is __FLOWPOS_CLIENT__, fixed in vite.config.ts from the Vite mode (mobile → mobile, desktop → desktop, otherwise pwa). vite dev and the desktop local run of the web bundle send pwa. The app does not look at the host. Nothing may branch on the header for access control; it is a directory label.
Unknown (null) means the client was not recorded: businesses created before the column, the legacy importer, API callers that omit the header, and any value the parser rejects. It does not mean "created in some other client."
Troubleshooting
| What you see | What it actually is |
|---|---|
403 FlowPOS staff access only. | Caller has no active super or owner membership on the operator business. See Access Control. |
| Directory names arrive Z to A | The request omitted sortOrder. The API default is desc. The PWA sends asc for name. |
| Last open is blank for someone who signed in | No POST /auth/app-open has landed, or it 404'd before the user row existed and that session did not retry. Backfill only fills null stamps from Firebase lastSignInTime, and only for users with a Firebase uid. |
| Last open looks shared across businesses | Expected. The column is on user. |
| User count is higher than people who can sign in | Inactive memberships are included in userCount and in search. They are excluded from lastAppOpenedAt. |
Billing filter none still shows a cancelled account | none is "no account." cancelled is a real latest status. A newer account hides an older one; the list does not show history. |
| Created in stays Unknown after the merchant finishes onboarding | The header was null on the insert. Later onboarding steps and PATCH do not backfill it. |
Local desktop run shows pwa | The header follows Vite mode, not the window hosting the page. A shell build (--mode desktop or --mode mobile) is what stamps desktop or mobile. |
createdClient=web on the list API | 400 from the query DTO. The database check rejects the same value on write. |
Related code
- Controller:
apps/backend/src/platform-billing/interfaces/platform-business-directory.controller.ts - Repository:
apps/backend/src/platform-billing/infrastructure/persistence/kysely/platform-business-directory.repository.ts - Backfill:
apps/backend/src/platform-billing/application/last-app-open-backfill.service.ts - App-open stamp:
POST /auth/app-openinapps/backend/src/auth/interfaces/auth.controller.ts,UsersRepository.touchLastAppOpened - Header parser:
apps/backend/src/businesses/interfaces/created-client.header.ts - PWA:
apps/frontend-pwa/src/pages/platform-billing/PlatformBillingBusinessesPage.tsx