Saltar al contenido principal

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.