Forms — Behavior
This document is the canonical behavior spec for compliance forms. Check-in behavior is summarised at the end; its deep dive lives elsewhere.
1. Domain shape
A compliance form is two persistent objects:
- Template (
formsrow) — versioned, per-organization, per-typeKey. Drafts are mutable; published rows are immutable except viabumpVersion. - Instance (
form_instancesrow) — issued to oneassigneeUserId, pinned to a specific(formId, formVersion). Carriesstatus, optionalsigningToken, optionalexpiresAt.
A signed instance has a third object attached append-only:
- Signature (
form_signaturesrow, 1:1 with instance) —r2Keyof the PDF,pdfChecksumSha256,signedAt,ipAddress,userAgent,signatureImageR2Key.
Source: libs/db/src/lib/schema/forms.ts.
2. Template state machine
┌──── patchDraft (any number of times) ──┐
│ ▼
createTemplate ──┼──> draft (publishedAt = null)
│ │
│ ├── publishTemplate ──> published (publishedAt = now)
│ │ │
│ │ ├── bumpVersion ──> NEW row, version+1,
│ │ │ publishedAt = now
│ │ │ (old row stays)
│ └── archiveTemplate ────┐ │
│ ▼ ▼
└────── setAutoIssueOnJoin ──> archived (archivedAt = now)
(works on draft + published; NO version bump)Encoded in:
FormsService.patchDraftrejects published rows with 409 (forms.service.ts:142).FormsService.publishTemplaterejects double-publish with 409 (forms.service.ts:170).FormsService.bumpVersionrejects unpublished sources with 409 (forms.service.ts:229).FormsService.assertPublishablerequires asignaturefield on compliance kinds before publish (forms.service.ts:269).
Template invariants
| Invariant | Enforcement |
|---|---|
One row per (organization_id, type_key, version) | DB unique forms_org_type_version_uq |
Compliance template has no recurrence; check-in has no validityPeriodDays | DB CHECK forms_kind_payload_chk |
Compliance template must have a signature field before publish | FormsService.assertPublishable (publish + bumpVersion) |
Compliance typeKey is one of the six FIT-158 slugs | complianceTypeKeyEnum.safeParse at create |
| Online orgs cannot create compliance templates | FormsService.assertCompliancePermitted |
autoIssueOnJoin toggles do NOT bump version | setAutoIssueOnJoin only updates the flag column |
3. Compliance instance state machine
┌─ archiveInstance (TODO — not yet exposed) ─┐
│ ▼
assignCompliance ──> pending ──┐ │ archived
issueOnboardingForms │ │
assignAllMissing ├─ submitInstanceAuthenticated ──> signed
├─ submitByToken (writes form_signatures,
│ PDF in R2, expires set
│ if validityPeriodDays)
│
└─ generateSigningLink (mints `signing_token`; does NOT change status)draft exists as a status enum value but no current code path puts compliance instances into draft — every issuance goes straight to pending. The status enum stays in place for future “save and send later” flows.
Allowed transitions (canonical in complianceTransitions, libs/shared/src/lib/schemas/forms.ts:207):
| From | To |
|---|---|
draft | pending, archived |
pending | signed, archived |
signed | archived |
archived | — (terminal) |
canTransitionCompliance(from, to) is the single gate (forms.service.ts:742).
Instance lifecycle markers
| Field | Set when | Notes |
|---|---|---|
sentAt | At issuance | Authenticated assignment + bulk + auto-issue all set this to now |
openedAt | First GET via token OR first authenticated submit attempt | Side-effect; idempotent (only writes when null) |
answeredAt | Inside commitSignedInstance | Same value as signedAt |
reviewedAt | Check-in only | Coach marks reviewed |
expiresAt | (a) when signing_token is minted → token expiry (7 days); (b) on signed → document expiry (validityPeriodDays from signedAt); (c) NULL if neither | Overloaded on purpose — there is no separate signed_at + validity_period column; both meanings live in expires_at per phase |
signingToken | generateSigningLink | Burned (set to NULL) on successful signed transition |
Instance invariants
| Invariant | Enforcement |
|---|---|
kind and status must agree (compliance ∈ {draft,pending,signed,archived}; check-in ∈ {scheduled,sent,answered,reviewed}) | DB CHECK form_instances_kind_status_chk |
signing_token only on compliance | DB CHECK form_instances_token_kind_chk |
signing_token is globally unique among non-null tokens | DB partial unique form_instances_signing_token_uq |
| A signed instance cannot be edited | Service never updates form_instances after status='signed' except to set archived. form_signatures is append-only (no UPDATE path in code). PDF in R2 is content-addressable by SHA-256. |
| Only the assignee can sign | FormsService.submitInstanceAuthenticated rejects when instance.assigneeUserId !== membership.userId (forms.service.ts:678) — staff cannot sign on a member’s behalf, ever |
| Token can sign for one instance only and is single-use | Partial unique index + signingToken: null on successful submit |
| Token expires 7 days after mint | SIGNING_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000 (forms.service.ts:50), enforced in getByToken with GoneException |
| Auto-issue never crosses kinds | handleMembershipActivated short-circuits when event.role !== 'member' |
| Auto-issue is idempotent | coveredTypeKeys set excludes pending + valid-signed; bulk also filters alreadyPending |
4. Golden paths
4.1 Coach publishes a health declaration and bulk-issues to every member
- Coach lands on
/dashboard/forms, clicks New form → form builder. - Picks
kind=compliance,typeKey=health_declaration,locale=he. Clicks Use preset to load the Hebrew PAR-Q skeleton fromforms-presets.ts. - POST
/organizations/{orgId}/forms/templates→ row inserted withversion=1,publishedAt=null. - Coach edits fields, clicks Publish. POST
/templates/{formId}/publish→assertPublishablechecks for asignaturefield; setspublishedAt=now. - Coach toggles Auto-issue to every new member (PATCH
/templates/{formId}/auto-issuewith{value:true}). Existing members are unaffected. - Coach clicks Coverage. GET
/templates/{formId}/coverage→{ missingCount: 42, missing: [...] }. - Coach clicks Send to 42 members. POST
/templates/{formId}/assign-all-missing→ 42form_instancesrows inserted withstatus='pending',sentAt=now. Already-pending members are skipped (idempotent re-press). - From now on, every new member triggers
handleMembershipActivated→ auto-issue of the latest published version.
4.2 Member signs in-app (authenticated, default path)
- Member opens the mobile app (or
/forms/mineweb — pending) and sees a pending instance card. - Tap card →
GET /organizations/{orgId}/forms/instances/{instanceId}returns{instance, form}envelope. Service marksopenedAt=nowon first open. - Member fills out the form; draws their signature on the canvas; the canvas-PNG is uploaded via the standard authenticated staged-PUT (
POST /organizations/{orgId}/uploads/presign), producing anr2Key. - Member taps Submit.
POST /organizations/{orgId}/forms/instances/{instanceId}/submitwith{ answers: { ..., signature_field_id: { r2Key } } }. - Service: validates assignee identity, validates answer shape against
formFieldsSchema+ required fields, fetches signature PNG bytes from R2, fetches org name + logo, renders the Hebrew RTL PDF via Puppeteer, hashes the PDF (sha256), uploads to compliance R2 bucket under{orgId}/forms/{memberId}/{typeKey}/{ts}_{audit16}.pdf. - In one transaction:
INSERT form_signatureswith the checksum + audit metadata;UPDATE form_instancestostatus='signed',answers=...,answeredAt=now,signingToken=null,expiresAt = now + validityPeriodDays(or null if not configured).
4.3 Parent signs on behalf of a minor via WhatsApp link (tokenized)
- Coach assigns
parental_consent_youthto the minor’s member account:POST /compliance/assign. Instance created withstatus='pending', no token. - Coach clicks Generate link.
POST /instances/{instanceId}/generate-link→ 64-hex token,expiresAt = now+7dwritten to the same instance row. - Web UI builds
https://app.fitkit.fit/{lang}/forms/sign/{token}and copies it to clipboard. Coach pastes into WhatsApp and sends to the parent. - Parent opens the link (no FitKit account). The web app loads the public signing page (FIT-178 — pending).
GET /forms/sign/{token}returns{instance, form}and marksopenedAt=now. - Parent fills out and signs on canvas. Mobile / web requests a tokenized presign:
POST /forms/sign/{token}/signature-upload→{ uploadUrl, r2Key }. Server generates the r2Key server-side; client cannot choose the path; presign restricts content type toimage/png. - Client PUTs the PNG bytes to the presigned URL.
- Client submits:
POST /forms/sign/{token}/submitwith{ answers: { signature: { r2Key } } }. - Same
commitSignedInstancepipeline runs; PDF is rendered, hashed, stored; instance flips tosignedand the token is burned (signingToken=null).
4.4 Coach downloads a signed PDF
- Coach navigates to a member’s profile → Forms tab.
GET /forms/users/{userId}→ list of instances. - Coach clicks Download PDF for a signed row.
GET /forms/instances/{instanceId}/pdf→{ url, expiresInSeconds: 2592000 }. - Web opens the URL in a new tab. Cloudflare R2 serves the PDF bytes. URL is cached in Redis with TTL
expiresIn - 300s.
4.5 Coach updates the health declaration (version bump)
- Coach opens the published v1 row. Editing the fields is blocked (UI shows “Already published — use Publish new version”).
- Coach clicks Publish new version with patch.
POST /templates/{formId}/bump-versionwith updatedfields,bodyRichtext, etc. - Service inserts a new
formsrow withversion=2, sametypeKey,publishedAt=now. v1 remains untouched. - In-flight v1 instances stay pinned; their
formVersion=1is preserved. Newauto_issue_on_joinissuances and bulk fan-outs use v2 going forward. - Members who already signed v1 are NOT automatically re-issued v2 (gap — see README). They will be when v1’s
expiresAtlapses and re-coverage runs.
5. Edge cases
| Scenario | Behavior |
|---|---|
| Member opens signing token twice | Each open marks openedAt only on first call. Token still valid until submit or expiry. |
| Token used after 7 days | getByToken returns 410 Gone. Staff must re-mint via generateSigningLink. |
| Token used after a successful submit | Token is NULLed on submit; getByToken returns 404. |
| Two simultaneous submits with the same token | DB transaction; second one sees status='signed' and canTransitionCompliance('signed','signed') returns false → 409 Conflict. |
| Member tries to submit someone else’s instance | assigneeUserId !== membership.userId → 403 Forbidden. |
| Staff tries to sign for a member | Same 403 — staff lack the assignee identity. Audit-correct: signature must trace to the actual person. |
| Signature PNG missing in answers | 400 Bad Request — commitSignedInstance requires a { r2Key } shape on the signature field. |
| Required field missing | 400 with Missing required fields: id1, id2, … |
| Puppeteer render fails | Exception bubbles; commitSignedInstance aborts before transaction; instance stays pending. R2 has no orphan because upload happens after render. |
| R2 upload fails (network) | Exception bubbles; instance stays pending. No form_signatures row, no status change. Safe retry. |
| R2 upload succeeds, DB transaction fails | R2 has an orphan PDF. Acceptable — “janitor concern” per service comment (forms.service.ts:728). The signed status was never set, so legally the form is still pending. |
Org type changes from physical → online | Existing compliance instances remain (legal evidence retained). New compliance creates / assigns are rejected. |
| Member account deleted | GAP — assigneeUserId FK is references(users.id) with no onDelete. Delete will fail or cascade depending on users config. PII inside the signed PDF (national_id, full name) is NOT redacted. Tracked in README gaps. |
| Auto-issue fires while membership is being onboarded | Async event listener; if it throws, the error is logged and swallowed (forms.service.ts:457-463) — onboarding never blocks on forms. |
| Bulk-assign while another bulk is in flight | Both queries run independently; the second one sees the first’s just-inserted pending rows in its alreadyPending set and skips them. |
| RTL Hebrew rendering with English fallback fonts | Puppeteer + Chromium uses native font shaping; the HTML template declares font-family: 'Heebo', 'Helvetica', 'Arial', sans-serif. Verify Heebo is bundled or the OS-installed fallback handles Hebrew before signing. |
| PDF over A4 height | page.pdf({ format: 'A4', margins: ... }) auto-paginates. Long Hebrew bodies span multiple pages. |
validityPeriodDays = null on template | expiresAt = null on signed instance — perpetually valid until archived. Used for one-shot agreements (studio rental for a specific date). |
6. Side effects per action
| Action | Writes | Emits | Reads R2 | Writes R2 |
|---|---|---|---|---|
createTemplate | forms insert | — | — | — |
publishTemplate | forms update (publishedAt) | — | — | — |
bumpVersion | forms insert (new row) | — | — | — |
setAutoIssueOnJoin | forms update (autoIssueOnJoin) | — | — | — |
archiveTemplate | forms update (archivedAt) | — | — | — |
assignCompliance | form_instances insert | — | — | — |
assignAllMissing | N × form_instances insert | — | — | — |
handleMembershipActivated (event) | N × form_instances insert (idempotent) | — | — | — |
generateSigningLink | form_instances update (signingToken, expiresAt) | — | — | — |
getByToken | form_instances update (openedAt on first call) | — | — | — |
getSignatureUploadUrlForToken | — | — | — | (presign — no actual write) |
submitInstanceAuthenticated / submitByToken | form_signatures insert, form_instances update | TODO (no event emitted on signed; future: emit form.signed for spotter-agent + push) | signature PNG | signed PDF |
getSignedPdfUrl | — | — | (presign + Redis cache write) | — |
7. Permissions matrix
| Action | Role required | Additional gate |
|---|---|---|
listTemplates | staff (owner/admin/coach) | org-scoped |
createTemplate, patchDraft, publish, archive, bumpVersion, setAutoIssueOnJoin | staff | compliance kind also requires org.type != 'online' |
assignCompliance, assignAllMissing, generateSigningLink, coverageForTemplate | staff | + non-online org |
getSignedPdfUrl, listForUserAsStaff | staff | org-scoped |
listForMember (own instances) | active org member | self only |
getInstanceForCaller | assignee OR any staff | both gated by org membership |
submitInstanceAuthenticated | assignee only | assigneeUserId === membership.userId |
getByToken, submitByToken, getSignatureUploadUrlForToken | public (no auth) | gated only by the 64-hex token and its expiry |
isStaffRole lives in libs/shared/src/lib/utils/roles.ts and accepts owner | admin | coach.
8. Audit trail (current vs. should-be)
Current — per-row audit metadata only:
form_signatures.ipAddress(fromX-Forwarded-Forfirst hop, fallback toreq.socket.remoteAddress, capped at 45 chars for IPv6).form_signatures.userAgent(raw header).form_signatures.signedAt.form_signatures.pdfChecksumSha256(re-computable from the R2 bytes for tamper detection).- Inside the PDF: token id (first 16 chars), IP, UA, instance UUID, timestamp — visible in the footer.
Missing — what a full audit trail SHOULD look like (FIT-20):
- A separate
audit_logtable writing one row per state transition:{ actor_id, action: 'form.signed' | 'form.assigned' | 'form.token_minted' | 'form.archived', entity_type: 'form_instance', entity_id, metadata: jsonb, ip, ua, occurred_at }. - Coverage of all mutations, not just the final sign event: who created the template, who toggled
autoIssueOnJoin, who archived. - Immutable (append-only) with a periodic snapshot to S3 / R2 cold storage.
- Searchable from the staff UI.
- Required for SOC2 / ISO 27001 readiness; required for Israeli regulator inquiries about who signed what when.
9. Check-in flow (summary, for contrast)
- Kind
check_in, status enum subset{ scheduled, sent, answered, reviewed }. - No PDF, no signature field, no signing token (
form_instances_token_kind_chkenforces it). recurrenceJSONB drives a scheduler (not yet wired in this bucket — seedocs/features/insights/).- Same template versioning, same answer validation, same
form_instancestable.
This bucket does not test check-in behavior; see docs/features/announcements/ and docs/features/insights/ for the schedule + review surfaces when they ship.