Attributes Module
Overview
The Attributes module manages product attribute definitions scoped to a business. An attribute represents a named property that can be attached to products for variant modeling — examples include Color, Size, and Warranty Period.
Attributes are catalog-level records. Each attribute can have multiple attribute values (handled by a separate module, e.g. attribute-values), and those values are combined to form product variants.
Architecture
This module follows Hexagonal Architecture with strict inward dependencies:
interfaces (controller, DTOs, query)
└── application (service / use cases)
└── domain (IAttributesRepository port, sortableAttributeKeys)
└── infrastructure (AttributesRepository — Kysely adapter)
Layer Responsibilities
| Layer | File | Responsibility |
|---|---|---|
| Domain | domain/attributes-repository.domain.ts | Repository interface (port) + sortableAttributeKeys constant |
| Application | application/attributes.service.ts | Orchestrates use cases, delegates to repository port |
| Infrastructure | infrastructure/attributes.repository.ts | Kysely queries, implements IAttributesRepository |
| Interface | interfaces/attributes.controller.ts | HTTP routing, request mapping, RBAC guards |
| Interface | interfaces/dtos/ | Request body validation (class-validator + Swagger) |
| Interface | interfaces/query/ | Pagination, sorting, and filter query params |
Dependency rule:
sortableAttributeKeysis defined in the domain layer and imported by both the query and repository layers. Never import interface-layer symbols into the domain.
Domain Concepts
- Attribute — a named, business-scoped property definition. Fields:
id,name,description,businessId,isActive,createdBy,updatedBy,createdAt,updatedAt. - Sortable keys — defined in the domain as
["name", "description"].
Access Control
- AuthGuard (global) — validates Firebase ID token on every request.
- RolesGuard — enforces role-based access via
@UseGuards(RolesGuard). - PermissionResource — maps to
PolicyResource.Attributefor CASL ability checks. - Multi-tenancy — all queries are scoped by
businessIdto prevent cross-tenant data access.
Use Cases
| Use Case | Method | Description |
|---|---|---|
| Create attribute | createAttribute | Inserts a new attribute for a business |
| List attributes | getAllAttributes | Returns paginated list with search + filter (scoped by businessId) |
| Get attribute | getAttributeById | Fetches a single attribute scoped by id + businessId; 404 if missing |
| Update attribute | updateAttribute | Partial update via PATCH, scoped by businessId |
| Delete attribute | deleteAttribute | Hard deletes the attribute, scoped by id + businessId |
API Endpoints
Base path: /attributes
POST /attributes
Creates a new attribute.
Request body:
{
"name": "Warranty Period",
"description": "Duration of the product warranty in months.",
"businessId": "550e8400-e29b-41d4-a716-446655440001",
"isActive": true,
"createdBy": "550e8400-e29b-41d4-a716-446655440000"
}
Responses: 201 Created | 400 Validation error | 401 Unauthorized | 403 Forbidden
GET /attributes
Returns a paginated list of attributes for a business.
Query params:
| Param | Required | Description |
|---|---|---|
businessId | Yes | Business UUID to scope attributes |
search | No | Case-insensitive search on name and description |
page | No | Page number (default: 1) |
size | No | Page size (default: 10; 0 = no limit) |
orderBy | No | name or description |
order | No | asc or desc |
Responses: 200 OK | 401 Unauthorized | 403 Forbidden
GET /attributes/:id
Returns a single attribute by UUID, scoped by business.
Query params:
| Param | Required | Description |
|---|---|---|
businessId | Yes | Business UUID to scope the lookup |
Responses: 200 OK | 401 Unauthorized | 403 Forbidden | 404 Not Found
PATCH /attributes/:id
Partially updates an attribute. Only provided fields are changed.
Request body (all optional except updatedBy and businessId):
{
"description": "Duration of the product warranty in months.",
"businessId": "550e8400-e29b-41d4-a716-446655440001",
"updatedBy": "550e8400-e29b-41d4-a716-446655440000"
}
Responses: 200 OK | 400 Validation error | 401 Unauthorized | 403 Forbidden | 404 Not Found
DELETE /attributes/:id
Hard deletes an attribute by UUID, scoped by business.
Query params:
| Param | Required | Description |
|---|---|---|
businessId | Yes | Business UUID to scope the deletion |
Responses: 200 OK | 401 Unauthorized | 403 Forbidden | 404 Not Found
Bruno API Collection
Requests are located in:
api-client/flowpos/collections/attributes/
├── attributes.yml # GET /attributes (list)
├── attribute.yml # POST /attributes (create)
├── attribute by Id.yml # GET /attributes/:id
├── attribute_1.yml # PATCH /attributes/:id (update)
└── attribute_2.yml # DELETE /attributes/:id
All requests use {{BASE_URL}}, {{ID_TOKEN}}, {{businessId}}, {{userId}}, and {{attributeId}} environment variables.
Design Decisions
descriptionis optional — allows lightweight attribute definitions where a description is not always meaningful.sortableAttributeKeyslives in the domain — prevents the domain layer from importing interface-layer symbols. Both the query class and the repository import it from the domain.- Hard delete — attributes are deleted with
DELETE. If soft-delete is needed in the future, add anisDeletedcolumn and filter it infindAll. NOT_FOUND(404) on missing attribute —getAttributeByIdraises 404, not 400, to correctly signal a missing resource.businessIdrequired on all operations — enforces multi-tenancy at the API level. GET by ID, PATCH, and DELETE all requirebusinessIdto prevent cross-tenant access.- Update DTO uses
OmitType+PartialType— inherits create fields as optional viaPartialType, excludesbusinessIdfrom inheritance and re-declares it as required, matching the categories module pattern. - RolesGuard + PermissionResource — RBAC is enforced at the controller level using
PolicyResource.Attribute, consistent with other catalog modules.