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/gin-gonic/gin" "github.com/google/uuid" ) // AcademyHandlers handles academy HTTP requests type AcademyHandlers struct { store *academy.Store } // NewAcademyHandlers creates new academy handlers func NewAcademyHandlers(store *academy.Store) *AcademyHandlers { return &AcademyHandlers{store: store} } // ============================================================================ // 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) } // ============================================================================ // 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) }