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:
Benjamin Boenisch
2026-02-11 23:47:28 +01:00
commit 4435e7ea0a
734 changed files with 251369 additions and 0 deletions

View 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
}

View 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())
}()
}

View 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
}

View 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)
}

View 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)
}

View 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())
}()
}

View 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)
}

View 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,
})
}

View 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"})
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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")
}
}

View 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
}