All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 21s
- Backend: UpdateLesson handler (PUT /lessons/:id) for editing title, content, quiz questions - Backend: TestQuiz handler (POST /lessons/:id/quiz-test) for quiz evaluation without enrollment - Frontend: Content editor with markdown textarea, save, and approve-for-video workflow - Frontend: Fix quiz endpoint to /lessons/:id/quiz-test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1047 lines
30 KiB
Go
1047 lines
30 KiB
Go
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
|
|
}
|