veza/veza-backend-api/internal/handlers/terms_handler.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

141 lines
4.9 KiB
Go

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