Users Module
Overview
The users module manages system user accounts that are tightly coupled to Firebase Authentication. It also owns the multi-step business onboarding flow through which new users create their first business, location, and billing configuration.
Module path
apps/backend/src/users/
Architecture
The module follows the Hexagonal Architecture pattern used project-wide:
users/
├── domain/ # Ports (interfaces)
│ ├── users-repository.domain.ts # IUsersRepository
│ └── users-service.domain.ts # IUsersService, ICreateUserParameters
├── application/ # Use cases
│ ├── users.service.ts # User CRUD + onboarding status
│ └── onboarding-orchestrator.service.ts # Multi-step onboarding logic
├── infrastructure/ # Adapters
│ └── users.repository.ts # Kysely PostgreSQL implementation
└── interfaces/ # HTTP adapters
├── me.controller.ts # GET /users/me
├── users.controller.ts # CRUD + businesses-and-locations
├── me-onboarding.controller.ts # /users/me/onboarding/* endpoints
├── firebase.controller.ts # GET /users/firebase
├── dtos/ # Request bodies
└── query/ # Pagination/sort query params
Layer Responsibilities
| Layer | Responsibility |
|---|---|
| Domain | Repository and service interfaces (ports). Framework-agnostic. |
| Application | Use cases (UsersService, OnboardingOrchestratorService). Orchestrates domain operations. |
| Infrastructure | Kysely PostgreSQL adapter. Implements IUsersRepository. |
| Interfaces | HTTP controllers, DTOs, query objects. Thin request/response mapping. |
Dependency Flow
Controllers → Application Services → Domain Interfaces ← Infrastructure Adapters
All cross-table queries delegate to their respective module services (BusinessesService, LocationsService, BusinessUsersService). The UsersRepository only queries the user table.
Domain Concepts
User
A user record represents an authenticated system identity. It is linked 1:1 with a Firebase Auth account via firebaseOauthUid. Users are not employees — see docs/employees/README.md for the distinction.
user
├── id UUID (PK)
├── firebaseOauthUid Firebase UID
├── fullName
├── email
├── phone
├── systemUniqueRoleName null | "admin" | "root" (super-admin bypass)
├── createdAt / updatedAt / updatedBy
Firebase ↔ DB User Synchronisation
- Firebase is the auth source of truth; the DB
usertable is the business data source of truth. - On first login via
POST /auth/firebase, a DB user record is created and thedb_user_idcustom claim is written back to Firebase. - Most authenticated endpoints extract the DB user ID from
db_user_idclaim rather than looking it up by Firebase UID on every request.
OnboardingStep
A state machine on the business.onboardingStatus column:
business → location → billing → complete → done
Application Services
UsersService
Core use-case service. Responsibilities:
| Method | Description |
|---|---|
createUser | Inserts DB user, syncs db_user_id Firebase claim |
findUserById | Lookup by DB ID |
findUserByFirebaseUid | Lookup by Firebase UID |
getOrCreateUser | Upsert on first login; updates fullName if changed |
getUser | Alias for findUserByFirebaseUid (used by AuthService) |
getUserBusinessesAndLocations | Returns businesses + roles for current user, and only the locations they are assigned to (see User Location Assignments); auto-creates user record |
getUserOnboardingStatus | Returns current onboarding step + typed snapshots |
getStatusCreatingBusiness | Same but scoped to an in-progress additional business |
updateUser | Updates fullName |
getAllUsers | Paginated list (sortable by fullName, email, createdAt) |
OnboardingOrchestratorService
Coordinates multi-step onboarding. Each method wraps a sequence of cross-module operations:
| Method | Description |
|---|---|
upsertOnboardingBusiness | Creates/updates business, creates Owner business_user, syncs Firebase claims |
upsertUserLocation | Resolves Google Place, creates/updates address + location, advances status to billing |
patchOnboardingBill | Saves legal name, tax ID, FEL credentials |
patchOnboardingStatus | Advances onboardingStatus; sets onboarding_completed Firebase claim when done |
Onboarding Response Types
The onboarding status endpoints return a typed OnboardingStatusResponse:
interface OnboardingStatusResponse {
step: OnboardingStep; // "business" | "location" | "billing" | "complete" | "done"
business: OnboardingBusinessSummary | null;
location: OnboardingLocationSnapshot | null;
billing: OnboardingBillingSummary | null;
}
interface OnboardingLocationSnapshot {
id: string;
name: string | null;
placeId: string | null;
address: { lineOne: string | null; lineTwo: string | null } | null;
}
API Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /users | Bearer | Create a user (links Firebase → DB) |
GET | /users | Bearer | List all users (paginated, sortable) |
GET | /users/me | Bearer | Get current authenticated user |
GET | /users/me/businesses-and-locations | Bearer | Get businesses + locations for current user |
PATCH | /users/:id | Bearer | Update user fullName |
GET | /users/firebase | Bearer | Get user by Firebase UID |
POST | /users/me/onboarding/business | Public | Create/update onboarding business |
POST | /users/me/onboarding/:businessId/location | Bearer | Create/update onboarding location |
PATCH | /users/me/onboarding/:businessId/bill | Bearer | Save billing / FEL info |
GET | /users/me/onboarding/status | Bearer | Get current onboarding step |
GET | /users/me/onboarding/creating-business | Bearer | Get status of an in-progress additional business |
PATCH | /users/me/onboarding/:businessId/status | Bearer | Advance onboarding status |
POST /users/me/onboarding/businessis@IsPublic()because the DB user record may not exist yet on first call.extractDbUserIdFromRequestcreates it if missing.
User Location Assignments
By default, any user linked to a business via business_user can see only the
locations they are explicitly assigned to — not every location of that
business. This is enforced both in the workspace bootstrap (GET /users/me/businesses-and-locations) and, more importantly, at the API layer
via a global guard.
Table schema: business_user_location
Join table between business_user and location. Module: apps/backend/src/business-users/.
| Column | Notes |
|---|---|
id | uuid PK |
business_user_id | FK → business_user.id, ON DELETE CASCADE |
location_id | FK → location.id, ON DELETE CASCADE |
business_id | FK → business.id — denormalized for scoped queries |
created_at | timestamptz |
created_by | nullable FK → user.id |
Unique on (business_user_id, location_id). Indexed on each FK — the location_id
index backs the hot per-request access check described below.
New business_user rows start with zero location rows (no access) until an
admin explicitly assigns locations from the Users admin page.
API: assigning locations
| Method | Path | Body | Auth |
|---|---|---|---|
PUT | /business-users/:id/locations | { locationIds: string[] } | BusinessUser / Update permission |
Replaces the full set of location assignments for a business_user in one
transaction (delete + insert). An empty array is allowed and revokes all of
that user's location access — this is the intended way to fully suspend a
user's access without deactivating their account. The one exception: the
caller cannot save an empty array for their own business_user row
(self‑lockout prevention, 400); another admin can still be reduced to zero.
Every locationId must belong to the target business and be active, or the
request is rejected with 400 (not silently dropped). Every call records an
ActivityLog entry (entityType: "permission_assignment",
sensitiveEventType: "permission_location_change"), fire‑and‑forget, mirroring
the existing role‑change audit event on PATCH /business-users/:id.
Enforcement: LocationAccessGuard
apps/backend/src/roles/infrastructure/location-access.guard.ts, registered as
a global APP_GUARD (in apps/backend/src/app/app.module.ts, after
AuthGuard) — not wired into the opt-in RolesGuard. This was a deliberate
correction during implementation: RolesGuard is only applied per-route via
@UseGuards, and the two highest‑value write paths — SalesController and the
restaurant OrdersController — never adopted it even though their DTOs already
carry a required locationId. Hooking the check into RolesGuard would have
enforced location access on a minority of routes and none of the POS write
surface. The global guard closes that gap in one place.
Behavior, on every authenticated request:
- Skips
@IsPublic()routes and requests with no resolveddb_user_id. - Skips platform root/admin (Firebase
roleclaimAppRoleName.Root/AppRoleName.Admin) — they are unscoped today, same as elsewhere in the app. - Reads the canonical
locationIdfield frombody→params→query(in that precedence order). If none is present, the route is business-level and the guard is a no-op. - Otherwise calls
UserLocationAccessService.assertLocationAccess(userId, locationId)— a single indexed join againstbusiness_user_location— and throws403if the user has no active assignment.
Scope boundary (intentional, not a bug): the guard only inspects the field
literally named locationId. Multi-location operations that use
sourceLocationId / destinationLocationId (e.g. inventory transfers) are
not covered by this generic check and remain each service's own
responsibility. Follow-up work should extend coverage there if per-location
enforcement is needed on transfer routes.
RolesGuard.resolveBusinessId was also fixed to resolve businessId from
params.locationId / query.locationId, not just body.locationId — an
unrelated pre-existing gap found while wiring this guard.
Distinction from employee.location_id
business_user_location governs which locations a user can see and act in
across the whole app (guard + workspace bootstrap). employee.location_id is a
separate, POS-specific concept (an employee's "home" location for shift/PIN
purposes) and is not synced with this table in v1 — updating one does not
update the other.
Admin workflow
- Open the Users admin page (PWA
frontend-pwa/src/components/forms/user/). - Edit a user → a
LocationCheckboxPicker(fed by the full business location catalog) replaces/extends the role picker. - Save triggers
PUT /business-users/:id/locationsalongside any name/role changes, in parallel. - If the caller edited their own row and their locations changed, the
header (
BusinessSelector/LocationSelector) refetches immediately viaUserDataContext(apps/frontend-pwa/src/contexts/UserDataContext.tsx). For a different, already-logged-in user, the change takes effect on their next request//mefetch — Firebase custom claims are intentionally left untouched (1KB limit; per-request DB resolution is the source of truth), so there is no way to push an instant revocation to an active session.
Backfill / ops notes
The migration
(packages/backend/database/src/migrations/2026-07-05t10-00-00-create-business-user-location.mjs)
backfills every active business_user with every active location of its
business — deliberately permissive, so the feature ships as an access no‑op.
Admins then restrict access per user by removing locations. The migration also
runs a verification query that fails the deploy if any active business_user
in a business with active locations ends up with zero assigned rows.
Bruno API Collection
api-client/flowpos/collections/users/
├── user.yml POST /users
├── users.yml GET /users
├── update-user.yml PATCH /users/:id
├── businesses and locations.yml GET /users/me/businesses-and-locations
└── me/
├── users-me.yml GET /users/me
├── users-firebase.yml GET /users/firebase
└── onboarding/
├── users-me-onboarding.yml GET /users/me/onboarding/status
├── creating-business.yml GET /users/me/onboarding/creating-business
├── business.yml POST /users/me/onboarding/business
├── location.yml POST /users/me/onboarding/:businessId/location
├── bill.yml PATCH /users/me/onboarding/:businessId/bill
└── done.yml PATCH /users/me/onboarding/:businessId/status
Onboarding Flow
See also: docs/onboarding/Onboarding-System-Architecture.md
1. POST /users/me/onboarding/business
→ Creates business (onboardingStatus = "location")
→ Creates business_user (Owner role)
→ Syncs Firebase claim: role_by_business_id
2. POST /users/me/onboarding/:businessId/location
→ Resolves Google Place (optional)
→ Creates address + location
→ Advances onboardingStatus → "billing"
3. PATCH /users/me/onboarding/:businessId/bill
→ Saves legalName, taxId, FEL credentials
4. PATCH /users/me/onboarding/:businessId/status { onboardingStatus: "done" }
→ Sets onboardingStatus = "done"
→ Sets Firebase claim: onboarding_completed = true
Design Decisions
@IsPublic() on the business upsert endpoint
The onboarding business endpoint is public because at the moment the user first hits it, no DB user record exists. extractDbUserIdFromRequest handles Firebase token validation and auto-creates the DB record transparently.
getOrCreateUser pattern
GET /users/me/businesses-and-locations uses get-or-create instead of hard-failing when the user does not exist. This prevents race conditions on first login when the frontend calls this endpoint immediately after Firebase sign-in before POST /auth/firebase completes.
Cross-module delegation for onboarding status
The UsersService delegates all non-user queries to their respective services (BusinessesService.getBusinessById, BusinessesService.findBusinessByUserId, LocationsService.findManyLocations). This maintains SRP — the UsersRepository only queries the user table.
FEL credentials storage
FEL (Guatemalan e-invoicing) credentials are stored encrypted in business.felCertifierConfig (JSONB). The schema for this field is { felUsername, felPassword, felAccessCode, felToken }.
Typed onboarding snapshots
The OnboardingLocationSnapshot interface provides a typed, minimal projection of location data for onboarding status responses, avoiding unknown types in the API contract.
Known Follow-ups
- Add transactional guarantees to
upsertOnboardingBusiness(business + business_user creation should be atomic). - Replace concrete
UsersRepositoryinjection inUsersServicewith anIUsersRepositoryDI token (pending project-wide convention adoption). -
LocationAccessGuarddoes not enforcesourceLocationId/destinationLocationIdon inventory transfer routes — extend if per-location enforcement is needed there. - No invariant preventing a business from ending up with zero fully-assigned admins — only the caller's own self-lockout is prevented today.
- Consider caching
UserLocationAccessService.getAssignedLocationIds(Redis, short TTL) if per-request DB load ever becomes a concern; not needed at current scale.