# Dynamic Forms Kernel (DF) ## Overview DF is a **generic form completion engine** — a kernel, not a domain module. It does not know what a form is *for*. Consuming modules (recognition flows, service requests, work-environment surveys, employee lifecycle, Bamboo integration, etc.) embed DF `tag`s into their own domain objects and delegate to DF for versioning, completion state, and reporting. **Key invariants:** - DF does **not** validate domain permissions. The consuming module is responsible for all permission checks before calling DF. - DF uses **signed query tokens** (`validateQueryToken`) for direct user-facing REST endpoints (POST progression, GET answer). The consuming module mints the token after its own permission check; DF trusts it. - Management operations (form CRUD, gRPC RPCs) have no per-user permission check inside DF. --- ## Directory Structure ``` kernels/dynamicForms/ ├── dynamicFormsPortsDI.ts # Port → Adapter binding map (loaded by diHandlers/serverHandlers.ts) ├── business/ │ ├── constants/ # dynamicFormReservedSections, dynamicFormSample, certificates │ ├── interfaces/ # DynamicFormInterface, DynamicFormSectionInterface, DynamicFormsInstanceInterface, etc. │ ├── mappers/ # certificateFormToPdfMapper │ ├── models/ # Domain models (see §Domain Model Glossary) │ ├── ports/ # Port interfaces (see §Ports) │ ├── services/ │ │ ├── dynamicFormsService.ts │ │ ├── dynamicFormsAnswerService.ts │ │ ├── dynamicFormsValidationService.ts │ │ ├── dynamicFormsReportService.ts │ │ ├── dynamicFormComponentTemplatesService.ts │ │ └── certificate/ │ │ ├── certificatesService.ts │ │ ├── certificatePdfBuilderService.ts │ │ └── certificatePdfRenderer.ts (+ helpers) │ ├── types/ # Discriminated-union types for actions, components, progress, schema, etc. │ └── util/ # dynamicFormsPathUtils ├── infrastructure/ │ ├── adapters/ # Port implementations (repository, file assets, profile fields, expression parser, component templates) │ ├── dataAccessObjects/ # Sequelize DAOs │ ├── mappers/ # ORM ↔ domain mappers │ └── types/ └── presentation/ ├── controllers/ # DynamicFormsController, DynamicFormComponentTemplatesController ├── routers/ # startDynamicFormsAppRouter, startDynamicFormsBackofficeRouter, startDynamicFormsDevopsRouter ├── validationClasses/ # VCs (request body / query validation) ├── serializationClasses/ # SCs (response shaping) └── dataTransferObjects/ # DTOs ``` --- ## Architectural Position & Integration Patterns ### In-process (monolith modules) Inject `DynamicFormsService`, `DynamicFormsAnswerService`, etc. directly via TypeDI. Examples: - `src/api/modules/formTemplates/infrastructure/adapters/formTemplateDynamicFormsAdapter.ts` - `src/api/modules/peopleExperience/…/workEnvironmentSurveyDynamicFormsAdapter.ts` ### External services (Bamboo, Cerberus, others) — gRPC Proto: [`src/proto/humand/dforms/v1/dynamic_forms_service.proto`](../../../../../../proto/humand/dforms/v1/dynamic_forms_service.proto) Implementation: [`src/api/grpc/implementations/dynamic_forms_service_implementation.ts`](../../../../grpc/implementations/dynamic_forms_service_implementation.ts) ### Direct from frontend — REST Routes are mounted in `src/api/routes/root.ts` (app), `backofficeRoot.ts`, and `devopsRoot.ts`. DI ports are registered in `diHandlers/serverHandlers.ts` via `dynamicFormsPortsHandlers` (from `dynamicFormsPortsDI.ts`). App endpoints that create progressions or fetch final answers sit **behind** `validateQueryToken(API_PREFIXES.APP)` — the consuming module must mint a signed token after its own auth check. --- ## Domain Model Glossary ### `DynamicForm` Versioned form definition. Keyed by `(tag, id)`. Multiple versions share the same `tag`; `id` distinguishes them. | Field | Notes | |---|---| | `tag` | Stable identifier across versions. Consuming modules store this. | | `id` | Version-specific numeric id. | | `instanceId` | Tenant identifier. | | `published` | Flips once — cannot be undone. Unpublished forms are mutable; published are read-only. | | `type` | `REGULAR \| PDF`. PDF forms require `fileAssetId` pointing to the base PDF template. | | `initialAction` | Action with event `FORM_START` — determines the first section. | | `variables` | JSON-schema definition (AJV-validated) for typed variables used across sections. | | `endpoint` | Default endpoint for dynamic sections (can be overridden per section). | | `sectionsCount` | Denormalized count, kept in sync on save. | ### `DynamicFormSection` A single step in a form. Keyed by `nameId` unique within a form version. | Field | Notes | |---|---| | `nameId` | Stable string ID referenced in actions and conditions. | | `nextNameId` | The next-in-position section; used to resolve the reserved `NEXT` action target. | | `position` | 1-based display order. | | `endpoint` | Numeric endpoint identifier (references a registered endpoint config). If set, the section is **dynamic** — content is resolved at runtime. `isSectionContentDynamic()` returns `true`. Saving partial answers is **forbidden** on dynamic sections. | | `content` | `{ title, description?, data?, components[], action }` | | `references` | Pre-computed map of variables/answers referenced by this section's conditions. Computed during `validateFormInterface` and persisted. | ### `DynamicFormComponent` A question inside a section. Discriminated union over `QUESTION_TYPES`. | Field | Notes | |---|---| | `nameId` | Stable string ID within the section. | | `content` | `{ title, description?, required?, visible?, disabled?, choices?, fieldId? }` | | `validations` | Array of validation rules. | | `storage` | Optional — names a form variable to write the validated answer into on `COMPLETE`. | | `action` | Optional — event `INPUT_COMPLETE`; triggers next-section resolution immediately on input. | | `attachmentReferences` | File assets embedded in the question definition. | | `pdfLayout` | **Required** when form `type = PDF`: `{ x, y, page, width, height, allowWrap }` | | `pdfStyles` | **Required** when form `type = PDF`: font, color, etc. | ### `DynamicFormAnswerProgression` Per-user, in-flight completion state. The model **does not enforce** uniqueness — multiple unfinished progressions are permitted for the same `(formTag, userId)`. The convention is **read-side**: `getOpenFormAnswerProgressionOfUser` and `UserHasOpenProgressionInDynamicForm` both return the most recent unfinished row (`ORDER BY createdAt DESC LIMIT 1`). The `resumeOpenProgression` flag in the create-progression request (REST body and gRPC field) defaults to `false` and is unused today; passing `true` reuses the latest open progression instead of creating another one. If true uniqueness is required, the consuming module must enforce it. | Field | Notes | |---|---| | `id` | String (ULID-style). | | `finished` | `true` once `currentSection = SUCCESS`. | | `formAnswerId` | Populated when `finished = true`. | | `progress` | JSONB: `{ firstSection, currentSection, sections: { [nameId]: SectionAnswerProgress }, variables }` | ### `DynamicFormAnswer` Finalized answer record, written on `SUCCESS`. | Field | Notes | |---|---| | `sectionsPath` | Ordered array of visited section `nameId`s. | | `lastProgressionId` | Id of the progression that produced this answer. | | `progressionIds` | All progression ids that contributed. | | `variables` | Final variable values from the progression. | | `pdfFileAssetId` | Filled for `PDF`-type forms on `SUCCESS`; also populated by `CertificatesService` cache. | > **Gotcha — `progressionIds` push branch is dead code today.** `buildFormAnswer` has a branch that, when an existing `DynamicFormAnswer` is found for the incoming progression, **updates that row** and pushes the new progression id onto `progressionIds`. The branch is intended for a future "edit answer" flow that reuses the same answer row. On the **only** path that calls `buildFormAnswer` today (`COMPLETE` → `SUCCESS` in `updateAnswerProgression`), `progression.formAnswerId` is set **after** `buildFormAnswer` returns — so `getFormAnswerOfAnswerProgression(progression)` always returns `null` on first SUCCESS, the existing-answer branch never runs, and `progressionIds` is initialized as a single-element array on creation. Any future edit-answer work must wire a path that sets `progression.formAnswerId` (or otherwise links progression to an existing answer) **before** calling `buildFormAnswer`. ### `DynamicFormComponentTemplate` Reusable component definition for the backoffice form builder. | Field | Notes | |---|---| | `status` | `DRAFT \| IN_USE`. Transitions to `IN_USE` when embedded in a published form; returns to `DRAFT` when released. | | `duplicationId` | Stable id for deduplication when duplicating templates. | ### Reserved section names (`dynamicFormReservedSections.ts`) | Name | Meaning | |---|---| | `NEXT` | Resolved to `section.nextNameId` — the next sequential section by position. | | `SUCCESS` | Signals form completion; triggers answer finalization. | --- ## Form Lifecycle - **`newForm(instanceId)`** — creates unpublished version 1 with a generated `tag` and sample content (`dynamicFormSample`). - **`newFormVersion(baseFormVersion)`** — creates a new version under the **same `tag`**. Requires `baseFormVersion.published = true` (else `BadRequestError: FORM_NOT_PUBLISHED`). Delegates to `duplicateForm`, which throws `RequestConflictError` (`FORM_UNPUBLISHED_VERSION_EXISTS`) if an unpublished version already exists for that tag. - **`duplicateForm(baseForm, overrideParams?)`** — clones a published form. Accepts a `tag` override (used for cross-instance duplication via `duplicateFormForInstance`). - **`updateForm`** — only allowed on unpublished forms (`BadRequestError: FORM_ALREADY_PUBLISHED` otherwise). Runs `DynamicFormsValidationService.validateFormInterface` before persisting. Reconciles file-asset usages via the `attachmentsChange.{addedAttachmentIds, removedAttachmentIds}` returned by validation. - **`publishForm(form, deleteOpenProgressions)`** — flips `published`. When `deleteOpenProgressions = true`, wipes all open progressions of that tag so users restart on the new version. - **`deleteForm(form)`** — only valid on unpublished versions (exposed at the HTTP layer as `DELETE /:tag/versions/:id`). - **`deleteFormsOfTag(tag, instanceId)`** — deletes all versions + open progressions of that tag. Finished progressions and `DynamicFormAnswer` records survive. --- ## Answer Progression Engine ### Creation - REST: `POST /:tag/progressions` (requires query token) - gRPC: `CreateDynamicFormAnswerProgression` - Internal: `DynamicFormsAnswerService.createAnswerProgressionInternal` Only works on **published** forms — throws `IllegalStateError` otherwise. `resumeOpenProgression: true` (in the request body) reuses the existing open progression instead of creating a new one. `previousProgressionId` is defined in the interface but **not yet implemented** — throws `NotImplementedError`. ### Section events (`DynamicFormSectionEvent`) All updates go through `DynamicFormsAnswerService.updateAnswerProgression`. | Event | Behavior | |---|---| | `COMPLETE` | Validates user answers (`processUserAnswers` → `questionTypesPort.validateAndCastUserAnswer`). Writes `storage` answers into `progress.variables`. Resolves the next section via the section's action (`buildActionForExecution` → `getNextSectionNameId`). If next section is `SUCCESS`: builds the `DynamicFormAnswer`, prunes unvisited section file-asset usages, generates the PDF for `PDF`-type forms, marks progression `finished`. Throws `MultipleBadRequestError` on validation failures. | | `SAVE` | Stores partial answers in the progression's current section. **Forbidden on dynamic sections** (`section.isSectionContentDynamic()` → `BadRequestError`). | | `BACK` | Returns to the previous section. For dynamic sections, current-section answers are cleared; for static, they are preserved after validation. Restores variable values from the target section's `variables` snapshot. Throws if there is no previous section. | | `RELOAD` | Not implemented — throws `NotImplementedError`. | ### Next-section resolution Two related-but-distinct enums to keep straight: - **`DynamicFormSectionEvent`** — the **user-facing** event sent in `PATCH /:tag/progressions/:progressionId`: `COMPLETE | SAVE | BACK | RELOAD`. This is what the section-events table above handles. - **`DynamicFormActionEvents`** — the **`event` field on action definitions**, indicating when in the lifecycle the action fires: `FORM_START | INPUT_COMPLETE | SECTION_COMPLETE | SECTION_RELOAD`. This is what `DynamicForm.initialAction.event` and `DynamicFormComponent.action.event` refer to. When the user sends `DynamicFormSectionEvent.COMPLETE`, the engine evaluates the section's action (which has `event = SECTION_COMPLETE`). `buildActionForExecution` → `getNextSectionNameId`: - **`STATIC`** action: `nextSection` literal, or `NEXT` resolved to `section.nextNameId`. - **`CONDITIONAL`** action: `DynamicFormsExpressionParserPort.evaluateBooleanConditions(conditions, { answers, variables })` picks the first matching branch; falls back to `default`. Only allowed on actions whose `event = SECTION_COMPLETE` (enforced in `DynamicFormsValidationService`). - **`ENDPOINT`** action: not fully implemented end-to-end. ### Variables and storage A component's `storage: string` names a variable defined on `DynamicForm.variables`. On `COMPLETE`, the validated answer is written into `progress.variables[].value`. `DynamicFormsValidationService` enforces type compatibility between the question type's answer schema and the variable's JSON schema. Variables are **snapshotted** per section in `SectionAnswerProgress.variables` so that `BACK` can restore them. ### References `DynamicFormElementReferences` (stored in `DynamicFormSection.references`) is a pre-computed map of `answers.{componentNameId}` and `variables.{name}` that appear in conditional expressions for that section. Computed once during `validateFormInterface` and stored on each section row. ### File-asset usage tracking - **Form-level** (attachments in the form definition): tracked via `DynamicFormsFileAssetsPort.useFileAssetsForDynamicForm` / `deleteFileAssetUsagesForDynamicForm` on form save and on update diff. - **Section-level** (answer attachments and signatures): tracked per `(progression, sectionNameId)` via `useFileAssetsForAnswerProgressionSectionAnswer` and reconciled on every `COMPLETE` / `SAVE` / `BACK`. Unvisited section usages are pruned when the progression reaches `SUCCESS`. ### High-level flow ```mermaid flowchart LR Create["createAnswerProgression()"] --> FirstSection["Section S\n(currentSection)"] FirstSection -->|"COMPLETE"| Branch{"Next?"} FirstSection -->|"SAVE (static only)"| FirstSection FirstSection -->|"BACK"| Prev["Previous section"] Branch -->|"literal or NEXT"| NextSection["Section N"] Branch -->|"SUCCESS"| Finalize["buildFormAnswer\n+ PDF render (PDF type)"] NextSection -->|"COMPLETE"| Branch Finalize --> Done["finished=true\nformAnswerId set"] ``` --- ## Form Types: REGULAR vs PDF | | `REGULAR` | `PDF` | |---|---|---| | Components require `pdfLayout`/`pdfStyles` | No | Yes — validation enforced by `DynamicFormsValidationService` | | On `SUCCESS` | Answer persisted, no PDF generated | Answer persisted + `generateAndPersistPdfUrlForAnswer` merges answers onto `DynamicForm.fileAssetId` template, uploads result, writes new file asset id into `DynamicFormAnswer.pdfFileAssetId` | | Certificate on demand | `CertificatesService` builds a structured PDF via `CertificatePdfBuilderService` | `CertificatesService` returns the already-stored rendered PDF directly | | Choice answers in PDF | n/a | Checked/unchecked icons rendered per option line (`asset://checkbox-checked.png`, `asset://checkbox-unchecked.png`, radio variants) | --- ## Service Map | Capability | Service | |---|---| | Form creation / versioning / publishing / deletion | `DynamicFormsService` | | Progression creation, update, retrieval; answer finalization | `DynamicFormsAnswerService` | | Form schema validation (structure, logic, storage, references, PDF layout) | `DynamicFormsValidationService` | | Report column hydration from finalized answers | `DynamicFormsReportService` | | On-demand PDF certificate retrieval / generation + caching | `CertificatesService` | | PDF layout assembly from form sections | `CertificatePdfBuilderService` | | Reusable component template CRUD | `DynamicFormComponentTemplatesService` | --- ## Services — Reference ### `DynamicFormsService` | Method | Description | |---|---| | `newForm(instanceId)` | Creates the first unpublished version with a generated `tag` and sample content. | | `newFormVersion(baseFormVersion)` | Creates a new version under the same `tag`. Base must be published. | | `duplicateForm(baseForm, overrideParams?)` | Clones a form, optionally under a new `tag`. | | `duplicateFormForInstance(instanceId, dynamicForm)` | Clones a form into another instance with a fresh `tag` (cross-instance duplication). | | `updateForm(dynamicForm, params, userId, instance)` | Validates and persists changes. Unpublished only. Reconciles file-asset usages. | | `publishForm(dynamicForm, deleteOpenProgressions)` | Marks form published. When `deleteOpenProgressions = true`, wipes open progressions of that tag. | | `deleteForm(dynamicForm)` | Deletes an unpublished version. | | `deleteFormsOfTag(tag, instanceId)` | Deletes all versions + open progressions of a tag. | | `getLatest(tag, instanceId, withSections?)` | Fetches the highest-id version (any publish state). | | `getLatestOrFail(tag, instanceId, withSections?)` | Same as above, throws if not found. | | `getLatestPublished(tag, instanceId, withSections?)` | Fetches the highest-id **published** version. | | `getLatestPublishedOrFail(tag, instanceId, withSections?)` | Throws if no published version exists. | | `getVersion(tag, id, instanceId, withSections?)` | Fetches a specific version. | | `getVersionOrFail(tag, id, instanceId, withSections?)` | Throws if not found. | | `getVersionIdList(tag, instanceId)` | Returns version ids for a tag. | | `getVersionList(tag, instanceId, options?)` | Returns version models. | | `getSectionsForForm(instanceId, formTag, formId)` | Fetches sections for a form version. | | `addFileAssetToForm(form)` | Hydrates `form.fileAsset` from `fileAssetId` (PDF-type forms). | ### `DynamicFormsAnswerService` | Method | Description | |---|---| | `createAnswerProgression(form, body, user, instance)` | Creates (or resumes) an open progression for a published form. | | `createAnswerProgressionInternal(form, prevId, userId)` | Low-level creation, used by gRPC and REST paths. | | `updateAnswerProgression(progression, params, user, instance)` | Drives a section event. Central engine method. | | `getFormAnswerProgressionForUserOrFail(tag, id, userId, instanceId)` | Gets progression, asserts ownership. | | `getOpenFormAnswerProgressionOfUser(tag, userId)` | Returns open progression or `null`. | | `deleteOpenFormAnswerProgressionsOfFormAnswer(tag, formAnswerId)` | Cleans up open progressions linked to a finalized answer. | | `addDetailsToAnswerProgression(progression, user, instance)` | Hydrates sections, file assets, and profile-field replacements onto a loaded progression. | | `getFormAnswerOrFail(formTag, formAnswerId, instanceId)` | Fetches a finalized `DynamicFormAnswer` scoped to a tag. | | `getFormAnswerByIdOrFail(formAnswerId)` | Fetches a finalized answer by id only (no tag scope). | | `addDetailsToFormAnswer(formAnswer)` | Hydrates sections, last progression, and PDF asset onto a single answer. | | `getFormAnswers(formTag, formAnswerIds, instanceId, options?)` | Bulk fetch. | | `countFormAnswersByIds(ids, options?)` | Count of existing answers for given ids. | | `countFormAnswersByVersion(formTag)` | Returns a `{ [formId]: count }` map across versions of a tag. | | `getFormAnswersWithSectionsAndAnswers(formTag, formAnswerIds, instanceId)` | Fetches answers with sections and answers, optimized for reports. | ### `DynamicFormsValidationService` | Method | Description | |---|---| | `validateFormInterface(form, formInterface, instance)` | Full structural validation: actions, sections, components, `storage` types, references (via expression parser), attachment file assets, PDF layout (when `type = PDF`). Computes and returns `DynamicFormReferences`. | ### `DynamicFormsReportService` | Method | Description | |---|---| | `getDynamicFormAnswersReport(answers, columns, instanceId)` | Maps `ReportColumn[]` definitions over `DynamicFormAnswer[]`. Hydrates profile-field and file-asset replacements. Returns a `ResourceReport`. | ### `DynamicFormComponentTemplatesService` | Method | Description | |---|---| | `createComponentTemplate(instanceId, creatorId, data)` | Creates a `DRAFT` template. | | `editComponentTemplate(template, data)` | Updates a `DRAFT` template. Throws if `IN_USE`. | | `deleteComponentTemplate(template)` | Deletes. Throws if `IN_USE`. | | `duplicateComponentTemplate(template, instanceId)` | Clones with a new `duplicationId`. | | `getComponentTemplate(uuid, instanceId)` | Fetches by UUID. | | `listComponentTemplates(instanceId, filters, pagination, sort)` | Paginated list. | ### `CertificatesService` | Method | Description | |---|---| | `getCertificate({ loggedUser, formAnswerId })` | Entry point for the `GetCertificate` gRPC RPC. For `PDF`-type forms returns the stored PDF; for `REGULAR` forms builds via `CertificatePdfBuilderService`, caches in `pdfFileAssetId` for `CERTIFICATE_CACHE_DAYS`, and returns the file asset. | --- ## Ports | Port | Default Adapter | Purpose | |---|---|---| | `DynamicFormsRepositoryPort` | `DynamicFormsRepositoryAdapter` | All persistence: forms, sections, progressions, answers. | | `DynamicFormsFileAssetsPort` | `DynamicFormsFileAssetsAdapter` | Signed URLs, attachment hydration, file-asset usage tracking at form / progression / section level. | | `DynamicFormsProfileFieldsPort` | `DynamicFormsProfileFieldsAdapter` | Resolves AUTOCOMPLETE answers from user profile fields (including `PROFILE_PICTURE`). | | `DynamicFormsQuestionTypesPort` | `QuestionTypesAdapter` (monolith service) | Validates and casts raw user answers; converts to human-readable strings for reports. | | `DynamicFormsExpressionParserPort` | `DynamicFormsExpressionParserAdapter` | Evaluates boolean conditions for `CONDITIONAL` actions; extracts referenced symbols from expressions. | | `DynamicFormsPDFServicePort` | `PDFUtils` (monolith service) | Low-level PDF rendering primitives. | | `DynamicFormComponentTemplatesRepositoryPort` | `DynamicFormComponentTemplatesRepositoryAdapter` | Persistence for component templates. | | `DynamicFormsUtilsPort` | (local implementation) | Unique ID generation, array utilities, sort by primitive. Not in `dynamicFormsPortsDI.ts` — injected directly as a typed token. | --- ## Endpoints ### App (`/dynamic-forms` via `startDynamicFormsAppRouter`) | Method | Path | Controller method | Auth | Notes | |---|---|---|---|---| | `GET` | `/:tag/progressions/:progressionId` | `getAnswerProgression` | Standard JWT (logged user) | Asserts progression belongs to the logged user. | | `PATCH` | `/:tag/progressions/:progressionId` | `updateAnswerProgression` | Standard JWT | Drives a section event. | | `POST` | `/:tag/progressions` | `createAnswerProgression` | JWT + `validateQueryToken(APP)` | Creates / resumes progression. Token must be minted by the consuming module. | | `GET` | `/:tag/answers/:answerId` | `getFormAnswer` | JWT + `validateQueryToken(APP)` | Fetches finalized answer. Token required. | ### Backoffice (`/dynamic-forms` via `startDynamicFormsBackofficeRouter`) | Method | Path | Controller method | Notes | |---|---|---|---| | `GET` | `/:tag` | `getLatest` | Latest form version. | | `PUT` | `/:tag` | `updateLatest` | Update latest unpublished version. | | `GET` | `/:tag/versions` | `getVersionList` | All versions. | | `POST` | `/:tag/versions` | `newFormVersion` | Creates new version. | | `GET` | `/:tag/versions/:id` | `getVersion` | Specific version. | | `PUT` | `/:tag/versions/:id` | `updateVersion` | Update a specific unpublished version. | | `DELETE` | `/:tag/versions/:id` | `deleteFormVersion` | Delete an unpublished version. | | `POST` | `/component-templates` | `createComponentTemplate` | — | | `GET` | `/component-templates` | `listComponentTemplates` | Paginated. | | `GET` | `/component-templates/:uuid` | `getComponentTemplate` | — | | `PUT` | `/component-templates/:uuid` | `editComponentTemplate` | `DRAFT` only. | | `DELETE` | `/component-templates/:uuid` | `deleteComponentTemplate` | `DRAFT` only. | | `POST` | `/component-templates/:uuid/duplicate` | `duplicateComponentTemplate` | — | Backoffice endpoints are gated by `PermissionsService.validateManageDynamicForms` (requires any of `CREATE_SERVICES`, `EDIT_SERVICES`, `DELETE_SERVICES`, `MANAGE_EMPLOYEE_LIFECYCLE`) or `validateViewDynamicForms` (requires `VIEW_WORKFLOWS` or `VIEW_EMPLOYEE_LIFECYCLE`) per controller. ### Devops (`/dynamic-forms` via `startDynamicFormsDevopsRouter`) | Method | Path | Controller method | Notes | |---|---|---|---| | `POST` | `/` | `newForm` | Bootstrap a new form (DevOps-gated). | | `POST` | `/:tag/versions/:id/publish` | `publishForm` | Publish a specific version. | | `DELETE` | `/:tag` | `deleteForm` | Delete all versions of a tag. | Devops routes exist primarily because gRPC is the regular programmatic path for external callers. --- ## gRPC Surface Proto: [`src/proto/humand/dforms/v1/dynamic_forms_service.proto`](../../../../../../proto/humand/dforms/v1/dynamic_forms_service.proto) Implementation: [`src/api/grpc/implementations/dynamic_forms_service_implementation.ts`](../../../../grpc/implementations/dynamic_forms_service_implementation.ts) | RPC | Description | |---|---| | `CreateDynamicForm` | Creates the first version of a form for an `instanceId`. | | `DuplicateDynamicForm` | Clones a form version, optionally to a different `tag`. | | `PublishLatestDynamicFormVersion` | Publishes the latest version of a tag. | | `DeleteDynamicFormsOfTag` | Deletes all versions and open progressions of a tag. | | `GetDynamicFormVersions` | Lists versions with cursor pagination. | | `CreateDynamicFormAnswerProgression` | Creates (or resumes) an open progression. | | `UserHasOpenProgressionInDynamicForm` | Checks whether a user has an open progression for a tag. | | `UserHasFormAnswerWithIdInDynamicForm` | Checks whether a user owns a specific `formAnswerId`. **Runs inside a Sequelize transaction to read against the writer — do not remove when modifying (SQPD-2252 race-condition workaround).** | | `DeleteOpenProgressionOfDynamicFormAnswer` | Deletes open progressions linked to a finished form answer. | | `GetDynamicFormAnswerById` | Fetches a finalized answer by id. | | `GetUserDynamicFormAnswer` | Gets the latest answer for a user + tag combination. **Also runs inside a Sequelize transaction — same SQPD-2252 workaround.** | | `GetDynamicFormAnswerReport` | Generates a `ResourceReport` from form answers. | | `GetCertificate` | Returns the PDF certificate for a finished form answer. | --- ## Certificates `business/services/certificate/` provides the `GetCertificate` RPC for Service Management to attach PDF certificates to completed tasks. Behavior in `CertificatesService.getCertificate`: 1. Load `DynamicFormAnswer` by `formAnswerId`. 2. If form `type = PDF` → return the already-stored `pdfFileAssetId` (rendered on form `SUCCESS`). 3. If form `type = REGULAR`: - If `pdfFileAssetId` is set and not older than `CERTIFICATE_CACHE_DAYS` → return cached. - Otherwise: build a structured PDF via `CertificatePdfBuilderService` (uses `buildPdfSectionsFromFormSections` mapper + `CertificatePdfRendererService`), upload as a new file asset, write the id back into `DynamicFormAnswer.pdfFileAssetId`. --- ## Permissions `PermissionsService` (monolith-wide service, called by consuming controllers/adapters — not inside DF itself): - `validateManageDynamicForms(user)` → requires any of: `CREATE_SERVICES`, `EDIT_SERVICES`, `DELETE_SERVICES`, `MANAGE_EMPLOYEE_LIFECYCLE`. - `validateViewDynamicForms(user)` → requires any of: `VIEW_WORKFLOWS`, `VIEW_EMPLOYEE_LIFECYCLE`. --- ## Database Tables | Table | Model | |---|---| | `DynamicForms` | `DynamicForm` | | `DynamicFormSections` | `DynamicFormSection` | | `DynamicFormAnswerProgressions` | `DynamicFormAnswerProgression` | | `DynamicFormAnswers` | `DynamicFormAnswer` | | `DynamicFormComponentTemplates` | `DynamicFormComponentTemplate` | Migrations live in `humand-packages/migrations-runner` per repo conventions. --- ## Tests ### Unit tests Located under `humand-packages/monolith/test/kernels/dynamicForms/`. The layout mirrors the source tree — adapters, validation classes, serialization classes, services, and utils each have their own subdirectory. - Service-level: `dynamicFormsService.test.ts`, `dynamicFormsAnswerService.test.ts`, `dynamicFormsValidationService.test.ts`, `dynamicFormsReportService.test.ts`. - Adapter-level: `adapters/dynamicFormsRepositoryAdapter.test.ts`, `dynamicFormsFileAssetsAdapter.test.ts`, `dynamicFormsExpressionParserAdapter.test.ts`, `dynamicFormsProfileFieldsAdapter.test.ts`. - `validationClasses/` — one file per VC (including the `decorators/` subfolder for shared decorators). - `serializationClasses/` — one file per SC. - `utils/dynamicFormsPathUtils.test.ts`. - Shared helpers in `common.ts`. When adding a new VC, SC, service method, adapter, or util, add a unit test in the corresponding subdirectory. ### Integration tests Framework details are in `humand-packages/monolith/test-integration/AGENTS.md`. DF-specific files: - `test-integration/api/dynamicForms/dynamicForms.test.ts` — form lifecycle (CRUD, versioning, publishing) - `test-integration/api/dynamicForms/dynamicFormsAnswers.test.ts` — progression creation and completion flows - `test-integration/api/dynamicForms/dynamicFormComponentTemplates.test.ts` — component templates CRUD - `test-integration/api/dynamicForms/common.ts` — shared test helpers Reusable commands: `test-integration/commands/dynamicForms/` --- ## Keeping This Document Up to Date Update the relevant section when you: - Add or change a public service method - Add a new service or port/adapter - Add, change, or remove a REST endpoint or gRPC RPC - Add a new section event handler - Add a new `DynamicFormType` or component type - Add a new file-asset usage path or variable storage rule - Change the permissions model or query-token gating - Add a new VC, SC, adapter, service, or util — also drop a unit test under the matching subdirectory in `test/kernels/dynamicForms/` Simple internal refactors (rename a private method, reorganize helpers) do **not** require an update. --- ## Further Reading (Notion — may be outdated; trust this document + the source code first) - [Dynamic Forms — original design doc](https://www.notion.so/humand-co/Dynamic-Forms-92603f76716b4b5eb6098b823bcb9939) — full original RFC. Section / component / action JSON shapes still largely match the code. - [Dynamic Forms Resumido](https://www.notion.so/humand-co/Dynamic-Forms-Resumido-a99ac030617f42d19f4f6442256508a4) — frontend interaction summary (UI flows, screen transitions). - [Interacción Bamboo - DF](https://www.notion.so/humand-co/Interacci-n-Bamboo-Df-934a4354f871443aa7480f5f3e40035c) — rationale for the in-process / gRPC / signed-token split; required reading before changing the auth model. - [PDF Forms (RFC-20)](https://www.notion.so/humand-co/PDF-Forms-2406757f3130806fb05acdf27fb5cdd0) — why the `PDF` type exists and what frontend vs backend own. - [Download PDF Certificate (RFC-35)](https://www.notion.so/humand-co/Download-PDF-Certificate-2fe6757f31308047b2c7d8797145c64e) — why `CertificatesService` exists; describes individual and bulk certificate flows in Service Management.