-- Unified hierarchy -- Course Category -> Course Sub-category -> Course -> Level -> Module -> Sub-Module -- -> Sub-Module Videos -- -> Sub-Module Practices (question sets) CREATE TABLE IF NOT EXISTS course_sub_categories ( id BIGSERIAL PRIMARY KEY, category_id BIGINT NOT NULL REFERENCES course_categories(id) ON DELETE CASCADE, name VARCHAR(150) NOT NULL, description TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, display_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(category_id, name) ); ALTER TABLE courses ADD COLUMN IF NOT EXISTS sub_category_id BIGINT REFERENCES course_sub_categories(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_courses_sub_category_id ON courses(sub_category_id); CREATE TABLE IF NOT EXISTS levels ( id BIGSERIAL PRIMARY KEY, course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE, cefr_level VARCHAR(2) NOT NULL, display_order INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(course_id, cefr_level), CHECK (cefr_level IN ('A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3')) ); CREATE INDEX IF NOT EXISTS idx_levels_course_id ON levels(course_id); CREATE TABLE IF NOT EXISTS modules ( id BIGSERIAL PRIMARY KEY, level_id BIGINT NOT NULL REFERENCES levels(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, description TEXT, display_order INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_modules_level_id ON modules(level_id); CREATE TABLE IF NOT EXISTS sub_modules ( id BIGSERIAL PRIMARY KEY, module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, description TEXT, display_order INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, legacy_sub_course_id BIGINT UNIQUE ); CREATE INDEX IF NOT EXISTS idx_sub_modules_module_id ON sub_modules(module_id); CREATE TABLE IF NOT EXISTS sub_module_videos ( id BIGSERIAL PRIMARY KEY, sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, description TEXT, video_url TEXT NOT NULL, duration INT, resolution VARCHAR(20), is_published BOOLEAN NOT NULL DEFAULT FALSE, publish_date TIMESTAMPTZ, visibility VARCHAR(50), instructor_id VARCHAR(100), thumbnail TEXT, display_order INT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', vimeo_id TEXT, vimeo_embed_url TEXT, vimeo_player_html TEXT, vimeo_status VARCHAR(50), video_host_provider VARCHAR(20), created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_sub_module_videos_sub_module_id ON sub_module_videos(sub_module_id); CREATE TABLE IF NOT EXISTS sub_module_practices ( id BIGSERIAL PRIMARY KEY, sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE, question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE, intro_video_url TEXT, display_order INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(question_set_id) ); CREATE INDEX IF NOT EXISTS idx_sub_module_practices_sub_module_id ON sub_module_practices(sub_module_id); -- Practice progress now supports sub-module owned practices where no legacy sub_course exists. ALTER TABLE user_practice_progress ALTER COLUMN sub_course_id DROP NOT NULL; -- Backfill from existing structure INSERT INTO course_sub_categories (category_id, name, description, display_order, is_active) SELECT cc.id, c.title || ' Group', 'Auto-generated from existing course structure', 0, TRUE FROM courses c JOIN course_categories cc ON cc.id = c.category_id LEFT JOIN course_sub_categories csc ON csc.category_id = cc.id AND csc.name = c.title || ' Group' WHERE csc.id IS NULL; UPDATE courses c SET sub_category_id = csc.id FROM course_sub_categories csc WHERE csc.category_id = c.category_id AND csc.name = c.title || ' Group' AND c.sub_category_id IS NULL; INSERT INTO levels (course_id, cefr_level, display_order, is_active) SELECT sc.course_id, sc.sub_level, MIN(sc.display_order), BOOL_AND(sc.is_active) FROM sub_courses sc WHERE sc.sub_level IN ('A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3') GROUP BY sc.course_id, sc.sub_level ON CONFLICT (course_id, cefr_level) DO NOTHING; INSERT INTO modules (level_id, title, description, display_order, is_active) SELECT l.id, l.cefr_level || ' Module 1', 'Auto-generated default module for ' || l.cefr_level, 1, l.is_active FROM levels l LEFT JOIN modules m ON m.level_id = l.id AND m.display_order = 1 WHERE m.id IS NULL; INSERT INTO sub_modules (module_id, title, description, display_order, is_active, legacy_sub_course_id) SELECT m.id, sc.title, sc.description, sc.display_order, sc.is_active, sc.id FROM sub_courses sc JOIN levels l ON l.course_id = sc.course_id AND l.cefr_level = sc.sub_level JOIN modules m ON m.level_id = l.id AND m.display_order = 1 LEFT JOIN sub_modules sm ON sm.legacy_sub_course_id = sc.id WHERE sm.id IS NULL; INSERT INTO sub_module_videos ( sub_module_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider ) SELECT sm.id, scv.title, scv.description, scv.video_url, scv.duration, scv.resolution, scv.is_published, scv.publish_date, scv.visibility, scv.instructor_id, scv.thumbnail, scv.display_order, scv.status, scv.vimeo_id, scv.vimeo_embed_url, scv.vimeo_player_html, scv.vimeo_status, scv.video_host_provider FROM sub_course_videos scv JOIN sub_modules sm ON sm.legacy_sub_course_id = scv.sub_course_id WHERE NOT EXISTS ( SELECT 1 FROM sub_module_videos smv WHERE smv.sub_module_id = sm.id AND smv.title = scv.title AND COALESCE(smv.video_url, '') = COALESCE(scv.video_url, '') ); UPDATE question_sets qs SET owner_type = 'SUB_MODULE', owner_id = sm.id FROM sub_modules sm WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = sm.legacy_sub_course_id AND qs.set_type = 'PRACTICE'; INSERT INTO sub_module_practices (sub_module_id, question_set_id, intro_video_url, display_order, is_active) SELECT sm.id, qs.id, qs.intro_video_url, COALESCE(qs.display_order, 0), (qs.status != 'ARCHIVED') FROM question_sets qs JOIN sub_modules sm ON qs.owner_type = 'SUB_MODULE' AND qs.owner_id = sm.id WHERE qs.set_type = 'PRACTICE' ON CONFLICT (question_set_id) DO NOTHING;