[split-required] [guardrail-change] Enforce 500 LOC budget across all services
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>
This commit is contained in:
319
consent-service/internal/handlers/admin_operations.go
Normal file
319
consent-service/internal/handlers/admin_operations.go
Normal file
@@ -0,0 +1,319 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user