Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/escalation_handlers.go
Benjamin Admin a5e4801b09
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 19s
CI / test-python-dsms-gateway (push) Successful in 16s
fix(escalations): Tenant/User-ID Defaults + Routing-Klarheit
- escalations/route.ts: X-Tenant-Id + X-User-Id Default-Header ergaenzt,
  X-User-Id aus Request weitergeleitet
- escalation_routes.py: DEFAULT_TENANT_ID Konstante (9282a473-...) statt 'default'
- test_escalation_routes.py: vollstaendige Test-Suite ergaenzt (+337 Zeilen)
- main.go + escalation_handlers.go: DEPRECATED-Kommentare — UCCA-Escalations
  bleiben fuer Assessment-Review, Haupt-Escalation-System ist Python-Backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 21:15:02 +01:00

426 lines
13 KiB
Go

// DEPRECATED: This file implements UCCA-specific escalation handlers (E0-E3 workflow).
// The primary escalation system is now Python backend-compliance escalation_routes.py
// (/api/compliance/escalations). These UCCA handlers remain only for assessment-review
// workflows and will be removed in a future release.
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)
}