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
| Invariant | Enforcement |
|---|---|
One row per (type, version, locale) | DB unique constraint |
| Consent records the document at acceptance time — versioned, never moves | Document FK with no special logic |
| Reconsent never deletes prior consent rows | legal_consents is append-only by convention |
hasRequiredConsents blocks app when TOS, privacy, or fitness waiver are missing | LegalService.hasRequiredConsents |
| IP + UA captured per consent | recordConsent reads X-Forwarded-For first hop, fallback to socket |
Golden paths
New user signup (owner_onboarding context)
- Clerk sign-up completes.
- Wizard step renders
<LegalAcceptanceForm context="owner_onboarding" />. - Component fetches
GET /legal/documents?locale=he→ list of latest docs per type. - User toggles checkboxes for each required doc; clicks Accept.
POST /legal/consentswith{ types: ['terms_of_use', 'privacy_policy', 'fitness_waiver'], context: 'owner_onboarding' }.- 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
- App entry →
GET /legal/consents/status→ returns[{ type: 'terms_of_use', requiresReconsent: true, ... }]. - Router / middleware redirects to the reconsent prompt.
- User accepts →
POST /legal/consentswith{ types: ['terms_of_use'], context: 'reconsent' }. - New
legal_consentsrow inserted; prior row stays as historical evidence of v1 acceptance.
Member of an org accepts fitness waiver
- Same as above with
context: 'member_onboarding'andorganizationId: <org id>. The org id is captured for audit context — the consent is still platform-level (FK is tolegal_documents, not org).
Edge cases
| Scenario | Behavior |
|---|---|
| User signs in but documents endpoint is unavailable | Form shows error state; submit disabled |
| User double-clicks Accept | recordConsent is idempotent — same doc IDs skipped |
Document published with a future published_at | Still picked as “latest” once that time arrives (no time gate today — acceptable since admin controls who publishes) |
| Locale switch mid-acceptance | Form refetches documents?locale=…; user must re-toggle (re-render) |
| User accepts in locale=he, then app shows en version | Consent 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 param | Defaults to he (see LegalController.getDocuments) |
| Revoke (column exists, no surface) | Future: setting revoked_at would make requiresReconsent flip to true |
Side effects
| Action | Writes | Reads | Emits |
|---|---|---|---|
getLatestDocuments | — | legal_documents | — |
recordConsent | legal_consents insert (N rows) | legal_documents, legal_consents | — |
getConsentStatus | — | both | — |
hasRequiredConsents | — | both | — |
No events emitted today. Audit trail relies on the row itself.
Permissions
| Action | Auth |
|---|---|
GET /legal/documents | Public (@Public()) — marketing-site visitors can fetch |
POST /legal/consents | Authenticated (Clerk bearer) |
GET /legal/consents/status | Authenticated |