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 by server-resolved required types for the selected organization role: privacy policy + Fitness & Health Acknowledgement (fitness_waiver) + current member_terms for members; platform terms_of_use only for owners; no required document for admins or coaches until staff-specific terms are introduced. External-payment acknowledgement is conditional and organization-scoped during member joining or pending-only onboarding, never part of the active-member backstop.UsersService.getLegalRequirements(userId, organizationId?) returns { types, organizationId }; /users/me and consent-status share this resolver
IP + UA captured per consentrecordConsent reads X-Forwarded-For first hop, fallback to socket
Existing active members accept missing current member terms through /accept-terms, not compliance formsOnly isRequired && requiresReconsent types are requested; already-current privacy and fitness acceptance remains valid
Required legal acceptance and optional analytics are independentMember onboarding uses one required legal checkbox for privacy + Fitness & Health Acknowledgement + member terms; conditional external-payment responsibility stays separately explicit. Analytics/session replay is a distinct unchecked optional checkbox and never satisfies a required document.
External acknowledgement for organization A cannot satisfy organization BConsent lookup and dedup include organization_id for external_payment_acknowledgement; external acknowledgement requires an organization context
Fitness & Health Acknowledgement preserves mandatory rightsDisplay title changes only; fitness_waiver type and existing slug remain unchanged. It is not a waiver of non-waivable statutory rights

Golden paths

New user signup (owner_onboarding context)

  1. Clerk sign-up completes.
  2. The onboarding setup sheet’s terms section renders <LegalAcceptanceForm context="owner_onboarding" standalone={false} />. The form reports its all-boxes-ticked state to the sheet through onAcceptedChange(accepted) on every change, including the initial unticked one, so unticking un-accepts and a consent restored from the sheet’s local persistence is never trusted.
  3. Component fetches GET /legal/documents?locale=he → list of latest docs per type.
  4. User ticks the platform Terms of Use box. There is no Accept button in embedded mode; the sheet’s single submit button sends the consent together with the rest of the setup.
  5. POST /legal/consents with { types: OWNER_LEGAL_DOC_TYPES, context: 'owner_onboarding' }.
  6. Service resolves the current document IDs in the requested locale (default he), skips current active evidence already recorded in the applicable scope, and inserts the rest with IP + UA. Evidence references the exact document/version/locale accepted; previous versions remain unchanged.

Returning user, TOS updated to v2

  1. App entry → GET /legal/consents/status?organizationId=… → returns per-type rows including server-computed isRequired, resolved organizationId, and requiresReconsent.
  2. Only rows with both isRequired and requiresReconsent are missing required acceptance. Router / middleware redirects to the reconsent prompt for that set.
  3. User accepts → POST /legal/consents with the missing required types, context: 'reconsent', locale, and organization context when applicable.
  4. New legal_consents row inserted; prior row stays as historical evidence of v1 acceptance.
  • PublicJoinInfo.requiredLegalDocumentTypes is resolved by the server. Privacy policy, fitness acknowledgement and member_terms are always present on the identity step, before the invitation leaves for Clerk. The member UI lists and links those documents under one required legal checkbox; neither the health acknowledgement nor member_terms is a second checkbox or a later confirmation step. Platform terms_of_use is the owner contract and never appears in the member-join agreement.
  • A server-resolved plan paymentLinkUrl or active external provider additionally requires external_payment_acknowledgement. It remains a separate explicit required acknowledgement.
  • Public invitation requests and authenticated direct acceptance re-resolve requirements before creating the invitation/membership. The transport retains explicit booleans for server validation, but the member terms boolean mirrors the unified required legal choice rather than a second user decision.
  • Analytics consent is separately optional and unchecked. Its disclosure names analytics and session replay; declining never blocks membership and cannot satisfy any legal document.
  • Consent is recorded against current exact localized document IDs with member_onboarding context, IP, UA, timestamp and target organization. External evidence is organization-scoped; other types remain platform-level.
  • /users/me?organizationId=… and consent-status resolve the selected organization consistently: valid explicit organization first, then membership role priority. Status returns organizationId and isRequired, so clients do not reconstruct requirements.
  • Active-member backstop always includes current member terms but never external acknowledgement. Pending-only onboarding may add external acknowledgement.
  • Staff can create or invite members without validating or recording member acceptance. No consent evidence is synthesized. The member accepts missing required documents through the existing /accept-terms gate at first login; member invitations remain pending until required consent is recorded.
  • A signed-in user with no membership at all is never sent to /accept-terms: owner onboarding and the member join form collect their respective first consent, while admins and coaches have no required platform terms yet. The gate applies once a role with requirements exists.

Optional mobile analytics and session replay

  • PostHog remains unconstructed until persisted or newly submitted analytics consent is true. The checkbox defaults unchecked.
  • The disclosure explicitly states that product analytics and session replay may record screen interactions to improve the app.
  • Replay config is fail-private and explicit: maskAllTextInputs=true, maskAllImages=true, maskAllSandboxedViews=true, captureLog=false, and captureNetworkTelemetry=false.
  • Sensitive screens can additionally use PostHogMaskView / maskFromReplay; global masking remains the baseline, not an optional per-screen convention.

Edge cases

ScenarioBehavior
User signs in but documents endpoint is unavailableForm shows error state; submit disabled
User double-clicks AcceptrecordConsent skips current active evidence; external acknowledgement dedup additionally matches the organization
Document published with a future published_atThere is no time gate today: greatest published_at wins immediately. Do not insert a future-dated row as a scheduling mechanism
Locale switch mid-acceptanceForm refetches documents?locale=…; user must re-toggle (re-render)
User accepts in locale=he, then app shows en versionEvidence retains the exact localized document ID. A locale switch alone does not demand acceptance again when the same current document version is already accepted
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
External acknowledgement exists for a different organizationDoes not satisfy the target organization’s requirement; unscoped/global gating never requires this type
User has accepted an older document versionA new row is recorded for the current version; old evidence cannot satisfy the latest-version check
Payment configuration changes between join-page load and submitServer recomputes requirements; a stale client cannot bypass newly required checkboxes
Requested locale has no current required documentConsent recording fails closed before returning the invitation ticket; the localized document catalog must be repaired rather than silently recording a partial acceptance

Side effects

ActionWritesReadsEmits
getLatestDocuments—legal_documents—
recordConsentlegal_consents insert (N rows)legal_documents, legal_consents—
getConsentStatus—both—
hasRequiredConsents—both—

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