Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Interactive Training Videos (CP-TRAIN): - DB migration 022: training_checkpoints + checkpoint_progress tables - NarratorScript generation via Anthropic (AI Teacher persona, German) - TTS batch synthesis + interactive video pipeline (slides + checkpoint slides + FFmpeg) - 4 new API endpoints: generate-interactive, interactive-manifest, checkpoint submit, checkpoint progress - InteractiveVideoPlayer component (HTML5 Video, quiz overlay, seek protection, progress tracking) - Learner portal integration with automatic completion on all checkpoints passed - 30 new tests (handler validation + grading logic + manifest/progress + seek protection) Training Blocks: - Block generator, block store, block config CRUD + preview/generate endpoints - Migration 021: training_blocks schema Control Generator + Canonical Library: - Control generator routes + service enhancements - Canonical control library helpers, sidebar entry - Citation backfill service + tests - CE libraries data (hazard, protection, evidence, lifecycle, components) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1865 lines
52 KiB
Go
1865 lines
52 KiB
Go
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),
|
|
})
|
|
}
|