Skip to main content

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 IAssociationAddonsRepository port, the ASSOCIATION_ADDONS_REPOSITORY injection 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​

ConceptDescription
associationAddonJoin record linking a business (+ optional location) to an addon
businessIdFK to business — required, enforces multi-tenancy
locationIdFK to location — optional; null means a business-level association
addonIdFK to addon — the installed addon
subscriptionIdFK to subscription — the subscription that granted the addon
isActiveSoft-enables or disables the association
settingsJSON blob for addon-specific configuration per business/location
justBusinessAddonsFilter 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 CaseMethodEndpoint
Create associationPOST/association-addons
List all associationsGET/association-addons
Get association by addon UUIDGET/association-addons/addon/:addonId
Get association by record UUIDGET/association-addons/:id
Update associationPATCH/association-addons/:id
Delete associationDELETE/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:

ParamTypeRequiredDescription
businessIdUUIDNoFilter by business
locationIdUUIDNoFilter by location
justBusinessAddonsbooleanNoWhen true, returns only records where locationId IS NULL
pagenumberNoPage number (default: 1)
sizenumberNoPage size (0 = no limit)
searchstringNoFull-text search on addon name, description, uniqueName

Note: orderBy is accepted but currently has no effect — sortableAssociationAddonKeys is 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 /:id in 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.