Skip to main content

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:

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

  1. Fetches the user record to retrieve firebaseOauthUid
  2. Throws 400 Bad Request if the UID is missing (user hasn't authenticated with Firebase yet)
  3. Calls FirebaseService.updateUserCustomClaims which deep-merges the new role without disturbing other claims

API Endpoints

MethodPathDescription
POST/business-usersCreate a business-user link and sync Firebase claims
GET/business-usersPaginated list (filter by businessId, search by name/email)
GET/business-users/:idGet single record by UUID
PATCH/business-users/:idUpdate record (role, active status) and re-sync Firebase claims
DELETE/business-users/:idHard-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: false immediately excludes the user from all findAll and findManyBusinessUsers queries

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/:id should revoke the role_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 BusinessUsersRepository injection in BusinessUsersService with the IBusinessUsersRepository port token for full DI inversion. (Done — uses BUSINESS_USERS_REPOSITORY Symbol token)
  • sortableBusinessUserKeys is an empty array — SortMixin removed from PaginateBusinessUsersQuery.