veza/veza-backend-api/migrations/991_terms_acceptance.sql

63 lines
3.1 KiB
MySQL
Raw Normal View History

feat(legal): versioned terms acceptance ledger (CGU/CGV/mentions) v1.0.10 légal item 3. RGPD requires explicit re-acceptance of any terms-of-service-class document on material change. Adds a per-user, per-document, per-version ledger so disputes can be answered with evidence (timestamp + originating IP + user-agent). Backend * migrations/991_terms_acceptance.sql — table terms_acceptances with UNIQUE (user_id, terms_type, version) so re-accepts are idempotent. inet column for IP, varchar(512) for UA, both nullable for the internal seed paths. * internal/services/terms_service.go — TermsService : - CurrentTerms map (ISO date version per class) is the single source of truth ; bump on text edit. - CurrentVersions(userID) returns versions + the user's unaccepted set ; userID==Nil ⇒ versions only (anonymous OK). - Accept(userID, []AcceptInput) : validates each (type, version) against CurrentTerms (ErrTermsVersionMismatch on stale POST), writes one row per accept in a single transaction, idempotent via FirstOrCreate against the unique index. * internal/handlers/terms_handler.go — REST surface : - GET /api/v1/legal/terms/current (public, OptionalAuth) - POST /api/v1/legal/terms/accept (RequireAuth) - Captures IP via gin's ClientIP() (X-Forwarded-For-aware) and UA from the request, truncates UA to fit the column. * routes_legal.go — wires the two endpoints. `current` falls back to no-middleware when AuthMiddleware is nil so test rigs work. Frontend * features/legal/pages/{CGUPage,CGVPage,MentionsPage}.tsx — initial drafts with version constants matching the backend's CurrentTerms. Counsel review required before v2.0.0 (text is honest baseline, not finalised legal copy). * services/api/legalTerms.ts — fetchCurrentTerms() / acceptTerms() ; hand-written to keep the consent-modal wiring readable. * components/TermsAcceptanceModal.tsx — non-dismissable modal that opens on every authenticated session when the unaccepted set is non-empty. Per-document checkboxes + single submit ; refusal keeps the modal open (no decline-and-continue path because the legal contract requires acceptance to use the platform). * Mounted in App.tsx alongside CookieBanner ; both must overlay every screen. * Lazy-component registry + routes for /legal/{cgu,cgv,mentions}. Operator workflow when text changes : 1. Edit the text in the relevant page component. Bump the `*_VERSION` const in that file. 2. Bump CurrentTerms[*] in services/terms_service.go to the same value. 3. Deploy. Every existing user gets force-prompted on their next session ; new users prompted at registration. baseline checks : tsc 0 errors, eslint 754, go build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:47:07 +00:00
-- 991_terms_acceptance.sql
-- v1.0.10 légal item 3 — CGU / CGV / mentions légales versionnées.
--
-- RGPD demands that any change to terms-of-service-class documents be
-- explicitly re-accepted by the user. To prove acceptance in case of
-- a contentieux, we need a per-user, per-document, per-version row
-- with the timestamp and the originating IP address.
--
-- The current version of each document is hardcoded in the Go service
-- (auth.CurrentTerms.{CGU,CGV,Mentions}, ISO date strings). When the
-- text is edited, bump the constant ; the next time the user lands on
-- /api/v1/legal/terms/current the response signals "you have unaccepted
-- versions" and the SPA forces the AcceptanceModal before any other
-- action is allowed.
--
-- terms_type is a string (not enum) so a new doc class can be added
-- without a schema migration.
CREATE TABLE IF NOT EXISTS public.terms_acceptances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
-- Document class. Conventional values : 'cgu' (terms of service),
-- 'cgv' (sales terms), 'mentions' (legal mentions / impressum),
-- 'privacy' (privacy policy). Lowercased ASCII so URL slugs and
-- DB rows agree.
terms_type VARCHAR(32) NOT NULL,
-- Version identifier. Convention : the publication date in ISO
-- format (YYYY-MM-DD). Sortable, unambiguous, no minor-version
-- ambiguity. Long enough for a hash suffix if a same-day rev needs
-- one (e.g. '2026-04-30-r2').
version VARCHAR(32) NOT NULL,
accepted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Legally useful : if a user later disputes acceptance, the IP at
-- accept time is one piece of corroborating evidence (alongside the
-- session log). NULL only if the request didn't carry one (CLI
-- scripts, internal seed paths) — should never be NULL on the
-- /api/v1/legal/terms/accept path.
ip_address INET,
-- The handler may want to record the user-agent for the same
-- audit purpose. Keep it bounded so a hostile UA can't bloat the
-- table.
user_agent VARCHAR(512)
);
COMMENT ON TABLE public.terms_acceptances IS
'Per-user / per-document / per-version acceptance ledger. v1.0.10 légal item 3.';
COMMENT ON COLUMN public.terms_acceptances.version IS
'Version label, conventionally the publication ISO date. Hardcoded server-side.';
COMMENT ON COLUMN public.terms_acceptances.ip_address IS
'Originating IP at accept time, for legal evidence in case of dispute.';
-- Lookup by user (the most frequent query — "has this user accepted
-- the current versions?"). The combination is unique per row : a
-- user accepting CGU v2 a second time should be a no-op, not a
-- duplicate insertion. UNIQUE captures this.
CREATE UNIQUE INDEX IF NOT EXISTS idx_terms_acceptances_user_doc_version
ON public.terms_acceptances(user_id, terms_type, version);
-- Reverse-direction lookup : "how many users have accepted version
-- 2026-04-30 of CGU" — useful for the operator dashboard / audit.
CREATE INDEX IF NOT EXISTS idx_terms_acceptances_doc_version
ON public.terms_acceptances(terms_type, version);