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>
456 lines
14 KiB
Go
456 lines
14 KiB
Go
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})
|
|
}
|