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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
392
ai-compliance-sdk/internal/api/handlers/rbac_role_handlers.go
Normal file
392
ai-compliance-sdk/internal/api/handlers/rbac_role_handlers.go
Normal 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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -278,374 +277,3 @@ func (s *Store) ListNamespaces(ctx context.Context, tenantID uuid.UUID) ([]*Name
|
||||
|
||||
return namespaces, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Role Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateRole creates a new role
|
||||
func (s *Store) CreateRole(ctx context.Context, role *Role) error {
|
||||
role.ID = uuid.New()
|
||||
role.CreatedAt = time.Now().UTC()
|
||||
role.UpdatedAt = role.CreatedAt
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO compliance_roles (id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, role.ID, role.TenantID, role.Name, role.Description, role.Permissions, role.IsSystemRole, role.HierarchyLevel, role.CreatedAt, role.UpdatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRole retrieves a role by ID
|
||||
func (s *Store) GetRole(ctx context.Context, id uuid.UUID) (*Role, error) {
|
||||
var role Role
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
|
||||
FROM compliance_roles
|
||||
WHERE id = $1
|
||||
`, id).Scan(
|
||||
&role.ID, &role.TenantID, &role.Name, &role.Description,
|
||||
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
|
||||
&role.CreatedAt, &role.UpdatedAt,
|
||||
)
|
||||
|
||||
return &role, err
|
||||
}
|
||||
|
||||
// GetRoleByName retrieves a role by tenant and name
|
||||
func (s *Store) GetRoleByName(ctx context.Context, tenantID *uuid.UUID, name string) (*Role, error) {
|
||||
var role Role
|
||||
|
||||
query := `
|
||||
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
|
||||
FROM compliance_roles
|
||||
WHERE name = $1 AND (tenant_id = $2 OR (tenant_id IS NULL AND is_system_role = TRUE))
|
||||
`
|
||||
|
||||
err := s.pool.QueryRow(ctx, query, name, tenantID).Scan(
|
||||
&role.ID, &role.TenantID, &role.Name, &role.Description,
|
||||
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
|
||||
&role.CreatedAt, &role.UpdatedAt,
|
||||
)
|
||||
|
||||
return &role, err
|
||||
}
|
||||
|
||||
// ListRoles lists roles for a tenant (including system roles)
|
||||
func (s *Store) ListRoles(ctx context.Context, tenantID *uuid.UUID) ([]*Role, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
|
||||
FROM compliance_roles
|
||||
WHERE tenant_id = $1 OR is_system_role = TRUE
|
||||
ORDER BY hierarchy_level, name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var roles []*Role
|
||||
for rows.Next() {
|
||||
var role Role
|
||||
err := rows.Scan(
|
||||
&role.ID, &role.TenantID, &role.Name, &role.Description,
|
||||
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
|
||||
&role.CreatedAt, &role.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
roles = append(roles, &role)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
// ListSystemRoles lists all system roles
|
||||
func (s *Store) ListSystemRoles(ctx context.Context) ([]*Role, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
|
||||
FROM compliance_roles
|
||||
WHERE is_system_role = TRUE
|
||||
ORDER BY hierarchy_level, name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var roles []*Role
|
||||
for rows.Next() {
|
||||
var role Role
|
||||
err := rows.Scan(
|
||||
&role.ID, &role.TenantID, &role.Name, &role.Description,
|
||||
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
|
||||
&role.CreatedAt, &role.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
roles = append(roles, &role)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// User Role Operations
|
||||
// ============================================================================
|
||||
|
||||
// AssignRole assigns a role to a user
|
||||
func (s *Store) AssignRole(ctx context.Context, ur *UserRole) error {
|
||||
ur.ID = uuid.New()
|
||||
ur.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO compliance_user_roles (id, user_id, role_id, tenant_id, namespace_id, granted_by, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (user_id, role_id, tenant_id, namespace_id) DO UPDATE SET
|
||||
granted_by = EXCLUDED.granted_by,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
`, ur.ID, ur.UserID, ur.RoleID, ur.TenantID, ur.NamespaceID, ur.GrantedBy, ur.ExpiresAt, ur.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeRole revokes a role from a user
|
||||
func (s *Store) RevokeRole(ctx context.Context, userID, roleID, tenantID uuid.UUID, namespaceID *uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM compliance_user_roles
|
||||
WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3 AND (namespace_id = $4 OR (namespace_id IS NULL AND $4 IS NULL))
|
||||
`, userID, roleID, tenantID, namespaceID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserRoles retrieves all roles for a user in a tenant
|
||||
func (s *Store) GetUserRoles(ctx context.Context, userID, tenantID uuid.UUID) ([]*UserRole, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at,
|
||||
r.name as role_name, r.permissions as role_permissions,
|
||||
n.name as namespace_name
|
||||
FROM compliance_user_roles ur
|
||||
JOIN compliance_roles r ON ur.role_id = r.id
|
||||
LEFT JOIN compliance_namespaces n ON ur.namespace_id = n.id
|
||||
WHERE ur.user_id = $1 AND ur.tenant_id = $2
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
ORDER BY r.hierarchy_level, r.name
|
||||
`, userID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userRoles []*UserRole
|
||||
for rows.Next() {
|
||||
var ur UserRole
|
||||
var namespaceName *string
|
||||
|
||||
err := rows.Scan(
|
||||
&ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID,
|
||||
&ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt,
|
||||
&ur.RoleName, &ur.RolePermissions, &namespaceName,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if namespaceName != nil {
|
||||
ur.NamespaceName = *namespaceName
|
||||
}
|
||||
|
||||
userRoles = append(userRoles, &ur)
|
||||
}
|
||||
|
||||
return userRoles, nil
|
||||
}
|
||||
|
||||
// GetUserRolesForNamespace retrieves roles for a user in a specific namespace
|
||||
func (s *Store) GetUserRolesForNamespace(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID) ([]*UserRole, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at,
|
||||
r.name as role_name, r.permissions as role_permissions
|
||||
FROM compliance_user_roles ur
|
||||
JOIN compliance_roles r ON ur.role_id = r.id
|
||||
WHERE ur.user_id = $1 AND ur.tenant_id = $2
|
||||
AND (ur.namespace_id = $3 OR ur.namespace_id IS NULL)
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
ORDER BY r.hierarchy_level, r.name
|
||||
`, userID, tenantID, namespaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userRoles []*UserRole
|
||||
for rows.Next() {
|
||||
var ur UserRole
|
||||
err := rows.Scan(
|
||||
&ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID,
|
||||
&ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt,
|
||||
&ur.RoleName, &ur.RolePermissions,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
userRoles = append(userRoles, &ur)
|
||||
}
|
||||
|
||||
return userRoles, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LLM Policy Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateLLMPolicy creates a new LLM policy
|
||||
func (s *Store) CreateLLMPolicy(ctx context.Context, policy *LLMPolicy) error {
|
||||
policy.ID = uuid.New()
|
||||
policy.CreatedAt = time.Now().UTC()
|
||||
policy.UpdatedAt = policy.CreatedAt
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO compliance_llm_policies (
|
||||
id, tenant_id, namespace_id, name, description,
|
||||
allowed_data_categories, blocked_data_categories,
|
||||
require_pii_redaction, pii_redaction_level,
|
||||
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
|
||||
is_active, priority, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
`,
|
||||
policy.ID, policy.TenantID, policy.NamespaceID, policy.Name, policy.Description,
|
||||
policy.AllowedDataCategories, policy.BlockedDataCategories,
|
||||
policy.RequirePIIRedaction, policy.PIIRedactionLevel,
|
||||
policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour,
|
||||
policy.IsActive, policy.Priority, policy.CreatedAt, policy.UpdatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLLMPolicy retrieves an LLM policy by ID
|
||||
func (s *Store) GetLLMPolicy(ctx context.Context, id uuid.UUID) (*LLMPolicy, error) {
|
||||
var policy LLMPolicy
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, namespace_id, name, description,
|
||||
allowed_data_categories, blocked_data_categories,
|
||||
require_pii_redaction, pii_redaction_level,
|
||||
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
|
||||
is_active, priority, created_at, updated_at
|
||||
FROM compliance_llm_policies
|
||||
WHERE id = $1
|
||||
`, id).Scan(
|
||||
&policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description,
|
||||
&policy.AllowedDataCategories, &policy.BlockedDataCategories,
|
||||
&policy.RequirePIIRedaction, &policy.PIIRedactionLevel,
|
||||
&policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour,
|
||||
&policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt,
|
||||
)
|
||||
|
||||
return &policy, err
|
||||
}
|
||||
|
||||
// GetEffectiveLLMPolicy retrieves the effective LLM policy for a namespace
|
||||
func (s *Store) GetEffectiveLLMPolicy(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID) (*LLMPolicy, error) {
|
||||
var policy LLMPolicy
|
||||
|
||||
// Get most specific active policy (namespace-specific or tenant-wide)
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, namespace_id, name, description,
|
||||
allowed_data_categories, blocked_data_categories,
|
||||
require_pii_redaction, pii_redaction_level,
|
||||
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
|
||||
is_active, priority, created_at, updated_at
|
||||
FROM compliance_llm_policies
|
||||
WHERE tenant_id = $1
|
||||
AND is_active = TRUE
|
||||
AND (namespace_id = $2 OR namespace_id IS NULL)
|
||||
ORDER BY
|
||||
CASE WHEN namespace_id = $2 THEN 0 ELSE 1 END,
|
||||
priority ASC
|
||||
LIMIT 1
|
||||
`, tenantID, namespaceID).Scan(
|
||||
&policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description,
|
||||
&policy.AllowedDataCategories, &policy.BlockedDataCategories,
|
||||
&policy.RequirePIIRedaction, &policy.PIIRedactionLevel,
|
||||
&policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour,
|
||||
&policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil // No policy = allow all
|
||||
}
|
||||
|
||||
return &policy, err
|
||||
}
|
||||
|
||||
// ListLLMPolicies lists LLM policies for a tenant
|
||||
func (s *Store) ListLLMPolicies(ctx context.Context, tenantID uuid.UUID) ([]*LLMPolicy, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, namespace_id, name, description,
|
||||
allowed_data_categories, blocked_data_categories,
|
||||
require_pii_redaction, pii_redaction_level,
|
||||
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
|
||||
is_active, priority, created_at, updated_at
|
||||
FROM compliance_llm_policies
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY priority, name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var policies []*LLMPolicy
|
||||
for rows.Next() {
|
||||
var policy LLMPolicy
|
||||
err := rows.Scan(
|
||||
&policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description,
|
||||
&policy.AllowedDataCategories, &policy.BlockedDataCategories,
|
||||
&policy.RequirePIIRedaction, &policy.PIIRedactionLevel,
|
||||
&policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour,
|
||||
&policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
policies = append(policies, &policy)
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// UpdateLLMPolicy updates an LLM policy
|
||||
func (s *Store) UpdateLLMPolicy(ctx context.Context, policy *LLMPolicy) error {
|
||||
policy.UpdatedAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE compliance_llm_policies SET
|
||||
name = $2, description = $3,
|
||||
allowed_data_categories = $4, blocked_data_categories = $5,
|
||||
require_pii_redaction = $6, pii_redaction_level = $7,
|
||||
allowed_models = $8, max_tokens_per_request = $9, max_requests_per_day = $10, max_requests_per_hour = $11,
|
||||
is_active = $12, priority = $13, updated_at = $14
|
||||
WHERE id = $1
|
||||
`,
|
||||
policy.ID, policy.Name, policy.Description,
|
||||
policy.AllowedDataCategories, policy.BlockedDataCategories,
|
||||
policy.RequirePIIRedaction, policy.PIIRedactionLevel,
|
||||
policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour,
|
||||
policy.IsActive, policy.Priority, policy.UpdatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteLLMPolicy deletes an LLM policy
|
||||
func (s *Store) DeleteLLMPolicy(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM compliance_llm_policies WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
379
ai-compliance-sdk/internal/rbac/store_roles.go
Normal file
379
ai-compliance-sdk/internal/rbac/store_roles.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Role Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateRole creates a new role
|
||||
func (s *Store) CreateRole(ctx context.Context, role *Role) error {
|
||||
role.ID = uuid.New()
|
||||
role.CreatedAt = time.Now().UTC()
|
||||
role.UpdatedAt = role.CreatedAt
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO compliance_roles (id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, role.ID, role.TenantID, role.Name, role.Description, role.Permissions, role.IsSystemRole, role.HierarchyLevel, role.CreatedAt, role.UpdatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRole retrieves a role by ID
|
||||
func (s *Store) GetRole(ctx context.Context, id uuid.UUID) (*Role, error) {
|
||||
var role Role
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
|
||||
FROM compliance_roles
|
||||
WHERE id = $1
|
||||
`, id).Scan(
|
||||
&role.ID, &role.TenantID, &role.Name, &role.Description,
|
||||
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
|
||||
&role.CreatedAt, &role.UpdatedAt,
|
||||
)
|
||||
|
||||
return &role, err
|
||||
}
|
||||
|
||||
// GetRoleByName retrieves a role by tenant and name
|
||||
func (s *Store) GetRoleByName(ctx context.Context, tenantID *uuid.UUID, name string) (*Role, error) {
|
||||
var role Role
|
||||
|
||||
query := `
|
||||
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
|
||||
FROM compliance_roles
|
||||
WHERE name = $1 AND (tenant_id = $2 OR (tenant_id IS NULL AND is_system_role = TRUE))
|
||||
`
|
||||
|
||||
err := s.pool.QueryRow(ctx, query, name, tenantID).Scan(
|
||||
&role.ID, &role.TenantID, &role.Name, &role.Description,
|
||||
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
|
||||
&role.CreatedAt, &role.UpdatedAt,
|
||||
)
|
||||
|
||||
return &role, err
|
||||
}
|
||||
|
||||
// ListRoles lists roles for a tenant (including system roles)
|
||||
func (s *Store) ListRoles(ctx context.Context, tenantID *uuid.UUID) ([]*Role, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
|
||||
FROM compliance_roles
|
||||
WHERE tenant_id = $1 OR is_system_role = TRUE
|
||||
ORDER BY hierarchy_level, name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var roles []*Role
|
||||
for rows.Next() {
|
||||
var role Role
|
||||
err := rows.Scan(
|
||||
&role.ID, &role.TenantID, &role.Name, &role.Description,
|
||||
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
|
||||
&role.CreatedAt, &role.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
roles = append(roles, &role)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
// ListSystemRoles lists all system roles
|
||||
func (s *Store) ListSystemRoles(ctx context.Context) ([]*Role, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
|
||||
FROM compliance_roles
|
||||
WHERE is_system_role = TRUE
|
||||
ORDER BY hierarchy_level, name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var roles []*Role
|
||||
for rows.Next() {
|
||||
var role Role
|
||||
err := rows.Scan(
|
||||
&role.ID, &role.TenantID, &role.Name, &role.Description,
|
||||
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
|
||||
&role.CreatedAt, &role.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
roles = append(roles, &role)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// User Role Operations
|
||||
// ============================================================================
|
||||
|
||||
// AssignRole assigns a role to a user
|
||||
func (s *Store) AssignRole(ctx context.Context, ur *UserRole) error {
|
||||
ur.ID = uuid.New()
|
||||
ur.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO compliance_user_roles (id, user_id, role_id, tenant_id, namespace_id, granted_by, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (user_id, role_id, tenant_id, namespace_id) DO UPDATE SET
|
||||
granted_by = EXCLUDED.granted_by,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
`, ur.ID, ur.UserID, ur.RoleID, ur.TenantID, ur.NamespaceID, ur.GrantedBy, ur.ExpiresAt, ur.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeRole revokes a role from a user
|
||||
func (s *Store) RevokeRole(ctx context.Context, userID, roleID, tenantID uuid.UUID, namespaceID *uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM compliance_user_roles
|
||||
WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3 AND (namespace_id = $4 OR (namespace_id IS NULL AND $4 IS NULL))
|
||||
`, userID, roleID, tenantID, namespaceID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserRoles retrieves all roles for a user in a tenant
|
||||
func (s *Store) GetUserRoles(ctx context.Context, userID, tenantID uuid.UUID) ([]*UserRole, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at,
|
||||
r.name as role_name, r.permissions as role_permissions,
|
||||
n.name as namespace_name
|
||||
FROM compliance_user_roles ur
|
||||
JOIN compliance_roles r ON ur.role_id = r.id
|
||||
LEFT JOIN compliance_namespaces n ON ur.namespace_id = n.id
|
||||
WHERE ur.user_id = $1 AND ur.tenant_id = $2
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
ORDER BY r.hierarchy_level, r.name
|
||||
`, userID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userRoles []*UserRole
|
||||
for rows.Next() {
|
||||
var ur UserRole
|
||||
var namespaceName *string
|
||||
|
||||
err := rows.Scan(
|
||||
&ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID,
|
||||
&ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt,
|
||||
&ur.RoleName, &ur.RolePermissions, &namespaceName,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if namespaceName != nil {
|
||||
ur.NamespaceName = *namespaceName
|
||||
}
|
||||
|
||||
userRoles = append(userRoles, &ur)
|
||||
}
|
||||
|
||||
return userRoles, nil
|
||||
}
|
||||
|
||||
// GetUserRolesForNamespace retrieves roles for a user in a specific namespace
|
||||
func (s *Store) GetUserRolesForNamespace(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID) ([]*UserRole, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at,
|
||||
r.name as role_name, r.permissions as role_permissions
|
||||
FROM compliance_user_roles ur
|
||||
JOIN compliance_roles r ON ur.role_id = r.id
|
||||
WHERE ur.user_id = $1 AND ur.tenant_id = $2
|
||||
AND (ur.namespace_id = $3 OR ur.namespace_id IS NULL)
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
ORDER BY r.hierarchy_level, r.name
|
||||
`, userID, tenantID, namespaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userRoles []*UserRole
|
||||
for rows.Next() {
|
||||
var ur UserRole
|
||||
err := rows.Scan(
|
||||
&ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID,
|
||||
&ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt,
|
||||
&ur.RoleName, &ur.RolePermissions,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
userRoles = append(userRoles, &ur)
|
||||
}
|
||||
|
||||
return userRoles, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LLM Policy Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateLLMPolicy creates a new LLM policy
|
||||
func (s *Store) CreateLLMPolicy(ctx context.Context, policy *LLMPolicy) error {
|
||||
policy.ID = uuid.New()
|
||||
policy.CreatedAt = time.Now().UTC()
|
||||
policy.UpdatedAt = policy.CreatedAt
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO compliance_llm_policies (
|
||||
id, tenant_id, namespace_id, name, description,
|
||||
allowed_data_categories, blocked_data_categories,
|
||||
require_pii_redaction, pii_redaction_level,
|
||||
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
|
||||
is_active, priority, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
`,
|
||||
policy.ID, policy.TenantID, policy.NamespaceID, policy.Name, policy.Description,
|
||||
policy.AllowedDataCategories, policy.BlockedDataCategories,
|
||||
policy.RequirePIIRedaction, policy.PIIRedactionLevel,
|
||||
policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour,
|
||||
policy.IsActive, policy.Priority, policy.CreatedAt, policy.UpdatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLLMPolicy retrieves an LLM policy by ID
|
||||
func (s *Store) GetLLMPolicy(ctx context.Context, id uuid.UUID) (*LLMPolicy, error) {
|
||||
var policy LLMPolicy
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, namespace_id, name, description,
|
||||
allowed_data_categories, blocked_data_categories,
|
||||
require_pii_redaction, pii_redaction_level,
|
||||
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
|
||||
is_active, priority, created_at, updated_at
|
||||
FROM compliance_llm_policies
|
||||
WHERE id = $1
|
||||
`, id).Scan(
|
||||
&policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description,
|
||||
&policy.AllowedDataCategories, &policy.BlockedDataCategories,
|
||||
&policy.RequirePIIRedaction, &policy.PIIRedactionLevel,
|
||||
&policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour,
|
||||
&policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt,
|
||||
)
|
||||
|
||||
return &policy, err
|
||||
}
|
||||
|
||||
// GetEffectiveLLMPolicy retrieves the effective LLM policy for a namespace
|
||||
func (s *Store) GetEffectiveLLMPolicy(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID) (*LLMPolicy, error) {
|
||||
var policy LLMPolicy
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, namespace_id, name, description,
|
||||
allowed_data_categories, blocked_data_categories,
|
||||
require_pii_redaction, pii_redaction_level,
|
||||
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
|
||||
is_active, priority, created_at, updated_at
|
||||
FROM compliance_llm_policies
|
||||
WHERE tenant_id = $1
|
||||
AND is_active = TRUE
|
||||
AND (namespace_id = $2 OR namespace_id IS NULL)
|
||||
ORDER BY
|
||||
CASE WHEN namespace_id = $2 THEN 0 ELSE 1 END,
|
||||
priority ASC
|
||||
LIMIT 1
|
||||
`, tenantID, namespaceID).Scan(
|
||||
&policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description,
|
||||
&policy.AllowedDataCategories, &policy.BlockedDataCategories,
|
||||
&policy.RequirePIIRedaction, &policy.PIIRedactionLevel,
|
||||
&policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour,
|
||||
&policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &policy, err
|
||||
}
|
||||
|
||||
// ListLLMPolicies lists LLM policies for a tenant
|
||||
func (s *Store) ListLLMPolicies(ctx context.Context, tenantID uuid.UUID) ([]*LLMPolicy, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, namespace_id, name, description,
|
||||
allowed_data_categories, blocked_data_categories,
|
||||
require_pii_redaction, pii_redaction_level,
|
||||
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
|
||||
is_active, priority, created_at, updated_at
|
||||
FROM compliance_llm_policies
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY priority, name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var policies []*LLMPolicy
|
||||
for rows.Next() {
|
||||
var policy LLMPolicy
|
||||
err := rows.Scan(
|
||||
&policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description,
|
||||
&policy.AllowedDataCategories, &policy.BlockedDataCategories,
|
||||
&policy.RequirePIIRedaction, &policy.PIIRedactionLevel,
|
||||
&policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour,
|
||||
&policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
policies = append(policies, &policy)
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// UpdateLLMPolicy updates an LLM policy
|
||||
func (s *Store) UpdateLLMPolicy(ctx context.Context, policy *LLMPolicy) error {
|
||||
policy.UpdatedAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE compliance_llm_policies SET
|
||||
name = $2, description = $3,
|
||||
allowed_data_categories = $4, blocked_data_categories = $5,
|
||||
require_pii_redaction = $6, pii_redaction_level = $7,
|
||||
allowed_models = $8, max_tokens_per_request = $9, max_requests_per_day = $10, max_requests_per_hour = $11,
|
||||
is_active = $12, priority = $13, updated_at = $14
|
||||
WHERE id = $1
|
||||
`,
|
||||
policy.ID, policy.Name, policy.Description,
|
||||
policy.AllowedDataCategories, policy.BlockedDataCategories,
|
||||
policy.RequirePIIRedaction, policy.PIIRedactionLevel,
|
||||
policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour,
|
||||
policy.IsActive, policy.Priority, policy.UpdatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteLLMPolicy deletes an LLM policy
|
||||
func (s *Store) DeleteLLMPolicy(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM compliance_llm_policies WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
@@ -40,21 +38,21 @@ var ColumnMapping = map[string][]string{
|
||||
|
||||
// DetectedColumn represents a detected column mapping
|
||||
type DetectedColumn struct {
|
||||
Index int `json:"index"`
|
||||
Header string `json:"header"`
|
||||
MappedTo string `json:"mapped_to"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Index int `json:"index"`
|
||||
Header string `json:"header"`
|
||||
MappedTo string `json:"mapped_to"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// ParseResult contains the result of parsing a file
|
||||
type ParseResult struct {
|
||||
Format ImportFormat `json:"format"`
|
||||
TotalRows int `json:"total_rows"`
|
||||
ValidRows int `json:"valid_rows"`
|
||||
InvalidRows int `json:"invalid_rows"`
|
||||
Columns []DetectedColumn `json:"columns"`
|
||||
Items []ParsedItem `json:"items"`
|
||||
Errors []string `json:"errors"`
|
||||
Format ImportFormat `json:"format"`
|
||||
TotalRows int `json:"total_rows"`
|
||||
ValidRows int `json:"valid_rows"`
|
||||
InvalidRows int `json:"invalid_rows"`
|
||||
Columns []DetectedColumn `json:"columns"`
|
||||
Items []ParsedItem `json:"items"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// ParseFile detects format and parses the file
|
||||
@@ -87,7 +85,6 @@ func (p *Parser) detectFormat(filename string, contentType string) ImportFormat
|
||||
return ImportFormatJSON
|
||||
}
|
||||
|
||||
// Check content type
|
||||
switch contentType {
|
||||
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel":
|
||||
@@ -113,7 +110,6 @@ func (p *Parser) parseExcel(data []byte) (*ParseResult, error) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Get the first sheet
|
||||
sheets := f.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return nil, fmt.Errorf("no sheets found in Excel file")
|
||||
@@ -128,13 +124,11 @@ func (p *Parser) parseExcel(data []byte) (*ParseResult, error) {
|
||||
return nil, fmt.Errorf("file must have at least a header row and one data row")
|
||||
}
|
||||
|
||||
// Detect column mappings from header
|
||||
headers := rows[0]
|
||||
result.Columns = p.detectColumns(headers)
|
||||
|
||||
// Parse data rows
|
||||
for i, row := range rows[1:] {
|
||||
rowNum := i + 2 // 1-based, skip header
|
||||
rowNum := i + 2
|
||||
item := p.parseRow(row, result.Columns, rowNum)
|
||||
result.Items = append(result.Items, item)
|
||||
result.TotalRows++
|
||||
@@ -158,7 +152,6 @@ func (p *Parser) parseCSV(data []byte) (*ParseResult, error) {
|
||||
reader.LazyQuotes = true
|
||||
reader.TrimLeadingSpace = true
|
||||
|
||||
// Try different delimiters
|
||||
delimiters := []rune{',', ';', '\t'}
|
||||
var records [][]string
|
||||
var err error
|
||||
@@ -182,11 +175,9 @@ func (p *Parser) parseCSV(data []byte) (*ParseResult, error) {
|
||||
return nil, fmt.Errorf("file must have at least a header row and one data row")
|
||||
}
|
||||
|
||||
// Detect column mappings from header
|
||||
headers := records[0]
|
||||
result.Columns = p.detectColumns(headers)
|
||||
|
||||
// Parse data rows
|
||||
for i, row := range records[1:] {
|
||||
rowNum := i + 2
|
||||
item := p.parseRow(row, result.Columns, rowNum)
|
||||
@@ -208,10 +199,8 @@ func (p *Parser) parseJSON(data []byte) (*ParseResult, error) {
|
||||
Format: ImportFormatJSON,
|
||||
}
|
||||
|
||||
// Try parsing as array of items
|
||||
var items []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &items); err != nil {
|
||||
// Try parsing as object with items array
|
||||
var wrapper struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
}
|
||||
@@ -225,18 +214,15 @@ func (p *Parser) parseJSON(data []byte) (*ParseResult, error) {
|
||||
return nil, fmt.Errorf("no items found in JSON file")
|
||||
}
|
||||
|
||||
// Detect columns from first item
|
||||
headers := make([]string, 0)
|
||||
for key := range items[0] {
|
||||
headers = append(headers, key)
|
||||
}
|
||||
result.Columns = p.detectColumns(headers)
|
||||
|
||||
// Parse items
|
||||
for i, itemMap := range items {
|
||||
rowNum := i + 1
|
||||
|
||||
// Convert map to row slice
|
||||
row := make([]string, len(result.Columns))
|
||||
for j, col := range result.Columns {
|
||||
if val, ok := itemMap[col.Header]; ok {
|
||||
@@ -270,7 +256,6 @@ func (p *Parser) detectColumns(headers []string) []DetectedColumn {
|
||||
|
||||
headerLower := strings.ToLower(strings.TrimSpace(header))
|
||||
|
||||
// Try to match against known column names
|
||||
for fieldName, variations := range ColumnMapping {
|
||||
for _, variation := range variations {
|
||||
if headerLower == variation || strings.Contains(headerLower, variation) {
|
||||
@@ -292,249 +277,3 @@ func (p *Parser) detectColumns(headers []string) []DetectedColumn {
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
// parseRow parses a single row into a ParsedItem
|
||||
func (p *Parser) parseRow(row []string, columns []DetectedColumn, rowNum int) ParsedItem {
|
||||
item := ParsedItem{
|
||||
RowNumber: rowNum,
|
||||
IsValid: true,
|
||||
Data: RoadmapItemInput{},
|
||||
}
|
||||
|
||||
// Build a map for easy access
|
||||
values := make(map[string]string)
|
||||
for i, col := range columns {
|
||||
if i < len(row) && col.MappedTo != "" {
|
||||
values[col.MappedTo] = strings.TrimSpace(row[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Extract title (required)
|
||||
if title, ok := values["title"]; ok && title != "" {
|
||||
item.Data.Title = title
|
||||
} else {
|
||||
item.IsValid = false
|
||||
item.Errors = append(item.Errors, "Titel/Title ist erforderlich")
|
||||
}
|
||||
|
||||
// Extract optional fields
|
||||
if desc, ok := values["description"]; ok {
|
||||
item.Data.Description = desc
|
||||
}
|
||||
|
||||
// Category
|
||||
if cat, ok := values["category"]; ok && cat != "" {
|
||||
item.Data.Category = p.parseCategory(cat)
|
||||
if item.Data.Category == "" {
|
||||
item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannte Kategorie: %s", cat))
|
||||
item.Data.Category = ItemCategoryTechnical
|
||||
}
|
||||
}
|
||||
|
||||
// Priority
|
||||
if prio, ok := values["priority"]; ok && prio != "" {
|
||||
item.Data.Priority = p.parsePriority(prio)
|
||||
if item.Data.Priority == "" {
|
||||
item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannte Priorität: %s", prio))
|
||||
item.Data.Priority = ItemPriorityMedium
|
||||
}
|
||||
}
|
||||
|
||||
// Status
|
||||
if status, ok := values["status"]; ok && status != "" {
|
||||
item.Data.Status = p.parseStatus(status)
|
||||
if item.Data.Status == "" {
|
||||
item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannter Status: %s", status))
|
||||
item.Data.Status = ItemStatusPlanned
|
||||
}
|
||||
}
|
||||
|
||||
// Control ID
|
||||
if ctrl, ok := values["control_id"]; ok {
|
||||
item.Data.ControlID = ctrl
|
||||
}
|
||||
|
||||
// Regulation reference
|
||||
if reg, ok := values["regulation_ref"]; ok {
|
||||
item.Data.RegulationRef = reg
|
||||
}
|
||||
|
||||
// Gap ID
|
||||
if gap, ok := values["gap_id"]; ok {
|
||||
item.Data.GapID = gap
|
||||
}
|
||||
|
||||
// Effort
|
||||
if effort, ok := values["effort_days"]; ok && effort != "" {
|
||||
if days, err := strconv.Atoi(effort); err == nil {
|
||||
item.Data.EffortDays = &days
|
||||
}
|
||||
}
|
||||
|
||||
// Assignee
|
||||
if assignee, ok := values["assignee"]; ok {
|
||||
item.Data.AssigneeName = assignee
|
||||
}
|
||||
|
||||
// Department
|
||||
if dept, ok := values["department"]; ok {
|
||||
item.Data.Department = dept
|
||||
}
|
||||
|
||||
// Dates
|
||||
if startStr, ok := values["planned_start"]; ok && startStr != "" {
|
||||
if start := p.parseDate(startStr); start != nil {
|
||||
item.Data.PlannedStart = start
|
||||
}
|
||||
}
|
||||
|
||||
if endStr, ok := values["planned_end"]; ok && endStr != "" {
|
||||
if end := p.parseDate(endStr); end != nil {
|
||||
item.Data.PlannedEnd = end
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if notes, ok := values["notes"]; ok {
|
||||
item.Data.Notes = notes
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// parseCategory converts a string to ItemCategory
|
||||
func (p *Parser) parseCategory(s string) ItemCategory {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
|
||||
switch {
|
||||
case strings.Contains(s, "tech"):
|
||||
return ItemCategoryTechnical
|
||||
case strings.Contains(s, "org"):
|
||||
return ItemCategoryOrganizational
|
||||
case strings.Contains(s, "proz") || strings.Contains(s, "process"):
|
||||
return ItemCategoryProcessual
|
||||
case strings.Contains(s, "dok") || strings.Contains(s, "doc"):
|
||||
return ItemCategoryDocumentation
|
||||
case strings.Contains(s, "train") || strings.Contains(s, "schul"):
|
||||
return ItemCategoryTraining
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parsePriority converts a string to ItemPriority
|
||||
func (p *Parser) parsePriority(s string) ItemPriority {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
|
||||
switch {
|
||||
case strings.Contains(s, "crit") || strings.Contains(s, "krit") || s == "1":
|
||||
return ItemPriorityCritical
|
||||
case strings.Contains(s, "high") || strings.Contains(s, "hoch") || s == "2":
|
||||
return ItemPriorityHigh
|
||||
case strings.Contains(s, "med") || strings.Contains(s, "mitt") || s == "3":
|
||||
return ItemPriorityMedium
|
||||
case strings.Contains(s, "low") || strings.Contains(s, "nied") || s == "4":
|
||||
return ItemPriorityLow
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parseStatus converts a string to ItemStatus
|
||||
func (p *Parser) parseStatus(s string) ItemStatus {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
|
||||
switch {
|
||||
case strings.Contains(s, "plan") || strings.Contains(s, "offen") || strings.Contains(s, "open"):
|
||||
return ItemStatusPlanned
|
||||
case strings.Contains(s, "progress") || strings.Contains(s, "lauf") || strings.Contains(s, "arbeit"):
|
||||
return ItemStatusInProgress
|
||||
case strings.Contains(s, "block") || strings.Contains(s, "wart"):
|
||||
return ItemStatusBlocked
|
||||
case strings.Contains(s, "complet") || strings.Contains(s, "done") || strings.Contains(s, "fertig") || strings.Contains(s, "erledigt"):
|
||||
return ItemStatusCompleted
|
||||
case strings.Contains(s, "defer") || strings.Contains(s, "zurück") || strings.Contains(s, "verschob"):
|
||||
return ItemStatusDeferred
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parseDate attempts to parse various date formats
|
||||
func (p *Parser) parseDate(s string) *time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"02.01.2006",
|
||||
"2.1.2006",
|
||||
"02/01/2006",
|
||||
"2/1/2006",
|
||||
"01/02/2006",
|
||||
"1/2/2006",
|
||||
"2006/01/02",
|
||||
time.RFC3339,
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAndEnrich validates parsed items and enriches them with mappings
|
||||
func (p *Parser) ValidateAndEnrich(items []ParsedItem, controls []string, regulations []string, gaps []string) []ParsedItem {
|
||||
// Build lookup maps
|
||||
controlSet := make(map[string]bool)
|
||||
for _, c := range controls {
|
||||
controlSet[strings.ToLower(c)] = true
|
||||
}
|
||||
|
||||
regSet := make(map[string]bool)
|
||||
for _, r := range regulations {
|
||||
regSet[strings.ToLower(r)] = true
|
||||
}
|
||||
|
||||
gapSet := make(map[string]bool)
|
||||
for _, g := range gaps {
|
||||
gapSet[strings.ToLower(g)] = true
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
item := &items[i]
|
||||
|
||||
// Validate control ID
|
||||
if item.Data.ControlID != "" {
|
||||
if controlSet[strings.ToLower(item.Data.ControlID)] {
|
||||
item.MatchedControl = item.Data.ControlID
|
||||
item.MatchConfidence = 1.0
|
||||
} else {
|
||||
item.Warnings = append(item.Warnings, fmt.Sprintf("Control '%s' nicht im Katalog gefunden", item.Data.ControlID))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate regulation reference
|
||||
if item.Data.RegulationRef != "" {
|
||||
if regSet[strings.ToLower(item.Data.RegulationRef)] {
|
||||
item.MatchedRegulation = item.Data.RegulationRef
|
||||
}
|
||||
}
|
||||
|
||||
// Validate gap ID
|
||||
if item.Data.GapID != "" {
|
||||
if gapSet[strings.ToLower(item.Data.GapID)] {
|
||||
item.MatchedGap = item.Data.GapID
|
||||
} else {
|
||||
item.Warnings = append(item.Warnings, fmt.Sprintf("Gap '%s' nicht im Mapping gefunden", item.Data.GapID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
236
ai-compliance-sdk/internal/roadmap/parser_row.go
Normal file
236
ai-compliance-sdk/internal/roadmap/parser_row.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package roadmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parseRow parses a single row into a ParsedItem
|
||||
func (p *Parser) parseRow(row []string, columns []DetectedColumn, rowNum int) ParsedItem {
|
||||
item := ParsedItem{
|
||||
RowNumber: rowNum,
|
||||
IsValid: true,
|
||||
Data: RoadmapItemInput{},
|
||||
}
|
||||
|
||||
values := make(map[string]string)
|
||||
for i, col := range columns {
|
||||
if i < len(row) && col.MappedTo != "" {
|
||||
values[col.MappedTo] = strings.TrimSpace(row[i])
|
||||
}
|
||||
}
|
||||
|
||||
if title, ok := values["title"]; ok && title != "" {
|
||||
item.Data.Title = title
|
||||
} else {
|
||||
item.IsValid = false
|
||||
item.Errors = append(item.Errors, "Titel/Title ist erforderlich")
|
||||
}
|
||||
|
||||
if desc, ok := values["description"]; ok {
|
||||
item.Data.Description = desc
|
||||
}
|
||||
|
||||
if cat, ok := values["category"]; ok && cat != "" {
|
||||
item.Data.Category = p.parseCategory(cat)
|
||||
if item.Data.Category == "" {
|
||||
item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannte Kategorie: %s", cat))
|
||||
item.Data.Category = ItemCategoryTechnical
|
||||
}
|
||||
}
|
||||
|
||||
if prio, ok := values["priority"]; ok && prio != "" {
|
||||
item.Data.Priority = p.parsePriority(prio)
|
||||
if item.Data.Priority == "" {
|
||||
item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannte Priorität: %s", prio))
|
||||
item.Data.Priority = ItemPriorityMedium
|
||||
}
|
||||
}
|
||||
|
||||
if status, ok := values["status"]; ok && status != "" {
|
||||
item.Data.Status = p.parseStatus(status)
|
||||
if item.Data.Status == "" {
|
||||
item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannter Status: %s", status))
|
||||
item.Data.Status = ItemStatusPlanned
|
||||
}
|
||||
}
|
||||
|
||||
if ctrl, ok := values["control_id"]; ok {
|
||||
item.Data.ControlID = ctrl
|
||||
}
|
||||
|
||||
if reg, ok := values["regulation_ref"]; ok {
|
||||
item.Data.RegulationRef = reg
|
||||
}
|
||||
|
||||
if gap, ok := values["gap_id"]; ok {
|
||||
item.Data.GapID = gap
|
||||
}
|
||||
|
||||
if effort, ok := values["effort_days"]; ok && effort != "" {
|
||||
if days, err := strconv.Atoi(effort); err == nil {
|
||||
item.Data.EffortDays = &days
|
||||
}
|
||||
}
|
||||
|
||||
if assignee, ok := values["assignee"]; ok {
|
||||
item.Data.AssigneeName = assignee
|
||||
}
|
||||
|
||||
if dept, ok := values["department"]; ok {
|
||||
item.Data.Department = dept
|
||||
}
|
||||
|
||||
if startStr, ok := values["planned_start"]; ok && startStr != "" {
|
||||
if start := p.parseDate(startStr); start != nil {
|
||||
item.Data.PlannedStart = start
|
||||
}
|
||||
}
|
||||
|
||||
if endStr, ok := values["planned_end"]; ok && endStr != "" {
|
||||
if end := p.parseDate(endStr); end != nil {
|
||||
item.Data.PlannedEnd = end
|
||||
}
|
||||
}
|
||||
|
||||
if notes, ok := values["notes"]; ok {
|
||||
item.Data.Notes = notes
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// parseCategory converts a string to ItemCategory
|
||||
func (p *Parser) parseCategory(s string) ItemCategory {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
|
||||
switch {
|
||||
case strings.Contains(s, "tech"):
|
||||
return ItemCategoryTechnical
|
||||
case strings.Contains(s, "org"):
|
||||
return ItemCategoryOrganizational
|
||||
case strings.Contains(s, "proz") || strings.Contains(s, "process"):
|
||||
return ItemCategoryProcessual
|
||||
case strings.Contains(s, "dok") || strings.Contains(s, "doc"):
|
||||
return ItemCategoryDocumentation
|
||||
case strings.Contains(s, "train") || strings.Contains(s, "schul"):
|
||||
return ItemCategoryTraining
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parsePriority converts a string to ItemPriority
|
||||
func (p *Parser) parsePriority(s string) ItemPriority {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
|
||||
switch {
|
||||
case strings.Contains(s, "crit") || strings.Contains(s, "krit") || s == "1":
|
||||
return ItemPriorityCritical
|
||||
case strings.Contains(s, "high") || strings.Contains(s, "hoch") || s == "2":
|
||||
return ItemPriorityHigh
|
||||
case strings.Contains(s, "med") || strings.Contains(s, "mitt") || s == "3":
|
||||
return ItemPriorityMedium
|
||||
case strings.Contains(s, "low") || strings.Contains(s, "nied") || s == "4":
|
||||
return ItemPriorityLow
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parseStatus converts a string to ItemStatus
|
||||
func (p *Parser) parseStatus(s string) ItemStatus {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
|
||||
switch {
|
||||
case strings.Contains(s, "plan") || strings.Contains(s, "offen") || strings.Contains(s, "open"):
|
||||
return ItemStatusPlanned
|
||||
case strings.Contains(s, "progress") || strings.Contains(s, "lauf") || strings.Contains(s, "arbeit"):
|
||||
return ItemStatusInProgress
|
||||
case strings.Contains(s, "block") || strings.Contains(s, "wart"):
|
||||
return ItemStatusBlocked
|
||||
case strings.Contains(s, "complet") || strings.Contains(s, "done") || strings.Contains(s, "fertig") || strings.Contains(s, "erledigt"):
|
||||
return ItemStatusCompleted
|
||||
case strings.Contains(s, "defer") || strings.Contains(s, "zurück") || strings.Contains(s, "verschob"):
|
||||
return ItemStatusDeferred
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parseDate attempts to parse various date formats
|
||||
func (p *Parser) parseDate(s string) *time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"02.01.2006",
|
||||
"2.1.2006",
|
||||
"02/01/2006",
|
||||
"2/1/2006",
|
||||
"01/02/2006",
|
||||
"1/2/2006",
|
||||
"2006/01/02",
|
||||
time.RFC3339,
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAndEnrich validates parsed items and enriches them with mappings
|
||||
func (p *Parser) ValidateAndEnrich(items []ParsedItem, controls []string, regulations []string, gaps []string) []ParsedItem {
|
||||
controlSet := make(map[string]bool)
|
||||
for _, c := range controls {
|
||||
controlSet[strings.ToLower(c)] = true
|
||||
}
|
||||
|
||||
regSet := make(map[string]bool)
|
||||
for _, r := range regulations {
|
||||
regSet[strings.ToLower(r)] = true
|
||||
}
|
||||
|
||||
gapSet := make(map[string]bool)
|
||||
for _, g := range gaps {
|
||||
gapSet[strings.ToLower(g)] = true
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
item := &items[i]
|
||||
|
||||
if item.Data.ControlID != "" {
|
||||
if controlSet[strings.ToLower(item.Data.ControlID)] {
|
||||
item.MatchedControl = item.Data.ControlID
|
||||
item.MatchConfidence = 1.0
|
||||
} else {
|
||||
item.Warnings = append(item.Warnings, fmt.Sprintf("Control '%s' nicht im Katalog gefunden", item.Data.ControlID))
|
||||
}
|
||||
}
|
||||
|
||||
if item.Data.RegulationRef != "" {
|
||||
if regSet[strings.ToLower(item.Data.RegulationRef)] {
|
||||
item.MatchedRegulation = item.Data.RegulationRef
|
||||
}
|
||||
}
|
||||
|
||||
if item.Data.GapID != "" {
|
||||
if gapSet[strings.ToLower(item.Data.GapID)] {
|
||||
item.MatchedGap = item.Data.GapID
|
||||
} else {
|
||||
item.Warnings = append(item.Warnings, fmt.Sprintf("Gap '%s' nicht im Mapping gefunden", item.Data.GapID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -32,17 +32,15 @@ func (s *Store) CreateReport(ctx context.Context, report *Report) error {
|
||||
report.CreatedAt = now
|
||||
report.UpdatedAt = now
|
||||
report.ReceivedAt = now
|
||||
report.DeadlineAcknowledgment = now.AddDate(0, 0, 7) // 7 days per HinSchG
|
||||
report.DeadlineFeedback = now.AddDate(0, 3, 0) // 3 months per HinSchG
|
||||
report.DeadlineAcknowledgment = now.AddDate(0, 0, 7) // 7 days per HinSchG
|
||||
report.DeadlineFeedback = now.AddDate(0, 3, 0) // 3 months per HinSchG
|
||||
|
||||
if report.Status == "" {
|
||||
report.Status = ReportStatusNew
|
||||
}
|
||||
|
||||
// Generate access key
|
||||
report.AccessKey = generateAccessKey()
|
||||
|
||||
// Generate reference number
|
||||
year := now.Year()
|
||||
seq, err := s.GetNextSequenceNumber(ctx, report.TenantID, year)
|
||||
if err != nil {
|
||||
@@ -50,7 +48,6 @@ func (s *Store) CreateReport(ctx context.Context, report *Report) error {
|
||||
}
|
||||
report.ReferenceNumber = generateReferenceNumber(year, seq)
|
||||
|
||||
// Initialize audit trail
|
||||
if report.AuditTrail == nil {
|
||||
report.AuditTrail = []AuditEntry{}
|
||||
}
|
||||
@@ -154,7 +151,6 @@ func (s *Store) GetReportByAccessKey(ctx context.Context, accessKey string) (*Re
|
||||
|
||||
// ListReports lists reports for a tenant with optional filters
|
||||
func (s *Store) ListReports(ctx context.Context, tenantID uuid.UUID, filters *ReportFilters) ([]Report, int, error) {
|
||||
// Count total
|
||||
countQuery := "SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1"
|
||||
countArgs := []interface{}{tenantID}
|
||||
countArgIdx := 2
|
||||
@@ -178,7 +174,6 @@ func (s *Store) ListReports(ctx context.Context, tenantID uuid.UUID, filters *Re
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Build data query
|
||||
query := `
|
||||
SELECT
|
||||
id, tenant_id, reference_number, access_key,
|
||||
@@ -249,9 +244,7 @@ func (s *Store) ListReports(ctx context.Context, tenantID uuid.UUID, filters *Re
|
||||
report.Status = ReportStatus(status)
|
||||
json.Unmarshal(auditTrailJSON, &report.AuditTrail)
|
||||
|
||||
// Do not expose access key in list responses
|
||||
report.AccessKey = ""
|
||||
|
||||
reports = append(reports, report)
|
||||
}
|
||||
|
||||
@@ -362,230 +355,3 @@ func (s *Store) DeleteReport(ctx context.Context, id uuid.UUID) error {
|
||||
_, err = s.pool.Exec(ctx, "DELETE FROM whistleblower_reports WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Operations
|
||||
// ============================================================================
|
||||
|
||||
// AddMessage adds an anonymous message to a report
|
||||
func (s *Store) AddMessage(ctx context.Context, msg *AnonymousMessage) error {
|
||||
msg.ID = uuid.New()
|
||||
msg.SentAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO whistleblower_messages (
|
||||
id, report_id, direction, content, sent_at, read_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6
|
||||
)
|
||||
`,
|
||||
msg.ID, msg.ReportID, string(msg.Direction), msg.Content, msg.SentAt, msg.ReadAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMessages lists messages for a report
|
||||
func (s *Store) ListMessages(ctx context.Context, reportID uuid.UUID) ([]AnonymousMessage, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
id, report_id, direction, content, sent_at, read_at
|
||||
FROM whistleblower_messages WHERE report_id = $1
|
||||
ORDER BY sent_at ASC
|
||||
`, reportID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []AnonymousMessage
|
||||
for rows.Next() {
|
||||
var msg AnonymousMessage
|
||||
var direction string
|
||||
|
||||
err := rows.Scan(
|
||||
&msg.ID, &msg.ReportID, &direction, &msg.Content, &msg.SentAt, &msg.ReadAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg.Direction = MessageDirection(direction)
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Measure Operations
|
||||
// ============================================================================
|
||||
|
||||
// AddMeasure adds a corrective measure to a report
|
||||
func (s *Store) AddMeasure(ctx context.Context, measure *Measure) error {
|
||||
measure.ID = uuid.New()
|
||||
measure.CreatedAt = time.Now().UTC()
|
||||
if measure.Status == "" {
|
||||
measure.Status = MeasureStatusPlanned
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO whistleblower_measures (
|
||||
id, report_id, title, description, status,
|
||||
responsible, due_date, completed_at, created_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9
|
||||
)
|
||||
`,
|
||||
measure.ID, measure.ReportID, measure.Title, measure.Description, string(measure.Status),
|
||||
measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMeasures lists measures for a report
|
||||
func (s *Store) ListMeasures(ctx context.Context, reportID uuid.UUID) ([]Measure, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
id, report_id, title, description, status,
|
||||
responsible, due_date, completed_at, created_at
|
||||
FROM whistleblower_measures WHERE report_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, reportID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var measures []Measure
|
||||
for rows.Next() {
|
||||
var m Measure
|
||||
var status string
|
||||
|
||||
err := rows.Scan(
|
||||
&m.ID, &m.ReportID, &m.Title, &m.Description, &status,
|
||||
&m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.Status = MeasureStatus(status)
|
||||
measures = append(measures, m)
|
||||
}
|
||||
|
||||
return measures, nil
|
||||
}
|
||||
|
||||
// UpdateMeasure updates a measure
|
||||
func (s *Store) UpdateMeasure(ctx context.Context, measure *Measure) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE whistleblower_measures SET
|
||||
title = $2, description = $3, status = $4,
|
||||
responsible = $5, due_date = $6, completed_at = $7
|
||||
WHERE id = $1
|
||||
`,
|
||||
measure.ID,
|
||||
measure.Title, measure.Description, string(measure.Status),
|
||||
measure.Responsible, measure.DueDate, measure.CompletedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
// GetStatistics returns aggregated whistleblower statistics for a tenant
|
||||
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*WhistleblowerStatistics, error) {
|
||||
stats := &WhistleblowerStatistics{
|
||||
ByStatus: make(map[string]int),
|
||||
ByCategory: make(map[string]int),
|
||||
}
|
||||
|
||||
// Total reports
|
||||
s.pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1",
|
||||
tenantID).Scan(&stats.TotalReports)
|
||||
|
||||
// By status
|
||||
rows, err := s.pool.Query(ctx,
|
||||
"SELECT status, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY status",
|
||||
tenantID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var count int
|
||||
rows.Scan(&status, &count)
|
||||
stats.ByStatus[status] = count
|
||||
}
|
||||
}
|
||||
|
||||
// By category
|
||||
rows, err = s.pool.Query(ctx,
|
||||
"SELECT category, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY category",
|
||||
tenantID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var category string
|
||||
var count int
|
||||
rows.Scan(&category, &count)
|
||||
stats.ByCategory[category] = count
|
||||
}
|
||||
}
|
||||
|
||||
// Overdue acknowledgments: reports past deadline_acknowledgment that haven't been acknowledged
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM whistleblower_reports
|
||||
WHERE tenant_id = $1
|
||||
AND acknowledged_at IS NULL
|
||||
AND status = 'new'
|
||||
AND deadline_acknowledgment < NOW()
|
||||
`, tenantID).Scan(&stats.OverdueAcknowledgments)
|
||||
|
||||
// Overdue feedbacks: reports past deadline_feedback that are still open
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM whistleblower_reports
|
||||
WHERE tenant_id = $1
|
||||
AND closed_at IS NULL
|
||||
AND status NOT IN ('closed', 'rejected')
|
||||
AND deadline_feedback < NOW()
|
||||
`, tenantID).Scan(&stats.OverdueFeedbacks)
|
||||
|
||||
// Average resolution days (for closed reports)
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - received_at)) / 86400), 0)
|
||||
FROM whistleblower_reports
|
||||
WHERE tenant_id = $1 AND closed_at IS NOT NULL
|
||||
`, tenantID).Scan(&stats.AvgResolutionDays)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sequence Number
|
||||
// ============================================================================
|
||||
|
||||
// GetNextSequenceNumber gets and increments the sequence number for reference number generation
|
||||
func (s *Store) GetNextSequenceNumber(ctx context.Context, tenantID uuid.UUID, year int) (int, error) {
|
||||
var seq int
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO whistleblower_sequences (tenant_id, year, last_sequence)
|
||||
VALUES ($1, $2, 1)
|
||||
ON CONFLICT (tenant_id, year) DO UPDATE SET
|
||||
last_sequence = whistleblower_sequences.last_sequence + 1
|
||||
RETURNING last_sequence
|
||||
`, tenantID, year).Scan(&seq)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return seq, nil
|
||||
}
|
||||
|
||||
229
ai-compliance-sdk/internal/whistleblower/store_messages.go
Normal file
229
ai-compliance-sdk/internal/whistleblower/store_messages.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package whistleblower
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Message Operations
|
||||
// ============================================================================
|
||||
|
||||
// AddMessage adds an anonymous message to a report
|
||||
func (s *Store) AddMessage(ctx context.Context, msg *AnonymousMessage) error {
|
||||
msg.ID = uuid.New()
|
||||
msg.SentAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO whistleblower_messages (
|
||||
id, report_id, direction, content, sent_at, read_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6
|
||||
)
|
||||
`,
|
||||
msg.ID, msg.ReportID, string(msg.Direction), msg.Content, msg.SentAt, msg.ReadAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMessages lists messages for a report
|
||||
func (s *Store) ListMessages(ctx context.Context, reportID uuid.UUID) ([]AnonymousMessage, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
id, report_id, direction, content, sent_at, read_at
|
||||
FROM whistleblower_messages WHERE report_id = $1
|
||||
ORDER BY sent_at ASC
|
||||
`, reportID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []AnonymousMessage
|
||||
for rows.Next() {
|
||||
var msg AnonymousMessage
|
||||
var direction string
|
||||
|
||||
err := rows.Scan(
|
||||
&msg.ID, &msg.ReportID, &direction, &msg.Content, &msg.SentAt, &msg.ReadAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg.Direction = MessageDirection(direction)
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Measure Operations
|
||||
// ============================================================================
|
||||
|
||||
// AddMeasure adds a corrective measure to a report
|
||||
func (s *Store) AddMeasure(ctx context.Context, measure *Measure) error {
|
||||
measure.ID = uuid.New()
|
||||
measure.CreatedAt = time.Now().UTC()
|
||||
if measure.Status == "" {
|
||||
measure.Status = MeasureStatusPlanned
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO whistleblower_measures (
|
||||
id, report_id, title, description, status,
|
||||
responsible, due_date, completed_at, created_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9
|
||||
)
|
||||
`,
|
||||
measure.ID, measure.ReportID, measure.Title, measure.Description, string(measure.Status),
|
||||
measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMeasures lists measures for a report
|
||||
func (s *Store) ListMeasures(ctx context.Context, reportID uuid.UUID) ([]Measure, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
id, report_id, title, description, status,
|
||||
responsible, due_date, completed_at, created_at
|
||||
FROM whistleblower_measures WHERE report_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, reportID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var measures []Measure
|
||||
for rows.Next() {
|
||||
var m Measure
|
||||
var status string
|
||||
|
||||
err := rows.Scan(
|
||||
&m.ID, &m.ReportID, &m.Title, &m.Description, &status,
|
||||
&m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.Status = MeasureStatus(status)
|
||||
measures = append(measures, m)
|
||||
}
|
||||
|
||||
return measures, nil
|
||||
}
|
||||
|
||||
// UpdateMeasure updates a measure
|
||||
func (s *Store) UpdateMeasure(ctx context.Context, measure *Measure) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE whistleblower_measures SET
|
||||
title = $2, description = $3, status = $4,
|
||||
responsible = $5, due_date = $6, completed_at = $7
|
||||
WHERE id = $1
|
||||
`,
|
||||
measure.ID,
|
||||
measure.Title, measure.Description, string(measure.Status),
|
||||
measure.Responsible, measure.DueDate, measure.CompletedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
// GetStatistics returns aggregated whistleblower statistics for a tenant
|
||||
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*WhistleblowerStatistics, error) {
|
||||
stats := &WhistleblowerStatistics{
|
||||
ByStatus: make(map[string]int),
|
||||
ByCategory: make(map[string]int),
|
||||
}
|
||||
|
||||
s.pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1",
|
||||
tenantID).Scan(&stats.TotalReports)
|
||||
|
||||
rows, err := s.pool.Query(ctx,
|
||||
"SELECT status, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY status",
|
||||
tenantID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var count int
|
||||
rows.Scan(&status, &count)
|
||||
stats.ByStatus[status] = count
|
||||
}
|
||||
}
|
||||
|
||||
rows, err = s.pool.Query(ctx,
|
||||
"SELECT category, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY category",
|
||||
tenantID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var category string
|
||||
var count int
|
||||
rows.Scan(&category, &count)
|
||||
stats.ByCategory[category] = count
|
||||
}
|
||||
}
|
||||
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM whistleblower_reports
|
||||
WHERE tenant_id = $1
|
||||
AND acknowledged_at IS NULL
|
||||
AND status = 'new'
|
||||
AND deadline_acknowledgment < NOW()
|
||||
`, tenantID).Scan(&stats.OverdueAcknowledgments)
|
||||
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM whistleblower_reports
|
||||
WHERE tenant_id = $1
|
||||
AND closed_at IS NULL
|
||||
AND status NOT IN ('closed', 'rejected')
|
||||
AND deadline_feedback < NOW()
|
||||
`, tenantID).Scan(&stats.OverdueFeedbacks)
|
||||
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - received_at)) / 86400), 0)
|
||||
FROM whistleblower_reports
|
||||
WHERE tenant_id = $1 AND closed_at IS NOT NULL
|
||||
`, tenantID).Scan(&stats.AvgResolutionDays)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sequence Number
|
||||
// ============================================================================
|
||||
|
||||
// GetNextSequenceNumber gets and increments the sequence number for reference number generation
|
||||
func (s *Store) GetNextSequenceNumber(ctx context.Context, tenantID uuid.UUID, year int) (int, error) {
|
||||
var seq int
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO whistleblower_sequences (tenant_id, year, last_sequence)
|
||||
VALUES ($1, $2, 1)
|
||||
ON CONFLICT (tenant_id, year) DO UPDATE SET
|
||||
last_sequence = whistleblower_sequences.last_sequence + 1
|
||||
RETURNING last_sequence
|
||||
`, tenantID, year).Scan(&seq)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return seq, nil
|
||||
}
|
||||
Reference in New Issue
Block a user