Skip to main content

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 + locations for current user; 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.


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).