Skip to content

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);