Initial commit: breakpilot-compliance - Compliance SDK Platform
Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
445
ai-compliance-sdk/internal/api/handlers/audit_handlers.go
Normal file
445
ai-compliance-sdk/internal/api/handlers/audit_handlers.go
Normal file
@@ -0,0 +1,445 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/audit"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AuditHandlers handles audit-related API endpoints
|
||||
type AuditHandlers struct {
|
||||
store *audit.Store
|
||||
exporter *audit.Exporter
|
||||
}
|
||||
|
||||
// NewAuditHandlers creates new audit handlers
|
||||
func NewAuditHandlers(store *audit.Store, exporter *audit.Exporter) *AuditHandlers {
|
||||
return &AuditHandlers{
|
||||
store: store,
|
||||
exporter: exporter,
|
||||
}
|
||||
}
|
||||
|
||||
// QueryLLMAudit queries LLM audit entries
|
||||
func (h *AuditHandlers) QueryLLMAudit(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
filter := &audit.LLMAuditFilter{
|
||||
TenantID: tenantID,
|
||||
Limit: 50,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
if nsID := c.Query("namespace_id"); nsID != "" {
|
||||
if id, err := uuid.Parse(nsID); err == nil {
|
||||
filter.NamespaceID = &id
|
||||
}
|
||||
}
|
||||
|
||||
if userID := c.Query("user_id"); userID != "" {
|
||||
if id, err := uuid.Parse(userID); err == nil {
|
||||
filter.UserID = &id
|
||||
}
|
||||
}
|
||||
|
||||
if op := c.Query("operation"); op != "" {
|
||||
filter.Operation = op
|
||||
}
|
||||
|
||||
if model := c.Query("model"); model != "" {
|
||||
filter.Model = model
|
||||
}
|
||||
|
||||
if pii := c.Query("pii_detected"); pii != "" {
|
||||
val := pii == "true"
|
||||
filter.PIIDetected = &val
|
||||
}
|
||||
|
||||
if violations := c.Query("has_violations"); violations == "true" {
|
||||
val := true
|
||||
filter.HasViolations = &val
|
||||
}
|
||||
|
||||
if startDate := c.Query("start_date"); startDate != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startDate); err == nil {
|
||||
filter.StartDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
if endDate := c.Query("end_date"); endDate != "" {
|
||||
if t, err := time.Parse(time.RFC3339, endDate); err == nil {
|
||||
filter.EndDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
if limit := c.Query("limit"); limit != "" {
|
||||
var l int
|
||||
if _, err := parseIntQuery(limit, &l); err == nil && l > 0 && l <= 1000 {
|
||||
filter.Limit = l
|
||||
}
|
||||
}
|
||||
|
||||
if offset := c.Query("offset"); offset != "" {
|
||||
var o int
|
||||
if _, err := parseIntQuery(offset, &o); err == nil && o >= 0 {
|
||||
filter.Offset = o
|
||||
}
|
||||
}
|
||||
|
||||
entries, total, err := h.store.QueryLLMAuditEntries(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"entries": entries,
|
||||
"total": total,
|
||||
"limit": filter.Limit,
|
||||
"offset": filter.Offset,
|
||||
})
|
||||
}
|
||||
|
||||
// QueryGeneralAudit queries general audit entries
|
||||
func (h *AuditHandlers) QueryGeneralAudit(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
filter := &audit.GeneralAuditFilter{
|
||||
TenantID: tenantID,
|
||||
Limit: 50,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
if nsID := c.Query("namespace_id"); nsID != "" {
|
||||
if id, err := uuid.Parse(nsID); err == nil {
|
||||
filter.NamespaceID = &id
|
||||
}
|
||||
}
|
||||
|
||||
if userID := c.Query("user_id"); userID != "" {
|
||||
if id, err := uuid.Parse(userID); err == nil {
|
||||
filter.UserID = &id
|
||||
}
|
||||
}
|
||||
|
||||
if action := c.Query("action"); action != "" {
|
||||
filter.Action = action
|
||||
}
|
||||
|
||||
if resourceType := c.Query("resource_type"); resourceType != "" {
|
||||
filter.ResourceType = resourceType
|
||||
}
|
||||
|
||||
if resourceID := c.Query("resource_id"); resourceID != "" {
|
||||
if id, err := uuid.Parse(resourceID); err == nil {
|
||||
filter.ResourceID = &id
|
||||
}
|
||||
}
|
||||
|
||||
if startDate := c.Query("start_date"); startDate != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startDate); err == nil {
|
||||
filter.StartDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
if endDate := c.Query("end_date"); endDate != "" {
|
||||
if t, err := time.Parse(time.RFC3339, endDate); err == nil {
|
||||
filter.EndDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
if limit := c.Query("limit"); limit != "" {
|
||||
var l int
|
||||
if _, err := parseIntQuery(limit, &l); err == nil && l > 0 && l <= 1000 {
|
||||
filter.Limit = l
|
||||
}
|
||||
}
|
||||
|
||||
if offset := c.Query("offset"); offset != "" {
|
||||
var o int
|
||||
if _, err := parseIntQuery(offset, &o); err == nil && o >= 0 {
|
||||
filter.Offset = o
|
||||
}
|
||||
}
|
||||
|
||||
entries, total, err := h.store.QueryGeneralAuditEntries(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"entries": entries,
|
||||
"total": total,
|
||||
"limit": filter.Limit,
|
||||
"offset": filter.Offset,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUsageStats returns LLM usage statistics
|
||||
func (h *AuditHandlers) GetUsageStats(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default to last 30 days
|
||||
endDate := time.Now().UTC()
|
||||
startDate := endDate.AddDate(0, 0, -30)
|
||||
|
||||
if sd := c.Query("start_date"); sd != "" {
|
||||
if t, err := time.Parse(time.RFC3339, sd); err == nil {
|
||||
startDate = t
|
||||
}
|
||||
}
|
||||
|
||||
if ed := c.Query("end_date"); ed != "" {
|
||||
if t, err := time.Parse(time.RFC3339, ed); err == nil {
|
||||
endDate = t
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := h.store.GetLLMUsageStats(c.Request.Context(), tenantID, startDate, endDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"period_start": startDate.Format(time.RFC3339),
|
||||
"period_end": endDate.Format(time.RFC3339),
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// ExportLLMAudit exports LLM audit entries
|
||||
func (h *AuditHandlers) ExportLLMAudit(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default to last 30 days
|
||||
endDate := time.Now().UTC()
|
||||
startDate := endDate.AddDate(0, 0, -30)
|
||||
|
||||
if sd := c.Query("start_date"); sd != "" {
|
||||
if t, err := time.Parse(time.RFC3339, sd); err == nil {
|
||||
startDate = t
|
||||
}
|
||||
}
|
||||
|
||||
if ed := c.Query("end_date"); ed != "" {
|
||||
if t, err := time.Parse(time.RFC3339, ed); err == nil {
|
||||
endDate = t
|
||||
}
|
||||
}
|
||||
|
||||
format := audit.FormatJSON
|
||||
if c.Query("format") == "csv" {
|
||||
format = audit.FormatCSV
|
||||
}
|
||||
|
||||
includePII := c.Query("include_pii") == "true"
|
||||
|
||||
opts := &audit.ExportOptions{
|
||||
TenantID: tenantID,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
Format: format,
|
||||
IncludePII: includePII,
|
||||
}
|
||||
|
||||
if nsID := c.Query("namespace_id"); nsID != "" {
|
||||
if id, err := uuid.Parse(nsID); err == nil {
|
||||
opts.NamespaceID = &id
|
||||
}
|
||||
}
|
||||
|
||||
if userID := c.Query("user_id"); userID != "" {
|
||||
if id, err := uuid.Parse(userID); err == nil {
|
||||
opts.UserID = &id
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := h.exporter.ExportLLMAudit(c.Request.Context(), &buf, opts); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set appropriate content type
|
||||
contentType := "application/json"
|
||||
ext := "json"
|
||||
if format == audit.FormatCSV {
|
||||
contentType = "text/csv"
|
||||
ext = "csv"
|
||||
}
|
||||
|
||||
filename := "llm_audit_" + time.Now().Format("20060102") + "." + ext
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Data(http.StatusOK, contentType, buf.Bytes())
|
||||
}
|
||||
|
||||
// ExportGeneralAudit exports general audit entries
|
||||
func (h *AuditHandlers) ExportGeneralAudit(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
endDate := time.Now().UTC()
|
||||
startDate := endDate.AddDate(0, 0, -30)
|
||||
|
||||
if sd := c.Query("start_date"); sd != "" {
|
||||
if t, err := time.Parse(time.RFC3339, sd); err == nil {
|
||||
startDate = t
|
||||
}
|
||||
}
|
||||
|
||||
if ed := c.Query("end_date"); ed != "" {
|
||||
if t, err := time.Parse(time.RFC3339, ed); err == nil {
|
||||
endDate = t
|
||||
}
|
||||
}
|
||||
|
||||
format := audit.FormatJSON
|
||||
if c.Query("format") == "csv" {
|
||||
format = audit.FormatCSV
|
||||
}
|
||||
|
||||
includePII := c.Query("include_pii") == "true"
|
||||
|
||||
opts := &audit.ExportOptions{
|
||||
TenantID: tenantID,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
Format: format,
|
||||
IncludePII: includePII,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := h.exporter.ExportGeneralAudit(c.Request.Context(), &buf, opts); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
contentType := "application/json"
|
||||
ext := "json"
|
||||
if format == audit.FormatCSV {
|
||||
contentType = "text/csv"
|
||||
ext = "csv"
|
||||
}
|
||||
|
||||
filename := "general_audit_" + time.Now().Format("20060102") + "." + ext
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Data(http.StatusOK, contentType, buf.Bytes())
|
||||
}
|
||||
|
||||
// ExportComplianceReport exports a compliance report
|
||||
func (h *AuditHandlers) ExportComplianceReport(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
endDate := time.Now().UTC()
|
||||
startDate := endDate.AddDate(0, 0, -30)
|
||||
|
||||
if sd := c.Query("start_date"); sd != "" {
|
||||
if t, err := time.Parse(time.RFC3339, sd); err == nil {
|
||||
startDate = t
|
||||
}
|
||||
}
|
||||
|
||||
if ed := c.Query("end_date"); ed != "" {
|
||||
if t, err := time.Parse(time.RFC3339, ed); err == nil {
|
||||
endDate = t
|
||||
}
|
||||
}
|
||||
|
||||
format := audit.FormatJSON
|
||||
if c.Query("format") == "csv" {
|
||||
format = audit.FormatCSV
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := h.exporter.ExportComplianceReport(c.Request.Context(), &buf, tenantID, startDate, endDate, format); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
contentType := "application/json"
|
||||
ext := "json"
|
||||
if format == audit.FormatCSV {
|
||||
contentType = "text/csv"
|
||||
ext = "csv"
|
||||
}
|
||||
|
||||
filename := "compliance_report_" + time.Now().Format("20060102") + "." + ext
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Data(http.StatusOK, contentType, buf.Bytes())
|
||||
}
|
||||
|
||||
// GetComplianceReport returns a compliance report as JSON (for dashboard)
|
||||
func (h *AuditHandlers) GetComplianceReport(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
endDate := time.Now().UTC()
|
||||
startDate := endDate.AddDate(0, 0, -30)
|
||||
|
||||
if sd := c.Query("start_date"); sd != "" {
|
||||
if t, err := time.Parse(time.RFC3339, sd); err == nil {
|
||||
startDate = t
|
||||
}
|
||||
}
|
||||
|
||||
if ed := c.Query("end_date"); ed != "" {
|
||||
if t, err := time.Parse(time.RFC3339, ed); err == nil {
|
||||
endDate = t
|
||||
}
|
||||
}
|
||||
|
||||
report, err := h.exporter.GenerateComplianceReport(c.Request.Context(), tenantID, startDate, endDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, report)
|
||||
}
|
||||
|
||||
// Helper function to parse int from query
|
||||
func parseIntQuery(s string, out *int) (int, error) {
|
||||
var i int
|
||||
_, err := fmt.Sscanf(s, "%d", &i)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
*out = i
|
||||
return i, nil
|
||||
}
|
||||
335
ai-compliance-sdk/internal/api/handlers/drafting_handlers.go
Normal file
335
ai-compliance-sdk/internal/api/handlers/drafting_handlers.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/audit"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DraftingHandlers handles Drafting Engine API endpoints
|
||||
type DraftingHandlers struct {
|
||||
accessGate *llm.AccessGate
|
||||
registry *llm.ProviderRegistry
|
||||
piiDetector *llm.PIIDetector
|
||||
auditStore *audit.Store
|
||||
trailBuilder *audit.TrailBuilder
|
||||
}
|
||||
|
||||
// NewDraftingHandlers creates new Drafting Engine handlers
|
||||
func NewDraftingHandlers(
|
||||
accessGate *llm.AccessGate,
|
||||
registry *llm.ProviderRegistry,
|
||||
piiDetector *llm.PIIDetector,
|
||||
auditStore *audit.Store,
|
||||
trailBuilder *audit.TrailBuilder,
|
||||
) *DraftingHandlers {
|
||||
return &DraftingHandlers{
|
||||
accessGate: accessGate,
|
||||
registry: registry,
|
||||
piiDetector: piiDetector,
|
||||
auditStore: auditStore,
|
||||
trailBuilder: trailBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request/Response Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// DraftDocumentRequest represents a request to generate a compliance document draft
|
||||
type DraftDocumentRequest struct {
|
||||
DocumentType string `json:"document_type" binding:"required"`
|
||||
ScopeLevel string `json:"scope_level" binding:"required"`
|
||||
Context map[string]interface{} `json:"context"`
|
||||
Instructions string `json:"instructions"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// ValidateDocumentRequest represents a request to validate document consistency
|
||||
type ValidateDocumentRequest struct {
|
||||
DocumentType string `json:"document_type" binding:"required"`
|
||||
DraftContent string `json:"draft_content"`
|
||||
ValidationContext map[string]interface{} `json:"validation_context"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// DraftHistoryEntry represents a single audit trail entry for drafts
|
||||
type DraftHistoryEntry struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
DocumentType string `json:"document_type"`
|
||||
ScopeLevel string `json:"scope_level"`
|
||||
Operation string `json:"operation"`
|
||||
ConstraintsRespected bool `json:"constraints_respected"`
|
||||
TokensUsed int `json:"tokens_used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// DraftDocument handles document draft generation via LLM with constraint validation
|
||||
func (h *DraftingHandlers) DraftDocument(c *gin.Context) {
|
||||
var req DraftDocumentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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
|
||||
}
|
||||
|
||||
// Validate scope level
|
||||
validLevels := map[string]bool{"L1": true, "L2": true, "L3": true, "L4": true}
|
||||
if !validLevels[req.ScopeLevel] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scope_level, must be L1-L4"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate document type
|
||||
validTypes := map[string]bool{
|
||||
"vvt": true, "tom": true, "dsfa": true, "dsi": true, "lf": true,
|
||||
"av_vertrag": true, "betroffenenrechte": true, "einwilligung": true,
|
||||
"daten_transfer": true, "datenpannen": true, "vertragsmanagement": true,
|
||||
"schulung": true, "audit_log": true, "risikoanalyse": true,
|
||||
"notfallplan": true, "zertifizierung": true, "datenschutzmanagement": true,
|
||||
}
|
||||
if !validTypes[req.DocumentType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document_type"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build system prompt for drafting
|
||||
systemPrompt := fmt.Sprintf(
|
||||
`Du bist ein DSGVO-Compliance-Experte. Erstelle einen strukturierten Entwurf fuer Dokument "%s" auf Level %s.
|
||||
Antworte NUR im JSON-Format mit einem "sections" Array.
|
||||
Jede Section hat: id, title, content, schemaField.
|
||||
Halte die Tiefe strikt am vorgegebenen Level.
|
||||
Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung].
|
||||
Sprache: Deutsch.`,
|
||||
req.DocumentType, req.ScopeLevel,
|
||||
)
|
||||
|
||||
userPrompt := "Erstelle den Dokumententwurf."
|
||||
if req.Instructions != "" {
|
||||
userPrompt = req.Instructions
|
||||
}
|
||||
|
||||
// Detect PII in context
|
||||
contextStr := fmt.Sprintf("%v", req.Context)
|
||||
dataCategories := h.piiDetector.DetectDataCategories(contextStr)
|
||||
|
||||
// Process through access gate
|
||||
chatReq := &llm.ChatRequest{
|
||||
Model: req.Model,
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: 16384,
|
||||
Temperature: 0.15,
|
||||
}
|
||||
|
||||
gatedReq, err := h.accessGate.ProcessChatRequest(
|
||||
c.Request.Context(),
|
||||
userID, tenantID, namespaceID,
|
||||
chatReq, dataCategories,
|
||||
)
|
||||
if err != nil {
|
||||
h.logDraftAudit(c, userID, tenantID, req.DocumentType, req.ScopeLevel, "draft", false, 0, err.Error())
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "access_denied",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Execute the request
|
||||
resp, err := h.accessGate.ExecuteChat(c.Request.Context(), gatedReq)
|
||||
if err != nil {
|
||||
h.logDraftAudit(c, userID, tenantID, req.DocumentType, req.ScopeLevel, "draft", false, 0, err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "llm_error",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokensUsed := 0
|
||||
if resp.Usage.TotalTokens > 0 {
|
||||
tokensUsed = resp.Usage.TotalTokens
|
||||
}
|
||||
|
||||
// Log successful draft
|
||||
h.logDraftAudit(c, userID, tenantID, req.DocumentType, req.ScopeLevel, "draft", true, tokensUsed, "")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"document_type": req.DocumentType,
|
||||
"scope_level": req.ScopeLevel,
|
||||
"content": resp.Message.Content,
|
||||
"model": resp.Model,
|
||||
"provider": resp.Provider,
|
||||
"tokens_used": tokensUsed,
|
||||
"pii_detected": gatedReq.PIIDetected,
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateDocument handles document cross-consistency validation
|
||||
func (h *DraftingHandlers) ValidateDocument(c *gin.Context) {
|
||||
var req ValidateDocumentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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
|
||||
}
|
||||
|
||||
// Build validation prompt
|
||||
systemPrompt := `Du bist ein DSGVO-Compliance-Validator. Pruefe die Konsistenz und Vollstaendigkeit.
|
||||
Antworte NUR im JSON-Format:
|
||||
{
|
||||
"passed": boolean,
|
||||
"errors": [{"id": string, "severity": "error", "title": string, "description": string, "documentType": string, "legalReference": string}],
|
||||
"warnings": [{"id": string, "severity": "warning", "title": string, "description": string}],
|
||||
"suggestions": [{"id": string, "severity": "suggestion", "title": string, "description": string, "suggestion": string}]
|
||||
}`
|
||||
|
||||
validationPrompt := fmt.Sprintf("Validiere Dokument '%s'.\nInhalt:\n%s\nKontext:\n%v",
|
||||
req.DocumentType, req.DraftContent, req.ValidationContext)
|
||||
|
||||
// Detect PII
|
||||
dataCategories := h.piiDetector.DetectDataCategories(req.DraftContent)
|
||||
|
||||
chatReq := &llm.ChatRequest{
|
||||
Model: req.Model,
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: validationPrompt},
|
||||
},
|
||||
MaxTokens: 8192,
|
||||
Temperature: 0.1,
|
||||
}
|
||||
|
||||
gatedReq, err := h.accessGate.ProcessChatRequest(
|
||||
c.Request.Context(),
|
||||
userID, tenantID, namespaceID,
|
||||
chatReq, dataCategories,
|
||||
)
|
||||
if err != nil {
|
||||
h.logDraftAudit(c, userID, tenantID, req.DocumentType, "", "validate", false, 0, err.Error())
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "access_denied",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.accessGate.ExecuteChat(c.Request.Context(), gatedReq)
|
||||
if err != nil {
|
||||
h.logDraftAudit(c, userID, tenantID, req.DocumentType, "", "validate", false, 0, err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "llm_error",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokensUsed := 0
|
||||
if resp.Usage.TotalTokens > 0 {
|
||||
tokensUsed = resp.Usage.TotalTokens
|
||||
}
|
||||
|
||||
h.logDraftAudit(c, userID, tenantID, req.DocumentType, "", "validate", true, tokensUsed, "")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"document_type": req.DocumentType,
|
||||
"validation": resp.Message.Content,
|
||||
"model": resp.Model,
|
||||
"provider": resp.Provider,
|
||||
"tokens_used": tokensUsed,
|
||||
"pii_detected": gatedReq.PIIDetected,
|
||||
})
|
||||
}
|
||||
|
||||
// GetDraftHistory returns the audit trail of all drafting operations for a tenant
|
||||
func (h *DraftingHandlers) GetDraftHistory(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
|
||||
}
|
||||
|
||||
// Query audit store for drafting operations
|
||||
entries, _, err := h.auditStore.QueryGeneralAuditEntries(c.Request.Context(), &audit.GeneralAuditFilter{
|
||||
TenantID: tenantID,
|
||||
ResourceType: "compliance_document",
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query draft history"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"history": entries,
|
||||
"total": len(entries),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit Logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *DraftingHandlers) logDraftAudit(
|
||||
c *gin.Context,
|
||||
userID, tenantID uuid.UUID,
|
||||
documentType, scopeLevel, operation string,
|
||||
constraintsRespected bool,
|
||||
tokensUsed int,
|
||||
errorMsg string,
|
||||
) {
|
||||
newValues := map[string]any{
|
||||
"document_type": documentType,
|
||||
"scope_level": scopeLevel,
|
||||
"constraints_respected": constraintsRespected,
|
||||
"tokens_used": tokensUsed,
|
||||
}
|
||||
if errorMsg != "" {
|
||||
newValues["error"] = errorMsg
|
||||
}
|
||||
|
||||
entry := h.trailBuilder.NewGeneralEntry().
|
||||
WithTenant(tenantID).
|
||||
WithUser(userID).
|
||||
WithAction("drafting_engine." + operation).
|
||||
WithResource("compliance_document", nil).
|
||||
WithNewValues(newValues).
|
||||
WithClient(c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
|
||||
go func() {
|
||||
entry.Save(c.Request.Context())
|
||||
}()
|
||||
}
|
||||
779
ai-compliance-sdk/internal/api/handlers/dsgvo_handlers.go
Normal file
779
ai-compliance-sdk/internal/api/handlers/dsgvo_handlers.go
Normal file
@@ -0,0 +1,779 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/dsgvo"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DSGVOHandlers handles DSGVO-related API endpoints
|
||||
type DSGVOHandlers struct {
|
||||
store *dsgvo.Store
|
||||
}
|
||||
|
||||
// NewDSGVOHandlers creates new DSGVO handlers
|
||||
func NewDSGVOHandlers(store *dsgvo.Store) *DSGVOHandlers {
|
||||
return &DSGVOHandlers{store: store}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VVT - Verarbeitungsverzeichnis (Processing Activities)
|
||||
// ============================================================================
|
||||
|
||||
// ListProcessingActivities returns all processing activities for a tenant
|
||||
func (h *DSGVOHandlers) ListProcessingActivities(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var namespaceID *uuid.UUID
|
||||
if nsID := c.Query("namespace_id"); nsID != "" {
|
||||
if id, err := uuid.Parse(nsID); err == nil {
|
||||
namespaceID = &id
|
||||
}
|
||||
}
|
||||
|
||||
activities, err := h.store.ListProcessingActivities(c.Request.Context(), tenantID, namespaceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"processing_activities": activities})
|
||||
}
|
||||
|
||||
// GetProcessingActivity returns a processing activity by ID
|
||||
func (h *DSGVOHandlers) GetProcessingActivity(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
activity, err := h.store.GetProcessingActivity(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if activity == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, activity)
|
||||
}
|
||||
|
||||
// CreateProcessingActivity creates a new processing activity
|
||||
func (h *DSGVOHandlers) CreateProcessingActivity(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var activity dsgvo.ProcessingActivity
|
||||
if err := c.ShouldBindJSON(&activity); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
activity.TenantID = tenantID
|
||||
activity.CreatedBy = userID
|
||||
if activity.Status == "" {
|
||||
activity.Status = "draft"
|
||||
}
|
||||
|
||||
if err := h.store.CreateProcessingActivity(c.Request.Context(), &activity); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, activity)
|
||||
}
|
||||
|
||||
// UpdateProcessingActivity updates a processing activity
|
||||
func (h *DSGVOHandlers) UpdateProcessingActivity(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var activity dsgvo.ProcessingActivity
|
||||
if err := c.ShouldBindJSON(&activity); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
activity.ID = id
|
||||
if err := h.store.UpdateProcessingActivity(c.Request.Context(), &activity); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, activity)
|
||||
}
|
||||
|
||||
// DeleteProcessingActivity deletes a processing activity
|
||||
func (h *DSGVOHandlers) DeleteProcessingActivity(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteProcessingActivity(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOM - Technische und Organisatorische Maßnahmen
|
||||
// ============================================================================
|
||||
|
||||
// ListTOMs returns all TOMs for a tenant
|
||||
func (h *DSGVOHandlers) ListTOMs(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
category := c.Query("category")
|
||||
|
||||
toms, err := h.store.ListTOMs(c.Request.Context(), tenantID, category)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"toms": toms, "categories": dsgvo.TOMCategories})
|
||||
}
|
||||
|
||||
// GetTOM returns a TOM by ID
|
||||
func (h *DSGVOHandlers) GetTOM(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
tom, err := h.store.GetTOM(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tom == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tom)
|
||||
}
|
||||
|
||||
// CreateTOM creates a new TOM
|
||||
func (h *DSGVOHandlers) CreateTOM(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var tom dsgvo.TOM
|
||||
if err := c.ShouldBindJSON(&tom); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tom.TenantID = tenantID
|
||||
tom.CreatedBy = userID
|
||||
if tom.ImplementationStatus == "" {
|
||||
tom.ImplementationStatus = "planned"
|
||||
}
|
||||
|
||||
if err := h.store.CreateTOM(c.Request.Context(), &tom); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, tom)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DSR - Data Subject Requests
|
||||
// ============================================================================
|
||||
|
||||
// ListDSRs returns all DSRs for a tenant
|
||||
func (h *DSGVOHandlers) ListDSRs(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Query("status")
|
||||
requestType := c.Query("type")
|
||||
|
||||
dsrs, err := h.store.ListDSRs(c.Request.Context(), tenantID, status, requestType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"dsrs": dsrs, "types": dsgvo.DSRTypes})
|
||||
}
|
||||
|
||||
// GetDSR returns a DSR by ID
|
||||
func (h *DSGVOHandlers) GetDSR(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
dsr, err := h.store.GetDSR(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if dsr == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dsr)
|
||||
}
|
||||
|
||||
// CreateDSR creates a new DSR
|
||||
func (h *DSGVOHandlers) CreateDSR(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var dsr dsgvo.DSR
|
||||
if err := c.ShouldBindJSON(&dsr); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
dsr.TenantID = tenantID
|
||||
dsr.CreatedBy = userID
|
||||
if dsr.Status == "" {
|
||||
dsr.Status = "received"
|
||||
}
|
||||
|
||||
if err := h.store.CreateDSR(c.Request.Context(), &dsr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, dsr)
|
||||
}
|
||||
|
||||
// UpdateDSR updates a DSR
|
||||
func (h *DSGVOHandlers) UpdateDSR(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var dsr dsgvo.DSR
|
||||
if err := c.ShouldBindJSON(&dsr); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
dsr.ID = id
|
||||
if err := h.store.UpdateDSR(c.Request.Context(), &dsr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dsr)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Retention Policies
|
||||
// ============================================================================
|
||||
|
||||
// ListRetentionPolicies returns all retention policies for a tenant
|
||||
func (h *DSGVOHandlers) ListRetentionPolicies(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.ListRetentionPolicies(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,
|
||||
"common_periods": dsgvo.CommonRetentionPeriods,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRetentionPolicy creates a new retention policy
|
||||
func (h *DSGVOHandlers) CreateRetentionPolicy(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var policy dsgvo.RetentionPolicy
|
||||
if err := c.ShouldBindJSON(&policy); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
policy.TenantID = tenantID
|
||||
policy.CreatedBy = userID
|
||||
if policy.Status == "" {
|
||||
policy.Status = "draft"
|
||||
}
|
||||
|
||||
if err := h.store.CreateRetentionPolicy(c.Request.Context(), &policy); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, policy)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
// GetStats returns DSGVO statistics for a tenant
|
||||
func (h *DSGVOHandlers) GetStats(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.store.GetStats(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DSFA - Datenschutz-Folgenabschätzung
|
||||
// ============================================================================
|
||||
|
||||
// ListDSFAs returns all DSFAs for a tenant
|
||||
func (h *DSGVOHandlers) ListDSFAs(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Query("status")
|
||||
|
||||
dsfas, err := h.store.ListDSFAs(c.Request.Context(), tenantID, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"dsfas": dsfas})
|
||||
}
|
||||
|
||||
// GetDSFA returns a DSFA by ID
|
||||
func (h *DSGVOHandlers) GetDSFA(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
dsfa, err := h.store.GetDSFA(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if dsfa == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dsfa)
|
||||
}
|
||||
|
||||
// CreateDSFA creates a new DSFA
|
||||
func (h *DSGVOHandlers) CreateDSFA(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var dsfa dsgvo.DSFA
|
||||
if err := c.ShouldBindJSON(&dsfa); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
dsfa.TenantID = tenantID
|
||||
dsfa.CreatedBy = userID
|
||||
if dsfa.Status == "" {
|
||||
dsfa.Status = "draft"
|
||||
}
|
||||
|
||||
if err := h.store.CreateDSFA(c.Request.Context(), &dsfa); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, dsfa)
|
||||
}
|
||||
|
||||
// UpdateDSFA updates a DSFA
|
||||
func (h *DSGVOHandlers) UpdateDSFA(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var dsfa dsgvo.DSFA
|
||||
if err := c.ShouldBindJSON(&dsfa); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
dsfa.ID = id
|
||||
if err := h.store.UpdateDSFA(c.Request.Context(), &dsfa); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dsfa)
|
||||
}
|
||||
|
||||
// DeleteDSFA deletes a DSFA
|
||||
func (h *DSGVOHandlers) DeleteDSFA(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteDSFA(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PDF Export
|
||||
// ============================================================================
|
||||
|
||||
// ExportVVT exports the Verarbeitungsverzeichnis as CSV/JSON
|
||||
func (h *DSGVOHandlers) ExportVVT(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
format := c.DefaultQuery("format", "csv")
|
||||
|
||||
activities, err := h.store.ListProcessingActivities(c.Request.Context(), tenantID, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if format == "json" {
|
||||
c.Header("Content-Disposition", "attachment; filename=vvt_export.json")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"processing_activities": activities,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// CSV Export
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("ID;Name;Zweck;Rechtsgrundlage;Datenkategorien;Betroffene;Empfänger;Drittland;Aufbewahrung;Verantwortlich;Status;Erstellt\n")
|
||||
|
||||
for _, pa := range activities {
|
||||
buf.WriteString(fmt.Sprintf("%s;%s;%s;%s;%s;%s;%s;%t;%s;%s;%s;%s\n",
|
||||
pa.ID.String(),
|
||||
escapeCSV(pa.Name),
|
||||
escapeCSV(pa.Purpose),
|
||||
pa.LegalBasis,
|
||||
joinStrings(pa.DataCategories),
|
||||
joinStrings(pa.DataSubjectCategories),
|
||||
joinStrings(pa.Recipients),
|
||||
pa.ThirdCountryTransfer,
|
||||
pa.RetentionPeriod,
|
||||
escapeCSV(pa.ResponsiblePerson),
|
||||
pa.Status,
|
||||
pa.CreatedAt.Format("2006-01-02"),
|
||||
))
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=vvt_export.csv")
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
// ExportTOM exports the TOM catalog as CSV/JSON
|
||||
func (h *DSGVOHandlers) ExportTOM(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
format := c.DefaultQuery("format", "csv")
|
||||
|
||||
toms, err := h.store.ListTOMs(c.Request.Context(), tenantID, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if format == "json" {
|
||||
c.Header("Content-Disposition", "attachment; filename=tom_export.json")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"toms": toms,
|
||||
"categories": dsgvo.TOMCategories,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// CSV Export
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("ID;Kategorie;Name;Beschreibung;Typ;Status;Implementiert am;Verantwortlich;Wirksamkeit;Erstellt\n")
|
||||
|
||||
for _, tom := range toms {
|
||||
implementedAt := ""
|
||||
if tom.ImplementedAt != nil {
|
||||
implementedAt = tom.ImplementedAt.Format("2006-01-02")
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("%s;%s;%s;%s;%s;%s;%s;%s;%s;%s\n",
|
||||
tom.ID.String(),
|
||||
tom.Category,
|
||||
escapeCSV(tom.Name),
|
||||
escapeCSV(tom.Description),
|
||||
tom.Type,
|
||||
tom.ImplementationStatus,
|
||||
implementedAt,
|
||||
escapeCSV(tom.ResponsiblePerson),
|
||||
tom.EffectivenessRating,
|
||||
tom.CreatedAt.Format("2006-01-02"),
|
||||
))
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=tom_export.csv")
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
// ExportDSR exports DSR overview as CSV/JSON
|
||||
func (h *DSGVOHandlers) ExportDSR(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
format := c.DefaultQuery("format", "csv")
|
||||
|
||||
dsrs, err := h.store.ListDSRs(c.Request.Context(), tenantID, "", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if format == "json" {
|
||||
c.Header("Content-Disposition", "attachment; filename=dsr_export.json")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"dsrs": dsrs,
|
||||
"types": dsgvo.DSRTypes,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// CSV Export
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("ID;Typ;Name;E-Mail;Status;Eingegangen;Frist;Abgeschlossen;Kanal;Zugewiesen\n")
|
||||
|
||||
for _, dsr := range dsrs {
|
||||
completedAt := ""
|
||||
if dsr.CompletedAt != nil {
|
||||
completedAt = dsr.CompletedAt.Format("2006-01-02")
|
||||
}
|
||||
assignedTo := ""
|
||||
if dsr.AssignedTo != nil {
|
||||
assignedTo = dsr.AssignedTo.String()
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("%s;%s;%s;%s;%s;%s;%s;%s;%s;%s\n",
|
||||
dsr.ID.String(),
|
||||
dsr.RequestType,
|
||||
escapeCSV(dsr.SubjectName),
|
||||
dsr.SubjectEmail,
|
||||
dsr.Status,
|
||||
dsr.ReceivedAt.Format("2006-01-02"),
|
||||
dsr.DeadlineAt.Format("2006-01-02"),
|
||||
completedAt,
|
||||
dsr.RequestChannel,
|
||||
assignedTo,
|
||||
))
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=dsr_export.csv")
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
// ExportDSFA exports a DSFA as JSON
|
||||
func (h *DSGVOHandlers) ExportDSFA(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
dsfa, err := h.store.GetDSFA(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if dsfa == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=dsfa_%s.json", id.String()[:8]))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"dsfa": dsfa,
|
||||
})
|
||||
}
|
||||
|
||||
// ExportRetentionPolicies exports retention policies as CSV/JSON
|
||||
func (h *DSGVOHandlers) ExportRetentionPolicies(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
format := c.DefaultQuery("format", "csv")
|
||||
|
||||
policies, err := h.store.ListRetentionPolicies(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if format == "json" {
|
||||
c.Header("Content-Disposition", "attachment; filename=retention_policies_export.json")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"policies": policies,
|
||||
"common_periods": dsgvo.CommonRetentionPeriods,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// CSV Export
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("ID;Name;Datenkategorie;Aufbewahrungsdauer (Tage);Dauer (Text);Rechtsgrundlage;Referenz;Löschmethode;Status\n")
|
||||
|
||||
for _, rp := range policies {
|
||||
buf.WriteString(fmt.Sprintf("%s;%s;%s;%d;%s;%s;%s;%s;%s\n",
|
||||
rp.ID.String(),
|
||||
escapeCSV(rp.Name),
|
||||
rp.DataCategory,
|
||||
rp.RetentionPeriodDays,
|
||||
escapeCSV(rp.RetentionPeriodText),
|
||||
escapeCSV(rp.LegalBasis),
|
||||
escapeCSV(rp.LegalReference),
|
||||
rp.DeletionMethod,
|
||||
rp.Status,
|
||||
))
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=retention_policies_export.csv")
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func escapeCSV(s string) string {
|
||||
// Simple CSV escaping - wrap in quotes if contains semicolon, quote, or newline
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
needsQuotes := false
|
||||
for _, c := range s {
|
||||
if c == ';' || c == '"' || c == '\n' || c == '\r' {
|
||||
needsQuotes = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsQuotes {
|
||||
// Double any quotes and wrap in quotes
|
||||
escaped := ""
|
||||
for _, c := range s {
|
||||
if c == '"' {
|
||||
escaped += "\"\""
|
||||
} else if c == '\n' || c == '\r' {
|
||||
escaped += " "
|
||||
} else {
|
||||
escaped += string(c)
|
||||
}
|
||||
}
|
||||
return "\"" + escaped + "\""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func joinStrings(strs []string) string {
|
||||
if len(strs) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := strs[0]
|
||||
for i := 1; i < len(strs); i++ {
|
||||
result += ", " + strs[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
421
ai-compliance-sdk/internal/api/handlers/escalation_handlers.go
Normal file
421
ai-compliance-sdk/internal/api/handlers/escalation_handlers.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// EscalationHandlers handles escalation-related API endpoints.
|
||||
type EscalationHandlers struct {
|
||||
store *ucca.EscalationStore
|
||||
assessmentStore *ucca.Store
|
||||
trigger *ucca.EscalationTrigger
|
||||
}
|
||||
|
||||
// NewEscalationHandlers creates new escalation handlers.
|
||||
func NewEscalationHandlers(store *ucca.EscalationStore, assessmentStore *ucca.Store) *EscalationHandlers {
|
||||
return &EscalationHandlers{
|
||||
store: store,
|
||||
assessmentStore: assessmentStore,
|
||||
trigger: ucca.DefaultEscalationTrigger(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /sdk/v1/ucca/escalations - List escalations
|
||||
// ============================================================================
|
||||
|
||||
// ListEscalations returns escalations for a tenant with optional filters.
|
||||
func (h *EscalationHandlers) ListEscalations(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Query("status")
|
||||
level := c.Query("level")
|
||||
|
||||
var assignedTo *uuid.UUID
|
||||
if assignedToStr := c.Query("assigned_to"); assignedToStr != "" {
|
||||
if id, err := uuid.Parse(assignedToStr); err == nil {
|
||||
assignedTo = &id
|
||||
}
|
||||
}
|
||||
|
||||
// If user is a reviewer, filter to their assignments by default
|
||||
userID := rbac.GetUserID(c)
|
||||
if c.Query("my_reviews") == "true" && userID != uuid.Nil {
|
||||
assignedTo = &userID
|
||||
}
|
||||
|
||||
escalations, err := h.store.ListEscalations(c.Request.Context(), tenantID, status, level, assignedTo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"escalations": escalations})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /sdk/v1/ucca/escalations/:id - Get single escalation
|
||||
// ============================================================================
|
||||
|
||||
// GetEscalation returns a single escalation by ID.
|
||||
func (h *EscalationHandlers) GetEscalation(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
escalation, err := h.store.GetEscalation(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if escalation == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get history
|
||||
history, _ := h.store.GetEscalationHistory(c.Request.Context(), id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"escalation": escalation,
|
||||
"history": history,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /sdk/v1/ucca/escalations - Create escalation (manual)
|
||||
// ============================================================================
|
||||
|
||||
// CreateEscalation creates a manual escalation for an assessment.
|
||||
func (h *EscalationHandlers) CreateEscalation(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req ucca.CreateEscalationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the assessment
|
||||
assessment, err := h.assessmentStore.GetAssessment(c.Request.Context(), req.AssessmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if assessment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assessment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine escalation level
|
||||
result := &ucca.AssessmentResult{
|
||||
Feasibility: assessment.Feasibility,
|
||||
RiskLevel: assessment.RiskLevel,
|
||||
RiskScore: assessment.RiskScore,
|
||||
TriggeredRules: assessment.TriggeredRules,
|
||||
DSFARecommended: assessment.DSFARecommended,
|
||||
Art22Risk: assessment.Art22Risk,
|
||||
}
|
||||
level, reason := h.trigger.DetermineEscalationLevel(result)
|
||||
|
||||
// Calculate due date based on SLA
|
||||
responseHours, _ := ucca.GetDefaultSLA(level)
|
||||
var dueDate *time.Time
|
||||
if responseHours > 0 {
|
||||
due := time.Now().UTC().Add(time.Duration(responseHours) * time.Hour)
|
||||
dueDate = &due
|
||||
}
|
||||
|
||||
// Create escalation
|
||||
escalation := &ucca.Escalation{
|
||||
TenantID: tenantID,
|
||||
AssessmentID: req.AssessmentID,
|
||||
EscalationLevel: level,
|
||||
EscalationReason: reason,
|
||||
Status: ucca.EscalationStatusPending,
|
||||
DueDate: dueDate,
|
||||
}
|
||||
|
||||
// For E0, auto-approve
|
||||
if level == ucca.EscalationLevelE0 {
|
||||
escalation.Status = ucca.EscalationStatusApproved
|
||||
approveDecision := ucca.EscalationDecisionApprove
|
||||
escalation.Decision = &approveDecision
|
||||
now := time.Now().UTC()
|
||||
escalation.DecisionAt = &now
|
||||
autoNotes := "Automatische Freigabe (E0)"
|
||||
escalation.DecisionNotes = &autoNotes
|
||||
}
|
||||
|
||||
if err := h.store.CreateEscalation(c.Request.Context(), escalation); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Add history entry
|
||||
h.store.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{
|
||||
EscalationID: escalation.ID,
|
||||
Action: "created",
|
||||
NewStatus: string(escalation.Status),
|
||||
NewLevel: string(escalation.EscalationLevel),
|
||||
ActorID: userID,
|
||||
Notes: reason,
|
||||
})
|
||||
|
||||
// For E1/E2/E3, try to auto-assign
|
||||
if level != ucca.EscalationLevelE0 {
|
||||
role := ucca.GetRoleForLevel(level)
|
||||
reviewer, err := h.store.GetNextAvailableReviewer(c.Request.Context(), tenantID, role)
|
||||
if err == nil && reviewer != nil {
|
||||
h.store.AssignEscalation(c.Request.Context(), escalation.ID, reviewer.UserID, role)
|
||||
h.store.IncrementReviewerCount(c.Request.Context(), reviewer.UserID)
|
||||
h.store.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{
|
||||
EscalationID: escalation.ID,
|
||||
Action: "auto_assigned",
|
||||
OldStatus: string(ucca.EscalationStatusPending),
|
||||
NewStatus: string(ucca.EscalationStatusAssigned),
|
||||
ActorID: userID,
|
||||
Notes: "Automatisch zugewiesen an: " + reviewer.UserName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, escalation)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /sdk/v1/ucca/escalations/:id/assign - Assign escalation
|
||||
// ============================================================================
|
||||
|
||||
// AssignEscalation assigns an escalation to a reviewer.
|
||||
func (h *EscalationHandlers) AssignEscalation(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := rbac.GetUserID(c)
|
||||
|
||||
var req ucca.AssignEscalationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
escalation, err := h.store.GetEscalation(c.Request.Context(), id)
|
||||
if err != nil || escalation == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "escalation not found"})
|
||||
return
|
||||
}
|
||||
|
||||
role := ucca.GetRoleForLevel(escalation.EscalationLevel)
|
||||
|
||||
if err := h.store.AssignEscalation(c.Request.Context(), id, req.AssignedTo, role); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.store.IncrementReviewerCount(c.Request.Context(), req.AssignedTo)
|
||||
h.store.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{
|
||||
EscalationID: id,
|
||||
Action: "assigned",
|
||||
OldStatus: string(escalation.Status),
|
||||
NewStatus: string(ucca.EscalationStatusAssigned),
|
||||
ActorID: userID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "assigned"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /sdk/v1/ucca/escalations/:id/review - Start review
|
||||
// ============================================================================
|
||||
|
||||
// StartReview marks an escalation as being reviewed.
|
||||
func (h *EscalationHandlers) StartReview(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := rbac.GetUserID(c)
|
||||
if userID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
escalation, err := h.store.GetEscalation(c.Request.Context(), id)
|
||||
if err != nil || escalation == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "escalation not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.StartReview(c.Request.Context(), id, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.store.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{
|
||||
EscalationID: id,
|
||||
Action: "review_started",
|
||||
OldStatus: string(escalation.Status),
|
||||
NewStatus: string(ucca.EscalationStatusInReview),
|
||||
ActorID: userID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "review started"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /sdk/v1/ucca/escalations/:id/decide - Make decision
|
||||
// ============================================================================
|
||||
|
||||
// DecideEscalation makes a decision on an escalation.
|
||||
func (h *EscalationHandlers) DecideEscalation(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := rbac.GetUserID(c)
|
||||
if userID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req ucca.DecideEscalationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
escalation, err := h.store.GetEscalation(c.Request.Context(), id)
|
||||
if err != nil || escalation == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "escalation not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DecideEscalation(c.Request.Context(), id, req.Decision, req.DecisionNotes, req.Conditions); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrement reviewer count
|
||||
if escalation.AssignedTo != nil {
|
||||
h.store.DecrementReviewerCount(c.Request.Context(), *escalation.AssignedTo)
|
||||
}
|
||||
|
||||
newStatus := "decided"
|
||||
switch req.Decision {
|
||||
case ucca.EscalationDecisionApprove:
|
||||
newStatus = string(ucca.EscalationStatusApproved)
|
||||
case ucca.EscalationDecisionReject:
|
||||
newStatus = string(ucca.EscalationStatusRejected)
|
||||
case ucca.EscalationDecisionModify:
|
||||
newStatus = string(ucca.EscalationStatusReturned)
|
||||
case ucca.EscalationDecisionEscalate:
|
||||
newStatus = "escalated"
|
||||
}
|
||||
|
||||
h.store.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{
|
||||
EscalationID: id,
|
||||
Action: "decision_made",
|
||||
OldStatus: string(escalation.Status),
|
||||
NewStatus: newStatus,
|
||||
ActorID: userID,
|
||||
Notes: req.DecisionNotes,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "decision recorded", "status": newStatus})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /sdk/v1/ucca/escalations/stats - Get statistics
|
||||
// ============================================================================
|
||||
|
||||
// GetEscalationStats returns escalation statistics.
|
||||
func (h *EscalationHandlers) GetEscalationStats(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.store.GetEscalationStats(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DSB Pool Management
|
||||
// ============================================================================
|
||||
|
||||
// ListDSBPool returns the DSB review pool for a tenant.
|
||||
func (h *EscalationHandlers) ListDSBPool(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
role := c.Query("role")
|
||||
members, err := h.store.GetDSBPoolMembers(c.Request.Context(), tenantID, role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"members": members})
|
||||
}
|
||||
|
||||
// AddDSBPoolMember adds a member to the DSB pool.
|
||||
func (h *EscalationHandlers) AddDSBPoolMember(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var member ucca.DSBPoolMember
|
||||
if err := c.ShouldBindJSON(&member); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
member.TenantID = tenantID
|
||||
member.IsActive = true
|
||||
if member.MaxConcurrentReviews == 0 {
|
||||
member.MaxConcurrentReviews = 10
|
||||
}
|
||||
|
||||
if err := h.store.AddDSBPoolMember(c.Request.Context(), &member); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, member)
|
||||
}
|
||||
638
ai-compliance-sdk/internal/api/handlers/funding_handlers.go
Normal file
638
ai-compliance-sdk/internal/api/handlers/funding_handlers.go
Normal file
@@ -0,0 +1,638 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/funding"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// FundingHandlers handles funding application API endpoints
|
||||
type FundingHandlers struct {
|
||||
store funding.Store
|
||||
providerRegistry *llm.ProviderRegistry
|
||||
wizardSchema *WizardSchema
|
||||
bundeslandProfiles map[string]*BundeslandProfile
|
||||
}
|
||||
|
||||
// WizardSchema represents the loaded wizard schema
|
||||
type WizardSchema struct {
|
||||
Metadata struct {
|
||||
Version string `yaml:"version"`
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
TotalSteps int `yaml:"total_steps"`
|
||||
} `yaml:"metadata"`
|
||||
Steps []WizardStep `yaml:"steps"`
|
||||
FundingAssistant struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Model string `yaml:"model"`
|
||||
SystemPrompt string `yaml:"system_prompt"`
|
||||
StepContexts map[int]string `yaml:"step_contexts"`
|
||||
QuickPrompts []QuickPrompt `yaml:"quick_prompts"`
|
||||
} `yaml:"funding_assistant"`
|
||||
Presets map[string]Preset `yaml:"presets"`
|
||||
}
|
||||
|
||||
// WizardStep represents a step in the wizard
|
||||
type WizardStep struct {
|
||||
Number int `yaml:"number" json:"number"`
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Subtitle string `yaml:"subtitle" json:"subtitle"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Icon string `yaml:"icon" json:"icon"`
|
||||
IsRequired bool `yaml:"is_required" json:"is_required"`
|
||||
Fields []WizardField `yaml:"fields" json:"fields"`
|
||||
AssistantContext string `yaml:"assistant_context" json:"assistant_context"`
|
||||
}
|
||||
|
||||
// WizardField represents a field in the wizard
|
||||
type WizardField struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Type string `yaml:"type" json:"type"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Placeholder string `yaml:"placeholder,omitempty" json:"placeholder,omitempty"`
|
||||
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
|
||||
Options []FieldOption `yaml:"options,omitempty" json:"options,omitempty"`
|
||||
HelpText string `yaml:"help_text,omitempty" json:"help_text,omitempty"`
|
||||
MaxLength int `yaml:"max_length,omitempty" json:"max_length,omitempty"`
|
||||
Min *int `yaml:"min,omitempty" json:"min,omitempty"`
|
||||
Max *int `yaml:"max,omitempty" json:"max,omitempty"`
|
||||
Default interface{} `yaml:"default,omitempty" json:"default,omitempty"`
|
||||
Conditional string `yaml:"conditional,omitempty" json:"conditional,omitempty"`
|
||||
}
|
||||
|
||||
// FieldOption represents an option for select fields
|
||||
type FieldOption struct {
|
||||
Value string `yaml:"value" json:"value"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// QuickPrompt represents a quick prompt for the assistant
|
||||
type QuickPrompt struct {
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Prompt string `yaml:"prompt" json:"prompt"`
|
||||
}
|
||||
|
||||
// Preset represents a BreakPilot preset
|
||||
type Preset struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
BudgetItems []funding.BudgetItem `yaml:"budget_items" json:"budget_items"`
|
||||
AutoFill map[string]interface{} `yaml:"auto_fill" json:"auto_fill"`
|
||||
}
|
||||
|
||||
// BundeslandProfile represents a federal state profile
|
||||
type BundeslandProfile struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Short string `yaml:"short" json:"short"`
|
||||
FundingPrograms []string `yaml:"funding_programs" json:"funding_programs"`
|
||||
DefaultFundingRate float64 `yaml:"default_funding_rate" json:"default_funding_rate"`
|
||||
RequiresMEP bool `yaml:"requires_mep" json:"requires_mep"`
|
||||
ContactAuthority ContactAuthority `yaml:"contact_authority" json:"contact_authority"`
|
||||
SpecialRequirements []string `yaml:"special_requirements" json:"special_requirements"`
|
||||
}
|
||||
|
||||
// ContactAuthority represents a contact authority
|
||||
type ContactAuthority struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Department string `yaml:"department,omitempty" json:"department,omitempty"`
|
||||
Website string `yaml:"website" json:"website"`
|
||||
Email string `yaml:"email,omitempty" json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// NewFundingHandlers creates new funding handlers
|
||||
func NewFundingHandlers(store funding.Store, providerRegistry *llm.ProviderRegistry) *FundingHandlers {
|
||||
h := &FundingHandlers{
|
||||
store: store,
|
||||
providerRegistry: providerRegistry,
|
||||
}
|
||||
|
||||
// Load wizard schema
|
||||
if err := h.loadWizardSchema(); err != nil {
|
||||
fmt.Printf("Warning: Could not load wizard schema: %v\n", err)
|
||||
}
|
||||
|
||||
// Load bundesland profiles
|
||||
if err := h.loadBundeslandProfiles(); err != nil {
|
||||
fmt.Printf("Warning: Could not load bundesland profiles: %v\n", err)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *FundingHandlers) loadWizardSchema() error {
|
||||
data, err := os.ReadFile("policies/funding/foerderantrag_wizard_v1.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.wizardSchema = &WizardSchema{}
|
||||
return yaml.Unmarshal(data, h.wizardSchema)
|
||||
}
|
||||
|
||||
func (h *FundingHandlers) loadBundeslandProfiles() error {
|
||||
data, err := os.ReadFile("policies/funding/bundesland_profiles.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var profiles struct {
|
||||
Bundeslaender map[string]*BundeslandProfile `yaml:"bundeslaender"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &profiles); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.bundeslandProfiles = profiles.Bundeslaender
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Application CRUD
|
||||
// ============================================================================
|
||||
|
||||
// CreateApplication creates a new funding application
|
||||
// POST /sdk/v1/funding/applications
|
||||
func (h *FundingHandlers) CreateApplication(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req funding.CreateApplicationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
app := &funding.FundingApplication{
|
||||
TenantID: tenantID,
|
||||
Title: req.Title,
|
||||
FundingProgram: req.FundingProgram,
|
||||
Status: funding.ApplicationStatusDraft,
|
||||
CurrentStep: 1,
|
||||
TotalSteps: 8,
|
||||
WizardData: make(map[string]interface{}),
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
}
|
||||
|
||||
// Initialize school profile with federal state
|
||||
app.SchoolProfile = &funding.SchoolProfile{
|
||||
FederalState: req.FederalState,
|
||||
}
|
||||
|
||||
// Apply preset if specified
|
||||
if req.PresetID != "" && h.wizardSchema != nil {
|
||||
if preset, ok := h.wizardSchema.Presets[req.PresetID]; ok {
|
||||
app.Budget = &funding.Budget{
|
||||
BudgetItems: preset.BudgetItems,
|
||||
}
|
||||
app.WizardData["preset_id"] = req.PresetID
|
||||
app.WizardData["preset_applied"] = true
|
||||
for k, v := range preset.AutoFill {
|
||||
app.WizardData[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.store.CreateApplication(c.Request.Context(), app); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Add history entry
|
||||
h.store.AddHistoryEntry(c.Request.Context(), &funding.ApplicationHistoryEntry{
|
||||
ApplicationID: app.ID,
|
||||
Action: "created",
|
||||
PerformedBy: userID,
|
||||
Notes: "Antrag erstellt",
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, app)
|
||||
}
|
||||
|
||||
// GetApplication retrieves a funding application
|
||||
// GET /sdk/v1/funding/applications/:id
|
||||
func (h *FundingHandlers) GetApplication(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.store.GetApplication(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, app)
|
||||
}
|
||||
|
||||
// ListApplications returns a list of funding applications
|
||||
// GET /sdk/v1/funding/applications
|
||||
func (h *FundingHandlers) ListApplications(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
filter := funding.ApplicationFilter{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
if status := c.Query("status"); status != "" {
|
||||
s := funding.ApplicationStatus(status)
|
||||
filter.Status = &s
|
||||
}
|
||||
if program := c.Query("program"); program != "" {
|
||||
p := funding.FundingProgram(program)
|
||||
filter.FundingProgram = &p
|
||||
}
|
||||
|
||||
result, err := h.store.ListApplications(c.Request.Context(), tenantID, filter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// UpdateApplication updates a funding application
|
||||
// PUT /sdk/v1/funding/applications/:id
|
||||
func (h *FundingHandlers) UpdateApplication(c *gin.Context) {
|
||||
userID := rbac.GetUserID(c)
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.store.GetApplication(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req funding.UpdateApplicationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
app.Title = *req.Title
|
||||
}
|
||||
if req.WizardData != nil {
|
||||
for k, v := range req.WizardData {
|
||||
app.WizardData[k] = v
|
||||
}
|
||||
}
|
||||
if req.CurrentStep != nil {
|
||||
app.CurrentStep = *req.CurrentStep
|
||||
}
|
||||
app.UpdatedBy = userID
|
||||
|
||||
if err := h.store.UpdateApplication(c.Request.Context(), app); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, app)
|
||||
}
|
||||
|
||||
// DeleteApplication deletes a funding application
|
||||
// DELETE /sdk/v1/funding/applications/:id
|
||||
func (h *FundingHandlers) DeleteApplication(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteApplication(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "application archived"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wizard Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// GetWizardSchema returns the wizard schema
|
||||
// GET /sdk/v1/funding/wizard/schema
|
||||
func (h *FundingHandlers) GetWizardSchema(c *gin.Context) {
|
||||
if h.wizardSchema == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "wizard schema not loaded"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"metadata": h.wizardSchema.Metadata,
|
||||
"steps": h.wizardSchema.Steps,
|
||||
"presets": h.wizardSchema.Presets,
|
||||
"assistant": gin.H{
|
||||
"enabled": h.wizardSchema.FundingAssistant.Enabled,
|
||||
"quick_prompts": h.wizardSchema.FundingAssistant.QuickPrompts,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SaveWizardStep saves wizard step data
|
||||
// POST /sdk/v1/funding/applications/:id/wizard
|
||||
func (h *FundingHandlers) SaveWizardStep(c *gin.Context) {
|
||||
userID := rbac.GetUserID(c)
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req funding.SaveWizardStepRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save step data
|
||||
if err := h.store.SaveWizardStep(c.Request.Context(), id, req.Step, req.Data); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get updated progress
|
||||
progress, err := h.store.GetWizardProgress(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Add history entry
|
||||
h.store.AddHistoryEntry(c.Request.Context(), &funding.ApplicationHistoryEntry{
|
||||
ApplicationID: id,
|
||||
Action: "wizard_step_saved",
|
||||
PerformedBy: userID,
|
||||
Notes: fmt.Sprintf("Schritt %d gespeichert", req.Step),
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, progress)
|
||||
}
|
||||
|
||||
// AskAssistant handles LLM assistant queries
|
||||
// POST /sdk/v1/funding/wizard/ask
|
||||
func (h *FundingHandlers) AskAssistant(c *gin.Context) {
|
||||
var req funding.AssistantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if h.wizardSchema == nil || !h.wizardSchema.FundingAssistant.Enabled {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "assistant not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build system prompt with step context
|
||||
systemPrompt := h.wizardSchema.FundingAssistant.SystemPrompt
|
||||
if stepContext, ok := h.wizardSchema.FundingAssistant.StepContexts[req.CurrentStep]; ok {
|
||||
systemPrompt += "\n\nKontext fuer diesen Schritt:\n" + stepContext
|
||||
}
|
||||
|
||||
// Build messages
|
||||
messages := []llm.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
}
|
||||
for _, msg := range req.History {
|
||||
messages = append(messages, llm.Message{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
messages = append(messages, llm.Message{
|
||||
Role: "user",
|
||||
Content: req.Question,
|
||||
})
|
||||
|
||||
// Generate response using registry
|
||||
chatReq := &llm.ChatRequest{
|
||||
Messages: messages,
|
||||
Temperature: 0.3,
|
||||
MaxTokens: 1000,
|
||||
}
|
||||
|
||||
response, err := h.providerRegistry.Chat(c.Request.Context(), chatReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, funding.AssistantResponse{
|
||||
Answer: response.Message.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Status Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// SubmitApplication submits an application for review
|
||||
// POST /sdk/v1/funding/applications/:id/submit
|
||||
func (h *FundingHandlers) SubmitApplication(c *gin.Context) {
|
||||
userID := rbac.GetUserID(c)
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.store.GetApplication(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that all required steps are completed
|
||||
progress, _ := h.store.GetWizardProgress(c.Request.Context(), id)
|
||||
if progress == nil || len(progress.CompletedSteps) < app.TotalSteps {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not all required steps completed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update status
|
||||
app.Status = funding.ApplicationStatusSubmitted
|
||||
now := time.Now()
|
||||
app.SubmittedAt = &now
|
||||
app.UpdatedBy = userID
|
||||
|
||||
if err := h.store.UpdateApplication(c.Request.Context(), app); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Add history entry
|
||||
h.store.AddHistoryEntry(c.Request.Context(), &funding.ApplicationHistoryEntry{
|
||||
ApplicationID: id,
|
||||
Action: "submitted",
|
||||
PerformedBy: userID,
|
||||
Notes: "Antrag eingereicht",
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, app)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// ExportApplication exports all documents as ZIP
|
||||
// GET /sdk/v1/funding/applications/:id/export
|
||||
func (h *FundingHandlers) ExportApplication(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.store.GetApplication(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate export (this will be implemented in export.go)
|
||||
// For now, return a placeholder response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Export generation initiated",
|
||||
"application_id": app.ID,
|
||||
"status": "processing",
|
||||
})
|
||||
}
|
||||
|
||||
// PreviewApplication generates a PDF preview
|
||||
// GET /sdk/v1/funding/applications/:id/preview
|
||||
func (h *FundingHandlers) PreviewApplication(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.store.GetApplication(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate PDF preview (placeholder)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Preview generation initiated",
|
||||
"application_id": app.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bundesland Profile Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// GetBundeslandProfiles returns all bundesland profiles
|
||||
// GET /sdk/v1/funding/bundeslaender
|
||||
func (h *FundingHandlers) GetBundeslandProfiles(c *gin.Context) {
|
||||
if h.bundeslandProfiles == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "bundesland profiles not loaded"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, h.bundeslandProfiles)
|
||||
}
|
||||
|
||||
// GetBundeslandProfile returns a specific bundesland profile
|
||||
// GET /sdk/v1/funding/bundeslaender/:state
|
||||
func (h *FundingHandlers) GetBundeslandProfile(c *gin.Context) {
|
||||
state := c.Param("state")
|
||||
|
||||
if h.bundeslandProfiles == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "bundesland profiles not loaded"})
|
||||
return
|
||||
}
|
||||
|
||||
profile, ok := h.bundeslandProfiles[state]
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bundesland not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, profile)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics Endpoint
|
||||
// ============================================================================
|
||||
|
||||
// GetStatistics returns funding statistics
|
||||
// GET /sdk/v1/funding/statistics
|
||||
func (h *FundingHandlers) GetStatistics(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// History Endpoint
|
||||
// ============================================================================
|
||||
|
||||
// GetApplicationHistory returns the audit trail
|
||||
// GET /sdk/v1/funding/applications/:id/history
|
||||
func (h *FundingHandlers) GetApplicationHistory(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
history, err := h.store.GetHistory(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
345
ai-compliance-sdk/internal/api/handlers/llm_handlers.go
Normal file
345
ai-compliance-sdk/internal/api/handlers/llm_handlers.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/audit"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// LLMHandlers handles LLM-related API endpoints
|
||||
type LLMHandlers struct {
|
||||
accessGate *llm.AccessGate
|
||||
registry *llm.ProviderRegistry
|
||||
piiDetector *llm.PIIDetector
|
||||
auditStore *audit.Store
|
||||
trailBuilder *audit.TrailBuilder
|
||||
}
|
||||
|
||||
// NewLLMHandlers creates new LLM handlers
|
||||
func NewLLMHandlers(
|
||||
accessGate *llm.AccessGate,
|
||||
registry *llm.ProviderRegistry,
|
||||
piiDetector *llm.PIIDetector,
|
||||
auditStore *audit.Store,
|
||||
trailBuilder *audit.TrailBuilder,
|
||||
) *LLMHandlers {
|
||||
return &LLMHandlers{
|
||||
accessGate: accessGate,
|
||||
registry: registry,
|
||||
piiDetector: piiDetector,
|
||||
auditStore: auditStore,
|
||||
trailBuilder: trailBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
// ChatRequest represents a chat completion request
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []llm.Message `json:"messages" binding:"required"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
DataCategories []string `json:"data_categories"` // Optional hint about data types
|
||||
}
|
||||
|
||||
// Chat handles chat completion requests
|
||||
func (h *LLMHandlers) Chat(c *gin.Context) {
|
||||
var req ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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
|
||||
}
|
||||
|
||||
// Detect data categories from messages if not provided
|
||||
dataCategories := req.DataCategories
|
||||
if len(dataCategories) == 0 {
|
||||
for _, msg := range req.Messages {
|
||||
detected := h.piiDetector.DetectDataCategories(msg.Content)
|
||||
dataCategories = append(dataCategories, detected...)
|
||||
}
|
||||
}
|
||||
|
||||
// Process through access gate
|
||||
chatReq := &llm.ChatRequest{
|
||||
Model: req.Model,
|
||||
Messages: req.Messages,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Temperature: req.Temperature,
|
||||
}
|
||||
|
||||
gatedReq, err := h.accessGate.ProcessChatRequest(
|
||||
c.Request.Context(),
|
||||
userID, tenantID, namespaceID,
|
||||
chatReq, dataCategories,
|
||||
)
|
||||
if err != nil {
|
||||
// Log denied request
|
||||
h.logDeniedRequest(c, userID, tenantID, namespaceID, "chat", req.Model, err.Error())
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "access_denied",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Execute the request
|
||||
resp, err := h.accessGate.ExecuteChat(c.Request.Context(), gatedReq)
|
||||
|
||||
// Log the request
|
||||
h.logLLMRequest(c, gatedReq.GatedRequest, "chat", resp, err)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "llm_error",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": resp.ID,
|
||||
"model": resp.Model,
|
||||
"provider": resp.Provider,
|
||||
"message": resp.Message,
|
||||
"finish_reason": resp.FinishReason,
|
||||
"usage": resp.Usage,
|
||||
"pii_detected": gatedReq.PIIDetected,
|
||||
"pii_redacted": gatedReq.PromptRedacted,
|
||||
})
|
||||
}
|
||||
|
||||
// CompletionRequest represents a text completion request
|
||||
type CompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
DataCategories []string `json:"data_categories"`
|
||||
}
|
||||
|
||||
// Complete handles text completion requests
|
||||
func (h *LLMHandlers) Complete(c *gin.Context) {
|
||||
var req CompletionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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
|
||||
}
|
||||
|
||||
// Detect data categories from prompt if not provided
|
||||
dataCategories := req.DataCategories
|
||||
if len(dataCategories) == 0 {
|
||||
dataCategories = h.piiDetector.DetectDataCategories(req.Prompt)
|
||||
}
|
||||
|
||||
// Process through access gate
|
||||
completionReq := &llm.CompletionRequest{
|
||||
Model: req.Model,
|
||||
Prompt: req.Prompt,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Temperature: req.Temperature,
|
||||
}
|
||||
|
||||
gatedReq, err := h.accessGate.ProcessCompletionRequest(
|
||||
c.Request.Context(),
|
||||
userID, tenantID, namespaceID,
|
||||
completionReq, dataCategories,
|
||||
)
|
||||
if err != nil {
|
||||
h.logDeniedRequest(c, userID, tenantID, namespaceID, "completion", req.Model, err.Error())
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "access_denied",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Execute the request
|
||||
resp, err := h.accessGate.ExecuteCompletion(c.Request.Context(), gatedReq)
|
||||
|
||||
// Log the request
|
||||
h.logLLMRequest(c, gatedReq.GatedRequest, "completion", resp, err)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "llm_error",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": resp.ID,
|
||||
"model": resp.Model,
|
||||
"provider": resp.Provider,
|
||||
"text": resp.Text,
|
||||
"finish_reason": resp.FinishReason,
|
||||
"usage": resp.Usage,
|
||||
"pii_detected": gatedReq.PIIDetected,
|
||||
"pii_redacted": gatedReq.PromptRedacted,
|
||||
})
|
||||
}
|
||||
|
||||
// ListModels returns available models
|
||||
func (h *LLMHandlers) ListModels(c *gin.Context) {
|
||||
models, err := h.registry.ListAllModels(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"models": models})
|
||||
}
|
||||
|
||||
// GetProviderStatus returns the status of LLM providers
|
||||
func (h *LLMHandlers) GetProviderStatus(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
statuses := make(map[string]bool)
|
||||
|
||||
if p, ok := h.registry.GetPrimary(); ok {
|
||||
statuses[p.Name()] = p.IsAvailable(ctx)
|
||||
}
|
||||
|
||||
if p, ok := h.registry.GetFallback(); ok {
|
||||
statuses[p.Name()] = p.IsAvailable(ctx)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"providers": statuses})
|
||||
}
|
||||
|
||||
// AnalyzeText analyzes text for PII without making an LLM call
|
||||
func (h *LLMHandlers) AnalyzeText(c *gin.Context) {
|
||||
var req struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
findings := h.piiDetector.FindPII(req.Text)
|
||||
categories := h.piiDetector.DetectDataCategories(req.Text)
|
||||
containsPII := len(findings) > 0
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"contains_pii": containsPII,
|
||||
"pii_findings": findings,
|
||||
"data_categories": categories,
|
||||
})
|
||||
}
|
||||
|
||||
// RedactText redacts PII from text
|
||||
func (h *LLMHandlers) RedactText(c *gin.Context) {
|
||||
var req struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
Level string `json:"level"` // strict, moderate, minimal
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
level := rbac.PIIRedactionStrict
|
||||
switch req.Level {
|
||||
case "moderate":
|
||||
level = rbac.PIIRedactionModerate
|
||||
case "minimal":
|
||||
level = rbac.PIIRedactionMinimal
|
||||
case "none":
|
||||
level = rbac.PIIRedactionNone
|
||||
}
|
||||
|
||||
redacted := h.piiDetector.Redact(req.Text, level)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"original": req.Text,
|
||||
"redacted": redacted,
|
||||
"level": level,
|
||||
})
|
||||
}
|
||||
|
||||
// logLLMRequest logs an LLM request to the audit trail
|
||||
func (h *LLMHandlers) logLLMRequest(c *gin.Context, gatedReq *llm.GatedRequest, operation string, resp any, err error) {
|
||||
entry := h.trailBuilder.NewLLMEntry().
|
||||
WithTenant(gatedReq.TenantID).
|
||||
WithUser(gatedReq.UserID).
|
||||
WithOperation(operation).
|
||||
WithPrompt(gatedReq.PromptHash, 0). // Length calculated below
|
||||
WithPII(gatedReq.PIIDetected, gatedReq.PIITypes, gatedReq.PromptRedacted)
|
||||
|
||||
if gatedReq.NamespaceID != nil {
|
||||
entry.WithNamespace(*gatedReq.NamespaceID)
|
||||
}
|
||||
|
||||
if gatedReq.Policy != nil {
|
||||
entry.WithPolicy(&gatedReq.Policy.ID, gatedReq.AccessResult.BlockedCategories)
|
||||
}
|
||||
|
||||
// Add response data if available
|
||||
switch r := resp.(type) {
|
||||
case *llm.ChatResponse:
|
||||
entry.WithModel(r.Model, r.Provider).
|
||||
WithResponse(len(r.Message.Content)).
|
||||
WithUsage(r.Usage.TotalTokens, int(r.Duration.Milliseconds()))
|
||||
case *llm.CompletionResponse:
|
||||
entry.WithModel(r.Model, r.Provider).
|
||||
WithResponse(len(r.Text)).
|
||||
WithUsage(r.Usage.TotalTokens, int(r.Duration.Milliseconds()))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
entry.WithError(err.Error())
|
||||
}
|
||||
|
||||
// Add client info
|
||||
entry.AddMetadata("ip_address", c.ClientIP()).
|
||||
AddMetadata("user_agent", c.GetHeader("User-Agent"))
|
||||
|
||||
// Save asynchronously
|
||||
go func() {
|
||||
entry.Save(c.Request.Context())
|
||||
}()
|
||||
}
|
||||
|
||||
// logDeniedRequest logs a denied LLM request
|
||||
func (h *LLMHandlers) logDeniedRequest(c *gin.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID, operation, model, reason string) {
|
||||
entry := h.trailBuilder.NewLLMEntry().
|
||||
WithTenant(tenantID).
|
||||
WithUser(userID).
|
||||
WithOperation(operation).
|
||||
WithModel(model, "denied").
|
||||
WithError("access_denied: " + reason).
|
||||
AddMetadata("ip_address", c.ClientIP()).
|
||||
AddMetadata("user_agent", c.GetHeader("User-Agent"))
|
||||
|
||||
if namespaceID != nil {
|
||||
entry.WithNamespace(*namespaceID)
|
||||
}
|
||||
|
||||
go func() {
|
||||
entry.Save(c.Request.Context())
|
||||
}()
|
||||
}
|
||||
539
ai-compliance-sdk/internal/api/handlers/obligations_handlers.go
Normal file
539
ai-compliance-sdk/internal/api/handlers/obligations_handlers.go
Normal file
@@ -0,0 +1,539 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ObligationsHandlers handles API requests for the generic obligations framework
|
||||
type ObligationsHandlers struct {
|
||||
registry *ucca.ObligationsRegistry
|
||||
store *ucca.ObligationsStore // Optional: for persisting assessments
|
||||
}
|
||||
|
||||
// NewObligationsHandlers creates a new ObligationsHandlers instance
|
||||
func NewObligationsHandlers() *ObligationsHandlers {
|
||||
return &ObligationsHandlers{
|
||||
registry: ucca.NewObligationsRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewObligationsHandlersWithStore creates a new ObligationsHandlers with a store
|
||||
func NewObligationsHandlersWithStore(store *ucca.ObligationsStore) *ObligationsHandlers {
|
||||
return &ObligationsHandlers{
|
||||
registry: ucca.NewObligationsRegistry(),
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all obligations-related routes
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// AssessObligations assesses which obligations apply based on provided facts
|
||||
// POST /sdk/v1/ucca/obligations/assess
|
||||
func (h *ObligationsHandlers) AssessObligations(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req ucca.ObligationsAssessRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Facts == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Facts are required"})
|
||||
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.")
|
||||
}
|
||||
if overview.ExecutiveSummary.OverdueObligations > 0 {
|
||||
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,
|
||||
TenantID: tenantID,
|
||||
OrganizationName: req.OrganizationName,
|
||||
Facts: req.Facts,
|
||||
Overview: overview,
|
||||
Status: "completed",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ucca.ObligationsAssessResponse{
|
||||
Overview: overview,
|
||||
Warnings: warnings,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAssessment retrieves a stored assessment by ID
|
||||
// GET /sdk/v1/ucca/obligations/:assessmentId
|
||||
func (h *ObligationsHandlers) GetAssessment(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
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build UnifiedFacts from quick check request
|
||||
facts := &ucca.UnifiedFacts{
|
||||
Organization: ucca.OrganizationFacts{
|
||||
EmployeeCount: req.EmployeeCount,
|
||||
AnnualRevenue: req.AnnualRevenue,
|
||||
BalanceSheetTotal: req.BalanceSheetTotal,
|
||||
Country: req.Country,
|
||||
EUMember: isEUCountry(req.Country),
|
||||
},
|
||||
Sector: ucca.SectorFacts{
|
||||
PrimarySector: req.PrimarySector,
|
||||
SpecialServices: req.SpecialServices,
|
||||
IsKRITIS: req.IsKRITIS,
|
||||
KRITISThresholdMet: req.IsKRITIS,
|
||||
IsFinancialInstitution: req.IsFinancialInstitution,
|
||||
},
|
||||
DataProtection: ucca.DataProtectionFacts{
|
||||
ProcessesPersonalData: req.ProcessesPersonalData,
|
||||
},
|
||||
AIUsage: ucca.AIUsageFacts{
|
||||
UsesAI: req.UsesAI,
|
||||
},
|
||||
Financial: ucca.FinancialFacts{
|
||||
IsRegulated: req.IsFinancialInstitution,
|
||||
},
|
||||
}
|
||||
|
||||
// Quick evaluation
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
tenantID = uuid.New() // Generate temporary ID for quick check
|
||||
}
|
||||
|
||||
overview := h.registry.EvaluateAll(tenantID, facts, "")
|
||||
|
||||
// Return simplified result
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"applicable_regulations": overview.ApplicableRegulations,
|
||||
"total_obligations": len(overview.Obligations),
|
||||
"critical_obligations": overview.ExecutiveSummary.CriticalObligations,
|
||||
"sanctions_summary": overview.SanctionsSummary,
|
||||
"executive_summary": overview.ExecutiveSummary,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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)
|
||||
}
|
||||
625
ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go
Normal file
625
ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go
Normal file
@@ -0,0 +1,625 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// PortfolioHandlers handles portfolio HTTP requests
|
||||
type PortfolioHandlers struct {
|
||||
store *portfolio.Store
|
||||
}
|
||||
|
||||
// NewPortfolioHandlers creates new portfolio handlers
|
||||
func NewPortfolioHandlers(store *portfolio.Store) *PortfolioHandlers {
|
||||
return &PortfolioHandlers{store: store}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Portfolio CRUD
|
||||
// ============================================================================
|
||||
|
||||
// CreatePortfolio creates a new portfolio
|
||||
// POST /sdk/v1/portfolios
|
||||
func (h *PortfolioHandlers) CreatePortfolio(c *gin.Context) {
|
||||
var req portfolio.CreatePortfolioRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
|
||||
p := &portfolio.Portfolio{
|
||||
TenantID: tenantID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Status: portfolio.PortfolioStatusDraft,
|
||||
Department: req.Department,
|
||||
BusinessUnit: req.BusinessUnit,
|
||||
Owner: req.Owner,
|
||||
OwnerEmail: req.OwnerEmail,
|
||||
Settings: req.Settings,
|
||||
CreatedBy: userID,
|
||||
}
|
||||
|
||||
// Set default settings
|
||||
if !p.Settings.AutoUpdateMetrics {
|
||||
p.Settings.AutoUpdateMetrics = true
|
||||
}
|
||||
|
||||
if err := h.store.CreatePortfolio(c.Request.Context(), p); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"portfolio": p})
|
||||
}
|
||||
|
||||
// ListPortfolios lists portfolios
|
||||
// GET /sdk/v1/portfolios
|
||||
func (h *PortfolioHandlers) ListPortfolios(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
filters := &portfolio.PortfolioFilters{
|
||||
Limit: 50,
|
||||
}
|
||||
|
||||
if status := c.Query("status"); status != "" {
|
||||
filters.Status = portfolio.PortfolioStatus(status)
|
||||
}
|
||||
if department := c.Query("department"); department != "" {
|
||||
filters.Department = department
|
||||
}
|
||||
if businessUnit := c.Query("business_unit"); businessUnit != "" {
|
||||
filters.BusinessUnit = businessUnit
|
||||
}
|
||||
if owner := c.Query("owner"); owner != "" {
|
||||
filters.Owner = owner
|
||||
}
|
||||
if limit := c.Query("limit"); limit != "" {
|
||||
if l, err := strconv.Atoi(limit); err == nil {
|
||||
filters.Limit = l
|
||||
}
|
||||
}
|
||||
if offset := c.Query("offset"); offset != "" {
|
||||
if o, err := strconv.Atoi(offset); err == nil {
|
||||
filters.Offset = o
|
||||
}
|
||||
}
|
||||
|
||||
portfolios, err := h.store.ListPortfolios(c.Request.Context(), tenantID, filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"portfolios": portfolios,
|
||||
"total": len(portfolios),
|
||||
})
|
||||
}
|
||||
|
||||
// GetPortfolio retrieves a portfolio
|
||||
// GET /sdk/v1/portfolios/:id
|
||||
func (h *PortfolioHandlers) GetPortfolio(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"})
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.store.GetPortfolioSummary(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if summary == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get stats
|
||||
stats, _ := h.store.GetPortfolioStats(c.Request.Context(), id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"portfolio": summary.Portfolio,
|
||||
"items": summary.Items,
|
||||
"risk_distribution": summary.RiskDistribution,
|
||||
"feasibility_dist": summary.FeasibilityDist,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePortfolio updates a portfolio
|
||||
// PUT /sdk/v1/portfolios/:id
|
||||
func (h *PortfolioHandlers) UpdatePortfolio(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
|
||||
}
|
||||
|
||||
var req portfolio.UpdatePortfolioRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
p.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
p.Description = req.Description
|
||||
}
|
||||
if req.Status != "" {
|
||||
p.Status = req.Status
|
||||
}
|
||||
if req.Department != "" {
|
||||
p.Department = req.Department
|
||||
}
|
||||
if req.BusinessUnit != "" {
|
||||
p.BusinessUnit = req.BusinessUnit
|
||||
}
|
||||
if req.Owner != "" {
|
||||
p.Owner = req.Owner
|
||||
}
|
||||
if req.OwnerEmail != "" {
|
||||
p.OwnerEmail = req.OwnerEmail
|
||||
}
|
||||
if req.Settings != nil {
|
||||
p.Settings = *req.Settings
|
||||
}
|
||||
|
||||
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{"portfolio": p})
|
||||
}
|
||||
|
||||
// DeletePortfolio deletes a portfolio
|
||||
// DELETE /sdk/v1/portfolios/:id
|
||||
func (h *PortfolioHandlers) DeletePortfolio(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.DeletePortfolio(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
548
ai-compliance-sdk/internal/api/handlers/rbac_handlers.go
Normal file
548
ai-compliance-sdk/internal/api/handlers/rbac_handlers.go
Normal file
@@ -0,0 +1,548 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RBACHandlers handles RBAC-related API endpoints
|
||||
type RBACHandlers struct {
|
||||
store *rbac.Store
|
||||
service *rbac.Service
|
||||
policyEngine *rbac.PolicyEngine
|
||||
}
|
||||
|
||||
// NewRBACHandlers creates new RBAC handlers
|
||||
func NewRBACHandlers(store *rbac.Store, service *rbac.Service, policyEngine *rbac.PolicyEngine) *RBACHandlers {
|
||||
return &RBACHandlers{
|
||||
store: store,
|
||||
service: service,
|
||||
policyEngine: policyEngine,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// ListTenants returns all tenants
|
||||
func (h *RBACHandlers) ListTenants(c *gin.Context) {
|
||||
tenants, err := h.store.ListTenants(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"tenants": tenants})
|
||||
}
|
||||
|
||||
// GetTenant returns a tenant by ID
|
||||
func (h *RBACHandlers) GetTenant(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := h.store.GetTenant(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tenant)
|
||||
}
|
||||
|
||||
// CreateTenant creates a new tenant
|
||||
func (h *RBACHandlers) CreateTenant(c *gin.Context) {
|
||||
var tenant rbac.Tenant
|
||||
if err := c.ShouldBindJSON(&tenant); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.CreateTenant(c.Request.Context(), &tenant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, tenant)
|
||||
}
|
||||
|
||||
// UpdateTenant updates a tenant
|
||||
func (h *RBACHandlers) UpdateTenant(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var tenant rbac.Tenant
|
||||
if err := c.ShouldBindJSON(&tenant); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenant.ID = id
|
||||
if err := h.store.UpdateTenant(c.Request.Context(), &tenant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tenant)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Namespace Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// ListNamespaces returns namespaces for a tenant
|
||||
func (h *RBACHandlers) ListNamespaces(c *gin.Context) {
|
||||
tenantID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
tenantID = rbac.GetTenantID(c)
|
||||
}
|
||||
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
namespaces, err := h.store.ListNamespaces(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"namespaces": namespaces})
|
||||
}
|
||||
|
||||
// GetNamespace returns a namespace by ID
|
||||
func (h *RBACHandlers) GetNamespace(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid namespace ID"})
|
||||
return
|
||||
}
|
||||
|
||||
namespace, err := h.store.GetNamespace(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "namespace not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, namespace)
|
||||
}
|
||||
|
||||
// CreateNamespace creates a new namespace
|
||||
func (h *RBACHandlers) CreateNamespace(c *gin.Context) {
|
||||
tenantID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
tenantID = rbac.GetTenantID(c)
|
||||
}
|
||||
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var namespace rbac.Namespace
|
||||
if err := c.ShouldBindJSON(&namespace); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
namespace.TenantID = tenantID
|
||||
if err := h.store.CreateNamespace(c.Request.Context(), &namespace); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
740
ai-compliance-sdk/internal/api/handlers/roadmap_handlers.go
Normal file
740
ai-compliance-sdk/internal/api/handlers/roadmap_handlers.go
Normal file
@@ -0,0 +1,740 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/roadmap"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RoadmapHandlers handles roadmap-related HTTP requests
|
||||
type RoadmapHandlers struct {
|
||||
store *roadmap.Store
|
||||
parser *roadmap.Parser
|
||||
}
|
||||
|
||||
// NewRoadmapHandlers creates new roadmap handlers
|
||||
func NewRoadmapHandlers(store *roadmap.Store) *RoadmapHandlers {
|
||||
return &RoadmapHandlers{
|
||||
store: store,
|
||||
parser: roadmap.NewParser(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Roadmap CRUD
|
||||
// ============================================================================
|
||||
|
||||
// CreateRoadmap creates a new roadmap
|
||||
// POST /sdk/v1/roadmaps
|
||||
func (h *RoadmapHandlers) CreateRoadmap(c *gin.Context) {
|
||||
var req roadmap.CreateRoadmapRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
|
||||
r := &roadmap.Roadmap{
|
||||
TenantID: tenantID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
AssessmentID: req.AssessmentID,
|
||||
PortfolioID: req.PortfolioID,
|
||||
StartDate: req.StartDate,
|
||||
TargetDate: req.TargetDate,
|
||||
Status: "draft",
|
||||
CreatedBy: userID,
|
||||
}
|
||||
|
||||
if err := h.store.CreateRoadmap(c.Request.Context(), r); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, roadmap.CreateRoadmapResponse{Roadmap: *r})
|
||||
}
|
||||
|
||||
// ListRoadmaps lists roadmaps for the tenant
|
||||
// GET /sdk/v1/roadmaps
|
||||
func (h *RoadmapHandlers) ListRoadmaps(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
filters := &roadmap.RoadmapFilters{
|
||||
Status: c.Query("status"),
|
||||
Limit: 50,
|
||||
}
|
||||
|
||||
if assessmentID := c.Query("assessment_id"); assessmentID != "" {
|
||||
if id, err := uuid.Parse(assessmentID); err == nil {
|
||||
filters.AssessmentID = &id
|
||||
}
|
||||
}
|
||||
|
||||
roadmaps, err := h.store.ListRoadmaps(c.Request.Context(), tenantID, filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"roadmaps": roadmaps,
|
||||
"total": len(roadmaps),
|
||||
})
|
||||
}
|
||||
|
||||
// GetRoadmap retrieves a roadmap by ID
|
||||
// GET /sdk/v1/roadmaps/:id
|
||||
func (h *RoadmapHandlers) GetRoadmap(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
r, err := h.store.GetRoadmap(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "roadmap not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get items
|
||||
items, err := h.store.ListItems(c.Request.Context(), id, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get stats
|
||||
stats, _ := h.store.GetRoadmapStats(c.Request.Context(), id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"roadmap": r,
|
||||
"items": items,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRoadmap updates a roadmap
|
||||
// PUT /sdk/v1/roadmaps/:id
|
||||
func (h *RoadmapHandlers) UpdateRoadmap(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
r, err := h.store.GetRoadmap(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "roadmap not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req roadmap.CreateRoadmapRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
r.Title = req.Title
|
||||
r.Description = req.Description
|
||||
r.AssessmentID = req.AssessmentID
|
||||
r.PortfolioID = req.PortfolioID
|
||||
r.StartDate = req.StartDate
|
||||
r.TargetDate = req.TargetDate
|
||||
|
||||
if err := h.store.UpdateRoadmap(c.Request.Context(), r); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"roadmap": r})
|
||||
}
|
||||
|
||||
// DeleteRoadmap deletes a roadmap
|
||||
// DELETE /sdk/v1/roadmaps/:id
|
||||
func (h *RoadmapHandlers) DeleteRoadmap(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteRoadmap(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "roadmap deleted"})
|
||||
}
|
||||
|
||||
// GetRoadmapStats returns statistics for a roadmap
|
||||
// GET /sdk/v1/roadmaps/:id/stats
|
||||
func (h *RoadmapHandlers) GetRoadmapStats(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.store.GetRoadmapStats(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RoadmapItem CRUD
|
||||
// ============================================================================
|
||||
|
||||
// CreateItem creates a new roadmap item
|
||||
// POST /sdk/v1/roadmaps/:id/items
|
||||
func (h *RoadmapHandlers) CreateItem(c *gin.Context) {
|
||||
roadmapID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid roadmap ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var input roadmap.RoadmapItemInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
item := &roadmap.RoadmapItem{
|
||||
RoadmapID: roadmapID,
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
Category: input.Category,
|
||||
Priority: input.Priority,
|
||||
Status: input.Status,
|
||||
ControlID: input.ControlID,
|
||||
RegulationRef: input.RegulationRef,
|
||||
GapID: input.GapID,
|
||||
EffortDays: input.EffortDays,
|
||||
AssigneeName: input.AssigneeName,
|
||||
Department: input.Department,
|
||||
PlannedStart: input.PlannedStart,
|
||||
PlannedEnd: input.PlannedEnd,
|
||||
Notes: input.Notes,
|
||||
}
|
||||
|
||||
if err := h.store.CreateItem(c.Request.Context(), item); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update roadmap progress
|
||||
h.store.UpdateRoadmapProgress(c.Request.Context(), roadmapID)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"item": item})
|
||||
}
|
||||
|
||||
// ListItems lists items for a roadmap
|
||||
// GET /sdk/v1/roadmaps/:id/items
|
||||
func (h *RoadmapHandlers) ListItems(c *gin.Context) {
|
||||
roadmapID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid roadmap ID"})
|
||||
return
|
||||
}
|
||||
|
||||
filters := &roadmap.RoadmapItemFilters{
|
||||
SearchQuery: c.Query("search"),
|
||||
Limit: 100,
|
||||
}
|
||||
|
||||
if status := c.Query("status"); status != "" {
|
||||
filters.Status = roadmap.ItemStatus(status)
|
||||
}
|
||||
if priority := c.Query("priority"); priority != "" {
|
||||
filters.Priority = roadmap.ItemPriority(priority)
|
||||
}
|
||||
if category := c.Query("category"); category != "" {
|
||||
filters.Category = roadmap.ItemCategory(category)
|
||||
}
|
||||
if controlID := c.Query("control_id"); controlID != "" {
|
||||
filters.ControlID = controlID
|
||||
}
|
||||
|
||||
items, err := h.store.ListItems(c.Request.Context(), roadmapID, filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
})
|
||||
}
|
||||
|
||||
// GetItem retrieves a roadmap item
|
||||
// GET /sdk/v1/roadmap-items/:id
|
||||
func (h *RoadmapHandlers) GetItem(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.store.GetItem(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "item not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": item})
|
||||
}
|
||||
|
||||
// UpdateItem updates a roadmap item
|
||||
// PUT /sdk/v1/roadmap-items/:id
|
||||
func (h *RoadmapHandlers) UpdateItem(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.store.GetItem(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "item not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var input roadmap.RoadmapItemInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
item.Title = input.Title
|
||||
item.Description = input.Description
|
||||
if input.Category != "" {
|
||||
item.Category = input.Category
|
||||
}
|
||||
if input.Priority != "" {
|
||||
item.Priority = input.Priority
|
||||
}
|
||||
if input.Status != "" {
|
||||
item.Status = input.Status
|
||||
}
|
||||
item.ControlID = input.ControlID
|
||||
item.RegulationRef = input.RegulationRef
|
||||
item.GapID = input.GapID
|
||||
item.EffortDays = input.EffortDays
|
||||
item.AssigneeName = input.AssigneeName
|
||||
item.Department = input.Department
|
||||
item.PlannedStart = input.PlannedStart
|
||||
item.PlannedEnd = input.PlannedEnd
|
||||
item.Notes = input.Notes
|
||||
|
||||
if err := h.store.UpdateItem(c.Request.Context(), item); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update roadmap progress
|
||||
h.store.UpdateRoadmapProgress(c.Request.Context(), item.RoadmapID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": item})
|
||||
}
|
||||
|
||||
// UpdateItemStatus updates just the status of a roadmap item
|
||||
// PATCH /sdk/v1/roadmap-items/:id/status
|
||||
func (h *RoadmapHandlers) UpdateItemStatus(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status roadmap.ItemStatus `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.store.GetItem(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "item not found"})
|
||||
return
|
||||
}
|
||||
|
||||
item.Status = req.Status
|
||||
|
||||
// Set actual dates
|
||||
now := time.Now().UTC()
|
||||
if req.Status == roadmap.ItemStatusInProgress && item.ActualStart == nil {
|
||||
item.ActualStart = &now
|
||||
}
|
||||
if req.Status == roadmap.ItemStatusCompleted && item.ActualEnd == nil {
|
||||
item.ActualEnd = &now
|
||||
}
|
||||
|
||||
if err := h.store.UpdateItem(c.Request.Context(), item); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update roadmap progress
|
||||
h.store.UpdateRoadmapProgress(c.Request.Context(), item.RoadmapID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": item})
|
||||
}
|
||||
|
||||
// DeleteItem deletes a roadmap item
|
||||
// DELETE /sdk/v1/roadmap-items/:id
|
||||
func (h *RoadmapHandlers) DeleteItem(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.store.GetItem(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "item not found"})
|
||||
return
|
||||
}
|
||||
|
||||
roadmapID := item.RoadmapID
|
||||
|
||||
if err := h.store.DeleteItem(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update roadmap progress
|
||||
h.store.UpdateRoadmapProgress(c.Request.Context(), roadmapID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "item deleted"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Import Workflow
|
||||
// ============================================================================
|
||||
|
||||
// UploadImport handles file upload for import
|
||||
// POST /sdk/v1/roadmaps/import/upload
|
||||
func (h *RoadmapHandlers) UploadImport(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
|
||||
// Get file from form
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read file content
|
||||
buf := bytes.Buffer{}
|
||||
if _, err := io.Copy(&buf, file); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Detect format
|
||||
format := roadmap.ImportFormat("")
|
||||
filename := header.Filename
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
|
||||
// Create import job
|
||||
job := &roadmap.ImportJob{
|
||||
TenantID: tenantID,
|
||||
Filename: filename,
|
||||
FileSize: header.Size,
|
||||
ContentType: contentType,
|
||||
Status: "pending",
|
||||
CreatedBy: userID,
|
||||
}
|
||||
|
||||
if err := h.store.CreateImportJob(c.Request.Context(), job); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the file
|
||||
job.Status = "parsing"
|
||||
h.store.UpdateImportJob(c.Request.Context(), job)
|
||||
|
||||
result, err := h.parser.ParseFile(buf.Bytes(), filename, contentType)
|
||||
if err != nil {
|
||||
job.Status = "failed"
|
||||
job.ErrorMessage = err.Error()
|
||||
h.store.UpdateImportJob(c.Request.Context(), job)
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "failed to parse file",
|
||||
"detail": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update job with parsed data
|
||||
job.Status = "parsed"
|
||||
job.Format = format
|
||||
job.TotalRows = result.TotalRows
|
||||
job.ValidRows = result.ValidRows
|
||||
job.InvalidRows = result.InvalidRows
|
||||
job.ParsedItems = result.Items
|
||||
|
||||
h.store.UpdateImportJob(c.Request.Context(), job)
|
||||
|
||||
c.JSON(http.StatusOK, roadmap.ImportParseResponse{
|
||||
JobID: job.ID,
|
||||
Status: job.Status,
|
||||
TotalRows: result.TotalRows,
|
||||
ValidRows: result.ValidRows,
|
||||
InvalidRows: result.InvalidRows,
|
||||
Items: result.Items,
|
||||
ColumnMap: buildColumnMap(result.Columns),
|
||||
})
|
||||
}
|
||||
|
||||
// GetImportJob returns the status of an import job
|
||||
// GET /sdk/v1/roadmaps/import/:jobId
|
||||
func (h *RoadmapHandlers) GetImportJob(c *gin.Context) {
|
||||
jobID, err := uuid.Parse(c.Param("jobId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job ID"})
|
||||
return
|
||||
}
|
||||
|
||||
job, err := h.store.GetImportJob(c.Request.Context(), jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "import job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"job": job,
|
||||
"items": job.ParsedItems,
|
||||
})
|
||||
}
|
||||
|
||||
// ConfirmImport confirms and executes the import
|
||||
// POST /sdk/v1/roadmaps/import/:jobId/confirm
|
||||
func (h *RoadmapHandlers) ConfirmImport(c *gin.Context) {
|
||||
jobID, err := uuid.Parse(c.Param("jobId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req roadmap.ImportConfirmRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
job, err := h.store.GetImportJob(c.Request.Context(), jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "import job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if job.Status != "parsed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "job is not ready for confirmation",
|
||||
"status": job.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
|
||||
// Create or use existing roadmap
|
||||
var roadmapID uuid.UUID
|
||||
if req.RoadmapID != nil {
|
||||
roadmapID = *req.RoadmapID
|
||||
// Verify roadmap exists
|
||||
r, err := h.store.GetRoadmap(c.Request.Context(), roadmapID)
|
||||
if err != nil || r == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "roadmap not found"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Create new roadmap
|
||||
title := req.RoadmapTitle
|
||||
if title == "" {
|
||||
title = "Imported Roadmap - " + job.Filename
|
||||
}
|
||||
|
||||
r := &roadmap.Roadmap{
|
||||
TenantID: tenantID,
|
||||
Title: title,
|
||||
Status: "active",
|
||||
CreatedBy: userID,
|
||||
}
|
||||
|
||||
if err := h.store.CreateRoadmap(c.Request.Context(), r); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
roadmapID = r.ID
|
||||
}
|
||||
|
||||
// Determine which rows to import
|
||||
selectedRows := make(map[int]bool)
|
||||
if len(req.SelectedRows) > 0 {
|
||||
for _, row := range req.SelectedRows {
|
||||
selectedRows[row] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Convert parsed items to roadmap items
|
||||
var items []roadmap.RoadmapItem
|
||||
var importedCount, skippedCount int
|
||||
|
||||
for i, parsed := range job.ParsedItems {
|
||||
// Skip invalid items
|
||||
if !parsed.IsValid {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip unselected rows if selection was specified
|
||||
if len(selectedRows) > 0 && !selectedRows[parsed.RowNumber] {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
item := roadmap.RoadmapItem{
|
||||
RoadmapID: roadmapID,
|
||||
Title: parsed.Data.Title,
|
||||
Description: parsed.Data.Description,
|
||||
Category: parsed.Data.Category,
|
||||
Priority: parsed.Data.Priority,
|
||||
Status: parsed.Data.Status,
|
||||
ControlID: parsed.Data.ControlID,
|
||||
RegulationRef: parsed.Data.RegulationRef,
|
||||
GapID: parsed.Data.GapID,
|
||||
EffortDays: parsed.Data.EffortDays,
|
||||
AssigneeName: parsed.Data.AssigneeName,
|
||||
Department: parsed.Data.Department,
|
||||
PlannedStart: parsed.Data.PlannedStart,
|
||||
PlannedEnd: parsed.Data.PlannedEnd,
|
||||
Notes: parsed.Data.Notes,
|
||||
SourceRow: parsed.RowNumber,
|
||||
SourceFile: job.Filename,
|
||||
SortOrder: i,
|
||||
}
|
||||
|
||||
// Apply auto-mappings if requested
|
||||
if req.ApplyMappings {
|
||||
if parsed.MatchedControl != "" {
|
||||
item.ControlID = parsed.MatchedControl
|
||||
}
|
||||
if parsed.MatchedRegulation != "" {
|
||||
item.RegulationRef = parsed.MatchedRegulation
|
||||
}
|
||||
if parsed.MatchedGap != "" {
|
||||
item.GapID = parsed.MatchedGap
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if item.Status == "" {
|
||||
item.Status = roadmap.ItemStatusPlanned
|
||||
}
|
||||
if item.Priority == "" {
|
||||
item.Priority = roadmap.ItemPriorityMedium
|
||||
}
|
||||
if item.Category == "" {
|
||||
item.Category = roadmap.ItemCategoryTechnical
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
importedCount++
|
||||
}
|
||||
|
||||
// Bulk create items
|
||||
if len(items) > 0 {
|
||||
if err := h.store.BulkCreateItems(c.Request.Context(), items); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update roadmap progress
|
||||
h.store.UpdateRoadmapProgress(c.Request.Context(), roadmapID)
|
||||
|
||||
// Update job status
|
||||
now := time.Now().UTC()
|
||||
job.Status = "completed"
|
||||
job.RoadmapID = &roadmapID
|
||||
job.ImportedItems = importedCount
|
||||
job.CompletedAt = &now
|
||||
h.store.UpdateImportJob(c.Request.Context(), job)
|
||||
|
||||
c.JSON(http.StatusOK, roadmap.ImportConfirmResponse{
|
||||
RoadmapID: roadmapID,
|
||||
ImportedItems: importedCount,
|
||||
SkippedItems: skippedCount,
|
||||
Message: "Import completed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
func buildColumnMap(columns []roadmap.DetectedColumn) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, col := range columns {
|
||||
if col.MappedTo != "" {
|
||||
result[col.Header] = col.MappedTo
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
1055
ai-compliance-sdk/internal/api/handlers/ucca_handlers.go
Normal file
1055
ai-compliance-sdk/internal/api/handlers/ucca_handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
626
ai-compliance-sdk/internal/api/handlers/ucca_handlers_test.go
Normal file
626
ai-compliance-sdk/internal/api/handlers/ucca_handlers_test.go
Normal file
@@ -0,0 +1,626 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// getProjectRoot returns the project root directory
|
||||
func getProjectRoot(t *testing.T) string {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatalf("Could not find project root (no go.mod found)")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// mockTenantContext sets up a gin context with tenant ID
|
||||
func mockTenantContext(c *gin.Context, tenantID, userID uuid.UUID) {
|
||||
c.Set("tenant_id", tenantID)
|
||||
c.Set("user_id", userID)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Policy Engine Integration Tests (No DB)
|
||||
// ============================================================================
|
||||
|
||||
func TestUCCAHandlers_ListPatterns(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
|
||||
engine, err := ucca.NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test - could not load policy engine: %v", err)
|
||||
}
|
||||
|
||||
handler := &UCCAHandlers{
|
||||
policyEngine: engine,
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
handler.ListPatterns(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
patterns, ok := response["patterns"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected patterns array in response")
|
||||
}
|
||||
|
||||
if len(patterns) == 0 {
|
||||
t.Error("Expected at least some patterns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_ListControls(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
|
||||
engine, err := ucca.NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test - could not load policy engine: %v", err)
|
||||
}
|
||||
|
||||
handler := &UCCAHandlers{
|
||||
policyEngine: engine,
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
handler.ListControls(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
controls, ok := response["controls"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected controls array in response")
|
||||
}
|
||||
|
||||
if len(controls) == 0 {
|
||||
t.Error("Expected at least some controls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_ListRules(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
|
||||
engine, err := ucca.NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test - could not load policy engine: %v", err)
|
||||
}
|
||||
|
||||
handler := &UCCAHandlers{
|
||||
policyEngine: engine,
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
handler.ListRules(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
rules, ok := response["rules"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected rules array in response")
|
||||
}
|
||||
|
||||
if len(rules) == 0 {
|
||||
t.Error("Expected at least some rules")
|
||||
}
|
||||
|
||||
// Check that policy version is returned
|
||||
if _, ok := response["policy_version"]; !ok {
|
||||
t.Error("Expected policy_version in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_ListExamples(t *testing.T) {
|
||||
handler := &UCCAHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
handler.ListExamples(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
examples, ok := response["examples"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected examples array in response")
|
||||
}
|
||||
|
||||
if len(examples) == 0 {
|
||||
t.Error("Expected at least some examples")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_ListProblemSolutions_WithEngine(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
|
||||
engine, err := ucca.NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test - could not load policy engine: %v", err)
|
||||
}
|
||||
|
||||
handler := &UCCAHandlers{
|
||||
policyEngine: engine,
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
handler.ListProblemSolutions(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := response["problem_solutions"]; !ok {
|
||||
t.Error("Expected problem_solutions in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_ListProblemSolutions_WithoutEngine(t *testing.T) {
|
||||
handler := &UCCAHandlers{
|
||||
policyEngine: nil,
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
handler.ListProblemSolutions(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := response["message"]; !ok {
|
||||
t.Error("Expected message when policy engine not available")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestUCCAHandlers_Assess_MissingTenantID(t *testing.T) {
|
||||
handler := &UCCAHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/assess", nil)
|
||||
|
||||
// Don't set tenant ID
|
||||
handler.Assess(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_Assess_InvalidJSON(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
|
||||
engine, _ := ucca.NewPolicyEngineFromPath(policyPath)
|
||||
|
||||
handler := &UCCAHandlers{
|
||||
policyEngine: engine,
|
||||
legacyRuleEngine: ucca.NewRuleEngine(),
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/assess", bytes.NewBufferString("invalid json"))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("tenant_id", uuid.New())
|
||||
c.Set("user_id", uuid.New())
|
||||
|
||||
handler.Assess(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for invalid JSON, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_GetAssessment_InvalidID(t *testing.T) {
|
||||
handler := &UCCAHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
|
||||
c.Request = httptest.NewRequest("GET", "/assessments/not-a-uuid", nil)
|
||||
|
||||
handler.GetAssessment(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for invalid ID, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_DeleteAssessment_InvalidID(t *testing.T) {
|
||||
handler := &UCCAHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "invalid"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/assessments/invalid", nil)
|
||||
|
||||
handler.DeleteAssessment(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for invalid ID, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_Export_InvalidID(t *testing.T) {
|
||||
handler := &UCCAHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-valid"}}
|
||||
c.Request = httptest.NewRequest("GET", "/export/not-valid", nil)
|
||||
|
||||
handler.Export(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for invalid ID, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_Explain_InvalidID(t *testing.T) {
|
||||
handler := &UCCAHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "bad-id"}}
|
||||
c.Request = httptest.NewRequest("POST", "/assessments/bad-id/explain", nil)
|
||||
|
||||
handler.Explain(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for invalid ID, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_ListAssessments_MissingTenantID(t *testing.T) {
|
||||
handler := &UCCAHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/assessments", nil)
|
||||
|
||||
handler.ListAssessments(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_GetStats_MissingTenantID(t *testing.T) {
|
||||
handler := &UCCAHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/stats", nil)
|
||||
|
||||
handler.GetStats(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Markdown Export Generation Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGenerateMarkdownExport(t *testing.T) {
|
||||
assessment := &ucca.Assessment{
|
||||
ID: uuid.New(),
|
||||
Title: "Test Assessment",
|
||||
Domain: ucca.DomainEducation,
|
||||
Feasibility: ucca.FeasibilityCONDITIONAL,
|
||||
RiskLevel: ucca.RiskLevelMEDIUM,
|
||||
RiskScore: 45,
|
||||
Complexity: ucca.ComplexityMEDIUM,
|
||||
TriggeredRules: []ucca.TriggeredRule{
|
||||
{Code: "R-A001", Title: "Test Rule", Severity: "WARN", ScoreDelta: 10},
|
||||
},
|
||||
RequiredControls: []ucca.RequiredControl{
|
||||
{ID: "C-001", Title: "Test Control", Description: "Test Description"},
|
||||
},
|
||||
DSFARecommended: true,
|
||||
Art22Risk: false,
|
||||
TrainingAllowed: ucca.TrainingCONDITIONAL,
|
||||
PolicyVersion: "1.0.0",
|
||||
}
|
||||
|
||||
markdown := generateMarkdownExport(assessment)
|
||||
|
||||
// Check for expected content
|
||||
if markdown == "" {
|
||||
t.Error("Expected non-empty markdown")
|
||||
}
|
||||
|
||||
expectedContents := []string{
|
||||
"# UCCA Use-Case Assessment",
|
||||
"CONDITIONAL",
|
||||
"MEDIUM",
|
||||
"45/100",
|
||||
"Test Rule",
|
||||
"Test Control",
|
||||
"DSFA",
|
||||
"1.0.0",
|
||||
}
|
||||
|
||||
for _, expected := range expectedContents {
|
||||
if !bytes.Contains([]byte(markdown), []byte(expected)) {
|
||||
t.Errorf("Expected markdown to contain '%s'", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMarkdownExport_WithExplanation(t *testing.T) {
|
||||
explanation := "Dies ist eine KI-generierte Erklärung."
|
||||
assessment := &ucca.Assessment{
|
||||
ID: uuid.New(),
|
||||
Feasibility: ucca.FeasibilityYES,
|
||||
RiskLevel: ucca.RiskLevelMINIMAL,
|
||||
RiskScore: 10,
|
||||
ExplanationText: &explanation,
|
||||
PolicyVersion: "1.0.0",
|
||||
}
|
||||
|
||||
markdown := generateMarkdownExport(assessment)
|
||||
|
||||
if !bytes.Contains([]byte(markdown), []byte("KI-Erklärung")) {
|
||||
t.Error("Expected markdown to contain explanation section")
|
||||
}
|
||||
|
||||
if !bytes.Contains([]byte(markdown), []byte(explanation)) {
|
||||
t.Error("Expected markdown to contain the explanation text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMarkdownExport_WithForbiddenPatterns(t *testing.T) {
|
||||
assessment := &ucca.Assessment{
|
||||
ID: uuid.New(),
|
||||
Feasibility: ucca.FeasibilityNO,
|
||||
RiskLevel: ucca.RiskLevelHIGH,
|
||||
RiskScore: 85,
|
||||
ForbiddenPatterns: []ucca.ForbiddenPattern{
|
||||
{PatternID: "FP-001", Title: "Forbidden Pattern", Reason: "Not allowed"},
|
||||
},
|
||||
PolicyVersion: "1.0.0",
|
||||
}
|
||||
|
||||
markdown := generateMarkdownExport(assessment)
|
||||
|
||||
if !bytes.Contains([]byte(markdown), []byte("Verbotene Patterns")) {
|
||||
t.Error("Expected markdown to contain forbidden patterns section")
|
||||
}
|
||||
|
||||
if !bytes.Contains([]byte(markdown), []byte("Not allowed")) {
|
||||
t.Error("Expected markdown to contain forbidden pattern reason")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Explanation Prompt Building Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestBuildExplanationPrompt(t *testing.T) {
|
||||
assessment := &ucca.Assessment{
|
||||
Feasibility: ucca.FeasibilityCONDITIONAL,
|
||||
RiskLevel: ucca.RiskLevelMEDIUM,
|
||||
RiskScore: 50,
|
||||
Complexity: ucca.ComplexityMEDIUM,
|
||||
TriggeredRules: []ucca.TriggeredRule{
|
||||
{Code: "R-001", Title: "Test", Severity: "WARN"},
|
||||
},
|
||||
RequiredControls: []ucca.RequiredControl{
|
||||
{Title: "Control", Description: "Desc"},
|
||||
},
|
||||
DSFARecommended: true,
|
||||
Art22Risk: true,
|
||||
}
|
||||
|
||||
prompt := buildExplanationPrompt(assessment, "de", "")
|
||||
|
||||
// Check prompt contains expected elements
|
||||
expectedElements := []string{
|
||||
"CONDITIONAL",
|
||||
"MEDIUM",
|
||||
"50/100",
|
||||
"Ausgelöste Regeln",
|
||||
"Erforderliche Maßnahmen",
|
||||
"DSFA",
|
||||
"Art. 22",
|
||||
}
|
||||
|
||||
for _, expected := range expectedElements {
|
||||
if !bytes.Contains([]byte(prompt), []byte(expected)) {
|
||||
t.Errorf("Expected prompt to contain '%s'", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExplanationPrompt_WithLegalContext(t *testing.T) {
|
||||
assessment := &ucca.Assessment{
|
||||
Feasibility: ucca.FeasibilityYES,
|
||||
RiskLevel: ucca.RiskLevelLOW,
|
||||
RiskScore: 15,
|
||||
Complexity: ucca.ComplexityLOW,
|
||||
}
|
||||
|
||||
legalContext := "**Relevante Rechtsgrundlagen:**\nArt. 6 DSGVO - Rechtmäßigkeit"
|
||||
|
||||
prompt := buildExplanationPrompt(assessment, "de", legalContext)
|
||||
|
||||
if !bytes.Contains([]byte(prompt), []byte("Relevante Rechtsgrundlagen")) {
|
||||
t.Error("Expected prompt to contain legal context")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Legacy Rule Engine Fallback Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestUCCAHandlers_ListRules_LegacyFallback(t *testing.T) {
|
||||
handler := &UCCAHandlers{
|
||||
policyEngine: nil, // No YAML engine
|
||||
legacyRuleEngine: ucca.NewRuleEngine(),
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
handler.ListRules(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
// Should have legacy policy version
|
||||
policyVersion, ok := response["policy_version"].(string)
|
||||
if !ok {
|
||||
t.Fatal("Expected policy_version string")
|
||||
}
|
||||
|
||||
if policyVersion != "1.0.0-legacy" {
|
||||
t.Errorf("Expected legacy policy version, got %s", policyVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_ListPatterns_LegacyFallback(t *testing.T) {
|
||||
handler := &UCCAHandlers{
|
||||
policyEngine: nil, // No YAML engine
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
handler.ListPatterns(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
patterns, ok := response["patterns"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected patterns array in response")
|
||||
}
|
||||
|
||||
// Legacy patterns should still be returned
|
||||
if len(patterns) == 0 {
|
||||
t.Error("Expected at least some legacy patterns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUCCAHandlers_ListControls_LegacyFallback(t *testing.T) {
|
||||
handler := &UCCAHandlers{
|
||||
policyEngine: nil, // No YAML engine
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
handler.ListControls(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
controls, ok := response["controls"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected controls array in response")
|
||||
}
|
||||
|
||||
// Legacy controls should still be returned
|
||||
if len(controls) == 0 {
|
||||
t.Error("Expected at least some legacy controls")
|
||||
}
|
||||
}
|
||||
923
ai-compliance-sdk/internal/api/handlers/workshop_handlers.go
Normal file
923
ai-compliance-sdk/internal/api/handlers/workshop_handlers.go
Normal file
@@ -0,0 +1,923 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// WorkshopHandlers handles workshop session HTTP requests
|
||||
type WorkshopHandlers struct {
|
||||
store *workshop.Store
|
||||
}
|
||||
|
||||
// NewWorkshopHandlers creates new workshop handlers
|
||||
func NewWorkshopHandlers(store *workshop.Store) *WorkshopHandlers {
|
||||
return &WorkshopHandlers{store: store}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Management
|
||||
// ============================================================================
|
||||
|
||||
// CreateSession creates a new workshop session
|
||||
// POST /sdk/v1/workshops
|
||||
func (h *WorkshopHandlers) CreateSession(c *gin.Context) {
|
||||
var req workshop.CreateSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
|
||||
session := &workshop.Session{
|
||||
TenantID: tenantID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
SessionType: req.SessionType,
|
||||
WizardSchema: req.WizardSchema,
|
||||
Status: workshop.SessionStatusDraft,
|
||||
CurrentStep: 1,
|
||||
TotalSteps: 10, // Default, will be updated when wizard is loaded
|
||||
ScheduledStart: req.ScheduledStart,
|
||||
ScheduledEnd: req.ScheduledEnd,
|
||||
AssessmentID: req.AssessmentID,
|
||||
RoadmapID: req.RoadmapID,
|
||||
PortfolioID: req.PortfolioID,
|
||||
Settings: req.Settings,
|
||||
CreatedBy: userID,
|
||||
}
|
||||
|
||||
// Set default settings if not provided
|
||||
if !session.Settings.AllowBackNavigation {
|
||||
session.Settings.AllowBackNavigation = true
|
||||
}
|
||||
if !session.Settings.AllowNotes {
|
||||
session.Settings.AllowNotes = true
|
||||
}
|
||||
if !session.Settings.AutoSave {
|
||||
session.Settings.AutoSave = true
|
||||
}
|
||||
|
||||
if err := h.store.CreateSession(c.Request.Context(), session); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Add creator as facilitator
|
||||
facilitator := &workshop.Participant{
|
||||
SessionID: session.ID,
|
||||
UserID: &userID,
|
||||
Name: "Session Owner",
|
||||
Role: workshop.ParticipantRoleFacilitator,
|
||||
CanEdit: true,
|
||||
CanComment: true,
|
||||
CanApprove: true,
|
||||
}
|
||||
h.store.AddParticipant(c.Request.Context(), facilitator)
|
||||
|
||||
c.JSON(http.StatusCreated, workshop.CreateSessionResponse{
|
||||
Session: *session,
|
||||
JoinCode: session.JoinCode,
|
||||
})
|
||||
}
|
||||
|
||||
// ListSessions lists workshop sessions
|
||||
// GET /sdk/v1/workshops
|
||||
func (h *WorkshopHandlers) ListSessions(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
filters := &workshop.SessionFilters{
|
||||
Limit: 50,
|
||||
}
|
||||
|
||||
if status := c.Query("status"); status != "" {
|
||||
filters.Status = workshop.SessionStatus(status)
|
||||
}
|
||||
if sessionType := c.Query("type"); sessionType != "" {
|
||||
filters.SessionType = sessionType
|
||||
}
|
||||
|
||||
sessions, err := h.store.ListSessions(c.Request.Context(), tenantID, filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sessions": sessions,
|
||||
"total": len(sessions),
|
||||
})
|
||||
}
|
||||
|
||||
// GetSession retrieves a session with details
|
||||
// GET /sdk/v1/workshops/:id
|
||||
func (h *WorkshopHandlers) GetSession(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.store.GetSessionSummary(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if summary == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get responses for current step
|
||||
stepNumber := summary.Session.CurrentStep
|
||||
responses, _ := h.store.GetResponses(c.Request.Context(), id, &stepNumber)
|
||||
|
||||
// Get stats
|
||||
stats, _ := h.store.GetSessionStats(c.Request.Context(), id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session": summary.Session,
|
||||
"participants": summary.Participants,
|
||||
"step_progress": summary.StepProgress,
|
||||
"responses": responses,
|
||||
"stats": stats,
|
||||
"progress": summary.OverallProgress,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateSession updates a session
|
||||
// PUT /sdk/v1/workshops/:id
|
||||
func (h *WorkshopHandlers) UpdateSession(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetSession(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req workshop.CreateSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
session.Title = req.Title
|
||||
session.Description = req.Description
|
||||
session.ScheduledStart = req.ScheduledStart
|
||||
session.ScheduledEnd = req.ScheduledEnd
|
||||
session.Settings = req.Settings
|
||||
|
||||
if err := h.store.UpdateSession(c.Request.Context(), session); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"session": session})
|
||||
}
|
||||
|
||||
// DeleteSession deletes a session
|
||||
// DELETE /sdk/v1/workshops/:id
|
||||
func (h *WorkshopHandlers) DeleteSession(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteSession(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "session deleted"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Status Control
|
||||
// ============================================================================
|
||||
|
||||
// StartSession starts a workshop session
|
||||
// POST /sdk/v1/workshops/:id/start
|
||||
func (h *WorkshopHandlers) StartSession(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetSession(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.Status != workshop.SessionStatusDraft && session.Status != workshop.SessionStatusScheduled && session.Status != workshop.SessionStatusPaused {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session cannot be started from current state"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.UpdateSessionStatus(c.Request.Context(), id, workshop.SessionStatusActive); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize first step progress
|
||||
h.store.UpdateStepProgress(c.Request.Context(), id, 1, "in_progress", 0)
|
||||
|
||||
session.Status = workshop.SessionStatusActive
|
||||
now := time.Now().UTC()
|
||||
session.ActualStart = &now
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"session": session, "message": "session started"})
|
||||
}
|
||||
|
||||
// PauseSession pauses a workshop session
|
||||
// POST /sdk/v1/workshops/:id/pause
|
||||
func (h *WorkshopHandlers) PauseSession(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.UpdateSessionStatus(c.Request.Context(), id, workshop.SessionStatusPaused); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "session paused"})
|
||||
}
|
||||
|
||||
// CompleteSession completes a workshop session
|
||||
// POST /sdk/v1/workshops/:id/complete
|
||||
func (h *WorkshopHandlers) CompleteSession(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.UpdateSessionStatus(c.Request.Context(), id, workshop.SessionStatusCompleted); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get final summary
|
||||
summary, _ := h.store.GetSessionSummary(c.Request.Context(), id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "session completed",
|
||||
"summary": summary,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Participant Management
|
||||
// ============================================================================
|
||||
|
||||
// JoinSession allows a participant to join a session
|
||||
// POST /sdk/v1/workshops/join/:code
|
||||
func (h *WorkshopHandlers) JoinSession(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
|
||||
session, err := h.store.GetSessionByJoinCode(c.Request.Context(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.Status == workshop.SessionStatusCompleted || session.Status == workshop.SessionStatusCancelled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session is no longer active"})
|
||||
return
|
||||
}
|
||||
|
||||
var req workshop.JoinSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID if authenticated
|
||||
var userID *uuid.UUID
|
||||
if id := rbac.GetUserID(c); id != uuid.Nil {
|
||||
userID = &id
|
||||
}
|
||||
|
||||
// Check if authentication is required
|
||||
if session.RequireAuth && userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required to join this session"})
|
||||
return
|
||||
}
|
||||
|
||||
participant := &workshop.Participant{
|
||||
SessionID: session.ID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Role: req.Role,
|
||||
Department: req.Department,
|
||||
CanEdit: true,
|
||||
CanComment: true,
|
||||
}
|
||||
|
||||
if participant.Role == "" {
|
||||
participant.Role = workshop.ParticipantRoleStakeholder
|
||||
}
|
||||
|
||||
if err := h.store.AddParticipant(c.Request.Context(), participant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, workshop.JoinSessionResponse{
|
||||
Participant: *participant,
|
||||
Session: *session,
|
||||
})
|
||||
}
|
||||
|
||||
// ListParticipants lists participants in a session
|
||||
// GET /sdk/v1/workshops/:id/participants
|
||||
func (h *WorkshopHandlers) ListParticipants(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
participants, err := h.store.ListParticipants(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"participants": participants,
|
||||
"total": len(participants),
|
||||
})
|
||||
}
|
||||
|
||||
// LeaveSession removes a participant from a session
|
||||
// POST /sdk/v1/workshops/:id/leave
|
||||
func (h *WorkshopHandlers) LeaveSession(c *gin.Context) {
|
||||
var req struct {
|
||||
ParticipantID uuid.UUID `json:"participant_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.LeaveSession(c.Request.Context(), req.ParticipantID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "left session"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wizard Navigation & Responses
|
||||
// ============================================================================
|
||||
|
||||
// SubmitResponse submits a response to a question
|
||||
// POST /sdk/v1/workshops/:id/responses
|
||||
func (h *WorkshopHandlers) SubmitResponse(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req workshop.SubmitResponseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get participant ID from request or context
|
||||
participantID, err := uuid.Parse(c.GetHeader("X-Participant-ID"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "participant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine value type
|
||||
valueType := "string"
|
||||
switch req.Value.(type) {
|
||||
case bool:
|
||||
valueType = "boolean"
|
||||
case float64:
|
||||
valueType = "number"
|
||||
case []interface{}:
|
||||
valueType = "array"
|
||||
case map[string]interface{}:
|
||||
valueType = "object"
|
||||
}
|
||||
|
||||
response := &workshop.Response{
|
||||
SessionID: sessionID,
|
||||
ParticipantID: participantID,
|
||||
StepNumber: req.StepNumber,
|
||||
FieldID: req.FieldID,
|
||||
Value: req.Value,
|
||||
ValueType: valueType,
|
||||
Status: workshop.ResponseStatusSubmitted,
|
||||
}
|
||||
|
||||
if err := h.store.SaveResponse(c.Request.Context(), response); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update participant activity
|
||||
h.store.UpdateParticipantActivity(c.Request.Context(), participantID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"response": response})
|
||||
}
|
||||
|
||||
// GetResponses retrieves responses for a session
|
||||
// GET /sdk/v1/workshops/:id/responses
|
||||
func (h *WorkshopHandlers) GetResponses(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var stepNumber *int
|
||||
if step := c.Query("step"); step != "" {
|
||||
if s, err := strconv.Atoi(step); err == nil {
|
||||
stepNumber = &s
|
||||
}
|
||||
}
|
||||
|
||||
responses, err := h.store.GetResponses(c.Request.Context(), sessionID, stepNumber)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"responses": responses,
|
||||
"total": len(responses),
|
||||
})
|
||||
}
|
||||
|
||||
// AdvanceStep moves the session to the next step
|
||||
// POST /sdk/v1/workshops/:id/advance
|
||||
func (h *WorkshopHandlers) AdvanceStep(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetSession(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.CurrentStep >= session.TotalSteps {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "already at last step"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark current step as completed
|
||||
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep, "completed", 100)
|
||||
|
||||
// Advance to next step
|
||||
if err := h.store.AdvanceStep(c.Request.Context(), sessionID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize next step
|
||||
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep+1, "in_progress", 0)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"previous_step": session.CurrentStep,
|
||||
"current_step": session.CurrentStep + 1,
|
||||
"message": "advanced to next step",
|
||||
})
|
||||
}
|
||||
|
||||
// GoToStep navigates to a specific step (if allowed)
|
||||
// POST /sdk/v1/workshops/:id/goto
|
||||
func (h *WorkshopHandlers) GoToStep(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
StepNumber int `json:"step_number"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetSession(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if back navigation is allowed
|
||||
if req.StepNumber < session.CurrentStep && !session.Settings.AllowBackNavigation {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "back navigation not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate step number
|
||||
if req.StepNumber < 1 || req.StepNumber > session.TotalSteps {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid step number"})
|
||||
return
|
||||
}
|
||||
|
||||
session.CurrentStep = req.StepNumber
|
||||
if err := h.store.UpdateSession(c.Request.Context(), session); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"current_step": req.StepNumber,
|
||||
"message": "navigated to step",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
// GetSessionStats returns statistics for a session
|
||||
// GET /sdk/v1/workshops/:id/stats
|
||||
func (h *WorkshopHandlers) GetSessionStats(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.store.GetSessionStats(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetSessionSummary returns a complete summary of a session
|
||||
// GET /sdk/v1/workshops/:id/summary
|
||||
func (h *WorkshopHandlers) GetSessionSummary(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.store.GetSessionSummary(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if summary == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Participant Management (Extended)
|
||||
// ============================================================================
|
||||
|
||||
// UpdateParticipant updates a participant's info
|
||||
// PUT /sdk/v1/workshops/:id/participants/:participantId
|
||||
func (h *WorkshopHandlers) UpdateParticipant(c *gin.Context) {
|
||||
participantID, err := uuid.Parse(c.Param("participantId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Role workshop.ParticipantRole `json:"role"`
|
||||
Department string `json:"department"`
|
||||
CanEdit *bool `json:"can_edit,omitempty"`
|
||||
CanComment *bool `json:"can_comment,omitempty"`
|
||||
CanApprove *bool `json:"can_approve,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
participant, err := h.store.GetParticipant(c.Request.Context(), participantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if participant == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
participant.Name = req.Name
|
||||
}
|
||||
if req.Role != "" {
|
||||
participant.Role = req.Role
|
||||
}
|
||||
if req.Department != "" {
|
||||
participant.Department = req.Department
|
||||
}
|
||||
if req.CanEdit != nil {
|
||||
participant.CanEdit = *req.CanEdit
|
||||
}
|
||||
if req.CanComment != nil {
|
||||
participant.CanComment = *req.CanComment
|
||||
}
|
||||
if req.CanApprove != nil {
|
||||
participant.CanApprove = *req.CanApprove
|
||||
}
|
||||
|
||||
if err := h.store.UpdateParticipant(c.Request.Context(), participant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"participant": participant})
|
||||
}
|
||||
|
||||
// RemoveParticipant removes a participant from a session
|
||||
// DELETE /sdk/v1/workshops/:id/participants/:participantId
|
||||
func (h *WorkshopHandlers) RemoveParticipant(c *gin.Context) {
|
||||
participantID, err := uuid.Parse(c.Param("participantId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.LeaveSession(c.Request.Context(), participantID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "participant removed"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Comments
|
||||
// ============================================================================
|
||||
|
||||
// AddComment adds a comment to a session
|
||||
// POST /sdk/v1/workshops/:id/comments
|
||||
func (h *WorkshopHandlers) AddComment(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ParticipantID uuid.UUID `json:"participant_id"`
|
||||
StepNumber *int `json:"step_number,omitempty"`
|
||||
FieldID *string `json:"field_id,omitempty"`
|
||||
ResponseID *uuid.UUID `json:"response_id,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Text == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "comment text is required"})
|
||||
return
|
||||
}
|
||||
|
||||
comment := &workshop.Comment{
|
||||
SessionID: sessionID,
|
||||
ParticipantID: req.ParticipantID,
|
||||
StepNumber: req.StepNumber,
|
||||
FieldID: req.FieldID,
|
||||
ResponseID: req.ResponseID,
|
||||
Text: req.Text,
|
||||
}
|
||||
|
||||
if err := h.store.AddComment(c.Request.Context(), comment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"comment": comment})
|
||||
}
|
||||
|
||||
// GetComments retrieves comments for a session
|
||||
// GET /sdk/v1/workshops/:id/comments
|
||||
func (h *WorkshopHandlers) GetComments(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var stepNumber *int
|
||||
if step := c.Query("step"); step != "" {
|
||||
if s, err := strconv.Atoi(step); err == nil {
|
||||
stepNumber = &s
|
||||
}
|
||||
}
|
||||
|
||||
comments, err := h.store.GetComments(c.Request.Context(), sessionID, stepNumber)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": comments,
|
||||
"total": len(comments),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export
|
||||
// ============================================================================
|
||||
|
||||
// ExportSession exports session data
|
||||
// GET /sdk/v1/workshops/:id/export
|
||||
func (h *WorkshopHandlers) ExportSession(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
format := c.DefaultQuery("format", "json")
|
||||
|
||||
// Get complete session data
|
||||
summary, err := h.store.GetSessionSummary(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if summary == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get all responses
|
||||
responses, _ := h.store.GetResponses(c.Request.Context(), sessionID, nil)
|
||||
|
||||
// Get all comments
|
||||
comments, _ := h.store.GetComments(c.Request.Context(), sessionID, nil)
|
||||
|
||||
// Get stats
|
||||
stats, _ := h.store.GetSessionStats(c.Request.Context(), sessionID)
|
||||
|
||||
exportData := gin.H{
|
||||
"session": summary.Session,
|
||||
"participants": summary.Participants,
|
||||
"step_progress": summary.StepProgress,
|
||||
"responses": responses,
|
||||
"comments": comments,
|
||||
"stats": stats,
|
||||
"exported_at": time.Now().UTC(),
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, exportData)
|
||||
case "md":
|
||||
// Generate markdown format
|
||||
md := generateSessionMarkdown(summary, responses, comments, stats)
|
||||
c.Header("Content-Type", "text/markdown")
|
||||
c.Header("Content-Disposition", "attachment; filename=workshop-session.md")
|
||||
c.String(http.StatusOK, md)
|
||||
default:
|
||||
c.JSON(http.StatusOK, exportData)
|
||||
}
|
||||
}
|
||||
|
||||
// generateSessionMarkdown generates a markdown export of the session
|
||||
func generateSessionMarkdown(summary *workshop.SessionSummary, responses []workshop.Response, comments []workshop.Comment, stats *workshop.SessionStats) string {
|
||||
md := "# Workshop Session: " + summary.Session.Title + "\n\n"
|
||||
md += "**Type:** " + summary.Session.SessionType + "\n"
|
||||
md += "**Status:** " + string(summary.Session.Status) + "\n"
|
||||
md += "**Created:** " + summary.Session.CreatedAt.Format("2006-01-02 15:04") + "\n\n"
|
||||
|
||||
if summary.Session.Description != "" {
|
||||
md += "## Description\n\n" + summary.Session.Description + "\n\n"
|
||||
}
|
||||
|
||||
// Participants
|
||||
md += "## Participants\n\n"
|
||||
for _, p := range summary.Participants {
|
||||
md += "- **" + p.Name + "** (" + string(p.Role) + ")"
|
||||
if p.Department != "" {
|
||||
md += " - " + p.Department
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
md += "\n"
|
||||
|
||||
// Progress
|
||||
md += "## Progress\n\n"
|
||||
md += "**Overall:** " + strconv.Itoa(summary.OverallProgress) + "%\n"
|
||||
md += "**Completed Steps:** " + strconv.Itoa(summary.CompletedSteps) + "/" + strconv.Itoa(summary.Session.TotalSteps) + "\n"
|
||||
md += "**Total Responses:** " + strconv.Itoa(summary.TotalResponses) + "\n\n"
|
||||
|
||||
// Step progress
|
||||
if len(summary.StepProgress) > 0 {
|
||||
md += "### Step Progress\n\n"
|
||||
for _, sp := range summary.StepProgress {
|
||||
md += "- Step " + strconv.Itoa(sp.StepNumber) + ": " + sp.Status + " (" + strconv.Itoa(sp.Progress) + "%)\n"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
|
||||
// Responses by step
|
||||
if len(responses) > 0 {
|
||||
md += "## Responses\n\n"
|
||||
currentStep := 0
|
||||
for _, r := range responses {
|
||||
if r.StepNumber != currentStep {
|
||||
currentStep = r.StepNumber
|
||||
md += "### Step " + strconv.Itoa(currentStep) + "\n\n"
|
||||
}
|
||||
md += "- **" + r.FieldID + ":** "
|
||||
switch v := r.Value.(type) {
|
||||
case string:
|
||||
md += v
|
||||
case bool:
|
||||
if v {
|
||||
md += "Yes"
|
||||
} else {
|
||||
md += "No"
|
||||
}
|
||||
default:
|
||||
md += "See JSON export for complex value"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
|
||||
// Comments
|
||||
if len(comments) > 0 {
|
||||
md += "## Comments\n\n"
|
||||
for _, c := range comments {
|
||||
md += "- " + c.Text
|
||||
if c.StepNumber != nil {
|
||||
md += " (Step " + strconv.Itoa(*c.StepNumber) + ")"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
|
||||
md += "---\n*Exported from AI Compliance SDK Workshop Module*\n"
|
||||
|
||||
return md
|
||||
}
|
||||
Reference in New Issue
Block a user