feat: Implement Compliance Academy E-Learning module (Phases 1-7)
Some checks failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled

Add complete Academy backend (Go) and frontend (Next.js) for DSGVO/IT-Security/AI-Literacy compliance training:
- Go backend: Course CRUD, enrollments, quiz evaluation, PDF certificates (gofpdf), video generation pipeline (ElevenLabs + HeyGen)
- In-memory data store with PostgreSQL migration for future DB support
- Frontend: Course creation (AI + manual), lesson viewer, interactive quiz, certificate viewer with PDF download
- Fix existing compile errors in generate.go (SearchResult type mismatch), llm/service.go (unused var), rag/service.go (Unicode chars)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BreakPilot Dev
2026-02-14 21:18:51 +01:00
parent 71cde313d5
commit ac1bb1d97b
19 changed files with 4070 additions and 22 deletions

View File

@@ -0,0 +1,681 @@
package db
import (
"fmt"
"sort"
"strings"
"sync"
"time"
)
// AcademyMemStore provides in-memory storage for academy data
type AcademyMemStore struct {
mu sync.RWMutex
courses map[string]*AcademyCourseRow
lessons map[string]*AcademyLessonRow
quizQuestions map[string]*AcademyQuizQuestionRow
enrollments map[string]*AcademyEnrollmentRow
certificates map[string]*AcademyCertificateRow
lessonProgress map[string]*AcademyLessonProgressRow
}
// Row types matching the DB schema
type AcademyCourseRow struct {
ID string
TenantID string
Title string
Description string
Category string
PassingScore int
DurationMinutes int
RequiredForRoles []string
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
type AcademyLessonRow struct {
ID string
CourseID string
Title string
Type string
ContentMarkdown string
VideoURL string
AudioURL string
SortOrder int
DurationMinutes int
CreatedAt time.Time
UpdatedAt time.Time
}
type AcademyQuizQuestionRow struct {
ID string
LessonID string
Question string
Options []string
CorrectOptionIndex int
Explanation string
SortOrder int
CreatedAt time.Time
}
type AcademyEnrollmentRow struct {
ID string
TenantID string
CourseID string
UserID string
UserName string
UserEmail string
Status string
Progress int
StartedAt time.Time
CompletedAt *time.Time
CertificateID string
Deadline time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type AcademyCertificateRow struct {
ID string
TenantID string
EnrollmentID string
CourseID string
UserID string
UserName string
CourseName string
Score int
IssuedAt time.Time
ValidUntil time.Time
PdfURL string
}
type AcademyLessonProgressRow struct {
ID string
EnrollmentID string
LessonID string
Completed bool
QuizScore *int
CompletedAt *time.Time
}
type AcademyStatisticsRow struct {
TotalCourses int
TotalEnrollments int
CompletionRate float64
OverdueCount int
ByCategory map[string]int
ByStatus map[string]int
}
func NewAcademyMemStore() *AcademyMemStore {
return &AcademyMemStore{
courses: make(map[string]*AcademyCourseRow),
lessons: make(map[string]*AcademyLessonRow),
quizQuestions: make(map[string]*AcademyQuizQuestionRow),
enrollments: make(map[string]*AcademyEnrollmentRow),
certificates: make(map[string]*AcademyCertificateRow),
lessonProgress: make(map[string]*AcademyLessonProgressRow),
}
}
// generateID creates a simple unique ID
func generateID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
// ---------------------------------------------------------------------------
// Course CRUD
// ---------------------------------------------------------------------------
// ListCourses returns all courses for a tenant, sorted by UpdatedAt DESC.
func (s *AcademyMemStore) ListCourses(tenantID string) []*AcademyCourseRow {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*AcademyCourseRow
for _, c := range s.courses {
if c.TenantID == tenantID {
result = append(result, c)
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].UpdatedAt.After(result[j].UpdatedAt)
})
return result
}
// GetCourse retrieves a single course by ID.
func (s *AcademyMemStore) GetCourse(id string) (*AcademyCourseRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
c, ok := s.courses[id]
if !ok {
return nil, fmt.Errorf("course not found: %s", id)
}
return c, nil
}
// CreateCourse inserts a new course with auto-generated ID and timestamps.
func (s *AcademyMemStore) CreateCourse(row *AcademyCourseRow) *AcademyCourseRow {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
row.ID = generateID()
row.CreatedAt = now
row.UpdatedAt = now
s.courses[row.ID] = row
return row
}
// UpdateCourse partially updates a course. Supported keys: Title, Description,
// Category, PassingScore, DurationMinutes, RequiredForRoles, Status.
func (s *AcademyMemStore) UpdateCourse(id string, updates map[string]interface{}) (*AcademyCourseRow, error) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.courses[id]
if !ok {
return nil, fmt.Errorf("course not found: %s", id)
}
for k, v := range updates {
switch strings.ToLower(k) {
case "title":
if val, ok := v.(string); ok {
c.Title = val
}
case "description":
if val, ok := v.(string); ok {
c.Description = val
}
case "category":
if val, ok := v.(string); ok {
c.Category = val
}
case "passingscore", "passing_score":
switch val := v.(type) {
case int:
c.PassingScore = val
case float64:
c.PassingScore = int(val)
}
case "durationminutes", "duration_minutes":
switch val := v.(type) {
case int:
c.DurationMinutes = val
case float64:
c.DurationMinutes = int(val)
}
case "requiredforroles", "required_for_roles":
if val, ok := v.([]string); ok {
c.RequiredForRoles = val
}
case "status":
if val, ok := v.(string); ok {
c.Status = val
}
}
}
c.UpdatedAt = time.Now()
return c, nil
}
// DeleteCourse removes a course and all related lessons, quiz questions,
// enrollments, certificates, and lesson progress.
func (s *AcademyMemStore) DeleteCourse(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.courses[id]; !ok {
return fmt.Errorf("course not found: %s", id)
}
// Collect lesson IDs for this course
lessonIDs := make(map[string]bool)
for lid, l := range s.lessons {
if l.CourseID == id {
lessonIDs[lid] = true
}
}
// Delete quiz questions belonging to those lessons
for qid, q := range s.quizQuestions {
if lessonIDs[q.LessonID] {
delete(s.quizQuestions, qid)
}
}
// Delete lessons
for lid := range lessonIDs {
delete(s.lessons, lid)
}
// Collect enrollment IDs for this course
enrollmentIDs := make(map[string]bool)
for eid, e := range s.enrollments {
if e.CourseID == id {
enrollmentIDs[eid] = true
}
}
// Delete lesson progress belonging to those enrollments
for pid, p := range s.lessonProgress {
if enrollmentIDs[p.EnrollmentID] {
delete(s.lessonProgress, pid)
}
}
// Delete certificates belonging to those enrollments
for cid, cert := range s.certificates {
if cert.CourseID == id {
delete(s.certificates, cid)
}
}
// Delete enrollments
for eid := range enrollmentIDs {
delete(s.enrollments, eid)
}
// Delete the course itself
delete(s.courses, id)
return nil
}
// ---------------------------------------------------------------------------
// Lesson CRUD
// ---------------------------------------------------------------------------
// ListLessons returns all lessons for a course, sorted by SortOrder ASC.
func (s *AcademyMemStore) ListLessons(courseID string) []*AcademyLessonRow {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*AcademyLessonRow
for _, l := range s.lessons {
if l.CourseID == courseID {
result = append(result, l)
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].SortOrder < result[j].SortOrder
})
return result
}
// GetLesson retrieves a single lesson by ID.
func (s *AcademyMemStore) GetLesson(id string) (*AcademyLessonRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
l, ok := s.lessons[id]
if !ok {
return nil, fmt.Errorf("lesson not found: %s", id)
}
return l, nil
}
// CreateLesson inserts a new lesson with auto-generated ID and timestamps.
func (s *AcademyMemStore) CreateLesson(row *AcademyLessonRow) *AcademyLessonRow {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
row.ID = generateID()
row.CreatedAt = now
row.UpdatedAt = now
s.lessons[row.ID] = row
return row
}
// UpdateLesson partially updates a lesson. Supported keys: Title, Type,
// ContentMarkdown, VideoURL, AudioURL, SortOrder, DurationMinutes.
func (s *AcademyMemStore) UpdateLesson(id string, updates map[string]interface{}) (*AcademyLessonRow, error) {
s.mu.Lock()
defer s.mu.Unlock()
l, ok := s.lessons[id]
if !ok {
return nil, fmt.Errorf("lesson not found: %s", id)
}
for k, v := range updates {
switch strings.ToLower(k) {
case "title":
if val, ok := v.(string); ok {
l.Title = val
}
case "type":
if val, ok := v.(string); ok {
l.Type = val
}
case "contentmarkdown", "content_markdown":
if val, ok := v.(string); ok {
l.ContentMarkdown = val
}
case "videourl", "video_url":
if val, ok := v.(string); ok {
l.VideoURL = val
}
case "audiourl", "audio_url":
if val, ok := v.(string); ok {
l.AudioURL = val
}
case "sortorder", "sort_order":
switch val := v.(type) {
case int:
l.SortOrder = val
case float64:
l.SortOrder = int(val)
}
case "durationminutes", "duration_minutes":
switch val := v.(type) {
case int:
l.DurationMinutes = val
case float64:
l.DurationMinutes = int(val)
}
}
}
l.UpdatedAt = time.Now()
return l, nil
}
// DeleteLesson removes a lesson and its quiz questions.
func (s *AcademyMemStore) DeleteLesson(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.lessons[id]; !ok {
return fmt.Errorf("lesson not found: %s", id)
}
// Delete quiz questions belonging to this lesson
for qid, q := range s.quizQuestions {
if q.LessonID == id {
delete(s.quizQuestions, qid)
}
}
delete(s.lessons, id)
return nil
}
// ---------------------------------------------------------------------------
// Quiz Questions
// ---------------------------------------------------------------------------
// ListQuizQuestions returns all quiz questions for a lesson, sorted by SortOrder ASC.
func (s *AcademyMemStore) ListQuizQuestions(lessonID string) []*AcademyQuizQuestionRow {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*AcademyQuizQuestionRow
for _, q := range s.quizQuestions {
if q.LessonID == lessonID {
result = append(result, q)
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].SortOrder < result[j].SortOrder
})
return result
}
// CreateQuizQuestion inserts a new quiz question with auto-generated ID and timestamp.
func (s *AcademyMemStore) CreateQuizQuestion(row *AcademyQuizQuestionRow) *AcademyQuizQuestionRow {
s.mu.Lock()
defer s.mu.Unlock()
row.ID = generateID()
row.CreatedAt = time.Now()
s.quizQuestions[row.ID] = row
return row
}
// ---------------------------------------------------------------------------
// Enrollments
// ---------------------------------------------------------------------------
// ListEnrollments returns enrollments filtered by tenantID and optionally by courseID.
// If courseID is empty, all enrollments for the tenant are returned.
func (s *AcademyMemStore) ListEnrollments(tenantID string, courseID string) []*AcademyEnrollmentRow {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*AcademyEnrollmentRow
for _, e := range s.enrollments {
if e.TenantID != tenantID {
continue
}
if courseID != "" && e.CourseID != courseID {
continue
}
result = append(result, e)
}
sort.Slice(result, func(i, j int) bool {
return result[i].UpdatedAt.After(result[j].UpdatedAt)
})
return result
}
// GetEnrollment retrieves a single enrollment by ID.
func (s *AcademyMemStore) GetEnrollment(id string) (*AcademyEnrollmentRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
e, ok := s.enrollments[id]
if !ok {
return nil, fmt.Errorf("enrollment not found: %s", id)
}
return e, nil
}
// CreateEnrollment inserts a new enrollment with auto-generated ID and timestamps.
func (s *AcademyMemStore) CreateEnrollment(row *AcademyEnrollmentRow) *AcademyEnrollmentRow {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
row.ID = generateID()
row.CreatedAt = now
row.UpdatedAt = now
if row.StartedAt.IsZero() {
row.StartedAt = now
}
s.enrollments[row.ID] = row
return row
}
// UpdateEnrollment partially updates an enrollment. Supported keys: Status,
// Progress, CompletedAt, CertificateID, Deadline.
func (s *AcademyMemStore) UpdateEnrollment(id string, updates map[string]interface{}) (*AcademyEnrollmentRow, error) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.enrollments[id]
if !ok {
return nil, fmt.Errorf("enrollment not found: %s", id)
}
for k, v := range updates {
switch strings.ToLower(k) {
case "status":
if val, ok := v.(string); ok {
e.Status = val
}
case "progress":
switch val := v.(type) {
case int:
e.Progress = val
case float64:
e.Progress = int(val)
}
case "completedat", "completed_at":
if val, ok := v.(*time.Time); ok {
e.CompletedAt = val
} else if val, ok := v.(time.Time); ok {
e.CompletedAt = &val
}
case "certificateid", "certificate_id":
if val, ok := v.(string); ok {
e.CertificateID = val
}
case "deadline":
if val, ok := v.(time.Time); ok {
e.Deadline = val
}
}
}
e.UpdatedAt = time.Now()
return e, nil
}
// ---------------------------------------------------------------------------
// Certificates
// ---------------------------------------------------------------------------
// GetCertificate retrieves a certificate by ID.
func (s *AcademyMemStore) GetCertificate(id string) (*AcademyCertificateRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cert, ok := s.certificates[id]
if !ok {
return nil, fmt.Errorf("certificate not found: %s", id)
}
return cert, nil
}
// GetCertificateByEnrollment retrieves a certificate by enrollment ID.
func (s *AcademyMemStore) GetCertificateByEnrollment(enrollmentID string) (*AcademyCertificateRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, cert := range s.certificates {
if cert.EnrollmentID == enrollmentID {
return cert, nil
}
}
return nil, fmt.Errorf("certificate not found for enrollment: %s", enrollmentID)
}
// CreateCertificate inserts a new certificate with auto-generated ID.
func (s *AcademyMemStore) CreateCertificate(row *AcademyCertificateRow) *AcademyCertificateRow {
s.mu.Lock()
defer s.mu.Unlock()
row.ID = generateID()
if row.IssuedAt.IsZero() {
row.IssuedAt = time.Now()
}
s.certificates[row.ID] = row
return row
}
// ---------------------------------------------------------------------------
// Lesson Progress
// ---------------------------------------------------------------------------
// ListLessonProgress returns all progress entries for an enrollment.
func (s *AcademyMemStore) ListLessonProgress(enrollmentID string) []*AcademyLessonProgressRow {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*AcademyLessonProgressRow
for _, p := range s.lessonProgress {
if p.EnrollmentID == enrollmentID {
result = append(result, p)
}
}
return result
}
// UpsertLessonProgress inserts or updates a lesson progress entry.
// Matching is done by EnrollmentID + LessonID composite key.
func (s *AcademyMemStore) UpsertLessonProgress(row *AcademyLessonProgressRow) *AcademyLessonProgressRow {
s.mu.Lock()
defer s.mu.Unlock()
// Look for existing entry with same enrollment_id + lesson_id
for _, p := range s.lessonProgress {
if p.EnrollmentID == row.EnrollmentID && p.LessonID == row.LessonID {
p.Completed = row.Completed
p.QuizScore = row.QuizScore
p.CompletedAt = row.CompletedAt
return p
}
}
// Insert new entry
row.ID = generateID()
s.lessonProgress[row.ID] = row
return row
}
// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------
// GetStatistics computes aggregate statistics for a tenant.
func (s *AcademyMemStore) GetStatistics(tenantID string) *AcademyStatisticsRow {
s.mu.RLock()
defer s.mu.RUnlock()
stats := &AcademyStatisticsRow{
ByCategory: make(map[string]int),
ByStatus: make(map[string]int),
}
// Count courses by category
for _, c := range s.courses {
if c.TenantID != tenantID {
continue
}
stats.TotalCourses++
if c.Category != "" {
stats.ByCategory[c.Category]++
}
}
// Count enrollments and compute completion rate
var completedCount int
now := time.Now()
for _, e := range s.enrollments {
if e.TenantID != tenantID {
continue
}
stats.TotalEnrollments++
stats.ByStatus[e.Status]++
if e.Status == "completed" {
completedCount++
}
// Overdue: not completed and past deadline
if e.Status != "completed" && !e.Deadline.IsZero() && now.After(e.Deadline) {
stats.OverdueCount++
}
}
if stats.TotalEnrollments > 0 {
stats.CompletionRate = float64(completedCount) / float64(stats.TotalEnrollments) * 100.0
}
return stats
}

View File

@@ -0,0 +1,305 @@
-- Migration: Create Academy Tables
-- Description: Schema for the Compliance Academy module (courses, lessons, quizzes, enrollments, certificates, progress)
-- Enable UUID extension if not already enabled
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================================================
-- 1. academy_courses - Training courses for compliance education
-- ============================================================================
CREATE TABLE IF NOT EXISTS academy_courses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(50),
passing_score INTEGER DEFAULT 70,
duration_minutes INTEGER,
required_for_roles JSONB DEFAULT '["all"]',
status VARCHAR(50) DEFAULT 'draft',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes for academy_courses
CREATE INDEX IF NOT EXISTS idx_academy_courses_tenant ON academy_courses(tenant_id);
CREATE INDEX IF NOT EXISTS idx_academy_courses_status ON academy_courses(status);
CREATE INDEX IF NOT EXISTS idx_academy_courses_category ON academy_courses(category);
-- Auto-update trigger for academy_courses.updated_at
CREATE OR REPLACE FUNCTION update_academy_courses_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_academy_courses_updated_at ON academy_courses;
CREATE TRIGGER trigger_academy_courses_updated_at
BEFORE UPDATE ON academy_courses
FOR EACH ROW
EXECUTE FUNCTION update_academy_courses_updated_at();
-- Comments for academy_courses
COMMENT ON TABLE academy_courses IS 'Stores compliance training courses per tenant';
COMMENT ON COLUMN academy_courses.tenant_id IS 'Identifier for the tenant owning this course';
COMMENT ON COLUMN academy_courses.title IS 'Course title displayed to users';
COMMENT ON COLUMN academy_courses.category IS 'Course category (e.g. dsgvo, ai-act, security)';
COMMENT ON COLUMN academy_courses.passing_score IS 'Minimum score (0-100) required to pass the course';
COMMENT ON COLUMN academy_courses.duration_minutes IS 'Estimated total duration of the course in minutes';
COMMENT ON COLUMN academy_courses.required_for_roles IS 'JSON array of roles required to complete this course';
COMMENT ON COLUMN academy_courses.status IS 'Course status: draft, published, archived';
-- ============================================================================
-- 2. academy_lessons - Individual lessons within a course
-- ============================================================================
CREATE TABLE IF NOT EXISTS academy_lessons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
course_id UUID NOT NULL REFERENCES academy_courses(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL,
content_markdown TEXT,
video_url VARCHAR(500),
audio_url VARCHAR(500),
sort_order INTEGER NOT NULL DEFAULT 0,
duration_minutes INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes for academy_lessons
CREATE INDEX IF NOT EXISTS idx_academy_lessons_course ON academy_lessons(course_id);
CREATE INDEX IF NOT EXISTS idx_academy_lessons_sort ON academy_lessons(course_id, sort_order);
-- Auto-update trigger for academy_lessons.updated_at
CREATE OR REPLACE FUNCTION update_academy_lessons_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_academy_lessons_updated_at ON academy_lessons;
CREATE TRIGGER trigger_academy_lessons_updated_at
BEFORE UPDATE ON academy_lessons
FOR EACH ROW
EXECUTE FUNCTION update_academy_lessons_updated_at();
-- Comments for academy_lessons
COMMENT ON TABLE academy_lessons IS 'Individual lessons belonging to a course';
COMMENT ON COLUMN academy_lessons.course_id IS 'Foreign key to the parent course';
COMMENT ON COLUMN academy_lessons.type IS 'Lesson type: text, video, audio, quiz, interactive';
COMMENT ON COLUMN academy_lessons.content_markdown IS 'Lesson content in Markdown format';
COMMENT ON COLUMN academy_lessons.video_url IS 'URL to video content (if type is video)';
COMMENT ON COLUMN academy_lessons.audio_url IS 'URL to audio content (if type is audio)';
COMMENT ON COLUMN academy_lessons.sort_order IS 'Order of the lesson within the course';
COMMENT ON COLUMN academy_lessons.duration_minutes IS 'Estimated duration of this lesson in minutes';
-- ============================================================================
-- 3. academy_quiz_questions - Quiz questions attached to lessons
-- ============================================================================
CREATE TABLE IF NOT EXISTS academy_quiz_questions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lesson_id UUID NOT NULL REFERENCES academy_lessons(id) ON DELETE CASCADE,
question TEXT NOT NULL,
options JSONB NOT NULL,
correct_option_index INTEGER NOT NULL,
explanation TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes for academy_quiz_questions
CREATE INDEX IF NOT EXISTS idx_academy_quiz_questions_lesson ON academy_quiz_questions(lesson_id);
CREATE INDEX IF NOT EXISTS idx_academy_quiz_questions_sort ON academy_quiz_questions(lesson_id, sort_order);
-- Comments for academy_quiz_questions
COMMENT ON TABLE academy_quiz_questions IS 'Quiz questions belonging to a lesson';
COMMENT ON COLUMN academy_quiz_questions.lesson_id IS 'Foreign key to the parent lesson';
COMMENT ON COLUMN academy_quiz_questions.question IS 'The question text';
COMMENT ON COLUMN academy_quiz_questions.options IS 'JSON array of answer options (strings)';
COMMENT ON COLUMN academy_quiz_questions.correct_option_index IS 'Zero-based index of the correct option';
COMMENT ON COLUMN academy_quiz_questions.explanation IS 'Explanation shown after answering (correct or incorrect)';
COMMENT ON COLUMN academy_quiz_questions.sort_order IS 'Order of the question within the lesson quiz';
-- ============================================================================
-- 4. academy_enrollments - User enrollments in courses
-- ============================================================================
CREATE TABLE IF NOT EXISTS academy_enrollments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id VARCHAR(255) NOT NULL,
course_id UUID NOT NULL REFERENCES academy_courses(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
user_name VARCHAR(255),
user_email VARCHAR(255),
status VARCHAR(20) DEFAULT 'not_started',
progress INTEGER DEFAULT 0,
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
certificate_id UUID,
deadline TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes for academy_enrollments
CREATE INDEX IF NOT EXISTS idx_academy_enrollments_tenant ON academy_enrollments(tenant_id);
CREATE INDEX IF NOT EXISTS idx_academy_enrollments_course ON academy_enrollments(course_id);
CREATE INDEX IF NOT EXISTS idx_academy_enrollments_user ON academy_enrollments(user_id);
CREATE INDEX IF NOT EXISTS idx_academy_enrollments_status ON academy_enrollments(status);
CREATE INDEX IF NOT EXISTS idx_academy_enrollments_tenant_user ON academy_enrollments(tenant_id, user_id);
-- Auto-update trigger for academy_enrollments.updated_at
CREATE OR REPLACE FUNCTION update_academy_enrollments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_academy_enrollments_updated_at ON academy_enrollments;
CREATE TRIGGER trigger_academy_enrollments_updated_at
BEFORE UPDATE ON academy_enrollments
FOR EACH ROW
EXECUTE FUNCTION update_academy_enrollments_updated_at();
-- Comments for academy_enrollments
COMMENT ON TABLE academy_enrollments IS 'Tracks user enrollments and progress in courses';
COMMENT ON COLUMN academy_enrollments.tenant_id IS 'Identifier for the tenant';
COMMENT ON COLUMN academy_enrollments.course_id IS 'Foreign key to the enrolled course';
COMMENT ON COLUMN academy_enrollments.user_id IS 'Identifier of the enrolled user';
COMMENT ON COLUMN academy_enrollments.user_name IS 'Display name of the enrolled user';
COMMENT ON COLUMN academy_enrollments.user_email IS 'Email address of the enrolled user';
COMMENT ON COLUMN academy_enrollments.status IS 'Enrollment status: not_started, in_progress, completed, expired';
COMMENT ON COLUMN academy_enrollments.progress IS 'Completion percentage (0-100)';
COMMENT ON COLUMN academy_enrollments.certificate_id IS 'Reference to issued certificate (if completed)';
COMMENT ON COLUMN academy_enrollments.deadline IS 'Deadline by which the course must be completed';
-- ============================================================================
-- 5. academy_certificates - Certificates issued upon course completion
-- ============================================================================
CREATE TABLE IF NOT EXISTS academy_certificates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id VARCHAR(255) NOT NULL,
enrollment_id UUID NOT NULL UNIQUE REFERENCES academy_enrollments(id) ON DELETE CASCADE,
course_id UUID NOT NULL REFERENCES academy_courses(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
user_name VARCHAR(255),
course_name VARCHAR(255),
score INTEGER,
issued_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
valid_until TIMESTAMP WITH TIME ZONE,
pdf_url VARCHAR(500)
);
-- Indexes for academy_certificates
CREATE INDEX IF NOT EXISTS idx_academy_certificates_tenant ON academy_certificates(tenant_id);
CREATE INDEX IF NOT EXISTS idx_academy_certificates_user ON academy_certificates(user_id);
CREATE INDEX IF NOT EXISTS idx_academy_certificates_course ON academy_certificates(course_id);
CREATE INDEX IF NOT EXISTS idx_academy_certificates_enrollment ON academy_certificates(enrollment_id);
-- Comments for academy_certificates
COMMENT ON TABLE academy_certificates IS 'Certificates issued when a user completes a course';
COMMENT ON COLUMN academy_certificates.tenant_id IS 'Identifier for the tenant';
COMMENT ON COLUMN academy_certificates.enrollment_id IS 'Unique reference to the enrollment (one certificate per enrollment)';
COMMENT ON COLUMN academy_certificates.course_id IS 'Foreign key to the completed course';
COMMENT ON COLUMN academy_certificates.user_id IS 'Identifier of the certified user';
COMMENT ON COLUMN academy_certificates.user_name IS 'Name of the user as printed on the certificate';
COMMENT ON COLUMN academy_certificates.course_name IS 'Name of the course as printed on the certificate';
COMMENT ON COLUMN academy_certificates.score IS 'Final quiz score achieved (0-100)';
COMMENT ON COLUMN academy_certificates.issued_at IS 'Timestamp when the certificate was issued';
COMMENT ON COLUMN academy_certificates.valid_until IS 'Expiry date of the certificate (NULL = no expiry)';
COMMENT ON COLUMN academy_certificates.pdf_url IS 'URL to the generated certificate PDF';
-- ============================================================================
-- 6. academy_lesson_progress - Per-lesson progress tracking
-- ============================================================================
CREATE TABLE IF NOT EXISTS academy_lesson_progress (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
enrollment_id UUID NOT NULL REFERENCES academy_enrollments(id) ON DELETE CASCADE,
lesson_id UUID NOT NULL REFERENCES academy_lessons(id) ON DELETE CASCADE,
completed BOOLEAN DEFAULT false,
quiz_score INTEGER,
completed_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT uq_academy_lesson_progress_enrollment_lesson UNIQUE (enrollment_id, lesson_id)
);
-- Indexes for academy_lesson_progress
CREATE INDEX IF NOT EXISTS idx_academy_lesson_progress_enrollment ON academy_lesson_progress(enrollment_id);
CREATE INDEX IF NOT EXISTS idx_academy_lesson_progress_lesson ON academy_lesson_progress(lesson_id);
-- Comments for academy_lesson_progress
COMMENT ON TABLE academy_lesson_progress IS 'Tracks completion status and quiz scores per lesson per enrollment';
COMMENT ON COLUMN academy_lesson_progress.enrollment_id IS 'Foreign key to the enrollment';
COMMENT ON COLUMN academy_lesson_progress.lesson_id IS 'Foreign key to the lesson';
COMMENT ON COLUMN academy_lesson_progress.completed IS 'Whether the lesson has been completed';
COMMENT ON COLUMN academy_lesson_progress.quiz_score IS 'Quiz score for this lesson (0-100), NULL if no quiz';
COMMENT ON COLUMN academy_lesson_progress.completed_at IS 'Timestamp when the lesson was completed';
-- ============================================================================
-- Helper: Upsert function for lesson progress (ON CONFLICT handling)
-- ============================================================================
CREATE OR REPLACE FUNCTION upsert_academy_lesson_progress(
p_enrollment_id UUID,
p_lesson_id UUID,
p_completed BOOLEAN,
p_quiz_score INTEGER DEFAULT NULL
)
RETURNS academy_lesson_progress AS $$
DECLARE
result academy_lesson_progress;
BEGIN
INSERT INTO academy_lesson_progress (enrollment_id, lesson_id, completed, quiz_score, completed_at)
VALUES (
p_enrollment_id,
p_lesson_id,
p_completed,
p_quiz_score,
CASE WHEN p_completed THEN NOW() ELSE NULL END
)
ON CONFLICT ON CONSTRAINT uq_academy_lesson_progress_enrollment_lesson
DO UPDATE SET
completed = EXCLUDED.completed,
quiz_score = COALESCE(EXCLUDED.quiz_score, academy_lesson_progress.quiz_score),
completed_at = CASE
WHEN EXCLUDED.completed AND academy_lesson_progress.completed_at IS NULL THEN NOW()
WHEN NOT EXCLUDED.completed THEN NULL
ELSE academy_lesson_progress.completed_at
END
RETURNING * INTO result;
RETURN result;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION upsert_academy_lesson_progress IS 'Insert or update lesson progress with ON CONFLICT handling on the unique (enrollment_id, lesson_id) constraint';
-- ============================================================================
-- Helper: Cleanup function for expired certificates
-- ============================================================================
CREATE OR REPLACE FUNCTION cleanup_expired_academy_certificates(days_past_expiry INTEGER DEFAULT 0)
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM academy_certificates
WHERE valid_until IS NOT NULL
AND valid_until < NOW() - (days_past_expiry || ' days')::INTERVAL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION cleanup_expired_academy_certificates IS 'Removes certificates that have expired beyond the specified number of days';