Forms (Compliance + Check-ins)
Linear epic: FIT-176 (Forms engine) · FIT-158 (Hebrew compliance PDFs) · FIT-178 (Hebrew signing UX) · FIT-184 (Engine design spike) · FIT-189 (Public token-gated upload presign) Status: Backend feature-complete; staff UI shipped; public signing surface (FIT-178) and member in-app signing UI are pending and about to enter QA. Last reviewed: 2026-05-28
What
A unified forms engine that drives two distinct domains over a shared template/instance schema:
- Compliance forms (this bucket’s focus): legally compelling Hebrew waivers that Israeli physical-premises gyms must collect — health declarations, club regulations, parental consent for minors, freeze/cancellation requests, studio rental agreements. Output is a signed, immutable PDF in R2 with a SHA-256 checksum and an audit footer (token id, IP, user-agent, timestamp).
- Check-in forms: recurring coach-driven questionnaires (mood, sleep, sRPE) that fan out on a schedule and produce answers, not PDFs. Documented under check-ins; the engine code is shared.
This README and the rest of this folder cover the compliance half in depth; check-ins are referenced where they share state machine surface but live in docs/features/onboarding/ and docs/features/insights/ discussions.
Why
Israeli law (חוק לקידום הספורט / Sports Promotion Law) requires gyms to obtain a health declaration before granting access, and the standard membership contract / freeze / cancellation flows are paper-driven across most Israeli studios. FitKit replaces the clipboard with an electronic signature flow that:
- renders Hebrew RTL with native font shaping (no PDFKit glyph hacks);
- pins the signed bytes by SHA-256 and stores them in a dedicated R2 bucket with planned 7-year retention;
- works on a member’s phone (in-app, authenticated) or via a single-use WhatsApp-sharable link for the non-app cases (a parent of a minor, an external studio renter).
A compliance form is unique among FitKit features because the artefact must outlive the platform: even if the gym leaves FitKit, the signed PDFs are legally meaningful evidence and must be downloadable indefinitely.
Who (personas)
| Persona | Surface | Capabilities |
|---|---|---|
| Org owner / admin / coach | /dashboard/forms (web) | Create templates, edit drafts, publish, archive, bump versions, assign to one member, bulk-assign to “everyone missing”, view coverage, mint signing links, download signed PDFs |
| Member | Mobile app (primary) + /forms/mine (web — pending) | View own pending instances, draw signature, submit (authenticated) |
| Unauthenticated signer | /[lang]/forms/sign/{token} (pending — FIT-178) | Open token-gated link, draw signature, submit. Used for parents of minors and external studio renters |
Capabilities (current)
- Template CRUD — staff create draft → patch → publish; published rows are immutable except via
bumpVersionwhich forks a new row atversion+1while keeping old in-flight instances pinned. - Six hardcoded compliance type-keys —
health_declaration,club_regulations,parental_consent_youth,membership_freeze,membership_cancellation,studio_rental_agreement. Validated server-side via the Zod enumcomplianceTypeKeyEnum(libs/shared/src/lib/schemas/forms.ts:31). - Hebrew presets — opinionated starter templates for the six type-keys in
libs/shared/src/lib/schemas/forms-presets.ts, aligned with PAR-Q-style health questions and standard Israeli gym contract clauses. - Auto-issue on join — staff toggle
autoIssueOnJoinon a compliance template; the engine listens forMEMBERSHIP_ACTIVATEDevents (FormsService.handleMembershipActivated,forms.service.ts:365) and issues pending instances for every newly-active member (not staff). Idempotent: skips type-keys the member already has covered. - Bulk coverage — for any compliance template, return the set of active members missing or expired against that
typeKey(any version) and fan out pending instances in one transaction. - Two signing paths:
- Authenticated: member signs in-app from their
forms/minelist. Identity verified against the Clerk session. - Token-gated: staff explicitly mints a 7-day single-use signing token. Public route
/forms/sign/:token. No auth — the 64-hex token is the credential.
- Authenticated: member signs in-app from their
- PDF render pipeline — Puppeteer +
@sparticuz/chromiumheadless render of a Hebrew RTL HTML template with org logo, signature image, and audit footer. SHA-256 of the bytes is stored alongside the R2 key. - Dedicated R2 bucket —
R2_COMPLIANCE_BUCKET_NAME(falls back to default in dev). The signature image PNG lives in the regular bucket (it’s an input, not a legal artefact); the PDF lives in the compliance bucket. - Staff PDF retrieval — presigned 30-day GET URL, cached in Redis with TTL < signed expiry.
- Online org guard — compliance is rejected at the service boundary for
organization.type = 'online'. Online studios don’t have a premises problem so the compliance UI is hidden and the API refuses direct hits.
Capabilities (gaps + tracking)
| Gap | Linear |
|---|---|
Public signing page (/[lang]/forms/sign/{token}) — Hebrew RTL signature canvas, submit, success state | FIT-178 |
| Member in-app signing screen + push deep-link target | FIT-178 |
Audit logging — audit_log table with actor_id, action, entity_id, metadata. Today, signature audit is the per-row metadata on form_signatures only | FIT-20 |
| R2 compliance-bucket object lock + 7-year lifecycle policy at the infra layer | FIT-158 |
| Push notification on issue + reminder cadence for pending compliance | FIT-170 (engine), forms-specific category TBD |
| Re-render / re-sign on version bump (currently old versions stay signed; no auto-issue of v+1) | not tracked |
Soft-delete / redaction policy on member account deletion (PII inside the PDF is currently retained — see behavior.md invariants) | not tracked |
| WhatsApp delivery integration (today, staff copy/paste the URL) | FIT-158 deferred |
Related features
- Memberships —
MEMBERSHIP_ACTIVATEDevent drives auto-issue; seeapps/api/src/memberships/membership-events.ts. Documented atdocs/features/memberships/. - Legal — separate domain (TOS, privacy, fitness waiver) tracked under
legal_documents/legal_consents. Seedocs/features/legal/. Forms are gym-issued; legal is platform-issued. - Uploads / R2 — see
docs/features/uploads-r2/. The signature PNG upload uses the regular staged-PUT flow when authenticated; tokenized signing uses a bespoke public presign (FIT-189). - Push notifications — see
docs/features/push-notifications/. Compliance issuance does NOT push yet; check-in fan-out does. - Onboarding — auto-issue plugs into the activate-membership step.
- Member detail page —
/dashboard/members/{id}exposes the per-member compliance audit tab viamember-forms-tab.tsx.
Source code anchor
| Layer | Path |
|---|---|
| API service | apps/api/src/forms/forms.service.ts |
| API controllers | apps/api/src/forms/forms.controller.ts (auth + public) |
| PDF renderer | apps/api/src/forms/pdf.service.ts |
| DTOs | apps/api/src/forms/dto/{create,update,assign-compliance,submit-form-answers}-form.dto.ts |
| DB schema | libs/db/src/lib/schema/forms.ts |
| Shared types + state machine | libs/shared/src/lib/schemas/forms.ts |
| Hebrew presets | libs/shared/src/lib/schemas/forms-presets.ts |
| Staff web UI | apps/web/src/components/overview/forms/ and apps/web/src/app/[lang]/(protected)/dashboard/forms/ |
| Member detail audit tab | apps/web/src/components/overview/members/member-forms-tab.tsx |
| Mobile member UI | fitkit-mobile (separate repo): in-app at app/(tabs)/profile/ “My Forms” via src/hooks/use-forms.ts; token-gated at app/forms/sign/[token].tsx via src/hooks/use-form-token.ts; renderer at src/components/forms/form-renderer.tsx. See FIT-178 and docs/architecture/mobile.md. |
| R2 client | apps/api/src/r2/r2.service.ts |
See code-map.md for the full inventory and behavior.md for the state machine + golden paths.