[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:
455
consent-service/internal/handlers/admin_approval.go
Normal file
455
consent-service/internal/handlers/admin_approval.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// 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})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 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})
|
||||
}
|
||||
Reference in New Issue
Block a user