package handlers import ( "net/http" "time" "github.com/breakpilot/ai-compliance-sdk/internal/rbac" "github.com/breakpilot/ai-compliance-sdk/internal/whistleblower" "github.com/gin-gonic/gin" "github.com/google/uuid" ) // WhistleblowerHandlers handles whistleblower HTTP requests type WhistleblowerHandlers struct { store *whistleblower.Store } // NewWhistleblowerHandlers creates new whistleblower handlers func NewWhistleblowerHandlers(store *whistleblower.Store) *WhistleblowerHandlers { return &WhistleblowerHandlers{store: store} } // ============================================================================ // Public Handlers (NO auth required — for anonymous reporters) // ============================================================================ // SubmitReport handles public report submission (no auth required) // POST /sdk/v1/whistleblower/public/submit func (h *WhistleblowerHandlers) SubmitReport(c *gin.Context) { var req whistleblower.PublicReportSubmission if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Get tenant ID from header or query param (public endpoint still needs tenant context) tenantIDStr := c.GetHeader("X-Tenant-ID") if tenantIDStr == "" { tenantIDStr = c.Query("tenant_id") } if tenantIDStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "tenant_id is required"}) return } tenantID, err := uuid.Parse(tenantIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id"}) return } report := &whistleblower.Report{ TenantID: tenantID, Category: req.Category, Title: req.Title, Description: req.Description, IsAnonymous: req.IsAnonymous, } // Only set reporter info if not anonymous if !req.IsAnonymous { report.ReporterName = req.ReporterName report.ReporterEmail = req.ReporterEmail report.ReporterPhone = req.ReporterPhone } if err := h.store.CreateReport(c.Request.Context(), report); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Return reference number and access key (access key only shown ONCE!) c.JSON(http.StatusCreated, whistleblower.PublicReportResponse{ ReferenceNumber: report.ReferenceNumber, AccessKey: report.AccessKey, }) } // GetReportByAccessKey retrieves a report by access key (for anonymous reporters) // GET /sdk/v1/whistleblower/public/report?access_key=xxx func (h *WhistleblowerHandlers) GetReportByAccessKey(c *gin.Context) { accessKey := c.Query("access_key") if accessKey == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "access_key is required"}) return } report, err := h.store.GetReportByAccessKey(c.Request.Context(), accessKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if report == nil { c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) return } // Return limited fields for public access (no access_key, no internal details) c.JSON(http.StatusOK, gin.H{ "reference_number": report.ReferenceNumber, "category": report.Category, "status": report.Status, "title": report.Title, "received_at": report.ReceivedAt, "deadline_acknowledgment": report.DeadlineAcknowledgment, "deadline_feedback": report.DeadlineFeedback, "acknowledged_at": report.AcknowledgedAt, "closed_at": report.ClosedAt, }) } // SendPublicMessage allows a reporter to send a message via access key // POST /sdk/v1/whistleblower/public/message?access_key=xxx func (h *WhistleblowerHandlers) SendPublicMessage(c *gin.Context) { accessKey := c.Query("access_key") if accessKey == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "access_key is required"}) return } report, err := h.store.GetReportByAccessKey(c.Request.Context(), accessKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if report == nil { c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) return } var req whistleblower.SendMessageRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } msg := &whistleblower.AnonymousMessage{ ReportID: report.ID, Direction: whistleblower.MessageDirectionReporterToAdmin, Content: req.Content, } if err := h.store.AddMessage(c.Request.Context(), msg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"message": msg}) } // ============================================================================ // Admin Handlers (auth required) // ============================================================================ // ListReports lists all reports for the tenant // GET /sdk/v1/whistleblower/reports func (h *WhistleblowerHandlers) ListReports(c *gin.Context) { tenantID := rbac.GetTenantID(c) filters := &whistleblower.ReportFilters{ Limit: 50, } if status := c.Query("status"); status != "" { filters.Status = whistleblower.ReportStatus(status) } if category := c.Query("category"); category != "" { filters.Category = whistleblower.ReportCategory(category) } reports, total, err := h.store.ListReports(c.Request.Context(), tenantID, filters) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, whistleblower.ReportListResponse{ Reports: reports, Total: total, }) } // GetReport retrieves a report by ID (admin) // GET /sdk/v1/whistleblower/reports/:id func (h *WhistleblowerHandlers) GetReport(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) return } report, err := h.store.GetReport(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if report == nil { c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) return } // Get messages and measures for full view messages, _ := h.store.ListMessages(c.Request.Context(), id) measures, _ := h.store.ListMeasures(c.Request.Context(), id) // Do not expose access key to admin either report.AccessKey = "" c.JSON(http.StatusOK, gin.H{ "report": report, "messages": messages, "measures": measures, }) } // UpdateReport updates a report // PUT /sdk/v1/whistleblower/reports/:id func (h *WhistleblowerHandlers) UpdateReport(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) return } report, err := h.store.GetReport(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if report == nil { c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) return } var req whistleblower.ReportUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) if req.Category != "" { report.Category = req.Category } if req.Status != "" { report.Status = req.Status } if req.Title != "" { report.Title = req.Title } if req.Description != "" { report.Description = req.Description } if req.AssignedTo != nil { report.AssignedTo = req.AssignedTo } report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{ Timestamp: time.Now().UTC(), Action: "report_updated", UserID: userID.String(), Details: "Report updated by admin", }) if err := h.store.UpdateReport(c.Request.Context(), report); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } report.AccessKey = "" c.JSON(http.StatusOK, gin.H{"report": report}) } // DeleteReport deletes a report // DELETE /sdk/v1/whistleblower/reports/:id func (h *WhistleblowerHandlers) DeleteReport(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) return } if err := h.store.DeleteReport(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "report deleted"}) } // AcknowledgeReport acknowledges a report (within 7-day HinSchG deadline) // POST /sdk/v1/whistleblower/reports/:id/acknowledge func (h *WhistleblowerHandlers) AcknowledgeReport(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) return } report, err := h.store.GetReport(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if report == nil { c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) return } if report.AcknowledgedAt != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "report already acknowledged"}) return } userID := rbac.GetUserID(c) if err := h.store.AcknowledgeReport(c.Request.Context(), id, userID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Optionally send acknowledgment message to reporter var req whistleblower.AcknowledgeRequest if err := c.ShouldBindJSON(&req); err == nil && req.Message != "" { msg := &whistleblower.AnonymousMessage{ ReportID: id, Direction: whistleblower.MessageDirectionAdminToReporter, Content: req.Message, } h.store.AddMessage(c.Request.Context(), msg) } // Check if deadline was met isOverdue := time.Now().UTC().After(report.DeadlineAcknowledgment) c.JSON(http.StatusOK, gin.H{ "message": "report acknowledged", "is_overdue": isOverdue, }) } // StartInvestigation changes the report status to investigation // POST /sdk/v1/whistleblower/reports/:id/investigate func (h *WhistleblowerHandlers) StartInvestigation(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) return } report, err := h.store.GetReport(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if report == nil { c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) return } userID := rbac.GetUserID(c) report.Status = whistleblower.ReportStatusInvestigation report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{ Timestamp: time.Now().UTC(), Action: "investigation_started", UserID: userID.String(), Details: "Investigation started", }) if err := h.store.UpdateReport(c.Request.Context(), report); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "message": "investigation started", "report": report, }) } // AddMeasure adds a corrective measure to a report // POST /sdk/v1/whistleblower/reports/:id/measures func (h *WhistleblowerHandlers) AddMeasure(c *gin.Context) { reportID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) return } // Verify report exists report, err := h.store.GetReport(c.Request.Context(), reportID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if report == nil { c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) return } var req whistleblower.AddMeasureRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) measure := &whistleblower.Measure{ ReportID: reportID, Title: req.Title, Description: req.Description, Responsible: req.Responsible, DueDate: req.DueDate, } if err := h.store.AddMeasure(c.Request.Context(), measure); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Update report status to measures_taken if not already if report.Status != whistleblower.ReportStatusMeasuresTaken && report.Status != whistleblower.ReportStatusClosed { report.Status = whistleblower.ReportStatusMeasuresTaken report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{ Timestamp: time.Now().UTC(), Action: "measure_added", UserID: userID.String(), Details: "Corrective measure added: " + req.Title, }) h.store.UpdateReport(c.Request.Context(), report) } c.JSON(http.StatusCreated, gin.H{"measure": measure}) } // CloseReport closes a report with a resolution // POST /sdk/v1/whistleblower/reports/:id/close func (h *WhistleblowerHandlers) CloseReport(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) return } var req whistleblower.CloseReportRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) if err := h.store.CloseReport(c.Request.Context(), id, userID, req.Resolution); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "report closed"}) } // SendAdminMessage sends a message from admin to reporter // POST /sdk/v1/whistleblower/reports/:id/messages func (h *WhistleblowerHandlers) SendAdminMessage(c *gin.Context) { reportID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) return } // Verify report exists report, err := h.store.GetReport(c.Request.Context(), reportID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if report == nil { c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) return } var req whistleblower.SendMessageRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } msg := &whistleblower.AnonymousMessage{ ReportID: reportID, Direction: whistleblower.MessageDirectionAdminToReporter, Content: req.Content, } if err := h.store.AddMessage(c.Request.Context(), msg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"message": msg}) } // ListMessages lists messages for a report // GET /sdk/v1/whistleblower/reports/:id/messages func (h *WhistleblowerHandlers) ListMessages(c *gin.Context) { reportID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) return } messages, err := h.store.ListMessages(c.Request.Context(), reportID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "messages": messages, "total": len(messages), }) } // GetStatistics returns whistleblower statistics for the tenant // GET /sdk/v1/whistleblower/statistics func (h *WhistleblowerHandlers) GetStatistics(c *gin.Context) { tenantID := rbac.GetTenantID(c) stats, err := h.store.GetStatistics(c.Request.Context(), tenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, stats) }