Business Users Module
Overview
The business-users module manages the many-to-many relationship between users and businesses. Each record (business_user) represents a user's membership in a business with an assigned role (uniqueRoleName). Creating or changing this link automatically propagates the role into Firebase custom claims (role_by_business_id) so the auth layer reflects access immediately.
Module path
apps/backend/src/business-users/
Architecture
The module follows the project-wide Hexagonal Architecture pattern with full DI inversion via a Symbol token:
business-users/
├── domain/
│ └── business-users-repository.domain.ts # IBusinessUsersRepository port, BUSINESS_USERS_REPOSITORY token, domain types
├── application/
│ └── business-users.service.ts # Use cases + Firebase claim sync orchestration (injects port via token)
├── infrastructure/
│ └── business-users.repository.ts # Kysely PostgreSQL adapter (implements IBusinessUsersRepository)
└── interfaces/
├── business-users.controller.ts # HTTP endpoints
├── dtos/
│ ├── create-business-user.dto.ts
│ └── update-business-user.dto.ts
└── query/
└── paginate-business-users.query.ts
Dependency Injection
The repository is registered with a Symbol token (BUSINESS_USERS_REPOSITORY) in the module:
{ provide: BUSINESS_USERS_REPOSITORY, useExisting: BusinessUsersRepository }
The service injects the port interface, not the concrete class:
@Inject(BUSINESS_USERS_REPOSITORY)
private readonly businessUsersRepository: IBusinessUsersRepository
This enables easy testing by swapping the repository implementation.
Domain Concepts
BusinessUser
A business_user record is the join between user and business:
business_user
├── id UUID (PK)
├── userId FK → user.id
├── businessId FK → business.id
├── uniqueRoleName Role identifier (e.g. "owner", "manager", "cashier")
├── isActive Soft-active flag
├── createdBy UUID of the actor who created the link
├── updatedBy UUID of the actor who last updated the link
├── createdAt / updatedAt
BusinessUserWithUserInfo
A projected type extending business_user with fullName and email from the joined user table. Returned by the paginated findAll query and the GET /business-users endpoint.
BusinessUserWithProfile
A projected type enriching business_user with the associated user's profile fields (fullName, email, phone). Used by findByBusinessId — the canonical way to list the members of a business.
Firebase Claims Sync
Every create or update of a business-user record deep-merges role_by_business_id: { [businessId]: roleName } into the user's Firebase custom claims. This keeps the JWT the frontend receives in sync with the DB role, without requiring the user to sign out and back in.
Important: Firebase claims are cached in the JWT until the token refreshes (~1 hour). For immediate access revocation, deactivate the record (isActive: false) rather than deleting it — the delete endpoint does not revoke claims.
Application Service
BusinessUsersService orchestrates all use cases:
| Method | Description |
|---|---|
createBusinessUser(dto) | Inserts record, then syncs Firebase claim |
getAllBusinessUsers(params) | Paginated list of active business-users with user profile fields |
getBusinessUserById(id) | Single record lookup |
updateBusinessUser(params) | Updates record, then re-syncs Firebase claim |
deleteBusinessUser(id) | Hard-deletes record (claims not revoked — see above) |
findManyBusinessUsers(userId) | All active businesses for a given user (used internally by UsersService) |
findByBusinessId(businessId) | All active members of a business with profile data (used by other modules) |
Firebase Sync (private)
syncUserFirebaseClaims(userId, businessId, roleName) is the shared private helper called by both createBusinessUser and updateBusinessUser. It:
- Fetches the user record to retrieve
firebaseOauthUid - Throws
400 Bad Requestif the UID is missing (user hasn't authenticated with Firebase yet) - Calls
FirebaseService.updateUserCustomClaimswhich deep-merges the new role without disturbing other claims
API Endpoints
| Method | Path | Description |
|---|---|---|
POST | /business-users | Create a business-user link and sync Firebase claims |
GET | /business-users | Paginated list (filter by businessId, search by name/email) |
GET | /business-users/:id | Get single record by UUID |
PATCH | /business-users/:id | Update record (role, active status) and re-sync Firebase claims |
DELETE | /business-users/:id | Hard-delete record (does not revoke Firebase claims) |
All endpoints require a valid Firebase Bearer token.
Request / Response Examples
POST /business-users
// Request body
{
"userId": "550e8400-e29b-41d4-a716-446655440000",
"businessId": "660e8400-e29b-41d4-a716-446655440001",
"uniqueRoleName": "owner",
"isActive": true,
"createdBy": "550e8400-e29b-41d4-a716-446655440000"
}
// Response 201
{
"id": "bu_01abc",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"businessId": "660e8400-e29b-41d4-a716-446655440001",
"uniqueRoleName": "owner",
"isActive": true,
"createdAt": "2026-01-01T00:00:00Z",
"createdBy": "550e8400-e29b-41d4-a716-446655440000",
"updatedAt": null,
"updatedBy": null
}
GET /business-users?businessId=...&page=1&size=10
{
"currentPage": 1,
"pages": 3,
"totalRecordsCount": 25,
"results": [
{
"id": "bu_01abc",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"businessId": "660e8400-e29b-41d4-a716-446655440001",
"uniqueRoleName": "manager",
"fullName": "Jane Doe",
"email": "jane@example.com",
"isActive": true
}
]
}
PATCH /business-users/:id
// Request body
{
"isActive": false,
"updatedBy": "550e8400-e29b-41d4-a716-446655440000"
}
Bruno API Collection
api-client/flowpos/collections/business-users/
├── list-business-users.yml GET /business-users
├── create-business-user.yml POST /business-users
├── get-business-user-by-id.yml GET /business-users/:id
├── update-business-user.yml PATCH /business-users/:id
└── delete-business-user.yml DELETE /business-users/:id
Design Decisions
Firebase sync in the service, not the controller
Originally the Firebase sync logic was duplicated in both the createBusinessUser and updateBusinessUser controller handlers. It has been extracted to a private syncUserFirebaseClaims helper in BusinessUsersService. This keeps the controller thin and ensures the sync cannot be accidentally bypassed by any caller of the service.
Hard delete vs. soft delete
The DELETE endpoint performs a hard delete. For scenarios requiring immediate access revocation, prefer deactivating via PATCH { isActive: false } instead, because:
- Deleting does not revoke Firebase claims (they're cached in the JWT)
- Deactivating with
isActive: falseimmediately excludes the user from allfindAllandfindManyBusinessUsersqueries
isActive filter on count and results
Both the count query and the results query in findAll share the same base query with isActive = true applied before branching. This ensures pagination totals are always accurate (a prior bug counted inactive users but excluded them from results).
Known Follow-ups
-
DELETE /business-users/:idshould revoke therole_by_business_id[businessId]Firebase claim to prevent stale role access until token expiry. - Consider replacing the hard delete with a soft-delete-only pattern (remove the DELETE endpoint and rely solely on
isActive: false). -
Replace concrete(Done — usesBusinessUsersRepositoryinjection inBusinessUsersServicewith theIBusinessUsersRepositoryport token for full DI inversion.BUSINESS_USERS_REPOSITORYSymbol token) -
sortableBusinessUserKeysis an empty array —SortMixinremoved fromPaginateBusinessUsersQuery.