package handlers import ( "context" "fmt" "net/http" "strconv" "strings" "time" "github.com/breakpilot/consent-service/internal/database" "github.com/breakpilot/consent-service/internal/middleware" "github.com/breakpilot/consent-service/internal/models" "github.com/gin-gonic/gin" "github.com/google/uuid" ) // Handler holds all HTTP handlers type Handler struct { db *database.DB } // New creates a new Handler instance func New(db *database.DB) *Handler { return &Handler{db: db} } // ======================================== // PUBLIC ENDPOINTS - Documents // ======================================== // GetDocuments returns all active legal documents func (h *Handler) GetDocuments(c *gin.Context) { ctx := context.Background() rows, err := h.db.Pool.Query(ctx, ` SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at FROM legal_documents WHERE is_active = true ORDER BY sort_order ASC `) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"}) return } defer rows.Close() var documents []models.LegalDocument for rows.Next() { var doc models.LegalDocument if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil { continue } documents = append(documents, doc) } c.JSON(http.StatusOK, gin.H{"documents": documents}) } // GetDocumentByType returns a document by its type func (h *Handler) GetDocumentByType(c *gin.Context) { docType := c.Param("type") ctx := context.Background() var doc models.LegalDocument err := h.db.Pool.QueryRow(ctx, ` SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at FROM legal_documents WHERE type = $1 AND is_active = true `, docType).Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) return } c.JSON(http.StatusOK, doc) } // GetLatestDocumentVersion returns the latest published version of a document func (h *Handler) GetLatestDocumentVersion(c *gin.Context) { docType := c.Param("type") language := c.DefaultQuery("language", "de") ctx := context.Background() var version models.DocumentVersion err := h.db.Pool.QueryRow(ctx, ` SELECT dv.id, dv.document_id, dv.version, dv.language, dv.title, dv.content, dv.summary, dv.status, dv.published_at, dv.created_at, dv.updated_at FROM document_versions dv JOIN legal_documents ld ON dv.document_id = ld.id WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published' ORDER BY dv.published_at DESC LIMIT 1 `, docType, language).Scan(&version.ID, &version.DocumentID, &version.Version, &version.Language, &version.Title, &version.Content, &version.Summary, &version.Status, &version.PublishedAt, &version.CreatedAt, &version.UpdatedAt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "No published version found"}) return } c.JSON(http.StatusOK, version) } // ======================================== // PUBLIC ENDPOINTS - Consent // ======================================== // CreateConsent creates a new user consent func (h *Handler) CreateConsent(c *gin.Context) { var req models.CreateConsentRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } userID, err := middleware.GetUserID(c) if err != nil || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) return } versionID, err := uuid.Parse(req.VersionID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) return } ctx := context.Background() ipAddress := middleware.GetClientIP(c) userAgent := middleware.GetUserAgent(c) // Upsert consent var consentID uuid.UUID err = h.db.Pool.QueryRow(ctx, ` INSERT INTO user_consents (user_id, document_version_id, consented, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, document_version_id) DO UPDATE SET consented = $3, consented_at = NOW(), withdrawn_at = NULL RETURNING id `, userID, versionID, req.Consented, ipAddress, userAgent).Scan(&consentID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save consent"}) return } // Log to audit trail h.logAudit(ctx, &userID, "consent_given", "document_version", &versionID, nil, ipAddress, userAgent) c.JSON(http.StatusCreated, gin.H{ "message": "Consent saved successfully", "consent_id": consentID, }) } // GetMyConsents returns all consents for the current user func (h *Handler) GetMyConsents(c *gin.Context) { userID, err := middleware.GetUserID(c) if err != nil || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) return } ctx := context.Background() rows, err := h.db.Pool.Query(ctx, ` SELECT uc.id, uc.consented, uc.consented_at, uc.withdrawn_at, ld.id, ld.type, ld.name, ld.is_mandatory, dv.id, dv.version, dv.language, dv.title 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.user_id = $1 ORDER BY uc.consented_at DESC `, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch consents"}) return } defer rows.Close() var consents []map[string]interface{} for rows.Next() { var ( consentID uuid.UUID consented bool consentedAt time.Time withdrawnAt *time.Time docID uuid.UUID docType string docName string isMandatory bool versionID uuid.UUID version string language string title string ) if err := rows.Scan(&consentID, &consented, &consentedAt, &withdrawnAt, &docID, &docType, &docName, &isMandatory, &versionID, &version, &language, &title); err != nil { continue } consents = append(consents, map[string]interface{}{ "consent_id": consentID, "consented": consented, "consented_at": consentedAt, "withdrawn_at": withdrawnAt, "document": map[string]interface{}{ "id": docID, "type": docType, "name": docName, "is_mandatory": isMandatory, }, "version": map[string]interface{}{ "id": versionID, "version": version, "language": language, "title": title, }, }) } c.JSON(http.StatusOK, gin.H{"consents": consents}) } // CheckConsent checks if the user has consented to a document func (h *Handler) CheckConsent(c *gin.Context) { docType := c.Param("documentType") language := c.DefaultQuery("language", "de") userID, err := middleware.GetUserID(c) if err != nil || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) return } ctx := context.Background() // Get latest published version var latestVersionID uuid.UUID var latestVersion string err = h.db.Pool.QueryRow(ctx, ` SELECT dv.id, dv.version FROM document_versions dv JOIN legal_documents ld ON dv.document_id = ld.id WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published' ORDER BY dv.published_at DESC LIMIT 1 `, docType, language).Scan(&latestVersionID, &latestVersion) if err != nil { c.JSON(http.StatusOK, models.ConsentCheckResponse{ HasConsent: false, NeedsUpdate: false, }) return } // Check if user has consented to this version var consentedVersionID uuid.UUID var consentedVersion string var consentedAt time.Time err = h.db.Pool.QueryRow(ctx, ` SELECT dv.id, dv.version, uc.consented_at 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.user_id = $1 AND ld.type = $2 AND uc.consented = true AND uc.withdrawn_at IS NULL ORDER BY uc.consented_at DESC LIMIT 1 `, userID, docType).Scan(&consentedVersionID, &consentedVersion, &consentedAt) if err != nil { // No consent found latestIDStr := latestVersionID.String() c.JSON(http.StatusOK, models.ConsentCheckResponse{ HasConsent: false, CurrentVersionID: &latestIDStr, NeedsUpdate: true, }) return } // Check if consent is for latest version needsUpdate := consentedVersionID != latestVersionID latestIDStr := latestVersionID.String() consentedVerStr := consentedVersion c.JSON(http.StatusOK, models.ConsentCheckResponse{ HasConsent: true, CurrentVersionID: &latestIDStr, ConsentedVersion: &consentedVerStr, NeedsUpdate: needsUpdate, ConsentedAt: &consentedAt, }) } // WithdrawConsent withdraws a consent func (h *Handler) WithdrawConsent(c *gin.Context) { consentID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid consent ID"}) return } userID, err := middleware.GetUserID(c) if err != nil || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) return } ctx := context.Background() ipAddress := middleware.GetClientIP(c) userAgent := middleware.GetUserAgent(c) // Update consent result, err := h.db.Pool.Exec(ctx, ` UPDATE user_consents SET withdrawn_at = NOW(), consented = false WHERE id = $1 AND user_id = $2 `, consentID, userID) if err != nil || result.RowsAffected() == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Consent not found"}) return } // Log to audit trail h.logAudit(ctx, &userID, "consent_withdrawn", "consent", &consentID, nil, ipAddress, userAgent) c.JSON(http.StatusOK, gin.H{"message": "Consent withdrawn successfully"}) } // ======================================== // PUBLIC ENDPOINTS - Cookie Consent // ======================================== // GetCookieCategories returns all active cookie categories func (h *Handler) GetCookieCategories(c *gin.Context) { language := c.DefaultQuery("language", "de") 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 FROM cookie_categories WHERE is_active = true 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 []map[string]interface{} 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); err != nil { continue } // Return localized data displayName := cat.DisplayNameDE description := cat.DescriptionDE if language == "en" && cat.DisplayNameEN != nil { displayName = *cat.DisplayNameEN if cat.DescriptionEN != nil { description = cat.DescriptionEN } } categories = append(categories, map[string]interface{}{ "id": cat.ID, "name": cat.Name, "display_name": displayName, "description": description, "is_mandatory": cat.IsMandatory, }) } c.JSON(http.StatusOK, gin.H{"categories": categories}) } // SetCookieConsent sets cookie preferences for a user func (h *Handler) SetCookieConsent(c *gin.Context) { var req models.CookieConsentRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } userID, err := middleware.GetUserID(c) if err != nil || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) return } ctx := context.Background() ipAddress := middleware.GetClientIP(c) userAgent := middleware.GetUserAgent(c) // Process each category for _, cat := range req.Categories { categoryID, err := uuid.Parse(cat.CategoryID) if err != nil { continue } _, err = h.db.Pool.Exec(ctx, ` INSERT INTO cookie_consents (user_id, category_id, consented) VALUES ($1, $2, $3) ON CONFLICT (user_id, category_id) DO UPDATE SET consented = $3, updated_at = NOW() `, userID, categoryID, cat.Consented) if err != nil { continue } } // Log to audit trail h.logAudit(ctx, &userID, "cookie_consent_updated", "cookie", nil, nil, ipAddress, userAgent) c.JSON(http.StatusOK, gin.H{"message": "Cookie preferences saved"}) } // GetMyCookieConsent returns cookie preferences for the current user func (h *Handler) GetMyCookieConsent(c *gin.Context) { userID, err := middleware.GetUserID(c) if err != nil || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) return } ctx := context.Background() rows, err := h.db.Pool.Query(ctx, ` SELECT cc.category_id, cc.consented, cc.updated_at, cat.name, cat.display_name_de, cat.is_mandatory FROM cookie_consents cc JOIN cookie_categories cat ON cc.category_id = cat.id WHERE cc.user_id = $1 `, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"}) return } defer rows.Close() var consents []map[string]interface{} for rows.Next() { var ( categoryID uuid.UUID consented bool updatedAt time.Time name string displayName string isMandatory bool ) if err := rows.Scan(&categoryID, &consented, &updatedAt, &name, &displayName, &isMandatory); err != nil { continue } consents = append(consents, map[string]interface{}{ "category_id": categoryID, "name": name, "display_name": displayName, "consented": consented, "is_mandatory": isMandatory, "updated_at": updatedAt, }) } c.JSON(http.StatusOK, gin.H{"cookie_consents": consents}) } // ======================================== // GDPR / DATA SUBJECT RIGHTS // ======================================== // GetMyData returns all data we have about the user func (h *Handler) GetMyData(c *gin.Context) { userID, err := middleware.GetUserID(c) if err != nil || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) return } ctx := context.Background() ipAddress := middleware.GetClientIP(c) userAgent := middleware.GetUserAgent(c) // Get user info var user models.User err = h.db.Pool.QueryRow(ctx, ` SELECT id, external_id, email, role, created_at, updated_at FROM users WHERE id = $1 `, userID).Scan(&user.ID, &user.ExternalID, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt) // Get consents consentRows, _ := h.db.Pool.Query(ctx, ` SELECT uc.consented, uc.consented_at, ld.type, ld.name, dv.version 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.user_id = $1 `, userID) defer consentRows.Close() var consents []map[string]interface{} for consentRows.Next() { var consented bool var consentedAt time.Time var docType, docName, version string consentRows.Scan(&consented, &consentedAt, &docType, &docName, &version) consents = append(consents, map[string]interface{}{ "document_type": docType, "document_name": docName, "version": version, "consented": consented, "consented_at": consentedAt, }) } // Get cookie consents cookieRows, _ := h.db.Pool.Query(ctx, ` SELECT cat.name, cc.consented, cc.updated_at FROM cookie_consents cc JOIN cookie_categories cat ON cc.category_id = cat.id WHERE cc.user_id = $1 `, userID) defer cookieRows.Close() var cookieConsents []map[string]interface{} for cookieRows.Next() { var name string var consented bool var updatedAt time.Time cookieRows.Scan(&name, &consented, &updatedAt) cookieConsents = append(cookieConsents, map[string]interface{}{ "category": name, "consented": consented, "updated_at": updatedAt, }) } // Log data access h.logAudit(ctx, &userID, "data_access", "user", &userID, nil, ipAddress, userAgent) c.JSON(http.StatusOK, gin.H{ "user": map[string]interface{}{ "id": user.ID, "email": user.Email, "created_at": user.CreatedAt, }, "consents": consents, "cookie_consents": cookieConsents, "exported_at": time.Now(), }) } // RequestDataExport creates a data export request func (h *Handler) RequestDataExport(c *gin.Context) { userID, err := middleware.GetUserID(c) if err != nil || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) return } ctx := context.Background() ipAddress := middleware.GetClientIP(c) userAgent := middleware.GetUserAgent(c) var requestID uuid.UUID err = h.db.Pool.QueryRow(ctx, ` INSERT INTO data_export_requests (user_id, status) VALUES ($1, 'pending') RETURNING id `, userID).Scan(&requestID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export request"}) return } // Log to audit trail h.logAudit(ctx, &userID, "data_export_requested", "export_request", &requestID, nil, ipAddress, userAgent) c.JSON(http.StatusAccepted, gin.H{ "message": "Export request created. You will be notified when ready.", "request_id": requestID, }) } // RequestDataDeletion creates a data deletion request func (h *Handler) RequestDataDeletion(c *gin.Context) { userID, err := middleware.GetUserID(c) if err != nil || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) return } var req struct { Reason string `json:"reason"` } c.ShouldBindJSON(&req) ctx := context.Background() ipAddress := middleware.GetClientIP(c) userAgent := middleware.GetUserAgent(c) var requestID uuid.UUID err = h.db.Pool.QueryRow(ctx, ` INSERT INTO data_deletion_requests (user_id, status, reason) VALUES ($1, 'pending', $2) RETURNING id `, userID, req.Reason).Scan(&requestID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deletion request"}) return } // Log to audit trail h.logAudit(ctx, &userID, "data_deletion_requested", "deletion_request", &requestID, nil, ipAddress, userAgent) c.JSON(http.StatusAccepted, gin.H{ "message": "Deletion request created. We will process your request within 30 days.", "request_id": requestID, }) } // ======================================== // ADMIN ENDPOINTS - Document Management // ======================================== // AdminGetDocuments returns all documents (including inactive) for admin func (h *Handler) AdminGetDocuments(c *gin.Context) { ctx := context.Background() rows, err := h.db.Pool.Query(ctx, ` SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at FROM legal_documents ORDER BY sort_order ASC, created_at DESC `) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"}) return } defer rows.Close() var documents []models.LegalDocument for rows.Next() { var doc models.LegalDocument if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil { continue } documents = append(documents, doc) } c.JSON(http.StatusOK, gin.H{"documents": documents}) } // AdminCreateDocument creates a new legal document func (h *Handler) AdminCreateDocument(c *gin.Context) { var req models.CreateDocumentRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } ctx := context.Background() var docID uuid.UUID err := h.db.Pool.QueryRow(ctx, ` INSERT INTO legal_documents (type, name, description, is_mandatory) VALUES ($1, $2, $3, $4) RETURNING id `, req.Type, req.Name, req.Description, req.IsMandatory).Scan(&docID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"}) return } c.JSON(http.StatusCreated, gin.H{ "message": "Document created successfully", "id": docID, }) } // AdminUpdateDocument updates a legal document func (h *Handler) AdminUpdateDocument(c *gin.Context) { docID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) return } var req struct { Name *string `json:"name"` Description *string `json:"description"` IsMandatory *bool `json:"is_mandatory"` IsActive *bool `json:"is_active"` SortOrder *int `json:"sort_order"` } 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 legal_documents SET name = COALESCE($2, name), description = COALESCE($3, description), is_mandatory = COALESCE($4, is_mandatory), is_active = COALESCE($5, is_active), sort_order = COALESCE($6, sort_order), updated_at = NOW() WHERE id = $1 `, docID, req.Name, req.Description, req.IsMandatory, req.IsActive, req.SortOrder) if err != nil || result.RowsAffected() == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "Document updated successfully"}) } // AdminDeleteDocument soft-deletes a document (sets is_active to false) func (h *Handler) AdminDeleteDocument(c *gin.Context) { docID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) return } ctx := context.Background() result, err := h.db.Pool.Exec(ctx, ` UPDATE legal_documents SET is_active = false, updated_at = NOW() WHERE id = $1 `, docID) if err != nil || result.RowsAffected() == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"}) } // ======================================== // ADMIN ENDPOINTS - Version Management // ======================================== // AdminGetVersions returns all versions for a document func (h *Handler) AdminGetVersions(c *gin.Context) { docID, err := uuid.Parse(c.Param("docId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) return } ctx := context.Background() rows, err := h.db.Pool.Query(ctx, ` SELECT id, document_id, version, language, title, content, summary, status, published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at FROM document_versions WHERE document_id = $1 ORDER BY created_at DESC `, docID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"}) return } defer rows.Close() var versions []models.DocumentVersion for rows.Next() { var v models.DocumentVersion if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Language, &v.Title, &v.Content, &v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt); err != nil { continue } versions = append(versions, v) } c.JSON(http.StatusOK, gin.H{"versions": versions}) } // AdminCreateVersion creates a new document version func (h *Handler) AdminCreateVersion(c *gin.Context) { var req models.CreateVersionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } docID, err := uuid.Parse(req.DocumentID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) return } userID, _ := middleware.GetUserID(c) ctx := context.Background() var versionID uuid.UUID err = h.db.Pool.QueryRow(ctx, ` INSERT INTO document_versions (document_id, version, language, title, content, summary, status, created_by) VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7) RETURNING id `, docID, req.Version, req.Language, req.Title, req.Content, req.Summary, userID).Scan(&versionID) if err != nil { // Check for unique constraint violation errStr := err.Error() if strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "unique constraint") { c.JSON(http.StatusConflict, gin.H{"error": "Eine Version mit dieser Versionsnummer und Sprache existiert bereits für dieses Dokument"}) return } // Log the actual error for debugging fmt.Printf("POST /api/v1/admin/versions ✗ %v\n", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version: " + errStr}) return } c.JSON(http.StatusCreated, gin.H{ "message": "Version created successfully", "id": versionID, }) } // AdminUpdateVersion updates a document version func (h *Handler) AdminUpdateVersion(c *gin.Context) { versionID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) return } var req models.UpdateVersionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } ctx := context.Background() // Check if version is in draft or review status (only these can be edited) 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" && status != "review" { c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft or review versions can be edited"}) return } result, err := h.db.Pool.Exec(ctx, ` UPDATE document_versions SET title = COALESCE($2, title), content = COALESCE($3, content), summary = COALESCE($4, summary), status = COALESCE($5, status), updated_at = NOW() WHERE id = $1 `, versionID, req.Title, req.Content, req.Summary, req.Status) if err != nil || result.RowsAffected() == 0 { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update version"}) return } c.JSON(http.StatusOK, gin.H{"message": "Version updated successfully"}) } // AdminPublishVersion publishes a document version func (h *Handler) AdminPublishVersion(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() // 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 != "approved" && status != "review" { c.JSON(http.StatusBadRequest, gin.H{"error": "Only approved or review versions can be published"}) return } result, err := h.db.Pool.Exec(ctx, ` UPDATE document_versions SET status = 'published', published_at = NOW(), approved_by = $2, updated_at = NOW() WHERE id = $1 `, versionID, userID) if err != nil || result.RowsAffected() == 0 { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"}) return } c.JSON(http.StatusOK, gin.H{"message": "Version published successfully"}) } // AdminArchiveVersion archives a document version func (h *Handler) AdminArchiveVersion(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() result, err := h.db.Pool.Exec(ctx, ` UPDATE document_versions SET status = 'archived', updated_at = NOW() WHERE id = $1 `, versionID) if err != nil || result.RowsAffected() == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "Version archived successfully"}) } // AdminDeleteVersion permanently deletes a draft/rejected version // Only draft and rejected versions can be deleted. Published versions must be archived. func (h *Handler) AdminDeleteVersion(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() // First check the version status - only draft/rejected can be deleted var status string var version string var docID uuid.UUID err = h.db.Pool.QueryRow(ctx, ` SELECT status, version, document_id FROM document_versions WHERE id = $1 `, versionID).Scan(&status, &version, &docID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) return } // Only allow deletion of draft and rejected versions if status != "draft" && status != "rejected" { c.JSON(http.StatusForbidden, gin.H{ "error": "Cannot delete version", "message": "Only draft or rejected versions can be deleted. Published versions must be archived instead.", "status": status, }) return } // Delete the version result, err := h.db.Pool.Exec(ctx, ` DELETE FROM document_versions WHERE id = $1 `, versionID) if err != nil || result.RowsAffected() == 0 { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete version"}) return } // Log the deletion userID, _ := c.Get("user_id") h.db.Pool.Exec(ctx, ` INSERT INTO consent_audit_log (action, entity_type, entity_id, user_id, details, ip_address, user_agent) VALUES ('version_deleted', 'document_version', $1, $2, $3, $4, $5) `, versionID, userID, "Version "+version+" permanently deleted", c.ClientIP(), c.Request.UserAgent()) c.JSON(http.StatusOK, gin.H{ "message": "Version deleted successfully", "deleted_version": version, "version_id": versionID, }) } // ======================================== // 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}) } // ======================================== // 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}) } // ======================================== // HELPER FUNCTIONS // ======================================== func (h *Handler) logAudit(ctx context.Context, userID *uuid.UUID, action, entityType string, entityID *uuid.UUID, details *string, ipAddress, userAgent string) { h.db.Pool.Exec(ctx, ` INSERT INTO consent_audit_log (user_id, action, entity_type, entity_id, details, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5, $6, $7) `, userID, action, entityType, entityID, details, ipAddress, userAgent) } func parseIntFromQuery(s string) (int, error) { return strconv.Atoi(s) } // ======================================== // 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}) }