veza/veza-backend-api/internal/services/terms_service.go
senke c0e06e61b6 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 20:47:07 +02:00

218 lines
7.8 KiB
Go

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
})
}