Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/training_handlers.go
Benjamin Boenisch 375914e568
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 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 21s
feat(training): add Media Pipeline — TTS Audio, Presentation Video, Bulk Generation
Phase A: 8 new IT-Security training modules (SEC-PWD, SEC-DESK, SEC-KIAI,
SEC-BYOD, SEC-VIDEO, SEC-USB, SEC-INC, SEC-HOME) with CTM entries.
Bulk content and quiz generation endpoints for all 28 modules.

Phase B: Piper TTS service (Python/FastAPI) for local German speech synthesis.
training_media table, TTSClient in Go backend, audio generation endpoints,
AudioPlayer component in frontend. MinIO storage integration.

Phase C: FFmpeg presentation video pipeline — LLM generates slide scripts,
ImageMagick renders 1920x1080 slides, FFmpeg combines with audio to MP4.
VideoPlayer and ScriptPreview components in frontend.

New files: 15 created, 9 modified
- compliance-tts-service/ (Dockerfile, main.py, tts_engine.py, storage.py,
  slide_renderer.py, video_generator.py)
- migrations 014-016 (training engine, IT-security modules, media table)
- training package (models, store, content_generator, media, handlers)
- frontend (AudioPlayer, VideoPlayer, ScriptPreview, api, types, page)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:45:05 +01:00

1114 lines
30 KiB
Go

package handlers
import (
"net/http"
"strconv"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/training"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// TrainingHandlers handles training-related API requests
type TrainingHandlers struct {
store *training.Store
contentGenerator *training.ContentGenerator
}
// NewTrainingHandlers creates new training handlers
func NewTrainingHandlers(store *training.Store, contentGenerator *training.ContentGenerator) *TrainingHandlers {
return &TrainingHandlers{
store: store,
contentGenerator: contentGenerator,
}
}
// ============================================================================
// Module Endpoints
// ============================================================================
// ListModules returns all training modules for the tenant
// GET /sdk/v1/training/modules
func (h *TrainingHandlers) ListModules(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &training.ModuleFilters{
Limit: 50,
Offset: 0,
}
if v := c.Query("regulation_area"); v != "" {
filters.RegulationArea = training.RegulationArea(v)
}
if v := c.Query("frequency_type"); v != "" {
filters.FrequencyType = training.FrequencyType(v)
}
if v := c.Query("search"); v != "" {
filters.Search = v
}
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filters.Limit = n
}
}
if v := c.Query("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filters.Offset = n
}
}
modules, total, err := h.store.ListModules(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, training.ModuleListResponse{
Modules: modules,
Total: total,
})
}
// GetModule returns a single training module with content and quiz
// GET /sdk/v1/training/modules/:id
func (h *TrainingHandlers) GetModule(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
module, err := h.store.GetModule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if module == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
return
}
// Include content and quiz questions
content, _ := h.store.GetPublishedContent(c.Request.Context(), id)
questions, _ := h.store.ListQuizQuestions(c.Request.Context(), id)
c.JSON(http.StatusOK, gin.H{
"module": module,
"content": content,
"questions": questions,
})
}
// CreateModule creates a new training module
// POST /sdk/v1/training/modules
func (h *TrainingHandlers) CreateModule(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var req training.CreateModuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
module := &training.TrainingModule{
TenantID: tenantID,
ModuleCode: req.ModuleCode,
Title: req.Title,
Description: req.Description,
RegulationArea: req.RegulationArea,
NIS2Relevant: req.NIS2Relevant,
ISOControls: req.ISOControls,
FrequencyType: req.FrequencyType,
ValidityDays: req.ValidityDays,
RiskWeight: req.RiskWeight,
ContentType: req.ContentType,
DurationMinutes: req.DurationMinutes,
PassThreshold: req.PassThreshold,
}
if module.ValidityDays == 0 {
module.ValidityDays = 365
}
if module.RiskWeight == 0 {
module.RiskWeight = 2.0
}
if module.ContentType == "" {
module.ContentType = "text"
}
if module.PassThreshold == 0 {
module.PassThreshold = 70
}
if module.ISOControls == nil {
module.ISOControls = []string{}
}
if err := h.store.CreateModule(c.Request.Context(), module); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, module)
}
// UpdateModule updates a training module
// PUT /sdk/v1/training/modules/:id
func (h *TrainingHandlers) UpdateModule(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
module, err := h.store.GetModule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if module == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
return
}
var req training.UpdateModuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != nil {
module.Title = *req.Title
}
if req.Description != nil {
module.Description = *req.Description
}
if req.NIS2Relevant != nil {
module.NIS2Relevant = *req.NIS2Relevant
}
if req.ISOControls != nil {
module.ISOControls = req.ISOControls
}
if req.ValidityDays != nil {
module.ValidityDays = *req.ValidityDays
}
if req.RiskWeight != nil {
module.RiskWeight = *req.RiskWeight
}
if req.DurationMinutes != nil {
module.DurationMinutes = *req.DurationMinutes
}
if req.PassThreshold != nil {
module.PassThreshold = *req.PassThreshold
}
if req.IsActive != nil {
module.IsActive = *req.IsActive
}
if err := h.store.UpdateModule(c.Request.Context(), module); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, module)
}
// ============================================================================
// Matrix Endpoints
// ============================================================================
// GetMatrix returns the full CTM for the tenant
// GET /sdk/v1/training/matrix
func (h *TrainingHandlers) GetMatrix(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
entries, err := h.store.GetMatrixForTenant(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resp := training.BuildMatrixResponse(entries)
c.JSON(http.StatusOK, resp)
}
// GetMatrixForRole returns matrix entries for a specific role
// GET /sdk/v1/training/matrix/:role
func (h *TrainingHandlers) GetMatrixForRole(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
role := c.Param("role")
entries, err := h.store.GetMatrixForRole(c.Request.Context(), tenantID, role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"role": role,
"label": training.RoleLabels[role],
"entries": entries,
"total": len(entries),
})
}
// SetMatrixEntry creates or updates a CTM entry
// POST /sdk/v1/training/matrix
func (h *TrainingHandlers) SetMatrixEntry(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var req training.SetMatrixEntryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
entry := &training.TrainingMatrixEntry{
TenantID: tenantID,
RoleCode: req.RoleCode,
ModuleID: req.ModuleID,
IsMandatory: req.IsMandatory,
Priority: req.Priority,
}
if err := h.store.SetMatrixEntry(c.Request.Context(), entry); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, entry)
}
// DeleteMatrixEntry removes a CTM entry
// DELETE /sdk/v1/training/matrix/:role/:moduleId
func (h *TrainingHandlers) DeleteMatrixEntry(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
role := c.Param("role")
moduleID, err := uuid.Parse(c.Param("moduleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
if err := h.store.DeleteMatrixEntry(c.Request.Context(), tenantID, role, moduleID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// ============================================================================
// Assignment Endpoints
// ============================================================================
// ComputeAssignments computes assignments for a user based on roles
// POST /sdk/v1/training/assignments/compute
func (h *TrainingHandlers) ComputeAssignments(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var req training.ComputeAssignmentsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
trigger := req.Trigger
if trigger == "" {
trigger = "manual"
}
assignments, err := training.ComputeAssignments(
c.Request.Context(), h.store, tenantID,
req.UserID, req.UserName, req.UserEmail, req.Roles, trigger,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"assignments": assignments,
"created": len(assignments),
})
}
// ListAssignments returns assignments for the tenant
// GET /sdk/v1/training/assignments
func (h *TrainingHandlers) ListAssignments(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &training.AssignmentFilters{
Limit: 50,
Offset: 0,
}
if v := c.Query("user_id"); v != "" {
if uid, err := uuid.Parse(v); err == nil {
filters.UserID = &uid
}
}
if v := c.Query("module_id"); v != "" {
if mid, err := uuid.Parse(v); err == nil {
filters.ModuleID = &mid
}
}
if v := c.Query("role"); v != "" {
filters.RoleCode = v
}
if v := c.Query("status"); v != "" {
filters.Status = training.AssignmentStatus(v)
}
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filters.Limit = n
}
}
if v := c.Query("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filters.Offset = n
}
}
assignments, total, err := h.store.ListAssignments(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, training.AssignmentListResponse{
Assignments: assignments,
Total: total,
})
}
// GetAssignment returns a single assignment
// GET /sdk/v1/training/assignments/:id
func (h *TrainingHandlers) GetAssignment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if assignment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
return
}
c.JSON(http.StatusOK, assignment)
}
// StartAssignment marks an assignment as started
// POST /sdk/v1/training/assignments/:id/start
func (h *TrainingHandlers) StartAssignment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
tenantID := rbac.GetTenantID(c)
if err := h.store.UpdateAssignmentStatus(c.Request.Context(), id, training.AssignmentStatusInProgress, 0); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Audit log
userID := rbac.GetUserID(c)
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
TenantID: tenantID,
UserID: &userID,
Action: training.AuditActionStarted,
EntityType: training.AuditEntityAssignment,
EntityID: &id,
})
c.JSON(http.StatusOK, gin.H{"status": "in_progress"})
}
// UpdateAssignmentProgress updates progress on an assignment
// POST /sdk/v1/training/assignments/:id/progress
func (h *TrainingHandlers) UpdateAssignmentProgress(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
var req training.UpdateAssignmentProgressRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status := training.AssignmentStatusInProgress
if req.Progress >= 100 {
status = training.AssignmentStatusCompleted
}
if err := h.store.UpdateAssignmentStatus(c.Request.Context(), id, status, req.Progress); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": string(status), "progress": req.Progress})
}
// CompleteAssignment marks an assignment as completed
// POST /sdk/v1/training/assignments/:id/complete
func (h *TrainingHandlers) CompleteAssignment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
tenantID := rbac.GetTenantID(c)
if err := h.store.UpdateAssignmentStatus(c.Request.Context(), id, training.AssignmentStatusCompleted, 100); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
TenantID: tenantID,
UserID: &userID,
Action: training.AuditActionCompleted,
EntityType: training.AuditEntityAssignment,
EntityID: &id,
})
c.JSON(http.StatusOK, gin.H{"status": "completed"})
}
// ============================================================================
// Quiz Endpoints
// ============================================================================
// GetQuiz returns quiz questions for a module
// GET /sdk/v1/training/quiz/:moduleId
func (h *TrainingHandlers) GetQuiz(c *gin.Context) {
moduleID, err := uuid.Parse(c.Param("moduleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
questions, err := h.store.ListQuizQuestions(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Strip correct_index for the student-facing response
type safeQuestion struct {
ID uuid.UUID `json:"id"`
Question string `json:"question"`
Options []string `json:"options"`
Difficulty string `json:"difficulty"`
}
safe := make([]safeQuestion, len(questions))
for i, q := range questions {
safe[i] = safeQuestion{
ID: q.ID,
Question: q.Question,
Options: q.Options,
Difficulty: string(q.Difficulty),
}
}
c.JSON(http.StatusOK, gin.H{
"questions": safe,
"total": len(safe),
})
}
// SubmitQuiz submits quiz answers and returns the score
// POST /sdk/v1/training/quiz/:moduleId/submit
func (h *TrainingHandlers) SubmitQuiz(c *gin.Context) {
moduleID, err := uuid.Parse(c.Param("moduleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
tenantID := rbac.GetTenantID(c)
var req training.SubmitTrainingQuizRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get the correct answers
questions, err := h.store.ListQuizQuestions(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Build answer map
questionMap := make(map[uuid.UUID]training.QuizQuestion)
for _, q := range questions {
questionMap[q.ID] = q
}
// Score the answers
correctCount := 0
totalCount := len(req.Answers)
scoredAnswers := make([]training.QuizAnswer, len(req.Answers))
for i, answer := range req.Answers {
q, exists := questionMap[answer.QuestionID]
correct := exists && answer.SelectedIndex == q.CorrectIndex
scoredAnswers[i] = training.QuizAnswer{
QuestionID: answer.QuestionID,
SelectedIndex: answer.SelectedIndex,
Correct: correct,
}
if correct {
correctCount++
}
}
score := float64(0)
if totalCount > 0 {
score = float64(correctCount) / float64(totalCount) * 100
}
// Get module for pass threshold
module, _ := h.store.GetModule(c.Request.Context(), moduleID)
threshold := 70
if module != nil {
threshold = module.PassThreshold
}
passed := score >= float64(threshold)
// Record the attempt
userID := rbac.GetUserID(c)
attempt := &training.QuizAttempt{
AssignmentID: req.AssignmentID,
UserID: userID,
Answers: scoredAnswers,
Score: score,
Passed: passed,
CorrectCount: correctCount,
TotalCount: totalCount,
DurationSeconds: req.DurationSeconds,
}
if err := h.store.CreateQuizAttempt(c.Request.Context(), attempt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update assignment quiz result
// Count total attempts
attempts, _ := h.store.ListQuizAttempts(c.Request.Context(), req.AssignmentID)
h.store.UpdateAssignmentQuizResult(c.Request.Context(), req.AssignmentID, score, passed, len(attempts))
// Audit log
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
TenantID: tenantID,
UserID: &userID,
Action: training.AuditActionQuizSubmitted,
EntityType: training.AuditEntityQuiz,
EntityID: &attempt.ID,
Details: map[string]interface{}{
"module_id": moduleID.String(),
"score": score,
"passed": passed,
"correct_count": correctCount,
"total_count": totalCount,
},
})
c.JSON(http.StatusOK, training.SubmitTrainingQuizResponse{
AttemptID: attempt.ID,
Score: score,
Passed: passed,
CorrectCount: correctCount,
TotalCount: totalCount,
Threshold: threshold,
})
}
// GetQuizAttempts returns quiz attempts for an assignment
// GET /sdk/v1/training/quiz/attempts/:assignmentId
func (h *TrainingHandlers) GetQuizAttempts(c *gin.Context) {
assignmentID, err := uuid.Parse(c.Param("assignmentId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
attempts, err := h.store.ListQuizAttempts(c.Request.Context(), assignmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"attempts": attempts,
"total": len(attempts),
})
}
// ============================================================================
// Content Endpoints
// ============================================================================
// GenerateContent generates module content via LLM
// POST /sdk/v1/training/content/generate
func (h *TrainingHandlers) GenerateContent(c *gin.Context) {
var req training.GenerateContentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
module, err := h.store.GetModule(c.Request.Context(), req.ModuleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if module == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
return
}
content, err := h.contentGenerator.GenerateModuleContent(c.Request.Context(), *module, req.Language)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, content)
}
// GenerateQuiz generates quiz questions via LLM
// POST /sdk/v1/training/content/generate-quiz
func (h *TrainingHandlers) GenerateQuiz(c *gin.Context) {
var req training.GenerateQuizRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
module, err := h.store.GetModule(c.Request.Context(), req.ModuleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if module == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
return
}
count := req.Count
if count <= 0 {
count = 5
}
questions, err := h.contentGenerator.GenerateQuizQuestions(c.Request.Context(), *module, count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"questions": questions,
"total": len(questions),
})
}
// GetContent returns published content for a module
// GET /sdk/v1/training/content/:moduleId
func (h *TrainingHandlers) GetContent(c *gin.Context) {
moduleID, err := uuid.Parse(c.Param("moduleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
content, err := h.store.GetPublishedContent(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if content == nil {
// Try latest unpublished
content, err = h.store.GetLatestContent(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if content == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no content found for this module"})
return
}
c.JSON(http.StatusOK, content)
}
// PublishContent publishes a content version
// POST /sdk/v1/training/content/:id/publish
func (h *TrainingHandlers) PublishContent(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content ID"})
return
}
reviewedBy := rbac.GetUserID(c)
if err := h.store.PublishContent(c.Request.Context(), id, reviewedBy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "published"})
}
// ============================================================================
// Deadline / Escalation Endpoints
// ============================================================================
// GetDeadlines returns upcoming deadlines
// GET /sdk/v1/training/deadlines
func (h *TrainingHandlers) GetDeadlines(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
limit := 20
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
deadlines, err := h.store.GetDeadlines(c.Request.Context(), tenantID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, training.DeadlineListResponse{
Deadlines: deadlines,
Total: len(deadlines),
})
}
// GetOverdueDeadlines returns overdue assignments
// GET /sdk/v1/training/deadlines/overdue
func (h *TrainingHandlers) GetOverdueDeadlines(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
deadlines, err := training.GetOverdueDeadlines(c.Request.Context(), h.store, tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, training.DeadlineListResponse{
Deadlines: deadlines,
Total: len(deadlines),
})
}
// CheckEscalation runs the escalation check
// POST /sdk/v1/training/escalation/check
func (h *TrainingHandlers) CheckEscalation(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
results, err := training.CheckEscalations(c.Request.Context(), h.store, tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
overdueAll, _ := h.store.ListOverdueAssignments(c.Request.Context(), tenantID)
c.JSON(http.StatusOK, training.EscalationResponse{
Results: results,
TotalChecked: len(overdueAll),
Escalated: len(results),
})
}
// ============================================================================
// Audit / Stats Endpoints
// ============================================================================
// GetAuditLog returns the training audit trail
// GET /sdk/v1/training/audit-log
func (h *TrainingHandlers) GetAuditLog(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &training.AuditLogFilters{
Limit: 50,
Offset: 0,
}
if v := c.Query("action"); v != "" {
filters.Action = training.AuditAction(v)
}
if v := c.Query("entity_type"); v != "" {
filters.EntityType = training.AuditEntityType(v)
}
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filters.Limit = n
}
}
if v := c.Query("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filters.Offset = n
}
}
entries, total, err := h.store.ListAuditLog(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, training.AuditLogResponse{
Entries: entries,
Total: total,
})
}
// GetStats returns training dashboard statistics
// GET /sdk/v1/training/stats
func (h *TrainingHandlers) GetStats(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetTrainingStats(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// VerifyCertificate verifies a certificate
// GET /sdk/v1/training/certificates/:id/verify
func (h *TrainingHandlers) VerifyCertificate(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
return
}
valid, assignment, err := training.VerifyCertificate(c.Request.Context(), h.store, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": valid,
"assignment": assignment,
})
}
// GenerateAllContent generates content for all modules that don't have content yet
// POST /sdk/v1/training/content/generate-all
func (h *TrainingHandlers) GenerateAllContent(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
language := "de"
if v := c.Query("language"); v != "" {
language = v
}
result, err := h.contentGenerator.GenerateAllModuleContent(c.Request.Context(), tenantID, language)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
// GenerateAllQuizzes generates quiz questions for all modules that don't have questions yet
// POST /sdk/v1/training/content/generate-all-quiz
func (h *TrainingHandlers) GenerateAllQuizzes(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
count := 5
result, err := h.contentGenerator.GenerateAllQuizQuestions(c.Request.Context(), tenantID, count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
// GenerateAudio generates audio for a module via TTS service
// POST /sdk/v1/training/content/:moduleId/generate-audio
func (h *TrainingHandlers) GenerateAudio(c *gin.Context) {
moduleID, err := uuid.Parse(c.Param("moduleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
module, err := h.store.GetModule(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if module == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
return
}
media, err := h.contentGenerator.GenerateAudio(c.Request.Context(), *module)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, media)
}
// GetModuleMedia returns all media files for a module
// GET /sdk/v1/training/media/:moduleId
func (h *TrainingHandlers) GetModuleMedia(c *gin.Context) {
moduleID, err := uuid.Parse(c.Param("moduleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
mediaList, err := h.store.GetMediaForModule(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"media": mediaList,
"total": len(mediaList),
})
}
// GetMediaURL returns a presigned URL for a media file
// GET /sdk/v1/training/media/:id/url
func (h *TrainingHandlers) GetMediaURL(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media ID"})
return
}
media, err := h.store.GetMedia(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if media == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
// Return the object info for the frontend to construct the URL
c.JSON(http.StatusOK, gin.H{
"bucket": media.Bucket,
"object_key": media.ObjectKey,
"mime_type": media.MimeType,
})
}
// PublishMedia publishes or unpublishes a media file
// POST /sdk/v1/training/media/:id/publish
func (h *TrainingHandlers) PublishMedia(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media ID"})
return
}
var req struct {
Publish bool `json:"publish"`
}
if err := c.ShouldBindJSON(&req); err != nil {
req.Publish = true // Default to publish
}
if err := h.store.PublishMedia(c.Request.Context(), id, req.Publish); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "is_published": req.Publish})
}
// GenerateVideo generates a presentation video for a module
// POST /sdk/v1/training/content/:moduleId/generate-video
func (h *TrainingHandlers) GenerateVideo(c *gin.Context) {
moduleID, err := uuid.Parse(c.Param("moduleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
module, err := h.store.GetModule(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if module == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
return
}
media, err := h.contentGenerator.GenerateVideo(c.Request.Context(), *module)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, media)
}
// PreviewVideoScript generates and returns a video script preview without creating the video
// POST /sdk/v1/training/content/:moduleId/preview-script
func (h *TrainingHandlers) PreviewVideoScript(c *gin.Context) {
moduleID, err := uuid.Parse(c.Param("moduleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
module, err := h.store.GetModule(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if module == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
return
}
script, err := h.contentGenerator.GenerateVideoScript(c.Request.Context(), *module)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, script)
}