Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1784 lines
52 KiB
Go
1784 lines
52 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/breakpilot/consent-service/internal/database"
|
|
"github.com/breakpilot/consent-service/internal/middleware"
|
|
"github.com/breakpilot/consent-service/internal/models"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Handler holds all HTTP handlers
|
|
type Handler struct {
|
|
db *database.DB
|
|
}
|
|
|
|
// New creates a new Handler instance
|
|
func New(db *database.DB) *Handler {
|
|
return &Handler{db: db}
|
|
}
|
|
|
|
// ========================================
|
|
// PUBLIC ENDPOINTS - Documents
|
|
// ========================================
|
|
|
|
// GetDocuments returns all active legal documents
|
|
func (h *Handler) GetDocuments(c *gin.Context) {
|
|
ctx := context.Background()
|
|
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
|
FROM legal_documents
|
|
WHERE is_active = true
|
|
ORDER BY sort_order ASC
|
|
`)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var documents []models.LegalDocument
|
|
for rows.Next() {
|
|
var doc models.LegalDocument
|
|
if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
|
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
|
|
continue
|
|
}
|
|
documents = append(documents, doc)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"documents": documents})
|
|
}
|
|
|
|
// GetDocumentByType returns a document by its type
|
|
func (h *Handler) GetDocumentByType(c *gin.Context) {
|
|
docType := c.Param("type")
|
|
ctx := context.Background()
|
|
|
|
var doc models.LegalDocument
|
|
err := h.db.Pool.QueryRow(ctx, `
|
|
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
|
FROM legal_documents
|
|
WHERE type = $1 AND is_active = true
|
|
`, docType).Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
|
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, doc)
|
|
}
|
|
|
|
// GetLatestDocumentVersion returns the latest published version of a document
|
|
func (h *Handler) GetLatestDocumentVersion(c *gin.Context) {
|
|
docType := c.Param("type")
|
|
language := c.DefaultQuery("language", "de")
|
|
ctx := context.Background()
|
|
|
|
var version models.DocumentVersion
|
|
err := h.db.Pool.QueryRow(ctx, `
|
|
SELECT dv.id, dv.document_id, dv.version, dv.language, dv.title, dv.content,
|
|
dv.summary, dv.status, dv.published_at, dv.created_at, dv.updated_at
|
|
FROM document_versions dv
|
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published'
|
|
ORDER BY dv.published_at DESC
|
|
LIMIT 1
|
|
`, docType, language).Scan(&version.ID, &version.DocumentID, &version.Version, &version.Language,
|
|
&version.Title, &version.Content, &version.Summary, &version.Status,
|
|
&version.PublishedAt, &version.CreatedAt, &version.UpdatedAt)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "No published version found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, version)
|
|
}
|
|
|
|
// ========================================
|
|
// PUBLIC ENDPOINTS - Consent
|
|
// ========================================
|
|
|
|
// CreateConsent creates a new user consent
|
|
func (h *Handler) CreateConsent(c *gin.Context) {
|
|
var req models.CreateConsentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
userID, err := middleware.GetUserID(c)
|
|
if err != nil || userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
return
|
|
}
|
|
|
|
versionID, err := uuid.Parse(req.VersionID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
ipAddress := middleware.GetClientIP(c)
|
|
userAgent := middleware.GetUserAgent(c)
|
|
|
|
// Upsert consent
|
|
var consentID uuid.UUID
|
|
err = h.db.Pool.QueryRow(ctx, `
|
|
INSERT INTO user_consents (user_id, document_version_id, consented, ip_address, user_agent)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (user_id, document_version_id)
|
|
DO UPDATE SET consented = $3, consented_at = NOW(), withdrawn_at = NULL
|
|
RETURNING id
|
|
`, userID, versionID, req.Consented, ipAddress, userAgent).Scan(&consentID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save consent"})
|
|
return
|
|
}
|
|
|
|
// Log to audit trail
|
|
h.logAudit(ctx, &userID, "consent_given", "document_version", &versionID, nil, ipAddress, userAgent)
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Consent saved successfully",
|
|
"consent_id": consentID,
|
|
})
|
|
}
|
|
|
|
// GetMyConsents returns all consents for the current user
|
|
func (h *Handler) GetMyConsents(c *gin.Context) {
|
|
userID, err := middleware.GetUserID(c)
|
|
if err != nil || userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT uc.id, uc.consented, uc.consented_at, uc.withdrawn_at,
|
|
ld.id, ld.type, ld.name, ld.is_mandatory,
|
|
dv.id, dv.version, dv.language, dv.title
|
|
FROM user_consents uc
|
|
JOIN document_versions dv ON uc.document_version_id = dv.id
|
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
WHERE uc.user_id = $1
|
|
ORDER BY uc.consented_at DESC
|
|
`, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch consents"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var consents []map[string]interface{}
|
|
for rows.Next() {
|
|
var (
|
|
consentID uuid.UUID
|
|
consented bool
|
|
consentedAt time.Time
|
|
withdrawnAt *time.Time
|
|
docID uuid.UUID
|
|
docType string
|
|
docName string
|
|
isMandatory bool
|
|
versionID uuid.UUID
|
|
version string
|
|
language string
|
|
title string
|
|
)
|
|
|
|
if err := rows.Scan(&consentID, &consented, &consentedAt, &withdrawnAt,
|
|
&docID, &docType, &docName, &isMandatory,
|
|
&versionID, &version, &language, &title); err != nil {
|
|
continue
|
|
}
|
|
|
|
consents = append(consents, map[string]interface{}{
|
|
"consent_id": consentID,
|
|
"consented": consented,
|
|
"consented_at": consentedAt,
|
|
"withdrawn_at": withdrawnAt,
|
|
"document": map[string]interface{}{
|
|
"id": docID,
|
|
"type": docType,
|
|
"name": docName,
|
|
"is_mandatory": isMandatory,
|
|
},
|
|
"version": map[string]interface{}{
|
|
"id": versionID,
|
|
"version": version,
|
|
"language": language,
|
|
"title": title,
|
|
},
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"consents": consents})
|
|
}
|
|
|
|
// CheckConsent checks if the user has consented to a document
|
|
func (h *Handler) CheckConsent(c *gin.Context) {
|
|
docType := c.Param("documentType")
|
|
language := c.DefaultQuery("language", "de")
|
|
|
|
userID, err := middleware.GetUserID(c)
|
|
if err != nil || userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Get latest published version
|
|
var latestVersionID uuid.UUID
|
|
var latestVersion string
|
|
err = h.db.Pool.QueryRow(ctx, `
|
|
SELECT dv.id, dv.version
|
|
FROM document_versions dv
|
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published'
|
|
ORDER BY dv.published_at DESC
|
|
LIMIT 1
|
|
`, docType, language).Scan(&latestVersionID, &latestVersion)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
|
HasConsent: false,
|
|
NeedsUpdate: false,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check if user has consented to this version
|
|
var consentedVersionID uuid.UUID
|
|
var consentedVersion string
|
|
var consentedAt time.Time
|
|
err = h.db.Pool.QueryRow(ctx, `
|
|
SELECT dv.id, dv.version, uc.consented_at
|
|
FROM user_consents uc
|
|
JOIN document_versions dv ON uc.document_version_id = dv.id
|
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
WHERE uc.user_id = $1 AND ld.type = $2 AND uc.consented = true AND uc.withdrawn_at IS NULL
|
|
ORDER BY uc.consented_at DESC
|
|
LIMIT 1
|
|
`, userID, docType).Scan(&consentedVersionID, &consentedVersion, &consentedAt)
|
|
|
|
if err != nil {
|
|
// No consent found
|
|
latestIDStr := latestVersionID.String()
|
|
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
|
HasConsent: false,
|
|
CurrentVersionID: &latestIDStr,
|
|
NeedsUpdate: true,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check if consent is for latest version
|
|
needsUpdate := consentedVersionID != latestVersionID
|
|
latestIDStr := latestVersionID.String()
|
|
consentedVerStr := consentedVersion
|
|
|
|
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
|
HasConsent: true,
|
|
CurrentVersionID: &latestIDStr,
|
|
ConsentedVersion: &consentedVerStr,
|
|
NeedsUpdate: needsUpdate,
|
|
ConsentedAt: &consentedAt,
|
|
})
|
|
}
|
|
|
|
// WithdrawConsent withdraws a consent
|
|
func (h *Handler) WithdrawConsent(c *gin.Context) {
|
|
consentID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid consent ID"})
|
|
return
|
|
}
|
|
|
|
userID, err := middleware.GetUserID(c)
|
|
if err != nil || userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
ipAddress := middleware.GetClientIP(c)
|
|
userAgent := middleware.GetUserAgent(c)
|
|
|
|
// Update consent
|
|
result, err := h.db.Pool.Exec(ctx, `
|
|
UPDATE user_consents
|
|
SET withdrawn_at = NOW(), consented = false
|
|
WHERE id = $1 AND user_id = $2
|
|
`, consentID, userID)
|
|
|
|
if err != nil || result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Consent not found"})
|
|
return
|
|
}
|
|
|
|
// Log to audit trail
|
|
h.logAudit(ctx, &userID, "consent_withdrawn", "consent", &consentID, nil, ipAddress, userAgent)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Consent withdrawn successfully"})
|
|
}
|
|
|
|
// ========================================
|
|
// PUBLIC ENDPOINTS - Cookie Consent
|
|
// ========================================
|
|
|
|
// GetCookieCategories returns all active cookie categories
|
|
func (h *Handler) GetCookieCategories(c *gin.Context) {
|
|
language := c.DefaultQuery("language", "de")
|
|
ctx := context.Background()
|
|
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT id, name, display_name_de, display_name_en, description_de, description_en,
|
|
is_mandatory, sort_order
|
|
FROM cookie_categories
|
|
WHERE is_active = true
|
|
ORDER BY sort_order ASC
|
|
`)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var categories []map[string]interface{}
|
|
for rows.Next() {
|
|
var cat models.CookieCategory
|
|
if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN,
|
|
&cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Return localized data
|
|
displayName := cat.DisplayNameDE
|
|
description := cat.DescriptionDE
|
|
if language == "en" && cat.DisplayNameEN != nil {
|
|
displayName = *cat.DisplayNameEN
|
|
if cat.DescriptionEN != nil {
|
|
description = cat.DescriptionEN
|
|
}
|
|
}
|
|
|
|
categories = append(categories, map[string]interface{}{
|
|
"id": cat.ID,
|
|
"name": cat.Name,
|
|
"display_name": displayName,
|
|
"description": description,
|
|
"is_mandatory": cat.IsMandatory,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
|
}
|
|
|
|
// SetCookieConsent sets cookie preferences for a user
|
|
func (h *Handler) SetCookieConsent(c *gin.Context) {
|
|
var req models.CookieConsentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
userID, err := middleware.GetUserID(c)
|
|
if err != nil || userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
ipAddress := middleware.GetClientIP(c)
|
|
userAgent := middleware.GetUserAgent(c)
|
|
|
|
// Process each category
|
|
for _, cat := range req.Categories {
|
|
categoryID, err := uuid.Parse(cat.CategoryID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
_, err = h.db.Pool.Exec(ctx, `
|
|
INSERT INTO cookie_consents (user_id, category_id, consented)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (user_id, category_id)
|
|
DO UPDATE SET consented = $3, updated_at = NOW()
|
|
`, userID, categoryID, cat.Consented)
|
|
|
|
if err != nil {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Log to audit trail
|
|
h.logAudit(ctx, &userID, "cookie_consent_updated", "cookie", nil, nil, ipAddress, userAgent)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Cookie preferences saved"})
|
|
}
|
|
|
|
// GetMyCookieConsent returns cookie preferences for the current user
|
|
func (h *Handler) GetMyCookieConsent(c *gin.Context) {
|
|
userID, err := middleware.GetUserID(c)
|
|
if err != nil || userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT cc.category_id, cc.consented, cc.updated_at,
|
|
cat.name, cat.display_name_de, cat.is_mandatory
|
|
FROM cookie_consents cc
|
|
JOIN cookie_categories cat ON cc.category_id = cat.id
|
|
WHERE cc.user_id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var consents []map[string]interface{}
|
|
for rows.Next() {
|
|
var (
|
|
categoryID uuid.UUID
|
|
consented bool
|
|
updatedAt time.Time
|
|
name string
|
|
displayName string
|
|
isMandatory bool
|
|
)
|
|
|
|
if err := rows.Scan(&categoryID, &consented, &updatedAt, &name, &displayName, &isMandatory); err != nil {
|
|
continue
|
|
}
|
|
|
|
consents = append(consents, map[string]interface{}{
|
|
"category_id": categoryID,
|
|
"name": name,
|
|
"display_name": displayName,
|
|
"consented": consented,
|
|
"is_mandatory": isMandatory,
|
|
"updated_at": updatedAt,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"cookie_consents": consents})
|
|
}
|
|
|
|
// ========================================
|
|
// GDPR / DATA SUBJECT RIGHTS
|
|
// ========================================
|
|
|
|
// GetMyData returns all data we have about the user
|
|
func (h *Handler) GetMyData(c *gin.Context) {
|
|
userID, err := middleware.GetUserID(c)
|
|
if err != nil || userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
ipAddress := middleware.GetClientIP(c)
|
|
userAgent := middleware.GetUserAgent(c)
|
|
|
|
// Get user info
|
|
var user models.User
|
|
err = h.db.Pool.QueryRow(ctx, `
|
|
SELECT id, external_id, email, role, created_at, updated_at
|
|
FROM users WHERE id = $1
|
|
`, userID).Scan(&user.ID, &user.ExternalID, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
|
|
|
// Get consents
|
|
consentRows, _ := h.db.Pool.Query(ctx, `
|
|
SELECT uc.consented, uc.consented_at, ld.type, ld.name, dv.version
|
|
FROM user_consents uc
|
|
JOIN document_versions dv ON uc.document_version_id = dv.id
|
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
WHERE uc.user_id = $1
|
|
`, userID)
|
|
defer consentRows.Close()
|
|
|
|
var consents []map[string]interface{}
|
|
for consentRows.Next() {
|
|
var consented bool
|
|
var consentedAt time.Time
|
|
var docType, docName, version string
|
|
consentRows.Scan(&consented, &consentedAt, &docType, &docName, &version)
|
|
consents = append(consents, map[string]interface{}{
|
|
"document_type": docType,
|
|
"document_name": docName,
|
|
"version": version,
|
|
"consented": consented,
|
|
"consented_at": consentedAt,
|
|
})
|
|
}
|
|
|
|
// Get cookie consents
|
|
cookieRows, _ := h.db.Pool.Query(ctx, `
|
|
SELECT cat.name, cc.consented, cc.updated_at
|
|
FROM cookie_consents cc
|
|
JOIN cookie_categories cat ON cc.category_id = cat.id
|
|
WHERE cc.user_id = $1
|
|
`, userID)
|
|
defer cookieRows.Close()
|
|
|
|
var cookieConsents []map[string]interface{}
|
|
for cookieRows.Next() {
|
|
var name string
|
|
var consented bool
|
|
var updatedAt time.Time
|
|
cookieRows.Scan(&name, &consented, &updatedAt)
|
|
cookieConsents = append(cookieConsents, map[string]interface{}{
|
|
"category": name,
|
|
"consented": consented,
|
|
"updated_at": updatedAt,
|
|
})
|
|
}
|
|
|
|
// Log data access
|
|
h.logAudit(ctx, &userID, "data_access", "user", &userID, nil, ipAddress, userAgent)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"user": map[string]interface{}{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"created_at": user.CreatedAt,
|
|
},
|
|
"consents": consents,
|
|
"cookie_consents": cookieConsents,
|
|
"exported_at": time.Now(),
|
|
})
|
|
}
|
|
|
|
// RequestDataExport creates a data export request
|
|
func (h *Handler) RequestDataExport(c *gin.Context) {
|
|
userID, err := middleware.GetUserID(c)
|
|
if err != nil || userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
ipAddress := middleware.GetClientIP(c)
|
|
userAgent := middleware.GetUserAgent(c)
|
|
|
|
var requestID uuid.UUID
|
|
err = h.db.Pool.QueryRow(ctx, `
|
|
INSERT INTO data_export_requests (user_id, status)
|
|
VALUES ($1, 'pending')
|
|
RETURNING id
|
|
`, userID).Scan(&requestID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export request"})
|
|
return
|
|
}
|
|
|
|
// Log to audit trail
|
|
h.logAudit(ctx, &userID, "data_export_requested", "export_request", &requestID, nil, ipAddress, userAgent)
|
|
|
|
c.JSON(http.StatusAccepted, gin.H{
|
|
"message": "Export request created. You will be notified when ready.",
|
|
"request_id": requestID,
|
|
})
|
|
}
|
|
|
|
// RequestDataDeletion creates a data deletion request
|
|
func (h *Handler) RequestDataDeletion(c *gin.Context) {
|
|
userID, err := middleware.GetUserID(c)
|
|
if err != nil || userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
c.ShouldBindJSON(&req)
|
|
|
|
ctx := context.Background()
|
|
ipAddress := middleware.GetClientIP(c)
|
|
userAgent := middleware.GetUserAgent(c)
|
|
|
|
var requestID uuid.UUID
|
|
err = h.db.Pool.QueryRow(ctx, `
|
|
INSERT INTO data_deletion_requests (user_id, status, reason)
|
|
VALUES ($1, 'pending', $2)
|
|
RETURNING id
|
|
`, userID, req.Reason).Scan(&requestID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deletion request"})
|
|
return
|
|
}
|
|
|
|
// Log to audit trail
|
|
h.logAudit(ctx, &userID, "data_deletion_requested", "deletion_request", &requestID, nil, ipAddress, userAgent)
|
|
|
|
c.JSON(http.StatusAccepted, gin.H{
|
|
"message": "Deletion request created. We will process your request within 30 days.",
|
|
"request_id": requestID,
|
|
})
|
|
}
|
|
|
|
// ========================================
|
|
// ADMIN ENDPOINTS - Document Management
|
|
// ========================================
|
|
|
|
// AdminGetDocuments returns all documents (including inactive) for admin
|
|
func (h *Handler) AdminGetDocuments(c *gin.Context) {
|
|
ctx := context.Background()
|
|
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
|
FROM legal_documents
|
|
ORDER BY sort_order ASC, created_at DESC
|
|
`)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var documents []models.LegalDocument
|
|
for rows.Next() {
|
|
var doc models.LegalDocument
|
|
if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
|
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
|
|
continue
|
|
}
|
|
documents = append(documents, doc)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"documents": documents})
|
|
}
|
|
|
|
// AdminCreateDocument creates a new legal document
|
|
func (h *Handler) AdminCreateDocument(c *gin.Context) {
|
|
var req models.CreateDocumentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var docID uuid.UUID
|
|
err := h.db.Pool.QueryRow(ctx, `
|
|
INSERT INTO legal_documents (type, name, description, is_mandatory)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, req.Type, req.Name, req.Description, req.IsMandatory).Scan(&docID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Document created successfully",
|
|
"id": docID,
|
|
})
|
|
}
|
|
|
|
// AdminUpdateDocument updates a legal document
|
|
func (h *Handler) AdminUpdateDocument(c *gin.Context) {
|
|
docID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
IsMandatory *bool `json:"is_mandatory"`
|
|
IsActive *bool `json:"is_active"`
|
|
SortOrder *int `json:"sort_order"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
result, err := h.db.Pool.Exec(ctx, `
|
|
UPDATE legal_documents
|
|
SET name = COALESCE($2, name),
|
|
description = COALESCE($3, description),
|
|
is_mandatory = COALESCE($4, is_mandatory),
|
|
is_active = COALESCE($5, is_active),
|
|
sort_order = COALESCE($6, sort_order),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, docID, req.Name, req.Description, req.IsMandatory, req.IsActive, req.SortOrder)
|
|
|
|
if err != nil || result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Document updated successfully"})
|
|
}
|
|
|
|
// AdminDeleteDocument soft-deletes a document (sets is_active to false)
|
|
func (h *Handler) AdminDeleteDocument(c *gin.Context) {
|
|
docID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
result, err := h.db.Pool.Exec(ctx, `
|
|
UPDATE legal_documents
|
|
SET is_active = false, updated_at = NOW()
|
|
WHERE id = $1
|
|
`, docID)
|
|
|
|
if err != nil || result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"})
|
|
}
|
|
|
|
// ========================================
|
|
// ADMIN ENDPOINTS - Version Management
|
|
// ========================================
|
|
|
|
// AdminGetVersions returns all versions for a document
|
|
func (h *Handler) AdminGetVersions(c *gin.Context) {
|
|
docID, err := uuid.Parse(c.Param("docId"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT id, document_id, version, language, title, content, summary, status,
|
|
published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at
|
|
FROM document_versions
|
|
WHERE document_id = $1
|
|
ORDER BY created_at DESC
|
|
`, docID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var versions []models.DocumentVersion
|
|
for rows.Next() {
|
|
var v models.DocumentVersion
|
|
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Language, &v.Title, &v.Content,
|
|
&v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
|
continue
|
|
}
|
|
versions = append(versions, v)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"versions": versions})
|
|
}
|
|
|
|
// AdminCreateVersion creates a new document version
|
|
func (h *Handler) AdminCreateVersion(c *gin.Context) {
|
|
var req models.CreateVersionRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
docID, err := uuid.Parse(req.DocumentID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
|
return
|
|
}
|
|
|
|
userID, _ := middleware.GetUserID(c)
|
|
ctx := context.Background()
|
|
|
|
var versionID uuid.UUID
|
|
err = h.db.Pool.QueryRow(ctx, `
|
|
INSERT INTO document_versions (document_id, version, language, title, content, summary, status, created_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7)
|
|
RETURNING id
|
|
`, docID, req.Version, req.Language, req.Title, req.Content, req.Summary, userID).Scan(&versionID)
|
|
|
|
if err != nil {
|
|
// Check for unique constraint violation
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "unique constraint") {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Eine Version mit dieser Versionsnummer und Sprache existiert bereits für dieses Dokument"})
|
|
return
|
|
}
|
|
// Log the actual error for debugging
|
|
fmt.Printf("POST /api/v1/admin/versions ✗ %v\n", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version: " + errStr})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Version created successfully",
|
|
"id": versionID,
|
|
})
|
|
}
|
|
|
|
// AdminUpdateVersion updates a document version
|
|
func (h *Handler) AdminUpdateVersion(c *gin.Context) {
|
|
versionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
return
|
|
}
|
|
|
|
var req models.UpdateVersionRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Check if version is in draft or review status (only these can be edited)
|
|
var status string
|
|
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
return
|
|
}
|
|
|
|
if status != "draft" && status != "review" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft or review versions can be edited"})
|
|
return
|
|
}
|
|
|
|
result, err := h.db.Pool.Exec(ctx, `
|
|
UPDATE document_versions
|
|
SET title = COALESCE($2, title),
|
|
content = COALESCE($3, content),
|
|
summary = COALESCE($4, summary),
|
|
status = COALESCE($5, status),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, versionID, req.Title, req.Content, req.Summary, req.Status)
|
|
|
|
if err != nil || result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update version"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Version updated successfully"})
|
|
}
|
|
|
|
// AdminPublishVersion publishes a document version
|
|
func (h *Handler) AdminPublishVersion(c *gin.Context) {
|
|
versionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
return
|
|
}
|
|
|
|
userID, _ := middleware.GetUserID(c)
|
|
ctx := context.Background()
|
|
|
|
// Check current status
|
|
var status string
|
|
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
return
|
|
}
|
|
|
|
if status != "approved" && status != "review" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only approved or review versions can be published"})
|
|
return
|
|
}
|
|
|
|
result, err := h.db.Pool.Exec(ctx, `
|
|
UPDATE document_versions
|
|
SET status = 'published',
|
|
published_at = NOW(),
|
|
approved_by = $2,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, versionID, userID)
|
|
|
|
if err != nil || result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Version published successfully"})
|
|
}
|
|
|
|
// AdminArchiveVersion archives a document version
|
|
func (h *Handler) AdminArchiveVersion(c *gin.Context) {
|
|
versionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
result, err := h.db.Pool.Exec(ctx, `
|
|
UPDATE document_versions
|
|
SET status = 'archived', updated_at = NOW()
|
|
WHERE id = $1
|
|
`, versionID)
|
|
|
|
if err != nil || result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Version archived successfully"})
|
|
}
|
|
|
|
// AdminDeleteVersion permanently deletes a draft/rejected version
|
|
// Only draft and rejected versions can be deleted. Published versions must be archived.
|
|
func (h *Handler) AdminDeleteVersion(c *gin.Context) {
|
|
versionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// First check the version status - only draft/rejected can be deleted
|
|
var status string
|
|
var version string
|
|
var docID uuid.UUID
|
|
err = h.db.Pool.QueryRow(ctx, `
|
|
SELECT status, version, document_id FROM document_versions WHERE id = $1
|
|
`, versionID).Scan(&status, &version, &docID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
return
|
|
}
|
|
|
|
// Only allow deletion of draft and rejected versions
|
|
if status != "draft" && status != "rejected" {
|
|
c.JSON(http.StatusForbidden, gin.H{
|
|
"error": "Cannot delete version",
|
|
"message": "Only draft or rejected versions can be deleted. Published versions must be archived instead.",
|
|
"status": status,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Delete the version
|
|
result, err := h.db.Pool.Exec(ctx, `
|
|
DELETE FROM document_versions WHERE id = $1
|
|
`, versionID)
|
|
|
|
if err != nil || result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete version"})
|
|
return
|
|
}
|
|
|
|
// Log the deletion
|
|
userID, _ := c.Get("user_id")
|
|
h.db.Pool.Exec(ctx, `
|
|
INSERT INTO consent_audit_log (action, entity_type, entity_id, user_id, details, ip_address, user_agent)
|
|
VALUES ('version_deleted', 'document_version', $1, $2, $3, $4, $5)
|
|
`, versionID, userID, "Version "+version+" permanently deleted", c.ClientIP(), c.Request.UserAgent())
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Version deleted successfully",
|
|
"deleted_version": version,
|
|
"version_id": versionID,
|
|
})
|
|
}
|
|
|
|
// ========================================
|
|
// ADMIN ENDPOINTS - Cookie Categories
|
|
// ========================================
|
|
|
|
// AdminGetCookieCategories returns all cookie categories
|
|
func (h *Handler) AdminGetCookieCategories(c *gin.Context) {
|
|
ctx := context.Background()
|
|
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT id, name, display_name_de, display_name_en, description_de, description_en,
|
|
is_mandatory, sort_order, is_active, created_at, updated_at
|
|
FROM cookie_categories
|
|
ORDER BY sort_order ASC
|
|
`)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var categories []models.CookieCategory
|
|
for rows.Next() {
|
|
var cat models.CookieCategory
|
|
if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN,
|
|
&cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder,
|
|
&cat.IsActive, &cat.CreatedAt, &cat.UpdatedAt); err != nil {
|
|
continue
|
|
}
|
|
categories = append(categories, cat)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
|
}
|
|
|
|
// AdminCreateCookieCategory creates a new cookie category
|
|
func (h *Handler) AdminCreateCookieCategory(c *gin.Context) {
|
|
var req models.CreateCookieCategoryRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var catID uuid.UUID
|
|
err := h.db.Pool.QueryRow(ctx, `
|
|
INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id
|
|
`, req.Name, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, req.IsMandatory, req.SortOrder).Scan(&catID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Cookie category created successfully",
|
|
"id": catID,
|
|
})
|
|
}
|
|
|
|
// AdminUpdateCookieCategory updates a cookie category
|
|
func (h *Handler) AdminUpdateCookieCategory(c *gin.Context) {
|
|
catID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
DisplayNameDE *string `json:"display_name_de"`
|
|
DisplayNameEN *string `json:"display_name_en"`
|
|
DescriptionDE *string `json:"description_de"`
|
|
DescriptionEN *string `json:"description_en"`
|
|
IsMandatory *bool `json:"is_mandatory"`
|
|
SortOrder *int `json:"sort_order"`
|
|
IsActive *bool `json:"is_active"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
result, err := h.db.Pool.Exec(ctx, `
|
|
UPDATE cookie_categories
|
|
SET display_name_de = COALESCE($2, display_name_de),
|
|
display_name_en = COALESCE($3, display_name_en),
|
|
description_de = COALESCE($4, description_de),
|
|
description_en = COALESCE($5, description_en),
|
|
is_mandatory = COALESCE($6, is_mandatory),
|
|
sort_order = COALESCE($7, sort_order),
|
|
is_active = COALESCE($8, is_active),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, catID, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN,
|
|
req.IsMandatory, req.SortOrder, req.IsActive)
|
|
|
|
if err != nil || result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Cookie category updated successfully"})
|
|
}
|
|
|
|
// AdminDeleteCookieCategory soft-deletes a cookie category
|
|
func (h *Handler) AdminDeleteCookieCategory(c *gin.Context) {
|
|
catID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
result, err := h.db.Pool.Exec(ctx, `
|
|
UPDATE cookie_categories
|
|
SET is_active = false, updated_at = NOW()
|
|
WHERE id = $1
|
|
`, catID)
|
|
|
|
if err != nil || result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Cookie category deleted successfully"})
|
|
}
|
|
|
|
// ========================================
|
|
// ADMIN ENDPOINTS - Statistics & Audit
|
|
// ========================================
|
|
|
|
// GetConsentStats returns consent statistics
|
|
func (h *Handler) GetConsentStats(c *gin.Context) {
|
|
ctx := context.Background()
|
|
docType := c.Query("document_type")
|
|
|
|
var stats models.ConsentStats
|
|
|
|
// Total users
|
|
h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
|
|
|
// Consented users (with active consent)
|
|
query := `
|
|
SELECT COUNT(DISTINCT uc.user_id)
|
|
FROM user_consents uc
|
|
JOIN document_versions dv ON uc.document_version_id = dv.id
|
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
WHERE uc.consented = true AND uc.withdrawn_at IS NULL
|
|
`
|
|
if docType != "" {
|
|
query += ` AND ld.type = $1`
|
|
h.db.Pool.QueryRow(ctx, query, docType).Scan(&stats.ConsentedUsers)
|
|
} else {
|
|
h.db.Pool.QueryRow(ctx, query).Scan(&stats.ConsentedUsers)
|
|
}
|
|
|
|
// Calculate consent rate
|
|
if stats.TotalUsers > 0 {
|
|
stats.ConsentRate = float64(stats.ConsentedUsers) / float64(stats.TotalUsers) * 100
|
|
}
|
|
|
|
// Recent consents (last 7 days)
|
|
h.db.Pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM user_consents
|
|
WHERE consented = true AND consented_at > NOW() - INTERVAL '7 days'
|
|
`).Scan(&stats.RecentConsents)
|
|
|
|
// Recent withdrawals
|
|
h.db.Pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM user_consents
|
|
WHERE withdrawn_at IS NOT NULL AND withdrawn_at > NOW() - INTERVAL '7 days'
|
|
`).Scan(&stats.RecentWithdrawals)
|
|
|
|
c.JSON(http.StatusOK, stats)
|
|
}
|
|
|
|
// GetCookieStats returns cookie consent statistics
|
|
func (h *Handler) GetCookieStats(c *gin.Context) {
|
|
ctx := context.Background()
|
|
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT cat.name,
|
|
COUNT(DISTINCT u.id) as total_users,
|
|
COUNT(DISTINCT CASE WHEN cc.consented = true THEN cc.user_id END) as consented_users
|
|
FROM cookie_categories cat
|
|
CROSS JOIN users u
|
|
LEFT JOIN cookie_consents cc ON cat.id = cc.category_id AND u.id = cc.user_id
|
|
WHERE cat.is_active = true
|
|
GROUP BY cat.id, cat.name
|
|
ORDER BY cat.sort_order
|
|
`)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch stats"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var stats []models.CookieStats
|
|
for rows.Next() {
|
|
var s models.CookieStats
|
|
if err := rows.Scan(&s.Category, &s.TotalUsers, &s.ConsentedUsers); err != nil {
|
|
continue
|
|
}
|
|
if s.TotalUsers > 0 {
|
|
s.ConsentRate = float64(s.ConsentedUsers) / float64(s.TotalUsers) * 100
|
|
}
|
|
stats = append(stats, s)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"cookie_stats": stats})
|
|
}
|
|
|
|
// GetAuditLog returns audit log entries
|
|
func (h *Handler) GetAuditLog(c *gin.Context) {
|
|
ctx := context.Background()
|
|
|
|
// Pagination
|
|
limit := 50
|
|
offset := 0
|
|
if l := c.Query("limit"); l != "" {
|
|
if parsed, err := parseIntFromQuery(l); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
if o := c.Query("offset"); o != "" {
|
|
if parsed, err := parseIntFromQuery(o); err == nil && parsed >= 0 {
|
|
offset = parsed
|
|
}
|
|
}
|
|
|
|
// Filters
|
|
userIDFilter := c.Query("user_id")
|
|
actionFilter := c.Query("action")
|
|
|
|
query := `
|
|
SELECT al.id, al.user_id, al.action, al.entity_type, al.entity_id, al.details,
|
|
al.ip_address, al.user_agent, al.created_at, u.email
|
|
FROM consent_audit_log al
|
|
LEFT JOIN users u ON al.user_id = u.id
|
|
WHERE 1=1
|
|
`
|
|
args := []interface{}{}
|
|
argCount := 0
|
|
|
|
if userIDFilter != "" {
|
|
argCount++
|
|
query += fmt.Sprintf(" AND al.user_id = $%d", argCount)
|
|
args = append(args, userIDFilter)
|
|
}
|
|
if actionFilter != "" {
|
|
argCount++
|
|
query += fmt.Sprintf(" AND al.action = $%d", argCount)
|
|
args = append(args, actionFilter)
|
|
}
|
|
|
|
query += fmt.Sprintf(" ORDER BY al.created_at DESC LIMIT $%d OFFSET $%d", argCount+1, argCount+2)
|
|
args = append(args, limit, offset)
|
|
|
|
rows, err := h.db.Pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit log"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var logs []map[string]interface{}
|
|
for rows.Next() {
|
|
var (
|
|
id uuid.UUID
|
|
userIDPtr *uuid.UUID
|
|
action string
|
|
entityType *string
|
|
entityID *uuid.UUID
|
|
details *string
|
|
ipAddress *string
|
|
userAgent *string
|
|
createdAt time.Time
|
|
email *string
|
|
)
|
|
|
|
if err := rows.Scan(&id, &userIDPtr, &action, &entityType, &entityID, &details,
|
|
&ipAddress, &userAgent, &createdAt, &email); err != nil {
|
|
continue
|
|
}
|
|
|
|
logs = append(logs, map[string]interface{}{
|
|
"id": id,
|
|
"user_id": userIDPtr,
|
|
"user_email": email,
|
|
"action": action,
|
|
"entity_type": entityType,
|
|
"entity_id": entityID,
|
|
"details": details,
|
|
"ip_address": ipAddress,
|
|
"user_agent": userAgent,
|
|
"created_at": createdAt,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"audit_log": logs})
|
|
}
|
|
|
|
// ========================================
|
|
// ADMIN ENDPOINTS - Version Approval Workflow (DSB)
|
|
// ========================================
|
|
|
|
// AdminSubmitForReview submits a version for DSB review
|
|
func (h *Handler) AdminSubmitForReview(c *gin.Context) {
|
|
versionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
return
|
|
}
|
|
|
|
userID, _ := middleware.GetUserID(c)
|
|
ctx := context.Background()
|
|
ipAddress := middleware.GetClientIP(c)
|
|
userAgent := middleware.GetUserAgent(c)
|
|
|
|
// Check current status
|
|
var status string
|
|
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
return
|
|
}
|
|
|
|
if status != "draft" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft versions can be submitted for review"})
|
|
return
|
|
}
|
|
|
|
// Update status to review
|
|
_, err = h.db.Pool.Exec(ctx, `
|
|
UPDATE document_versions
|
|
SET status = 'review', updated_at = NOW()
|
|
WHERE id = $1
|
|
`, versionID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit for review"})
|
|
return
|
|
}
|
|
|
|
// Log approval action
|
|
_, err = h.db.Pool.Exec(ctx, `
|
|
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
|
VALUES ($1, $2, 'submitted', 'Submitted for DSB review')
|
|
`, versionID, userID)
|
|
|
|
h.logAudit(ctx, &userID, "version_submitted_review", "document_version", &versionID, nil, ipAddress, userAgent)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Version submitted for review"})
|
|
}
|
|
|
|
// AdminApproveVersion approves a version with scheduled publish date (DSB only)
|
|
func (h *Handler) AdminApproveVersion(c *gin.Context) {
|
|
versionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
return
|
|
}
|
|
|
|
// Check if user is DSB or Admin (for dev purposes)
|
|
if !middleware.IsDSB(c) && !middleware.IsAdmin(c) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can approve versions"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Comment string `json:"comment"`
|
|
ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601: "2026-01-01T00:00:00Z"
|
|
}
|
|
c.ShouldBindJSON(&req)
|
|
|
|
// Validate scheduled publish date
|
|
var scheduledAt *time.Time
|
|
if req.ScheduledPublishAt != nil && *req.ScheduledPublishAt != "" {
|
|
parsed, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scheduled_publish_at format. Use ISO 8601 (e.g., 2026-01-01T00:00:00Z)"})
|
|
return
|
|
}
|
|
if parsed.Before(time.Now()) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Scheduled publish date must be in the future"})
|
|
return
|
|
}
|
|
scheduledAt = &parsed
|
|
}
|
|
|
|
userID, _ := middleware.GetUserID(c)
|
|
ctx := context.Background()
|
|
ipAddress := middleware.GetClientIP(c)
|
|
userAgent := middleware.GetUserAgent(c)
|
|
|
|
// Check current status
|
|
var status string
|
|
var createdBy *uuid.UUID
|
|
err = h.db.Pool.QueryRow(ctx, `SELECT status, created_by FROM document_versions WHERE id = $1`, versionID).Scan(&status, &createdBy)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
return
|
|
}
|
|
|
|
if status != "review" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review status can be approved"})
|
|
return
|
|
}
|
|
|
|
// Four-eyes principle: DSB cannot approve their own version
|
|
// Exception: Admins can approve their own versions for development/testing purposes
|
|
role, _ := c.Get("role")
|
|
roleStr, _ := role.(string)
|
|
if createdBy != nil && *createdBy == userID && roleStr != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "You cannot approve your own version (four-eyes principle)"})
|
|
return
|
|
}
|
|
|
|
// Determine new status: 'scheduled' if date set, otherwise 'approved'
|
|
newStatus := "approved"
|
|
if scheduledAt != nil {
|
|
newStatus = "scheduled"
|
|
}
|
|
|
|
// Update status to approved/scheduled
|
|
_, err = h.db.Pool.Exec(ctx, `
|
|
UPDATE document_versions
|
|
SET status = $2, approved_by = $3, approved_at = NOW(), scheduled_publish_at = $4, updated_at = NOW()
|
|
WHERE id = $1
|
|
`, versionID, newStatus, userID, scheduledAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve version"})
|
|
return
|
|
}
|
|
|
|
// Log approval action
|
|
comment := req.Comment
|
|
if comment == "" {
|
|
if scheduledAt != nil {
|
|
comment = "Approved by DSB, scheduled for " + scheduledAt.Format("02.01.2006 15:04")
|
|
} else {
|
|
comment = "Approved by DSB"
|
|
}
|
|
}
|
|
_, err = h.db.Pool.Exec(ctx, `
|
|
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
|
VALUES ($1, $2, 'approved', $3)
|
|
`, versionID, userID, comment)
|
|
|
|
h.logAudit(ctx, &userID, "version_approved", "document_version", &versionID, &comment, ipAddress, userAgent)
|
|
|
|
response := gin.H{"message": "Version approved", "status": newStatus}
|
|
if scheduledAt != nil {
|
|
response["scheduled_publish_at"] = scheduledAt.Format(time.RFC3339)
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// AdminRejectVersion rejects a version (DSB only)
|
|
func (h *Handler) AdminRejectVersion(c *gin.Context) {
|
|
versionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
return
|
|
}
|
|
|
|
// Check if user is DSB
|
|
if !middleware.IsDSB(c) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can reject versions"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Comment string `json:"comment" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Comment is required when rejecting"})
|
|
return
|
|
}
|
|
|
|
userID, _ := middleware.GetUserID(c)
|
|
ctx := context.Background()
|
|
ipAddress := middleware.GetClientIP(c)
|
|
userAgent := middleware.GetUserAgent(c)
|
|
|
|
// Check current status
|
|
var status string
|
|
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
return
|
|
}
|
|
|
|
if status != "review" && status != "approved" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review or approved status can be rejected"})
|
|
return
|
|
}
|
|
|
|
// Update status back to draft
|
|
_, err = h.db.Pool.Exec(ctx, `
|
|
UPDATE document_versions
|
|
SET status = 'draft', approved_by = NULL, updated_at = NOW()
|
|
WHERE id = $1
|
|
`, versionID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject version"})
|
|
return
|
|
}
|
|
|
|
// Log rejection
|
|
_, err = h.db.Pool.Exec(ctx, `
|
|
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
|
VALUES ($1, $2, 'rejected', $3)
|
|
`, versionID, userID, req.Comment)
|
|
|
|
h.logAudit(ctx, &userID, "version_rejected", "document_version", &versionID, &req.Comment, ipAddress, userAgent)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Version rejected and returned to draft"})
|
|
}
|
|
|
|
// AdminCompareVersions returns two versions for side-by-side comparison
|
|
func (h *Handler) AdminCompareVersions(c *gin.Context) {
|
|
versionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Get the current version and its document
|
|
var currentVersion models.DocumentVersion
|
|
var documentID uuid.UUID
|
|
err = h.db.Pool.QueryRow(ctx, `
|
|
SELECT id, document_id, version, language, title, content, summary, status, created_at, updated_at
|
|
FROM document_versions
|
|
WHERE id = $1
|
|
`, versionID).Scan(¤tVersion.ID, &documentID, ¤tVersion.Version, ¤tVersion.Language,
|
|
¤tVersion.Title, ¤tVersion.Content, ¤tVersion.Summary, ¤tVersion.Status,
|
|
¤tVersion.CreatedAt, ¤tVersion.UpdatedAt)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
return
|
|
}
|
|
|
|
// Get the currently published version (if any)
|
|
var publishedVersion *models.DocumentVersion
|
|
var pv models.DocumentVersion
|
|
err = h.db.Pool.QueryRow(ctx, `
|
|
SELECT id, document_id, version, language, title, content, summary, status, published_at, created_at, updated_at
|
|
FROM document_versions
|
|
WHERE document_id = $1 AND language = $2 AND status = 'published'
|
|
ORDER BY published_at DESC
|
|
LIMIT 1
|
|
`, documentID, currentVersion.Language).Scan(&pv.ID, &pv.DocumentID, &pv.Version, &pv.Language,
|
|
&pv.Title, &pv.Content, &pv.Summary, &pv.Status, &pv.PublishedAt, &pv.CreatedAt, &pv.UpdatedAt)
|
|
|
|
if err == nil && pv.ID != currentVersion.ID {
|
|
publishedVersion = &pv
|
|
}
|
|
|
|
// Get approval history
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT va.action, va.comment, va.created_at, u.email
|
|
FROM version_approvals va
|
|
LEFT JOIN users u ON va.approver_id = u.id
|
|
WHERE va.version_id = $1
|
|
ORDER BY va.created_at DESC
|
|
`, versionID)
|
|
|
|
var approvalHistory []map[string]interface{}
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var action, email string
|
|
var comment *string
|
|
var createdAt time.Time
|
|
if err := rows.Scan(&action, &comment, &createdAt, &email); err == nil {
|
|
approvalHistory = append(approvalHistory, map[string]interface{}{
|
|
"action": action,
|
|
"comment": comment,
|
|
"created_at": createdAt,
|
|
"approver": email,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"current_version": currentVersion,
|
|
"published_version": publishedVersion,
|
|
"approval_history": approvalHistory,
|
|
})
|
|
}
|
|
|
|
// AdminGetApprovalHistory returns the approval history for a version
|
|
func (h *Handler) AdminGetApprovalHistory(c *gin.Context) {
|
|
versionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT va.id, va.action, va.comment, va.created_at, u.email, u.name
|
|
FROM version_approvals va
|
|
LEFT JOIN users u ON va.approver_id = u.id
|
|
WHERE va.version_id = $1
|
|
ORDER BY va.created_at DESC
|
|
`, versionID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch approval history"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var history []map[string]interface{}
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var action string
|
|
var comment *string
|
|
var createdAt time.Time
|
|
var email, name *string
|
|
|
|
if err := rows.Scan(&id, &action, &comment, &createdAt, &email, &name); err != nil {
|
|
continue
|
|
}
|
|
|
|
history = append(history, map[string]interface{}{
|
|
"id": id,
|
|
"action": action,
|
|
"comment": comment,
|
|
"created_at": createdAt,
|
|
"approver": email,
|
|
"name": name,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"approval_history": history})
|
|
}
|
|
|
|
// ========================================
|
|
// HELPER FUNCTIONS
|
|
// ========================================
|
|
|
|
func (h *Handler) logAudit(ctx context.Context, userID *uuid.UUID, action, entityType string, entityID *uuid.UUID, details *string, ipAddress, userAgent string) {
|
|
h.db.Pool.Exec(ctx, `
|
|
INSERT INTO consent_audit_log (user_id, action, entity_type, entity_id, details, ip_address, user_agent)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
`, userID, action, entityType, entityID, details, ipAddress, userAgent)
|
|
}
|
|
|
|
func parseIntFromQuery(s string) (int, error) {
|
|
return strconv.Atoi(s)
|
|
}
|
|
|
|
// ========================================
|
|
// SCHEDULED PUBLISHING
|
|
// ========================================
|
|
|
|
// ProcessScheduledPublishing publishes all versions that are due
|
|
// This should be called by a cron job or scheduler
|
|
func (h *Handler) ProcessScheduledPublishing(c *gin.Context) {
|
|
ctx := context.Background()
|
|
|
|
// Find all scheduled versions that are due
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT id, document_id, version
|
|
FROM document_versions
|
|
WHERE status = 'scheduled'
|
|
AND scheduled_publish_at IS NOT NULL
|
|
AND scheduled_publish_at <= NOW()
|
|
`)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var published []string
|
|
for rows.Next() {
|
|
var versionID, docID uuid.UUID
|
|
var version string
|
|
if err := rows.Scan(&versionID, &docID, &version); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Publish this version
|
|
_, err := h.db.Pool.Exec(ctx, `
|
|
UPDATE document_versions
|
|
SET status = 'published', published_at = NOW(), updated_at = NOW()
|
|
WHERE id = $1
|
|
`, versionID)
|
|
|
|
if err == nil {
|
|
// Archive previous published versions for this document
|
|
h.db.Pool.Exec(ctx, `
|
|
UPDATE document_versions
|
|
SET status = 'archived', updated_at = NOW()
|
|
WHERE document_id = $1 AND id != $2 AND status = 'published'
|
|
`, docID, versionID)
|
|
|
|
// Log the publishing
|
|
details := fmt.Sprintf("Version %s automatically published by scheduler", version)
|
|
h.logAudit(ctx, nil, "version_scheduled_published", "document_version", &versionID, &details, "", "scheduler")
|
|
|
|
published = append(published, version)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Scheduled publishing processed",
|
|
"published_count": len(published),
|
|
"published_versions": published,
|
|
})
|
|
}
|
|
|
|
// GetScheduledVersions returns all versions scheduled for publishing
|
|
func (h *Handler) GetScheduledVersions(c *gin.Context) {
|
|
ctx := context.Background()
|
|
|
|
rows, err := h.db.Pool.Query(ctx, `
|
|
SELECT dv.id, dv.document_id, dv.version, dv.title, dv.scheduled_publish_at, ld.name as document_name
|
|
FROM document_versions dv
|
|
JOIN legal_documents ld ON ld.id = dv.document_id
|
|
WHERE dv.status = 'scheduled'
|
|
AND dv.scheduled_publish_at IS NOT NULL
|
|
ORDER BY dv.scheduled_publish_at ASC
|
|
`)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type ScheduledVersion struct {
|
|
ID uuid.UUID `json:"id"`
|
|
DocumentID uuid.UUID `json:"document_id"`
|
|
Version string `json:"version"`
|
|
Title string `json:"title"`
|
|
ScheduledPublishAt *time.Time `json:"scheduled_publish_at"`
|
|
DocumentName string `json:"document_name"`
|
|
}
|
|
|
|
var versions []ScheduledVersion
|
|
for rows.Next() {
|
|
var v ScheduledVersion
|
|
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Title, &v.ScheduledPublishAt, &v.DocumentName); err != nil {
|
|
continue
|
|
}
|
|
versions = append(versions, v)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"scheduled_versions": versions})
|
|
}
|