Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesFormsForms — Behavior

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 (forms row) — versioned, per-organization, per-typeKey. Drafts are mutable; published rows are immutable except via bumpVersion.
  • Instance (form_instances row) — issued to one assigneeUserId, pinned to a specific (formId, formVersion). Carries status, optional signingToken, optional expiresAt.

A signed instance has a third object attached append-only:

  • Signature (form_signatures row, 1:1 with instance) — r2Key of 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.patchDraft rejects published rows with 409 (forms.service.ts:142).
  • FormsService.publishTemplate rejects double-publish with 409 (forms.service.ts:170).
  • FormsService.bumpVersion rejects unpublished sources with 409 (forms.service.ts:229).
  • FormsService.assertPublishable requires a signature field on compliance kinds before publish (forms.service.ts:269).

Template invariants

InvariantEnforcement
One row per (organization_id, type_key, version)DB unique forms_org_type_version_uq
Compliance template has no recurrence; check-in has no validityPeriodDaysDB CHECK forms_kind_payload_chk
Compliance template must have a signature field before publishFormsService.assertPublishable (publish + bumpVersion)
Compliance typeKey is one of the six FIT-158 slugscomplianceTypeKeyEnum.safeParse at create
Online orgs cannot create compliance templatesFormsService.assertCompliancePermitted
autoIssueOnJoin toggles do NOT bump versionsetAutoIssueOnJoin 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):

FromTo
draftpending, archived
pendingsigned, archived
signedarchived
archived— (terminal)

canTransitionCompliance(from, to) is the single gate (forms.service.ts:742).

Instance lifecycle markers

FieldSet whenNotes
sentAtAt issuanceAuthenticated assignment + bulk + auto-issue all set this to now
openedAtFirst GET via token OR first authenticated submit attemptSide-effect; idempotent (only writes when null)
answeredAtInside commitSignedInstanceSame value as signedAt
reviewedAtCheck-in onlyCoach marks reviewed
expiresAt(a) when signing_token is minted → token expiry (7 days); (b) on signed → document expiry (validityPeriodDays from signedAt); (c) NULL if neitherOverloaded on purpose — there is no separate signed_at + validity_period column; both meanings live in expires_at per phase
signingTokengenerateSigningLinkBurned (set to NULL) on successful signed transition

Instance invariants

InvariantEnforcement
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 complianceDB CHECK form_instances_token_kind_chk
signing_token is globally unique among non-null tokensDB partial unique form_instances_signing_token_uq
A signed instance cannot be editedService 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 signFormsService.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-usePartial unique index + signingToken: null on successful submit
Token expires 7 days after mintSIGNING_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000 (forms.service.ts:50), enforced in getByToken with GoneException
Auto-issue never crosses kindshandleMembershipActivated short-circuits when event.role !== 'member'
Auto-issue is idempotentcoveredTypeKeys 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

  1. Coach lands on /dashboard/forms, clicks New form → form builder.
  2. Picks kind=compliance, typeKey=health_declaration, locale=he. Clicks Use preset to load the Hebrew PAR-Q skeleton from forms-presets.ts.
  3. POST /organizations/{orgId}/forms/templates → row inserted with version=1, publishedAt=null.
  4. Coach edits fields, clicks Publish. POST /templates/{formId}/publishassertPublishable checks for a signature field; sets publishedAt=now.
  5. Coach toggles Auto-issue to every new member (PATCH /templates/{formId}/auto-issue with {value:true}). Existing members are unaffected.
  6. Coach clicks Coverage. GET /templates/{formId}/coverage{ missingCount: 42, missing: [...] }.
  7. Coach clicks Send to 42 members. POST /templates/{formId}/assign-all-missing → 42 form_instances rows inserted with status='pending', sentAt=now. Already-pending members are skipped (idempotent re-press).
  8. From now on, every new member triggers handleMembershipActivated → auto-issue of the latest published version.

4.2 Member signs in-app (authenticated, default path)

  1. Member opens the mobile app (or /forms/mine web — pending) and sees a pending instance card.
  2. Tap card → GET /organizations/{orgId}/forms/instances/{instanceId} returns {instance, form} envelope. Service marks openedAt=now on first open.
  3. 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 an r2Key.
  4. Member taps Submit. POST /organizations/{orgId}/forms/instances/{instanceId}/submit with { answers: { ..., signature_field_id: { r2Key } } }.
  5. 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.
  6. In one transaction: INSERT form_signatures with the checksum + audit metadata; UPDATE form_instances to status='signed', answers=..., answeredAt=now, signingToken=null, expiresAt = now + validityPeriodDays (or null if not configured).
  1. Coach assigns parental_consent_youth to the minor’s member account: POST /compliance/assign. Instance created with status='pending', no token.
  2. Coach clicks Generate link. POST /instances/{instanceId}/generate-link → 64-hex token, expiresAt = now+7d written to the same instance row.
  3. 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.
  4. 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 marks openedAt=now.
  5. 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 to image/png.
  6. Client PUTs the PNG bytes to the presigned URL.
  7. Client submits: POST /forms/sign/{token}/submit with { answers: { signature: { r2Key } } }.
  8. Same commitSignedInstance pipeline runs; PDF is rendered, hashed, stored; instance flips to signed and the token is burned (signingToken=null).

4.4 Coach downloads a signed PDF

  1. Coach navigates to a member’s profile → Forms tab. GET /forms/users/{userId} → list of instances.
  2. Coach clicks Download PDF for a signed row. GET /forms/instances/{instanceId}/pdf{ url, expiresInSeconds: 2592000 }.
  3. 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)

  1. Coach opens the published v1 row. Editing the fields is blocked (UI shows “Already published — use Publish new version”).
  2. Coach clicks Publish new version with patch. POST /templates/{formId}/bump-version with updated fields, bodyRichtext, etc.
  3. Service inserts a new forms row with version=2, same typeKey, publishedAt=now. v1 remains untouched.
  4. In-flight v1 instances stay pinned; their formVersion=1 is preserved. New auto_issue_on_join issuances and bulk fan-outs use v2 going forward.
  5. Members who already signed v1 are NOT automatically re-issued v2 (gap — see README). They will be when v1’s expiresAt lapses and re-coverage runs.

5. Edge cases

ScenarioBehavior
Member opens signing token twiceEach open marks openedAt only on first call. Token still valid until submit or expiry.
Token used after 7 daysgetByToken returns 410 Gone. Staff must re-mint via generateSigningLink.
Token used after a successful submitToken is NULLed on submit; getByToken returns 404.
Two simultaneous submits with the same tokenDB transaction; second one sees status='signed' and canTransitionCompliance('signed','signed') returns false → 409 Conflict.
Member tries to submit someone else’s instanceassigneeUserId !== membership.userId → 403 Forbidden.
Staff tries to sign for a memberSame 403 — staff lack the assignee identity. Audit-correct: signature must trace to the actual person.
Signature PNG missing in answers400 Bad Request — commitSignedInstance requires a { r2Key } shape on the signature field.
Required field missing400 with Missing required fields: id1, id2, …
Puppeteer render failsException 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 failsR2 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 physicalonlineExisting compliance instances remain (legal evidence retained). New compliance creates / assigns are rejected.
Member account deletedGAPassigneeUserId 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 onboardedAsync 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 flightBoth 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 fontsPuppeteer + 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 heightpage.pdf({ format: 'A4', margins: ... }) auto-paginates. Long Hebrew bodies span multiple pages.
validityPeriodDays = null on templateexpiresAt = null on signed instance — perpetually valid until archived. Used for one-shot agreements (studio rental for a specific date).

6. Side effects per action

ActionWritesEmitsReads R2Writes R2
createTemplateforms insert
publishTemplateforms update (publishedAt)
bumpVersionforms insert (new row)
setAutoIssueOnJoinforms update (autoIssueOnJoin)
archiveTemplateforms update (archivedAt)
assignComplianceform_instances insert
assignAllMissingN × form_instances insert
handleMembershipActivated (event)N × form_instances insert (idempotent)
generateSigningLinkform_instances update (signingToken, expiresAt)
getByTokenform_instances update (openedAt on first call)
getSignatureUploadUrlForToken(presign — no actual write)
submitInstanceAuthenticated / submitByTokenform_signatures insert, form_instances updateTODO (no event emitted on signed; future: emit form.signed for spotter-agent + push)signature PNGsigned PDF
getSignedPdfUrl(presign + Redis cache write)

7. Permissions matrix

ActionRole requiredAdditional gate
listTemplatesstaff (owner/admin/coach)org-scoped
createTemplate, patchDraft, publish, archive, bumpVersion, setAutoIssueOnJoinstaffcompliance kind also requires org.type != 'online'
assignCompliance, assignAllMissing, generateSigningLink, coverageForTemplatestaff+ non-online org
getSignedPdfUrl, listForUserAsStaffstafforg-scoped
listForMember (own instances)active org memberself only
getInstanceForCallerassignee OR any staffboth gated by org membership
submitInstanceAuthenticatedassignee onlyassigneeUserId === membership.userId
getByToken, submitByToken, getSignatureUploadUrlForTokenpublic (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 (from X-Forwarded-For first hop, fallback to req.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_log table 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_chk enforces it).
  • recurrence JSONB drives a scheduler (not yet wired in this bucket — see docs/features/insights/).
  • Same template versioning, same answer validation, same form_instances table.

This bucket does not test check-in behavior; see docs/features/announcements/ and docs/features/insights/ for the schedule + review surfaces when they ship.