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 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 consent | recordConsent reads X-Forwarded-For first hop, fallback to socket |
Existing active members accept missing current member terms through /accept-terms, not compliance forms | Only isRequired && requiresReconsent types are requested; already-current privacy and fitness acceptance remains valid |
| Required legal acceptance and optional analytics are independent | Member 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 B | Consent lookup and dedup include organization_id for external_payment_acknowledgement; external acknowledgement requires an organization context |
| Fitness & Health Acknowledgement preserves mandatory rights | Display 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)
- Clerk sign-up completes.
- 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 throughonAcceptedChange(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. - Component fetches
GET /legal/documents?locale=he→ list of latest docs per type. - 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.
POST /legal/consentswith{ types: OWNER_LEGAL_DOC_TYPES, context: 'owner_onboarding' }.- 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
- App entry →
GET /legal/consents/status?organizationId=…→ returns per-type rows including server-computedisRequired, resolvedorganizationId, andrequiresReconsent. - Only rows with both
isRequiredandrequiresReconsentare missing required acceptance. Router / middleware redirects to the reconsent prompt for that set. - User accepts →
POST /legal/consentswith the missing required types,context: 'reconsent', locale, and organization context when applicable. - New
legal_consentsrow inserted; prior row stays as historical evidence of v1 acceptance.
Member joins through a join link (member_onboarding context)
PublicJoinInfo.requiredLegalDocumentTypesis resolved by the server. Privacy policy, fitness acknowledgement andmember_termsare 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 normember_termsis a second checkbox or a later confirmation step. Platformterms_of_useis the owner contract and never appears in the member-join agreement.- A server-resolved plan
paymentLinkUrlor activeexternalprovider additionally requiresexternal_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_onboardingcontext, 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 returnsorganizationIdandisRequired, 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-termsgate 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, andcaptureNetworkTelemetry=false. - Sensitive screens can additionally use
PostHogMaskView/maskFromReplay; global masking remains the baseline, not an optional per-screen convention.
Edge cases
| Scenario | Behavior |
|---|---|
| User signs in but documents endpoint is unavailable | Form shows error state; submit disabled |
| User double-clicks Accept | recordConsent skips current active evidence; external acknowledgement dedup additionally matches the organization |
Document published with a future published_at | There is no time gate today: greatest published_at wins immediately. Do not insert a future-dated row as a scheduling mechanism |
| Locale switch mid-acceptance | Form refetches documents?locale=…; user must re-toggle (re-render) |
| User accepts in locale=he, then app shows en version | Evidence 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 param | Defaults 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 organization | Does not satisfy the target organization’s requirement; unscoped/global gating never requires this type |
| User has accepted an older document version | A new row is recorded for the current version; old evidence cannot satisfy the latest-version check |
| Payment configuration changes between join-page load and submit | Server recomputes requirements; a stale client cannot bypass newly required checkboxes |
| Requested locale has no current required document | Consent recording fails closed before returning the invitation ticket; the localized document catalog must be repaired rather than silently recording a partial acceptance |
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 |