refactor(go): split obligations, portfolio, rbac, whistleblower handlers and stores, roadmap parser

Split 7 files exceeding the 500 LOC hard cap into 16 files, all under 500 LOC.
No exported symbols renamed; zero behavior changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-19 10:00:15 +02:00
parent 3f2aff2389
commit 13f57c4519
16 changed files with 2348 additions and 2362 deletions

View File

@@ -0,0 +1,216 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
)
// ExportMemo exports the obligations overview as a C-Level memo
// POST /sdk/v1/ucca/obligations/export/memo
func (h *ObligationsHandlers) ExportMemo(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var req ucca.ExportMemoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if h.store == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"})
return
}
id, err := uuid.Parse(req.AssessmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
return
}
assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
return
}
exporter := ucca.NewPDFExporter(req.Language)
var response *ucca.ExportMemoResponse
switch req.Format {
case "pdf":
response, err = exporter.ExportManagementMemo(assessment.Overview)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate PDF", "details": err.Error()})
return
}
case "markdown", "":
response, err = exporter.ExportMarkdown(assessment.Overview)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Markdown", "details": err.Error()})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format. Use 'markdown' or 'pdf'"})
return
}
c.JSON(http.StatusOK, response)
}
// ExportMemoFromOverview exports an overview directly (without persistence)
// POST /sdk/v1/ucca/obligations/export/direct
func (h *ObligationsHandlers) ExportMemoFromOverview(c *gin.Context) {
var req struct {
Overview *ucca.ManagementObligationsOverview `json:"overview"`
Format string `json:"format"` // "markdown" or "pdf"
Language string `json:"language,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.Overview == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Overview is required"})
return
}
exporter := ucca.NewPDFExporter(req.Language)
var response *ucca.ExportMemoResponse
var err error
switch req.Format {
case "pdf":
response, err = exporter.ExportManagementMemo(req.Overview)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate PDF", "details": err.Error()})
return
}
case "markdown", "":
response, err = exporter.ExportMarkdown(req.Overview)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Markdown", "details": err.Error()})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format. Use 'markdown' or 'pdf'"})
return
}
c.JSON(http.StatusOK, response)
}
// ============================================================================
// Helper Functions
// ============================================================================
func generateMemoMarkdown(overview *ucca.ManagementObligationsOverview) string {
content := "# Pflichten-Übersicht für die Geschäftsführung\n\n"
content += "**Datum:** " + overview.AssessmentDate.Format("02.01.2006") + "\n"
if overview.OrganizationName != "" {
content += "**Organisation:** " + overview.OrganizationName + "\n"
}
content += "\n---\n\n"
content += "## Executive Summary\n\n"
content += "| Kennzahl | Wert |\n"
content += "|----------|------|\n"
content += "| Anwendbare Regulierungen | " + itoa(overview.ExecutiveSummary.TotalRegulations) + " |\n"
content += "| Gesamtzahl Pflichten | " + itoa(overview.ExecutiveSummary.TotalObligations) + " |\n"
content += "| Kritische Pflichten | " + itoa(overview.ExecutiveSummary.CriticalObligations) + " |\n"
content += "| Überfällige Pflichten | " + itoa(overview.ExecutiveSummary.OverdueObligations) + " |\n"
content += "| Anstehende Fristen (30 Tage) | " + itoa(overview.ExecutiveSummary.UpcomingDeadlines) + " |\n"
content += "\n"
if len(overview.ExecutiveSummary.KeyRisks) > 0 {
content += "### Hauptrisiken\n\n"
for _, risk := range overview.ExecutiveSummary.KeyRisks {
content += "- ⚠️ " + risk + "\n"
}
content += "\n"
}
if len(overview.ExecutiveSummary.RecommendedActions) > 0 {
content += "### Empfohlene Maßnahmen\n\n"
for i, action := range overview.ExecutiveSummary.RecommendedActions {
content += itoa(i+1) + ". " + action + "\n"
}
content += "\n"
}
content += "## Anwendbare Regulierungen\n\n"
for _, reg := range overview.ApplicableRegulations {
content += "### " + reg.Name + "\n\n"
content += "- **Klassifizierung:** " + reg.Classification + "\n"
content += "- **Begründung:** " + reg.Reason + "\n"
content += "- **Anzahl Pflichten:** " + itoa(reg.ObligationCount) + "\n"
content += "\n"
}
content += "## Sanktionsrisiken\n\n"
content += overview.SanctionsSummary.Summary + "\n\n"
if overview.SanctionsSummary.MaxFinancialRisk != "" {
content += "- **Maximales Bußgeld:** " + overview.SanctionsSummary.MaxFinancialRisk + "\n"
}
if overview.SanctionsSummary.PersonalLiabilityRisk {
content += "- **Persönliche Haftung:** Ja ⚠️\n"
}
content += "\n"
content += "## Kritische Pflichten\n\n"
for _, obl := range overview.Obligations {
if obl.Priority == ucca.PriorityCritical {
content += "### " + obl.ID + ": " + obl.Title + "\n\n"
content += obl.Description + "\n\n"
content += "- **Verantwortlich:** " + string(obl.Responsible) + "\n"
if obl.Deadline != nil {
if obl.Deadline.Date != nil {
content += "- **Frist:** " + obl.Deadline.Date.Format("02.01.2006") + "\n"
} else if obl.Deadline.Duration != "" {
content += "- **Frist:** " + obl.Deadline.Duration + "\n"
}
}
if obl.Sanctions != nil && obl.Sanctions.MaxFine != "" {
content += "- **Sanktion:** " + obl.Sanctions.MaxFine + "\n"
}
content += "\n"
}
}
if len(overview.IncidentDeadlines) > 0 {
content += "## Meldepflichten bei Sicherheitsvorfällen\n\n"
content += "| Phase | Frist | Empfänger |\n"
content += "|-------|-------|-----------|\n"
for _, deadline := range overview.IncidentDeadlines {
content += "| " + deadline.Phase + " | " + deadline.Deadline + " | " + deadline.Recipient + " |\n"
}
content += "\n"
}
content += "---\n\n"
content += "*Dieses Dokument wurde automatisch generiert und ersetzt keine Rechtsberatung.*\n"
return content
}
func isEUCountry(country string) bool {
euCountries := map[string]bool{
"DE": true, "AT": true, "BE": true, "BG": true, "HR": true, "CY": true,
"CZ": true, "DK": true, "EE": true, "FI": true, "FR": true, "GR": true,
"HU": true, "IE": true, "IT": true, "LV": true, "LT": true, "LU": true,
"MT": true, "NL": true, "PL": true, "PT": true, "RO": true, "SK": true,
"SI": true, "ES": true, "SE": true,
}
return euCountries[country]
}
func itoa(i int) string {
return strconv.Itoa(i)
}

View File

@@ -17,7 +17,6 @@ package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
@@ -29,10 +28,10 @@ import (
// ObligationsHandlers handles API requests for the generic obligations framework
type ObligationsHandlers struct {
registry *ucca.ObligationsRegistry
store *ucca.ObligationsStore // Optional: for persisting assessments
tomIndex *ucca.TOMControlIndex
tomMapper *ucca.TOMObligationMapper
registry *ucca.ObligationsRegistry
store *ucca.ObligationsStore // Optional: for persisting assessments
tomIndex *ucca.TOMControlIndex
tomMapper *ucca.TOMObligationMapper
gapAnalyzer *ucca.TOMGapAnalyzer
}
@@ -64,10 +63,8 @@ func (h *ObligationsHandlers) initTOM() {
}
h.tomIndex = tomIndex
// Try to load v2 TOM mapping
mapping, err := ucca.LoadV2TOMMapping()
if err != nil {
// Build mapping from v2 regulation files
regs, err2 := ucca.LoadAllV2Regulations()
if err2 == nil {
var allObligations []ucca.V2Obligation
@@ -89,30 +86,23 @@ func (h *ObligationsHandlers) initTOM() {
func (h *ObligationsHandlers) RegisterRoutes(r *gin.RouterGroup) {
obligations := r.Group("/obligations")
{
// Assessment endpoints
obligations.POST("/assess", h.AssessObligations)
obligations.GET("/:assessmentId", h.GetAssessment)
// Grouping/filtering endpoints
obligations.GET("/:assessmentId/by-regulation", h.GetByRegulation)
obligations.GET("/:assessmentId/by-deadline", h.GetByDeadline)
obligations.GET("/:assessmentId/by-responsible", h.GetByResponsible)
// Export endpoints
obligations.POST("/export/memo", h.ExportMemo)
obligations.POST("/export/direct", h.ExportMemoFromOverview)
// Metadata endpoints
obligations.GET("/regulations", h.ListRegulations)
obligations.GET("/regulations/:regulationId/decision-tree", h.GetDecisionTree)
// Quick check endpoint (no persistence)
obligations.POST("/quick-check", h.QuickCheck)
// v2: Scope-based assessment
obligations.POST("/assess-from-scope", h.AssessFromScope)
// v2: TOM Control endpoints
obligations.GET("/tom-controls/for-obligation/:obligationId", h.GetTOMControlsForObligation)
obligations.POST("/gap-analysis", h.GapAnalysis)
obligations.GET("/tom-controls/:controlId/obligations", h.GetObligationsForControl)
@@ -139,10 +129,8 @@ func (h *ObligationsHandlers) AssessObligations(c *gin.Context) {
return
}
// Evaluate all regulations against the facts
overview := h.registry.EvaluateAll(tenantID, req.Facts, req.OrganizationName)
// Generate warnings if any
var warnings []string
if len(overview.ApplicableRegulations) == 0 {
warnings = append(warnings, "Keine der konfigurierten Regulierungen scheint anwendbar zu sein. Bitte prüfen Sie die eingegebenen Daten.")
@@ -151,7 +139,6 @@ func (h *ObligationsHandlers) AssessObligations(c *gin.Context) {
warnings = append(warnings, "Es gibt überfällige Pflichten, die sofortige Aufmerksamkeit erfordern.")
}
// Optionally persist the assessment
if h.store != nil {
assessment := &ucca.ObligationsAssessment{
ID: overview.ID,
@@ -165,7 +152,6 @@ func (h *ObligationsHandlers) AssessObligations(c *gin.Context) {
CreatedBy: rbac.GetUserID(c),
}
if err := h.store.CreateAssessment(c.Request.Context(), assessment); err != nil {
// Log but don't fail - assessment was still generated
c.Set("store_error", err.Error())
}
}
@@ -202,235 +188,20 @@ func (h *ObligationsHandlers) GetAssessment(c *gin.Context) {
c.JSON(http.StatusOK, assessment.Overview)
}
// GetByRegulation returns obligations grouped by regulation
// GET /sdk/v1/ucca/obligations/:assessmentId/by-regulation
func (h *ObligationsHandlers) GetByRegulation(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
assessmentID := c.Param("assessmentId")
if h.store == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"})
return
}
id, err := uuid.Parse(assessmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
return
}
assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
return
}
grouped := h.registry.GroupByRegulation(assessment.Overview.Obligations)
c.JSON(http.StatusOK, ucca.ObligationsByRegulationResponse{
Regulations: grouped,
})
}
// GetByDeadline returns obligations grouped by deadline timeframe
// GET /sdk/v1/ucca/obligations/:assessmentId/by-deadline
func (h *ObligationsHandlers) GetByDeadline(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
assessmentID := c.Param("assessmentId")
if h.store == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"})
return
}
id, err := uuid.Parse(assessmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
return
}
assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
return
}
grouped := h.registry.GroupByDeadline(assessment.Overview.Obligations)
c.JSON(http.StatusOK, grouped)
}
// GetByResponsible returns obligations grouped by responsible role
// GET /sdk/v1/ucca/obligations/:assessmentId/by-responsible
func (h *ObligationsHandlers) GetByResponsible(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
assessmentID := c.Param("assessmentId")
if h.store == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"})
return
}
id, err := uuid.Parse(assessmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
return
}
assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
return
}
grouped := h.registry.GroupByResponsible(assessment.Overview.Obligations)
c.JSON(http.StatusOK, ucca.ObligationsByResponsibleResponse{
ByRole: grouped,
})
}
// ExportMemo exports the obligations overview as a C-Level memo
// POST /sdk/v1/ucca/obligations/export/memo
func (h *ObligationsHandlers) ExportMemo(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var req ucca.ExportMemoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if h.store == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"})
return
}
id, err := uuid.Parse(req.AssessmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
return
}
assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
return
}
// Create exporter
exporter := ucca.NewPDFExporter(req.Language)
// Generate export based on format
var response *ucca.ExportMemoResponse
switch req.Format {
case "pdf":
response, err = exporter.ExportManagementMemo(assessment.Overview)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate PDF", "details": err.Error()})
return
}
case "markdown", "":
response, err = exporter.ExportMarkdown(assessment.Overview)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Markdown", "details": err.Error()})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format. Use 'markdown' or 'pdf'"})
return
}
c.JSON(http.StatusOK, response)
}
// ExportMemoFromOverview exports an overview directly (without persistence)
// POST /sdk/v1/ucca/obligations/export/direct
func (h *ObligationsHandlers) ExportMemoFromOverview(c *gin.Context) {
var req struct {
Overview *ucca.ManagementObligationsOverview `json:"overview"`
Format string `json:"format"` // "markdown" or "pdf"
Language string `json:"language,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.Overview == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Overview is required"})
return
}
exporter := ucca.NewPDFExporter(req.Language)
var response *ucca.ExportMemoResponse
var err error
switch req.Format {
case "pdf":
response, err = exporter.ExportManagementMemo(req.Overview)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate PDF", "details": err.Error()})
return
}
case "markdown", "":
response, err = exporter.ExportMarkdown(req.Overview)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Markdown", "details": err.Error()})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format. Use 'markdown' or 'pdf'"})
return
}
c.JSON(http.StatusOK, response)
}
// ListRegulations returns all available regulation modules
// GET /sdk/v1/ucca/obligations/regulations
func (h *ObligationsHandlers) ListRegulations(c *gin.Context) {
modules := h.registry.ListModules()
c.JSON(http.StatusOK, ucca.AvailableRegulationsResponse{
Regulations: modules,
})
}
// GetDecisionTree returns the decision tree for a specific regulation
// GET /sdk/v1/ucca/obligations/regulations/:regulationId/decision-tree
func (h *ObligationsHandlers) GetDecisionTree(c *gin.Context) {
regulationID := c.Param("regulationId")
tree, err := h.registry.GetDecisionTree(regulationID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tree)
}
// QuickCheck performs a quick obligations check without persistence
// POST /sdk/v1/ucca/obligations/quick-check
func (h *ObligationsHandlers) QuickCheck(c *gin.Context) {
var req struct {
// Organization basics
EmployeeCount int `json:"employee_count"`
AnnualRevenue float64 `json:"annual_revenue"`
BalanceSheetTotal float64 `json:"balance_sheet_total,omitempty"`
Country string `json:"country"`
// Sector
PrimarySector string `json:"primary_sector"`
SpecialServices []string `json:"special_services,omitempty"`
IsKRITIS bool `json:"is_kritis,omitempty"`
// Quick flags
ProcessesPersonalData bool `json:"processes_personal_data,omitempty"`
UsesAI bool `json:"uses_ai,omitempty"`
IsFinancialInstitution bool `json:"is_financial_institution,omitempty"`
EmployeeCount int `json:"employee_count"`
AnnualRevenue float64 `json:"annual_revenue"`
BalanceSheetTotal float64 `json:"balance_sheet_total,omitempty"`
Country string `json:"country"`
PrimarySector string `json:"primary_sector"`
SpecialServices []string `json:"special_services,omitempty"`
IsKRITIS bool `json:"is_kritis,omitempty"`
ProcessesPersonalData bool `json:"processes_personal_data,omitempty"`
UsesAI bool `json:"uses_ai,omitempty"`
IsFinancialInstitution bool `json:"is_financial_institution,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -438,7 +209,6 @@ func (h *ObligationsHandlers) QuickCheck(c *gin.Context) {
return
}
// Build UnifiedFacts from quick check request
facts := &ucca.UnifiedFacts{
Organization: ucca.OrganizationFacts{
EmployeeCount: req.EmployeeCount,
@@ -465,15 +235,13 @@ func (h *ObligationsHandlers) QuickCheck(c *gin.Context) {
},
}
// Quick evaluation
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
tenantID = uuid.New() // Generate temporary ID for quick check
tenantID = uuid.New()
}
overview := h.registry.EvaluateAll(tenantID, facts, "")
// Return simplified result
c.JSON(http.StatusOK, gin.H{
"applicable_regulations": overview.ApplicableRegulations,
"total_obligations": len(overview.Obligations),
@@ -497,13 +265,9 @@ func (h *ObligationsHandlers) AssessFromScope(c *gin.Context) {
return
}
// Convert scope to facts
facts := ucca.MapScopeToFacts(&scope)
// Evaluate
overview := h.registry.EvaluateAll(tenantID, facts, "")
// Enrich with TOM control requirements if available
if h.tomMapper != nil {
overview.TOMControlRequirements = h.tomMapper.DeriveControlsFromObligations(overview.Obligations)
}
@@ -518,182 +282,3 @@ func (h *ObligationsHandlers) AssessFromScope(c *gin.Context) {
Warnings: warnings,
})
}
// GetTOMControlsForObligation returns TOM controls linked to an obligation
// GET /sdk/v1/ucca/obligations/:id/tom-controls
func (h *ObligationsHandlers) GetTOMControlsForObligation(c *gin.Context) {
obligationID := c.Param("obligationId")
if h.tomMapper == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "TOM mapping not available"})
return
}
controls := h.tomMapper.GetControlsForObligation(obligationID)
controlIDs := h.tomMapper.GetControlIDsForObligation(obligationID)
c.JSON(http.StatusOK, gin.H{
"obligation_id": obligationID,
"control_ids": controlIDs,
"controls": controls,
"count": len(controls),
})
}
// GapAnalysis performs a TOM control gap analysis
// POST /sdk/v1/ucca/obligations/gap-analysis
func (h *ObligationsHandlers) GapAnalysis(c *gin.Context) {
if h.gapAnalyzer == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Gap analysis not available"})
return
}
var req ucca.GapAnalysisRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
result := h.gapAnalyzer.Analyze(&req)
c.JSON(http.StatusOK, result)
}
// GetObligationsForControl returns obligations linked to a TOM control
// GET /sdk/v1/ucca/obligations/tom-controls/:controlId/obligations
func (h *ObligationsHandlers) GetObligationsForControl(c *gin.Context) {
controlID := c.Param("controlId")
if h.tomMapper == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "TOM mapping not available"})
return
}
obligationIDs := h.tomMapper.GetObligationsForControl(controlID)
var control *ucca.TOMControl
if h.tomIndex != nil {
control, _ = h.tomIndex.GetControl(controlID)
}
c.JSON(http.StatusOK, gin.H{
"control_id": controlID,
"control": control,
"obligation_ids": obligationIDs,
"count": len(obligationIDs),
})
}
// ============================================================================
// Helper Functions
// ============================================================================
func generateMemoMarkdown(overview *ucca.ManagementObligationsOverview) string {
content := "# Pflichten-Übersicht für die Geschäftsführung\n\n"
content += "**Datum:** " + overview.AssessmentDate.Format("02.01.2006") + "\n"
if overview.OrganizationName != "" {
content += "**Organisation:** " + overview.OrganizationName + "\n"
}
content += "\n---\n\n"
// Executive Summary
content += "## Executive Summary\n\n"
content += "| Kennzahl | Wert |\n"
content += "|----------|------|\n"
content += "| Anwendbare Regulierungen | " + itoa(overview.ExecutiveSummary.TotalRegulations) + " |\n"
content += "| Gesamtzahl Pflichten | " + itoa(overview.ExecutiveSummary.TotalObligations) + " |\n"
content += "| Kritische Pflichten | " + itoa(overview.ExecutiveSummary.CriticalObligations) + " |\n"
content += "| Überfällige Pflichten | " + itoa(overview.ExecutiveSummary.OverdueObligations) + " |\n"
content += "| Anstehende Fristen (30 Tage) | " + itoa(overview.ExecutiveSummary.UpcomingDeadlines) + " |\n"
content += "\n"
// Key Risks
if len(overview.ExecutiveSummary.KeyRisks) > 0 {
content += "### Hauptrisiken\n\n"
for _, risk := range overview.ExecutiveSummary.KeyRisks {
content += "- ⚠️ " + risk + "\n"
}
content += "\n"
}
// Recommended Actions
if len(overview.ExecutiveSummary.RecommendedActions) > 0 {
content += "### Empfohlene Maßnahmen\n\n"
for i, action := range overview.ExecutiveSummary.RecommendedActions {
content += itoa(i+1) + ". " + action + "\n"
}
content += "\n"
}
// Applicable Regulations
content += "## Anwendbare Regulierungen\n\n"
for _, reg := range overview.ApplicableRegulations {
content += "### " + reg.Name + "\n\n"
content += "- **Klassifizierung:** " + reg.Classification + "\n"
content += "- **Begründung:** " + reg.Reason + "\n"
content += "- **Anzahl Pflichten:** " + itoa(reg.ObligationCount) + "\n"
content += "\n"
}
// Sanctions Summary
content += "## Sanktionsrisiken\n\n"
content += overview.SanctionsSummary.Summary + "\n\n"
if overview.SanctionsSummary.MaxFinancialRisk != "" {
content += "- **Maximales Bußgeld:** " + overview.SanctionsSummary.MaxFinancialRisk + "\n"
}
if overview.SanctionsSummary.PersonalLiabilityRisk {
content += "- **Persönliche Haftung:** Ja ⚠️\n"
}
content += "\n"
// Critical Obligations
content += "## Kritische Pflichten\n\n"
for _, obl := range overview.Obligations {
if obl.Priority == ucca.PriorityCritical {
content += "### " + obl.ID + ": " + obl.Title + "\n\n"
content += obl.Description + "\n\n"
content += "- **Verantwortlich:** " + string(obl.Responsible) + "\n"
if obl.Deadline != nil {
if obl.Deadline.Date != nil {
content += "- **Frist:** " + obl.Deadline.Date.Format("02.01.2006") + "\n"
} else if obl.Deadline.Duration != "" {
content += "- **Frist:** " + obl.Deadline.Duration + "\n"
}
}
if obl.Sanctions != nil && obl.Sanctions.MaxFine != "" {
content += "- **Sanktion:** " + obl.Sanctions.MaxFine + "\n"
}
content += "\n"
}
}
// Incident Deadlines
if len(overview.IncidentDeadlines) > 0 {
content += "## Meldepflichten bei Sicherheitsvorfällen\n\n"
content += "| Phase | Frist | Empfänger |\n"
content += "|-------|-------|-----------|\n"
for _, deadline := range overview.IncidentDeadlines {
content += "| " + deadline.Phase + " | " + deadline.Deadline + " | " + deadline.Recipient + " |\n"
}
content += "\n"
}
content += "---\n\n"
content += "*Dieses Dokument wurde automatisch generiert und ersetzt keine Rechtsberatung.*\n"
return content
}
func isEUCountry(country string) bool {
euCountries := map[string]bool{
"DE": true, "AT": true, "BE": true, "BG": true, "HR": true, "CY": true,
"CZ": true, "DK": true, "EE": true, "FI": true, "FR": true, "GR": true,
"HU": true, "IE": true, "IT": true, "LV": true, "LT": true, "LU": true,
"MT": true, "NL": true, "PL": true, "PT": true, "RO": true, "SK": true,
"SI": true, "ES": true, "SE": true,
}
return euCountries[country]
}
func itoa(i int) string {
return strconv.Itoa(i)
}

View File

@@ -0,0 +1,187 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
)
// GetByRegulation returns obligations grouped by regulation
// GET /sdk/v1/ucca/obligations/:assessmentId/by-regulation
func (h *ObligationsHandlers) GetByRegulation(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
assessmentID := c.Param("assessmentId")
if h.store == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"})
return
}
id, err := uuid.Parse(assessmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
return
}
assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
return
}
grouped := h.registry.GroupByRegulation(assessment.Overview.Obligations)
c.JSON(http.StatusOK, ucca.ObligationsByRegulationResponse{
Regulations: grouped,
})
}
// GetByDeadline returns obligations grouped by deadline timeframe
// GET /sdk/v1/ucca/obligations/:assessmentId/by-deadline
func (h *ObligationsHandlers) GetByDeadline(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
assessmentID := c.Param("assessmentId")
if h.store == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"})
return
}
id, err := uuid.Parse(assessmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
return
}
assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
return
}
grouped := h.registry.GroupByDeadline(assessment.Overview.Obligations)
c.JSON(http.StatusOK, grouped)
}
// GetByResponsible returns obligations grouped by responsible role
// GET /sdk/v1/ucca/obligations/:assessmentId/by-responsible
func (h *ObligationsHandlers) GetByResponsible(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
assessmentID := c.Param("assessmentId")
if h.store == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"})
return
}
id, err := uuid.Parse(assessmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
return
}
assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
return
}
grouped := h.registry.GroupByResponsible(assessment.Overview.Obligations)
c.JSON(http.StatusOK, ucca.ObligationsByResponsibleResponse{
ByRole: grouped,
})
}
// ListRegulations returns all available regulation modules
// GET /sdk/v1/ucca/obligations/regulations
func (h *ObligationsHandlers) ListRegulations(c *gin.Context) {
modules := h.registry.ListModules()
c.JSON(http.StatusOK, ucca.AvailableRegulationsResponse{
Regulations: modules,
})
}
// GetDecisionTree returns the decision tree for a specific regulation
// GET /sdk/v1/ucca/obligations/regulations/:regulationId/decision-tree
func (h *ObligationsHandlers) GetDecisionTree(c *gin.Context) {
regulationID := c.Param("regulationId")
tree, err := h.registry.GetDecisionTree(regulationID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tree)
}
// GetTOMControlsForObligation returns TOM controls linked to an obligation
// GET /sdk/v1/ucca/obligations/:id/tom-controls
func (h *ObligationsHandlers) GetTOMControlsForObligation(c *gin.Context) {
obligationID := c.Param("obligationId")
if h.tomMapper == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "TOM mapping not available"})
return
}
controls := h.tomMapper.GetControlsForObligation(obligationID)
controlIDs := h.tomMapper.GetControlIDsForObligation(obligationID)
c.JSON(http.StatusOK, gin.H{
"obligation_id": obligationID,
"control_ids": controlIDs,
"controls": controls,
"count": len(controls),
})
}
// GapAnalysis performs a TOM control gap analysis
// POST /sdk/v1/ucca/obligations/gap-analysis
func (h *ObligationsHandlers) GapAnalysis(c *gin.Context) {
if h.gapAnalyzer == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Gap analysis not available"})
return
}
var req ucca.GapAnalysisRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
result := h.gapAnalyzer.Analyze(&req)
c.JSON(http.StatusOK, result)
}
// GetObligationsForControl returns obligations linked to a TOM control
// GET /sdk/v1/ucca/obligations/tom-controls/:controlId/obligations
func (h *ObligationsHandlers) GetObligationsForControl(c *gin.Context) {
controlID := c.Param("controlId")
if h.tomMapper == nil {
c.JSON(http.StatusNotImplemented, gin.H{"error": "TOM mapping not available"})
return
}
obligationIDs := h.tomMapper.GetObligationsForControl(controlID)
var control *ucca.TOMControl
if h.tomIndex != nil {
control, _ = h.tomIndex.GetControl(controlID)
}
c.JSON(http.StatusOK, gin.H{
"control_id": controlID,
"control": control,
"obligation_ids": obligationIDs,
"count": len(obligationIDs),
})
}

View File

@@ -49,7 +49,6 @@ func (h *PortfolioHandlers) CreatePortfolio(c *gin.Context) {
CreatedBy: userID,
}
// Set default settings
if !p.Settings.AutoUpdateMetrics {
p.Settings.AutoUpdateMetrics = true
}
@@ -125,7 +124,6 @@ func (h *PortfolioHandlers) GetPortfolio(c *gin.Context) {
return
}
// Get stats
stats, _ := h.store.GetPortfolioStats(c.Request.Context(), id)
c.JSON(http.StatusOK, gin.H{
@@ -211,415 +209,3 @@ func (h *PortfolioHandlers) DeletePortfolio(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "portfolio deleted"})
}
// ============================================================================
// Portfolio Items
// ============================================================================
// AddItem adds an item to a portfolio
// POST /sdk/v1/portfolios/:id/items
func (h *PortfolioHandlers) AddItem(c *gin.Context) {
portfolioID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
var req portfolio.AddItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
item := &portfolio.PortfolioItem{
PortfolioID: portfolioID,
ItemType: req.ItemType,
ItemID: req.ItemID,
Tags: req.Tags,
Notes: req.Notes,
AddedBy: userID,
}
if err := h.store.AddItem(c.Request.Context(), item); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"item": item})
}
// ListItems lists items in a portfolio
// GET /sdk/v1/portfolios/:id/items
func (h *PortfolioHandlers) ListItems(c *gin.Context) {
portfolioID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
var itemType *portfolio.ItemType
if t := c.Query("type"); t != "" {
it := portfolio.ItemType(t)
itemType = &it
}
items, err := h.store.ListItems(c.Request.Context(), portfolioID, itemType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": len(items),
})
}
// BulkAddItems adds multiple items to a portfolio
// POST /sdk/v1/portfolios/:id/items/bulk
func (h *PortfolioHandlers) BulkAddItems(c *gin.Context) {
portfolioID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
var req portfolio.BulkAddItemsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
// Convert AddItemRequest to PortfolioItem
items := make([]portfolio.PortfolioItem, len(req.Items))
for i, r := range req.Items {
items[i] = portfolio.PortfolioItem{
ItemType: r.ItemType,
ItemID: r.ItemID,
Tags: r.Tags,
Notes: r.Notes,
}
}
result, err := h.store.BulkAddItems(c.Request.Context(), portfolioID, items, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
// RemoveItem removes an item from a portfolio
// DELETE /sdk/v1/portfolios/:id/items/:itemId
func (h *PortfolioHandlers) RemoveItem(c *gin.Context) {
itemID, err := uuid.Parse(c.Param("itemId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid item ID"})
return
}
if err := h.store.RemoveItem(c.Request.Context(), itemID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "item removed"})
}
// ReorderItems updates the order of items
// PUT /sdk/v1/portfolios/:id/items/order
func (h *PortfolioHandlers) ReorderItems(c *gin.Context) {
portfolioID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
var req struct {
ItemIDs []uuid.UUID `json:"item_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.store.UpdateItemOrder(c.Request.Context(), portfolioID, req.ItemIDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "items reordered"})
}
// ============================================================================
// Merge Operations
// ============================================================================
// MergePortfolios merges two portfolios
// POST /sdk/v1/portfolios/merge
func (h *PortfolioHandlers) MergePortfolios(c *gin.Context) {
var req portfolio.MergeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate portfolios exist
source, err := h.store.GetPortfolio(c.Request.Context(), req.SourcePortfolioID)
if err != nil || source == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "source portfolio not found"})
return
}
target, err := h.store.GetPortfolio(c.Request.Context(), req.TargetPortfolioID)
if err != nil || target == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "target portfolio not found"})
return
}
// Set defaults
if req.Strategy == "" {
req.Strategy = portfolio.MergeStrategyUnion
}
userID := rbac.GetUserID(c)
result, err := h.store.MergePortfolios(c.Request.Context(), &req, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "portfolios merged",
"result": result,
})
}
// ============================================================================
// Statistics & Reports
// ============================================================================
// GetPortfolioStats returns statistics for a portfolio
// GET /sdk/v1/portfolios/:id/stats
func (h *PortfolioHandlers) GetPortfolioStats(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
stats, err := h.store.GetPortfolioStats(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetPortfolioActivity returns recent activity for a portfolio
// GET /sdk/v1/portfolios/:id/activity
func (h *PortfolioHandlers) GetPortfolioActivity(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
limit := 20
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
activities, err := h.store.GetRecentActivity(c.Request.Context(), id, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"activities": activities,
"total": len(activities),
})
}
// ComparePortfolios compares multiple portfolios
// POST /sdk/v1/portfolios/compare
func (h *PortfolioHandlers) ComparePortfolios(c *gin.Context) {
var req portfolio.ComparePortfoliosRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.PortfolioIDs) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "at least 2 portfolios required for comparison"})
return
}
if len(req.PortfolioIDs) > 5 {
c.JSON(http.StatusBadRequest, gin.H{"error": "maximum 5 portfolios can be compared"})
return
}
// Get all portfolios
var portfolios []portfolio.Portfolio
comparison := portfolio.PortfolioComparison{
RiskScores: make(map[string]float64),
ComplianceScores: make(map[string]float64),
ItemCounts: make(map[string]int),
UniqueItems: make(map[string][]uuid.UUID),
}
allItems := make(map[uuid.UUID][]string) // item_id -> portfolio_ids
for _, id := range req.PortfolioIDs {
p, err := h.store.GetPortfolio(c.Request.Context(), id)
if err != nil || p == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio not found: " + id.String()})
return
}
portfolios = append(portfolios, *p)
idStr := id.String()
comparison.RiskScores[idStr] = p.AvgRiskScore
comparison.ComplianceScores[idStr] = p.ComplianceScore
comparison.ItemCounts[idStr] = p.TotalAssessments + p.TotalRoadmaps + p.TotalWorkshops
// Get items for comparison
items, _ := h.store.ListItems(c.Request.Context(), id, nil)
for _, item := range items {
allItems[item.ItemID] = append(allItems[item.ItemID], idStr)
}
}
// Find common and unique items
for itemID, portfolioIDs := range allItems {
if len(portfolioIDs) > 1 {
comparison.CommonItems = append(comparison.CommonItems, itemID)
} else {
pid := portfolioIDs[0]
comparison.UniqueItems[pid] = append(comparison.UniqueItems[pid], itemID)
}
}
c.JSON(http.StatusOK, portfolio.ComparePortfoliosResponse{
Portfolios: portfolios,
Comparison: comparison,
})
}
// RecalculateMetrics manually recalculates portfolio metrics
// POST /sdk/v1/portfolios/:id/recalculate
func (h *PortfolioHandlers) RecalculateMetrics(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
if err := h.store.RecalculateMetrics(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Get updated portfolio
p, _ := h.store.GetPortfolio(c.Request.Context(), id)
c.JSON(http.StatusOK, gin.H{
"message": "metrics recalculated",
"portfolio": p,
})
}
// ============================================================================
// Approval Workflow
// ============================================================================
// ApprovePortfolio approves a portfolio
// POST /sdk/v1/portfolios/:id/approve
func (h *PortfolioHandlers) ApprovePortfolio(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
p, err := h.store.GetPortfolio(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if p == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"})
return
}
if p.Status != portfolio.PortfolioStatusReview {
c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in REVIEW status to approve"})
return
}
userID := rbac.GetUserID(c)
now := c.Request.Context().Value("now")
if now == nil {
t := p.UpdatedAt
p.ApprovedAt = &t
}
p.ApprovedBy = &userID
p.Status = portfolio.PortfolioStatusApproved
if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "portfolio approved",
"portfolio": p,
})
}
// SubmitForReview submits a portfolio for review
// POST /sdk/v1/portfolios/:id/submit-review
func (h *PortfolioHandlers) SubmitForReview(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
p, err := h.store.GetPortfolio(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if p == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"})
return
}
if p.Status != portfolio.PortfolioStatusDraft && p.Status != portfolio.PortfolioStatusActive {
c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in DRAFT or ACTIVE status to submit for review"})
return
}
p.Status = portfolio.PortfolioStatusReview
if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "portfolio submitted for review",
"portfolio": p,
})
}

View File

@@ -0,0 +1,196 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/portfolio"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ============================================================================
// Portfolio Items
// ============================================================================
// AddItem adds an item to a portfolio
// POST /sdk/v1/portfolios/:id/items
func (h *PortfolioHandlers) AddItem(c *gin.Context) {
portfolioID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
var req portfolio.AddItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
item := &portfolio.PortfolioItem{
PortfolioID: portfolioID,
ItemType: req.ItemType,
ItemID: req.ItemID,
Tags: req.Tags,
Notes: req.Notes,
AddedBy: userID,
}
if err := h.store.AddItem(c.Request.Context(), item); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"item": item})
}
// ListItems lists items in a portfolio
// GET /sdk/v1/portfolios/:id/items
func (h *PortfolioHandlers) ListItems(c *gin.Context) {
portfolioID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
var itemType *portfolio.ItemType
if t := c.Query("type"); t != "" {
it := portfolio.ItemType(t)
itemType = &it
}
items, err := h.store.ListItems(c.Request.Context(), portfolioID, itemType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": len(items),
})
}
// BulkAddItems adds multiple items to a portfolio
// POST /sdk/v1/portfolios/:id/items/bulk
func (h *PortfolioHandlers) BulkAddItems(c *gin.Context) {
portfolioID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
var req portfolio.BulkAddItemsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
items := make([]portfolio.PortfolioItem, len(req.Items))
for i, r := range req.Items {
items[i] = portfolio.PortfolioItem{
ItemType: r.ItemType,
ItemID: r.ItemID,
Tags: r.Tags,
Notes: r.Notes,
}
}
result, err := h.store.BulkAddItems(c.Request.Context(), portfolioID, items, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
// RemoveItem removes an item from a portfolio
// DELETE /sdk/v1/portfolios/:id/items/:itemId
func (h *PortfolioHandlers) RemoveItem(c *gin.Context) {
itemID, err := uuid.Parse(c.Param("itemId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid item ID"})
return
}
if err := h.store.RemoveItem(c.Request.Context(), itemID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "item removed"})
}
// ReorderItems updates the order of items
// PUT /sdk/v1/portfolios/:id/items/order
func (h *PortfolioHandlers) ReorderItems(c *gin.Context) {
portfolioID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
var req struct {
ItemIDs []uuid.UUID `json:"item_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.store.UpdateItemOrder(c.Request.Context(), portfolioID, req.ItemIDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "items reordered"})
}
// ============================================================================
// Merge Operations
// ============================================================================
// MergePortfolios merges two portfolios
// POST /sdk/v1/portfolios/merge
func (h *PortfolioHandlers) MergePortfolios(c *gin.Context) {
var req portfolio.MergeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
source, err := h.store.GetPortfolio(c.Request.Context(), req.SourcePortfolioID)
if err != nil || source == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "source portfolio not found"})
return
}
target, err := h.store.GetPortfolio(c.Request.Context(), req.TargetPortfolioID)
if err != nil || target == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "target portfolio not found"})
return
}
if req.Strategy == "" {
req.Strategy = portfolio.MergeStrategyUnion
}
userID := rbac.GetUserID(c)
result, err := h.store.MergePortfolios(c.Request.Context(), &req, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "portfolios merged",
"result": result,
})
}

View File

@@ -0,0 +1,230 @@
package handlers
import (
"net/http"
"strconv"
"github.com/breakpilot/ai-compliance-sdk/internal/portfolio"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ============================================================================
// Statistics & Reports
// ============================================================================
// GetPortfolioStats returns statistics for a portfolio
// GET /sdk/v1/portfolios/:id/stats
func (h *PortfolioHandlers) GetPortfolioStats(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
stats, err := h.store.GetPortfolioStats(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetPortfolioActivity returns recent activity for a portfolio
// GET /sdk/v1/portfolios/:id/activity
func (h *PortfolioHandlers) GetPortfolioActivity(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
limit := 20
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
activities, err := h.store.GetRecentActivity(c.Request.Context(), id, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"activities": activities,
"total": len(activities),
})
}
// ComparePortfolios compares multiple portfolios
// POST /sdk/v1/portfolios/compare
func (h *PortfolioHandlers) ComparePortfolios(c *gin.Context) {
var req portfolio.ComparePortfoliosRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.PortfolioIDs) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "at least 2 portfolios required for comparison"})
return
}
if len(req.PortfolioIDs) > 5 {
c.JSON(http.StatusBadRequest, gin.H{"error": "maximum 5 portfolios can be compared"})
return
}
var portfolios []portfolio.Portfolio
comparison := portfolio.PortfolioComparison{
RiskScores: make(map[string]float64),
ComplianceScores: make(map[string]float64),
ItemCounts: make(map[string]int),
UniqueItems: make(map[string][]uuid.UUID),
}
allItems := make(map[uuid.UUID][]string)
for _, id := range req.PortfolioIDs {
p, err := h.store.GetPortfolio(c.Request.Context(), id)
if err != nil || p == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio not found: " + id.String()})
return
}
portfolios = append(portfolios, *p)
idStr := id.String()
comparison.RiskScores[idStr] = p.AvgRiskScore
comparison.ComplianceScores[idStr] = p.ComplianceScore
comparison.ItemCounts[idStr] = p.TotalAssessments + p.TotalRoadmaps + p.TotalWorkshops
items, _ := h.store.ListItems(c.Request.Context(), id, nil)
for _, item := range items {
allItems[item.ItemID] = append(allItems[item.ItemID], idStr)
}
}
for itemID, portfolioIDs := range allItems {
if len(portfolioIDs) > 1 {
comparison.CommonItems = append(comparison.CommonItems, itemID)
} else {
pid := portfolioIDs[0]
comparison.UniqueItems[pid] = append(comparison.UniqueItems[pid], itemID)
}
}
c.JSON(http.StatusOK, portfolio.ComparePortfoliosResponse{
Portfolios: portfolios,
Comparison: comparison,
})
}
// RecalculateMetrics manually recalculates portfolio metrics
// POST /sdk/v1/portfolios/:id/recalculate
func (h *PortfolioHandlers) RecalculateMetrics(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
if err := h.store.RecalculateMetrics(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
p, _ := h.store.GetPortfolio(c.Request.Context(), id)
c.JSON(http.StatusOK, gin.H{
"message": "metrics recalculated",
"portfolio": p,
})
}
// ============================================================================
// Approval Workflow
// ============================================================================
// ApprovePortfolio approves a portfolio
// POST /sdk/v1/portfolios/:id/approve
func (h *PortfolioHandlers) ApprovePortfolio(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
p, err := h.store.GetPortfolio(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if p == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"})
return
}
if p.Status != portfolio.PortfolioStatusReview {
c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in REVIEW status to approve"})
return
}
userID := rbac.GetUserID(c)
now := c.Request.Context().Value("now")
if now == nil {
t := p.UpdatedAt
p.ApprovedAt = &t
}
p.ApprovedBy = &userID
p.Status = portfolio.PortfolioStatusApproved
if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "portfolio approved",
"portfolio": p,
})
}
// SubmitForReview submits a portfolio for review
// POST /sdk/v1/portfolios/:id/submit-review
func (h *PortfolioHandlers) SubmitForReview(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
return
}
p, err := h.store.GetPortfolio(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if p == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"})
return
}
if p.Status != portfolio.PortfolioStatusDraft && p.Status != portfolio.PortfolioStatusActive {
c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in DRAFT or ACTIVE status to submit for review"})
return
}
p.Status = portfolio.PortfolioStatusReview
if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "portfolio submitted for review",
"portfolio": p,
})
}

View File

@@ -163,386 +163,3 @@ func (h *RBACHandlers) CreateNamespace(c *gin.Context) {
c.JSON(http.StatusCreated, namespace)
}
// ============================================================================
// Role Endpoints
// ============================================================================
// ListRoles returns roles for a tenant (including system roles)
func (h *RBACHandlers) ListRoles(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var tenantIDPtr *uuid.UUID
if tenantID != uuid.Nil {
tenantIDPtr = &tenantID
}
roles, err := h.store.ListRoles(c.Request.Context(), tenantIDPtr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"roles": roles})
}
// ListSystemRoles returns all system roles
func (h *RBACHandlers) ListSystemRoles(c *gin.Context) {
roles, err := h.store.ListSystemRoles(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"roles": roles})
}
// GetRole returns a role by ID
func (h *RBACHandlers) GetRole(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"})
return
}
role, err := h.store.GetRole(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "role not found"})
return
}
c.JSON(http.StatusOK, role)
}
// CreateRole creates a new role
func (h *RBACHandlers) CreateRole(c *gin.Context) {
var role rbac.Role
if err := c.ShouldBindJSON(&role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
if tenantID != uuid.Nil {
role.TenantID = &tenantID
}
if err := h.store.CreateRole(c.Request.Context(), &role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, role)
}
// ============================================================================
// User Role Endpoints
// ============================================================================
// AssignRoleRequest represents a role assignment request
type AssignRoleRequest struct {
UserID string `json:"user_id" binding:"required"`
RoleID string `json:"role_id" binding:"required"`
NamespaceID *string `json:"namespace_id"`
ExpiresAt *string `json:"expires_at"` // RFC3339 format
}
// AssignRole assigns a role to a user
func (h *RBACHandlers) AssignRole(c *gin.Context) {
var req AssignRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"})
return
}
roleID, err := uuid.Parse(req.RoleID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"})
return
}
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
grantorID := rbac.GetUserID(c)
if grantorID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
userRole := &rbac.UserRole{
UserID: userID,
RoleID: roleID,
TenantID: tenantID,
}
if req.NamespaceID != nil {
nsID, err := uuid.Parse(*req.NamespaceID)
if err == nil {
userRole.NamespaceID = &nsID
}
}
if err := h.service.AssignRoleToUser(c.Request.Context(), userRole, grantorID); err != nil {
if err == rbac.ErrPermissionDenied {
c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "role assigned successfully"})
}
// RevokeRole revokes a role from a user
func (h *RBACHandlers) RevokeRole(c *gin.Context) {
userIDStr := c.Param("userId")
roleIDStr := c.Param("roleId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"})
return
}
roleID, err := uuid.Parse(roleIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"})
return
}
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
revokerID := rbac.GetUserID(c)
if revokerID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
var namespaceID *uuid.UUID
if nsIDStr := c.Query("namespace_id"); nsIDStr != "" {
if nsID, err := uuid.Parse(nsIDStr); err == nil {
namespaceID = &nsID
}
}
if err := h.service.RevokeRoleFromUser(c.Request.Context(), userID, roleID, tenantID, namespaceID, revokerID); err != nil {
if err == rbac.ErrPermissionDenied {
c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "role revoked successfully"})
}
// GetUserRoles returns all roles for a user
func (h *RBACHandlers) GetUserRoles(c *gin.Context) {
userIDStr := c.Param("userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"})
return
}
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
roles, err := h.store.GetUserRoles(c.Request.Context(), userID, tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"roles": roles})
}
// ============================================================================
// Permission Endpoints
// ============================================================================
// GetEffectivePermissions returns effective permissions for the current user
func (h *RBACHandlers) GetEffectivePermissions(c *gin.Context) {
userID := rbac.GetUserID(c)
tenantID := rbac.GetTenantID(c)
namespaceID := rbac.GetNamespaceID(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
perms, err := h.service.GetEffectivePermissions(c.Request.Context(), userID, tenantID, namespaceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, perms)
}
// GetUserContext returns complete context for the current user
func (h *RBACHandlers) GetUserContext(c *gin.Context) {
userID := rbac.GetUserID(c)
tenantID := rbac.GetTenantID(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
ctx, err := h.policyEngine.GetUserContext(c.Request.Context(), userID, tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, ctx)
}
// CheckPermission checks if user has a specific permission
func (h *RBACHandlers) CheckPermission(c *gin.Context) {
permission := c.Query("permission")
if permission == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "permission parameter required"})
return
}
userID := rbac.GetUserID(c)
tenantID := rbac.GetTenantID(c)
namespaceID := rbac.GetNamespaceID(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
hasPermission, err := h.service.HasPermission(c.Request.Context(), userID, tenantID, namespaceID, permission)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"permission": permission,
"has_permission": hasPermission,
})
}
// ============================================================================
// LLM Policy Endpoints
// ============================================================================
// ListLLMPolicies returns LLM policies for a tenant
func (h *RBACHandlers) ListLLMPolicies(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
policies, err := h.store.ListLLMPolicies(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"policies": policies})
}
// GetLLMPolicy returns an LLM policy by ID
func (h *RBACHandlers) GetLLMPolicy(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"})
return
}
policy, err := h.store.GetLLMPolicy(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"})
return
}
c.JSON(http.StatusOK, policy)
}
// CreateLLMPolicy creates a new LLM policy
func (h *RBACHandlers) CreateLLMPolicy(c *gin.Context) {
var policy rbac.LLMPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
policy.TenantID = tenantID
if err := h.store.CreateLLMPolicy(c.Request.Context(), &policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, policy)
}
// UpdateLLMPolicy updates an LLM policy
func (h *RBACHandlers) UpdateLLMPolicy(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"})
return
}
var policy rbac.LLMPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
policy.ID = id
if err := h.store.UpdateLLMPolicy(c.Request.Context(), &policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, policy)
}
// DeleteLLMPolicy deletes an LLM policy
func (h *RBACHandlers) DeleteLLMPolicy(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"})
return
}
if err := h.store.DeleteLLMPolicy(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "policy deleted"})
}

View File

@@ -0,0 +1,392 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ============================================================================
// Role Endpoints
// ============================================================================
// ListRoles returns roles for a tenant (including system roles)
func (h *RBACHandlers) ListRoles(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var tenantIDPtr *uuid.UUID
if tenantID != uuid.Nil {
tenantIDPtr = &tenantID
}
roles, err := h.store.ListRoles(c.Request.Context(), tenantIDPtr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"roles": roles})
}
// ListSystemRoles returns all system roles
func (h *RBACHandlers) ListSystemRoles(c *gin.Context) {
roles, err := h.store.ListSystemRoles(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"roles": roles})
}
// GetRole returns a role by ID
func (h *RBACHandlers) GetRole(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"})
return
}
role, err := h.store.GetRole(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "role not found"})
return
}
c.JSON(http.StatusOK, role)
}
// CreateRole creates a new role
func (h *RBACHandlers) CreateRole(c *gin.Context) {
var role rbac.Role
if err := c.ShouldBindJSON(&role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
if tenantID != uuid.Nil {
role.TenantID = &tenantID
}
if err := h.store.CreateRole(c.Request.Context(), &role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, role)
}
// ============================================================================
// User Role Endpoints
// ============================================================================
// AssignRoleRequest represents a role assignment request
type AssignRoleRequest struct {
UserID string `json:"user_id" binding:"required"`
RoleID string `json:"role_id" binding:"required"`
NamespaceID *string `json:"namespace_id"`
ExpiresAt *string `json:"expires_at"` // RFC3339 format
}
// AssignRole assigns a role to a user
func (h *RBACHandlers) AssignRole(c *gin.Context) {
var req AssignRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"})
return
}
roleID, err := uuid.Parse(req.RoleID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"})
return
}
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
grantorID := rbac.GetUserID(c)
if grantorID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
userRole := &rbac.UserRole{
UserID: userID,
RoleID: roleID,
TenantID: tenantID,
}
if req.NamespaceID != nil {
nsID, err := uuid.Parse(*req.NamespaceID)
if err == nil {
userRole.NamespaceID = &nsID
}
}
if err := h.service.AssignRoleToUser(c.Request.Context(), userRole, grantorID); err != nil {
if err == rbac.ErrPermissionDenied {
c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "role assigned successfully"})
}
// RevokeRole revokes a role from a user
func (h *RBACHandlers) RevokeRole(c *gin.Context) {
userIDStr := c.Param("userId")
roleIDStr := c.Param("roleId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"})
return
}
roleID, err := uuid.Parse(roleIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"})
return
}
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
revokerID := rbac.GetUserID(c)
if revokerID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
var namespaceID *uuid.UUID
if nsIDStr := c.Query("namespace_id"); nsIDStr != "" {
if nsID, err := uuid.Parse(nsIDStr); err == nil {
namespaceID = &nsID
}
}
if err := h.service.RevokeRoleFromUser(c.Request.Context(), userID, roleID, tenantID, namespaceID, revokerID); err != nil {
if err == rbac.ErrPermissionDenied {
c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "role revoked successfully"})
}
// GetUserRoles returns all roles for a user
func (h *RBACHandlers) GetUserRoles(c *gin.Context) {
userIDStr := c.Param("userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"})
return
}
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
roles, err := h.store.GetUserRoles(c.Request.Context(), userID, tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"roles": roles})
}
// ============================================================================
// Permission Endpoints
// ============================================================================
// GetEffectivePermissions returns effective permissions for the current user
func (h *RBACHandlers) GetEffectivePermissions(c *gin.Context) {
userID := rbac.GetUserID(c)
tenantID := rbac.GetTenantID(c)
namespaceID := rbac.GetNamespaceID(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
perms, err := h.service.GetEffectivePermissions(c.Request.Context(), userID, tenantID, namespaceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, perms)
}
// GetUserContext returns complete context for the current user
func (h *RBACHandlers) GetUserContext(c *gin.Context) {
userID := rbac.GetUserID(c)
tenantID := rbac.GetTenantID(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
ctx, err := h.policyEngine.GetUserContext(c.Request.Context(), userID, tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, ctx)
}
// CheckPermission checks if user has a specific permission
func (h *RBACHandlers) CheckPermission(c *gin.Context) {
permission := c.Query("permission")
if permission == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "permission parameter required"})
return
}
userID := rbac.GetUserID(c)
tenantID := rbac.GetTenantID(c)
namespaceID := rbac.GetNamespaceID(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
hasPermission, err := h.service.HasPermission(c.Request.Context(), userID, tenantID, namespaceID, permission)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"permission": permission,
"has_permission": hasPermission,
})
}
// ============================================================================
// LLM Policy Endpoints
// ============================================================================
// ListLLMPolicies returns LLM policies for a tenant
func (h *RBACHandlers) ListLLMPolicies(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
policies, err := h.store.ListLLMPolicies(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"policies": policies})
}
// GetLLMPolicy returns an LLM policy by ID
func (h *RBACHandlers) GetLLMPolicy(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"})
return
}
policy, err := h.store.GetLLMPolicy(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"})
return
}
c.JSON(http.StatusOK, policy)
}
// CreateLLMPolicy creates a new LLM policy
func (h *RBACHandlers) CreateLLMPolicy(c *gin.Context) {
var policy rbac.LLMPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
policy.TenantID = tenantID
if err := h.store.CreateLLMPolicy(c.Request.Context(), &policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, policy)
}
// UpdateLLMPolicy updates an LLM policy
func (h *RBACHandlers) UpdateLLMPolicy(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"})
return
}
var policy rbac.LLMPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
policy.ID = id
if err := h.store.UpdateLLMPolicy(c.Request.Context(), &policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, policy)
}
// DeleteLLMPolicy deletes an LLM policy
func (h *RBACHandlers) DeleteLLMPolicy(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"})
return
}
if err := h.store.DeleteLLMPolicy(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "policy deleted"})
}

View File

@@ -33,7 +33,6 @@ func (h *WhistleblowerHandlers) SubmitReport(c *gin.Context) {
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")
@@ -57,7 +56,6 @@ func (h *WhistleblowerHandlers) SubmitReport(c *gin.Context) {
IsAnonymous: req.IsAnonymous,
}
// Only set reporter info if not anonymous
if !req.IsAnonymous {
report.ReporterName = req.ReporterName
report.ReporterEmail = req.ReporterEmail
@@ -69,7 +67,6 @@ func (h *WhistleblowerHandlers) SubmitReport(c *gin.Context) {
return
}
// Return reference number and access key (access key only shown ONCE!)
c.JSON(http.StatusCreated, whistleblower.PublicReportResponse{
ReferenceNumber: report.ReferenceNumber,
AccessKey: report.AccessKey,
@@ -95,7 +92,6 @@ func (h *WhistleblowerHandlers) GetReportByAccessKey(c *gin.Context) {
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,
@@ -199,11 +195,9 @@ func (h *WhistleblowerHandlers) GetReport(c *gin.Context) {
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{
@@ -288,251 +282,3 @@ func (h *WhistleblowerHandlers) DeleteReport(c *gin.Context) {
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)
}

View File

@@ -0,0 +1,254 @@
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"
)
// 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
}
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)
}
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
}
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
}
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
}
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)
}