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>
218 lines
7.8 KiB
Go
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
|
|
})
|
|
}
|