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
| Concept | Description |
|---|---|
ServiceAttribute | Junction record linking a service to an attribute with a custom value |
serviceId | FK to the service table |
attributeId | FK to the attribute table |
value | The specific value for this service-attribute combination |
businessId | Multi-tenancy boundary |
createdBy / updatedBy | Audit 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.tsimportssortableServiceAttributeKeysfrom 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 Case | Method | HTTP |
|---|---|---|
| Create service attribute | createServiceAttribute | POST /service-attributes |
| List service attributes (paginated) | getAllServiceAttributes | GET /service-attributes |
| Get service attribute by ID | getServiceAttributeById | GET /service-attributes/:id |
| Update service attribute | updateServiceAttribute | PATCH /service-attributes/:id |
| Delete service attribute | deleteServiceAttribute | DELETE /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)
- Class-level:
- 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:
| Param | Required | Description |
|---|---|---|
businessId | no | UUID — filter by business |
serviceId | no | UUID — filter by service |
page | no | Page number (default: 1) |
size | no | Page size (default: 20, 0 = all) |
search | no | Full-text search across value, service name, attribute name |
orderBy | no | Column to sort by (value) |
order | no | asc or desc |
Response: 200 OK — IOffsetPagination<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/
| File | Method | Route |
|---|---|---|
create service attribute.yml | POST | /service-attributes |
list service attributes.yml | GET | /service-attributes |
get service attribute by id.yml | GET | /service-attributes/:id |
update service attribute.yml | PATCH | /service-attributes/:id |
delete service attribute.yml | DELETE | /service-attributes/:id |
Environment variables used: BASE_URL, ID_TOKEN, businessId, serviceId, attributeId, userId, serviceAttributeId.