Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook) and split all 44 files exceeding 500 LOC into domain-focused modules: - consent-service (Go): models, handlers, services, database splits - backend-core (Python): security_api, rbac_api, pdf_service, auth splits - admin-core (TypeScript): 5 page.tsx + sidebar extractions - pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits - voice-service (Python): enhanced_task_orchestrator split Result: 0 violations, 36 exempted (pipeline, tests, pure-data files). Go build verified clean. No behavior changes — pure structural splits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
320 lines
9.0 KiB
Go
320 lines
9.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/breakpilot/consent-service/internal/models"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ========================================
|
|
// 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})
|
|
}
|