Skip to main content

Service Attributes Module

Overview

The service-attributes module manages associations between services and attributes, storing a custom value for each pair. It acts as a junction table with payload — linking a service (e.g., "Shipping") to an attribute (e.g., "Speed") with a value (e.g., "Express").

All data is scoped to a businessId for multi-tenancy.


Domain Concepts

ConceptDescription
ServiceAttributeJunction record linking a service to an attribute with a custom value
serviceIdFK to the service table
attributeIdFK to the attribute table
valueThe specific value for this service-attribute combination
businessIdMulti-tenancy boundary
createdBy / updatedByAudit trail — user UUID who performed the operation

Architecture

The module follows strict Hexagonal Architecture:

domain/
service-attributes-repository.domain.ts ← IServiceAttributesRepository port + SERVICE_ATTRIBUTES_REPOSITORY token

application/
service-attributes.service.ts ← Orchestrates use cases (depends on domain port via @Inject)

infrastructure/
service-attributes.repository.ts ← Kysely adapter (implements the port)

interfaces/
service-attributes.controller.ts ← HTTP REST adapter
dtos/
create-service-attribute.dto.ts
update-service-attribute.dto.ts
query/
paginate-service-attributes.query.ts

Dependency flow

interfaces → application → domain ← infrastructure

Infrastructure depends on domain (implements its port); the application layer depends only on the domain interface. The repository is wired via a Symbol-based injection token (SERVICE_ATTRIBUTES_REPOSITORY) defined in the domain layer.

Known design debt: service-attributes-repository.domain.ts imports sortableServiceAttributeKeys from the interfaces layer. This is a consistent project-wide pattern (present in all modules) and is accepted as a pragmatic trade-off until a cross-module refactor is done.


Use Cases

Use CaseMethodHTTP
Create service attributecreateServiceAttributePOST /service-attributes
List service attributes (paginated)getAllServiceAttributesGET /service-attributes
Get service attribute by IDgetServiceAttributeByIdGET /service-attributes/:id
Update service attributeupdateServiceAttributePATCH /service-attributes/:id
Delete service attributedeleteServiceAttributeDELETE /service-attributes/:id

Authorization

  • Authentication: Bearer token (Firebase) — validated by global AuthGuard
  • Authorization: RolesGuard + CASL permissions
    • Class-level: @PermissionResource(PolicyResource.ServiceAttribute)
    • Method-level: @PermissionAction(PolicyAction.Create | Read | Update | Delete)
  • Swagger: @ApiBearerAuth() marks all endpoints as requiring authentication

API Endpoints

POST /service-attributes — Create Service Attribute

Permission: ServiceAttribute:Create

Request body:

{
"serviceId": "<service-uuid>",
"attributeId": "<attribute-uuid>",
"value": "Express",
"businessId": "<business-uuid>",
"isActive": true,
"createdBy": "<user-uuid>"
}

Response: 201 Created — full service attribute object.


GET /service-attributes — List Service Attributes

Permission: ServiceAttribute:Read

Query params:

ParamRequiredDescription
businessIdnoUUID — filter by business
serviceIdnoUUID — filter by service
pagenoPage number (default: 1)
sizenoPage size (default: 20, 0 = all)
searchnoFull-text search across value, service name, attribute name
orderBynoColumn to sort by (value)
ordernoasc or desc

Response: 200 OKIOffsetPagination<ServiceAttribute> with serviceName and attributeName joined.

{
"data": [
{
"id": "...",
"serviceId": "...",
"attributeId": "...",
"value": "Express",
"serviceName": "Shipping",
"attributeName": "Speed",
"isActive": true,
...
}
],
"total": 42,
"page": 1,
"size": 20,
"totalPages": 3
}

GET /service-attributes/:id — Get Service Attribute by ID

Permission: ServiceAttribute:Read

Response: 200 OK — service attribute object, or 404 Not Found.


PATCH /service-attributes/:id — Update Service Attribute

Permission: ServiceAttribute:Update

Request body:

{
"value": "Standard",
"updatedBy": "<user-uuid>"
}

All fields except updatedBy are optional. businessId and createdBy cannot be changed via update.

Response: 200 OK — updated service attribute object.


DELETE /service-attributes/:id — Delete Service Attribute

Permission: ServiceAttribute:Delete

Response: 200 OK (no body). Throws if record not found.


Design Decisions

Hard delete

DELETE performs a hard delete. There is no soft-delete toggle on this endpoint. The isActive field is managed through create/update operations.

Joined names in list response

The findAll query joins service.name and attribute.name to avoid N+1 lookups on the client. The SelectableServiceAttributeWithNames type is defined in the domain layer.

findAll uses a transaction

The count + data query pair runs inside a Kysely transaction for snapshot isolation. This is a consistent project pattern.

UpdateDTO excludes businessId and createdBy

The update DTO uses OmitType to prevent accidentally changing the business ownership or overwriting the audit createdBy field.


Bruno API Collection

Located at: api-client/flowpos/collections/service-attributes/

FileMethodRoute
create service attribute.ymlPOST/service-attributes
list service attributes.ymlGET/service-attributes
get service attribute by id.ymlGET/service-attributes/:id
update service attribute.ymlPATCH/service-attributes/:id
delete service attribute.ymlDELETE/service-attributes/:id

Environment variables used: BASE_URL, ID_TOKEN, businessId, serviceId, attributeId, userId, serviceAttributeId.