# LearningCertificates Module Two responsibilities: 1. **Certificate template management**: creation, editing, file asset management (backgrounds, signatures, logos), and status lifecycle. 2. **Certificate generation**: turning a template + per-user data into a stored PDF, invoked by `courses`, `learningPaths`, and `learningSessions` when a user finishes a training. When no template is selected (legacy), the caller's existing default HTML is rendered instead via a fallback callback. ## Directory Structure ``` learningCertificates/ ├── business/ │ ├── constants/ # 4 enums + list sorting helper + COLOR_PALETTE + instance allow-list │ ├── helpers/ # placeholderSubstitution (5 supported placeholders, HTML-escaped) │ ├── interfaces/ # certificate params, locals, list preview │ ├── models/ # LearningCertificate, ForCreation, AssignmentCounts type │ ├── ports/ # 6 abstract ports (repository, file assets, assignment counts, rendering, storage, validation) │ ├── services/ # 2 service classes (see below) │ ├── templates/ # classic / modern / customBackground Mustache templates │ └── types/ # 2 type files (scopes, response type) ├── infrastructure/ │ ├── adapters/ # 6 concrete port implementations │ ├── dataAccessObjects/ # 1 Sequelize DAO + barrel │ └── mappers/ # 3 pure mapper functions (DAO ↔ Domain) ├── presentation/ │ ├── controllers/learningCertificatesController.ts # HTTP endpoints │ ├── routers/ # 1 router function (Backoffice) │ ├── dataTransferObjects/ # 3 response DTOs (detail, list-item, search-for-assignment) │ ├── serializationClasses/ # 3 serialization classes (detail, list-item, search-for-assignment) │ └── validationClasses/ # 6 validation classes └── learningCertificatesPortsDI.ts # Port → Adapter DI registration ``` ## Service Map — Which Service Owns Each Feature | Feature Area | Service | |---|---| | Certificate CRUD (create, patch) | `LearningCertificatesService` | | Certificate lookup by ID | `LearningCertificatesService` | | File asset ownership validation | `LearningCertificatesService` | | File asset sync (pictures, signatures, logos) | `LearningCertificatesService` | | Certificate PDF generation (custom + default fallback) | `LearningCertificateGenerationService` | | Certificate status patch (with usage guard) | `LearningCertificatesService` | | Certificate soft delete (with usage guard) | `LearningCertificatesService` | | Search-for-assignment listing | `LearningCertificatesService` | | Cross-module certificate validation (consumed by courses, paths, sessions) | `LearningCertificatesValidationPort` | --- ## Services — Detailed Reference ### `LearningCertificatesService` Core certificate lifecycle management. Owns creation, patching, lookup, and all file asset synchronization logic. Injects `LearningCertificatesRepositoryPort` and `LearningCertificatesFileAssetsPort`. **Certificate CRUD** - `createCertificate()` — Creates a certificate. Defaults `status` to `ACTIVE`; accepts `INACTIVE` via params. Validates `pictureId` is required for `CUSTOM_BACKGROUND` type; validates user owns the file asset; links picture via `useAssetsForResource`; returns certificate with signed picture URL - `patchCertificate()` — Partial update of certificate fields; syncs picture, signatures, and logo file assets (add/remove diffs); validates user owns any new file assets. If transitioning from `ACTIVE` to `INACTIVE`, asserts the certificate is not in use (via `LearningCertificatesAssignmentCountsPort.isCertificateInUse`) and throws `RequestConflictError` (`LEARNING_CERTIFICATE_IN_USE`) otherwise. - `patchCertificateStatus(certificateId, instanceId, status)` — Status-only update used by `PATCH /:id/status`. Same in-use guard as `patchCertificate`; idempotent when status unchanged. - `deleteCertificate(certificateId, instanceId)` — Used by `DELETE /:id`. Throws `RequestConflictError` if any course/path/session references the certificate. Otherwise cleans up file-asset usages and soft-deletes the row (paranoid `destroy()` sets `deletedAt`). - `searchCertificatesForAssignment(instanceId, params)` — Used by `GET /search-for-assignment`. Returns `{ items, count, defaultCertificateAvailable }`. Filters by `status = ACTIVE`, optional `q` (unaccent ILIKE on `name`), name-asc order. `defaultCertificateAvailable` flag is computed from `INSTANCES_WITH_DEFAULT_CERTIFICATE` (hardcoded allow-list constant). - `getCertificateById()` — Fetches certificate by ID scoped to instance; throws `NotFoundError` if not found - `getCertificatesList(instanceId, params)` — Paginated list with name filter (`q`, unaccent ILIKE) and sort by name/createdAt; hydrates assignment counts in batch (counts are only included in the list response) - `getCertificateByIdWithDetails(id, instanceId)` — Same as `getCertificateById` but enriches with signed picture/signature/logo URLs (used by `GET /:id`; does **not** return assignment counts) **File Asset Sync (private)** - `validatePatchFileAssets()` — Collects all new file asset IDs (not already on the certificate) and validates user ownership - `syncPictureFileAsset()` — Swaps old/new picture file asset usage records - `syncSignaturesFileAssets()` — Syncs signature file assets with max 3 limit; throws `BadRequestError` if exceeded - `syncLogoFileAssets()` — Syncs logo file assets with max 3 limit; throws `BadRequestError` if exceeded - `syncFileAssetIds()` — Generic diff-based file asset sync: removes old IDs, adds new IDs - `getSignatureFileAssetIds()` — Extracts `fileAssetId` from a `CertificateSignatureData[]` ### `LearningCertificateGenerationService` Renders a certificate to PDF and stores it in S3 on behalf of `courses`, `learningPaths`, and `learningSessions`. Injects `LearningCertificatesRepositoryPort`, `LearningCertificatesFileAssetsPort`, `LearningCertificateRenderingPort`, and `LearningCertificateStoragePort`. **Public surface** - `generate(input)` — Single entry point. `input` shape: - `instanceId: number` - `learningCertificateId: number | null` — `null` triggers the legacy default-HTML fallback - `storage: { fileName, folder }` — caller-owned S3 path; folder must match the caller's existing convention (`courses/cert`, `learningPaths/cert`, `learningSessions/cert`) so existing URLs keep resolving - `placeholders: { title, duration, startDate, endDate, fullname }` — pre-formatted strings (no locale logic in this service); use `'-'` when a value does not apply - `defaultHtmlBuilder: () => Promise` — invoked only when `learningCertificateId === null` Returns `{ certKey, certUrl }`. The caller persists `certUrl` on its own user-cert table. **Branching algorithm** - `learningCertificateId === null` → call `defaultHtmlBuilder()`, never touch the repository. - `learningCertificateId !== null` → load the cert via `findById(id, instanceId)`; if missing throw `NotFoundError(NOT_FOUND)`; collect the union of file asset IDs (`pictureId` + `logoFileAssetIds` + signatures' `fileAssetId`s) and bulk-fetch via `fetchEmbeddable(ids, instanceId)` (single round trip); render via the per-`type` Mustache template. **Template selection** - `CLASSIC` → `business/templates/classic.mustache` (decorative double-line frame, italic serif title, color accents). - `MODERN` → `business/templates/modern.mustache` (vertical accent bar, sans-serif italic title). - `CUSTOM_BACKGROUND` → `business/templates/customBackground.mustache` (full-bleed user-uploaded image; **no color theme** — `cert.color` is ignored for this type). **Placeholder substitution** - Substitution lives in `business/helpers/placeholderSubstitution.ts`. - Supported keys: `title`, `duration`, `startDate`, `endDate`, `fullname`. Unknown keys are left literal (defense in depth). - Values are HTML-escaped before injection; templates use triple-brace `{{{subtitle}}}` so the escaped fragment is not double-escaped. **File asset embedding** - The adapter (`LearningCertificatesFileAssetsAdapter.fetchEmbeddable`) reads each file asset's S3 binary via `StorageService.downloadByKey` and converts to `data:${mimeType};base64,<...>`. - If a file asset is missing in storage or doesn't belong to the instance, the adapter logs a warning and omits it from the returned `Map`. The renderer treats omitted assets gracefully (e.g., a signature whose image is missing is dropped, generation still succeeds). - Total embed budget: up to 7 images per cert (1 background + 3 logos + 3 signatures). If HTML payload size becomes a problem in production, downsize images before base64 (open work, not implemented yet). **Color palette** - `business/constants/colorPalette.ts` maps every `CERTIFICATE_COLOR` enum value to its canonical hex from Figma (`Aprendizajes — Certificados por color`). Note: `DARK_GRAY` is mapped to `#111927` ("Negro" in Figma) since the Figma palette only ships one dark-gray-equivalent color. --- ## Ports ### `LearningCertificatesRepositoryPort` (extends `BaseRepositoryPort`) - `createCertificate(cert)` — Persists a new certificate; returns the saved domain model - `findById(id, instanceId)` — Finds a certificate by ID scoped to instance; returns `null` if not found - `saveCertificate(cert)` — Updates an existing certificate; returns the saved domain model - `findCertificates(params, scopes)` — Paginated find using DAO scopes; returns `{ items, count }` - `deleteCertificate(id, instanceId)` — Soft-deletes a certificate scoped to instance (paranoid mode) ### `LearningCertificatesValidationPort` (extends `BasePort`) — public, consumed by courses/paths/sessions - `assertActiveCertificateBelongsToInstance(certificateId, instanceId)` — Throws `BadRequestError` if the certificate does not exist, is not in the instance, or is not `ACTIVE`. No return value. ### `LearningCertificatesFileAssetsPort` (extends `BasePort`) - `isUserCreatorOfFileAssets(ids, userId)` — Validates user owns the file assets - `useAssetsForResource(certificateId, fileAssetIds)` — Records file asset usage for a certificate (resource type: `LEARNING_CERTIFICATE`) - `deleteAssetUsagesOfResource(certificateId, fileAssetIds?)` — Removes file asset usage records for a certificate - `getSignedFileAssets(ids, instanceId)` — Returns file assets with signed URLs - `fetchEmbeddable(ids, instanceId)` — Bulk-downloads each file asset's binary from S3 and returns `Map` of base64 data URIs for embedding into the cert HTML. Missing assets are logged and omitted, never thrown. ### `LearningCertificatesAssignmentCountsPort` (extends `BasePort`) - `getCountsForCertificateIds(ids, instanceId)` — Returns `Map` via three parallel `GROUP BY` queries against `Courses`, `LearningPaths`, `LearningSessions` - `isCertificateInUse(certificateId, instanceId)` — Returns `true` if any course/path/session references this certificate. Used by the status-change and delete guards. ### `LearningCertificateRenderingPort` (extends `BasePort`) - `htmlToPdf(html)` — Thin wrapper around the shared `PdfBuilderService` (Puppeteer cloud function). Returns a PDF `Buffer`. ### `LearningCertificateStoragePort` (extends `BasePort`) - `uploadCertBuffer(fileName, buffer, folder)` — Uploads the PDF buffer under `${folder}/${fileNameWithTimestamp}` and returns the resulting key. The caller passes its own folder so legacy URLs (e.g. `courses/cert`) keep resolving. - `getUrlOfKey(key)` — Builds the public storage URL for a given key. --- ## Domain Model ### `LearningCertificateForCreation` | Field | Type | Notes | |---|---|---| | `name` | `string` | Display name for identifying the certificate | | `title` | `string` | Certificate title displayed on the certificate (max 512) | | `subtitle` | `string \| null` | Optional, may contain keyword placeholders | | `description` | `string \| null` | Optional, may contain keyword placeholders | | `type` | `CERTIFICATE_TYPE` | `CLASSIC`, `MODERN`, or `CUSTOM_BACKGROUND` | | `pictureId` | `number \| null` | Background image file asset ID (required for `CUSTOM_BACKGROUND`) | | `color` | `CERTIFICATE_COLOR \| null` | Color theme from predefined palette (15 colors) | | `alignment` | `CERTIFICATE_ALIGNMENT` | `LEFT`, `RIGHT`, or `CENTER` (default: `CENTER`) | | `signatures` | `CertificateSignatureData[] \| null` | Max 3; each has `fileAssetId`, `signerName?`, `signerTitle?` | | `logoFileAssetIds` | `number[] \| null` | Max 3 logo file asset IDs | | `status` | `CERTIFICATE_STATUS` | `ACTIVE` or `INACTIVE` | | `instanceId` | `number` | Owning instance | | `userId` | `number` | Creating user | ### `LearningCertificate` (extends `LearningCertificateForCreation`) Adds: `id`, `createdAt`, `updatedAt`, `deletedAt`, `picture` (signed `FileAsset`), `logoFileAssets`, `signaturesFileAssets`. --- ## Endpoints All endpoints are Backoffice-only and require `MANAGE_LEARNING_CERTIFICATES` permission (validated via `PermissionsService`). | Method | Path | Controller Method | Description | |---|---|---|---| | `GET` | `/` | `list` | Lists certificates for the instance with pagination, name filter (`q`), and sort by name or createdAt; each item includes assignment counts | | `GET` | `/search-for-assignment` | `searchForAssignment` | Lightweight search returning ACTIVE-only certificates for selection in course/path/session forms. Paginated, name `q` filter, name-asc order. Includes `defaultCertificateAvailable` boolean computed from a hardcoded instance allow-list | | `GET` | `/:id` | `getById` | Returns a single certificate enriched with signed file-asset URLs (no assignment counts) | | `POST` | `/` | `create` | Creates a new certificate. Defaults `status` to `ACTIVE`; `INACTIVE` may be passed in the body | | `PATCH` | `/:id` | `patch` | Partially updates an existing certificate. Setting `status: INACTIVE` is rejected if the certificate is in use | | `PATCH` | `/:id/status` | `patchStatus` | Status-only update (body: `{ status }`). Same in-use guard for `ACTIVE → INACTIVE` | | `DELETE` | `/:id` | `delete` | Soft-deletes the certificate (paranoid `destroy()`). Rejects when the certificate is in use | Both endpoints wrap mutations in `getSequelize().transaction()`. --- ## Key Patterns ### Adding a New Feature 1. **Add the port method** in `business/ports/` if new infrastructure access is needed. 2. **Implement the adapter** in `infrastructure/adapters/`. 3. **Add the business method** to `LearningCertificatesService`. 4. **Add the endpoint** in `presentation/controllers/learningCertificatesController.ts`. 5. **Register the route** in `presentation/routers/learningCertificatesRouters.ts`. ### Transaction Pattern Mutations use `getSequelize().transaction()` directly in the controller: ```typescript const serialized = await getSequelize().transaction(async () => { const certificate = await this.learningCertificatesService.someMethod(...); return new LearningCertificateSC().serialize(certificate); }); res.status(OK).json(serialized); ``` ### File Asset Management All file assets (pictures, signatures, logos) are managed through `LearningCertificatesFileAssetsPort`: - **On create**: link new assets via `useAssetsForResource` - **On patch**: diff old vs new IDs, remove orphaned usages via `deleteAssetUsagesOfResource`, link new ones via `useAssetsForResource` - **Ownership validation**: before linking any new file asset, verify user ownership via `isUserCreatorOfFileAssets` - **Signed URLs**: after save, enrich the response with signed URLs via `getSignedFileAssets` ### Permission Guard All endpoints validate `MANAGE_LEARNING_CERTIFICATES` permission via `PermissionsService.validateManageLearningCertificates(user)` before any business logic. ### Cross-Module Validation Pattern `LearningCertificatesValidationPort` lives in this module (`business/ports/`) and is registered in `learningCertificatesPortsDI.ts`. External modules (`courses`, `learningPaths`, `learningSessions`) inject this port via `@PortsDI(...)` and call `assertActiveCertificateBelongsToInstance` before assigning a `learningCertificateId` on create or patch. The adapter (`infrastructure/adapters/learningCertificatesValidationAdapter.ts`) uses the DAO directly with the `ofInstance` scope — no service-layer roundtrip — to keep the cross-module call cheap. ### Default Certificate Allow-List `business/constants/instancesWithDefaultCertificate.ts` exports a hardcoded `INSTANCES_WITH_DEFAULT_CERTIFICATE: ReadonlyArray`. When this list contains the requesting instance ID, the search-for-assignment endpoint returns `defaultCertificateAvailable: true` so the UI can surface a "Default" option. The list starts empty and must be populated manually for legacy instances. No DB column, no runtime check on courses/paths/sessions — purely UX exposure. --- ## Keeping This Document Up to Date After every change to this module, check whether this file needs updating: - **New service method added** → add it to the relevant service section under Services — Detailed Reference. - **New service created** → add a row to the Service Map table and a new section under Services — Detailed Reference. - **Port or adapter added/removed** → update the Directory Structure counts and Ports section. - **New endpoint added** → update the Endpoints table. - **New pattern introduced** (new guard, new validation, new file asset type) → add it under Key Patterns. - **Existing method renamed or removed** → update or remove the corresponding entry. The document is wrong if it describes code that no longer exists, or omits code that does. Keep it exact.