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,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';