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
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 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: STAFF_LEGAL_DOC_TYPES, context: 'owner_onboarding' } — Terms, privacy, waiver.
  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.
  • The join form’s single legal checkbox names the privacy policy and the fitness waiver — never the platform Terms — and JoinService.requestInvite records exactly MEMBER_LEGAL_DOC_TYPES against the pre-provisioned user, so the /accept-terms gate never fires for a quick registration. The org id is captured for audit context — the consent is still platform-level (FK is to legal_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

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