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

Legal — Behavior

State machine

Legal documents have no draft state in this codebase — every row inserted is considered live as of published_at. The “state” the user cares about is a derived per-user view:

user × document_type ─┬─ no row in legal_consents (or row with revoked_at != null) │ → requiresReconsent = true; access blocked if type ∈ required set ├─ row exists for latest (type, locale) document; revoked_at = null │ → isCurrentVersion = true; access allowed └─ row exists for an older version of the same type; latest not yet accepted → requiresReconsent = true; show "Documents updated — please re-accept"

The selection of “latest” is deterministic: selectDistinctOn(type, ORDER BY published_at DESC, id ASC). The id ASC tiebreaker ensures repeatable picks if two versions land at the same published_at.

Invariants

InvariantEnforcement
One row per (type, version, locale)DB unique constraint
Consent records the document at acceptance time — versioned, never movesDocument FK with no special logic
Reconsent never deletes prior consent rowslegal_consents is append-only by convention
hasRequiredConsents blocks app when TOS, privacy, or fitness waiver are missingLegalService.hasRequiredConsents
IP + UA captured per consentrecordConsent reads X-Forwarded-For first hop, fallback to socket

Golden paths

New user signup (owner_onboarding context)

  1. Clerk sign-up completes.
  2. Wizard step renders <LegalAcceptanceForm context="owner_onboarding" />.
  3. Component fetches GET /legal/documents?locale=he → list of latest docs per type.
  4. User toggles checkboxes for each required doc; clicks Accept.
  5. POST /legal/consents with { types: ['terms_of_use', 'privacy_policy', 'fitness_waiver'], context: 'owner_onboarding' }.
  6. Service resolves current document IDs for each type, skips ones the user already consented to, inserts the rest with IP + UA.

Returning user, TOS updated to v2

  1. App entry → GET /legal/consents/status → returns [{ type: 'terms_of_use', requiresReconsent: true, ... }].
  2. Router / middleware redirects to the reconsent prompt.
  3. User accepts → POST /legal/consents with { types: ['terms_of_use'], context: 'reconsent' }.
  4. New legal_consents row inserted; prior row stays as historical evidence of v1 acceptance.

Member of an org accepts fitness waiver

  • Same as above with context: 'member_onboarding' and organizationId: <org id>. The org id is captured for audit context — the consent is still platform-level (FK is to legal_documents, not org).

Edge cases

ScenarioBehavior
User signs in but documents endpoint is unavailableForm shows error state; submit disabled
User double-clicks AcceptrecordConsent is idempotent — same doc IDs skipped
Document published with a future published_atStill picked as “latest” once that time arrives (no time gate today — acceptable since admin controls who publishes)
Locale switch mid-acceptanceForm refetches documents?locale=…; user must re-toggle (re-render)
User accepts in locale=he, then app shows en versionConsent is recorded against the document id (which is locale-specific). Switching locales does NOT re-trigger consent for the same type; the prior consent against the he row counts
Public endpoint hit without locale paramDefaults to he (see LegalController.getDocuments)
Revoke (column exists, no surface)Future: setting revoked_at would make requiresReconsent flip to true

Side effects

ActionWritesReadsEmits
getLatestDocumentslegal_documents
recordConsentlegal_consents insert (N rows)legal_documents, legal_consents
getConsentStatusboth
hasRequiredConsentsboth

No events emitted today. Audit trail relies on the row itself.

Permissions

ActionAuth
GET /legal/documentsPublic (@Public()) — marketing-site visitors can fetch
POST /legal/consentsAuthenticated (Clerk bearer)
GET /legal/consents/statusAuthenticated