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