Initial DB Schema
Below is a single, coherent PostgreSQL schema with all the pieces we discussed:
- Multi-tenant (universities, departments, countries)
- Domains & subjects taxonomy
- Courses, course runs, prerequisites
- Templates + instances (modules)
- Polymorphic content (JSONB)
- Media asset library
- Users, roles, enrollments, progress
- Assessments (quizzes, labs), grades
- Certificates, discussions, reviews
You can treat this as your baseline v1.
0. ENUM Types¶
-- Academic level of courses
CREATE TYPE course_level_enum AS ENUM (
'undergraduate',
'graduate',
'postgraduate',
'certificate',
'other'
);
-- Roles within a university tenant
CREATE TYPE university_role_enum AS ENUM (
'student',
'instructor',
'ta',
'department_admin',
'university_admin',
'platform_admin'
);
-- Types of modules in the recursive syllabus tree
CREATE TYPE module_type_enum AS ENUM (
'course_root',
'unit',
'chapter',
'lesson',
'topic',
'section',
'lab',
'quiz',
'other'
);
-- Types of polymorphic content items
CREATE TYPE content_type_enum AS ENUM (
'video',
'quiz',
'engineering_lab',
'text',
'assignment',
'resource'
);
-- Prerequisite relation types
CREATE TYPE prerequisite_type_enum AS ENUM (
'strict',
'recommended',
'co_requisite'
);
-- Enrollment status
CREATE TYPE enrollment_status_enum AS ENUM (
'active',
'completed',
'dropped',
'pending',
'waitlisted'
);
1. Geography & Tenancy¶
-- Countries / regions
CREATE TABLE countries (
id BIGSERIAL PRIMARY KEY,
iso_code VARCHAR(2) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
region_name VARCHAR(255)
);
COMMENT ON COLUMN countries.iso_code IS 'ISO 3166-1 alpha-2 country code.';
-- Universities (tenants)
CREATE TABLE universities (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
short_code VARCHAR(64) NOT NULL, -- e.g. "MIT"
country_id BIGINT NOT NULL REFERENCES countries(id),
time_zone VARCHAR(64) NOT NULL DEFAULT 'UTC',
website_url VARCHAR(512),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_universities_short_code
ON universities (short_code);
CREATE INDEX ix_universities_country_id
ON universities (country_id);
-- Departments within universities
CREATE TABLE departments (
id BIGSERIAL PRIMARY KEY,
university_id BIGINT NOT NULL REFERENCES universities(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
code VARCHAR(64) NOT NULL, -- e.g. "ME", "CS"
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_departments_university_code
ON departments (university_id, code);
CREATE INDEX ix_departments_university_id
ON departments (university_id);
2. Users & Roles (Multi-Tenant)¶
-- Platform users
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- or external auth id
locale VARCHAR(16) NOT NULL DEFAULT 'en',
time_zone VARCHAR(64) NOT NULL DEFAULT 'UTC',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User roles per university (multi-tenant RBAC)
CREATE TABLE user_university_roles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
university_id BIGINT NOT NULL REFERENCES universities(id) ON DELETE CASCADE,
role university_role_enum NOT NULL,
department_id BIGINT REFERENCES departments(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_user_university_role
ON user_university_roles (user_id, university_id, role);
CREATE INDEX ix_user_university_roles_university_id
ON user_university_roles (university_id);
3. Domains & Subjects Taxonomy¶
-- High-level domains, e.g. Mathematics, Science, Engineering
CREATE TABLE domains (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(64) NOT NULL UNIQUE, -- "MATH", "SCI", ...
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Global engineering / academic subjects (recursive)
CREATE TABLE subjects (
id BIGSERIAL PRIMARY KEY,
parent_subject_id BIGINT REFERENCES subjects(id) ON DELETE SET NULL,
domain_id BIGINT REFERENCES domains(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
code VARCHAR(128) NOT NULL, -- e.g. "MECH_THERMO"
description TEXT,
depth INT NOT NULL DEFAULT 0, -- cache tree depth
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_subjects_code
ON subjects (code);
CREATE INDEX ix_subjects_parent_subject_id
ON subjects (parent_subject_id);
CREATE INDEX ix_subjects_domain_id
ON subjects (domain_id);
4. Courses, Subjects, Prerequisites¶
-- Courses (definition level)
CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
university_id BIGINT NOT NULL REFERENCES universities(id) ON DELETE CASCADE,
department_id BIGINT REFERENCES departments(id) ON DELETE SET NULL,
primary_subject_id BIGINT REFERENCES subjects(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
code VARCHAR(128) NOT NULL, -- e.g. "ME101"
description TEXT,
level course_level_enum NOT NULL DEFAULT 'undergraduate',
language_code VARCHAR(8) NOT NULL DEFAULT 'en',
credit_hours NUMERIC(4,2),
is_published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_courses_university_code
ON courses (university_id, code);
CREATE INDEX ix_courses_university_id
ON courses (university_id);
CREATE INDEX ix_courses_department_id
ON courses (department_id);
CREATE INDEX ix_courses_primary_subject_id
ON courses (primary_subject_id);
-- Additional subject tags per course (many-to-many)
CREATE TABLE course_subjects (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE UNIQUE INDEX ux_course_subjects_unique
ON course_subjects (course_id, subject_id);
CREATE INDEX ix_course_subjects_subject_id
ON course_subjects (subject_id);
-- Course prerequisites
CREATE TABLE course_prerequisites (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
prerequisite_course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
prerequisite_type prerequisite_type_enum NOT NULL DEFAULT 'strict',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_course_prerequisites_unique
ON course_prerequisites (course_id, prerequisite_course_id);
CREATE INDEX ix_course_prerequisites_prereq
ON course_prerequisites (prerequisite_course_id);
5. Course Offerings / Runs & Enrollments¶
-- Concrete runs / terms of a course
CREATE TABLE course_offerings (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
university_id BIGINT NOT NULL REFERENCES universities(id) ON DELETE CASCADE,
term_name VARCHAR(255), -- e.g. "Fall 2026"
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
enrollment_start TIMESTAMPTZ,
enrollment_end TIMESTAMPTZ,
visibility VARCHAR(32) NOT NULL DEFAULT 'private', -- or enum if you prefer
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ix_course_offerings_course_id
ON course_offerings (course_id);
CREATE INDEX ix_course_offerings_university_id
ON course_offerings (university_id);
-- Instructors for a course offering
CREATE TABLE course_offering_instructors (
id BIGSERIAL PRIMARY KEY,
course_offering_id BIGINT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX ux_course_offering_instructor_unique
ON course_offering_instructors (course_offering_id, user_id);
-- Enrollments
CREATE TABLE course_enrollments (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_offering_id BIGINT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE,
status enrollment_status_enum NOT NULL DEFAULT 'active',
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX ux_course_enrollments_unique
ON course_enrollments (user_id, course_offering_id);
CREATE INDEX ix_course_enrollments_status
ON course_enrollments (status);
6. Templates + Modules (Recursive Syllabus)¶
-- Reusable module templates (library of lessons/chapters/units)
CREATE TABLE module_templates (
id BIGSERIAL PRIMARY KEY,
template_type module_type_enum NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
default_data JSONB, -- optional metadata / default layout
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Course-specific modules (instances in the syllabus tree)
CREATE TABLE modules (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
parent_module_id BIGINT REFERENCES modules(id) ON DELETE CASCADE,
module_type module_type_enum NOT NULL,
module_template_id BIGINT REFERENCES module_templates(id),
title VARCHAR(255) NOT NULL,
description TEXT,
sequence_index INT NOT NULL DEFAULT 1, -- order among siblings
depth INT NOT NULL DEFAULT 0, -- cached tree depth
is_published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Ensure module ordering is unique per parent inside a course
CREATE UNIQUE INDEX ux_modules_course_parent_sequence
ON modules (course_id, COALESCE(parent_module_id, 0), sequence_index);
CREATE INDEX ix_modules_course_id
ON modules (course_id);
CREATE INDEX ix_modules_parent_module_id
ON modules (parent_module_id);
CREATE INDEX ix_modules_template_id
ON modules (module_template_id);
7. Media / Asset Library (Advanced Option)¶
-- Shared assets: images, PDFs, downloadable files, etc.
CREATE TABLE assets (
id BIGSERIAL PRIMARY KEY,
university_id BIGINT REFERENCES universities(id) ON DELETE SET NULL,
url VARCHAR(1024) NOT NULL,
asset_type VARCHAR(32) NOT NULL, -- e.g. "image", "pdf", "audio", "other"
meta_json JSONB, -- e.g. dimensions, alt-text, licensing
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ix_assets_university_id
ON assets (university_id);
CREATE INDEX ix_assets_meta_json_gin
ON assets USING GIN (meta_json);
8. Polymorphic Content Items (JSONB)¶
-- Polymorphic learning content: video, quiz, engineering_lab, text, resource, ...
CREATE TABLE content_items (
id BIGSERIAL PRIMARY KEY,
module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
university_id BIGINT NOT NULL REFERENCES universities(id) ON DELETE CASCADE,
content_type content_type_enum NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
sequence_index INT NOT NULL DEFAULT 1, -- order within module
estimated_duration_seconds INT, -- rough duration
data_json JSONB NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Per-module ordering
CREATE UNIQUE INDEX ux_content_items_module_sequence
ON content_items (module_id, sequence_index);
CREATE INDEX ix_content_items_module_id
ON content_items (module_id);
CREATE INDEX ix_content_items_course_id
ON content_items (course_id);
CREATE INDEX ix_content_items_university_id
ON content_items (university_id);
CREATE INDEX ix_content_items_content_type
ON content_items (content_type);
-- JSONB GIN index for querying on provider/runtime/etc.
CREATE INDEX ix_content_items_data_json_gin
ON content_items USING GIN (data_json);
COMMENT ON COLUMN content_items.data_json IS
'Polymorphic JSONB payload depending on content_type:
video:
{
"provider": "youtube" | "vimeo" | "internal",
"video_id": "abc123",
"url": "https://...",
"duration_seconds": 2700,
"transcript": {
"language": "en",
"segments": [ { "start": 0.0, "end": 8.5, "text": "..." }, ... ]
},
"thumbnail_url": "https://...",
"asset_id": 42 -- optional link to assets table
}
quiz:
{
"question_pool_id": 12345,
"quiz_mode": "practice" | "exam",
"num_questions_per_attempt": 10,
"randomize_order": true,
"time_limit_seconds": null,
"mastery": { "points": 50, "threshold_correct_in_row": 3, "max_attempts": 5 }
}
engineering_lab:
{
"lab_type": "code",
"runtime": "python-datascience" | "matlab",
"container_image": "registry/image:tag",
"cpu_limit": "1",
"memory_limit": "2Gi",
"timeout_seconds": 3600,
"entrypoint": "jupyter-lab",
"env": { "VAR1": "value" },
"files": [
{ "path": "notebooks/lab.ipynb", "role": "student_visible" },
{ "path": "tests/test_basic.py", "role": "hidden_test" }
],
"grading": { "type": "unit_tests", "max_points": 100 }
}
text (article/section):
{
"layout": "rich_text_v1",
"title": "What are linear equations and inequalities?",
"blocks": [
{ "type": "paragraph", "text": "..." },
{ "type": "math", "latex": "2x + 1 = 5" },
{ "type": "image", "asset_id": 42, "caption": "..." },
{ "type": "link_list", "items": [ { "label": "...", "url": "..." } ] }
]
}
resource:
{
"resources": [
{ "label": "...", "url": "https://...", "kind": "pdf" | "article" | "video" }
]
}';
9. Progress Tracking¶
-- Progress on modules
CREATE TABLE user_module_progress (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_offering_id BIGINT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE,
module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
progress_percent NUMERIC(5,2) NOT NULL DEFAULT 0.0 -- 0–100
);
CREATE UNIQUE INDEX ux_user_module_progress_unique
ON user_module_progress (user_id, course_offering_id, module_id);
CREATE INDEX ix_user_module_progress_module_id
ON user_module_progress (module_id);
-- Progress on individual content items
CREATE TABLE user_content_progress (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_offering_id BIGINT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE,
content_item_id BIGINT NOT NULL REFERENCES content_items(id) ON DELETE CASCADE,
first_started_at TIMESTAMPTZ,
last_interacted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
progress_percent NUMERIC(5,2) NOT NULL DEFAULT 0.0,
attempts_count INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX ux_user_content_progress_unique
ON user_content_progress (user_id, course_offering_id, content_item_id);
10. Assessments: Quizzes, Labs, Grades¶
-- Quiz attempts
CREATE TABLE quiz_attempts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_offering_id BIGINT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE,
content_item_id BIGINT NOT NULL REFERENCES content_items(id) ON DELETE CASCADE, -- quiz
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
submitted_at TIMESTAMPTZ,
score_obtained NUMERIC(6,2),
max_score NUMERIC(6,2),
passed BOOLEAN,
raw_response_json JSONB,
attempt_number INT NOT NULL DEFAULT 1
);
CREATE INDEX ix_quiz_attempts_user_item
ON quiz_attempts (user_id, content_item_id);
CREATE INDEX ix_quiz_attempts_course_offering
ON quiz_attempts (course_offering_id);
-- Lab submissions (engineering_lab)
CREATE TABLE lab_submissions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_offering_id BIGINT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE,
content_item_id BIGINT NOT NULL REFERENCES content_items(id) ON DELETE CASCADE, -- engineering_lab
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(32) NOT NULL DEFAULT 'submitted', -- "submitted", "graded", etc.
auto_grade_json JSONB, -- e.g. unit test results
score_obtained NUMERIC(6,2),
max_score NUMERIC(6,2),
instructor_feedback TEXT,
artifacts_url VARCHAR(1024) -- pointer to stored notebook/code archive
);
CREATE INDEX ix_lab_submissions_user_item
ON lab_submissions (user_id, content_item_id);
-- Overall course grades
CREATE TABLE course_grades (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_offering_id BIGINT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE,
final_score NUMERIC(6,2),
letter_grade VARCHAR(8),
passed BOOLEAN,
graded_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX ux_course_grades_unique
ON course_grades (user_id, course_offering_id);
11. Certificates¶
CREATE TABLE certificates (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_offering_id BIGINT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE,
certificate_code VARCHAR(64) NOT NULL UNIQUE,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
metadata_json JSONB
);
CREATE INDEX ix_certificates_user
ON certificates (user_id);
12. Discussions & Reviews (Social Layer)¶
-- Discussion threads (per course/module/content)
CREATE TABLE discussion_threads (
id BIGSERIAL PRIMARY KEY,
course_offering_id BIGINT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE,
module_id BIGINT REFERENCES modules(id) ON DELETE SET NULL,
content_item_id BIGINT REFERENCES content_items(id) ON DELETE SET NULL,
created_by_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_locked BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX ix_discussion_threads_course_offering
ON discussion_threads (course_offering_id);
-- Posts within threads (nested)
CREATE TABLE discussion_posts (
id BIGSERIAL PRIMARY KEY,
thread_id BIGINT NOT NULL REFERENCES discussion_threads(id) ON DELETE CASCADE,
parent_post_id BIGINT REFERENCES discussion_posts(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX ix_discussion_posts_thread_id
ON discussion_posts (thread_id);
-- Course reviews / ratings
CREATE TABLE course_reviews (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_offering_id BIGINT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE,
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
review_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_course_reviews_unique
ON course_reviews (user_id, course_offering_id);
CREATE INDEX ix_course_reviews_course_offering
ON course_reviews (course_offering_id);