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 six FIT-158 slugs. 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 (@fitkit/shared) |
version | int NOT NULL DEFAULT 1 | Monotonic per (org, type_key). New version = NEW row |
body_richtext | text | Hebrew legal body (compliance) or instructions (check-in) |
validity_period_days | int | Compliance only — null forbidden by CHECK if kind='check_in' |
recurrence | jsonb | Check-in only — null forbidden if kind='compliance' |
auto_issue_on_join | bool NOT NULL DEFAULT false | Compliance only. 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— unique(organization_id, type_key, version).forms_kind_payload_chk—(kind='compliance' AND recurrence IS NULL) OR (kind='check_in' AND validity_period_days IS NULL).
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.
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 |
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). No ON DELETE policy → see PII section |
assigned_by_user_id | uuid | NULL when system-issued (auto-issue, scheduler) |
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 CASCADE — same org-deletion behavior as instances |
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). - Cascade on org delete: org → forms + form_instances + form_signatures all cascade. This is dangerous for compliance retention — deleting an org wipes the legal evidence. See gap below.
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. If a user’s account is deleted,
form_instances.assignee_user_idbecomes dangling (noonDeleteclause), and the PDF in R2 still contains their national ID and signature. Israeli “right to erasure” requests would need a bespoke wipe path. Tracked in README gaps.
Soft vs. hard delete
- Templates — soft via
archived_at. Existing instances pinned to a now-archived template still resolve correctly (template row is never deleted). - Instances — soft via
archived_at. Status transition toarchivedis a valid terminal state. - Signatures — never deleted by application code. Hard delete only happens if the org cascade fires (org row delete → CASCADE all the way down). This is the legal-retention risk mentioned above.
- No application-level retention enforcement. Bucket-level lifecycle (Cloudflare R2 object lock + 7-year retention policy) is in the FIT-158 backlog but not yet provisioned.
Versioning model
- Each
(organization_id, type_key)is a chain offormsrows:v1, v2, v3, …. All rows persist; old versions never delete. - 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) |