refactor(go): split academy_handlers, workshop_handlers, content_generator
- academy_handlers.go (1046 LOC) → academy_handlers.go (228) + academy_enrollment_handlers.go (320) + academy_generation_handlers.go (472) - workshop_handlers.go (923 LOC) → workshop_handlers.go (292) + workshop_interaction_handlers.go (452) + workshop_export_handlers.go (196) - content_generator.go (978 LOC) → content_generator.go (491) + content_generator_media.go (497) All files under 500 LOC hard cap. Zero behavior changes, no exported symbol renames. Both packages vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 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})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Quiz Submission
|
||||
// ============================================================================
|
||||
|
||||
// SubmitQuiz submits quiz answers and returns the results
|
||||
// POST /sdk/v1/academy/enrollments/:id/quiz
|
||||
func (h *AcademyHandlers) SubmitQuiz(c *gin.Context) {
|
||||
enrollmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req academy.SubmitQuizRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify enrollment exists
|
||||
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if enrollment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the lesson with quiz questions
|
||||
lesson, err := h.store.GetLesson(c.Request.Context(), req.LessonID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if lesson == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(lesson.QuizQuestions) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Answers) != len(lesson.QuizQuestions) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
|
||||
return
|
||||
}
|
||||
|
||||
// Grade the quiz
|
||||
correctCount := 0
|
||||
var results []academy.QuizResult
|
||||
|
||||
for i, question := range lesson.QuizQuestions {
|
||||
correct := req.Answers[i] == question.CorrectIndex
|
||||
if correct {
|
||||
correctCount++
|
||||
}
|
||||
results = append(results, academy.QuizResult{
|
||||
Question: question.Question,
|
||||
Correct: correct,
|
||||
Explanation: question.Explanation,
|
||||
})
|
||||
}
|
||||
|
||||
totalQuestions := len(lesson.QuizQuestions)
|
||||
score := 0
|
||||
if totalQuestions > 0 {
|
||||
score = (correctCount * 100) / totalQuestions
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, academy.SubmitQuizResponse{
|
||||
Score: score,
|
||||
Passed: score >= 70,
|
||||
CorrectAnswers: correctCount,
|
||||
TotalQuestions: totalQuestions,
|
||||
Results: results,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lesson Update
|
||||
// ============================================================================
|
||||
|
||||
// UpdateLesson updates a lesson's content, title, or quiz questions
|
||||
// PUT /sdk/v1/academy/lessons/:id
|
||||
func (h *AcademyHandlers) UpdateLesson(c *gin.Context) {
|
||||
lessonID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lesson ID"})
|
||||
return
|
||||
}
|
||||
|
||||
lesson, err := h.store.GetLesson(c.Request.Context(), lessonID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if lesson == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
ContentURL *string `json:"content_url"`
|
||||
DurationMinutes *int `json:"duration_minutes"`
|
||||
QuizQuestions *[]academy.QuizQuestion `json:"quiz_questions"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
lesson.Title = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
lesson.Description = *req.Description
|
||||
}
|
||||
if req.ContentURL != nil {
|
||||
lesson.ContentURL = *req.ContentURL
|
||||
}
|
||||
if req.DurationMinutes != nil {
|
||||
lesson.DurationMinutes = *req.DurationMinutes
|
||||
}
|
||||
if req.QuizQuestions != nil {
|
||||
lesson.QuizQuestions = *req.QuizQuestions
|
||||
}
|
||||
|
||||
if err := h.store.UpdateLesson(c.Request.Context(), lesson); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"lesson": lesson})
|
||||
}
|
||||
|
||||
// TestQuiz evaluates quiz answers without requiring an enrollment
|
||||
// POST /sdk/v1/academy/lessons/:id/quiz-test
|
||||
func (h *AcademyHandlers) TestQuiz(c *gin.Context) {
|
||||
lessonID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lesson ID"})
|
||||
return
|
||||
}
|
||||
|
||||
lesson, err := h.store.GetLesson(c.Request.Context(), lessonID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if lesson == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(lesson.QuizQuestions) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Answers []int `json:"answers"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Answers) != len(lesson.QuizQuestions) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
|
||||
return
|
||||
}
|
||||
|
||||
correctCount := 0
|
||||
var results []academy.QuizResult
|
||||
for i, question := range lesson.QuizQuestions {
|
||||
correct := req.Answers[i] == question.CorrectIndex
|
||||
if correct {
|
||||
correctCount++
|
||||
}
|
||||
results = append(results, academy.QuizResult{
|
||||
Question: question.Question,
|
||||
Correct: correct,
|
||||
Explanation: question.Explanation,
|
||||
})
|
||||
}
|
||||
|
||||
totalQuestions := len(lesson.QuizQuestions)
|
||||
score := 0
|
||||
if totalQuestions > 0 {
|
||||
score = (correctCount * 100) / totalQuestions
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, academy.SubmitQuizResponse{
|
||||
Score: score,
|
||||
Passed: score >= 70,
|
||||
CorrectAnswers: correctCount,
|
||||
TotalQuestions: totalQuestions,
|
||||
Results: results,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
// GetStatistics returns academy statistics for the current tenant
|
||||
// GET /sdk/v1/academy/statistics
|
||||
func (h *AcademyHandlers) GetStatistics(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Course Generation from Training Modules
|
||||
// ============================================================================
|
||||
|
||||
// regulationToCategory maps training regulation areas to academy categories
|
||||
var regulationToCategory = map[training.RegulationArea]academy.CourseCategory{
|
||||
training.RegulationDSGVO: academy.CourseCategoryDSGVOBasics,
|
||||
training.RegulationNIS2: academy.CourseCategoryITSecurity,
|
||||
training.RegulationISO27001: academy.CourseCategoryITSecurity,
|
||||
training.RegulationAIAct: academy.CourseCategoryAILiteracy,
|
||||
training.RegulationGeschGehG: academy.CourseCategoryWhistleblowerProtection,
|
||||
training.RegulationHinSchG: academy.CourseCategoryWhistleblowerProtection,
|
||||
}
|
||||
|
||||
// GenerateCourseFromTraining creates an academy course from a training module
|
||||
// POST /sdk/v1/academy/courses/generate
|
||||
func (h *AcademyHandlers) GenerateCourseFromTraining(c *gin.Context) {
|
||||
if h.trainingStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ModuleID string `json:"module_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
moduleID, err := uuid.Parse(req.ModuleID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module_id"})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
// 1. Get the training module
|
||||
module, err := h.trainingStore.GetModule(c.Request.Context(), moduleID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if module == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "training module not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// If module already linked to an academy course, return that
|
||||
if module.AcademyCourseID != nil {
|
||||
existing, err := h.store.GetCourse(c.Request.Context(), *module.AcademyCourseID)
|
||||
if err == nil && existing != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"course": existing, "message": "course already exists for this module"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get generated content (if any)
|
||||
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), moduleID)
|
||||
|
||||
// 3. Get quiz questions (if any)
|
||||
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), moduleID)
|
||||
|
||||
// 4. Determine academy category from regulation area
|
||||
category, ok := regulationToCategory[module.RegulationArea]
|
||||
if !ok {
|
||||
category = academy.CourseCategoryCustom
|
||||
}
|
||||
|
||||
// 5. Build lessons from content + quiz
|
||||
lessons := buildModuleLessons(*module, content, quizQuestions)
|
||||
|
||||
// 6. Create the academy course
|
||||
course := &academy.Course{
|
||||
TenantID: tenantID,
|
||||
Title: module.Title,
|
||||
Description: module.Description,
|
||||
Category: category,
|
||||
DurationMinutes: module.DurationMinutes,
|
||||
RequiredForRoles: []string{},
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create course: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Create lessons
|
||||
for i := range lessons {
|
||||
lessons[i].CourseID = course.ID
|
||||
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create lesson: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
course.Lessons = lessons
|
||||
|
||||
// 8. Link training module to academy course
|
||||
if err := h.trainingStore.SetAcademyCourseID(c.Request.Context(), moduleID, course.ID); err != nil {
|
||||
// Non-fatal: course is created, just not linked
|
||||
fmt.Printf("Warning: failed to link training module %s to academy course %s: %v\n", moduleID, course.ID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"course": course})
|
||||
}
|
||||
|
||||
// GenerateAllCourses creates academy courses for all training modules that don't have one yet
|
||||
// POST /sdk/v1/academy/courses/generate-all
|
||||
func (h *AcademyHandlers) GenerateAllCourses(c *gin.Context) {
|
||||
if h.trainingStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
// Get all training modules
|
||||
modules, _, err := h.trainingStore.ListModules(c.Request.Context(), tenantID, &training.ModuleFilters{Limit: 100})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
generated := 0
|
||||
skipped := 0
|
||||
var errors []string
|
||||
|
||||
for _, module := range modules {
|
||||
// Skip if already linked
|
||||
if module.AcademyCourseID != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Get content and quiz
|
||||
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), module.ID)
|
||||
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), module.ID)
|
||||
|
||||
category, ok := regulationToCategory[module.RegulationArea]
|
||||
if !ok {
|
||||
category = academy.CourseCategoryCustom
|
||||
}
|
||||
|
||||
lessons := buildModuleLessons(module, content, quizQuestions)
|
||||
|
||||
course := &academy.Course{
|
||||
TenantID: tenantID,
|
||||
Title: module.Title,
|
||||
Description: module.Description,
|
||||
Category: category,
|
||||
DurationMinutes: module.DurationMinutes,
|
||||
RequiredForRoles: []string{},
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range lessons {
|
||||
lessons[i].CourseID = course.ID
|
||||
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s lesson: %v", module.ModuleCode, err))
|
||||
}
|
||||
}
|
||||
|
||||
_ = h.trainingStore.SetAcademyCourseID(c.Request.Context(), module.ID, course.ID)
|
||||
generated++
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"generated": generated,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
"total": len(modules),
|
||||
})
|
||||
}
|
||||
|
||||
// buildModuleLessons constructs academy lessons from a training module, its content, and quiz questions.
|
||||
func buildModuleLessons(module training.TrainingModule, content *training.ModuleContent, quizQuestions []training.QuizQuestion) []academy.Lesson {
|
||||
var lessons []academy.Lesson
|
||||
orderIdx := 0
|
||||
|
||||
// Lesson 1: Text content (if generated)
|
||||
if content != nil && content.ContentBody != "" {
|
||||
lessons = append(lessons, academy.Lesson{
|
||||
Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title),
|
||||
Description: content.Summary,
|
||||
LessonType: academy.LessonTypeText,
|
||||
ContentURL: content.ContentBody,
|
||||
DurationMinutes: estimateReadingTime(content.ContentBody),
|
||||
OrderIndex: orderIdx,
|
||||
})
|
||||
orderIdx++
|
||||
}
|
||||
|
||||
// Lesson 2: Quiz (if questions exist)
|
||||
if len(quizQuestions) > 0 {
|
||||
var academyQuiz []academy.QuizQuestion
|
||||
for _, q := range quizQuestions {
|
||||
academyQuiz = append(academyQuiz, academy.QuizQuestion{
|
||||
Question: q.Question,
|
||||
Options: q.Options,
|
||||
CorrectIndex: q.CorrectIndex,
|
||||
Explanation: q.Explanation,
|
||||
})
|
||||
}
|
||||
lessons = append(lessons, academy.Lesson{
|
||||
Title: fmt.Sprintf("%s - Quiz", module.Title),
|
||||
Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)),
|
||||
LessonType: academy.LessonTypeQuiz,
|
||||
DurationMinutes: len(quizQuestions) * 2, // ~2 min per question
|
||||
OrderIndex: orderIdx,
|
||||
QuizQuestions: academyQuiz,
|
||||
})
|
||||
}
|
||||
|
||||
// If no content or quiz exists, create a placeholder
|
||||
if len(lessons) == 0 {
|
||||
lessons = append(lessons, academy.Lesson{
|
||||
Title: module.Title,
|
||||
Description: module.Description,
|
||||
LessonType: academy.LessonTypeText,
|
||||
ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description),
|
||||
DurationMinutes: module.DurationMinutes,
|
||||
OrderIndex: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return lessons
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
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"
|
||||
@@ -218,822 +216,6 @@ func (h *AcademyHandlers) DeleteCourse(c *gin.Context) {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
// GetSessionStats returns statistics for a session
|
||||
// GET /sdk/v1/workshops/:id/stats
|
||||
func (h *WorkshopHandlers) GetSessionStats(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.store.GetSessionStats(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetSessionSummary returns a complete summary of a session
|
||||
// GET /sdk/v1/workshops/:id/summary
|
||||
func (h *WorkshopHandlers) GetSessionSummary(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.store.GetSessionSummary(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if summary == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export
|
||||
// ============================================================================
|
||||
|
||||
// ExportSession exports session data
|
||||
// GET /sdk/v1/workshops/:id/export
|
||||
func (h *WorkshopHandlers) ExportSession(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
format := c.DefaultQuery("format", "json")
|
||||
|
||||
// Get complete session data
|
||||
summary, err := h.store.GetSessionSummary(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if summary == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get all responses
|
||||
responses, _ := h.store.GetResponses(c.Request.Context(), sessionID, nil)
|
||||
|
||||
// Get all comments
|
||||
comments, _ := h.store.GetComments(c.Request.Context(), sessionID, nil)
|
||||
|
||||
// Get stats
|
||||
stats, _ := h.store.GetSessionStats(c.Request.Context(), sessionID)
|
||||
|
||||
exportData := gin.H{
|
||||
"session": summary.Session,
|
||||
"participants": summary.Participants,
|
||||
"step_progress": summary.StepProgress,
|
||||
"responses": responses,
|
||||
"comments": comments,
|
||||
"stats": stats,
|
||||
"exported_at": time.Now().UTC(),
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, exportData)
|
||||
case "md":
|
||||
// Generate markdown format
|
||||
md := generateSessionMarkdown(summary, responses, comments, stats)
|
||||
c.Header("Content-Type", "text/markdown")
|
||||
c.Header("Content-Disposition", "attachment; filename=workshop-session.md")
|
||||
c.String(http.StatusOK, md)
|
||||
default:
|
||||
c.JSON(http.StatusOK, exportData)
|
||||
}
|
||||
}
|
||||
|
||||
// generateSessionMarkdown generates a markdown export of the session
|
||||
func generateSessionMarkdown(summary *workshop.SessionSummary, responses []workshop.Response, comments []workshop.Comment, stats *workshop.SessionStats) string {
|
||||
md := "# Workshop Session: " + summary.Session.Title + "\n\n"
|
||||
md += "**Type:** " + summary.Session.SessionType + "\n"
|
||||
md += "**Status:** " + string(summary.Session.Status) + "\n"
|
||||
md += "**Created:** " + summary.Session.CreatedAt.Format("2006-01-02 15:04") + "\n\n"
|
||||
|
||||
if summary.Session.Description != "" {
|
||||
md += "## Description\n\n" + summary.Session.Description + "\n\n"
|
||||
}
|
||||
|
||||
// Participants
|
||||
md += "## Participants\n\n"
|
||||
for _, p := range summary.Participants {
|
||||
md += "- **" + p.Name + "** (" + string(p.Role) + ")"
|
||||
if p.Department != "" {
|
||||
md += " - " + p.Department
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
md += "\n"
|
||||
|
||||
// Progress
|
||||
md += "## Progress\n\n"
|
||||
md += "**Overall:** " + strconv.Itoa(summary.OverallProgress) + "%\n"
|
||||
md += "**Completed Steps:** " + strconv.Itoa(summary.CompletedSteps) + "/" + strconv.Itoa(summary.Session.TotalSteps) + "\n"
|
||||
md += "**Total Responses:** " + strconv.Itoa(summary.TotalResponses) + "\n\n"
|
||||
|
||||
// Step progress
|
||||
if len(summary.StepProgress) > 0 {
|
||||
md += "### Step Progress\n\n"
|
||||
for _, sp := range summary.StepProgress {
|
||||
md += "- Step " + strconv.Itoa(sp.StepNumber) + ": " + sp.Status + " (" + strconv.Itoa(sp.Progress) + "%)\n"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
|
||||
// Responses by step
|
||||
if len(responses) > 0 {
|
||||
md += "## Responses\n\n"
|
||||
currentStep := 0
|
||||
for _, r := range responses {
|
||||
if r.StepNumber != currentStep {
|
||||
currentStep = r.StepNumber
|
||||
md += "### Step " + strconv.Itoa(currentStep) + "\n\n"
|
||||
}
|
||||
md += "- **" + r.FieldID + ":** "
|
||||
switch v := r.Value.(type) {
|
||||
case string:
|
||||
md += v
|
||||
case bool:
|
||||
if v {
|
||||
md += "Yes"
|
||||
} else {
|
||||
md += "No"
|
||||
}
|
||||
default:
|
||||
md += "See JSON export for complex value"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
|
||||
// Comments
|
||||
if len(comments) > 0 {
|
||||
md += "## Comments\n\n"
|
||||
for _, c := range comments {
|
||||
md += "- " + c.Text
|
||||
if c.StepNumber != nil {
|
||||
md += " (Step " + strconv.Itoa(*c.StepNumber) + ")"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
|
||||
md += "---\n*Exported from AI Compliance SDK Workshop Module*\n"
|
||||
|
||||
return md
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
@@ -291,633 +290,3 @@ func (h *WorkshopHandlers) CompleteSession(c *gin.Context) {
|
||||
"summary": summary,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Participant Management
|
||||
// ============================================================================
|
||||
|
||||
// JoinSession allows a participant to join a session
|
||||
// POST /sdk/v1/workshops/join/:code
|
||||
func (h *WorkshopHandlers) JoinSession(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
|
||||
session, err := h.store.GetSessionByJoinCode(c.Request.Context(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.Status == workshop.SessionStatusCompleted || session.Status == workshop.SessionStatusCancelled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session is no longer active"})
|
||||
return
|
||||
}
|
||||
|
||||
var req workshop.JoinSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID if authenticated
|
||||
var userID *uuid.UUID
|
||||
if id := rbac.GetUserID(c); id != uuid.Nil {
|
||||
userID = &id
|
||||
}
|
||||
|
||||
// Check if authentication is required
|
||||
if session.RequireAuth && userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required to join this session"})
|
||||
return
|
||||
}
|
||||
|
||||
participant := &workshop.Participant{
|
||||
SessionID: session.ID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Role: req.Role,
|
||||
Department: req.Department,
|
||||
CanEdit: true,
|
||||
CanComment: true,
|
||||
}
|
||||
|
||||
if participant.Role == "" {
|
||||
participant.Role = workshop.ParticipantRoleStakeholder
|
||||
}
|
||||
|
||||
if err := h.store.AddParticipant(c.Request.Context(), participant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, workshop.JoinSessionResponse{
|
||||
Participant: *participant,
|
||||
Session: *session,
|
||||
})
|
||||
}
|
||||
|
||||
// ListParticipants lists participants in a session
|
||||
// GET /sdk/v1/workshops/:id/participants
|
||||
func (h *WorkshopHandlers) ListParticipants(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
participants, err := h.store.ListParticipants(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"participants": participants,
|
||||
"total": len(participants),
|
||||
})
|
||||
}
|
||||
|
||||
// LeaveSession removes a participant from a session
|
||||
// POST /sdk/v1/workshops/:id/leave
|
||||
func (h *WorkshopHandlers) LeaveSession(c *gin.Context) {
|
||||
var req struct {
|
||||
ParticipantID uuid.UUID `json:"participant_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.LeaveSession(c.Request.Context(), req.ParticipantID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "left session"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wizard Navigation & Responses
|
||||
// ============================================================================
|
||||
|
||||
// SubmitResponse submits a response to a question
|
||||
// POST /sdk/v1/workshops/:id/responses
|
||||
func (h *WorkshopHandlers) SubmitResponse(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req workshop.SubmitResponseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get participant ID from request or context
|
||||
participantID, err := uuid.Parse(c.GetHeader("X-Participant-ID"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "participant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine value type
|
||||
valueType := "string"
|
||||
switch req.Value.(type) {
|
||||
case bool:
|
||||
valueType = "boolean"
|
||||
case float64:
|
||||
valueType = "number"
|
||||
case []interface{}:
|
||||
valueType = "array"
|
||||
case map[string]interface{}:
|
||||
valueType = "object"
|
||||
}
|
||||
|
||||
response := &workshop.Response{
|
||||
SessionID: sessionID,
|
||||
ParticipantID: participantID,
|
||||
StepNumber: req.StepNumber,
|
||||
FieldID: req.FieldID,
|
||||
Value: req.Value,
|
||||
ValueType: valueType,
|
||||
Status: workshop.ResponseStatusSubmitted,
|
||||
}
|
||||
|
||||
if err := h.store.SaveResponse(c.Request.Context(), response); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update participant activity
|
||||
h.store.UpdateParticipantActivity(c.Request.Context(), participantID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"response": response})
|
||||
}
|
||||
|
||||
// GetResponses retrieves responses for a session
|
||||
// GET /sdk/v1/workshops/:id/responses
|
||||
func (h *WorkshopHandlers) GetResponses(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var stepNumber *int
|
||||
if step := c.Query("step"); step != "" {
|
||||
if s, err := strconv.Atoi(step); err == nil {
|
||||
stepNumber = &s
|
||||
}
|
||||
}
|
||||
|
||||
responses, err := h.store.GetResponses(c.Request.Context(), sessionID, stepNumber)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"responses": responses,
|
||||
"total": len(responses),
|
||||
})
|
||||
}
|
||||
|
||||
// AdvanceStep moves the session to the next step
|
||||
// POST /sdk/v1/workshops/:id/advance
|
||||
func (h *WorkshopHandlers) AdvanceStep(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetSession(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.CurrentStep >= session.TotalSteps {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "already at last step"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark current step as completed
|
||||
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep, "completed", 100)
|
||||
|
||||
// Advance to next step
|
||||
if err := h.store.AdvanceStep(c.Request.Context(), sessionID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize next step
|
||||
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep+1, "in_progress", 0)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"previous_step": session.CurrentStep,
|
||||
"current_step": session.CurrentStep + 1,
|
||||
"message": "advanced to next step",
|
||||
})
|
||||
}
|
||||
|
||||
// GoToStep navigates to a specific step (if allowed)
|
||||
// POST /sdk/v1/workshops/:id/goto
|
||||
func (h *WorkshopHandlers) GoToStep(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
StepNumber int `json:"step_number"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetSession(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if back navigation is allowed
|
||||
if req.StepNumber < session.CurrentStep && !session.Settings.AllowBackNavigation {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "back navigation not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate step number
|
||||
if req.StepNumber < 1 || req.StepNumber > session.TotalSteps {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid step number"})
|
||||
return
|
||||
}
|
||||
|
||||
session.CurrentStep = req.StepNumber
|
||||
if err := h.store.UpdateSession(c.Request.Context(), session); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"current_step": req.StepNumber,
|
||||
"message": "navigated to step",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
// GetSessionStats returns statistics for a session
|
||||
// GET /sdk/v1/workshops/:id/stats
|
||||
func (h *WorkshopHandlers) GetSessionStats(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.store.GetSessionStats(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetSessionSummary returns a complete summary of a session
|
||||
// GET /sdk/v1/workshops/:id/summary
|
||||
func (h *WorkshopHandlers) GetSessionSummary(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.store.GetSessionSummary(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if summary == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Participant Management (Extended)
|
||||
// ============================================================================
|
||||
|
||||
// UpdateParticipant updates a participant's info
|
||||
// PUT /sdk/v1/workshops/:id/participants/:participantId
|
||||
func (h *WorkshopHandlers) UpdateParticipant(c *gin.Context) {
|
||||
participantID, err := uuid.Parse(c.Param("participantId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Role workshop.ParticipantRole `json:"role"`
|
||||
Department string `json:"department"`
|
||||
CanEdit *bool `json:"can_edit,omitempty"`
|
||||
CanComment *bool `json:"can_comment,omitempty"`
|
||||
CanApprove *bool `json:"can_approve,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
participant, err := h.store.GetParticipant(c.Request.Context(), participantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if participant == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
participant.Name = req.Name
|
||||
}
|
||||
if req.Role != "" {
|
||||
participant.Role = req.Role
|
||||
}
|
||||
if req.Department != "" {
|
||||
participant.Department = req.Department
|
||||
}
|
||||
if req.CanEdit != nil {
|
||||
participant.CanEdit = *req.CanEdit
|
||||
}
|
||||
if req.CanComment != nil {
|
||||
participant.CanComment = *req.CanComment
|
||||
}
|
||||
if req.CanApprove != nil {
|
||||
participant.CanApprove = *req.CanApprove
|
||||
}
|
||||
|
||||
if err := h.store.UpdateParticipant(c.Request.Context(), participant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"participant": participant})
|
||||
}
|
||||
|
||||
// RemoveParticipant removes a participant from a session
|
||||
// DELETE /sdk/v1/workshops/:id/participants/:participantId
|
||||
func (h *WorkshopHandlers) RemoveParticipant(c *gin.Context) {
|
||||
participantID, err := uuid.Parse(c.Param("participantId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.LeaveSession(c.Request.Context(), participantID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "participant removed"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Comments
|
||||
// ============================================================================
|
||||
|
||||
// AddComment adds a comment to a session
|
||||
// POST /sdk/v1/workshops/:id/comments
|
||||
func (h *WorkshopHandlers) AddComment(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ParticipantID uuid.UUID `json:"participant_id"`
|
||||
StepNumber *int `json:"step_number,omitempty"`
|
||||
FieldID *string `json:"field_id,omitempty"`
|
||||
ResponseID *uuid.UUID `json:"response_id,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Text == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "comment text is required"})
|
||||
return
|
||||
}
|
||||
|
||||
comment := &workshop.Comment{
|
||||
SessionID: sessionID,
|
||||
ParticipantID: req.ParticipantID,
|
||||
StepNumber: req.StepNumber,
|
||||
FieldID: req.FieldID,
|
||||
ResponseID: req.ResponseID,
|
||||
Text: req.Text,
|
||||
}
|
||||
|
||||
if err := h.store.AddComment(c.Request.Context(), comment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"comment": comment})
|
||||
}
|
||||
|
||||
// GetComments retrieves comments for a session
|
||||
// GET /sdk/v1/workshops/:id/comments
|
||||
func (h *WorkshopHandlers) GetComments(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var stepNumber *int
|
||||
if step := c.Query("step"); step != "" {
|
||||
if s, err := strconv.Atoi(step); err == nil {
|
||||
stepNumber = &s
|
||||
}
|
||||
}
|
||||
|
||||
comments, err := h.store.GetComments(c.Request.Context(), sessionID, stepNumber)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": comments,
|
||||
"total": len(comments),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export
|
||||
// ============================================================================
|
||||
|
||||
// ExportSession exports session data
|
||||
// GET /sdk/v1/workshops/:id/export
|
||||
func (h *WorkshopHandlers) ExportSession(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
format := c.DefaultQuery("format", "json")
|
||||
|
||||
// Get complete session data
|
||||
summary, err := h.store.GetSessionSummary(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if summary == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get all responses
|
||||
responses, _ := h.store.GetResponses(c.Request.Context(), sessionID, nil)
|
||||
|
||||
// Get all comments
|
||||
comments, _ := h.store.GetComments(c.Request.Context(), sessionID, nil)
|
||||
|
||||
// Get stats
|
||||
stats, _ := h.store.GetSessionStats(c.Request.Context(), sessionID)
|
||||
|
||||
exportData := gin.H{
|
||||
"session": summary.Session,
|
||||
"participants": summary.Participants,
|
||||
"step_progress": summary.StepProgress,
|
||||
"responses": responses,
|
||||
"comments": comments,
|
||||
"stats": stats,
|
||||
"exported_at": time.Now().UTC(),
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, exportData)
|
||||
case "md":
|
||||
// Generate markdown format
|
||||
md := generateSessionMarkdown(summary, responses, comments, stats)
|
||||
c.Header("Content-Type", "text/markdown")
|
||||
c.Header("Content-Disposition", "attachment; filename=workshop-session.md")
|
||||
c.String(http.StatusOK, md)
|
||||
default:
|
||||
c.JSON(http.StatusOK, exportData)
|
||||
}
|
||||
}
|
||||
|
||||
// generateSessionMarkdown generates a markdown export of the session
|
||||
func generateSessionMarkdown(summary *workshop.SessionSummary, responses []workshop.Response, comments []workshop.Comment, stats *workshop.SessionStats) string {
|
||||
md := "# Workshop Session: " + summary.Session.Title + "\n\n"
|
||||
md += "**Type:** " + summary.Session.SessionType + "\n"
|
||||
md += "**Status:** " + string(summary.Session.Status) + "\n"
|
||||
md += "**Created:** " + summary.Session.CreatedAt.Format("2006-01-02 15:04") + "\n\n"
|
||||
|
||||
if summary.Session.Description != "" {
|
||||
md += "## Description\n\n" + summary.Session.Description + "\n\n"
|
||||
}
|
||||
|
||||
// Participants
|
||||
md += "## Participants\n\n"
|
||||
for _, p := range summary.Participants {
|
||||
md += "- **" + p.Name + "** (" + string(p.Role) + ")"
|
||||
if p.Department != "" {
|
||||
md += " - " + p.Department
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
md += "\n"
|
||||
|
||||
// Progress
|
||||
md += "## Progress\n\n"
|
||||
md += "**Overall:** " + strconv.Itoa(summary.OverallProgress) + "%\n"
|
||||
md += "**Completed Steps:** " + strconv.Itoa(summary.CompletedSteps) + "/" + strconv.Itoa(summary.Session.TotalSteps) + "\n"
|
||||
md += "**Total Responses:** " + strconv.Itoa(summary.TotalResponses) + "\n\n"
|
||||
|
||||
// Step progress
|
||||
if len(summary.StepProgress) > 0 {
|
||||
md += "### Step Progress\n\n"
|
||||
for _, sp := range summary.StepProgress {
|
||||
md += "- Step " + strconv.Itoa(sp.StepNumber) + ": " + sp.Status + " (" + strconv.Itoa(sp.Progress) + "%)\n"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
|
||||
// Responses by step
|
||||
if len(responses) > 0 {
|
||||
md += "## Responses\n\n"
|
||||
currentStep := 0
|
||||
for _, r := range responses {
|
||||
if r.StepNumber != currentStep {
|
||||
currentStep = r.StepNumber
|
||||
md += "### Step " + strconv.Itoa(currentStep) + "\n\n"
|
||||
}
|
||||
md += "- **" + r.FieldID + ":** "
|
||||
switch v := r.Value.(type) {
|
||||
case string:
|
||||
md += v
|
||||
case bool:
|
||||
if v {
|
||||
md += "Yes"
|
||||
} else {
|
||||
md += "No"
|
||||
}
|
||||
default:
|
||||
md += "See JSON export for complex value"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
|
||||
// Comments
|
||||
if len(comments) > 0 {
|
||||
md += "## Comments\n\n"
|
||||
for _, c := range comments {
|
||||
md += "- " + c.Text
|
||||
if c.StepNumber != nil {
|
||||
md += " (Step " + strconv.Itoa(*c.StepNumber) + ")"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
md += "\n"
|
||||
}
|
||||
|
||||
md += "---\n*Exported from AI Compliance SDK Workshop Module*\n"
|
||||
|
||||
return md
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Participant Management
|
||||
// ============================================================================
|
||||
|
||||
// JoinSession allows a participant to join a session
|
||||
// POST /sdk/v1/workshops/join/:code
|
||||
func (h *WorkshopHandlers) JoinSession(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
|
||||
session, err := h.store.GetSessionByJoinCode(c.Request.Context(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.Status == workshop.SessionStatusCompleted || session.Status == workshop.SessionStatusCancelled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session is no longer active"})
|
||||
return
|
||||
}
|
||||
|
||||
var req workshop.JoinSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID if authenticated
|
||||
var userID *uuid.UUID
|
||||
if id := rbac.GetUserID(c); id != uuid.Nil {
|
||||
userID = &id
|
||||
}
|
||||
|
||||
// Check if authentication is required
|
||||
if session.RequireAuth && userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required to join this session"})
|
||||
return
|
||||
}
|
||||
|
||||
participant := &workshop.Participant{
|
||||
SessionID: session.ID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Role: req.Role,
|
||||
Department: req.Department,
|
||||
CanEdit: true,
|
||||
CanComment: true,
|
||||
}
|
||||
|
||||
if participant.Role == "" {
|
||||
participant.Role = workshop.ParticipantRoleStakeholder
|
||||
}
|
||||
|
||||
if err := h.store.AddParticipant(c.Request.Context(), participant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, workshop.JoinSessionResponse{
|
||||
Participant: *participant,
|
||||
Session: *session,
|
||||
})
|
||||
}
|
||||
|
||||
// ListParticipants lists participants in a session
|
||||
// GET /sdk/v1/workshops/:id/participants
|
||||
func (h *WorkshopHandlers) ListParticipants(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
participants, err := h.store.ListParticipants(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"participants": participants,
|
||||
"total": len(participants),
|
||||
})
|
||||
}
|
||||
|
||||
// LeaveSession removes a participant from a session
|
||||
// POST /sdk/v1/workshops/:id/leave
|
||||
func (h *WorkshopHandlers) LeaveSession(c *gin.Context) {
|
||||
var req struct {
|
||||
ParticipantID uuid.UUID `json:"participant_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.LeaveSession(c.Request.Context(), req.ParticipantID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "left session"})
|
||||
}
|
||||
|
||||
// UpdateParticipant updates a participant's info
|
||||
// PUT /sdk/v1/workshops/:id/participants/:participantId
|
||||
func (h *WorkshopHandlers) UpdateParticipant(c *gin.Context) {
|
||||
participantID, err := uuid.Parse(c.Param("participantId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Role workshop.ParticipantRole `json:"role"`
|
||||
Department string `json:"department"`
|
||||
CanEdit *bool `json:"can_edit,omitempty"`
|
||||
CanComment *bool `json:"can_comment,omitempty"`
|
||||
CanApprove *bool `json:"can_approve,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
participant, err := h.store.GetParticipant(c.Request.Context(), participantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if participant == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
participant.Name = req.Name
|
||||
}
|
||||
if req.Role != "" {
|
||||
participant.Role = req.Role
|
||||
}
|
||||
if req.Department != "" {
|
||||
participant.Department = req.Department
|
||||
}
|
||||
if req.CanEdit != nil {
|
||||
participant.CanEdit = *req.CanEdit
|
||||
}
|
||||
if req.CanComment != nil {
|
||||
participant.CanComment = *req.CanComment
|
||||
}
|
||||
if req.CanApprove != nil {
|
||||
participant.CanApprove = *req.CanApprove
|
||||
}
|
||||
|
||||
if err := h.store.UpdateParticipant(c.Request.Context(), participant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"participant": participant})
|
||||
}
|
||||
|
||||
// RemoveParticipant removes a participant from a session
|
||||
// DELETE /sdk/v1/workshops/:id/participants/:participantId
|
||||
func (h *WorkshopHandlers) RemoveParticipant(c *gin.Context) {
|
||||
participantID, err := uuid.Parse(c.Param("participantId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.LeaveSession(c.Request.Context(), participantID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "participant removed"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wizard Navigation & Responses
|
||||
// ============================================================================
|
||||
|
||||
// SubmitResponse submits a response to a question
|
||||
// POST /sdk/v1/workshops/:id/responses
|
||||
func (h *WorkshopHandlers) SubmitResponse(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req workshop.SubmitResponseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get participant ID from request or context
|
||||
participantID, err := uuid.Parse(c.GetHeader("X-Participant-ID"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "participant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine value type
|
||||
valueType := "string"
|
||||
switch req.Value.(type) {
|
||||
case bool:
|
||||
valueType = "boolean"
|
||||
case float64:
|
||||
valueType = "number"
|
||||
case []interface{}:
|
||||
valueType = "array"
|
||||
case map[string]interface{}:
|
||||
valueType = "object"
|
||||
}
|
||||
|
||||
response := &workshop.Response{
|
||||
SessionID: sessionID,
|
||||
ParticipantID: participantID,
|
||||
StepNumber: req.StepNumber,
|
||||
FieldID: req.FieldID,
|
||||
Value: req.Value,
|
||||
ValueType: valueType,
|
||||
Status: workshop.ResponseStatusSubmitted,
|
||||
}
|
||||
|
||||
if err := h.store.SaveResponse(c.Request.Context(), response); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update participant activity
|
||||
h.store.UpdateParticipantActivity(c.Request.Context(), participantID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"response": response})
|
||||
}
|
||||
|
||||
// GetResponses retrieves responses for a session
|
||||
// GET /sdk/v1/workshops/:id/responses
|
||||
func (h *WorkshopHandlers) GetResponses(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var stepNumber *int
|
||||
if step := c.Query("step"); step != "" {
|
||||
if s, err := strconv.Atoi(step); err == nil {
|
||||
stepNumber = &s
|
||||
}
|
||||
}
|
||||
|
||||
responses, err := h.store.GetResponses(c.Request.Context(), sessionID, stepNumber)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"responses": responses,
|
||||
"total": len(responses),
|
||||
})
|
||||
}
|
||||
|
||||
// AdvanceStep moves the session to the next step
|
||||
// POST /sdk/v1/workshops/:id/advance
|
||||
func (h *WorkshopHandlers) AdvanceStep(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetSession(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.CurrentStep >= session.TotalSteps {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "already at last step"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark current step as completed
|
||||
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep, "completed", 100)
|
||||
|
||||
// Advance to next step
|
||||
if err := h.store.AdvanceStep(c.Request.Context(), sessionID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize next step
|
||||
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep+1, "in_progress", 0)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"previous_step": session.CurrentStep,
|
||||
"current_step": session.CurrentStep + 1,
|
||||
"message": "advanced to next step",
|
||||
})
|
||||
}
|
||||
|
||||
// GoToStep navigates to a specific step (if allowed)
|
||||
// POST /sdk/v1/workshops/:id/goto
|
||||
func (h *WorkshopHandlers) GoToStep(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
StepNumber int `json:"step_number"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetSession(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if back navigation is allowed
|
||||
if req.StepNumber < session.CurrentStep && !session.Settings.AllowBackNavigation {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "back navigation not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate step number
|
||||
if req.StepNumber < 1 || req.StepNumber > session.TotalSteps {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid step number"})
|
||||
return
|
||||
}
|
||||
|
||||
session.CurrentStep = req.StepNumber
|
||||
if err := h.store.UpdateSession(c.Request.Context(), session); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"current_step": req.StepNumber,
|
||||
"message": "navigated to step",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Comments
|
||||
// ============================================================================
|
||||
|
||||
// AddComment adds a comment to a session
|
||||
// POST /sdk/v1/workshops/:id/comments
|
||||
func (h *WorkshopHandlers) AddComment(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ParticipantID uuid.UUID `json:"participant_id"`
|
||||
StepNumber *int `json:"step_number,omitempty"`
|
||||
FieldID *string `json:"field_id,omitempty"`
|
||||
ResponseID *uuid.UUID `json:"response_id,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Text == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "comment text is required"})
|
||||
return
|
||||
}
|
||||
|
||||
comment := &workshop.Comment{
|
||||
SessionID: sessionID,
|
||||
ParticipantID: req.ParticipantID,
|
||||
StepNumber: req.StepNumber,
|
||||
FieldID: req.FieldID,
|
||||
ResponseID: req.ResponseID,
|
||||
Text: req.Text,
|
||||
}
|
||||
|
||||
if err := h.store.AddComment(c.Request.Context(), comment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"comment": comment})
|
||||
}
|
||||
|
||||
// GetComments retrieves comments for a session
|
||||
// GET /sdk/v1/workshops/:id/comments
|
||||
func (h *WorkshopHandlers) GetComments(c *gin.Context) {
|
||||
sessionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var stepNumber *int
|
||||
if step := c.Query("step"); step != "" {
|
||||
if s, err := strconv.Atoi(step); err == nil {
|
||||
stepNumber = &s
|
||||
}
|
||||
}
|
||||
|
||||
comments, err := h.store.GetComments(c.Request.Context(), sessionID, stepNumber)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": comments,
|
||||
"total": len(comments),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user