This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/ai-compliance-sdk/internal/api/handlers/academy_handlers.go
BreakPilot Dev 557305db5d
Some checks failed
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
feat: Add Academy, Whistleblower, Incidents SDK modules, pitch-deck, blog and CI/CD config
- Academy, Whistleblower, Incidents frontend pages with API proxies and types
- Vendor compliance API proxy route
- Go backend handlers and models for all new SDK modules
- Investor pitch-deck app with interactive slides
- Blog section with DSGVO, AI Act, NIS2, glossary articles
- MkDocs documentation site
- CI/CD pipelines (Woodpecker, GitHub Actions), security scanning config
- Planning and implementation documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:12:16 +01:00

588 lines
16 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/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)
}