Association Addons Module
Overview
The association-addons module manages the join records between a business or location and an installed addon. Each associationAddon record represents a single addon installation event scoped to a business, optionally further scoped to a specific location.
This module is the persistence layer for the addon install/uninstall workflow: when an addon is installed via the addons module, an associationAddon record is created here; when it is uninstalled the record is deleted and a domain event is emitted for downstream cleanup.
Architecture
This module follows the Hexagonal Architecture (ports & adapters) pattern used across the backend:
association-addons/
├── associations-addons.module.ts # NestJS module wiring
├── application/
│ ├── association-addons.service.ts # Use cases
│ └── events/
│ └── on-delete-association-addon.event.ts # Delete domain event
├── domain/
│ ├── association-addons-repository.domain.ts # IAssociationAddonsRepository port
│ │ # + ASSOCIATION_ADDONS_REPOSITORY token
│ │ # + sortableAssociationAddonKeys
│ └── association-addons-service.domain.ts # IGetAssociationAddonByAddonIdParameters
├── infrastructure/
│ └── association-addons.repository.ts # Kysely DB adapter
└── interfaces/
├── association-addons.controller.ts # REST endpoints
├── dtos/
│ ├── create-association-addon.dto.ts
│ └── update-association-addon.dto.ts
└── query/
├── paginate-association-addons.query.ts
└── fetch-association-addon-by-addon-id.query.ts
Dependency flow
interfaces → application → domain ← infrastructure
- Domain owns the
IAssociationAddonsRepositoryport, theASSOCIATION_ADDONS_REPOSITORYinjection token,sortableAssociationAddonKeys, and service-level parameter interfaces. No framework imports. - Application (service) coordinates use cases, depends on domain contracts and
EventEmitter2. Injects the repository via the domain token (@Inject(ASSOCIATION_ADDONS_REPOSITORY)). - Infrastructure (repository) implements the domain port via Kysely queries and DB mixins.
- Interfaces (controller + DTOs) handles HTTP concerns and delegates to the service. Uses
@UseGuards(RolesGuard)and@PermissionResource(PolicyResource.AssociationAddon)at class level for RBAC.
Domain Concepts
| Concept | Description |
|---|---|
associationAddon | Join record linking a business (+ optional location) to an addon |
businessId | FK to business — required, enforces multi-tenancy |
locationId | FK to location — optional; null means a business-level association |
addonId | FK to addon — the installed addon |
subscriptionId | FK to subscription — the subscription that granted the addon |
isActive | Soft-enables or disables the association |
settings | JSON blob for addon-specific configuration per business/location |
justBusinessAddons | Filter flag: when true, returns only records where locationId IS NULL |
Business-level vs. location-level
An addon can be installed at either the business level (locationId = null) or at a specific location. The justBusinessAddons query parameter isolates business-level installations. This enables scenarios where a business-wide addon coexists with location-specific overrides.
Main Use Cases
| Use Case | Method | Endpoint |
|---|---|---|
| Create association | POST | /association-addons |
| List all associations | GET | /association-addons |
| Get association by addon UUID | GET | /association-addons/addon/:addonId |
| Get association by record UUID | GET | /association-addons/:id |
| Update association | PATCH | /association-addons/:id |
| Delete association | DELETE | /association-addons/:id |
API Endpoints
All endpoints require a valid Firebase ID token (Authorization: Bearer <token> or flowpos-id-token cookie) and the AssociationAddon RBAC permission.
POST /association-addons
Creates a new association between a business/location and an addon.
Required fields: businessId, addonId, subscriptionId, isActive, createdBy
Optional fields: locationId, settings
{
"businessId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"locationId": "3fa85f64-5717-4562-b3fc-2c963f66afa7",
"addonId": "3fa85f64-5717-4562-b3fc-2c963f66afa8",
"subscriptionId": "3fa85f64-5717-4562-b3fc-2c963f66afa9",
"isActive": true,
"createdBy": "3fa85f64-5717-4562-b3fc-2c963f66afaa",
"settings": {}
}
Returns 201 Created with the raw associationAddon record (no relations).
GET /association-addons
Returns a paginated list of association-addon records with full relations (addon, subscription, business, location).
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
businessId | UUID | No | Filter by business |
locationId | UUID | No | Filter by location |
justBusinessAddons | boolean | No | When true, returns only records where locationId IS NULL |
page | number | No | Page number (default: 1) |
size | number | No | Page size (0 = no limit) |
search | string | No | Full-text search on addon name, description, uniqueName |
Note:
orderByis accepted but currently has no effect —sortableAssociationAddonKeysis empty.
GET /association-addons/addon/:addonId
Looks up the association record for a specific addon within a business. Optionally scope to a location.
Path parameters: addonId (UUID)
Query parameters: businessId (required), locationId (optional)
Returns the association record with full relations, or undefined if not found.
Route ordering note: This route (
/addon/:addonId) is registered before/:idin the controller to prevent NestJS from misrouting requests to the wrong handler.
GET /association-addons/:id
Returns a single association-addon record by UUID.
Path parameters: id (UUID)
Query parameters: businessId (required — multi-tenancy scope)
Returns 404 Not Found if the record does not exist within the given business.
PATCH /association-addons/:id
Updates an existing association-addon record. All fields except businessId and updatedBy are optional.
Required fields in body: businessId, updatedBy
{
"businessId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"isActive": false,
"updatedBy": "3fa85f64-5717-4562-b3fc-2c963f66afaa"
}
DELETE /association-addons/:id
Deletes an association-addon record. Returns 204 No Content on success.
After deletion, an OnDeleteAssociationAddonEvent (association-addon.delete) is emitted with the full deleted record. Other modules (e.g., addons) listen for this event to perform cleanup.
Query parameters: businessId (required — multi-tenancy scope)
Events
OnDeleteAssociationAddonEvent
- Event name:
association-addon.delete - Payload:
AssociationAddonWithRelations(the deleted record with all relations) - Emitted by:
AssociationAddonsService.deleteAssociationAddon - Purpose: Allows downstream modules to react to addon uninstalls (e.g., deactivating addon-specific configuration)
Design Decisions
Injection token for repository port
The domain layer exports a ASSOCIATION_ADDONS_REPOSITORY Symbol token alongside the IAssociationAddonsRepository interface. The module wires the concrete AssociationAddonsRepository via provide/useClass, and the service injects the interface via @Inject(ASSOCIATION_ADDONS_REPOSITORY). This keeps the application layer decoupled from the infrastructure implementation.
sortableAssociationAddonKeys lives in the domain layer
The sort-key tuple is defined in domain/association-addons-repository.domain.ts and re-exported from the query class. This keeps the domain layer authoritative over the repository contract without importing from the interfaces layer. When a sortable column is added, update the tuple in the domain file.
Business-level vs. location-level distinction
Omitting locationId at creation time creates a business-wide association. The justBusinessAddons filter is provided for callers that need to list only business-wide installations without polluting results with location-specific ones.
Event-driven uninstall
Deletion emits a domain event rather than calling downstream services directly. This keeps the module decoupled from consumers and allows multiple handlers to react independently.
Full relations on read, raw record on write
Read operations (findAll, findOne, findById) return AssociationAddonWithRelations (with joined addon, subscription, business, location). Create and update operations return the raw SelectableAssociationAddon to avoid unnecessary joins on write paths.