package handlers import ( "fmt" "net/http" "strconv" "strings" "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" ) // AcademyHandlers handles academy HTTP requests type AcademyHandlers struct { store *academy.Store trainingStore *training.Store } // NewAcademyHandlers creates new academy handlers func NewAcademyHandlers(store *academy.Store, trainingStore *training.Store) *AcademyHandlers { return &AcademyHandlers{store: store, trainingStore: trainingStore} } // ============================================================================ // Course Management // ============================================================================ // CreateCourse creates a new compliance training course // POST /sdk/v1/academy/courses func (h *AcademyHandlers) CreateCourse(c *gin.Context) { var req academy.CreateCourseRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tenantID := rbac.GetTenantID(c) course := &academy.Course{ TenantID: tenantID, Title: req.Title, Description: req.Description, Category: req.Category, DurationMinutes: req.DurationMinutes, RequiredForRoles: req.RequiredForRoles, IsActive: true, } if course.RequiredForRoles == nil { course.RequiredForRoles = []string{} } if err := h.store.CreateCourse(c.Request.Context(), course); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Create lessons if provided for i := range req.Lessons { lesson := &academy.Lesson{ CourseID: course.ID, Title: req.Lessons[i].Title, Description: req.Lessons[i].Description, LessonType: req.Lessons[i].LessonType, ContentURL: req.Lessons[i].ContentURL, DurationMinutes: req.Lessons[i].DurationMinutes, OrderIndex: req.Lessons[i].OrderIndex, QuizQuestions: req.Lessons[i].QuizQuestions, } if err := h.store.CreateLesson(c.Request.Context(), lesson); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } course.Lessons = append(course.Lessons, *lesson) } if course.Lessons == nil { course.Lessons = []academy.Lesson{} } c.JSON(http.StatusCreated, gin.H{"course": course}) } // GetCourse retrieves a course with its lessons // GET /sdk/v1/academy/courses/:id func (h *AcademyHandlers) GetCourse(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"}) return } course, err := h.store.GetCourse(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if course == nil { c.JSON(http.StatusNotFound, gin.H{"error": "course not found"}) return } c.JSON(http.StatusOK, gin.H{"course": course}) } // ListCourses lists courses for the current tenant // GET /sdk/v1/academy/courses func (h *AcademyHandlers) ListCourses(c *gin.Context) { tenantID := rbac.GetTenantID(c) filters := &academy.CourseFilters{ Limit: 50, } if category := c.Query("category"); category != "" { filters.Category = academy.CourseCategory(category) } if search := c.Query("search"); search != "" { filters.Search = search } if activeStr := c.Query("is_active"); activeStr != "" { active := activeStr == "true" filters.IsActive = &active } if limitStr := c.Query("limit"); limitStr != "" { if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { filters.Limit = limit } } if offsetStr := c.Query("offset"); offsetStr != "" { if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 { filters.Offset = offset } } courses, total, err := h.store.ListCourses(c.Request.Context(), tenantID, filters) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, academy.CourseListResponse{ Courses: courses, Total: total, }) } // UpdateCourse updates a course // PUT /sdk/v1/academy/courses/:id func (h *AcademyHandlers) UpdateCourse(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"}) return } course, err := h.store.GetCourse(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if course == nil { c.JSON(http.StatusNotFound, gin.H{"error": "course not found"}) return } var req academy.UpdateCourseRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Title != nil { course.Title = *req.Title } if req.Description != nil { course.Description = *req.Description } if req.Category != nil { course.Category = *req.Category } if req.DurationMinutes != nil { course.DurationMinutes = *req.DurationMinutes } if req.RequiredForRoles != nil { course.RequiredForRoles = req.RequiredForRoles } if req.IsActive != nil { course.IsActive = *req.IsActive } if err := h.store.UpdateCourse(c.Request.Context(), course); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"course": course}) } // DeleteCourse deletes a course // DELETE /sdk/v1/academy/courses/:id func (h *AcademyHandlers) DeleteCourse(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"}) return } if err := h.store.DeleteCourse(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "course deleted"}) } // ============================================================================ // Enrollment Management // ============================================================================ // CreateEnrollment enrolls a user in a course // POST /sdk/v1/academy/enrollments func (h *AcademyHandlers) CreateEnrollment(c *gin.Context) { var req academy.EnrollUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tenantID := rbac.GetTenantID(c) // Verify course exists course, err := h.store.GetCourse(c.Request.Context(), req.CourseID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if course == nil { c.JSON(http.StatusNotFound, gin.H{"error": "course not found"}) return } enrollment := &academy.Enrollment{ TenantID: tenantID, CourseID: req.CourseID, UserID: req.UserID, UserName: req.UserName, UserEmail: req.UserEmail, Status: academy.EnrollmentStatusNotStarted, Deadline: req.Deadline, } if err := h.store.CreateEnrollment(c.Request.Context(), enrollment); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"enrollment": enrollment}) } // ListEnrollments lists enrollments for the current tenant // GET /sdk/v1/academy/enrollments func (h *AcademyHandlers) ListEnrollments(c *gin.Context) { tenantID := rbac.GetTenantID(c) filters := &academy.EnrollmentFilters{ Limit: 50, } if status := c.Query("status"); status != "" { filters.Status = academy.EnrollmentStatus(status) } if courseIDStr := c.Query("course_id"); courseIDStr != "" { if courseID, err := uuid.Parse(courseIDStr); err == nil { filters.CourseID = &courseID } } if userIDStr := c.Query("user_id"); userIDStr != "" { if userID, err := uuid.Parse(userIDStr); err == nil { filters.UserID = &userID } } if limitStr := c.Query("limit"); limitStr != "" { if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { filters.Limit = limit } } if offsetStr := c.Query("offset"); offsetStr != "" { if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 { filters.Offset = offset } } enrollments, total, err := h.store.ListEnrollments(c.Request.Context(), tenantID, filters) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, academy.EnrollmentListResponse{ Enrollments: enrollments, Total: total, }) } // UpdateProgress updates an enrollment's progress // PUT /sdk/v1/academy/enrollments/:id/progress func (h *AcademyHandlers) UpdateProgress(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"}) return } enrollment, err := h.store.GetEnrollment(c.Request.Context(), id) 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 } var req academy.UpdateProgressRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Progress < 0 || req.Progress > 100 { c.JSON(http.StatusBadRequest, gin.H{"error": "progress must be between 0 and 100"}) return } if err := h.store.UpdateEnrollmentProgress(c.Request.Context(), id, req.Progress, req.CurrentLesson); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Fetch updated enrollment updated, err := h.store.GetEnrollment(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"enrollment": updated}) } // CompleteEnrollment marks an enrollment as completed // POST /sdk/v1/academy/enrollments/:id/complete func (h *AcademyHandlers) CompleteEnrollment(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"}) return } enrollment, err := h.store.GetEnrollment(c.Request.Context(), id) 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 } if enrollment.Status == academy.EnrollmentStatusCompleted { c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment already completed"}) return } if err := h.store.CompleteEnrollment(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Fetch updated enrollment updated, err := h.store.GetEnrollment(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "enrollment": updated, "message": "enrollment completed", }) } // ============================================================================ // Certificate Management // ============================================================================ // GetCertificate retrieves a certificate // GET /sdk/v1/academy/certificates/:id func (h *AcademyHandlers) GetCertificate(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"}) return } cert, err := h.store.GetCertificate(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if cert == nil { c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) return } c.JSON(http.StatusOK, gin.H{"certificate": cert}) } // GenerateCertificate generates a certificate for a completed enrollment // POST /sdk/v1/academy/enrollments/:id/certificate func (h *AcademyHandlers) GenerateCertificate(c *gin.Context) { enrollmentID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"}) return } 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 } if enrollment.Status != academy.EnrollmentStatusCompleted { c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment must be completed before generating certificate"}) return } // Check if certificate already exists existing, err := h.store.GetCertificateByEnrollment(c.Request.Context(), enrollmentID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if existing != nil { c.JSON(http.StatusOK, gin.H{"certificate": existing, "message": "certificate already exists"}) return } // Get the course for the certificate title course, err := h.store.GetCourse(c.Request.Context(), enrollment.CourseID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } courseTitle := "Unknown Course" if course != nil { courseTitle = course.Title } // Certificate is valid for 1 year by default validUntil := time.Now().UTC().AddDate(1, 0, 0) cert := &academy.Certificate{ EnrollmentID: enrollmentID, UserName: enrollment.UserName, CourseTitle: courseTitle, ValidUntil: &validUntil, } if err := h.store.CreateCertificate(c.Request.Context(), cert); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"certificate": cert}) } // ============================================================================ // 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 } // Pass threshold: 70% passed := score >= 70 response := academy.SubmitQuizResponse{ Score: score, Passed: passed, CorrectAnswers: correctCount, TotalQuestions: totalQuestions, Results: results, } c.JSON(http.StatusOK, response) } // ============================================================================ // 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) } // ============================================================================ // Certificate PDF Download // ============================================================================ // DownloadCertificatePDF generates and downloads a certificate as PDF // GET /sdk/v1/academy/certificates/:id/pdf func (h *AcademyHandlers) DownloadCertificatePDF(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"}) return } cert, err := h.store.GetCertificate(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if cert == nil { c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) return } validUntil := time.Now().UTC().AddDate(1, 0, 0) if cert.ValidUntil != nil { validUntil = *cert.ValidUntil } pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{ CertificateID: cert.ID.String(), UserName: cert.UserName, CourseName: cert.CourseTitle, IssuedAt: cert.IssuedAt, ValidUntil: validUntil, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PDF: " + err.Error()}) return } shortID := cert.ID.String()[:8] c.Header("Content-Disposition", "attachment; filename=zertifikat-"+shortID+".pdf") c.Data(http.StatusOK, "application/pdf", pdfBytes) } // ============================================================================ // 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 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, // Store markdown in content_url for text lessons 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, }) orderIdx++ } // 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, }) } // 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 } var lessons []academy.Lesson orderIdx := 0 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++ } 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, OrderIndex: orderIdx, QuizQuestions: academyQuiz, }) orderIdx++ } 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, }) } 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), }) } // estimateReadingTime estimates reading time in minutes from markdown content // Average reading speed: ~200 words per minute func estimateReadingTime(content string) int { words := len(strings.Fields(content)) minutes := words / 200 if minutes < 5 { minutes = 5 } return minutes }