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>
503 lines
14 KiB
Go
503 lines
14 KiB
Go
package ucca
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// EscalationStore handles database operations for escalations.
|
|
type EscalationStore struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewEscalationStore creates a new escalation store.
|
|
func NewEscalationStore(pool *pgxpool.Pool) *EscalationStore {
|
|
return &EscalationStore{pool: pool}
|
|
}
|
|
|
|
// CreateEscalation creates a new escalation for an assessment.
|
|
func (s *EscalationStore) CreateEscalation(ctx context.Context, e *Escalation) error {
|
|
conditionsJSON, err := json.Marshal(e.Conditions)
|
|
if err != nil {
|
|
conditionsJSON = []byte("[]")
|
|
}
|
|
|
|
query := `
|
|
INSERT INTO ucca_escalations (
|
|
id, tenant_id, assessment_id, escalation_level, escalation_reason,
|
|
status, conditions, due_date, created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()
|
|
)
|
|
`
|
|
|
|
e.ID = uuid.New()
|
|
e.CreatedAt = time.Now().UTC()
|
|
e.UpdatedAt = e.CreatedAt
|
|
|
|
_, err = s.pool.Exec(ctx, query,
|
|
e.ID, e.TenantID, e.AssessmentID, e.EscalationLevel, e.EscalationReason,
|
|
e.Status, conditionsJSON, e.DueDate,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// GetEscalation retrieves an escalation by ID.
|
|
func (s *EscalationStore) GetEscalation(ctx context.Context, id uuid.UUID) (*Escalation, error) {
|
|
query := `
|
|
SELECT id, tenant_id, assessment_id, escalation_level, escalation_reason,
|
|
assigned_to, assigned_role, assigned_at, status, reviewer_id,
|
|
reviewer_notes, reviewed_at, decision, decision_notes, decision_at,
|
|
conditions, created_at, updated_at, due_date,
|
|
notification_sent, notification_sent_at
|
|
FROM ucca_escalations
|
|
WHERE id = $1
|
|
`
|
|
|
|
var e Escalation
|
|
var conditionsJSON []byte
|
|
|
|
err := s.pool.QueryRow(ctx, query, id).Scan(
|
|
&e.ID, &e.TenantID, &e.AssessmentID, &e.EscalationLevel, &e.EscalationReason,
|
|
&e.AssignedTo, &e.AssignedRole, &e.AssignedAt, &e.Status, &e.ReviewerID,
|
|
&e.ReviewerNotes, &e.ReviewedAt, &e.Decision, &e.DecisionNotes, &e.DecisionAt,
|
|
&conditionsJSON, &e.CreatedAt, &e.UpdatedAt, &e.DueDate,
|
|
&e.NotificationSent, &e.NotificationSentAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(conditionsJSON) > 0 {
|
|
json.Unmarshal(conditionsJSON, &e.Conditions)
|
|
}
|
|
|
|
return &e, nil
|
|
}
|
|
|
|
// GetEscalationByAssessment retrieves an escalation for an assessment.
|
|
func (s *EscalationStore) GetEscalationByAssessment(ctx context.Context, assessmentID uuid.UUID) (*Escalation, error) {
|
|
query := `
|
|
SELECT id, tenant_id, assessment_id, escalation_level, escalation_reason,
|
|
assigned_to, assigned_role, assigned_at, status, reviewer_id,
|
|
reviewer_notes, reviewed_at, decision, decision_notes, decision_at,
|
|
conditions, created_at, updated_at, due_date,
|
|
notification_sent, notification_sent_at
|
|
FROM ucca_escalations
|
|
WHERE assessment_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`
|
|
|
|
var e Escalation
|
|
var conditionsJSON []byte
|
|
|
|
err := s.pool.QueryRow(ctx, query, assessmentID).Scan(
|
|
&e.ID, &e.TenantID, &e.AssessmentID, &e.EscalationLevel, &e.EscalationReason,
|
|
&e.AssignedTo, &e.AssignedRole, &e.AssignedAt, &e.Status, &e.ReviewerID,
|
|
&e.ReviewerNotes, &e.ReviewedAt, &e.Decision, &e.DecisionNotes, &e.DecisionAt,
|
|
&conditionsJSON, &e.CreatedAt, &e.UpdatedAt, &e.DueDate,
|
|
&e.NotificationSent, &e.NotificationSentAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(conditionsJSON) > 0 {
|
|
json.Unmarshal(conditionsJSON, &e.Conditions)
|
|
}
|
|
|
|
return &e, nil
|
|
}
|
|
|
|
// ListEscalations lists escalations for a tenant with optional filters.
|
|
func (s *EscalationStore) ListEscalations(ctx context.Context, tenantID uuid.UUID, status string, level string, assignedTo *uuid.UUID) ([]EscalationWithAssessment, error) {
|
|
query := `
|
|
SELECT e.id, e.tenant_id, e.assessment_id, e.escalation_level, e.escalation_reason,
|
|
e.assigned_to, e.assigned_role, e.assigned_at, e.status, e.reviewer_id,
|
|
e.reviewer_notes, e.reviewed_at, e.decision, e.decision_notes, e.decision_at,
|
|
e.conditions, e.created_at, e.updated_at, e.due_date,
|
|
e.notification_sent, e.notification_sent_at,
|
|
a.title, a.feasibility, a.risk_score, a.domain
|
|
FROM ucca_escalations e
|
|
JOIN ucca_assessments a ON e.assessment_id = a.id
|
|
WHERE e.tenant_id = $1
|
|
`
|
|
args := []interface{}{tenantID}
|
|
argCount := 1
|
|
|
|
if status != "" {
|
|
argCount++
|
|
query += fmt.Sprintf(" AND e.status = $%d", argCount)
|
|
args = append(args, status)
|
|
}
|
|
|
|
if level != "" {
|
|
argCount++
|
|
query += fmt.Sprintf(" AND e.escalation_level = $%d", argCount)
|
|
args = append(args, level)
|
|
}
|
|
|
|
if assignedTo != nil {
|
|
argCount++
|
|
query += fmt.Sprintf(" AND e.assigned_to = $%d", argCount)
|
|
args = append(args, *assignedTo)
|
|
}
|
|
|
|
query += " ORDER BY e.created_at DESC"
|
|
|
|
rows, err := s.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var escalations []EscalationWithAssessment
|
|
for rows.Next() {
|
|
var e EscalationWithAssessment
|
|
var conditionsJSON []byte
|
|
|
|
err := rows.Scan(
|
|
&e.ID, &e.TenantID, &e.AssessmentID, &e.EscalationLevel, &e.EscalationReason,
|
|
&e.AssignedTo, &e.AssignedRole, &e.AssignedAt, &e.Status, &e.ReviewerID,
|
|
&e.ReviewerNotes, &e.ReviewedAt, &e.Decision, &e.DecisionNotes, &e.DecisionAt,
|
|
&conditionsJSON, &e.CreatedAt, &e.UpdatedAt, &e.DueDate,
|
|
&e.NotificationSent, &e.NotificationSentAt,
|
|
&e.AssessmentTitle, &e.AssessmentFeasibility, &e.AssessmentRiskScore, &e.AssessmentDomain,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(conditionsJSON) > 0 {
|
|
json.Unmarshal(conditionsJSON, &e.Conditions)
|
|
}
|
|
|
|
escalations = append(escalations, e)
|
|
}
|
|
|
|
return escalations, nil
|
|
}
|
|
|
|
// AssignEscalation assigns an escalation to a reviewer.
|
|
func (s *EscalationStore) AssignEscalation(ctx context.Context, id uuid.UUID, assignedTo uuid.UUID, role string) error {
|
|
query := `
|
|
UPDATE ucca_escalations
|
|
SET assigned_to = $2, assigned_role = $3, assigned_at = NOW(),
|
|
status = 'assigned', updated_at = NOW()
|
|
WHERE id = $1
|
|
`
|
|
|
|
_, err := s.pool.Exec(ctx, query, id, assignedTo, role)
|
|
return err
|
|
}
|
|
|
|
// StartReview marks an escalation as being reviewed.
|
|
func (s *EscalationStore) StartReview(ctx context.Context, id uuid.UUID, reviewerID uuid.UUID) error {
|
|
query := `
|
|
UPDATE ucca_escalations
|
|
SET reviewer_id = $2, status = 'in_review', updated_at = NOW()
|
|
WHERE id = $1
|
|
`
|
|
|
|
_, err := s.pool.Exec(ctx, query, id, reviewerID)
|
|
return err
|
|
}
|
|
|
|
// DecideEscalation records a decision on an escalation.
|
|
func (s *EscalationStore) DecideEscalation(ctx context.Context, id uuid.UUID, decision EscalationDecision, notes string, conditions []string) error {
|
|
var newStatus EscalationStatus
|
|
switch decision {
|
|
case EscalationDecisionApprove:
|
|
newStatus = EscalationStatusApproved
|
|
case EscalationDecisionReject:
|
|
newStatus = EscalationStatusRejected
|
|
case EscalationDecisionModify:
|
|
newStatus = EscalationStatusReturned
|
|
case EscalationDecisionEscalate:
|
|
// Keep in review for re-assignment
|
|
newStatus = EscalationStatusPending
|
|
default:
|
|
newStatus = EscalationStatusPending
|
|
}
|
|
|
|
conditionsJSON, _ := json.Marshal(conditions)
|
|
|
|
query := `
|
|
UPDATE ucca_escalations
|
|
SET decision = $2, decision_notes = $3, decision_at = NOW(),
|
|
status = $4, conditions = $5, updated_at = NOW()
|
|
WHERE id = $1
|
|
`
|
|
|
|
_, err := s.pool.Exec(ctx, query, id, decision, notes, newStatus, conditionsJSON)
|
|
return err
|
|
}
|
|
|
|
// AddEscalationHistory adds an audit entry for an escalation.
|
|
func (s *EscalationStore) AddEscalationHistory(ctx context.Context, h *EscalationHistory) error {
|
|
query := `
|
|
INSERT INTO ucca_escalation_history (
|
|
id, escalation_id, action, old_status, new_status,
|
|
old_level, new_level, actor_id, actor_role, notes, created_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()
|
|
)
|
|
`
|
|
|
|
h.ID = uuid.New()
|
|
h.CreatedAt = time.Now().UTC()
|
|
|
|
_, err := s.pool.Exec(ctx, query,
|
|
h.ID, h.EscalationID, h.Action, h.OldStatus, h.NewStatus,
|
|
h.OldLevel, h.NewLevel, h.ActorID, h.ActorRole, h.Notes,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// GetEscalationHistory retrieves the audit history for an escalation.
|
|
func (s *EscalationStore) GetEscalationHistory(ctx context.Context, escalationID uuid.UUID) ([]EscalationHistory, error) {
|
|
query := `
|
|
SELECT id, escalation_id, action, old_status, new_status,
|
|
old_level, new_level, actor_id, actor_role, notes, created_at
|
|
FROM ucca_escalation_history
|
|
WHERE escalation_id = $1
|
|
ORDER BY created_at ASC
|
|
`
|
|
|
|
rows, err := s.pool.Query(ctx, query, escalationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var history []EscalationHistory
|
|
for rows.Next() {
|
|
var h EscalationHistory
|
|
err := rows.Scan(
|
|
&h.ID, &h.EscalationID, &h.Action, &h.OldStatus, &h.NewStatus,
|
|
&h.OldLevel, &h.NewLevel, &h.ActorID, &h.ActorRole, &h.Notes, &h.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
history = append(history, h)
|
|
}
|
|
|
|
return history, nil
|
|
}
|
|
|
|
// GetEscalationStats retrieves escalation statistics for a tenant.
|
|
func (s *EscalationStore) GetEscalationStats(ctx context.Context, tenantID uuid.UUID) (*EscalationStats, error) {
|
|
stats := &EscalationStats{
|
|
ByLevel: make(map[EscalationLevel]int),
|
|
}
|
|
|
|
// Count by status
|
|
statusQuery := `
|
|
SELECT status, COUNT(*) as count
|
|
FROM ucca_escalations
|
|
WHERE tenant_id = $1
|
|
GROUP BY status
|
|
`
|
|
rows, err := s.pool.Query(ctx, statusQuery, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for rows.Next() {
|
|
var status string
|
|
var count int
|
|
if err := rows.Scan(&status, &count); err != nil {
|
|
continue
|
|
}
|
|
switch EscalationStatus(status) {
|
|
case EscalationStatusPending:
|
|
stats.TotalPending = count
|
|
case EscalationStatusInReview, EscalationStatusAssigned:
|
|
stats.TotalInReview += count
|
|
case EscalationStatusApproved:
|
|
stats.TotalApproved = count
|
|
case EscalationStatusRejected:
|
|
stats.TotalRejected = count
|
|
}
|
|
}
|
|
rows.Close()
|
|
|
|
// Count by level
|
|
levelQuery := `
|
|
SELECT escalation_level, COUNT(*) as count
|
|
FROM ucca_escalations
|
|
WHERE tenant_id = $1 AND status NOT IN ('approved', 'rejected')
|
|
GROUP BY escalation_level
|
|
`
|
|
rows, err = s.pool.Query(ctx, levelQuery, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for rows.Next() {
|
|
var level string
|
|
var count int
|
|
if err := rows.Scan(&level, &count); err != nil {
|
|
continue
|
|
}
|
|
stats.ByLevel[EscalationLevel(level)] = count
|
|
}
|
|
rows.Close()
|
|
|
|
// Count overdue SLA
|
|
overdueQuery := `
|
|
SELECT COUNT(*)
|
|
FROM ucca_escalations
|
|
WHERE tenant_id = $1
|
|
AND status NOT IN ('approved', 'rejected')
|
|
AND due_date < NOW()
|
|
`
|
|
s.pool.QueryRow(ctx, overdueQuery, tenantID).Scan(&stats.OverdueSLA)
|
|
|
|
// Count approaching SLA (within 8 hours)
|
|
approachingQuery := `
|
|
SELECT COUNT(*)
|
|
FROM ucca_escalations
|
|
WHERE tenant_id = $1
|
|
AND status NOT IN ('approved', 'rejected')
|
|
AND due_date > NOW()
|
|
AND due_date < NOW() + INTERVAL '8 hours'
|
|
`
|
|
s.pool.QueryRow(ctx, approachingQuery, tenantID).Scan(&stats.ApproachingSLA)
|
|
|
|
// Average resolution time
|
|
avgQuery := `
|
|
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (decision_at - created_at)) / 3600), 0)
|
|
FROM ucca_escalations
|
|
WHERE tenant_id = $1 AND decision_at IS NOT NULL
|
|
`
|
|
s.pool.QueryRow(ctx, avgQuery, tenantID).Scan(&stats.AvgResolutionHours)
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// DSB Pool Operations
|
|
|
|
// AddDSBPoolMember adds a member to the DSB review pool.
|
|
func (s *EscalationStore) AddDSBPoolMember(ctx context.Context, m *DSBPoolMember) error {
|
|
query := `
|
|
INSERT INTO ucca_dsb_pool (
|
|
id, tenant_id, user_id, user_name, user_email, role,
|
|
is_active, max_concurrent_reviews, created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()
|
|
)
|
|
ON CONFLICT (tenant_id, user_id) DO UPDATE
|
|
SET user_name = $4, user_email = $5, role = $6,
|
|
is_active = $7, max_concurrent_reviews = $8, updated_at = NOW()
|
|
`
|
|
|
|
if m.ID == uuid.Nil {
|
|
m.ID = uuid.New()
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, query,
|
|
m.ID, m.TenantID, m.UserID, m.UserName, m.UserEmail, m.Role,
|
|
m.IsActive, m.MaxConcurrentReviews,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// GetDSBPoolMembers retrieves active DSB pool members for a tenant.
|
|
func (s *EscalationStore) GetDSBPoolMembers(ctx context.Context, tenantID uuid.UUID, role string) ([]DSBPoolMember, error) {
|
|
query := `
|
|
SELECT id, tenant_id, user_id, user_name, user_email, role,
|
|
is_active, max_concurrent_reviews, current_reviews, created_at, updated_at
|
|
FROM ucca_dsb_pool
|
|
WHERE tenant_id = $1 AND is_active = true
|
|
`
|
|
args := []interface{}{tenantID}
|
|
|
|
if role != "" {
|
|
query += " AND role = $2"
|
|
args = append(args, role)
|
|
}
|
|
|
|
query += " ORDER BY current_reviews ASC, user_name ASC"
|
|
|
|
rows, err := s.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var members []DSBPoolMember
|
|
for rows.Next() {
|
|
var m DSBPoolMember
|
|
err := rows.Scan(
|
|
&m.ID, &m.TenantID, &m.UserID, &m.UserName, &m.UserEmail, &m.Role,
|
|
&m.IsActive, &m.MaxConcurrentReviews, &m.CurrentReviews, &m.CreatedAt, &m.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
members = append(members, m)
|
|
}
|
|
|
|
return members, nil
|
|
}
|
|
|
|
// GetNextAvailableReviewer finds the next available reviewer for a role.
|
|
func (s *EscalationStore) GetNextAvailableReviewer(ctx context.Context, tenantID uuid.UUID, role string) (*DSBPoolMember, error) {
|
|
query := `
|
|
SELECT id, tenant_id, user_id, user_name, user_email, role,
|
|
is_active, max_concurrent_reviews, current_reviews, created_at, updated_at
|
|
FROM ucca_dsb_pool
|
|
WHERE tenant_id = $1 AND is_active = true AND role = $2
|
|
AND current_reviews < max_concurrent_reviews
|
|
ORDER BY current_reviews ASC
|
|
LIMIT 1
|
|
`
|
|
|
|
var m DSBPoolMember
|
|
err := s.pool.QueryRow(ctx, query, tenantID, role).Scan(
|
|
&m.ID, &m.TenantID, &m.UserID, &m.UserName, &m.UserEmail, &m.Role,
|
|
&m.IsActive, &m.MaxConcurrentReviews, &m.CurrentReviews, &m.CreatedAt, &m.UpdatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &m, nil
|
|
}
|
|
|
|
// IncrementReviewerCount increments the current review count for a DSB member.
|
|
func (s *EscalationStore) IncrementReviewerCount(ctx context.Context, userID uuid.UUID) error {
|
|
query := `
|
|
UPDATE ucca_dsb_pool
|
|
SET current_reviews = current_reviews + 1, updated_at = NOW()
|
|
WHERE user_id = $1
|
|
`
|
|
_, err := s.pool.Exec(ctx, query, userID)
|
|
return err
|
|
}
|
|
|
|
// DecrementReviewerCount decrements the current review count for a DSB member.
|
|
func (s *EscalationStore) DecrementReviewerCount(ctx context.Context, userID uuid.UUID) error {
|
|
query := `
|
|
UPDATE ucca_dsb_pool
|
|
SET current_reviews = GREATEST(0, current_reviews - 1), updated_at = NOW()
|
|
WHERE user_id = $1
|
|
`
|
|
_, err := s.pool.Exec(ctx, query, userID)
|
|
return err
|
|
}
|