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.