merge: Resolve conflicts with gitea remote
Keep local versions for .gitignore, vendors route (full header forwarding), investor-agent soul (slide-awareness + follow-up questions), mkdocs site_url. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user