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>
This commit is contained in:
senke 2026-05-01 20:47:07 +02:00
parent 7f61fb225f
commit c0e06e61b6
14 changed files with 1391 additions and 5 deletions

View file

@ -8,6 +8,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { AstralBackground } from '@/components/ui/AstralBackground';
import { OfflineIndicator } from '@/components/OfflineIndicator';
import { CookieBanner } from '@/components/CookieBanner';
import { TermsAcceptanceModal } from '@/components/TermsAcceptanceModal';
import { AppRouter } from '@/router';
import { csrfService } from '@/services/csrf';
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
@ -200,6 +201,11 @@ export function App() {
once the user has decided ; persists for 13 months per
CNIL guidance. */}
<CookieBanner />
{/* v1.0.10 légal 3: terms-acceptance gate. Mounted alongside
CookieBanner because both must overlay every screen.
Self-hides when the user has accepted every current
version (or is anonymous). */}
<TermsAcceptanceModal />
{/* PWA Install Banner - Disabled for now as it is too intrusive */}
{/* <PWAInstallBanner /> */}
{/* Keyboard Shortcuts Panel (Discord-style overlay) */}

View file

@ -0,0 +1,210 @@
/**
* TermsAcceptanceModal RGPD-compliant force-prompt for new terms versions.
*
* v1.0.10 légal item 3. Mounted in App.tsx alongside CookieBanner.
* On every authenticated session, the modal :
*
* 1. fetches GET /legal/terms/current
* 2. if `unaccepted` is non-empty, opens as a non-dismissable modal
* with checkboxes per document class
* 3. requires the user to tick every box and click "J'accepte"
* 4. POSTs the batch to /legal/terms/accept
* 5. closes itself ; the SPA continues normally
*
* If the user refuses, the modal stays open there is no "decline and
* continue" path because the legal contract requires acceptance to use
* the platform. The user can sign out (clears the session) or close
* the tab. This is the same pattern banks / regulated services use.
*
* The fetch + post are a single round-trip each ; we don't poll. A
* version bump deployed mid-session takes effect on the next page
* reload, which is acceptable for v2.0.0. v1.1 may add a periodic
* re-check.
*/
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
fetchCurrentTerms,
acceptTerms,
type TermsType,
} from '@/services/api/legalTerms';
import { useAuthStore } from '@/features/auth/store/authStore';
import { logger } from '@/utils/logger';
const TERMS_LABEL: Record<TermsType, { title: string; href: string }> = {
cgu: { title: "Conditions Générales d'Utilisation", href: '/legal/cgu' },
cgv: { title: 'Conditions Générales de Vente', href: '/legal/cgv' },
mentions: { title: 'Mentions légales', href: '/legal/mentions' },
};
export function TermsAcceptanceModal() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [unaccepted, setUnaccepted] = useState<TermsType[]>([]);
const [versions, setVersions] = useState<Record<TermsType, string>>({
cgu: '',
cgv: '',
mentions: '',
});
const [checked, setChecked] = useState<Record<TermsType, boolean>>({
cgu: false,
cgv: false,
mentions: false,
});
const [submitting, setSubmitting] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
useEffect(() => {
if (!isAuthenticated) {
setUnaccepted([]);
return;
}
let cancelled = false;
fetchCurrentTerms()
.then((res) => {
if (cancelled) return;
setVersions(res.versions);
setUnaccepted(res.unaccepted ?? []);
})
.catch((err) => {
// Network or backend error : fail open. Re-prompting on the
// next session is fine ; blocking the SPA on a 5xx would be
// worse UX than the rare missed acceptance.
logger.warn('TermsAcceptanceModal: failed to fetch current terms', {
error: err instanceof Error ? err.message : String(err),
});
});
return () => {
cancelled = true;
};
}, [isAuthenticated]);
if (unaccepted.length === 0) return null;
const allChecked = unaccepted.every((tt) => checked[tt]);
const onSubmit = async () => {
if (!allChecked || submitting) return;
setSubmitting(true);
setErrorMsg(null);
try {
await acceptTerms(
unaccepted.map((tt) => ({ terms_type: tt, version: versions[tt] })),
);
setUnaccepted([]);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur réseau';
setErrorMsg(msg);
logger.error('TermsAcceptanceModal: accept failed', { error: msg });
} finally {
setSubmitting(false);
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="terms-modal-title"
className="fixed inset-0 z-[var(--sumi-z-max)] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div
className="w-full max-w-lg rounded-lg border bg-card shadow-2xl"
style={{
borderColor: 'var(--sumi-border-faint)',
color: 'var(--sumi-text-primary)',
}}
>
<div className="px-6 py-5 border-b" style={{ borderColor: 'var(--sumi-border-faint)' }}>
<h2 id="terms-modal-title" className="font-heading text-xl font-semibold">
Mise à jour de nos conditions
</h2>
<p
className="text-sm mt-2 leading-relaxed"
style={{ color: 'var(--sumi-text-secondary)' }}
>
Nous avons mis à jour les documents ci-dessous. Pour continuer à
utiliser Veza, veuillez les lire et confirmer votre acceptation.
Vous pourrez les retrouver à tout moment depuis le footer de la
plateforme.
</p>
</div>
<div className="px-6 py-5 space-y-4">
{unaccepted.map((tt) => {
const meta = TERMS_LABEL[tt];
return (
<label
key={tt}
htmlFor={`terms-${tt}`}
className="flex items-start gap-3 cursor-pointer p-3 rounded border transition-colors hover:bg-muted/30"
style={{ borderColor: 'var(--sumi-border-faint)' }}
>
<input
id={`terms-${tt}`}
type="checkbox"
checked={checked[tt]}
onChange={(e) =>
setChecked((prev) => ({ ...prev, [tt]: e.target.checked }))
}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<p className="font-medium">{meta.title}</p>
<p
className="text-xs mt-0.5"
style={{ color: 'var(--sumi-text-tertiary)' }}
>
Version {versions[tt]} ·{' '}
<Link
to={meta.href}
target="_blank"
rel="noopener"
className="underline underline-offset-2"
style={{ color: 'var(--sumi-text-secondary)' }}
>
Lire le document
</Link>
</p>
</div>
</label>
);
})}
{errorMsg && (
<p
role="alert"
className="text-sm rounded border px-3 py-2"
style={{
borderColor: 'var(--sumi-color-destructive, #c84a4a)',
color: 'var(--sumi-color-destructive, #c84a4a)',
}}
>
{errorMsg}
</p>
)}
</div>
<div
className="px-6 py-4 border-t flex justify-end gap-3"
style={{ borderColor: 'var(--sumi-border-faint)' }}
>
{/* eslint-disable-next-line no-restricted-syntax -- standalone modal must work without the design-system Button (mounted very early) */}
<button
type="button"
onClick={onSubmit}
disabled={!allChecked || submitting}
className="px-5 py-2 rounded text-sm font-medium transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
style={{
backgroundColor: 'var(--sumi-text-primary)',
color: 'var(--sumi-bg-base)',
}}
>
{submitting ? 'Enregistrement…' : "J'accepte"}
</button>
</div>
</div>
</div>
);
}
export default TermsAcceptanceModal;

View file

@ -55,5 +55,8 @@ export {
LazyDmca,
LazyDmcaNotice,
LazyPrivacy,
LazyCGU,
LazyCGV,
LazyMentions,
} from './lazy-component';
export type { LazyComponentProps, LazyErrorFallbackProps, LazyErrorBoundaryProps } from './lazy-component';

View file

@ -57,4 +57,7 @@ export {
LazyDmca,
LazyDmcaNotice,
LazyPrivacy,
LazyCGU,
LazyCGV,
LazyMentions,
} from './lazyExports';

View file

@ -375,3 +375,19 @@ export const LazyPrivacy = createLazyComponent(
undefined,
'Privacy',
);
// Legal — CGU/CGV/Mentions versionnées (v1.0.10 légal 3)
export const LazyCGU = createLazyComponent(
() => import('@/features/legal/pages/CGUPage'),
undefined,
'CGU',
);
export const LazyCGV = createLazyComponent(
() => import('@/features/legal/pages/CGVPage'),
undefined,
'CGV',
);
export const LazyMentions = createLazyComponent(
() => import('@/features/legal/pages/MentionsPage'),
undefined,
'Mentions',
);

View file

@ -0,0 +1,228 @@
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
/**
* CGUPage Conditions Générales d'Utilisation.
*
* v1.0.10 légal item 3 initial draft. The version label here MUST
* match the constant in `internal/services/terms_service.go`
* (CurrentTerms[TermsTypeCGU]). When the text below is materially
* edited, bump both. The terms-acceptance ledger then force-prompts
* every existing user on their next session.
*
* This text is a reasonable baseline ; counsel should review before
* v2.0.0 to add jurisdiction-specific clauses (FR/EU primary,
* US secondary).
*/
export const CGU_VERSION = '2026-04-30';
export default function CGUPage() {
useEffect(() => {
document.title = "CGU — Veza";
}, []);
return (
<main
style={{
backgroundColor: 'var(--sumi-bg-base)',
color: 'var(--sumi-text-primary)',
minHeight: '100vh',
}}
>
<div className="mx-auto max-w-3xl px-6 py-16 md:py-24">
<header className="mb-12">
<Link
to="/launch"
className="text-sm hover:underline"
style={{ color: 'var(--sumi-text-tertiary)' }}
>
Veza
</Link>
<h1 className="font-heading text-4xl md:text-5xl font-bold mt-6">
Conditions Générales d'Utilisation
</h1>
<p className="mt-3 text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
Version {CGU_VERSION}
</p>
</header>
<section className="space-y-8 leading-relaxed">
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">1. Objet</h2>
<p>
Les présentes Conditions Générales d'Utilisation (CGU) régissent l'usage
de la plateforme Veza (le « Service ») par tout utilisateur (« Utilisateur »).
L'inscription vaut acceptation pleine et entière des CGU dans leur version
en vigueur. Toute modification matérielle requiert une nouvelle acceptation
explicite à la connexion suivante.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
2. Conditions d'inscription
</h2>
<ul className="list-disc pl-6 space-y-2">
<li>Avoir au moins 16 ans révolus à la date d'inscription (RGPD).</li>
<li>
Disposer d'un email valide et accessible la vérification est
obligatoire avant la première connexion.
</li>
<li>
Fournir des informations exactes (nom d'utilisateur, date de naissance).
Toute inscription frauduleuse est nulle et entraîne suppression du compte
sans préavis.
</li>
<li>
Un seul compte par personne. Les comptes multiples liés à des fins de
contournement (modération, fraude marketplace) sont sanctionnés.
</li>
</ul>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
3. Contenu publié par l'Utilisateur
</h2>
<p className="mb-3">
L'Utilisateur reste propriétaire des œuvres qu'il publie (pistes audio,
playlists, commentaires). Il accorde à Veza une licence non exclusive,
mondiale, gratuite et limitée à la durée d'usage du Service, pour stocker,
transcoder, diffuser et afficher ces œuvres dans le cadre des fonctionnalités
de la plateforme.
</p>
<p>
L'Utilisateur garantit qu'il dispose des droits sur tout contenu publié et
s'engage à ne pas téléverser d'œuvres protégées sans autorisation. Veza
applique une procédure DMCA décrite à{' '}
<Link to="/legal/dmca" className="underline">
/legal/dmca
</Link>
.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
4. Comportements interdits
</h2>
<ul className="list-disc pl-6 space-y-2">
<li>Harcèlement, menaces, propos haineux ou discriminatoires.</li>
<li>Spam, scraping automatisé, contournement des limites de débit.</li>
<li>
Téléversement de malware, scripts, contenus exploitant des failles de
sécurité.
</li>
<li>
Usurpation d'identité, fausse revendication d'œuvres tiers, fraude au
paiement.
</li>
<li>
Contournement de la modération (compte multiple, VPN après ban).
</li>
</ul>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
5. Modération &amp; sanctions
</h2>
<p>
Veza se réserve le droit de masquer, retirer ou restreindre l'accès à
tout contenu manifestement illicite ou contraire aux CGU. Les sanctions
incluent : avertissement, retrait de contenu, suspension temporaire,
suppression définitive. Un appel est possible via{' '}
<strong>support@veza.fr</strong> sous 30 jours.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
6. Service marketplace
</h2>
<p>
Pour toute transaction commerciale (achat de licences, ventes
créateur, abonnements), les{' '}
<Link to="/legal/cgv" className="underline">
Conditions Générales de Vente
</Link>{' '}
s'appliquent en complément des présentes.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
7. Disponibilité &amp; responsabilité
</h2>
<p>
Le Service est fourni « en l'état », sans garantie de disponibilité
ininterrompue. Veza s'efforce d'assurer un uptime cible de 99,5 % en
annuel mais n'engage pas sa responsabilité en cas de perte de données
imputable à un cas de force majeure ou à un usage non conforme.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
8. Données personnelles
</h2>
<p>
Le traitement des données personnelles est décrit dans la{' '}
<Link to="/legal/privacy" className="underline">
politique de confidentialité
</Link>
. L'Utilisateur peut exercer ses droits RGPD (accès, rectification,
effacement, portabilité, opposition) à <strong>privacy@veza.fr</strong>.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
9. Modifications
</h2>
<p>
Toute modification des présentes CGU est notifiée à l'Utilisateur lors
de sa prochaine connexion par une fenêtre d'acceptation explicite.
Le refus d'accepter la nouvelle version empêche l'usage du Service.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
10. Droit applicable
</h2>
<p>
Les présentes CGU sont régies par le droit français. Tout litige relève
de la compétence des juridictions françaises, sous réserve des règles
impératives en matière de consommation pour les Utilisateurs résidant
dans l'Union européenne.
</p>
</div>
</section>
<footer
className="mt-16 pt-8 border-t"
style={{ borderColor: 'var(--sumi-border-faint)' }}
>
<p className="text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
<Link to="/legal/cgv" className="underline">
CGV
</Link>{' '}
·{' '}
<Link to="/legal/mentions" className="underline">
Mentions légales
</Link>{' '}
·{' '}
<Link to="/legal/privacy" className="underline">
Politique de confidentialité
</Link>{' '}
·{' '}
<Link to="/legal/dmca" className="underline">
DMCA
</Link>
</p>
</footer>
</div>
</main>
);
}

View file

@ -0,0 +1,225 @@
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
/**
* CGVPage Conditions Générales de Vente (marketplace).
*
* v1.0.10 légal item 3 initial draft. Match the version constant
* in `internal/services/terms_service.go` (CurrentTerms[TermsTypeCGV]).
* Counsel review required before v2.0.0 for jurisdictions where Veza
* operates marketplace flows (FR/EU primary).
*/
export const CGV_VERSION = '2026-04-30';
export default function CGVPage() {
useEffect(() => {
document.title = 'CGV — Veza Marketplace';
}, []);
return (
<main
style={{
backgroundColor: 'var(--sumi-bg-base)',
color: 'var(--sumi-text-primary)',
minHeight: '100vh',
}}
>
<div className="mx-auto max-w-3xl px-6 py-16 md:py-24">
<header className="mb-12">
<Link
to="/launch"
className="text-sm hover:underline"
style={{ color: 'var(--sumi-text-tertiary)' }}
>
Veza
</Link>
<h1 className="font-heading text-4xl md:text-5xl font-bold mt-6">
Conditions Générales de Vente
</h1>
<p className="mt-3 text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
Version {CGV_VERSION} · Marketplace Veza
</p>
</header>
<section className="space-y-8 leading-relaxed">
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
1. Champ d'application
</h2>
<p>
Les présentes Conditions Générales de Vente (CGV) s'appliquent à toute
transaction commerciale réalisée sur la place de marché Veza : achat
de pistes audio, packs de samples, prestations de service entre
utilisateurs (« Acheteur » et « Vendeur »). Les CGV complètent les{' '}
<Link to="/legal/cgu" className="underline">
CGU
</Link>
.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">2. Parties</h2>
<ul className="list-disc pl-6 space-y-2">
<li>
<strong>Vendeur</strong> : utilisateur disposant du rôle créateur
ayant complété son onboarding Stripe Connect (KYC).
</li>
<li>
<strong>Acheteur</strong> : tout utilisateur authentifié majeur ou
disposant d'un consentement parental conforme.
</li>
<li>
<strong>Veza</strong> : opérateur de la plateforme, intermédiaire
technique. Veza n'est pas vendeur de l'œuvre la transaction est
directe Vendeur Acheteur ; Veza perçoit une commission sur la
vente.
</li>
</ul>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
3. Prix &amp; commissions
</h2>
<p>
Le Vendeur fixe librement ses prix. La commission de Veza est de{' '}
<strong>15 % HT</strong> du prix de vente affiché, prélevée
automatiquement au moment du transfert. Le solde est crédité sur le
compte Stripe Connect du Vendeur après confirmation du paiement par
le PSP. Les prix affichés à l'Acheteur sont TTC.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
4. Paiement
</h2>
<p>
Les paiements sont traités par Hyperswitch / Stripe. Veza ne stocke
aucune donnée de carte bancaire. Devises supportées : EUR, USD, GBP.
Les frais de change éventuels sont à la charge de l'Acheteur.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
5. Droit de rétractation
</h2>
<p>
Conformément à l'article L221-28 du Code de la consommation
(français), pour les contenus numériques fournis sur support
immatériel et téléchargés immédiatement, le droit de rétractation
de 14 jours <strong>ne s'applique pas</strong> dès lors que
l'Acheteur a expressément consenti à la fourniture immédiate et a
renoncé à ce droit. Veza affiche cette mention au moment du
checkout et requiert un opt-in explicite.
</p>
<p className="mt-3">
Pour les pistes audio non encore téléchargées, l'Acheteur conserve
son droit de rétractation pendant 14 jours.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
6. Remboursements
</h2>
<ul className="list-disc pl-6 space-y-2">
<li>
Demande possible sous <strong>14 jours</strong> calendaires après la
transaction.
</li>
<li>
Acceptée si : œuvre non livrée techniquement, fichier corrompu,
violation manifeste des droits par le Vendeur (DMCA confirmée).
</li>
<li>
Refusée si : insatisfaction subjective sur la qualité audio (le
préview pré-achat permet de décider), changement d'avis post-
téléchargement.
</li>
<li>
Procédé : POST /orders/:id/refund. Délai de remboursement bancaire :
5 à 7 jours ouvrés selon le PSP.
</li>
</ul>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
7. Licences accordées
</h2>
<p>
L'achat d'une œuvre confère à l'Acheteur la licence définie sur la
fiche produit (streaming personnel, usage commercial, exclusivité,
etc.). Aucune licence n'autorise la redistribution sans accord
écrit complémentaire du Vendeur. Veza fournit un historique de
licence consultable depuis le profil Acheteur.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
8. TVA &amp; obligations fiscales
</h2>
<p>
Veza applique le régime de TVA OSS pour les ventes vers l'Union
européenne. Les Vendeurs assujettis fournissent leur numéro de TVA
intracommunautaire ; le calcul de la TVA finale est ajusté au pays
de l'Acheteur. Les Vendeurs sont responsables de leurs obligations
fiscales locales sur les revenus perçus via la plateforme.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
9. Litiges entre Acheteur et Vendeur
</h2>
<p>
En cas de litige, les parties s'efforcent d'abord de trouver une
résolution amiable via la messagerie Veza. À défaut, un médiateur
de la consommation peut être saisi (coordonnées sur demande à
support@veza.fr). En dernier recours, les juridictions
françaises sont compétentes.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
10. Modifications
</h2>
<p>
Toute modification matérielle des CGV est notifiée par fenêtre
d'acceptation explicite. Les transactions en cours sont régies par
la version applicable à la date de l'achat.
</p>
</div>
</section>
<footer
className="mt-16 pt-8 border-t"
style={{ borderColor: 'var(--sumi-border-faint)' }}
>
<p className="text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
<Link to="/legal/cgu" className="underline">
CGU
</Link>{' '}
·{' '}
<Link to="/legal/mentions" className="underline">
Mentions légales
</Link>{' '}
·{' '}
<Link to="/legal/privacy" className="underline">
Politique de confidentialité
</Link>{' '}
·{' '}
<Link to="/legal/dmca" className="underline">
DMCA
</Link>
</p>
</footer>
</div>
</main>
);
}

View file

@ -0,0 +1,190 @@
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
/**
* MentionsPage Mentions légales / impressum.
*
* v1.0.10 légal item 3 initial draft. Match the version constant in
* `internal/services/terms_service.go` (CurrentTerms[TermsTypeMentions]).
* Counsel review required before v2.0.0 French Loi pour la Confiance
* dans l'Économie Numérique (LCEN) imposes specific minimum disclosures.
*/
export const MENTIONS_VERSION = '2026-04-30';
export default function MentionsPage() {
useEffect(() => {
document.title = 'Mentions légales — Veza';
}, []);
return (
<main
style={{
backgroundColor: 'var(--sumi-bg-base)',
color: 'var(--sumi-text-primary)',
minHeight: '100vh',
}}
>
<div className="mx-auto max-w-3xl px-6 py-16 md:py-24">
<header className="mb-12">
<Link
to="/launch"
className="text-sm hover:underline"
style={{ color: 'var(--sumi-text-tertiary)' }}
>
Veza
</Link>
<h1 className="font-heading text-4xl md:text-5xl font-bold mt-6">
Mentions légales
</h1>
<p className="mt-3 text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
Version {MENTIONS_VERSION}
</p>
</header>
<section className="space-y-8 leading-relaxed">
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
Éditeur du site
</h2>
<p>
Veza<br />
<em style={{ color: 'var(--sumi-text-tertiary)' }}>
(Forme juridique, capital social, RCS, n° SIREN à compléter
avant v2.0.0)
</em>
<br />
Adresse postale : <em style={{ color: 'var(--sumi-text-tertiary)' }}>(à compléter)</em>
<br />
Email : <strong>contact@veza.fr</strong>
<br />
Téléphone : <em style={{ color: 'var(--sumi-text-tertiary)' }}>(à compléter)</em>
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
Directeur de la publication
</h2>
<p>
<em style={{ color: 'var(--sumi-text-tertiary)' }}>
(Nom et fonction du directeur de la publication à compléter)
</em>
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
Hébergement
</h2>
<p>
Le site est auto-hébergé sur infrastructure dédiée (Incus / R720 lab).
<br />
Pour toute requête liée à l'hébergement : <strong>infra@veza.fr</strong>
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
Propriété intellectuelle
</h2>
<p>
La structure générale du site, son design (SUMI design system) et les
composants techniques sont la propriété exclusive de Veza, à l'exception
des contributions tiers (logos partenaires, polices de caractères sous
licence) dont les droits restent à leurs détenteurs respectifs.
</p>
<p className="mt-3">
Les œuvres publiées par les utilisateurs (pistes audio, playlists,
commentaires) restent leur propriété pleine et entière. Veza dispose
d'une licence d'exploitation limitée définie dans les{' '}
<Link to="/legal/cgu" className="underline">
CGU
</Link>
.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
Signalement de contenu illicite
</h2>
<p>
Conformément à l'article 6 de la LCEN, tout contenu manifestement
illicite peut être signalé à <strong>abuse@veza.fr</strong> ou via le
formulaire <Link to="/legal/dmca/notice" className="underline">DMCA</Link>.
Veza traite les signalements sous 72 heures.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
Données personnelles
</h2>
<p>
Le traitement des données personnelles est régi par notre{' '}
<Link to="/legal/privacy" className="underline">
politique de confidentialité
</Link>
. Délégué à la protection des données :{' '}
<strong>privacy@veza.fr</strong>.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
Cookies
</h2>
<p>
Veza utilise des cookies strictement nécessaires (authentification,
sécurité) sans consentement préalable, et des cookies optionnels avec
opt-in via la bannière. Détails et choix dans la{' '}
<Link to="/legal/privacy" className="underline">
politique de confidentialité
</Link>
.
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-semibold mb-3">
Médiation de la consommation
</h2>
<p>
Conformément aux articles L.611-1 et suivants du Code de la consommation,
tout consommateur a la possibilité de saisir gratuitement un médiateur
en cas de litige non résolu après une réclamation préalable écrite à
<strong> support@veza.fr</strong>.
<br />
<em style={{ color: 'var(--sumi-text-tertiary)' }}>
(Coordonnées du médiateur agréé à compléter avant v2.0.0)
</em>
</p>
</div>
</section>
<footer
className="mt-16 pt-8 border-t"
style={{ borderColor: 'var(--sumi-border-faint)' }}
>
<p className="text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
<Link to="/legal/cgu" className="underline">
CGU
</Link>{' '}
·{' '}
<Link to="/legal/cgv" className="underline">
CGV
</Link>{' '}
·{' '}
<Link to="/legal/privacy" className="underline">
Politique de confidentialité
</Link>{' '}
·{' '}
<Link to="/legal/dmca" className="underline">
DMCA
</Link>
</p>
</footer>
</div>
</main>
);
}

View file

@ -54,6 +54,9 @@ import {
LazyDmca,
LazyDmcaNotice,
LazyPrivacy,
LazyCGU,
LazyCGV,
LazyMentions,
} from '@/components/ui/LazyComponent';
const LazyPrototype = React.lazy(() => import('@/features/prototype/PrototypePage'));
import { PublicRoute } from './PublicRoute';
@ -122,6 +125,9 @@ export function getPublicStandaloneRoutes(): RouteEntry[] {
{ path: '/legal/dmca', element: <ErrorBoundary><LazyDmca /></ErrorBoundary> },
{ path: '/legal/dmca/notice', element: <ErrorBoundary><LazyDmcaNotice /></ErrorBoundary> },
{ path: '/legal/privacy', element: <ErrorBoundary><LazyPrivacy /></ErrorBoundary> },
{ path: '/legal/cgu', element: <ErrorBoundary><LazyCGU /></ErrorBoundary> },
{ path: '/legal/cgv', element: <ErrorBoundary><LazyCGV /></ErrorBoundary> },
{ path: '/legal/mentions', element: <ErrorBoundary><LazyMentions /></ErrorBoundary> },
];
}

View file

@ -0,0 +1,47 @@
/**
* legalTerms REST client for the v1.0.10 légal item 3 endpoints.
*
* Two endpoints :
* GET /api/v1/legal/terms/current versions + per-user unaccepted set
* POST /api/v1/legal/terms/accept record acceptance, capture IP+UA
*
* Kept as a hand-written service (rather than orval-generated) because
* the response shape is small and stable, and the SPA wires the
* acceptance modal directly off the unaccepted array easier to read
* than an orval-wrapped React Query hook.
*/
import { apiClient } from './httpClient';
export type TermsType = 'cgu' | 'cgv' | 'mentions';
export interface CurrentVersionsResponse {
versions: Record<TermsType, string>;
unaccepted?: TermsType[];
}
export interface AcceptanceItem {
terms_type: TermsType;
version: string;
}
/**
* Fetch the live versions + (when authenticated) the user's unaccepted
* set. Safe to call from anywhere ; the backend tolerates missing auth
* and just returns versions only.
*/
export async function fetchCurrentTerms(): Promise<CurrentVersionsResponse> {
// The apiClient response interceptor unwraps the {success, data}
// envelope so r.data IS the inner payload (cf.
// services/api/orval-mutator.ts comment for context). Cast at the
// boundary instead of carrying the cast through every consumer.
const r = await apiClient.get<CurrentVersionsResponse>('/legal/terms/current');
return r.data as unknown as CurrentVersionsResponse;
}
/**
* Submit one or more (type, version) acceptances. Idempotent on the
* backend re-accepting the same tuple is a no-op.
*/
export async function acceptTerms(items: AcceptanceItem[]): Promise<void> {
await apiClient.post('/legal/terms/accept', { acceptances: items });
}

View file

@ -7,13 +7,18 @@ import (
"veza-backend-api/internal/services"
)
// setupLegalRoutes registers DMCA + future legal endpoints (counter-
// notices, GDPR data export requests, ToS dispute submissions).
// v1.0.9 W3 Day 14.
// setupLegalRoutes registers DMCA + terms-acceptance ledger + future
// legal endpoints (counter-notices, GDPR data export requests, ToS
// dispute submissions). v1.0.9 W3 Day 14 + v1.0.10 légal item 3.
//
// Public endpoint :
// Public endpoints :
//
// POST /api/v1/dmca/notice (rate-limited via global per-IP limiter)
// POST /api/v1/dmca/notice (rate-limited via global per-IP limiter)
// GET /api/v1/legal/terms/current (versions + per-user unaccepted set)
//
// Authenticated endpoint :
//
// POST /api/v1/legal/terms/accept (records acceptance with IP + UA evidence)
//
// Admin-only endpoints (auth + admin + MFA) :
//
@ -39,6 +44,32 @@ func (r *APIRouter) setupLegalRoutes(router *gin.RouterGroup) {
dmca.POST("/notice", dmcaHandler.SubmitNotice)
}
// v1.0.10 légal item 3 — terms acceptance ledger.
// `current` is public (the SPA fetches it on app load to know
// whether to show the AcceptanceModal). `accept` requires auth
// because we need a user_id to write the ledger row.
termsService := services.NewTermsService(r.db.GormDB, r.logger)
termsHandler := handlers.NewTermsHandler(termsService)
terms := router.Group("/legal/terms")
{
// `current` accepts an optional auth token : if present,
// the response includes the per-user unaccepted set ; if
// absent, only the live versions. The OptionalAuth
// middleware sets user_id when a valid bearer is supplied
// and silently drops bad ones. Falls back to the standard
// Auth middleware if OptionalAuth isn't wired.
if r.config != nil && r.config.AuthMiddleware != nil {
terms.GET("/current", r.config.AuthMiddleware.OptionalAuth(), termsHandler.GetCurrent)
terms.POST("/accept", r.config.AuthMiddleware.RequireAuth(), termsHandler.Accept)
} else {
// Test / no-middleware path : `current` still works
// (returns versions only), `accept` will 401 because
// GetUserIDUUID won't find a user_id.
terms.GET("/current", termsHandler.GetCurrent)
terms.POST("/accept", termsHandler.Accept)
}
}
// Admin queue + actions. Same auth chain as moderation routes.
admin := router.Group("/admin/dmca")
{

View file

@ -0,0 +1,141 @@
package handlers
// TermsHandler — REST surface for the v1.0.10 légal item 3
// terms-acceptance ledger. Two endpoints :
//
// GET /api/v1/legal/terms/current (public)
// Returns the live document versions and, if the caller is
// authenticated, the list of unaccepted classes. The SPA polls
// this on app load + on the AcceptanceModal opener.
//
// POST /api/v1/legal/terms/accept (authenticated)
// Body: { acceptances: [{ terms_type, version }, ...] }
// Records each tuple. Captures originating IP + UA from the
// request for legal evidence. Idempotent.
//
// Error semantics match the AppError pattern used elsewhere :
// 400 = client-side fixable (bad version, missing fields),
// 401 = not authenticated for the protected endpoint,
// 500 = DB or internal failure.
import (
"net/http"
"strings"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
)
// TermsHandler bundles the dependencies the two endpoints need.
type TermsHandler struct {
svc *services.TermsService
}
// NewTermsHandler constructs the handler with the shared service.
func NewTermsHandler(svc *services.TermsService) *TermsHandler {
return &TermsHandler{svc: svc}
}
// GetCurrent returns the live versions + (when authenticated) the
// list of unaccepted classes. The optional auth middleware sets
// `user_id` in the context ; an absent value is fine — we just skip
// the unaccepted check.
//
// @Summary Get current terms versions + per-user acceptance gap
// @Description Public. Returns {versions, unaccepted}. `unaccepted` is empty for anonymous callers.
// @Tags Legal
// @Produce json
// @Success 200 {object} services.CurrentVersionsResponse
// @Router /legal/terms/current [get]
func (h *TermsHandler) GetCurrent(c *gin.Context) {
// User ID is optional — the route may be called by guests.
userID, _ := GetUserIDUUID(c)
resp, err := h.svc.CurrentVersions(c.Request.Context(), userID)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to load terms versions", err))
return
}
RespondSuccess(c, http.StatusOK, resp)
}
// AcceptRequest is the body shape for POST /legal/terms/accept.
type AcceptRequest struct {
Acceptances []struct {
TermsType string `json:"terms_type" binding:"required"`
Version string `json:"version" binding:"required"`
} `json:"acceptances" binding:"required,min=1,dive"`
}
// Accept records the user's acceptance of the listed (type, version)
// pairs. The IP + user-agent at request time are persisted alongside
// for legal-evidence purposes.
//
// @Summary Accept one or more terms versions
// @Description Authenticated. Records (user, terms_type, version) tuples with IP + UA evidence. Idempotent.
// @Tags Legal
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body handlers.AcceptRequest true "Acceptance batch"
// @Success 200 {object} handlers.APIResponse{data=object{accepted=int}}
// @Failure 400 {object} handlers.APIResponse "Validation"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 500 {object} handlers.APIResponse "Internal"
// @Router /legal/terms/accept [post]
func (h *TermsHandler) Accept(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // helper already responded
}
var req AcceptRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid acceptance payload"))
return
}
// Capture evidence from the request — IP first non-private (the
// request may be behind a proxy ; ClientIP() handles
// X-Forwarded-For per Gin's TrustedProxies config).
ip := c.ClientIP()
ua := c.Request.UserAgent()
var ipPtr, uaPtr *string
if ip != "" {
ipPtr = &ip
}
if ua != "" {
// Truncate defensively — the column is VARCHAR(512). UA
// strings beyond that are abusive ; we want the row to fit.
if len(ua) > 512 {
ua = ua[:512]
}
uaPtr = &ua
}
inputs := make([]services.AcceptInput, 0, len(req.Acceptances))
for _, a := range req.Acceptances {
inputs = append(inputs, services.AcceptInput{
TermsType: services.TermsType(strings.ToLower(a.TermsType)),
Version: a.Version,
IPAddress: ipPtr,
UserAgent: uaPtr,
})
}
if err := h.svc.Accept(c.Request.Context(), userID, inputs); err != nil {
// Map the version-mismatch sentinel to 400 so the SPA can
// render "the document was updated, please re-read" without
// pattern-matching on the message.
if err == services.ErrTermsVersionMismatch {
RespondWithAppError(c, apperrors.NewValidationError(err.Error()))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to record terms acceptance", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"accepted": len(inputs)})
}

View file

@ -0,0 +1,218 @@
package services
// TermsService — RGPD-compliant terms-acceptance ledger (v1.0.10 légal 3).
//
// Three document classes are tracked : CGU (terms of service), CGV
// (sales terms — for marketplace buyers/sellers), Mentions (legal
// mentions/impressum). The privacy notice (CookieBanner + the
// `/legal/privacy` page in v1.0.10 légal 1) intentionally has no
// "accept" button — privacy notice is informational, not contractual.
//
// Architecture :
// - Current versions hardcoded as a struct constant in this file.
// Bump the constants when the text changes ; next call to
// NeedsAcceptance returns the unaccepted set.
// - One row per (user, terms_type, version) ; UNIQUE index prevents
// duplicate inserts on idempotent retries.
// - The `/api/v1/legal/terms/accept` handler captures IP + UA at
// accept time and writes them in the row — legal evidence.
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
// TermsType enumerates the document classes the platform tracks.
// Stored as VARCHAR (not enum) so adding a new class needs no schema
// migration ; just register it in `CurrentTerms` below.
type TermsType string
const (
TermsTypeCGU TermsType = "cgu"
TermsTypeCGV TermsType = "cgv"
TermsTypeMentions TermsType = "mentions"
)
// CurrentTerms holds the live version label per document class.
// Convention : ISO publication date (YYYY-MM-DD). Bump when the text
// changes — every existing user will be force-re-prompted on their
// next session.
//
// Keep these aligned with what the frontend pages display. Drift here
// is the legal contradiction the migration was designed to prevent ;
// guard with the integration test in service_test if added later.
var CurrentTerms = map[TermsType]string{
TermsTypeCGU: "2026-04-30",
TermsTypeCGV: "2026-04-30",
TermsTypeMentions: "2026-04-30",
}
// TermsAcceptance is the GORM model — one row per accepted document.
type TermsAcceptance struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_terms_acceptances_user_doc_version,unique" json:"user_id"`
TermsType string `gorm:"size:32;not null;index:idx_terms_acceptances_user_doc_version,unique;index:idx_terms_acceptances_doc_version" json:"terms_type"`
Version string `gorm:"size:32;not null;index:idx_terms_acceptances_user_doc_version,unique;index:idx_terms_acceptances_doc_version" json:"version"`
AcceptedAt time.Time `gorm:"not null;default:now()" json:"accepted_at"`
IPAddress *string `gorm:"type:inet" json:"ip_address,omitempty"`
UserAgent *string `gorm:"size:512" json:"user_agent,omitempty"`
}
// TableName overrides GORM's default pluralisation — the migration
// uses the explicit name `terms_acceptances`.
func (TermsAcceptance) TableName() string { return "terms_acceptances" }
// TermsService writes acceptance ledger rows and answers
// "what's still unaccepted ?" queries.
type TermsService struct {
db *gorm.DB
logger *zap.Logger
}
// NewTermsService constructs the service with the shared GORM handle
// and a structured logger.
func NewTermsService(db *gorm.DB, logger *zap.Logger) *TermsService {
return &TermsService{db: db, logger: logger}
}
// CurrentVersionsResponse is the public shape returned by
// GET /api/v1/legal/terms/current. The Unaccepted slice is empty for
// guests + for users who have already accepted every current version.
type CurrentVersionsResponse struct {
Versions map[TermsType]string `json:"versions"`
Unaccepted []TermsType `json:"unaccepted,omitempty"`
}
// CurrentVersions returns the platform's currently-published versions
// and, when userID is non-zero, the list of document classes the user
// hasn't yet accepted at the current version.
func (s *TermsService) CurrentVersions(ctx context.Context, userID uuid.UUID) (*CurrentVersionsResponse, error) {
resp := &CurrentVersionsResponse{
Versions: make(map[TermsType]string, len(CurrentTerms)),
}
for k, v := range CurrentTerms {
resp.Versions[k] = v
}
if userID == uuid.Nil {
return resp, nil
}
// Pull every (terms_type, version) the user has accepted. Cheap:
// 1-3 rows per user in steady state, indexed by user_id.
type acceptedRow struct {
TermsType string
Version string
}
var rows []acceptedRow
if err := s.db.WithContext(ctx).
Model(&TermsAcceptance{}).
Where("user_id = ?", userID).
Select("terms_type, version").
Scan(&rows).Error; err != nil {
return nil, err
}
accepted := make(map[TermsType]string, len(rows))
for _, r := range rows {
accepted[TermsType(r.TermsType)] = r.Version
}
for tt, current := range CurrentTerms {
if accepted[tt] != current {
resp.Unaccepted = append(resp.Unaccepted, tt)
}
}
return resp, nil
}
// AcceptInput captures the per-document accept payload. The handler
// builds one of these per accepted class ; the service writes them
// in a single transaction for atomicity.
type AcceptInput struct {
TermsType TermsType
Version string
IPAddress *string
UserAgent *string
}
// ErrTermsVersionMismatch is returned by Accept when the client
// supplies a version that doesn't match what the server considers
// current. Prevents stale-cache replays where a user clicks
// "accept" on yesterday's terms while today's are already published.
var ErrTermsVersionMismatch = errors.New("terms version mismatch — refresh the page and re-read the document")
// Accept records one or more terms acceptances for a user. Each
// (terms_type, version) is verified against CurrentTerms before
// insert ; mismatched submissions return ErrTermsVersionMismatch
// without writing anything. Idempotent — duplicate (user, type,
// version) tuples are absorbed by the unique index.
func (s *TermsService) Accept(ctx context.Context, userID uuid.UUID, inputs []AcceptInput) error {
if userID == uuid.Nil {
return errors.New("userID required")
}
if len(inputs) == 0 {
return errors.New("no acceptances provided")
}
// Server-side authoritative version check : even if the SPA
// thinks it's accepting current, only the server's CurrentTerms
// definition matters.
for _, in := range inputs {
current, ok := CurrentTerms[in.TermsType]
if !ok {
return errors.New("unknown terms_type: " + string(in.TermsType))
}
if in.Version != current {
s.logger.Warn("Terms accept rejected: version mismatch",
zap.String("user_id", userID.String()),
zap.String("terms_type", string(in.TermsType)),
zap.String("submitted", in.Version),
zap.String("current", current),
)
return ErrTermsVersionMismatch
}
}
// Single transaction so a partial failure leaves no half-state.
rows := make([]TermsAcceptance, 0, len(inputs))
now := time.Now().UTC()
for _, in := range inputs {
rows = append(rows, TermsAcceptance{
UserID: userID,
TermsType: string(in.TermsType),
Version: in.Version,
AcceptedAt: now,
IPAddress: in.IPAddress,
UserAgent: in.UserAgent,
})
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// `OnConflict DO NOTHING` makes the call idempotent : if the
// user already accepted this exact (type, version), we just
// no-op rather than 500 on a unique-violation. GORM doesn't
// expose ON CONFLICT directly across all dialects ; we use
// the Clause() form so this stays Postgres-friendly.
// The unique index on (user_id, terms_type, version) is what
// the conflict resolution targets.
for _, row := range rows {
if err := tx.
Where("user_id = ? AND terms_type = ? AND version = ?",
row.UserID, row.TermsType, row.Version).
FirstOrCreate(&row).Error; err != nil {
return err
}
}
s.logger.Info("Terms acceptances recorded",
zap.String("user_id", userID.String()),
zap.Int("count", len(rows)),
)
return nil
})
}

View file

@ -0,0 +1,62 @@
-- 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);