package handlers import ( "fmt" "net/http" "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" ) // ============================================================================ // Quiz Submission // ============================================================================ // SubmitQuiz submits quiz answers and returns the results // POST /sdk/v1/academy/enrollments/:id/quiz func (h *AcademyHandlers) SubmitQuiz(c *gin.Context) { enrollmentID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"}) return } var req academy.SubmitQuizRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify enrollment exists enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if enrollment == nil { c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"}) return } // Get the lesson with quiz questions lesson, err := h.store.GetLesson(c.Request.Context(), req.LessonID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if lesson == nil { c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"}) return } if len(lesson.QuizQuestions) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"}) return } if len(req.Answers) != len(lesson.QuizQuestions) { c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"}) return } // Grade the quiz correctCount := 0 var results []academy.QuizResult for i, question := range lesson.QuizQuestions { correct := req.Answers[i] == question.CorrectIndex if correct { correctCount++ } results = append(results, academy.QuizResult{ Question: question.Question, Correct: correct, Explanation: question.Explanation, }) } totalQuestions := len(lesson.QuizQuestions) score := 0 if totalQuestions > 0 { score = (correctCount * 100) / totalQuestions } c.JSON(http.StatusOK, academy.SubmitQuizResponse{ Score: score, Passed: score >= 70, CorrectAnswers: correctCount, TotalQuestions: totalQuestions, Results: results, }) } // ============================================================================ // Lesson Update // ============================================================================ // UpdateLesson updates a lesson's content, title, or quiz questions // PUT /sdk/v1/academy/lessons/:id func (h *AcademyHandlers) UpdateLesson(c *gin.Context) { lessonID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lesson ID"}) return } lesson, err := h.store.GetLesson(c.Request.Context(), lessonID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if lesson == nil { c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"}) return } var req struct { Title *string `json:"title"` Description *string `json:"description"` ContentURL *string `json:"content_url"` DurationMinutes *int `json:"duration_minutes"` QuizQuestions *[]academy.QuizQuestion `json:"quiz_questions"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Title != nil { lesson.Title = *req.Title } if req.Description != nil { lesson.Description = *req.Description } if req.ContentURL != nil { lesson.ContentURL = *req.ContentURL } if req.DurationMinutes != nil { lesson.DurationMinutes = *req.DurationMinutes } if req.QuizQuestions != nil { lesson.QuizQuestions = *req.QuizQuestions } if err := h.store.UpdateLesson(c.Request.Context(), lesson); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"lesson": lesson}) } // TestQuiz evaluates quiz answers without requiring an enrollment // POST /sdk/v1/academy/lessons/:id/quiz-test func (h *AcademyHandlers) TestQuiz(c *gin.Context) { lessonID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lesson ID"}) return } lesson, err := h.store.GetLesson(c.Request.Context(), lessonID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if lesson == nil { c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"}) return } if len(lesson.QuizQuestions) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"}) return } var req struct { Answers []int `json:"answers"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(req.Answers) != len(lesson.QuizQuestions) { c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"}) return } correctCount := 0 var results []academy.QuizResult for i, question := range lesson.QuizQuestions { correct := req.Answers[i] == question.CorrectIndex if correct { correctCount++ } results = append(results, academy.QuizResult{ Question: question.Question, Correct: correct, Explanation: question.Explanation, }) } totalQuestions := len(lesson.QuizQuestions) score := 0 if totalQuestions > 0 { score = (correctCount * 100) / totalQuestions } c.JSON(http.StatusOK, academy.SubmitQuizResponse{ Score: score, Passed: score >= 70, CorrectAnswers: correctCount, TotalQuestions: totalQuestions, Results: results, }) } // ============================================================================ // Statistics // ============================================================================ // GetStatistics returns academy statistics for the current tenant // GET /sdk/v1/academy/statistics func (h *AcademyHandlers) GetStatistics(c *gin.Context) { tenantID := rbac.GetTenantID(c) stats, err := h.store.GetStatistics(c.Request.Context(), tenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, stats) } // ============================================================================ // Course Generation from Training Modules // ============================================================================ // regulationToCategory maps training regulation areas to academy categories var regulationToCategory = map[training.RegulationArea]academy.CourseCategory{ training.RegulationDSGVO: academy.CourseCategoryDSGVOBasics, training.RegulationNIS2: academy.CourseCategoryITSecurity, training.RegulationISO27001: academy.CourseCategoryITSecurity, training.RegulationAIAct: academy.CourseCategoryAILiteracy, training.RegulationGeschGehG: academy.CourseCategoryWhistleblowerProtection, training.RegulationHinSchG: academy.CourseCategoryWhistleblowerProtection, } // GenerateCourseFromTraining creates an academy course from a training module // POST /sdk/v1/academy/courses/generate func (h *AcademyHandlers) GenerateCourseFromTraining(c *gin.Context) { if h.trainingStore == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"}) return } var req struct { ModuleID string `json:"module_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } moduleID, err := uuid.Parse(req.ModuleID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module_id"}) return } tenantID := rbac.GetTenantID(c) // 1. Get the training module module, err := h.trainingStore.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": "training module not found"}) return } // If module already linked to an academy course, return that if module.AcademyCourseID != nil { existing, err := h.store.GetCourse(c.Request.Context(), *module.AcademyCourseID) if err == nil && existing != nil { c.JSON(http.StatusOK, gin.H{"course": existing, "message": "course already exists for this module"}) return } } // 2. Get generated content (if any) content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), moduleID) // 3. Get quiz questions (if any) quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), moduleID) // 4. Determine academy category from regulation area category, ok := regulationToCategory[module.RegulationArea] if !ok { category = academy.CourseCategoryCustom } // 5. Build lessons from content + quiz lessons := buildModuleLessons(*module, content, quizQuestions) // 6. Create the academy course course := &academy.Course{ TenantID: tenantID, Title: module.Title, Description: module.Description, Category: category, DurationMinutes: module.DurationMinutes, RequiredForRoles: []string{}, IsActive: true, } if err := h.store.CreateCourse(c.Request.Context(), course); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create course: " + err.Error()}) return } // 7. Create lessons for i := range lessons { lessons[i].CourseID = course.ID if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create lesson: " + err.Error()}) return } } course.Lessons = lessons // 8. Link training module to academy course if err := h.trainingStore.SetAcademyCourseID(c.Request.Context(), moduleID, course.ID); err != nil { // Non-fatal: course is created, just not linked fmt.Printf("Warning: failed to link training module %s to academy course %s: %v\n", moduleID, course.ID, err) } c.JSON(http.StatusCreated, gin.H{"course": course}) } // GenerateAllCourses creates academy courses for all training modules that don't have one yet // POST /sdk/v1/academy/courses/generate-all func (h *AcademyHandlers) GenerateAllCourses(c *gin.Context) { if h.trainingStore == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"}) return } tenantID := rbac.GetTenantID(c) // Get all training modules modules, _, err := h.trainingStore.ListModules(c.Request.Context(), tenantID, &training.ModuleFilters{Limit: 100}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } generated := 0 skipped := 0 var errors []string for _, module := range modules { // Skip if already linked if module.AcademyCourseID != nil { skipped++ continue } // Get content and quiz content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), module.ID) quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), module.ID) category, ok := regulationToCategory[module.RegulationArea] if !ok { category = academy.CourseCategoryCustom } lessons := buildModuleLessons(module, content, quizQuestions) course := &academy.Course{ TenantID: tenantID, Title: module.Title, Description: module.Description, Category: category, DurationMinutes: module.DurationMinutes, RequiredForRoles: []string{}, IsActive: true, } if err := h.store.CreateCourse(c.Request.Context(), course); err != nil { errors = append(errors, fmt.Sprintf("%s: %v", module.ModuleCode, err)) continue } for i := range lessons { lessons[i].CourseID = course.ID if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil { errors = append(errors, fmt.Sprintf("%s lesson: %v", module.ModuleCode, err)) } } _ = h.trainingStore.SetAcademyCourseID(c.Request.Context(), module.ID, course.ID) generated++ } c.JSON(http.StatusOK, gin.H{ "generated": generated, "skipped": skipped, "errors": errors, "total": len(modules), }) } // buildModuleLessons constructs academy lessons from a training module, its content, and quiz questions. func buildModuleLessons(module training.TrainingModule, content *training.ModuleContent, quizQuestions []training.QuizQuestion) []academy.Lesson { var lessons []academy.Lesson orderIdx := 0 // Lesson 1: Text content (if generated) if content != nil && content.ContentBody != "" { lessons = append(lessons, academy.Lesson{ Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title), Description: content.Summary, LessonType: academy.LessonTypeText, ContentURL: content.ContentBody, DurationMinutes: estimateReadingTime(content.ContentBody), OrderIndex: orderIdx, }) orderIdx++ } // Lesson 2: Quiz (if questions exist) if len(quizQuestions) > 0 { var academyQuiz []academy.QuizQuestion for _, q := range quizQuestions { academyQuiz = append(academyQuiz, academy.QuizQuestion{ Question: q.Question, Options: q.Options, CorrectIndex: q.CorrectIndex, Explanation: q.Explanation, }) } lessons = append(lessons, academy.Lesson{ Title: fmt.Sprintf("%s - Quiz", module.Title), Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)), LessonType: academy.LessonTypeQuiz, DurationMinutes: len(quizQuestions) * 2, // ~2 min per question OrderIndex: orderIdx, QuizQuestions: academyQuiz, }) } // If no content or quiz exists, create a placeholder if len(lessons) == 0 { lessons = append(lessons, academy.Lesson{ Title: module.Title, Description: module.Description, LessonType: academy.LessonTypeText, ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description), DurationMinutes: module.DurationMinutes, OrderIndex: 0, }) } return lessons }