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}) }