Saltar al contenido principal

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 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/

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.