# Recognitions Module ## Purpose Manages **Products** and **ProductCategories** for the recognitions/acknowledgments system. Products are redeemable items employees can exchange for recognition points within time-limited exchange windows (managed externally by the `ExchangeService`). The module is multi-tenant: every record is scoped by `instanceId`. > **Legacy context**: This module was originally called "Acknowledgments" and is being refactored in stages (multiple `TODO: Refactor in Q2` comments exist). Do not rename or restructure without understanding the full migration plan. --- ## Architecture Hexagonal (Clean) Architecture with strict layer isolation: ``` presentation/ → HTTP layer (controllers, routers, validation, serialization, DTOs) business/ → Domain (models, services, ports, interfaces, constants) infrastructure/ → Data access (repository adapters, DAOs, mappers, external service adapters) recognitionsPortsDI.ts → DI binding: Ports → Adapters ``` **Dependency flow**: `presentation → business → (ports) → infrastructure` The `business/` layer never imports from `infrastructure/` or `presentation/`. --- ## Directory Structure ``` recognitions/ ├── business/ │ ├── constants/pagintations.ts # DEFAULT_LIMIT = 20 │ ├── interfaces/ # Input contracts for services │ │ ├── createProductInterface.ts │ │ ├── updateProductInterface.ts │ │ ├── partialUpdateProductInterface.ts │ │ ├── createProductCategoryInterface.ts │ │ ├── updateProductCategoryInterface.ts │ │ ├── listProductsOptionsInterface.ts │ │ ├── signableInterface.ts │ │ └── locals.ts │ ├── models/ │ │ ├── product.ts # Product + ProductForCreation │ │ └── productCategory.ts # ProductCategory + ProductCategoryForCreation │ ├── ports/ │ │ ├── productsRepositoryPort.ts │ │ ├── productCategoriesRepositoryPort.ts │ │ └── recognitionsAttachmentsPort.ts │ ├── services/ │ │ ├── productsService.ts │ │ └── productCategoriesService.ts │ └── types/recognitionsResponse.ts ├── infrastructure/ │ ├── adapters/ │ │ ├── recognitionsAttachmentsAdapter.ts │ │ └── repositories/ │ │ ├── productsRepositoryAdapter.ts │ │ └── productCategoriesRepositoryAdapter.ts │ ├── dataAccessObjects/ │ │ ├── productDAO.ts # Table: Products │ │ └── productCategoryDAO.ts # Table: ProductCategories │ └── mappers/ │ ├── productMapper.ts │ └── productCategoryMapper.ts ├── presentation/ │ ├── controllers/ │ │ ├── productsController.ts │ │ └── productCategoriesController.ts │ ├── routers/ │ │ ├── productsRouters.ts │ │ └── productCategoriesRouters.ts │ ├── validationClasses/ │ │ ├── createProduct.ts │ │ ├── updateProduct.ts │ │ ├── partialUpdateProduct.ts │ │ ├── backofficeListProductsVC.ts │ │ ├── createProductCategoryVC.ts │ │ └── updateProductCategoryVC.ts │ ├── serializationClasses/ │ │ ├── productSC.ts │ │ ├── productWithPeriodSC.ts │ │ └── productCategorySC.ts │ └── dataTransferObjects/ │ ├── productDTO.ts │ ├── productWithPeriodDTO.ts │ └── productCategoryDTO.ts └── recognitionsPortsDI.ts ``` --- ## Domain Models ### Product (`business/models/product.ts`) | Field | Type | Notes | |-------|------|-------| | `id` | `number` | PK | | `name` | `string` | Required, max 255 chars | | `cost` | `number` | Integer ≥ 1 (recognition points) | | `coverPicture` | `string \| null` | S3 URL (nullable since migration 20230524) | | `description` | `string` | Optional metadata; only settable via PATCH | | `categoryId` | `number` | FK to ProductCategories | | `instanceId` | `number` | Multi-tenant scope | | `createdAt/updatedAt/deletedAt` | `Date` | Paranoid soft delete | | `category` | `ProductCategory \| null` | Eager-loaded relationship | | `attachments` | `Attachment[]` | Loaded via `RecognitionsAttachmentsPort` | **Interfaces implemented by Product**: - `Attachable` — enables attachment system integration (`getAttachments`, `setAttachments`, `getId`, `getAttachableType`) - `SignableInterface` — enables S3 URL signing; `getSignableAttributes()` returns `['coverPicture']` ### ProductCategory (`business/models/productCategory.ts`) | Field | Type | Notes | |-------|------|-------| | `id` | `number` | PK | | `name` | `string` | Required, max 255 chars | | `instanceId` | `number` | Multi-tenant scope | | `products` | `Product[]` | Relationship — initialized as `[]` in mapper to avoid circular dependency | | `createdAt/updatedAt/deletedAt` | `Date` | Paranoid soft delete | --- ## Endpoints ### Products — App Router | Method | Path | Permission | Handler | Response | |--------|------|------------|---------|----------| | `GET` | `/recognitions/products` | `validateViewExchanges` | `appListProducts` | `PaginatedResponse` | | `GET` | `/recognitions/products/:id` | `validateViewExchanges` | `appGet` | `ProductDTO` (with signed S3 URLs) | | `GET` | `/recognitions/product-categories/:id/products` | `validateViewExchanges` | `listCategoryProducts` | `PaginatedResponse` | ### Products — Backoffice Router | Method | Path | Permission | Handler | Response | |--------|------|------------|---------|----------| | `GET` | `/recognitions/products` | `validateManageProducts` | `boListProducts` | `PaginatedResponse` | | `GET` | `/recognitions/products/:id` | `validateManageProducts` | `boGet` | `ProductDTO` | | `POST` | `/recognitions/products` | `validateCreateProduct` | `create` | `201 + ProductDTO` | | `PUT` | `/recognitions/products/:id` | `validateUpdateProduct` | `totalUpdate` | `ProductDTO` | | `PATCH` | `/recognitions/products/:id` | `validateUpdateProduct` | `partialUpdate` | `ProductDTO` | | `DELETE` | `/recognitions/products/:id` | `validateDeleteProduct` | `delete` | `204` | ### Product Categories — App Router | Method | Path | Permission | Handler | Response | |--------|------|------------|---------|----------| | `GET` | `/recognitions/product-categories` | none (TODO) | `list` | `PaginatedResponse` | ### Product Categories — Backoffice Router | Method | Path | Permission | Handler | Response | |--------|------|------------|---------|----------| | `GET` | `/recognitions/product-categories` | none | `list` | `PaginatedResponse` | | `GET` | `/recognitions/product-categories/:id` | none | `get` | `ProductCategoryDTO` | | `POST` | `/recognitions/product-categories` | `validateCreateProductCategory` | `create` | `201 + ProductCategoryDTO` | | `PUT` | `/recognitions/product-categories/:id` | `validateUpdateProductCategory` | `update` | `ProductCategoryDTO` | | `DELETE` | `/recognitions/product-categories/:id` | `validateDeleteProductCategory` | `delete` | `204` | > **Note**: GET endpoints for ProductCategories lack permission validation — this is a known TODO, not a pattern to replicate. --- ## Services ### ProductsService Key behaviors: - **Default category auto-creation**: When `categoryId` is omitted on create, the service calls `productCategoriesService.findDefaultByName(instanceId)`. If not found, it creates it using `ENV_OPTIONAL_STRING_KEYS_MAPPING.DEFAULT_PRODUCT_CATEGORY_NAME` from env. If that env var is missing, product creation will fail. - **URI decoding**: `coverPicture` is always passed through `decodeURI()` on create and update. Frontend sends URI-encoded S3 URLs. - **Attachment lifecycle**: On PUT/total update, old attachments are deleted and new ones created. On PATCH, attachments are only replaced if a non-empty array is provided. - **URL signing**: After any retrieval, `RecognitionsAttachmentsPort.signUrls()` is called to re-sign `coverPicture`, and `signAttachmentsUrls()` for attached files. ### ProductCategoriesService - `findDefaultByName(instanceId)`: case-insensitive lookup using `Op.iLike`. Returns `null` if not found (never throws). - `createDefaultCategory(instanceId)`: creates with injected default name. Only called from `ProductsService`. --- ## Data Access ### Sequelize DAOs **Products table** (`Products`): - Paranoid (soft deletes via `deletedAt`) - Scopes: `base` (includes category), `belongsToProductCategory(categoryId)`, `olderThan(ts)`, `youngerThan(ts)`, `defaultOrder` (cost ASC), `ofInstance(instanceId)`, `limit(n)`, `offset(n)` - Validations: `name` length 1–255, `cost` integer ≥ 1 **ProductCategories table** (`ProductCategories`): - Paranoid - Scopes: `ofInstance(instanceId)`, `sorted` (createdAt DESC) - Validations: `name` length 1–255 ### Pagination Two mechanisms coexist — check which the endpoint uses before adding query params: 1. **Limit/Offset** (`limit`, `offset`): standard page-based, used in backoffice 2. **Timestamp cursor** (`timestamp`, direction): cursor-based for mobile/infinite scroll, `olderThan`/`youngerThan` scopes Default limit: `20` (from `business/constants/pagintations.ts`). ### Circular Dependency Workaround `productCategoryMapper.ts` initializes `products: []` when mapping `ProductCategoryDAO → ProductCategory`. This is intentional — calling `productDAOToProduct` from inside the category mapper creates a circular import. Never remove this or try to "fix" it by importing the product mapper. --- ## Cross-Module Dependencies | Module | What is imported | |--------|-----------------| | `attachments` | `Attachable`, `Attachment`, `CreateAttachmentInterface`, `AttachmentsService`, `AttachmentVC`, `AttachmentSC`, `AttachmentDTO` | | `permissions` | `PermissionsService` (all permission validation methods) | | `instances` | `Instance` (in `RecognitionsLocals`) | | `users` | `UserDAO` (in `RecognitionsLocals`) | | `monolith-shared` (ExchangeService) | `ExchangeService` (exchange window dates for `ProductWithPeriodSC`) | | `@humand-packages/common` | Base classes, errors, validation helpers, `ErrorCodes` | These are declared as `implicitDependencies` in `project.json`. If you add a new cross-module import, regenerate the dependency graph. --- ## Serialization Notes - **`ProductDTO`** — flat product; includes `deleted: boolean` derived from `deletedAt !== null` - **`ProductWithPeriodDTO`** — wraps `ProductDTO` with `startDate`/`endDate` strings from `ExchangeService.getActiveWindow()`. Used in all list endpoints (both app and backoffice). Uses moment.js for ISO formatting. - **`ProductCategoryDTO`** — id, name, deleted boolean; no products array - All serializers use `class-transformer` `@Expose()` decorator pattern via `Serializer` base class --- ## Validation Classes | Class | Key fields | Used in | |-------|-----------|---------| | `CreateProductVC` | `name` (required), `cost` (int), `coverPicture?` (@BucketUrl), `categoryId?`, `attachments?` | `POST /products` | | `UpdateProductVC` | Same as create but all required | `PUT /products/:id` | | `PartialUpdateProductVC` | All optional, adds `description?` | `PATCH /products/:id` | | `BackofficeListProductsVC` | `minDate?`, `maxDate?` (ISO date strings) | `GET /products` (backoffice) | | `CreateProductCategoryVC` | `name` (required) | `POST /product-categories` | | `UpdateProductCategoryVC` | `name` (required) | `PUT /product-categories/:id` | `@BucketUrl()` is a custom decorator (from `mappers/decorators/bucketUrl`) that validates S3 URLs. Use it whenever validating S3/bucket URLs in this module. --- ## DI Registration ```typescript // recognitionsPortsDI.ts { [ProductCategoriesRepositoryPort.name]: ProductCategoriesRepositoryAdapter, [ProductsRepositoryPort.name]: ProductsRepositoryAdapter, [RecognitionsAttachmentsPort.name]: RecognitionsAttachmentsAdapter, } ``` This object is spread into `buildServerHandlers()` in `src/api/diHandlers/serverHandlers.ts`. When adding new ports, register them here. --- ## Registration Points (Monolith-Shared Zone) If you add new DAOs or routers, you must also register them in the shared zone: - **ORM Models**: `src/api/ormModels/all.ts` — export new DAOs - **Routes (App)**: `src/api/routes/root.ts` — `privateRouter.use('/recognitions/...')` - **Routes (Backoffice)**: `src/api/routes/backofficeRoot.ts` — `privateRouter.use('/recognitions/...')` > Changing files in `src/api/ormModels/`, `routes/`, or `diHandlers/` triggers ALL tests in CI, not just recognitions tests. --- ## Error Codes | Situation | Error | |-----------|-------| | Product not found | `ErrorCodes.PRODUCT_NOT_FOUND` | | ProductCategory not found | `ErrorCodes.PRODUCT_CATEGORY_NOT_FOUND` | Always use `ErrorCodes` — never throw raw strings. --- ## Testing ### Integration Tests Location: `test-integration/api/recognitions/recognitions.test.ts` Test command classes (reusable builders): `CreateProduct`, `CreateProductCategory`, `GetProductById`, `GetProductCategoryById`, `UpdateProduct`, `UpdateProductCategory`, `PartialUpdateProduct`, `ListProducts`, `ListProductCategories`, `DeleteProductById`, `DeleteProductCategoryById`. Pattern: ```typescript const community = await CreateCommunity.new(); // adds MANAGE_PRODUCTS capability const product = await new CreateProduct({ name: faker.commerce.productName(), cost: 100 }).executeOn(backofficeSession); expect(product.id).toBeDefined(); ``` ### Unit Tests None currently exist for this module. --- ## Key Constraints for Agents 1. **Multi-tenancy is mandatory**: Every query must be scoped by `instanceId`. Never query `Products` or `ProductCategories` without it. 2. **Don't break the attachment lifecycle**: URL signing happens after retrieval in the controller, not the service. If you add a new retrieval path, ensure `signUrls` / `signAttachmentsUrls` is called. 3. **`description` field is PATCH-only**: It was added later and is intentionally absent from create/PUT validation classes. 4. **Default category logic is fragile**: It depends on `ENV_OPTIONAL_STRING_KEYS_MAPPING.DEFAULT_PRODUCT_CATEGORY_NAME`. If adding new product creation paths, carry this logic forward or delegate to the service. 5. **Don't add events/queues without a plan**: The module currently has no async operations. Adding them would require changes to `EVENT_HANDLER` / `WORKER` server types and integration test coverage. 6. **Respect the circular dependency workaround**: `productCategoryMapper` initializes `products: []` intentionally. Don't "fix" it. 7. **Legacy acknowledgments coupling**: `ExchangeService` and `ProductWithPeriodDTO` are legacy integrations. Don't deepen this coupling — the module is moving away from it.