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 |
The app is gated until the documents required of THIS user are accepted: privacy policy + fitness waiver for everyone (MEMBER_LEGAL_DOC_TYPES), plus the platform Terms of Use for anyone holding a staff role in any org (STAFF_LEGAL_DOC_TYPES). The Terms are Taikan’s contract with the gym; a member’s contract is the gym’s own תקנון. | UsersService.hasOutstandingConsents (one SQL round-trip on /users/me; the staff check is a subquery on memberships) |
| 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: STAFF_LEGAL_DOC_TYPES, context: 'owner_onboarding' }— Terms, privacy, waiver.- 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 joins through a join link (member_onboarding context)
- The join form’s single legal checkbox names the privacy policy and the fitness waiver — never the platform Terms — and
JoinService.requestInviterecords exactlyMEMBER_LEGAL_DOC_TYPESagainst the pre-provisioned user, so the/accept-termsgate never fires for a quick registration. The org id is captured for audit context — the consent is still platform-level (FK is tolegal_documents, not org). /accept-terms(reconsent, or a member whose join-form consent write failed) renders the member set when the user holds no staff role in any active membership, the staff set otherwise. The page decides by any staff role rather than the active org’s, to match the API gate exactly — a member here who coaches elsewhere owes the Terms.
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 |