Saltar al contenido principal

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

LayerResponsibility
DomainRepository and service interfaces (ports). Framework-agnostic.
ApplicationUse cases (UsersService, OnboardingOrchestratorService). Orchestrates domain operations.
InfrastructureKysely PostgreSQL adapter. Implements IUsersRepository.
InterfacesHTTP 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 user table is the business data source of truth.
  • On first login via POST /auth/firebase, a DB user record is created and the db_user_id custom claim is written back to Firebase.
  • Most authenticated endpoints extract the DB user ID from db_user_id claim 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:

MethodDescription
createUserInserts DB user, syncs db_user_id Firebase claim
findUserByIdLookup by DB ID
findUserByFirebaseUidLookup by Firebase UID
getOrCreateUserUpsert on first login; updates fullName if changed
getUserAlias for findUserByFirebaseUid (used by AuthService)
getUserBusinessesAndLocationsReturns businesses + roles for current user, and only the locations they are assigned to (see User Location Assignments); auto-creates user record
getUserOnboardingStatusReturns current onboarding step + typed snapshots
getStatusCreatingBusinessSame but scoped to an in-progress additional business
updateUserUpdates fullName
getAllUsersPaginated list (sortable by fullName, email, createdAt)

OnboardingOrchestratorService

Coordinates multi-step onboarding. Each method wraps a sequence of cross-module operations:

MethodDescription
upsertOnboardingBusinessCreates/updates business, creates Owner business_user, syncs Firebase claims
upsertUserLocationResolves Google Place, creates/updates address + location, advances status to billing
patchOnboardingBillSaves legal name, tax ID, FEL credentials
patchOnboardingStatusAdvances 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

MethodPathAuthDescription
POST/usersBearerCreate a user (links Firebase → DB)
GET/usersBearerList all users (paginated, sortable)
GET/users/meBearerGet current authenticated user
GET/users/me/businesses-and-locationsBearerGet businesses + locations for current user
PATCH/users/:idBearerUpdate user fullName
GET/users/firebaseBearerGet user by Firebase UID
POST/users/me/onboarding/businessPublicCreate/update onboarding business
POST/users/me/onboarding/:businessId/locationBearerCreate/update onboarding location
PATCH/users/me/onboarding/:businessId/billBearerSave billing / FEL info
GET/users/me/onboarding/statusBearerGet current onboarding step
GET/users/me/onboarding/creating-businessBearerGet status of an in-progress additional business
PATCH/users/me/onboarding/:businessId/statusBearerAdvance onboarding status

POST /users/me/onboarding/business is @IsPublic() because the DB user record may not exist yet on first call. extractDbUserIdFromRequest creates 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/.

ColumnNotes
iduuid PK
business_user_idFK → business_user.id, ON DELETE CASCADE
location_idFK → location.id, ON DELETE CASCADE
business_idFK → business.id — denormalized for scoped queries
created_attimestamptz
created_bynullable 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

MethodPathBodyAuth
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:

  1. Skips @IsPublic() routes and requests with no resolved db_user_id.
  2. Skips platform root/admin (Firebase role claim AppRoleName.Root / AppRoleName.Admin) — they are unscoped today, same as elsewhere in the app.
  3. Reads the canonical locationId field from bodyparamsquery (in that precedence order). If none is present, the route is business-level and the guard is a no-op.
  4. Otherwise calls UserLocationAccessService.assertLocationAccess(userId, locationId) — a single indexed join against business_user_location — and throws 403 if 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

  1. Open the Users admin page (PWA frontend-pwa/src/components/forms/user/).
  2. Edit a user → a LocationCheckboxPicker (fed by the full business location catalog) replaces/extends the role picker.
  3. Save triggers PUT /business-users/:id/locations alongside any name/role changes, in parallel.
  4. If the caller edited their own row and their locations changed, the header (BusinessSelector/LocationSelector) refetches immediately via UserDataContext (apps/frontend-pwa/src/contexts/UserDataContext.tsx). For a different, already-logged-in user, the change takes effect on their next request//me fetch — 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 UsersRepository injection in UsersService with an IUsersRepository DI token (pending project-wide convention adoption).
  • LocationAccessGuard does not enforce sourceLocationId/destinationLocationId on 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.