Forms — Data Model
Three tables. All defined in libs/db/src/lib/schema/forms.ts.
forms — template
| Column | Type | Notes |
|---|---|---|
id | uuid PK | defaultRandom() |
organization_id | uuid NOT NULL | FK organizations(id) ON DELETE CASCADE |
kind | form_kind NOT NULL | 'compliance' | 'check_in' |
type_key | varchar(64) NOT NULL | Compliance: one of the complianceTypeKeyEnum slugs (FIT-158 six + contract + plan_regulations). Check-in: free-form. Validated at service layer, not DB. |
name | varchar(255) NOT NULL | Display name |
locale | varchar(5) NOT NULL | he, en, ru |
fields | jsonb NOT NULL DEFAULT '[]' | FormField[] typed via formFieldsSchema (@taikan/shared) |
version | int NOT NULL DEFAULT 1 | Monotonic per template family — (org, type_key) org-wide, (org, type_key, plan_id) plan-linked. New version = NEW row |
body_richtext | text | Plain-text legal body (compliance) or instructions (check-in). When body_rich_json is set this is its generated plain-text projection (pre-rich clients) |
body_rich_json | jsonb | Tiptap/ProseMirror doc JSON — canonical rich body. Rendered by renderFormBodyHtml (@taikan/shared) on web sign pages, staff preview and the PDF |
validity_period_days | int | Compliance only — null forbidden by CHECK if kind='check_in' |
recurrence | jsonb | Check-in only — null forbidden if kind='compliance' |
plan_id | uuid | FK plans(id), compliance only. Required for type_key='plan_regulations'; optional for other compliance keys (e.g. youth plan’s parental consent). A published, non-archived plan-linked template gates purchasing that plan |
requires_resign | bool NOT NULL DEFAULT false | Compliance only, per-version. Marks the version’s change material: signatures on earlier versions of the family stop satisfying the gates and signers are re-issued. Cosmetic versions leave it false. |
required_for_booking | bool NOT NULL DEFAULT false | Compliance only, org-wide (plan_id IS NULL) only. When true, booking any class requires a signed, unexpired instance. Toggleable like auto_issue_on_join (no version bump) |
auto_issue_on_join | bool NOT NULL DEFAULT false | Compliance only, org-wide only (plan-linked templates issue on demand at purchase). Drives the MEMBERSHIP_ACTIVATED fan-out |
published_at | timestamptz | null = draft |
archived_at | timestamptz | null = active |
created_at, updated_at | timestamptz NOT NULL | |
created_by_id | uuid NOT NULL | FK users(id) (no ON DELETE) |
Constraints:
forms_org_type_version_uq— partial unique(organization_id, type_key, version)WHEREplan_id IS NULL.forms_org_type_plan_version_uq— partial unique(organization_id, type_key, plan_id, version)WHEREplan_id IS NOT NULL— each plan owns its own versioned family of the same typeKey.forms_kind_payload_chk—(kind='compliance' AND recurrence IS NULL) OR (kind='check_in' AND validity_period_days IS NULL).forms_plan_link_chk— plan link is compliance-only;plan_regulationsmust carryplan_id;required_for_bookingonly on org-wide compliance rows.
Indexes:
forms_org_kind_type_idxon(organization_id, kind, type_key)— auto-issue lookup hot path.forms_active_check_in_idxpartial on(organization_id, kind)WHEREarchived_at IS NULL AND published_at IS NOT NULL— recurrence scheduler.forms_plan_gate_idxpartial on(organization_id, plan_id)WHEREplan_id IS NOT NULL— purchase-gate lookup.forms_booking_gate_idxpartial on(organization_id)WHERErequired_for_booking AND archived_at IS NULL AND published_at IS NOT NULL— booking-gate lookup.
form_instances — issuance to a user
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
organization_id | uuid NOT NULL | Denormalized for scope-checked reads without joining forms. FK CASCADE on org delete — but see retention: the cascade only ever fires for orgs with no signatures, because form_signatures RESTRICTs first |
form_id | uuid NOT NULL | FK forms(id) — no cascade; deleting a template with instances is blocked (intentional) |
form_version | int NOT NULL | Snapshot of forms.version at issue. Pins the instance to a template version |
kind | form_kind NOT NULL | Denormalized discriminator |
assignee_user_id | uuid NOT NULL | FK users(id) ON DELETE RESTRICT — deleting a user who has form instances is blocked. See PII section |
assigned_by_user_id | uuid | NULL when system-issued (auto-issue, scheduler). FK users(id) ON DELETE RESTRICT |
status | form_status NOT NULL | 8-value superset; CHECK narrows per kind |
scheduled_for | timestamptz | Check-in only |
sent_at, opened_at, answered_at, reviewed_at, archived_at | timestamptz | Phase markers |
expires_at | timestamptz | Overloaded: token TTL (7d from generateSigningLink) OR document TTL (signed_at + validityPeriodDays) |
answers | jsonb | FormAnswers (record of fieldId → value) |
signing_token | varchar(64) | 64 hex chars (32 bytes). Compliance only. NULL after submit |
created_at, updated_at | timestamptz NOT NULL |
Constraints:
form_instances_kind_status_chk— compliance ∈{draft,pending,signed,archived}; check-in ∈{scheduled,sent,answered,reviewed}.form_instances_token_kind_chk—signing_token IS NULL OR kind='compliance'.
Indexes:
form_instances_assignee_status_idxon(assignee_user_id, status)— member “my open forms” + coach “pending for this member”.form_instances_org_kind_status_idxon(organization_id, kind, status)— coach review queue.form_instances_assignee_kind_answered_idxpartial on(assignee_user_id, kind, answered_at)WHEREanswered_at IS NOT NULL— trend charts.form_instances_scheduled_for_idxpartial on(scheduled_for)WHEREstatus='scheduled'— recurrence batch.form_instances_signing_token_uqpartial unique on(signing_token)WHERE NOT NULL — public token lookup.
form_signatures — append-only legal artefact
1:1 with a signed instance row. No UPDATE path exists in the codebase. Carries the immutable evidence.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
form_instance_id | uuid NOT NULL UNIQUE (form_signatures_instance_uq) | FK form_instances(id) |
organization_id | uuid NOT NULL | FK ON DELETE RESTRICT (FIT-280) — an org with signatures on file cannot be hard-deleted. See retention |
r2_key | text NOT NULL | Path inside the compliance bucket: {orgId}/forms/{memberId}/{typeKey}/{ts}_{audit16}.pdf |
thumbnail_r2_key | text | Reserved; not populated by current code |
pdf_checksum_sha256 | varchar(64) NOT NULL | Hex SHA-256 of the PDF bytes at sign time. Tamper detection |
signed_at | timestamptz NOT NULL | |
ip_address | varchar(45) | IPv6-capable. From X-Forwarded-For first hop or req.socket.remoteAddress |
user_agent | text | Truncated to 200 chars in PDF footer; full string in DB |
signature_image_r2_key | text NOT NULL | Path to the canvas-drawn PNG in the default (non-compliance) bucket |
Indexes:
form_signatures_org_signed_idxon(organization_id, signed_at)— compliance reporting.
Relationships
organizations 1 ─── * forms
└── * form_instances
└── * form_signatures
forms 1 ─── * form_instances
form_instances 1 ─── 0..1 form_signatures (UNIQUE form_instance_id)
users 1 ─── * forms (createdById)
└── * form_instances (assigneeUserId, assignedByUserId)Multi-org isolation
- All three tables carry
organization_id. Service layer queries always includeWHERE organization_id = $1. getTemplateScoped(orgId, formId)(forms.service.ts:1018) is the canonical “load this template if it belongs to my org” helper. 404 on cross-org access.- Public token routes bypass the org gate but the token is globally unique (
form_instances_signing_token_uq), so cross-org leakage is impossible without leaking the token itself. - Cross-org
typeKeycollisions are permitted by design (every gym’s “health_declaration” v1 is its own row). - Org delete:
formsandform_instancescascade;form_signaturesrestricts. See Org deletion & retention.
PII handling
The signed PDF contains the most sensitive PII in the system. By design, fields can include national ID (תעודת זהות), date of birth, phone, emergency contact details (see forms-presets.ts healthDeclaration).
| Surface | What’s stored | Encryption / protection |
|---|---|---|
form_instances.answers (DB) | Raw JSONB. National ID, names, etc. | At-rest only — depends on Postgres disk encryption. Not field-level encrypted. |
form_signatures.r2_key (R2, compliance bucket) | Signed PDF bytes with full PII baked in | At-rest encryption from Cloudflare R2. Object-level access only via presigned URLs. No client-side encryption. |
form_signatures.signature_image_r2_key (R2, default bucket) | Canvas-drawn PNG of the signature | Same R2 protections. Lives outside the compliance bucket because it’s an input, not a legal artefact. |
form_signatures.ip_address / user_agent | Plaintext in DB and inside the PDF footer | Audit evidence — needs to be readable |
| Presigned URLs | TTL 30 days for staff downloads (getSignedPdfUrl); 1 hour for normal R2 reads; 5 minutes for upload presigns | Cached in Redis with TTL strictly less than signed expiry |
Open issues:
- No field-level encryption. A DB compromise reveals every member’s national ID. Acceptable today because the threat model treats Postgres as trusted, but should be revisited.
- Signature image bucket lacks retention. The PNG is technically reproducible by reading the PDF, so deletion is safe — but no janitor exists yet.
- No redaction on user deletion. Deleting a user who has form instances is blocked (
assignee_user_id/assigned_by_user_idare ON DELETE RESTRICT) — the row can’t dangle, but nothing redacts either: the PDF in R2 still contains their national ID and signature. Israeli “right to erasure” requests need a bespoke wipe path that unpicks instances, signatures and R2 objects before the user row can go. Tracked in README gaps.
Soft vs. hard delete
- Templates — soft via
archived_at, applied to the whole version family at once (archiveTemplate). Existing instances pinned to a now-archived template still resolve correctly (the row survives archiving). A hard delete exists for the never-used case only:deleteTemplateerases the family’s rows and open instances, and 409s (form_has_submissions) the moment any completed submission or signature exists. - Instances — soft via
archived_at. Status transition toarchivedis a valid terminal state. Hard-deleted only as part of a familydeleteTemplate, which the submission guard restricts to never-completed instances. - Signatures — never deleted, by application code or by cascade. The org FK is RESTRICT, so there is no DB path that removes a signature row as a side effect of deleting something else. Removing one requires a deliberate, explicit
DELETE FROM form_signatures. - No application-level retention enforcement. The DB now guarantees the row survives; the PDF bytes in R2 have no equivalent guard. Bucket-level lifecycle (Cloudflare R2 object lock + 7-year retention policy) is in the FIT-158 backlog but not yet provisioned.
Org deletion & retention
form_signatures.organization_id is ON DELETE RESTRICT (FIT-280, migration 0098_keen_ozymandias). Signed forms are legal evidence — health declarations, parental consents, cancellation notices — and the Israeli statute-of-limitations posture puts the retention horizon at roughly 7 years, well past the end of the customer relationship. Losing them because someone deleted an org row is not an acceptable failure mode.
What this means concretely:
| Situation | Result |
|---|---|
DELETE FROM organizations where the org has no signatures | Succeeds. forms + form_instances cascade away as before. |
DELETE FROM organizations where the org has ≥1 signature | Fails with a foreign-key violation on form_signatures_organization_id_organizations_id_fk. Nothing is deleted — the statement aborts before the forms / form_instances cascades can fire, so the whole delete is atomic-all-or-nothing. |
forms and form_instances deliberately keep their cascades. An instance with no signature is workflow state, not evidence, and with signatures restricting first, those cascades are only reachable for orgs that have nothing to retain.
Offboarding an org with signatures therefore requires exporting / archiving the evidence first — pull the signature rows and their R2 objects into whatever the retention store ends up being, delete the signature rows explicitly, and only then delete the org. Soft-delete (deactivating the org and leaving the rows in place) satisfies retention with no extra work and is the expected default. The operational runbook for the hard-delete path — where the export lands, who authorizes it, how the R2 objects are held for the remainder of the 7 years — is deferred, not written. Until it exists, treat a blocked org delete as correct behavior rather than a bug to route around.
Versioning model
- Each template family —
(organization_id, type_key)for org-wide rows,(organization_id, type_key, plan_id)for plan-linked ones — is a chain offormsrows:v1, v2, v3, …. All rows persist; old versions never delete. Coverage, expiry re-issue and the purchase/booking gates all match instances by family, never by baretype_key(two plans’plan_regulationsmust not cross-cover). - An instance pins
form_idANDform_version. The pin is redundant (the row already encodes its version), but the denormalization lets the audit page render “Signed v1 — current is v3” without joining back through(org, type_key). - Version bump via
bumpVersioninserts a row withpublishedAt=nowdirectly; there is no v(n+1) draft phase. If a coach wants to iterate before going live, they must currently archive v(n) and create from scratch (gap). auto_issue_on_joinlives on the template row, not the chain head — when a coach bumps the version, the new row carries forward the flag value from the previous (seeforms.service.ts:256-257).
Reading paths
Hot queries:
| Query | Index used | Page |
|---|---|---|
| ”What forms does this member have?” | form_instances_assignee_status_idx | member detail → forms tab |
| ”Who hasn’t signed health_declaration?” | forms_org_kind_type_idx + form_instances_assignee_kind_answered_idx | bulk coverage preview |
| ”Resolve this signing token” | form_instances_signing_token_uq | public signing landing |
| ”Recent signed forms for compliance reporting” | form_signatures_org_signed_idx | (planned reporting surface) |