A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
422 lines
13 KiB
Go
422 lines
13 KiB
Go
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)
|
|
}
|