package handlers import ( "net/http" "strconv" "time" "github.com/breakpilot/ai-compliance-sdk/internal/academy" "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 blockGenerator *training.BlockGenerator ttsClient *training.TTSClient } // NewTrainingHandlers creates new training handlers func NewTrainingHandlers(store *training.Store, contentGenerator *training.ContentGenerator, blockGenerator *training.BlockGenerator, ttsClient *training.TTSClient) *TrainingHandlers { return &TrainingHandlers{ store: store, contentGenerator: contentGenerator, blockGenerator: blockGenerator, ttsClient: ttsClient, } } // ============================================================================ // 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) } // DeleteModule deletes a training module // DELETE /sdk/v1/training/modules/:id func (h *TrainingHandlers) DeleteModule(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 } if err := h.store.DeleteModule(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "deleted"}) } // ============================================================================ // 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}) } // UpdateAssignment updates assignment fields (e.g. deadline) // PUT /sdk/v1/training/assignments/:id func (h *TrainingHandlers) UpdateAssignment(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 struct { Deadline *string `json:"deadline"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if req.Deadline != nil { deadline, err := time.Parse(time.RFC3339, *req.Deadline) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deadline format (use RFC3339)"}) return } if err := h.store.UpdateAssignmentDeadline(c.Request.Context(), id, deadline); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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) } // 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) } // ============================================================================ // Training Block Endpoints (Controls → Schulungsmodule) // ============================================================================ // ListBlockConfigs returns all block configs for the tenant // GET /sdk/v1/training/blocks func (h *TrainingHandlers) ListBlockConfigs(c *gin.Context) { tenantID := rbac.GetTenantID(c) configs, err := h.store.ListBlockConfigs(c.Request.Context(), tenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "blocks": configs, "total": len(configs), }) } // CreateBlockConfig creates a new block configuration // POST /sdk/v1/training/blocks func (h *TrainingHandlers) CreateBlockConfig(c *gin.Context) { tenantID := rbac.GetTenantID(c) var req training.CreateBlockConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } config := &training.TrainingBlockConfig{ TenantID: tenantID, Name: req.Name, Description: req.Description, DomainFilter: req.DomainFilter, CategoryFilter: req.CategoryFilter, SeverityFilter: req.SeverityFilter, TargetAudienceFilter: req.TargetAudienceFilter, RegulationArea: req.RegulationArea, ModuleCodePrefix: req.ModuleCodePrefix, FrequencyType: req.FrequencyType, DurationMinutes: req.DurationMinutes, PassThreshold: req.PassThreshold, MaxControlsPerModule: req.MaxControlsPerModule, } if config.FrequencyType == "" { config.FrequencyType = training.FrequencyAnnual } if config.DurationMinutes == 0 { config.DurationMinutes = 45 } if config.PassThreshold == 0 { config.PassThreshold = 70 } if config.MaxControlsPerModule == 0 { config.MaxControlsPerModule = 20 } if err := h.store.CreateBlockConfig(c.Request.Context(), config); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, config) } // GetBlockConfig returns a single block config // GET /sdk/v1/training/blocks/:id func (h *TrainingHandlers) GetBlockConfig(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"}) return } config, err := h.store.GetBlockConfig(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if config == nil { c.JSON(http.StatusNotFound, gin.H{"error": "block config not found"}) return } c.JSON(http.StatusOK, config) } // UpdateBlockConfig updates a block config // PUT /sdk/v1/training/blocks/:id func (h *TrainingHandlers) UpdateBlockConfig(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"}) return } config, err := h.store.GetBlockConfig(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if config == nil { c.JSON(http.StatusNotFound, gin.H{"error": "block config not found"}) return } var req training.UpdateBlockConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Name != nil { config.Name = *req.Name } if req.Description != nil { config.Description = *req.Description } if req.DomainFilter != nil { config.DomainFilter = *req.DomainFilter } if req.CategoryFilter != nil { config.CategoryFilter = *req.CategoryFilter } if req.SeverityFilter != nil { config.SeverityFilter = *req.SeverityFilter } if req.TargetAudienceFilter != nil { config.TargetAudienceFilter = *req.TargetAudienceFilter } if req.MaxControlsPerModule != nil { config.MaxControlsPerModule = *req.MaxControlsPerModule } if req.DurationMinutes != nil { config.DurationMinutes = *req.DurationMinutes } if req.PassThreshold != nil { config.PassThreshold = *req.PassThreshold } if req.IsActive != nil { config.IsActive = *req.IsActive } if err := h.store.UpdateBlockConfig(c.Request.Context(), config); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, config) } // DeleteBlockConfig deletes a block config // DELETE /sdk/v1/training/blocks/:id func (h *TrainingHandlers) DeleteBlockConfig(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"}) return } if err := h.store.DeleteBlockConfig(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "deleted"}) } // PreviewBlock performs a dry run showing matching controls and proposed roles // POST /sdk/v1/training/blocks/:id/preview func (h *TrainingHandlers) PreviewBlock(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"}) return } preview, err := h.blockGenerator.Preview(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, preview) } // GenerateBlock runs the full generation pipeline // POST /sdk/v1/training/blocks/:id/generate func (h *TrainingHandlers) GenerateBlock(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"}) return } var req training.GenerateBlockRequest if err := c.ShouldBindJSON(&req); err != nil { // Defaults are fine req.Language = "de" req.AutoMatrix = true } result, err := h.blockGenerator.Generate(c.Request.Context(), id, req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, result) } // GetBlockControls returns control links for a block config // GET /sdk/v1/training/blocks/:id/controls func (h *TrainingHandlers) GetBlockControls(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"}) return } links, err := h.store.GetControlLinksForBlock(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "controls": links, "total": len(links), }) } // ListCanonicalControls returns filtered canonical controls for browsing // GET /sdk/v1/training/canonical/controls func (h *TrainingHandlers) ListCanonicalControls(c *gin.Context) { domain := c.Query("domain") category := c.Query("category") severity := c.Query("severity") targetAudience := c.Query("target_audience") controls, err := h.store.QueryCanonicalControls(c.Request.Context(), domain, category, severity, targetAudience, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "controls": controls, "total": len(controls), }) } // GetCanonicalMeta returns aggregated metadata about canonical controls // GET /sdk/v1/training/canonical/meta func (h *TrainingHandlers) GetCanonicalMeta(c *gin.Context) { meta, err := h.store.GetCanonicalControlMeta(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, meta) } // ============================================================================ // Media Streaming Endpoint // ============================================================================ // StreamMedia returns a redirect to a presigned URL for a media file // GET /sdk/v1/training/media/:mediaId/stream func (h *TrainingHandlers) StreamMedia(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 } if h.ttsClient == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "media streaming not available"}) return } url, err := h.ttsClient.GetPresignedURL(c.Request.Context(), media.Bucket, media.ObjectKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate streaming URL: " + err.Error()}) return } c.Redirect(http.StatusTemporaryRedirect, url) } // ============================================================================ // Certificate Endpoints // ============================================================================ // GenerateCertificate generates a certificate for a completed assignment // POST /sdk/v1/training/certificates/generate/:assignmentId func (h *TrainingHandlers) GenerateCertificate(c *gin.Context) { assignmentID, err := uuid.Parse(c.Param("assignmentId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"}) return } tenantID := rbac.GetTenantID(c) assignment, err := h.store.GetAssignment(c.Request.Context(), assignmentID) 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 } if assignment.Status != training.AssignmentStatusCompleted { c.JSON(http.StatusBadRequest, gin.H{"error": "assignment is not completed"}) return } if assignment.QuizPassed == nil || !*assignment.QuizPassed { c.JSON(http.StatusBadRequest, gin.H{"error": "quiz has not been passed"}) return } // Generate certificate ID certID := uuid.New() if err := h.store.SetCertificateID(c.Request.Context(), assignmentID, certID); 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.AuditActionCertificateIssued, EntityType: training.AuditEntityCertificate, EntityID: &certID, Details: map[string]interface{}{ "assignment_id": assignmentID.String(), "user_name": assignment.UserName, "module_title": assignment.ModuleTitle, }, }) // Reload assignment with certificate_id assignment, _ = h.store.GetAssignment(c.Request.Context(), assignmentID) c.JSON(http.StatusOK, gin.H{ "certificate_id": certID, "assignment": assignment, }) } // DownloadCertificatePDF generates and returns a PDF certificate // GET /sdk/v1/training/certificates/:id/pdf func (h *TrainingHandlers) DownloadCertificatePDF(c *gin.Context) { certID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"}) return } assignment, err := h.store.GetAssignmentByCertificateID(c.Request.Context(), certID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if assignment == nil { c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) return } // Get module for title module, _ := h.store.GetModule(c.Request.Context(), assignment.ModuleID) courseName := assignment.ModuleTitle if module != nil { courseName = module.Title } score := 0 if assignment.QuizScore != nil { score = int(*assignment.QuizScore) } issuedAt := assignment.UpdatedAt if assignment.CompletedAt != nil { issuedAt = *assignment.CompletedAt } // Use academy PDF generator pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{ CertificateID: certID.String(), UserName: assignment.UserName, CourseName: courseName, Score: score, IssuedAt: issuedAt, ValidUntil: issuedAt.AddDate(1, 0, 0), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "PDF generation failed: " + err.Error()}) return } c.Header("Content-Disposition", "attachment; filename=zertifikat-"+certID.String()[:8]+".pdf") c.Data(http.StatusOK, "application/pdf", pdfBytes) } // ListCertificates returns all certificates for a tenant // GET /sdk/v1/training/certificates func (h *TrainingHandlers) ListCertificates(c *gin.Context) { tenantID := rbac.GetTenantID(c) certificates, err := h.store.ListCertificates(c.Request.Context(), tenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "certificates": certificates, "total": len(certificates), }) } // ============================================================================ // Interactive Video Endpoints // ============================================================================ // GenerateInteractiveVideo triggers the full interactive video pipeline // POST /sdk/v1/training/content/:moduleId/generate-interactive func (h *TrainingHandlers) GenerateInteractiveVideo(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.GenerateInteractiveVideo(c.Request.Context(), *module) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, media) } // GetInteractiveManifest returns the interactive video manifest with checkpoints and progress // GET /sdk/v1/training/content/:moduleId/interactive-manifest func (h *TrainingHandlers) GetInteractiveManifest(c *gin.Context) { moduleID, err := uuid.Parse(c.Param("moduleId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) return } // Get interactive video media mediaList, err := h.store.GetMediaForModule(c.Request.Context(), moduleID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Find interactive video var interactiveMedia *training.TrainingMedia for i := range mediaList { if mediaList[i].MediaType == training.MediaTypeInteractiveVideo && mediaList[i].Status == training.MediaStatusCompleted { interactiveMedia = &mediaList[i] break } } if interactiveMedia == nil { c.JSON(http.StatusNotFound, gin.H{"error": "no interactive video found for this module"}) return } // Get checkpoints checkpoints, err := h.store.ListCheckpoints(c.Request.Context(), moduleID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Optional: get assignment ID for progress assignmentIDStr := c.Query("assignment_id") // Build manifest entries entries := make([]training.CheckpointManifestEntry, len(checkpoints)) for i, cp := range checkpoints { // Get questions for this checkpoint questions, _ := h.store.GetCheckpointQuestions(c.Request.Context(), cp.ID) cpQuestions := make([]training.CheckpointQuestion, len(questions)) for j, q := range questions { cpQuestions[j] = training.CheckpointQuestion{ Question: q.Question, Options: q.Options, CorrectIndex: q.CorrectIndex, Explanation: q.Explanation, } } entry := training.CheckpointManifestEntry{ CheckpointID: cp.ID, Index: cp.CheckpointIndex, Title: cp.Title, TimestampSeconds: cp.TimestampSeconds, Questions: cpQuestions, } // Get progress if assignment_id provided if assignmentIDStr != "" { if assignmentID, err := uuid.Parse(assignmentIDStr); err == nil { progress, _ := h.store.GetCheckpointProgress(c.Request.Context(), assignmentID, cp.ID) entry.Progress = progress } } entries[i] = entry } // Get stream URL streamURL := "" if h.ttsClient != nil { url, err := h.ttsClient.GetPresignedURL(c.Request.Context(), interactiveMedia.Bucket, interactiveMedia.ObjectKey) if err == nil { streamURL = url } } manifest := training.InteractiveVideoManifest{ MediaID: interactiveMedia.ID, StreamURL: streamURL, Checkpoints: entries, } c.JSON(http.StatusOK, manifest) } // SubmitCheckpointQuiz handles checkpoint quiz submission // POST /sdk/v1/training/checkpoints/:checkpointId/submit func (h *TrainingHandlers) SubmitCheckpointQuiz(c *gin.Context) { checkpointID, err := uuid.Parse(c.Param("checkpointId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid checkpoint ID"}) return } var req training.SubmitCheckpointQuizRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } assignmentID, err := uuid.Parse(req.AssignmentID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"}) return } // Get checkpoint questions questions, err := h.store.GetCheckpointQuestions(c.Request.Context(), checkpointID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if len(questions) == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "no questions found for this checkpoint"}) return } // Grade answers correctCount := 0 feedback := make([]training.CheckpointQuizFeedback, len(questions)) for i, q := range questions { isCorrect := false if i < len(req.Answers) && req.Answers[i] == q.CorrectIndex { isCorrect = true correctCount++ } feedback[i] = training.CheckpointQuizFeedback{ Question: q.Question, Correct: isCorrect, Explanation: q.Explanation, } } score := float64(correctCount) / float64(len(questions)) * 100 passed := score >= 70 // 70% threshold for checkpoint // Update progress progress := &training.CheckpointProgress{ AssignmentID: assignmentID, CheckpointID: checkpointID, Passed: passed, Attempts: 1, } if err := h.store.UpsertCheckpointProgress(c.Request.Context(), progress); 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: rbac.GetTenantID(c), UserID: &userID, Action: training.AuditAction("checkpoint_submitted"), EntityType: training.AuditEntityType("checkpoint"), EntityID: &checkpointID, Details: map[string]interface{}{ "assignment_id": assignmentID.String(), "score": score, "passed": passed, "correct": correctCount, "total": len(questions), }, }) c.JSON(http.StatusOK, training.SubmitCheckpointQuizResponse{ Passed: passed, Score: score, Feedback: feedback, }) } // GetCheckpointProgress returns all checkpoint progress for an assignment // GET /sdk/v1/training/checkpoints/progress/:assignmentId func (h *TrainingHandlers) GetCheckpointProgress(c *gin.Context) { assignmentID, err := uuid.Parse(c.Param("assignmentId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"}) return } progress, err := h.store.ListCheckpointProgress(c.Request.Context(), assignmentID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "progress": progress, "total": len(progress), }) }