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>
141 lines
4.9 KiB
Go
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)})
|
|
}
|