Compare commits

..

No commits in common. "main" and "poduction" have entirely different histories.

382 changed files with 17515 additions and 52027 deletions

View File

@ -1,14 +0,0 @@
---
description: Commit before push whenever the tree is dirty
alwaysApply: true
---
# Git push
When the user asks to push (including phrases like “push”, “push to remote”, or “git push”):
1. Run `git status` (and if needed `git diff`) to check for unstaged/uncommitted changes.
2. If there are changes worth shipping, stage and **commit first**—never omit secrets such as `.env`, credentials files, or private keys. Follow the repos commit message conventions.
3. Then run `git push` to the tracked upstream.
If nothing is staged and the working tree is clean, pushing without a commit is fine.

View File

@ -1,4 +1,4 @@
# Yimaru Backend API # Yimaru Backend
Yimaru Backend is the server-side application that powers the Yimaru online learning system. It manages courses, lessons, quizzes, student progress, instructor content, and administrative operations for institutions and users on the platform. Yimaru Backend is the server-side application that powers the Yimaru online learning system. It manages courses, lessons, quizzes, student progress, instructor content, and administrative operations for institutions and users on the platform.

View File

@ -10,44 +10,31 @@ import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
customlogger "Yimaru-Backend/internal/logger" customlogger "Yimaru-Backend/internal/logger"
"Yimaru-Backend/internal/logger/mongoLogger" "Yimaru-Backend/internal/logger/mongoLogger"
minioclient "Yimaru-Backend/internal/pkgs/minio"
"Yimaru-Backend/internal/repository" "Yimaru-Backend/internal/repository"
activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/appversions"
"Yimaru-Backend/internal/services/chapa"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" "Yimaru-Backend/internal/services/course_management"
coursesservice "Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/emailtemplates"
"Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/faqs"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
lessonsservice "Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/messenger" "Yimaru-Backend/internal/services/messenger"
minioservice "Yimaru-Backend/internal/services/minio"
moduleservice "Yimaru-Backend/internal/services/modules"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
personasservice "Yimaru-Backend/internal/services/personas"
practicesservice "Yimaru-Backend/internal/services/practices"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
programsservice "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
ratingsservice "Yimaru-Backend/internal/services/ratings"
rbacservice "Yimaru-Backend/internal/services/rbac"
"Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/recommendation"
"Yimaru-Backend/internal/services/settings" "Yimaru-Backend/internal/services/settings"
"Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/subscriptions"
"Yimaru-Backend/internal/services/team" "Yimaru-Backend/internal/services/team"
activitylogservice "Yimaru-Backend/internal/services/activity_log"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
minioservice "Yimaru-Backend/internal/services/minio"
minioclient "Yimaru-Backend/internal/pkgs/minio"
ratingsservice "Yimaru-Backend/internal/services/ratings"
rbacservice "Yimaru-Backend/internal/services/rbac"
vimeoservice "Yimaru-Backend/internal/services/vimeo" vimeoservice "Yimaru-Backend/internal/services/vimeo"
"context" "context"
// referralservice "Yimaru-Backend/internal/services/referal" // referralservice "Yimaru-Backend/internal/services/referal"
"Yimaru-Backend/internal/services/transaction" "Yimaru-Backend/internal/services/transaction"
"Yimaru-Backend/internal/services/user" "Yimaru-Backend/internal/services/user"
videoengagementservice "Yimaru-Backend/internal/services/videoengagement"
httpserver "Yimaru-Backend/internal/web_server" httpserver "Yimaru-Backend/internal/web_server"
jwtutil "Yimaru-Backend/internal/web_server/jwt" jwtutil "Yimaru-Backend/internal/web_server/jwt"
customvalidator "Yimaru-Backend/internal/web_server/validator" customvalidator "Yimaru-Backend/internal/web_server/validator"
@ -111,16 +98,16 @@ func main() {
settingSvc := settings.NewService(settingRepo) settingSvc := settings.NewService(settingRepo)
messengerSvc := messenger.NewService(settingSvc, cfg) messengerSvc := messenger.NewService(settingSvc, cfg)
emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store)) // statSvc := stats.NewService(
profileFieldOptionSvc := profilefieldoptions.NewService(repository.NewProfileFieldOptionStore(store)) // repository.NewCompanyStatStore(store),
// repository.NewBranchStatStore(store),
// )
userSvc := user.NewService( userSvc := user.NewService(
repository.NewTokenStore(store), repository.NewTokenStore(store),
repository.NewUserStore(store), repository.NewUserStore(store),
repository.NewOTPStore(store), repository.NewOTPStore(store),
messengerSvc, messengerSvc,
emailTemplateSvc,
profileFieldOptionSvc,
cfg, cfg,
) )
@ -373,10 +360,24 @@ func main() {
logger.Info("Vimeo service disabled (VIMEO_ENABLED not set or missing access token)") logger.Info("Vimeo service disabled (VIMEO_ENABLED not set or missing access token)")
} }
// CloudConvert service for image/video optimization // Course management service
courseSvc := course_management.NewService(
repository.NewUserStore(store),
repository.NewCourseStore(store),
repository.NewProgressionStore(store),
notificationSvc,
cfg,
)
// Wire up Vimeo service to course management
if vimeoSvc != nil {
courseSvc.SetVimeoService(vimeoSvc)
}
// CloudConvert service for video compression
var ccSvc *cloudconvertservice.Service var ccSvc *cloudconvertservice.Service
if cfg.CloudConvert.Enabled && cfg.CloudConvert.APIKey != "" { if cfg.CloudConvert.Enabled && cfg.CloudConvert.APIKey != "" {
ccSvc = cloudconvertservice.NewService(cfg.CloudConvert.APIKey, domain.MongoDBLogger) ccSvc = cloudconvertservice.NewService(cfg.CloudConvert.APIKey, domain.MongoDBLogger)
courseSvc.SetCloudConvertService(ccSvc)
logger.Info("CloudConvert service initialized") logger.Info("CloudConvert service initialized")
} else { } else {
logger.Info("CloudConvert service disabled (CLOUDCONVERT_ENABLED not set or missing API key)") logger.Info("CloudConvert service disabled (CLOUDCONVERT_ENABLED not set or missing API key)")
@ -400,34 +401,11 @@ func main() {
// Questions service (unified questions system) // Questions service (unified questions system)
questionsSvc := questions.NewService(store) questionsSvc := questions.NewService(store)
faqSvc := faqs.NewService(repository.NewFAQStore(store))
appVersionSvc := appversions.NewService(repository.NewMobileAppVersionStore(store))
personasSvc := personasservice.NewService(store)
examPrepSvc := examprep.NewService(store)
// LMS programs (top-level hierarchy)
programSvc := programsservice.NewService(store)
// LMS courses (under programs)
courseSvc := coursesservice.NewService(store, store)
// LMS modules (under courses)
moduleSvc := moduleservice.NewService(store, store)
// LMS lessons (under modules)
lessonSvc := lessonsservice.NewService(store, store)
lmsProgressSvc := lmsprogress.NewService(store)
videoEngagementSvc := videoengagementservice.NewService(store)
// LMS practices (under course, module, or lesson)
practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
// Subscriptions service // Subscriptions service
subscriptionsSvc := subscriptions.NewService(store) subscriptionsSvc := subscriptions.NewService(store)
// ArifPay service (direct/legacy payment flows) // ArifPay service with payment and subscription stores
arifpaySvc := arifpay.NewArifpayService( arifpaySvc := arifpay.NewArifpayService(
cfg, cfg,
&http.Client{Timeout: 30 * time.Second}, &http.Client{Timeout: 30 * time.Second},
@ -435,24 +413,8 @@ func main() {
store, // implements SubscriptionStore store, // implements SubscriptionStore
) )
// Chapa service for subscription checkout payments
chapaSvc := chapa.NewService(
cfg,
&http.Client{Timeout: 30 * time.Second},
store,
store,
store,
)
// Team management service // Team management service
teamSvc := team.NewService( teamSvc := team.NewService(repository.NewTeamStore(store))
repository.NewTeamStore(store),
cfg.RefreshExpiry,
emailTemplateSvc,
messengerSvc,
cfg.TeamInviteBaseURL,
cfg.TeamInviteExpiry,
)
// santimpayClient := santimpay.NewSantimPayClient(cfg) // santimpayClient := santimpay.NewSantimPayClient(cfg)
@ -480,22 +442,10 @@ func main() {
// Initialize and start HTTP server // Initialize and start HTTP server
app := httpserver.NewApp( app := httpserver.NewApp(
assessmentSvc, assessmentSvc,
questionsSvc,
faqSvc,
appVersionSvc,
emailTemplateSvc,
profileFieldOptionSvc,
personasSvc,
examPrepSvc,
programSvc,
courseSvc, courseSvc,
moduleSvc, questionsSvc,
lessonSvc,
lmsProgressSvc,
practiceSvc,
subscriptionsSvc, subscriptionsSvc,
arifpaySvc, arifpaySvc,
chapaSvc,
issueReportingSvc, issueReportingSvc,
vimeoSvc, vimeoSvc,
teamSvc, teamSvc,
@ -520,7 +470,6 @@ func main() {
domain.MongoDBLogger, domain.MongoDBLogger,
analyticsDB, analyticsDB,
rbacSvc, rbacSvc,
videoEngagementSvc,
) )
logger.Info("Starting server", "port", cfg.Port) logger.Info("Starting server", "port", cfg.Port)

View File

@ -4,7 +4,6 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ====================================================== -- ======================================================
-- Customer/Learner Users (login via /api/v1/auth/customer-login) -- Customer/Learner Users (login via /api/v1/auth/customer-login)
-- Credentials: email + password@123 -- Credentials: email + password@123
-- OPEN_LEARNER demo user is seeded by migration 000061_open_learner_role (not here).
-- ====================================================== -- ======================================================
INSERT INTO users ( INSERT INTO users (
id, id,
@ -137,6 +136,190 @@ VALUES
) )
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- Ensure seeded admin has full panel permissions in legacy team_members.permissions JSON.
-- RBAC permissions are managed separately, but this keeps seed behavior consistent.
UPDATE team_members
SET permissions = '["*"]'::jsonb
WHERE id = 2 OR email = 'admin@yimaru.com';
-- ======================================================
-- Global Settings (LMS)
-- ======================================================
INSERT INTO global_settings (key, value)
VALUES
('platform_name', 'Yimaru LMS'),
('default_language', 'en'),
('allow_self_signup', 'true'),
('otp_expiry_minutes', '5'),
('certificate_enabled', 'true'),
('max_courses_per_instructor', '50')
ON CONFLICT (key) DO NOTHING;
-- ======================================================
-- ======================================================
-- Questions - Level A2 (EASY)
-- ======================================================
INSERT INTO questions (id, question_text, question_type, difficulty_level, points, status)
VALUES
(1, 'What would you say to greet someone before lunchtime?', 'MCQ', 'EASY', 1, 'PUBLISHED'),
(2, 'Which question is correct to ask about your routine?', 'MCQ', 'EASY', 1, 'PUBLISHED'),
(3, 'She ___ like pizza.', 'MCQ', 'EASY', 1, 'PUBLISHED'),
(4, 'I usually go to school and start class ____ eight o''clock.', 'MCQ', 'EASY', 1, 'PUBLISHED'),
(5, 'Someone says, "Here is the book you asked for." What is the best response?', 'MCQ', 'EASY', 1, 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_options (question_id, option_text, option_order, is_correct)
VALUES
-- Q1
(1, 'Good morning.', 1, TRUE),
(1, 'How do you do?', 2, FALSE),
(1, 'Good afternoon.', 3, FALSE),
(1, 'Goodbye.', 4, FALSE),
-- Q2
(2, 'What time you wake up?', 1, FALSE),
(2, 'What time do you wake up?', 2, TRUE),
(2, 'What time are you wake up?', 3, FALSE),
(2, 'What time waking you?', 4, FALSE),
-- Q3
(3, 'do not', 1, FALSE),
(3, 'not', 2, FALSE),
(3, 'is not', 3, FALSE),
(3, 'does not', 4, TRUE),
-- Q4
(4, 'about', 1, FALSE),
(4, 'on', 2, FALSE),
(4, 'at', 3, TRUE),
(4, 'in', 4, FALSE),
-- Q5
(5, 'Never mind.', 1, FALSE),
(5, 'Really?', 2, FALSE),
(5, 'What a pity!', 3, FALSE),
(5, 'Thank you.', 4, TRUE);
-- ======================================================
-- Questions - Level B1 (MEDIUM)
-- ======================================================
INSERT INTO questions (id, question_text, question_type, difficulty_level, points, status)
VALUES
(6, 'How do you introduce your friend to another person?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'),
(7, 'How would you ask for the price of an item in a shop?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'),
(8, 'Which sentence correctly gives simple directions?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'),
(9, 'The watch shows 10:50, but the real time is 10:45. What can you say?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'),
(10, 'Which instruction is correct when giving directions?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_options (question_id, option_text, option_order, is_correct)
VALUES
-- Q6
(6, 'Hello, my name is Samson.', 1, FALSE),
(6, 'Good morning. Nice to meet you.', 2, FALSE),
(6, 'Let me introduce myself to my friend.', 3, FALSE),
(6, 'This is my friend, Samson.', 4, TRUE),
-- Q7
(7, 'How many are these?', 1, FALSE),
(7, 'What is this?', 2, FALSE),
(7, 'How much is this?', 3, TRUE),
(7, 'Where is the nearest shop?', 4, FALSE),
-- Q8
(8, 'Thank you very much for asking.', 1, FALSE),
(8, 'Turn left and walk two blocks.', 2, TRUE),
(8, 'Why don''t you eat out.', 3, FALSE),
(8, 'Take the bus to the park.', 4, FALSE),
-- Q9
(9, 'My watch is slow.', 1, TRUE),
(9, 'My watch is late.', 2, FALSE),
(9, 'My watch is fast.', 3, FALSE),
(9, 'My watch is early.', 4, FALSE),
-- Q10
(10, 'Turn left.', 1, TRUE),
(10, 'Turn on left.', 2, FALSE),
(10, 'Turn left side.', 3, FALSE),
(10, 'Turn to straight.', 4, FALSE);
-- ======================================================
-- Questions - Level B2 (HARD)
-- ======================================================
INSERT INTO questions (id, question_text, question_type, difficulty_level, points, status)
VALUES
(11, 'What is the most polite way to ask to speak to someone on the phone?', 'MCQ', 'HARD', 1, 'PUBLISHED'),
(12, 'How do you correctly state the age of a person who is 30 years old?', 'MCQ', 'HARD', 1, 'PUBLISHED'),
(13, 'When asking for help with a new Yimaru App feature, which option is most appropriate?', 'MCQ', 'HARD', 1, 'PUBLISHED'),
(14, 'Which word has the unvoiced "th" sound?', 'MCQ', 'HARD', 1, 'PUBLISHED'),
(15, 'Which sentence sounds like a warning, not friendly advice?', 'MCQ', 'HARD', 1, 'PUBLISHED'),
(16, 'What does this sentence mean? "I will definitely be there on time."', 'MCQ', 'HARD', 1, 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_options (question_id, option_text, option_order, is_correct)
VALUES
-- Q11
(11, 'May I speak to Mr. Tesfaye, please?', 1, TRUE),
(11, 'Can I talk to Mr. Tesfaye?', 2, FALSE),
(11, 'Is Mr. Tesfaye there?', 3, FALSE),
(11, 'I want to talk to Mr. Tesfaye.', 4, FALSE),
-- Q12
(12, 'He is thirty years.', 1, FALSE),
(12, 'He has thirty years.', 2, FALSE),
(12, 'He has thirty years old.', 3, FALSE),
(12, 'He is thirty.', 4, TRUE),
-- Q13
(13, 'Are you familiar with how this feature works?', 1, FALSE),
(13, 'Could you walk me through how this feature works?', 2, TRUE),
(13, 'I believe I understand how this feature works.', 3, FALSE),
(13, 'I''ve tried similar features before.', 4, FALSE),
-- Q14
(14, 'That', 1, FALSE),
(14, 'They', 2, FALSE),
(14, 'These', 3, FALSE),
(14, 'Three', 4, TRUE),
-- Q15
(15, 'You might want to plan your time better.', 1, FALSE),
(15, 'If I were you, I''d start earlier.', 2, FALSE),
(15, 'You''d better meet the deadline this time.', 3, TRUE),
(15, 'Why don''t you try using a planner?', 4, FALSE),
-- Q16
(16, 'The speaker is unsure about arriving.', 1, FALSE),
(16, 'The speaker is promising to arrive on time.', 2, TRUE),
(16, 'The speaker might arrive late.', 3, FALSE),
(16, 'The speaker has already arrived.', 4, FALSE);
-- ======================================================
-- Initial Assessment Question Set
-- ======================================================
INSERT INTO question_sets (id, title, description, set_type, owner_type, status)
VALUES
(1, 'Initial Assessment', 'Default initial assessment for new users', 'INITIAL_ASSESSMENT', 'STANDALONE', 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_set_items (set_id, question_id, display_order)
VALUES
(1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5),
(1, 6, 6), (1, 7, 7), (1, 8, 8), (1, 9, 9), (1, 10, 10),
(1, 11, 11), (1, 12, 12), (1, 13, 13), (1, 14, 14), (1, 15, 15), (1, 16, 16)
ON CONFLICT (set_id, question_id) DO NOTHING;
-- ======================================================
-- Course Management seed data removed intentionally.
-- Course/category/sub-course/video/practice/question-set fixtures
-- are no longer seeded from this baseline script.
-- ======================================================
-- ====================================================== -- ======================================================
-- Team Members / Admin Panel Users (login via /api/v1/team/login) -- Team Members / Admin Panel Users (login via /api/v1/team/login)
-- Credentials: email + password@123 -- Credentials: email + password@123
@ -176,7 +359,7 @@ VALUES
'Administrative staff managing day-to-day operations.', 'Administrative staff managing day-to-day operations.',
'active', 'active',
TRUE, TRUE,
'["*"]'::jsonb, '[*]'::jsonb,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
), ),
( (
@ -288,20 +471,3 @@ VALUES
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
) )
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- Legacy team_members row may pre-exist; align admin permissions with seed expectations.
UPDATE team_members
SET permissions = '["*"]'::jsonb
WHERE id = 2 OR email = 'admin@yimaru.com';
-- ======================================================
-- RBAC safety seed: ensure ADMIN has permission grants
-- NOTE: API authorization uses RBAC role_permissions, not
-- team_members.permissions JSON.
-- ======================================================
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'ADMIN'
ON CONFLICT (role_id, permission_id) DO NOTHING;

View File

@ -1,25 +1,108 @@
-- Reset sequences for tables touched by login-only seed (PostgreSQL) -- ======================================================
-- Reset sequences for LMS tables (PostgreSQL)
-- ======================================================
-- users.id (BIGSERIAL)
SELECT setval( SELECT setval(
pg_get_serial_sequence('users', 'id'), pg_get_serial_sequence('users', 'id'),
COALESCE((SELECT MAX(id) FROM users), 1), COALESCE((SELECT MAX(id) FROM users), 1),
true true
); );
-- questions.id (BIGSERIAL)
SELECT setval( SELECT setval(
pg_get_serial_sequence('team_members', 'id'), pg_get_serial_sequence('questions', 'id'),
COALESCE((SELECT MAX(id) FROM team_members), 1), COALESCE((SELECT MAX(id) FROM questions), 1),
true true
); );
-- question_options.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('question_options', 'id'),
COALESCE((SELECT MAX(id) FROM question_options), 1),
true
);
-- question_short_answers.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('question_short_answers', 'id'),
COALESCE((SELECT MAX(id) FROM question_short_answers), 1),
true
);
-- question_sets.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('question_sets', 'id'),
COALESCE((SELECT MAX(id) FROM question_sets), 1),
true
);
-- question_set_items.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('question_set_items', 'id'),
COALESCE((SELECT MAX(id) FROM question_set_items), 1),
true
);
-- refresh_tokens.id (BIGSERIAL)
SELECT setval( SELECT setval(
pg_get_serial_sequence('refresh_tokens', 'id'), pg_get_serial_sequence('refresh_tokens', 'id'),
COALESCE((SELECT MAX(id) FROM refresh_tokens), 1), COALESCE((SELECT MAX(id) FROM refresh_tokens), 1),
true true
); );
-- otps.id (BIGSERIAL)
SELECT setval( SELECT setval(
pg_get_serial_sequence('otps', 'id'), pg_get_serial_sequence('otps', 'id'),
COALESCE((SELECT MAX(id) FROM otps), 1), COALESCE((SELECT MAX(id) FROM otps), 1),
true true
); );
-- notifications.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('notifications', 'id'),
COALESCE((SELECT MAX(id) FROM notifications), 1),
true
);
-- reported_issues.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('reported_issues', 'id'),
COALESCE((SELECT MAX(id) FROM reported_issues), 1),
true
);
-- course_categories.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('course_categories', 'id'),
COALESCE((SELECT MAX(id) FROM course_categories), 1),
true
);
-- courses.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('courses', 'id'),
COALESCE((SELECT MAX(id) FROM courses), 1),
true
);
-- sub_courses.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('sub_courses', 'id'),
COALESCE((SELECT MAX(id) FROM sub_courses), 1),
true
);
-- sub_course_videos.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('sub_course_videos', 'id'),
COALESCE((SELECT MAX(id) FROM sub_course_videos), 1),
true
);
-- question_set_personas.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('question_set_personas', 'id'),
COALESCE((SELECT MAX(id) FROM question_set_personas), 1),
true
);

View File

@ -1 +1,31 @@
-- Intentionally empty: no demo activity log seed (login-only seed in 001). INSERT INTO activity_logs (actor_id, actor_role, action, resource_type, resource_id, message, metadata, ip_address, user_agent, created_at) VALUES
(1, 'SUPER_ADMIN', 'CATEGORY_CREATED', 'CATEGORY', 1, 'Created course category: Mathematics', '{"name": "Mathematics"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '30 days'),
(1, 'SUPER_ADMIN', 'CATEGORY_CREATED', 'CATEGORY', 2, 'Created course category: Science', '{"name": "Science"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '29 days'),
(1, 'SUPER_ADMIN', 'CATEGORY_CREATED', 'CATEGORY', 3, 'Created course category: Language Arts', '{"name": "Language Arts"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '28 days'),
(1, 'SUPER_ADMIN', 'COURSE_CREATED', 'COURSE', 1, 'Created course: Algebra Fundamentals', '{"title": "Algebra Fundamentals", "category_id": 1}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '27 days'),
(1, 'SUPER_ADMIN', 'COURSE_CREATED', 'COURSE', 2, 'Created course: Biology 101', '{"title": "Biology 101", "category_id": 2}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '26 days'),
(2, 'ADMIN', 'COURSE_CREATED', 'COURSE', 3, 'Created course: English Grammar', '{"title": "English Grammar", "category_id": 3}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '25 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_CREATED', 'SUB_COURSE', 1, 'Created sub-course: Linear Equations', '{"title": "Linear Equations", "course_id": 1, "level": "BEGINNER"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '24 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_CREATED', 'SUB_COURSE', 2, 'Created sub-course: Quadratic Equations', '{"title": "Quadratic Equations", "course_id": 1, "level": "INTERMEDIATE"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '23 days'),
(2, 'ADMIN', 'SUB_COURSE_CREATED', 'SUB_COURSE', 3, 'Created sub-course: Cell Biology', '{"title": "Cell Biology", "course_id": 2, "level": "BEGINNER"}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '22 days'),
(1, 'SUPER_ADMIN', 'VIDEO_CREATED', 'VIDEO', 1, 'Created video: Introduction to Algebra', '{"title": "Introduction to Algebra", "sub_course_id": 1}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '21 days'),
(1, 'SUPER_ADMIN', 'VIDEO_UPLOADED', 'VIDEO', 1, 'Uploaded video to Vimeo: Introduction to Algebra', '{"title": "Introduction to Algebra", "vimeo_id": "987654321", "file_size": 52428800}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '21 days'),
(1, 'SUPER_ADMIN', 'VIDEO_PUBLISHED', 'VIDEO', 1, 'Published video: Introduction to Algebra', '{"title": "Introduction to Algebra"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '20 days'),
(2, 'ADMIN', 'VIDEO_CREATED', 'VIDEO', 2, 'Created video: Solving for X', '{"title": "Solving for X", "sub_course_id": 1}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '19 days'),
(2, 'ADMIN', 'VIDEO_UPLOADED', 'VIDEO', 2, 'Uploaded video to Vimeo: Solving for X', '{"title": "Solving for X", "vimeo_id": "987654322", "file_size": 41943040}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '19 days'),
(1, 'SUPER_ADMIN', 'COURSE_UPDATED', 'COURSE', 1, 'Updated course: Algebra Fundamentals', '{"title": "Algebra Fundamentals", "changed_fields": ["description", "thumbnail"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '18 days'),
(1, 'SUPER_ADMIN', 'CATEGORY_UPDATED', 'CATEGORY', 1, 'Updated course category: Mathematics & Statistics', '{"name": "Mathematics & Statistics"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '17 days'),
(2, 'ADMIN', 'VIDEO_CREATED', 'VIDEO', 3, 'Created video: Cell Structure Overview', '{"title": "Cell Structure Overview", "sub_course_id": 3}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '15 days'),
(2, 'ADMIN', 'VIDEO_UPLOADED', 'VIDEO', 3, 'Uploaded video to Vimeo: Cell Structure Overview', '{"title": "Cell Structure Overview", "vimeo_id": "987654323", "file_size": 73400320}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '15 days'),
(2, 'ADMIN', 'VIDEO_PUBLISHED', 'VIDEO', 2, 'Published video: Solving for X', '{"title": "Solving for X"}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '14 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_UPDATED', 'SUB_COURSE', 2, 'Updated sub-course: Quadratic Equations', '{"title": "Quadratic Equations", "changed_fields": ["description"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '12 days'),
(2, 'ADMIN', 'VIDEO_UPDATED', 'VIDEO', 3, 'Updated video: Cell Structure Overview', '{"title": "Cell Structure Overview", "changed_fields": ["thumbnail", "resolution"]}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '10 days'),
(2, 'ADMIN', 'VIDEO_PUBLISHED', 'VIDEO', 3, 'Published video: Cell Structure Overview', '{"title": "Cell Structure Overview"}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '9 days'),
(1, 'SUPER_ADMIN', 'VIDEO_ARCHIVED', 'VIDEO', 4, 'Archived video ID: 4', '{"id": 4}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '7 days'),
(1, 'SUPER_ADMIN', 'SETTINGS_UPDATED', 'SETTINGS', NULL, 'Updated global settings', '{"keys": ["site_name", "maintenance_mode"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '5 days'),
(1, 'SUPER_ADMIN', 'TEAM_MEMBER_CREATED', 'TEAM_MEMBER', 3, 'Created team member: John Doe', '{"name": "John Doe", "role": "instructor"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '4 days'),
(1, 'SUPER_ADMIN', 'COURSE_CREATED', 'COURSE', 4, 'Created course: Advanced Physics', '{"title": "Advanced Physics", "category_id": 2}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '3 days'),
(2, 'ADMIN', 'CATEGORY_DELETED', 'CATEGORY', 5, 'Deleted category ID: 5', '{"id": 5}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '2 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_DELETED', 'SUB_COURSE', 6, 'Deleted sub-course ID: 6', '{"id": 6}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '1 day'),
(2, 'ADMIN', 'VIDEO_DELETED', 'VIDEO', 5, 'Deleted video ID: 5', '{"id": 5}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '6 hours'),
(1, 'SUPER_ADMIN', 'TEAM_MEMBER_UPDATED', 'TEAM_MEMBER', 3, 'Updated team member: John Doe', '{"name": "John Doe", "changed_fields": ["role"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '2 hours');

View File

@ -1 +1,14 @@
-- Intentionally empty: no demo issue-report seed (login-only seed in 001). INSERT INTO reported_issues (user_id, user_role, subject, description, issue_type, status, metadata, created_at, updated_at) VALUES
(10, 'USER', 'Video not loading on mobile', 'When I try to play the Algebra Fundamentals introduction video on my phone, it shows a blank screen with a spinner that never stops.', 'video', 'pending', '{"course": "Algebra Fundamentals", "device": "iPhone 14", "browser": "Safari 17"}', now() - interval '14 days', now() - interval '14 days'),
(10, 'USER', 'Payment confirmation not received', 'I subscribed to the premium plan yesterday and the money was deducted from my account, but I have not received any confirmation email or SMS.', 'payment', 'in_progress', '{"plan": "Premium", "amount": 500, "payment_method": "telebirr"}', now() - interval '10 days', now() - interval '8 days'),
(10, 'USER', 'Cannot change profile picture', 'I am trying to upload a new profile picture but the upload button does not respond when I click it.', 'account', 'resolved', '{"browser": "Chrome 120", "file_type": "jpg", "file_size_kb": 2048}', now() - interval '20 days', now() - interval '15 days'),
(10, 'USER', 'Add dark mode support', 'It would be great if the platform had a dark mode option. Studying at night with the bright white background is hard on the eyes.', 'feature_request', 'pending', '{"platform": "web"}', now() - interval '7 days', now() - interval '7 days'),
(10, 'USER', 'Quiz results not saving', 'I completed the Biology 101 quiz but when I go back to check my results, it shows as incomplete.', 'bug', 'in_progress', '{"course": "Biology 101", "quiz_id": 5, "attempts": 3}', now() - interval '5 days', now() - interval '3 days'),
(12, 'SUPPORT', 'Course content displays incorrectly on tablets', 'Multiple users have reported that course text overlaps with images on tablet devices in landscape mode.', 'content', 'pending', '{"affected_devices": ["iPad Air", "Samsung Galaxy Tab S9"], "orientation": "landscape"}', now() - interval '12 days', now() - interval '12 days'),
(12, 'SUPPORT', 'Login fails after password reset', 'After resetting my password through the forgot password flow, the new password is not accepted for login.', 'login', 'resolved', '{"browser": "Firefox 121", "reset_method": "email"}', now() - interval '25 days', now() - interval '18 days'),
(12, 'SUPPORT', 'Slow page load times', 'The course listing page takes over 10 seconds to load, especially when filtering by category.', 'performance', 'in_progress', '{"page": "/courses", "avg_load_time_ms": 12500, "filter": "category=Science"}', now() - interval '9 days', now() - interval '6 days'),
(10, 'USER', 'Subscription auto-renewal not working', 'My monthly subscription expired even though I had auto-renewal enabled. I had to manually resubscribe.', 'subscription', 'rejected', '{"plan": "Monthly Basic", "expected_renewal": "2026-01-15"}', now() - interval '30 days', now() - interval '22 days'),
(12, 'SUPPORT', 'Screen reader cannot read course navigation', 'The course sidebar navigation is not accessible with screen readers. ARIA labels are missing on several interactive elements.', 'accessibility', 'pending', '{"screen_reader": "NVDA", "browser": "Chrome 120", "affected_elements": ["sidebar nav", "progress bar", "video controls"]}', now() - interval '4 days', now() - interval '4 days'),
(10, 'USER', 'Certificate download gives 404 error', 'After completing the English Grammar course, clicking the download certificate button returns a page not found error.', 'course', 'pending', '{"course": "English Grammar", "completion_date": "2026-01-28"}', now() - interval '2 days', now() - interval '2 days'),
(10, 'USER', 'Cannot access course after subscription renewal', 'I renewed my subscription but I still cannot access premium courses. It says my subscription is inactive.', 'subscription', 'in_progress', '{"plan": "Premium Annual", "renewal_date": "2026-02-01"}', now() - interval '1 day', now() - interval '12 hours')
ON CONFLICT DO NOTHING;

View File

@ -1 +1,40 @@
-- Intentionally empty: no demo notification seed (login-only seed in 001). INSERT INTO notifications (
id, user_id, receiver_type, type, level, channel, title, message, payload, is_read, created_at
) VALUES
-- Learner notifications (receiver_type=user, user_id=10)
(1001, 10, 'user', 'course_created', 'info', 'in_app', 'New Course Available', 'A new course "Algebra Fundamentals" has been added. Check it out!', '{"course_title": "Algebra Fundamentals", "category": "Mathematics"}', false, now() - interval '30 days'),
(1002, 10, 'user', 'course_created', 'info', 'in_app', 'New Course Available', 'A new course "English Grammar 101" has been added. Check it out!', '{"course_title": "English Grammar 101", "category": "Language"}', false, now() - interval '25 days'),
(1003, 10, 'user', 'sub_course_created', 'info', 'in_app', 'New Content Available', 'A new sub-course "Linear Equations" has been added.', '{"sub_course_title": "Linear Equations", "course": "Algebra Fundamentals"}', false, now() - interval '24 days'),
(1004, 10, 'user', 'video_added', 'info', 'in_app', 'New Video Available', 'A new video "Introduction to Variables" has been added.', '{"video_title": "Introduction to Variables", "sub_course": "Linear Equations"}', false, now() - interval '23 days'),
(1005, 10, 'user', 'payment_verified', 'success', 'in_app', 'Payment Successful', 'Your payment has been verified successfully. Your subscription is now active.', '{"plan": "Premium Monthly", "amount": 500}', true, now() - interval '20 days'),
(1006, 10, 'user', 'subscription_activated', 'success', 'in_app', 'Subscription Activated', 'Your Premium Monthly subscription is now active until March 20, 2026.', '{"plan": "Premium Monthly", "expires": "2026-03-20"}', true, now() - interval '20 days'),
(1007, 10, 'user', 'knowledge_level_update', 'info', 'in_app', 'Knowledge Level Updated', 'Your knowledge level has been updated to: Intermediate', '{"previous_level": "Beginner", "new_level": "Intermediate"}', false, now() - interval '15 days'),
(1008, 10, 'user', 'issue_status_updated', 'info', 'in_app', 'Issue Status Updated', 'Your issue "Video not loading on mobile" has been updated to: in_progress', '{"issue_id": 1, "subject": "Video not loading on mobile", "status": "in_progress"}', true, now() - interval '12 days'),
(1009, 10, 'user', 'issue_status_updated', 'success', 'in_app', 'Issue Status Updated', 'Your issue "Cannot change profile picture" has been updated to: resolved', '{"issue_id": 3, "subject": "Cannot change profile picture", "status": "resolved"}', true, now() - interval '10 days'),
(1010, 10, 'user', 'course_enrolled', 'success', 'in_app', 'Course Enrolled', 'You have been enrolled in "Biology 101".', '{"course_title": "Biology 101"}', false, now() - interval '8 days'),
(1011, 10, 'user', 'assessment_assigned', 'info', 'in_app', 'New Assessment Available', 'A new assessment is available for "Algebra Fundamentals".', '{"course": "Algebra Fundamentals", "assessment_type": "quiz"}', false, now() - interval '5 days'),
(1012, 10, 'user', 'announcement', 'info', 'in_app', 'Platform Maintenance', 'Scheduled maintenance on Feb 15, 2026 from 2:00 AM - 4:00 AM EAT.', '{"scheduled_at": "2026-02-15T02:00:00+03:00", "duration_hours": 2}', false, now() - interval '2 days'),
(1013, 10, 'user', 'video_added', 'info', 'in_app', 'New Video Available', 'A new video "Solving Quadratic Equations" has been added.', '{"video_title": "Solving Quadratic Equations", "sub_course": "Quadratics"}', false, now() - interval '1 day'),
-- Team member notifications (receiver_type=team_member, user_id references team_members.id)
(1014, 2, 'team_member', 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Video not loading on mobile" has been reported.', '{"issue_id": 1, "subject": "Video not loading on mobile", "reporter_id": 10}', false, now() - interval '14 days'),
(1015, 2, 'team_member', 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Payment confirmation not received" has been reported.', '{"issue_id": 2, "subject": "Payment confirmation not received", "reporter_id": 10}', false, now() - interval '10 days'),
(1016, 2, 'team_member', 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Quiz results not saving" has been reported.', '{"issue_id": 5, "subject": "Quiz results not saving", "reporter_id": 10}', false, now() - interval '5 days'),
(1017, 2, 'team_member', 'user_deleted', 'warning', 'in_app', 'User Deleted', 'User ID 99 has been deleted.', '{"deleted_user_id": 99, "deleted_by": 2}', true, now() - interval '18 days'),
(1018, 2, 'team_member', 'admin_created', 'info', 'in_app', 'New Admin Created', 'A new admin account has been created for admin@yimaru.com.', '{"admin_email": "admin@yimaru.com"}', true, now() - interval '28 days'),
(1019, 2, 'team_member', 'team_member_created','info', 'in_app', 'New Team Member', 'A new team member has been added.', '{"member_email": "support@yimaru.com", "role": "support"}', true, now() - interval '26 days'),
(1020, 2, 'team_member', 'system_alert', 'warning', 'in_app', 'High Error Rate Detected', 'The notification delivery failure rate exceeded 5% in the last hour.', '{"failure_rate": 5.2, "window": "1h"}', false, now() - interval '3 days'),
(1021, 3, 'team_member', 'announcement', 'info', 'in_app', 'Weekly Registration Report','15 new students registered this week.', '{"count": 15, "period": "weekly"}', false, now() - interval '1 day')
ON CONFLICT (id) DO NOTHING;
-- Scheduled notifications seeds (created_by references users.id)
INSERT INTO scheduled_notifications (
id, channel, title, message, html, scheduled_at, status, target_user_ids, target_role, target_raw,
attempt_count, last_error, processing_started_at, sent_at, cancelled_at, created_by, created_at, updated_at
) VALUES
(2001, 'push', 'Reminder: Continue Your Lesson', 'Pick up where you left off and continue learning today.', NULL, now() + interval '6 hours', 'pending', ARRAY[10,11], NULL, NULL, 0, NULL, NULL, NULL, NULL, 10, now() - interval '1 day', now() - interval '1 day'),
(2002, 'email', 'Weekly Progress Summary', 'Your weekly course progress summary is ready.', '<p>Your weekly course progress summary is ready.</p>', now() + interval '1 day', 'pending', NULL, 'STUDENT', NULL, 0, NULL, NULL, NULL, NULL, 10, now() - interval '1 day', now() - interval '1 day'),
(2003, 'sms', 'Platform Maintenance', 'Scheduled maintenance tonight from 02:00 to 04:00 EAT.', NULL, now() - interval '2 days', 'sent', ARRAY[10,12], NULL, NULL, 1, NULL, now() - interval '2 days' - interval '5 minutes', now() - interval '2 days', NULL, 10, now() - interval '3 days', now() - interval '2 days'),
(2004, 'email', 'Payment Service Alert', 'Some users may experience delayed payment confirmation.', '<p>Some users may experience delayed payment confirmation.</p>', now() - interval '1 day', 'failed', NULL, 'SUPPORT', NULL, 3, 'SMTP temporary outage', now() - interval '1 day' - interval '15 minutes', NULL, NULL, 10, now() - interval '2 days', now() - interval '1 day'),
(2005, 'push', 'Obsolete Campaign', 'This campaign was cancelled by admin.', NULL, now() + interval '2 days', 'cancelled', NULL, NULL, '{"segment":"inactive_users"}'::jsonb, 0, NULL, NULL, NULL, now() - interval '12 hours', 10, now() - interval '1 day', now() - interval '12 hours')
ON CONFLICT (id) DO NOTHING;

View File

@ -0,0 +1,469 @@
-- ======================================================
-- Complete Course Management Seed Data
-- Covers: categories, courses, sub-courses, videos,
-- question sets, questions, options, prerequisites,
-- and user progress for admin panel integration
-- ======================================================
-- ======================================================
-- Course Categories (supplement existing 3 categories)
-- Existing: 1=Programming, 2=Data Science, 3=Web Development
-- ======================================================
INSERT INTO course_categories (id, name, is_active, created_at) VALUES
(4, 'Mobile Development', TRUE, CURRENT_TIMESTAMP),
(5, 'DevOps & Cloud', TRUE, CURRENT_TIMESTAMP),
(6, 'Cybersecurity', FALSE, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Courses (supplement existing 7 courses)
-- Existing: 1-7 in categories 1-3
-- ======================================================
INSERT INTO courses (id, category_id, title, description, thumbnail, intro_video_url, is_active) VALUES
(8, 4, 'Flutter App Development', 'Build cross-platform mobile apps with Flutter and Dart', 'https://example.com/thumbnails/flutter.jpg', 'https://example.com/intro/flutter.mp4', TRUE),
(9, 4, 'React Native Essentials', 'Create native mobile apps using React Native', 'https://example.com/thumbnails/react-native.jpg', NULL, TRUE),
(10, 5, 'Docker & Kubernetes', 'Container orchestration and deployment strategies', 'https://example.com/thumbnails/docker-k8s.jpg', 'https://example.com/intro/docker.mp4', TRUE),
(11, 5, 'CI/CD Pipeline Mastery', 'Automate your build, test, and deployment workflows', 'https://example.com/thumbnails/cicd.jpg', NULL, FALSE),
(12, 6, 'Ethical Hacking Fundamentals', 'Learn penetration testing and security analysis', 'https://example.com/thumbnails/ethical-hacking.jpg', NULL, FALSE)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Sub-courses (supplement existing 17 sub-courses: IDs 1-17)
-- ======================================================
INSERT INTO sub_courses (id, course_id, title, description, thumbnail, display_order, level, sub_level, is_active) VALUES
-- Flutter sub-courses (course 8) — IDs 18-21
(18, 8, 'Dart Language Basics', 'Learn Dart programming language fundamentals', NULL, 1, 'BEGINNER', 'A1', TRUE),
(19, 8, 'Flutter UI Widgets', 'Build beautiful UIs with Flutter widgets', NULL, 2, 'BEGINNER', 'A2', TRUE),
(20, 8, 'State Management', 'Manage app state with Provider and Riverpod', NULL, 3, 'INTERMEDIATE', 'B1', TRUE),
(21, 8, 'Flutter Networking & APIs', 'HTTP requests, REST APIs, and data persistence', NULL, 4, 'ADVANCED', 'C1', TRUE),
-- React Native sub-courses (course 9) — IDs 22-24
(22, 9, 'React Native Setup', 'Environment setup and first app', NULL, 1, 'BEGINNER', 'A1', TRUE),
(23, 9, 'Navigation & Routing', 'React Navigation and screen management', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
(24, 9, 'Native Modules', 'Bridge native code with React Native', NULL, 3, 'ADVANCED', 'C1', TRUE),
-- Docker & Kubernetes sub-courses (course 10) — IDs 25-27
(25, 10, 'Docker Fundamentals', 'Containers, images, and Dockerfiles', NULL, 1, 'BEGINNER', 'A1', TRUE),
(26, 10, 'Docker Compose', 'Multi-container applications', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
(27, 10, 'Kubernetes Basics', 'Pods, services, and deployments', NULL, 3, 'ADVANCED', 'C1', TRUE),
-- CI/CD sub-courses (course 11) — IDs 28-29
(28, 11, 'Git Workflows', 'Branching strategies and pull requests', NULL, 1, 'BEGINNER', 'A1', TRUE),
(29, 11, 'GitHub Actions', 'Automate workflows with GitHub Actions', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
-- Cybersecurity sub-courses (course 12) — IDs 30-31
(30, 12, 'Network Security Basics', 'Firewalls, VPNs, and network protocols', NULL, 1, 'BEGINNER', 'A1', TRUE),
(31, 12, 'Penetration Testing', 'Tools and techniques for pen testing', NULL, 2, 'ADVANCED', 'C1', TRUE)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Sub-course Videos (supplement existing 5 videos: IDs 1-5)
-- ======================================================
INSERT INTO sub_course_videos (
id, sub_course_id, title, description, video_url,
duration, resolution, visibility, display_order, status,
video_host_provider, vimeo_id, vimeo_embed_url, vimeo_status
) VALUES
-- Dart Language Basics videos (sub_course 18)
(6, 18, 'Introduction to Dart', 'Overview of Dart programming language', 'https://example.com/dart-intro.mp4', 720, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(7, 18, 'Variables and Data Types', 'Dart variables, constants, and types', 'https://example.com/dart-variables.mp4', 900, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(8, 18, 'Control Flow in Dart', 'If/else, loops, and switch statements', 'https://example.com/dart-control.mp4', 1100, '720p', 'public', 3, 'DRAFT', 'DIRECT', NULL, NULL, NULL),
-- Flutter UI Widgets videos (sub_course 19)
(9, 19, 'Widget Tree Basics', 'Understanding the Flutter widget tree', 'https://player.vimeo.com/video/100000001', 1500, '1080p', 'public', 1, 'PUBLISHED', 'VIMEO', '100000001', 'https://player.vimeo.com/video/100000001', 'available'),
(10, 19, 'Layout Widgets', 'Row, Column, Stack, and Container widgets', 'https://player.vimeo.com/video/100000002', 1800, '1080p', 'public', 2, 'PUBLISHED', 'VIMEO', '100000002', 'https://player.vimeo.com/video/100000002', 'available'),
(11, 19, 'Custom Widgets', 'Building reusable custom widgets', 'https://player.vimeo.com/video/100000003', 2100, '1080p', 'public', 3, 'DRAFT', 'VIMEO', '100000003', 'https://player.vimeo.com/video/100000003', 'transcoding'),
-- State Management videos (sub_course 20)
(12, 20, 'setState and Stateful Widgets', 'Managing local state in Flutter', 'https://example.com/flutter-setstate.mp4', 1200, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(13, 20, 'Provider Pattern', 'Global state management with Provider', 'https://example.com/flutter-provider.mp4', 1600, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
-- Docker Fundamentals videos (sub_course 25)
(14, 25, 'What is Docker?', 'Introduction to containerization', 'https://example.com/docker-intro.mp4', 600, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(15, 25, 'Building Docker Images', 'Writing Dockerfiles and building images', 'https://example.com/docker-images.mp4', 1400, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
-- Docker Compose videos (sub_course 26)
(16, 26, 'Docker Compose Basics', 'Defining multi-container applications', 'https://example.com/compose-basics.mp4', 1300, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
-- React Native Setup videos (sub_course 22)
(17, 22, 'Setting Up React Native', 'Installing React Native CLI and Expo', 'https://example.com/rn-setup.mp4', 900, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(18, 22, 'Your First React Native App', 'Creating and running a basic app', 'https://example.com/rn-first-app.mp4', 1100, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Question Options for existing practice questions (17-20)
-- These were missing from the initial seed
-- ======================================================
INSERT INTO question_options (question_id, option_text, option_order, is_correct) VALUES
-- Q17: What is the correct way to print "Hello World" in Python?
(17, 'print("Hello World")', 1, TRUE),
(17, 'echo "Hello World"', 2, FALSE),
(17, 'console.log("Hello World")', 3, FALSE),
(17, 'System.out.println("Hello World")', 4, FALSE),
-- Q18: Which is a valid Python variable name?
(18, '2name', 1, FALSE),
(18, 'my_name', 2, TRUE),
(18, 'my-name', 3, FALSE),
(18, 'class', 4, FALSE),
-- Q19: How do you convert "123" to an integer?
(19, 'int("123")', 1, TRUE),
(19, 'integer("123")', 2, FALSE),
(19, 'str(123)', 3, FALSE),
(19, 'toInt("123")', 4, FALSE),
-- Q20: How many times does range(3) loop run?
(20, '2', 1, FALSE),
(20, '3', 2, TRUE),
(20, '4', 3, FALSE),
(20, '1', 4, FALSE);
-- ======================================================
-- Additional Practice Questions for new sub-courses
-- ======================================================
INSERT INTO questions (id, question_text, question_type, tips, status) VALUES
(21, 'What keyword is used to declare a variable in Dart?', 'MCQ', 'Dart uses var, final, or const', 'PUBLISHED'),
(22, 'Which widget is the root of every Flutter app?', 'MCQ', 'Think about the main() function', 'PUBLISHED'),
(23, 'What is a StatefulWidget?', 'MCQ', 'Consider mutable state', 'PUBLISHED'),
(24, 'What command creates a Docker container from an image?', 'MCQ', 'Think about docker run', 'PUBLISHED'),
(25, 'What file defines a Docker Compose application?', 'MCQ', 'It is a YAML file', 'PUBLISHED'),
(26, 'Which tool is used to create a new React Native project?', 'MCQ', 'Consider npx or expo', 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_options (question_id, option_text, option_order, is_correct) VALUES
-- Q21: Dart variable declaration
(21, 'var', 1, TRUE),
(21, 'let', 2, FALSE),
(21, 'dim', 3, FALSE),
(21, 'define', 4, FALSE),
-- Q22: Root Flutter widget
(22, 'MaterialApp', 1, TRUE),
(22, 'Container', 2, FALSE),
(22, 'Scaffold', 3, FALSE),
(22, 'AppBar', 4, FALSE),
-- Q23: StatefulWidget
(23, 'A widget that can change its state during its lifetime', 1, TRUE),
(23, 'A widget that never changes', 2, FALSE),
(23, 'A widget for static content only', 3, FALSE),
(23, 'A widget that cannot have children', 4, FALSE),
-- Q24: Docker container creation
(24, 'docker run', 1, TRUE),
(24, 'docker create', 2, FALSE),
(24, 'docker start', 3, FALSE),
(24, 'docker build', 4, FALSE),
-- Q25: Docker Compose file
(25, 'docker-compose.yml', 1, TRUE),
(25, 'Dockerfile', 2, FALSE),
(25, 'docker.json', 3, FALSE),
(25, 'compose.xml', 4, FALSE),
-- Q26: React Native project creation
(26, 'npx react-native init', 1, TRUE),
(26, 'npm create react-native', 2, FALSE),
(26, 'react-native new', 3, FALSE),
(26, 'rn init', 4, FALSE);
-- ======================================================
-- Question Sets for new sub-courses
-- ======================================================
INSERT INTO question_sets (id, title, description, set_type, owner_type, owner_id, persona, status) VALUES
(5, 'Dart Basics Quiz', 'Test your Dart fundamentals', 'PRACTICE', 'SUB_COURSE', 18, 'beginner', 'PUBLISHED'),
(6, 'Flutter Widgets Assessment', 'Assess Flutter widget knowledge', 'PRACTICE', 'SUB_COURSE', 19, 'beginner', 'PUBLISHED'),
(7, 'State Management Quiz', 'Test state management concepts', 'PRACTICE', 'SUB_COURSE', 20, 'intermediate', 'DRAFT'),
(8, 'Docker Fundamentals Quiz', 'Test Docker basics', 'PRACTICE', 'SUB_COURSE', 25, 'beginner', 'PUBLISHED'),
(9, 'Docker Compose Assessment', 'Assess Docker Compose skills', 'PRACTICE', 'SUB_COURSE', 26, 'intermediate', 'PUBLISHED'),
(10, 'React Native Setup Quiz', 'Test React Native setup knowledge', 'PRACTICE', 'SUB_COURSE', 22, 'beginner', 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
-- Ensure every sub-course has at least one practice set
INSERT INTO question_sets (title, description, set_type, owner_type, owner_id, status)
SELECT
sc.title || ' Practice',
'Default practice set for ' || sc.title,
'PRACTICE',
'SUB_COURSE',
sc.id,
'DRAFT'
FROM sub_courses sc
WHERE NOT EXISTS (
SELECT 1
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = sc.id
AND qs.set_type = 'PRACTICE'
AND qs.status != 'ARCHIVED'
);
-- Ensure every sub-course has one initial assessment set
INSERT INTO question_sets (title, description, set_type, owner_type, owner_id, status)
SELECT
sc.title || ' Entry Assessment',
'Initial assessment used before learners start ' || sc.title,
'INITIAL_ASSESSMENT',
'SUB_COURSE',
sc.id,
'DRAFT'
FROM sub_courses sc
WHERE NOT EXISTS (
SELECT 1
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = sc.id
AND qs.set_type = 'INITIAL_ASSESSMENT'
AND qs.status != 'ARCHIVED'
);
-- Link questions to question sets
INSERT INTO question_set_items (set_id, question_id, display_order) VALUES
(5, 21, 1),
(6, 22, 1),
(7, 23, 1),
(8, 24, 1),
(9, 25, 1),
(10, 26, 1)
ON CONFLICT (set_id, question_id) DO NOTHING;
-- Link personas to question sets
INSERT INTO question_set_personas (question_set_id, user_id, display_order) VALUES
(5, 10, 1), (5, 11, 2),
(6, 10, 1), (6, 12, 2),
(8, 11, 1),
(10, 10, 1)
ON CONFLICT (question_set_id, user_id) DO NOTHING;
-- ======================================================
-- Sub-course Prerequisites
-- Defines the learning path / dependency graph
-- ======================================================
INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id) VALUES
-- Python course (IDs 1-5): linear progression
-- "Python Basics - Data Types" requires "Python Basics - Getting Started"
(2, 1),
-- "Python Intermediate - Functions" requires "Python Basics - Data Types"
(3, 2),
-- "Python Intermediate - Collections" requires "Python Intermediate - Functions"
(4, 3),
-- "Python Advanced - Best Practices" requires "Python Intermediate - Collections"
(5, 4),
-- JavaScript course (IDs 6-7): linear
-- "DOM Manipulation Basics" requires "JavaScript Fundamentals"
(7, 6),
-- Java course (IDs 8-9): linear
-- "Spring Framework Intro" requires "Java Core Concepts"
(9, 8),
-- Data Science course (IDs 10-11): linear
-- "Advanced Data Analysis" requires "Data Analysis Fundamentals"
(11, 10),
-- ML course (IDs 12-13): linear
-- "ML Algorithms" requires "ML Basics"
(13, 12),
-- Full Stack course (IDs 14-15): linear
-- "Backend Development" requires "Frontend Fundamentals"
(15, 14),
-- React course (IDs 16-17): linear
-- "React Advanced Patterns" requires "React Basics"
(17, 16),
-- Flutter course (IDs 18-21): structured path
-- "Flutter UI Widgets" requires "Dart Language Basics"
(19, 18),
-- "State Management" requires "Flutter UI Widgets"
(20, 19),
-- "Flutter Networking & APIs" requires "State Management"
(21, 20),
-- React Native course (IDs 22-24): linear
-- "Navigation & Routing" requires "React Native Setup"
(23, 22),
-- "Native Modules" requires "Navigation & Routing"
(24, 23),
-- Docker & Kubernetes course (IDs 25-27): structured
-- "Docker Compose" requires "Docker Fundamentals"
(26, 25),
-- "Kubernetes Basics" requires "Docker Compose"
(27, 26),
-- CI/CD course (IDs 28-29): linear
-- "GitHub Actions" requires "Git Workflows"
(29, 28),
-- Cybersecurity course (IDs 30-31): linear
-- "Penetration Testing" requires "Network Security Basics"
(31, 30)
ON CONFLICT (sub_course_id, prerequisite_sub_course_id) DO NOTHING;
-- ======================================================
-- Completion-driven progress seed (auto-aggregate model)
-- Seed video/practice completion records, then derive sub-course progress
-- ======================================================
-- Video completions
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
SELECT 10, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '20 days'
FROM sub_course_videos v
WHERE v.sub_course_id IN (1, 2, 18)
AND v.status = 'PUBLISHED'
ON CONFLICT (user_id, video_id) DO NOTHING;
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
SELECT 10, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '8 days', CURRENT_TIMESTAMP - INTERVAL '8 days'
FROM sub_course_videos v
WHERE v.sub_course_id = 19
AND v.status = 'PUBLISHED'
AND v.display_order = 1
ON CONFLICT (user_id, video_id) DO NOTHING;
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
SELECT 11, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '25 days', CURRENT_TIMESTAMP - INTERVAL '25 days'
FROM sub_course_videos v
WHERE v.sub_course_id IN (1, 2, 25)
AND v.status = 'PUBLISHED'
ON CONFLICT (user_id, video_id) DO NOTHING;
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
SELECT 11, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '3 days', CURRENT_TIMESTAMP - INTERVAL '3 days'
FROM sub_course_videos v
WHERE v.sub_course_id = 26
AND v.status = 'PUBLISHED'
ON CONFLICT (user_id, video_id) DO NOTHING;
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
SELECT 12, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '7 days', CURRENT_TIMESTAMP - INTERVAL '7 days'
FROM sub_course_videos v
WHERE v.sub_course_id = 22
AND v.status = 'PUBLISHED'
ON CONFLICT (user_id, video_id) DO NOTHING;
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
SELECT 12, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '3 days', CURRENT_TIMESTAMP - INTERVAL '3 days'
FROM sub_course_videos v
WHERE v.sub_course_id = 18
AND v.status = 'PUBLISHED'
AND v.display_order = 1
ON CONFLICT (user_id, video_id) DO NOTHING;
-- Practice completions
INSERT INTO user_practice_progress (user_id, sub_course_id, question_set_id, completed_at, updated_at)
SELECT 10, qs.owner_id::BIGINT, qs.id, CURRENT_TIMESTAMP - INTERVAL '18 days', CURRENT_TIMESTAMP - INTERVAL '18 days'
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND qs.owner_id IN (1, 2, 18)
ON CONFLICT (user_id, question_set_id) DO NOTHING;
INSERT INTO user_practice_progress (user_id, sub_course_id, question_set_id, completed_at, updated_at)
SELECT 11, qs.owner_id::BIGINT, qs.id, CURRENT_TIMESTAMP - INTERVAL '10 days', CURRENT_TIMESTAMP - INTERVAL '10 days'
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND qs.owner_id IN (1, 2, 25)
ON CONFLICT (user_id, question_set_id) DO NOTHING;
INSERT INTO user_practice_progress (user_id, sub_course_id, question_set_id, completed_at, updated_at)
SELECT 12, qs.owner_id::BIGINT, qs.id, CURRENT_TIMESTAMP - INTERVAL '7 days', CURRENT_TIMESTAMP - INTERVAL '7 days'
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND qs.owner_id IN (22)
ON CONFLICT (user_id, question_set_id) DO NOTHING;
-- Derive sub-course progress from completion tables (same model as runtime auto-aggregate)
WITH target_pairs AS (
SELECT DISTINCT user_id, sub_course_id
FROM user_sub_course_video_progress
WHERE user_id IN (10, 11, 12)
UNION
SELECT DISTINCT user_id, sub_course_id
FROM user_practice_progress
WHERE user_id IN (10, 11, 12)
),
stats AS (
SELECT
tp.user_id,
tp.sub_course_id,
(SELECT COUNT(*)::INT
FROM sub_course_videos v
WHERE v.sub_course_id = tp.sub_course_id
AND v.status = 'PUBLISHED')
+
(SELECT COUNT(*)::INT
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = tp.sub_course_id
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED') AS total_items,
(SELECT COUNT(*)::INT
FROM user_sub_course_video_progress uv
JOIN sub_course_videos v ON v.id = uv.video_id
WHERE uv.user_id = tp.user_id
AND uv.sub_course_id = tp.sub_course_id
AND uv.completed_at IS NOT NULL
AND v.status = 'PUBLISHED')
+
(SELECT COUNT(*)::INT
FROM user_practice_progress up
JOIN question_sets qs ON qs.id = up.question_set_id
WHERE up.user_id = tp.user_id
AND up.sub_course_id = tp.sub_course_id
AND up.completed_at IS NOT NULL
AND qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = tp.sub_course_id
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED') AS completed_items
FROM target_pairs tp
)
INSERT INTO user_sub_course_progress (user_id, sub_course_id, status, progress_percentage, started_at, completed_at, updated_at)
SELECT
user_id,
sub_course_id,
CASE
WHEN total_items > 0 AND completed_items >= total_items THEN 'COMPLETED'
WHEN completed_items > 0 THEN 'IN_PROGRESS'
ELSE 'NOT_STARTED'
END AS status,
CASE
WHEN total_items = 0 THEN 0
ELSE ROUND((completed_items::NUMERIC * 100.0) / total_items::NUMERIC)::SMALLINT
END AS progress_percentage,
CASE WHEN completed_items > 0 THEN CURRENT_TIMESTAMP - INTERVAL '10 days' ELSE NULL END AS started_at,
CASE WHEN total_items > 0 AND completed_items >= total_items THEN CURRENT_TIMESTAMP - INTERVAL '3 days' ELSE NULL END AS completed_at,
CURRENT_TIMESTAMP AS updated_at
FROM stats
ON CONFLICT (user_id, sub_course_id) DO UPDATE SET
status = EXCLUDED.status,
progress_percentage = EXCLUDED.progress_percentage,
started_at = COALESCE(user_sub_course_progress.started_at, EXCLUDED.started_at),
completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at;
-- ======================================================
-- Reset sequences to avoid ID conflicts after seeding
-- ======================================================
SELECT setval(pg_get_serial_sequence('course_categories', 'id'), COALESCE((SELECT MAX(id) FROM course_categories), 1), true);
SELECT setval(pg_get_serial_sequence('courses', 'id'), COALESCE((SELECT MAX(id) FROM courses), 1), true);
SELECT setval(pg_get_serial_sequence('sub_courses', 'id'), COALESCE((SELECT MAX(id) FROM sub_courses), 1), true);
SELECT setval(pg_get_serial_sequence('sub_course_videos', 'id'), COALESCE((SELECT MAX(id) FROM sub_course_videos), 1), true);
SELECT setval(pg_get_serial_sequence('questions', 'id'), COALESCE((SELECT MAX(id) FROM questions), 1), true);
SELECT setval(pg_get_serial_sequence('question_options', 'id'), COALESCE((SELECT MAX(id) FROM question_options), 1), true);
SELECT setval(pg_get_serial_sequence('question_sets', 'id'), COALESCE((SELECT MAX(id) FROM question_sets), 1), true);
SELECT setval(pg_get_serial_sequence('question_set_items', 'id'), COALESCE((SELECT MAX(id) FROM question_set_items), 1), true);
SELECT setval(pg_get_serial_sequence('question_set_personas', 'id'), COALESCE((SELECT MAX(id) FROM question_set_personas), 1), true);
SELECT setval(pg_get_serial_sequence('sub_course_prerequisites', 'id'), COALESCE((SELECT MAX(id) FROM sub_course_prerequisites), 1), true);
SELECT setval(pg_get_serial_sequence('user_sub_course_progress', 'id'), COALESCE((SELECT MAX(id) FROM user_sub_course_progress), 1), true);
SELECT setval(pg_get_serial_sequence('user_sub_course_video_progress', 'id'), COALESCE((SELECT MAX(id) FROM user_sub_course_video_progress), 1), true);
SELECT setval(pg_get_serial_sequence('user_practice_progress', 'id'), COALESCE((SELECT MAX(id) FROM user_practice_progress), 1), true);

View File

@ -1 +1,29 @@
-- Intentionally empty: no demo account-deletion seed (login-only seed in 001). -- Seed account deletion request states for admin panel tracking
-- Users referenced here are seeded in 001_initial_seed_data.sql (IDs: 10, 11, 12).
-- Pending deletion request (within grace period)
UPDATE users
SET
deletion_requested_at = now() - interval '2 days',
deletion_scheduled_at = now() + interval '13 days',
deletion_cancelled_at = NULL,
updated_at = now()
WHERE id = 10;
-- Due deletion request (grace period elapsed, awaiting purge worker)
UPDATE users
SET
deletion_requested_at = now() - interval '20 days',
deletion_scheduled_at = now() - interval '5 days',
deletion_cancelled_at = NULL,
updated_at = now()
WHERE id = 11;
-- Cancelled deletion request (request made then cancelled)
UPDATE users
SET
deletion_requested_at = now() - interval '10 days',
deletion_scheduled_at = now() + interval '5 days',
deletion_cancelled_at = now() - interval '3 days',
updated_at = now()
WHERE id = 12;

View File

@ -1 +1,67 @@
-- Intentionally empty: no demo question seed (login-only seed in 001). -- Seed TRUE_FALSE and SHORT_ANSWER question types
-- Ensures question sets contain non-MCQ questions for end-to-end testing.
-- ======================================================
-- TRUE_FALSE questions (stored in questions + question_options)
-- ======================================================
INSERT INTO questions (
id,
question_text,
question_type,
difficulty_level,
points,
status,
created_at
)
VALUES
(27, 'The Python interpreter executes Python code top-to-bottom.', 'TRUE_FALSE', 'EASY', 1, 'PUBLISHED', CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- question_options for TRUE_FALSE: use two options with exactly one correct
INSERT INTO question_options (question_id, option_text, option_order, is_correct)
VALUES
(27, 'True', 1, TRUE),
(27, 'False', 2, FALSE)
ON CONFLICT DO NOTHING;
-- ======================================================
-- SHORT_ANSWER questions (stored in questions + question_short_answers)
-- ======================================================
INSERT INTO questions (
id,
question_text,
question_type,
difficulty_level,
points,
status,
created_at
)
VALUES
(29, 'What keyword is used in Python to define a function?', 'SHORT_ANSWER', 'EASY', 1, 'PUBLISHED', CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_short_answers (question_id, acceptable_answer, match_type)
VALUES
(29, 'def', 'EXACT')
ON CONFLICT DO NOTHING;
-- ======================================================
-- Link new questions into existing question sets
-- Question Set 1: Initial Assessment (set_id = 1, PUBLISHED)
-- Question Set 2: Python Basics Assessment (set_id = 2, PUBLISHED)
-- ======================================================
INSERT INTO question_set_items (set_id, question_id, display_order)
VALUES
(1, 27, 17),
(1, 29, 18),
(2, 27, 3),
(2, 29, 4)
ON CONFLICT (set_id, question_id) DO NOTHING;
-- ======================================================
-- Reset sequences to avoid ID collisions after seeding
-- ======================================================
SELECT setval(pg_get_serial_sequence('questions', 'id'), COALESCE((SELECT MAX(id) FROM questions), 1), true);
SELECT setval(pg_get_serial_sequence('question_options', 'id'), COALESCE((SELECT MAX(id) FROM question_options), 1), true);
SELECT setval(pg_get_serial_sequence('question_short_answers', 'id'), COALESCE((SELECT MAX(id) FROM question_short_answers), 1), true);

View File

@ -1,25 +0,0 @@
UPDATE question_sets qs
SET owner_type = 'SUB_COURSE',
owner_id = sm.legacy_sub_course_id
FROM sub_modules sm
WHERE qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
AND qs.set_type = 'PRACTICE'
AND sm.legacy_sub_course_id IS NOT NULL;
DROP TABLE IF EXISTS sub_module_practices CASCADE;
DROP TABLE IF EXISTS sub_module_videos CASCADE;
DROP TABLE IF EXISTS sub_modules CASCADE;
DROP TABLE IF EXISTS modules CASCADE;
DROP TABLE IF EXISTS levels CASCADE;
ALTER TABLE courses DROP COLUMN IF EXISTS sub_category_id;
DROP TABLE IF EXISTS course_sub_categories CASCADE;
-- Best-effort rollback to old expectation.
UPDATE user_practice_progress
SET sub_course_id = 1
WHERE sub_course_id IS NULL;
ALTER TABLE user_practice_progress
ALTER COLUMN sub_course_id SET NOT NULL;

View File

@ -1,228 +0,0 @@
-- 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;

View File

@ -1,4 +0,0 @@
DROP INDEX IF EXISTS idx_sub_module_lessons_sub_module_id;
DROP TABLE IF EXISTS sub_module_lessons;

View File

@ -1,15 +0,0 @@
-- Keep practices as a separate feature and introduce lessons as a new table.
CREATE TABLE IF NOT EXISTS sub_module_lessons (
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_lessons_sub_module_id
ON sub_module_lessons(sub_module_id);

View File

@ -1,4 +0,0 @@
DROP INDEX IF EXISTS idx_sub_module_practices_sub_module_id;
DROP TABLE IF EXISTS sub_module_practices;

View File

@ -1,35 +0,0 @@
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,
title VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
intro_video_url TEXT,
question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE,
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)
);
-- If the table already existed from older unified hierarchy migrations,
-- backfill missing columns so practices keep their own richer schema.
ALTER TABLE sub_module_practices
ADD COLUMN IF NOT EXISTS title VARCHAR(255);
ALTER TABLE sub_module_practices
ADD COLUMN IF NOT EXISTS description TEXT;
ALTER TABLE sub_module_practices
ADD COLUMN IF NOT EXISTS thumbnail TEXT;
UPDATE sub_module_practices
SET title = COALESCE(NULLIF(title, ''), 'Practice')
WHERE title IS NULL OR title = '';
ALTER TABLE sub_module_practices
ALTER COLUMN title SET NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sub_module_practices_sub_module_id
ON sub_module_practices(sub_module_id);

View File

@ -1,18 +0,0 @@
-- Restores legacy lesson columns. Rows will have NULL question_set_id until repopulated.
ALTER TABLE sub_module_lessons
ADD COLUMN IF NOT EXISTS question_set_id BIGINT REFERENCES question_sets(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS intro_video_url TEXT;
UPDATE sub_module_lessons
SET intro_video_url = teaching_video_url
WHERE teaching_video_url IS NOT NULL;
ALTER TABLE sub_module_lessons
DROP COLUMN IF EXISTS title,
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS thumbnail,
DROP COLUMN IF EXISTS teaching_text,
DROP COLUMN IF EXISTS teaching_image_url,
DROP COLUMN IF EXISTS teaching_audio_url,
DROP COLUMN IF EXISTS teaching_video_url;

View File

@ -1,37 +0,0 @@
-- Lessons are teaching content only (text, images, audio, video, thumbnail).
-- Question sets remain linked to practices, not lessons.
ALTER TABLE sub_module_lessons
ADD COLUMN IF NOT EXISTS title VARCHAR(255),
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS thumbnail TEXT,
ADD COLUMN IF NOT EXISTS teaching_text TEXT,
ADD COLUMN IF NOT EXISTS teaching_image_url TEXT,
ADD COLUMN IF NOT EXISTS teaching_audio_url TEXT,
ADD COLUMN IF NOT EXISTS teaching_video_url TEXT;
UPDATE sub_module_lessons sml
SET
title = qs.title,
description = qs.description
FROM question_sets qs
WHERE sml.question_set_id IS NOT NULL
AND qs.id = sml.question_set_id;
UPDATE sub_module_lessons
SET title = 'Lesson'
WHERE title IS NULL OR trim(title) = '';
UPDATE sub_module_lessons
SET teaching_video_url = intro_video_url
WHERE intro_video_url IS NOT NULL;
ALTER TABLE sub_module_lessons DROP CONSTRAINT IF EXISTS sub_module_lessons_question_set_id_fkey;
ALTER TABLE sub_module_lessons DROP CONSTRAINT IF EXISTS sub_module_lessons_question_set_id_key;
ALTER TABLE sub_module_lessons DROP COLUMN IF EXISTS question_set_id;
ALTER TABLE sub_module_lessons DROP COLUMN IF EXISTS intro_video_url;
ALTER TABLE sub_module_lessons
ALTER COLUMN title SET NOT NULL,
ALTER COLUMN title SET DEFAULT 'Lesson';

View File

@ -1,4 +0,0 @@
ALTER TABLE levels
DROP COLUMN IF EXISTS title,
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS thumbnail;

View File

@ -1,11 +0,0 @@
ALTER TABLE levels
ADD COLUMN IF NOT EXISTS title VARCHAR(255),
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS thumbnail TEXT;
UPDATE levels
SET title = cefr_level
WHERE title IS NULL OR trim(title) = '';
ALTER TABLE levels
ALTER COLUMN title SET NOT NULL;

View File

@ -1,12 +0,0 @@
DROP INDEX IF EXISTS idx_sub_module_capstones_sub_module_id;
DROP TABLE IF EXISTS sub_module_capstones;
ALTER TABLE question_sets DROP CONSTRAINT IF EXISTS question_sets_set_type_check;
ALTER TABLE question_sets ADD CONSTRAINT question_sets_set_type_check
CHECK (set_type IN (
'PRACTICE',
'INITIAL_ASSESSMENT',
'QUIZ',
'EXAM',
'SURVEY'
));

View File

@ -1,29 +0,0 @@
-- Capstone assessments: sub-module scoped, backed by question_sets (type CAPSTONE).
ALTER TABLE question_sets DROP CONSTRAINT IF EXISTS question_sets_set_type_check;
ALTER TABLE question_sets ADD CONSTRAINT question_sets_set_type_check
CHECK (set_type IN (
'PRACTICE',
'INITIAL_ASSESSMENT',
'QUIZ',
'EXAM',
'SURVEY',
'CAPSTONE'
));
CREATE TABLE IF NOT EXISTS sub_module_capstones (
id BIGSERIAL PRIMARY KEY,
sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
tips TEXT,
thumbnail TEXT,
question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE,
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_capstones_sub_module_id
ON sub_module_capstones (sub_module_id);

View File

@ -1,4 +0,0 @@
DROP INDEX IF EXISTS idx_module_capstones_module_id;
DROP TABLE IF EXISTS module_capstones;
ALTER TABLE modules DROP COLUMN IF EXISTS icon_url;

View File

@ -1,19 +0,0 @@
ALTER TABLE modules
ADD COLUMN IF NOT EXISTS icon_url TEXT;
CREATE TABLE IF NOT EXISTS module_capstones (
id BIGSERIAL PRIMARY KEY,
module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
tips TEXT,
thumbnail TEXT,
question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE,
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_module_capstones_module_id
ON module_capstones (module_id);

View File

@ -1,3 +0,0 @@
ALTER TABLE sub_modules
DROP COLUMN IF EXISTS tips,
DROP COLUMN IF EXISTS thumbnail;

View File

@ -1,3 +0,0 @@
ALTER TABLE sub_modules
ADD COLUMN IF NOT EXISTS thumbnail TEXT,
ADD COLUMN IF NOT EXISTS tips TEXT;

View File

@ -1,7 +0,0 @@
-- Restores fixed CEFR list; fails if any row has cefr_level outside the old set or longer than 2 characters.
ALTER TABLE levels
ALTER COLUMN cefr_level TYPE VARCHAR(2);
ALTER TABLE levels
ADD CONSTRAINT levels_cefr_level_check
CHECK (cefr_level IN ('A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3'));

View File

@ -1,20 +0,0 @@
-- Allow arbitrary level codes/labels per course (not only fixed CEFR bands).
DO $$
DECLARE
con_name text;
BEGIN
SELECT c.conname INTO con_name
FROM pg_constraint c
JOIN pg_class t ON c.conrelid = t.oid
JOIN pg_namespace n ON t.relnamespace = n.oid
WHERE n.nspname = current_schema()
AND t.relname = 'levels'
AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) LIKE '%cefr_level%IN (%A1%';
IF con_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE levels DROP CONSTRAINT %I', con_name);
END IF;
END $$;
ALTER TABLE levels
ALTER COLUMN cefr_level TYPE VARCHAR(64);

View File

@ -1,2 +0,0 @@
DROP INDEX IF EXISTS idx_team_refresh_tokens_team_member_id;
DROP TABLE IF EXISTS team_refresh_tokens;

View File

@ -1,11 +0,0 @@
CREATE TABLE IF NOT EXISTS team_refresh_tokens (
id BIGSERIAL PRIMARY KEY,
team_member_id BIGINT NOT NULL REFERENCES team_members(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_team_refresh_tokens_team_member_id
ON team_refresh_tokens (team_member_id);

View File

@ -1,3 +0,0 @@
ALTER TABLE sub_module_lessons DROP COLUMN IF EXISTS inactive_since;
ALTER TABLE sub_module_practices DROP COLUMN IF EXISTS inactive_since;
ALTER TABLE sub_module_capstones DROP COLUMN IF EXISTS inactive_since;

View File

@ -1,26 +0,0 @@
-- Track when submodule lessons, practices, and capstones became inactive for retention-based hard delete.
ALTER TABLE sub_module_lessons
ADD COLUMN IF NOT EXISTS inactive_since TIMESTAMPTZ;
ALTER TABLE sub_module_practices
ADD COLUMN IF NOT EXISTS inactive_since TIMESTAMPTZ;
ALTER TABLE sub_module_capstones
ADD COLUMN IF NOT EXISTS inactive_since TIMESTAMPTZ;
-- Existing inactive rows: start retention window from migration time (conservative).
UPDATE sub_module_lessons
SET inactive_since = NOW()
WHERE is_active = FALSE
AND inactive_since IS NULL;
UPDATE sub_module_practices
SET inactive_since = NOW()
WHERE is_active = FALSE
AND inactive_since IS NULL;
UPDATE sub_module_capstones
SET inactive_since = NOW()
WHERE is_active = FALSE
AND inactive_since IS NULL;

View File

@ -1 +0,0 @@
-- Restoring the removed course hierarchy is not supported; apply new migrations for the next model.

View File

@ -1,46 +0,0 @@
-- Tear down the legacy course / learning-tree schema so a new hierarchy can be introduced.
BEGIN;
-- Entry-assessment automation on sub_courses (from 000024)
DROP TRIGGER IF EXISTS trg_sub_courses_create_entry_assessment ON sub_courses;
DROP FUNCTION IF EXISTS create_sub_course_entry_assessment();
DROP FUNCTION IF EXISTS clone_default_initial_assessment_items(BIGINT);
DROP INDEX IF EXISTS idx_question_sets_unique_subcourse_initial_assessment;
ALTER TABLE question_sets DROP COLUMN IF EXISTS sub_course_video_id;
-- Dependent objects first
DROP TABLE IF EXISTS user_sub_course_video_progress CASCADE;
DROP TABLE IF EXISTS user_practice_progress CASCADE;
DROP TABLE IF EXISTS sub_course_prerequisites CASCADE;
DROP TABLE IF EXISTS user_sub_course_progress CASCADE;
DROP TABLE IF EXISTS sub_module_practices CASCADE;
DROP TABLE IF EXISTS sub_module_capstones CASCADE;
DROP TABLE IF EXISTS sub_module_lessons CASCADE;
DROP TABLE IF EXISTS sub_module_videos CASCADE;
DROP TABLE IF EXISTS sub_modules CASCADE;
DROP TABLE IF EXISTS module_capstones CASCADE;
DROP TABLE IF EXISTS modules CASCADE;
DROP TABLE IF EXISTS levels CASCADE;
DROP TABLE IF EXISTS sub_course_videos CASCADE;
DROP TABLE IF EXISTS sub_courses CASCADE;
DROP TABLE IF EXISTS course_sub_categories CASCADE;
DROP TABLE IF EXISTS courses CASCADE;
DROP TABLE IF EXISTS course_categories CASCADE;
-- Keep learner practice completion for the questions system (no sub_course column)
CREATE TABLE user_practice_progress (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, question_set_id)
);
CREATE INDEX idx_user_practice_progress_user_id ON user_practice_progress(user_id);
COMMIT;

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS programs;

View File

@ -1,11 +0,0 @@
-- Top-level LMS program (e.g. Beginner / Intermediate / Advanced — labels come from admin config later).
CREATE TABLE programs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_programs_created_at ON programs (created_at DESC);

View File

@ -1,4 +0,0 @@
DELETE FROM programs
WHERE (name = 'Beginner' AND description = 'Default program for the beginner level.')
OR (name = 'Intermediate' AND description = 'Default program for the intermediate level.')
OR (name = 'Advanced' AND description = 'Default program for the advanced level.');

View File

@ -1,6 +0,0 @@
-- Default top-level programs (hierarchy: Program → Course → …).
INSERT INTO programs (name, description, thumbnail)
VALUES
('Beginner', 'Default program for the beginner level.', NULL),
('Intermediate', 'Default program for the intermediate level.', NULL),
('Advanced', 'Default program for the advanced level.', NULL);

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS courses;

View File

@ -1,13 +0,0 @@
-- Courses belong to a Program (CEFR-style labels like A1..C2 will be configured separately).
CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
program_id BIGINT NOT NULL REFERENCES programs (id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_courses_program_id ON courses (program_id);
CREATE INDEX idx_courses_program_created ON courses (program_id, created_at DESC);

View File

@ -1,2 +0,0 @@
DROP TABLE IF EXISTS modules;
ALTER TABLE courses DROP CONSTRAINT IF EXISTS courses_program_id_id_key;

View File

@ -1,22 +0,0 @@
-- Modules belong to a Course; program_id is denormalized and enforced with the course by a composite FK.
ALTER TABLE courses
ADD CONSTRAINT courses_program_id_id_key UNIQUE (program_id, id);
CREATE TABLE modules (
id BIGSERIAL PRIMARY KEY,
program_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
icon TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ,
CONSTRAINT modules_course_scope_fkey
FOREIGN KEY (program_id, course_id)
REFERENCES courses (program_id, id)
ON DELETE CASCADE
);
CREATE INDEX idx_modules_course_id ON modules (course_id);
CREATE INDEX idx_modules_program_id ON modules (program_id);
CREATE INDEX idx_modules_program_course_created ON modules (program_id, course_id, created_at DESC);

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS lessons;

View File

@ -1,14 +0,0 @@
-- Lessons belong to a Module.
CREATE TABLE lessons (
id BIGSERIAL PRIMARY KEY,
module_id BIGINT NOT NULL REFERENCES modules (id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
video_url TEXT,
thumbnail TEXT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_lessons_module_id ON lessons (module_id);
CREATE INDEX idx_lessons_module_created ON lessons (module_id, created_at DESC);

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS lms_practices;

View File

@ -1,29 +0,0 @@
-- Practices attach to exactly one of: course, module, or lesson.
CREATE TABLE lms_practices (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT REFERENCES courses (id) ON DELETE CASCADE,
module_id BIGINT REFERENCES modules (id) ON DELETE CASCADE,
lesson_id BIGINT REFERENCES lessons (id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
story_description TEXT,
story_image TEXT,
persona_id BIGINT REFERENCES users (id) ON DELETE SET NULL,
question_set_id BIGINT NOT NULL REFERENCES question_sets (id) ON DELETE RESTRICT,
quick_tips TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ,
CONSTRAINT lms_practices_one_parent CHECK (
(course_id IS NOT NULL)::int
+ (module_id IS NOT NULL)::int
+ (lesson_id IS NOT NULL)::int
= 1
)
);
CREATE INDEX idx_lms_practices_course_id ON lms_practices (course_id);
CREATE INDEX idx_lms_practices_module_id ON lms_practices (module_id);
CREATE INDEX idx_lms_practices_lesson_id ON lms_practices (lesson_id);
CREATE INDEX idx_lms_practices_question_set_id ON lms_practices (question_set_id);
CREATE INDEX idx_lms_practices_course_created ON lms_practices (course_id, created_at DESC);
CREATE INDEX idx_lms_practices_module_created ON lms_practices (module_id, created_at DESC);
CREATE INDEX idx_lms_practices_lesson_created ON lms_practices (lesson_id, created_at DESC);

View File

@ -1,3 +0,0 @@
DELETE FROM courses
WHERE description = 'Default CEFR level course (system seed).'
AND name IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2');

View File

@ -1,19 +0,0 @@
-- Default CEFR-style courses per seeded program: Beginner→A1,A2; Intermediate→B1,B2; Advanced→C1,C2.
-- Custom courses can still be created via the API with any name.
INSERT INTO courses (program_id, name, description, thumbnail)
SELECT
p.id,
v.name,
'Default CEFR level course (system seed).',
NULL
FROM programs AS p
INNER JOIN (
VALUES
('Beginner', 'A1'),
('Beginner', 'A2'),
('Intermediate', 'B1'),
('Intermediate', 'B2'),
('Advanced', 'C1'),
('Advanced', 'C2')
) AS v (program_name, name)
ON p.name = v.program_name;

View File

@ -1,18 +0,0 @@
DROP TABLE IF EXISTS lms_user_program_progress;
DROP TABLE IF EXISTS lms_user_course_progress;
DROP TABLE IF EXISTS lms_user_module_progress;
DROP TABLE IF EXISTS lms_user_lesson_progress;
DROP INDEX IF EXISTS uq_lessons_module_sort;
DROP INDEX IF EXISTS uq_modules_course_sort;
DROP INDEX IF EXISTS uq_courses_program_sort;
DROP INDEX IF EXISTS uq_programs_sort_order;
ALTER TABLE lessons
DROP COLUMN IF EXISTS sort_order;
ALTER TABLE modules
DROP COLUMN IF EXISTS sort_order;
ALTER TABLE courses
DROP COLUMN IF EXISTS sort_order;
ALTER TABLE programs
DROP COLUMN IF EXISTS sort_order;

View File

@ -1,150 +0,0 @@
-- Sequential order for programs, courses, modules, and lessons (1 = first in each scope).
-- Progress tables mark completion; API enforces prerequisites for learners (STUDENT role).
ALTER TABLE programs
ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE courses
ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE modules
ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE lessons
ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
-- Program order (one global sequence): Beginner -> Intermediate -> Advanced; others by id
UPDATE programs
SET sort_order = v.so
FROM (
VALUES
('Beginner', 1),
('Intermediate', 2),
('Advanced', 3)
) AS v (name, so)
WHERE programs.name = v.name;
UPDATE programs
SET sort_order = 1000 + r.rn
FROM (
SELECT
id,
row_number() OVER (
ORDER BY id
) AS rn
FROM programs
WHERE
sort_order = 0
) AS r
WHERE
programs.id = r.id;
-- CEFR courses: A1..C2; remaining courses in each program: stable order
UPDATE courses
SET sort_order = CASE name
WHEN 'A1' THEN
1
WHEN 'A2' THEN
2
WHEN 'B1' THEN
3
WHEN 'B2' THEN
4
WHEN 'C1' THEN
5
WHEN 'C2' THEN
6
ELSE
0
END
WHERE
name IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2');
UPDATE courses c
SET sort_order = 2000 + s.rn
FROM (
SELECT
id,
row_number() OVER (
PARTITION BY program_id
ORDER BY
id
) AS rn
FROM courses
WHERE
sort_order = 0
) AS s
WHERE
c.id = s.id;
UPDATE modules m
SET sort_order = r.rn
FROM (
SELECT
id,
row_number() OVER (
PARTITION BY course_id
ORDER BY
id
) AS rn
FROM modules
) AS r
WHERE
m.id = r.id;
UPDATE lessons l
SET sort_order = r.rn
FROM (
SELECT
id,
row_number() OVER (
PARTITION BY module_id
ORDER BY
id
) AS rn
FROM lessons
) AS r
WHERE
l.id = r.id;
CREATE UNIQUE INDEX uq_programs_sort_order ON programs (sort_order);
CREATE UNIQUE INDEX uq_courses_program_sort ON courses (program_id, sort_order);
CREATE UNIQUE INDEX uq_modules_course_sort ON modules (course_id, sort_order);
CREATE UNIQUE INDEX uq_lessons_module_sort ON lessons (module_id, sort_order);
CREATE TABLE lms_user_lesson_progress (
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
lesson_id BIGINT NOT NULL REFERENCES lessons (id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, lesson_id)
);
CREATE INDEX idx_lms_user_lesson_progress_user ON lms_user_lesson_progress (user_id);
CREATE INDEX idx_lms_user_lesson_progress_lesson ON lms_user_lesson_progress (lesson_id);
CREATE TABLE lms_user_module_progress (
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
module_id BIGINT NOT NULL REFERENCES modules (id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, module_id)
);
CREATE INDEX idx_lms_user_module_progress_user ON lms_user_module_progress (user_id);
CREATE INDEX idx_lms_user_module_progress_module ON lms_user_module_progress (module_id);
CREATE TABLE lms_user_course_progress (
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
course_id BIGINT NOT NULL REFERENCES courses (id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, course_id)
);
CREATE INDEX idx_lms_user_course_progress_user ON lms_user_course_progress (user_id);
CREATE INDEX idx_lms_user_course_progress_course ON lms_user_course_progress (course_id);
CREATE TABLE lms_user_program_progress (
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
program_id BIGINT NOT NULL REFERENCES programs (id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, program_id)
);
CREATE INDEX idx_lms_user_program_progress_user ON lms_user_program_progress (user_id);
CREATE INDEX idx_lms_user_program_progress_program ON lms_user_program_progress (program_id);

View File

@ -1 +0,0 @@
-- Data cleanup is not reversed; restoring the old cross-product seed would be ambiguous.

View File

@ -1,45 +0,0 @@
-- Align default seeded courses with program: Beginner→A1,A2; Intermediate→B1,B2; Advanced→C1,C2.
-- Only touches rows with the system seed description; custom courses are unchanged.
-- Removing a course cascades to modules, lessons, and related LMS progress (see FKs on those tables).
DELETE FROM courses AS c
USING programs AS p
WHERE c.program_id = p.id
AND c.description = 'Default CEFR level course (system seed).'
AND (
(
p.name = 'Beginner'
AND c.name IN ('B1', 'B2', 'C1', 'C2')
)
OR (
p.name = 'Intermediate'
AND c.name IN ('A1', 'A2', 'C1', 'C2')
)
OR (
p.name = 'Advanced'
AND c.name IN ('A1', 'A2', 'B1', 'B2')
)
);
INSERT INTO courses (program_id, name, description, thumbnail)
SELECT
p.id,
v.name,
'Default CEFR level course (system seed).',
NULL
FROM programs AS p
INNER JOIN (
VALUES
('Beginner', 'A1'),
('Beginner', 'A2'),
('Intermediate', 'B1'),
('Intermediate', 'B2'),
('Advanced', 'C1'),
('Advanced', 'C2')
) AS v (program_name, name)
ON p.name = v.program_name
WHERE
NOT EXISTS (
SELECT 1 FROM courses AS e
WHERE e.program_id = p.id AND e.name = v.name
);

View File

@ -1,2 +0,0 @@
DROP TABLE IF EXISTS exam_prep.catalog_courses;
DROP SCHEMA IF EXISTS exam_prep;

View File

@ -1,15 +0,0 @@
-- Standalone exam-prep content hierarchy (DET, IELTS, TOEFL, etc.) — isolated from LMS Learn English tables.
CREATE SCHEMA IF NOT EXISTS exam_prep;
-- Top-level catalog "course" (e.g. Duolingo English Test, IELTS); admin-configurable labels.
CREATE TABLE exam_prep.catalog_courses (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_exam_prep_catalog_courses_sort ON exam_prep.catalog_courses (sort_order, id);

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS exam_prep.units;

View File

@ -1,14 +0,0 @@
-- Units under an exam-prep catalog course (e.g. "Introduction to the DET English Test").
CREATE TABLE exam_prep.units (
id BIGSERIAL PRIMARY KEY,
catalog_course_id BIGINT NOT NULL REFERENCES exam_prep.catalog_courses (id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_exam_prep_units_catalog_course_id ON exam_prep.units (catalog_course_id);
CREATE INDEX idx_exam_prep_units_catalog_sort ON exam_prep.units (catalog_course_id, sort_order, id);

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS exam_prep.unit_modules;

View File

@ -1,15 +0,0 @@
-- Modules under an exam-prep unit (table name unit_modules avoids sqlc/LMS collision with public.modules).
CREATE TABLE exam_prep.unit_modules (
id BIGSERIAL PRIMARY KEY,
unit_id BIGINT NOT NULL REFERENCES exam_prep.units (id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
icon TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_exam_prep_unit_modules_unit_id ON exam_prep.unit_modules (unit_id);
CREATE INDEX idx_exam_prep_unit_modules_unit_sort ON exam_prep.unit_modules (unit_id, sort_order, id);

View File

@ -1,2 +0,0 @@
DROP INDEX IF EXISTS uq_exam_prep_unit_module_lessons_sort;
DROP TABLE IF EXISTS exam_prep.unit_module_lessons;

View File

@ -1,17 +0,0 @@
-- Lessons under an exam-prep unit module (mirrors LMS lessons under modules; avoids collision with public.lessons / sqlc).
CREATE TABLE exam_prep.unit_module_lessons (
id BIGSERIAL PRIMARY KEY,
unit_module_id BIGINT NOT NULL REFERENCES exam_prep.unit_modules (id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
video_url TEXT,
thumbnail TEXT,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uq_exam_prep_unit_module_lessons_sort ON exam_prep.unit_module_lessons (unit_module_id, sort_order);
CREATE INDEX idx_exam_prep_unit_module_lessons_module_id ON exam_prep.unit_module_lessons (unit_module_id);
CREATE INDEX idx_exam_prep_unit_module_lessons_module_created ON exam_prep.unit_module_lessons (unit_module_id, created_at DESC);

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS exam_prep.lesson_practices;

View File

@ -1,17 +0,0 @@
-- Exam-prep practices: one row per practice, attached to an exam-prep lesson only; reuses public.question_sets / questions.
CREATE TABLE exam_prep.lesson_practices (
id BIGSERIAL PRIMARY KEY,
unit_module_lesson_id BIGINT NOT NULL REFERENCES exam_prep.unit_module_lessons (id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
story_description TEXT,
story_image TEXT,
persona_id BIGINT REFERENCES users (id) ON DELETE SET NULL,
question_set_id BIGINT NOT NULL REFERENCES question_sets (id) ON DELETE RESTRICT,
quick_tips TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_exam_prep_lesson_practices_lesson_id ON exam_prep.lesson_practices (unit_module_lesson_id);
CREATE INDEX idx_exam_prep_lesson_practices_question_set_id ON exam_prep.lesson_practices (question_set_id);
CREATE INDEX idx_exam_prep_lesson_practices_lesson_created ON exam_prep.lesson_practices (unit_module_lesson_id, created_at DESC);

View File

@ -1,3 +0,0 @@
DROP INDEX IF EXISTS idx_question_type_definitions_status;
DROP TABLE IF EXISTS question_type_definitions;

View File

@ -1,56 +0,0 @@
CREATE TABLE IF NOT EXISTS question_type_definitions (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(64) NOT NULL UNIQUE,
display_name VARCHAR(120) NOT NULL,
description TEXT,
stimulus_component_kinds TEXT[] NOT NULL DEFAULT '{}',
response_component_kinds TEXT[] NOT NULL DEFAULT '{}',
is_system BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_question_type_definitions_status
ON question_type_definitions(status);
INSERT INTO question_type_definitions
(key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status)
VALUES
(
'multiple_choice',
'Multiple Choice',
'Select one correct answer from a list of options.',
ARRAY['INSTRUCTION']::TEXT[],
ARRAY['MULTIPLE_CHOICE']::TEXT[],
TRUE,
'ACTIVE'
),
(
'true_false',
'True / False',
'Binary response question with true/false options.',
ARRAY['INSTRUCTION']::TEXT[],
ARRAY['MULTIPLE_CHOICE']::TEXT[],
TRUE,
'ACTIVE'
),
(
'fill_in_the_blank',
'Fill In The Blank',
'Learner fills missing words in a prompt or passage.',
ARRAY['TEXT_PASSAGE', 'SELECT_MISSING_WORDS']::TEXT[],
ARRAY['TEXT_INPUT', 'SELECT_MISSING_WORDS']::TEXT[],
TRUE,
'ACTIVE'
),
(
'short_answer',
'Short Answer',
'Learner provides a concise text answer.',
ARRAY['INSTRUCTION']::TEXT[],
ARRAY['SHORT_ANSWER']::TEXT[],
TRUE,
'ACTIVE'
)
ON CONFLICT (key) DO NOTHING;

View File

@ -1,7 +0,0 @@
DROP INDEX IF EXISTS idx_questions_question_type_definition_id;
ALTER TABLE questions
DROP CONSTRAINT IF EXISTS questions_question_type_definition_id_fkey;
ALTER TABLE questions
DROP COLUMN IF EXISTS question_type_definition_id;

View File

@ -1,14 +0,0 @@
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS question_type_definition_id BIGINT NULL;
ALTER TABLE questions
DROP CONSTRAINT IF EXISTS questions_question_type_definition_id_fkey;
ALTER TABLE questions
ADD CONSTRAINT questions_question_type_definition_id_fkey
FOREIGN KEY (question_type_definition_id)
REFERENCES question_type_definitions(id)
ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_questions_question_type_definition_id
ON questions(question_type_definition_id);

View File

@ -1,13 +0,0 @@
ALTER TABLE question_type_definitions
DROP COLUMN IF EXISTS stimulus_schema,
DROP COLUMN IF EXISTS response_schema;
ALTER TABLE questions
DROP COLUMN IF EXISTS dynamic_payload;
ALTER TABLE questions
DROP CONSTRAINT IF EXISTS questions_question_type_check;
ALTER TABLE questions
ADD CONSTRAINT questions_question_type_check
CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER', 'AUDIO'));

View File

@ -1,13 +0,0 @@
ALTER TABLE questions
DROP CONSTRAINT IF EXISTS questions_question_type_check;
ALTER TABLE questions
ADD CONSTRAINT questions_question_type_check
CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER', 'AUDIO', 'DYNAMIC'));
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS dynamic_payload JSONB NULL;
ALTER TABLE question_type_definitions
ADD COLUMN IF NOT EXISTS stimulus_schema JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS response_schema JSONB NOT NULL DEFAULT '[]'::jsonb;

View File

@ -1,5 +0,0 @@
DROP INDEX IF EXISTS idx_faqs_display_order;
DROP INDEX IF EXISTS idx_faqs_category;
DROP INDEX IF EXISTS idx_faqs_status;
DROP TABLE IF EXISTS faqs;

View File

@ -1,14 +0,0 @@
CREATE TABLE IF NOT EXISTS faqs (
id BIGSERIAL PRIMARY KEY,
question TEXT NOT NULL,
answer TEXT NOT NULL,
category VARCHAR(100),
display_order INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_faqs_status ON faqs(status);
CREATE INDEX IF NOT EXISTS idx_faqs_category ON faqs(category);
CREATE INDEX IF NOT EXISTS idx_faqs_display_order ON faqs(display_order);

View File

@ -1,5 +0,0 @@
ALTER TABLE lms_practices DROP CONSTRAINT chk_lms_practices_publish_status;
ALTER TABLE lms_practices DROP COLUMN publish_status;
ALTER TABLE exam_prep.lesson_practices DROP CONSTRAINT chk_exam_prep_lesson_practices_publish_status;
ALTER TABLE exam_prep.lesson_practices DROP COLUMN publish_status;

View File

@ -1,8 +0,0 @@
-- Draft vs published visibility for LMS and exam-prep practices.
ALTER TABLE lms_practices
ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED'
CONSTRAINT chk_lms_practices_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED'));
ALTER TABLE exam_prep.lesson_practices
ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED'
CONSTRAINT chk_exam_prep_lesson_practices_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED'));

View File

@ -1,5 +0,0 @@
DELETE FROM users WHERE id = 13 AND email = 'openlearner@yimaru.com';
DELETE FROM role_permissions WHERE role_id = (SELECT id FROM roles WHERE name = 'OPEN_LEARNER');
DELETE FROM roles WHERE name = 'OPEN_LEARNER';

View File

@ -1,79 +0,0 @@
-- OPEN_LEARNER: learner role with STUDENT-like RBAC but without LMS sequential prerequisite locks (handled in app code).
CREATE EXTENSION IF NOT EXISTS pgcrypto;
INSERT INTO roles (name, description, is_system) VALUES
(
'OPEN_LEARNER',
'Learner with full LMS catalog access without sequential prerequisite locking',
TRUE
)
ON CONFLICT (name) DO NOTHING;
-- Demo OPEN_LEARNER (customer-login): openlearner@yimaru.com / password@123
INSERT INTO users (
id,
first_name,
last_name,
gender,
birth_day,
email,
phone_number,
role,
password,
age_group,
education_level,
country,
region,
knowledge_level,
nick_name,
occupation,
learning_goal,
language_goal,
language_challange,
favourite_topic,
initial_assessment_completed,
email_verified,
phone_verified,
status,
last_login,
profile_completed,
profile_picture_url,
preferred_language,
created_at,
updated_at
)
VALUES
(
13,
'Demo',
'OpenLearner',
'Female',
'1999-06-01',
'openlearner@yimaru.com',
NULL,
'OPEN_LEARNER',
crypt('password@123', gen_salt('bf'))::bytea,
'25_34',
'Bachelor',
'Ethiopia',
'Addis Ababa',
'BEGINNER',
'OpenLearner',
'Tester',
'Preview LMS content without sequential locks',
'English',
'Grammar',
'Technology',
FALSE,
TRUE,
FALSE,
'ACTIVE',
NULL,
FALSE,
NULL,
'en',
CURRENT_TIMESTAMP,
NULL
)
ON CONFLICT (id) DO NOTHING;

View File

@ -1,3 +0,0 @@
ALTER TABLE lessons DROP CONSTRAINT IF EXISTS chk_lessons_publish_status;
ALTER TABLE lessons DROP COLUMN IF EXISTS publish_status;

View File

@ -1,9 +0,0 @@
-- Draft vs published visibility for LMS lessons (mirrors lms_practices.publish_status).
ALTER TABLE lessons
ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED'
CONSTRAINT chk_lessons_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED'));
-- New inserts default to draft unless the API sends PUBLISHED; existing rows stay published.
ALTER TABLE lessons
ALTER COLUMN publish_status SET DEFAULT 'DRAFT';

View File

@ -1,21 +0,0 @@
ALTER TABLE exam_prep.lesson_practices DROP CONSTRAINT IF EXISTS lesson_practices_persona_id_fkey;
UPDATE exam_prep.lesson_practices
SET persona_id = NULL;
ALTER TABLE exam_prep.lesson_practices
ADD CONSTRAINT lesson_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES users (id) ON DELETE SET NULL;
ALTER TABLE lms_practices DROP CONSTRAINT IF EXISTS lms_practices_persona_id_fkey;
UPDATE lms_practices
SET persona_id = NULL;
ALTER TABLE lms_practices
ADD CONSTRAINT lms_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES users (id) ON DELETE SET NULL;
-- Remove seeded default personas before dropping the catalog table.
DELETE FROM lms_personas
WHERE id IN (1, 2, 3);
DROP TABLE IF EXISTS lms_personas;

View File

@ -1,64 +0,0 @@
-- Catalog of LMS personas (coach/avatar profiles) referenced by Learn English + exam-prep practices.
CREATE TABLE lms_personas (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
avatar_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_lms_personas_is_active ON lms_personas (is_active)
WHERE is_active;
CREATE INDEX idx_lms_personas_created_at ON lms_personas (created_at DESC);
-- Default catalog personas (stable ids for envs and Postman); add more via API.
INSERT INTO lms_personas (id, name, description, avatar_url, is_active)
VALUES
(
1,
'Friendly Coach',
'Warm, encouraging tutor for everyday conversational practice.',
NULL,
TRUE
),
(
2,
'Exam Coach',
'Structured, exam-style guidance and clear checkpoints.',
NULL,
TRUE
),
(
3,
'Story Narrator',
'Story-led scenarios with character-driven prompts.',
NULL,
TRUE
);
SELECT setval(
pg_get_serial_sequence('lms_personas', 'id'),
(SELECT COALESCE(MAX(id), 1) FROM lms_personas)
);
-- persona_id historically referenced users.id; personas are now catalog rows on lms_personas.
ALTER TABLE lms_practices DROP CONSTRAINT IF EXISTS lms_practices_persona_id_fkey;
UPDATE lms_practices
SET persona_id = NULL
WHERE persona_id IS NOT NULL;
ALTER TABLE lms_practices
ADD CONSTRAINT lms_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES lms_personas (id) ON DELETE SET NULL;
ALTER TABLE exam_prep.lesson_practices DROP CONSTRAINT IF EXISTS lesson_practices_persona_id_fkey;
UPDATE exam_prep.lesson_practices
SET persona_id = NULL
WHERE persona_id IS NOT NULL;
ALTER TABLE exam_prep.lesson_practices
ADD CONSTRAINT lesson_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES lms_personas (id) ON DELETE SET NULL;

View File

@ -1,2 +0,0 @@
ALTER TABLE lms_personas
RENAME COLUMN profile_picture TO avatar_url;

View File

@ -1,3 +0,0 @@
-- Persona profile image URL stored as profile_picture (replaces avatar_url naming).
ALTER TABLE lms_personas
RENAME COLUMN avatar_url TO profile_picture;

View File

@ -1,2 +0,0 @@
ALTER TABLE lms_personas
DROP COLUMN IF EXISTS gender;

View File

@ -1,2 +0,0 @@
ALTER TABLE lms_personas
ADD COLUMN gender TEXT;

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS email_templates;

View File

@ -1,186 +0,0 @@
CREATE TABLE IF NOT EXISTS email_templates (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
subject TEXT NOT NULL,
body_text TEXT NOT NULL,
body_html TEXT NOT NULL,
variables JSONB NOT NULL DEFAULT '[]'::jsonb,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_email_templates_status ON email_templates(status);
CREATE INDEX IF NOT EXISTS idx_email_templates_slug ON email_templates(slug);
INSERT INTO email_templates (slug, name, subject, body_text, body_html, variables, is_system, status)
VALUES
(
'otp',
'One-Time Password',
'Yimaru Academy — Your verification code',
'Yimaru Academy{{if .FirstName}}, {{.FirstName}}{{end}}
Your verification code is {{.OTP}}.
It expires in {{.ExpiresMinutes}} minutes.
Please do not share this code with anyone.',
$otp_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;letter-spacing:0.3px;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;line-height:1.3;">Your verification code</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}use the code below to continue signing in to Yimaru Academy.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td style="background-color:#eef4ff;border-radius:8px;padding:20px;border:1px solid #e0e8f5;text-align:center;">
<p style="margin:0 0 6px;color:#9d2a83;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;">One-time password</p>
<p style="margin:0;color:#333333;font-size:34px;font-weight:700;letter-spacing:8px;font-family:Consolas,Monaco,monospace;">{{.OTP}}</p>
<p style="margin:12px 0 0;color:#666666;font-size:13px;">Expires in {{.ExpiresMinutes}} minutes</p>
</td></tr></table>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">If you did not request this code, you can safely ignore this email.</p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;line-height:1.5;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$otp_html$,
'["OTP", "FirstName", "ExpiresMinutes"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'invitation',
'User Invitation',
'You are invited to Yimaru Academy',
'Hi{{if .FirstName}} {{.FirstName}}{{end}},
You have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Academy.
Accept your invitation: {{.InviteLink}}',
$invite_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">You&rsquo;re invited</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}you have been invited{{if .InviterName}} by <strong style="color:#9d2a83;">{{.InviterName}}</strong>{{end}} to join Yimaru Academy.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.InviteLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Accept invitation</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">Or copy this link: <a href="{{.InviteLink}}" style="color:#9d2a83;">{{.InviteLink}}</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$invite_html$,
'["FirstName", "InviterName", "InviteLink"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'password_reset',
'Password Reset',
'Reset your Yimaru Academy password',
'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Reset your password: {{.ResetLink}}
This link expires in {{.ExpiresMinutes}} minutes.',
$reset_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Reset your password</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}we received a request to reset your Yimaru Academy password. The link below expires in {{.ExpiresMinutes}} minutes.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.ResetLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Reset password</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">If you did not request a reset, ignore this email.</p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$reset_html$,
'["FirstName", "ResetLink", "ExpiresMinutes"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'welcome',
'Welcome Email',
'Welcome to Yimaru Academy',
'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Welcome to Yimaru Academy! Sign in to get started: {{.LoginURL}}',
$welcome_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Welcome aboard</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}your Yimaru Academy account is ready. Start learning at your own pace.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.LoginURL}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Sign in to Yimaru Academy</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$welcome_html$,
'["FirstName", "LoginURL"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'custom_message',
'Custom Message',
'{{.Subject}}',
'{{.Message}}',
$custom_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">{{.Subject}}</h1>
<div style="margin:0;color:#666666;font-size:15px;line-height:1.6;">{{.Message}}</div>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$custom_html$,
'["Subject", "Message"]'::jsonb,
TRUE,
'ACTIVE'
)
ON CONFLICT (slug) DO NOTHING;

View File

@ -1 +0,0 @@
-- No-op: branded template content is not reverted automatically.

View File

@ -1,156 +0,0 @@
-- Refresh system email templates with Yimaru Academy branded HTML (admin portal colors).
-- Safe to run after 000066 when seeds used the original plain layout.
UPDATE email_templates SET
name = 'One-Time Password',
subject = 'Yimaru Academy — Your verification code',
body_text = 'Yimaru Academy{{if .FirstName}}, {{.FirstName}}{{end}}
Your verification code is {{.OTP}}.
It expires in {{.ExpiresMinutes}} minutes.
Please do not share this code with anyone.',
body_html = $otp_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;letter-spacing:0.3px;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;line-height:1.3;">Your verification code</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}use the code below to continue signing in to Yimaru Academy.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td style="background-color:#eef4ff;border-radius:8px;padding:20px;border:1px solid #e0e8f5;text-align:center;">
<p style="margin:0 0 6px;color:#9d2a83;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;">One-time password</p>
<p style="margin:0;color:#333333;font-size:34px;font-weight:700;letter-spacing:8px;font-family:Consolas,Monaco,monospace;">{{.OTP}}</p>
<p style="margin:12px 0 0;color:#666666;font-size:13px;">Expires in {{.ExpiresMinutes}} minutes</p>
</td></tr></table>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">If you did not request this code, you can safely ignore this email.</p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;line-height:1.5;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$otp_html$,
updated_at = NOW()
WHERE slug = 'otp';
UPDATE email_templates SET
name = 'User Invitation',
subject = 'You are invited to Yimaru Academy',
body_text = 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
You have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Academy.
Accept your invitation: {{.InviteLink}}',
body_html = $invite_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">You&rsquo;re invited</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}you have been invited{{if .InviterName}} by <strong style="color:#9d2a83;">{{.InviterName}}</strong>{{end}} to join Yimaru Academy.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.InviteLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Accept invitation</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">Or copy this link: <a href="{{.InviteLink}}" style="color:#9d2a83;">{{.InviteLink}}</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$invite_html$,
updated_at = NOW()
WHERE slug = 'invitation';
UPDATE email_templates SET
name = 'Password Reset',
subject = 'Reset your Yimaru Academy password',
body_text = 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Reset your password: {{.ResetLink}}
This link expires in {{.ExpiresMinutes}} minutes.',
body_html = $reset_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Reset your password</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}we received a request to reset your Yimaru Academy password. The link below expires in {{.ExpiresMinutes}} minutes.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.ResetLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Reset password</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">If you did not request a reset, ignore this email.</p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$reset_html$,
updated_at = NOW()
WHERE slug = 'password_reset';
UPDATE email_templates SET
name = 'Welcome Email',
subject = 'Welcome to Yimaru Academy',
body_text = 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Welcome to Yimaru Academy! Sign in to get started: {{.LoginURL}}',
body_html = $welcome_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Welcome aboard</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}your Yimaru Academy account is ready. Start learning at your own pace.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.LoginURL}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Sign in to Yimaru Academy</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$welcome_html$,
updated_at = NOW()
WHERE slug = 'welcome';
UPDATE email_templates SET
name = 'Custom Message',
body_html = $custom_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">{{.Subject}}</h1>
<div style="margin:0;color:#666666;font-size:15px;line-height:1.6;">{{.Message}}</div>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$custom_html$,
updated_at = NOW()
WHERE slug = 'custom_message';

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS team_invitations;

View File

@ -1,18 +0,0 @@
CREATE TABLE IF NOT EXISTS team_invitations (
id BIGSERIAL PRIMARY KEY,
team_member_id BIGINT NOT NULL REFERENCES team_members(id) ON DELETE CASCADE,
token VARCHAR(128) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (
status IN ('pending', 'accepted', 'expired', 'revoked')
),
expires_at TIMESTAMPTZ NOT NULL,
invited_by BIGINT,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_team_invitations_token ON team_invitations(token);
CREATE INDEX IF NOT EXISTS idx_team_invitations_team_member_id ON team_invitations(team_member_id);
CREATE INDEX IF NOT EXISTS idx_team_invitations_status ON team_invitations(status);
CREATE INDEX IF NOT EXISTS idx_team_invitations_expires_at ON team_invitations(expires_at);

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS field_options;

View File

@ -1,236 +0,0 @@
CREATE TABLE IF NOT EXISTS field_options (
id BIGSERIAL PRIMARY KEY,
field_key VARCHAR(50) NOT NULL,
code VARCHAR(50) NOT NULL,
label VARCHAR(255) NOT NULL,
display_order INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
CONSTRAINT field_options_field_key_format CHECK (field_key ~ '^[a-z][a-z0-9_]*$'),
CONSTRAINT field_options_unique_field_code UNIQUE (field_key, code)
);
CREATE INDEX IF NOT EXISTS idx_field_options_field_key ON field_options(field_key);
CREATE INDEX IF NOT EXISTS idx_field_options_status ON field_options(status);
CREATE INDEX IF NOT EXISTS idx_field_options_display_order ON field_options(display_order);
INSERT INTO field_options (field_key, code, label, display_order, status) VALUES
('education_level', 'NO_FORMAL', 'No formal education', 1, 'ACTIVE'),
('education_level', 'PRIMARY', 'Primary school', 2, 'ACTIVE'),
('education_level', 'SECONDARY', 'Secondary school', 3, 'ACTIVE'),
('education_level', 'HIGH_SCHOOL', 'High school', 4, 'ACTIVE'),
('education_level', 'VOCATIONAL', 'Vocational / technical', 5, 'ACTIVE'),
('education_level', 'BACHELOR', 'Bachelor''s degree', 6, 'ACTIVE'),
('education_level', 'MASTER', 'Master''s degree', 7, 'ACTIVE'),
('education_level', 'DOCTORATE', 'Doctorate', 8, 'ACTIVE'),
('education_level', 'OTHER', 'Other', 99, 'ACTIVE'),
('occupation', 'STUDENTS', 'Students (High school & University)', 1, 'ACTIVE'),
('occupation', 'JOB_SEEKERS', 'Job Seekers / Fresh Graduates', 2, 'ACTIVE'),
('occupation', 'WORKING_PROFESSIONALS', 'Working Professionals (Corporate/Office)', 3, 'ACTIVE'),
('occupation', 'GOVERNMENT_NGO', 'Government & NGO Workers', 4, 'ACTIVE'),
('occupation', 'ENTREPRENEURS', 'Entrepreneurs & Small Business Owners', 5, 'ACTIVE'),
('occupation', 'HOSPITALITY_TOURISM', 'Hospitality & Tourism Workers', 6, 'ACTIVE'),
('occupation', 'FREELANCERS_REMOTE', 'Freelancers / Remote Workers (Digital Economy)', 7, 'ACTIVE'),
('age_group', 'UNDER_13', 'Under 13', 1, 'ACTIVE'),
('age_group', '13_17', '1317', 2, 'ACTIVE'),
('age_group', '18_24', '1824', 3, 'ACTIVE'),
('age_group', '25_34', '2534', 4, 'ACTIVE'),
('age_group', '35_44', '3544', 5, 'ACTIVE'),
('age_group', '45_54', '4554', 6, 'ACTIVE'),
('age_group', '55_PLUS', '55+', 7, 'ACTIVE'),
('learning_goal', 'EVERYDAY_CONVERSATION', 'Everyday conversation', 1, 'ACTIVE'),
('learning_goal', 'WORK_CAREER', 'Work and career', 2, 'ACTIVE'),
('learning_goal', 'ACADEMIC_STUDY', 'Academic study', 3, 'ACTIVE'),
('learning_goal', 'TRAVEL', 'Travel', 4, 'ACTIVE'),
('learning_goal', 'EXAM_PREP', 'Exam preparation', 5, 'ACTIVE'),
('learning_goal', 'PERSONAL_GROWTH', 'Personal growth', 6, 'ACTIVE'),
('learning_goal', 'OTHER', 'Other', 99, 'ACTIVE'),
('language_challange', 'PRONUNCIATION', 'Pronunciation', 1, 'ACTIVE'),
('language_challange', 'WORDS_GRAMMAR', 'Finding words or grammar quickly', 2, 'ACTIVE'),
('language_challange', 'CONFIDENCE', 'Feeling nervous or lacking confidence', 3, 'ACTIVE'),
('language_challange', 'ACCENTS_FAST_SPEECH', 'Understanding accents or fast speech', 4, 'ACTIVE'),
('language_challange', 'OTHER', 'Other', 99, 'ACTIVE'),
('language_goal', 'SPEAK_CONFIDENTLY', 'Speak confidently at work or school', 1, 'ACTIVE'),
('language_goal', 'TRAVEL_DAILY', 'Travel or handle daily situations', 2, 'ACTIVE'),
('language_goal', 'FAMILY_FRIENDS', 'Connect with family or friends', 3, 'ACTIVE'),
('language_goal', 'GENERAL_SKILLS', 'General skills expansion', 4, 'ACTIVE'),
('language_goal', 'OTHER', 'Other', 99, 'ACTIVE'),
('favourite_topic', 'FOOD_COOKING', 'Food & Cooking', 1, 'ACTIVE'),
('favourite_topic', 'HOBBIES_SPORTS_MUSIC', 'Hobbies, Sports, Music', 2, 'ACTIVE'),
('favourite_topic', 'TECH_NEWS_BUSINESS', 'Tech, News, Business', 3, 'ACTIVE'),
('favourite_topic', 'TRAVEL_PLACES_CULTURE', 'Travel, Places, Culture', 4, 'ACTIVE'),
('favourite_topic', 'OTHER', 'Other', 99, 'ACTIVE'),
('country', 'AF', 'Afghanistan', 1, 'ACTIVE'),
('country', 'AL', 'Albania', 2, 'ACTIVE'),
('country', 'DZ', 'Algeria', 3, 'ACTIVE'),
('country', 'AD', 'Andorra', 4, 'ACTIVE'),
('country', 'AO', 'Angola', 5, 'ACTIVE'),
('country', 'AR', 'Argentina', 6, 'ACTIVE'),
('country', 'AM', 'Armenia', 7, 'ACTIVE'),
('country', 'AU', 'Australia', 8, 'ACTIVE'),
('country', 'AT', 'Austria', 9, 'ACTIVE'),
('country', 'AZ', 'Azerbaijan', 10, 'ACTIVE'),
('country', 'BH', 'Bahrain', 11, 'ACTIVE'),
('country', 'BD', 'Bangladesh', 12, 'ACTIVE'),
('country', 'BY', 'Belarus', 13, 'ACTIVE'),
('country', 'BE', 'Belgium', 14, 'ACTIVE'),
('country', 'BZ', 'Belize', 15, 'ACTIVE'),
('country', 'BJ', 'Benin', 16, 'ACTIVE'),
('country', 'BT', 'Bhutan', 17, 'ACTIVE'),
('country', 'BO', 'Bolivia', 18, 'ACTIVE'),
('country', 'BA', 'Bosnia and Herzegovina', 19, 'ACTIVE'),
('country', 'BW', 'Botswana', 20, 'ACTIVE'),
('country', 'BR', 'Brazil', 21, 'ACTIVE'),
('country', 'BN', 'Brunei', 22, 'ACTIVE'),
('country', 'BG', 'Bulgaria', 23, 'ACTIVE'),
('country', 'BF', 'Burkina Faso', 24, 'ACTIVE'),
('country', 'BI', 'Burundi', 25, 'ACTIVE'),
('country', 'KH', 'Cambodia', 26, 'ACTIVE'),
('country', 'CM', 'Cameroon', 27, 'ACTIVE'),
('country', 'CA', 'Canada', 28, 'ACTIVE'),
('country', 'TD', 'Chad', 29, 'ACTIVE'),
('country', 'CL', 'Chile', 30, 'ACTIVE'),
('country', 'CN', 'China', 31, 'ACTIVE'),
('country', 'CO', 'Colombia', 32, 'ACTIVE'),
('country', 'KM', 'Comoros', 33, 'ACTIVE'),
('country', 'CG', 'Congo', 34, 'ACTIVE'),
('country', 'CR', 'Costa Rica', 35, 'ACTIVE'),
('country', 'HR', 'Croatia', 36, 'ACTIVE'),
('country', 'CU', 'Cuba', 37, 'ACTIVE'),
('country', 'CY', 'Cyprus', 38, 'ACTIVE'),
('country', 'CZ', 'Czech Republic', 39, 'ACTIVE'),
('country', 'DK', 'Denmark', 40, 'ACTIVE'),
('country', 'DJ', 'Djibouti', 41, 'ACTIVE'),
('country', 'DO', 'Dominican Republic', 42, 'ACTIVE'),
('country', 'EC', 'Ecuador', 43, 'ACTIVE'),
('country', 'EG', 'Egypt', 44, 'ACTIVE'),
('country', 'SV', 'El Salvador', 45, 'ACTIVE'),
('country', 'ER', 'Eritrea', 46, 'ACTIVE'),
('country', 'EE', 'Estonia', 47, 'ACTIVE'),
('country', 'SZ', 'Eswatini', 48, 'ACTIVE'),
('country', 'ET', 'Ethiopia', 49, 'ACTIVE'),
('country', 'FI', 'Finland', 50, 'ACTIVE'),
('country', 'FR', 'France', 51, 'ACTIVE'),
('country', 'GA', 'Gabon', 52, 'ACTIVE'),
('country', 'GM', 'Gambia', 53, 'ACTIVE'),
('country', 'GE', 'Georgia', 54, 'ACTIVE'),
('country', 'DE', 'Germany', 55, 'ACTIVE'),
('country', 'GH', 'Ghana', 56, 'ACTIVE'),
('country', 'GR', 'Greece', 57, 'ACTIVE'),
('country', 'GT', 'Guatemala', 58, 'ACTIVE'),
('country', 'GN', 'Guinea', 59, 'ACTIVE'),
('country', 'HT', 'Haiti', 60, 'ACTIVE'),
('country', 'HN', 'Honduras', 61, 'ACTIVE'),
('country', 'HU', 'Hungary', 62, 'ACTIVE'),
('country', 'IS', 'Iceland', 63, 'ACTIVE'),
('country', 'IN', 'India', 64, 'ACTIVE'),
('country', 'ID', 'Indonesia', 65, 'ACTIVE'),
('country', 'IR', 'Iran', 66, 'ACTIVE'),
('country', 'IQ', 'Iraq', 67, 'ACTIVE'),
('country', 'IE', 'Ireland', 68, 'ACTIVE'),
('country', 'IL', 'Israel', 69, 'ACTIVE'),
('country', 'IT', 'Italy', 70, 'ACTIVE'),
('country', 'JM', 'Jamaica', 71, 'ACTIVE'),
('country', 'JP', 'Japan', 72, 'ACTIVE'),
('country', 'JO', 'Jordan', 73, 'ACTIVE'),
('country', 'KZ', 'Kazakhstan', 74, 'ACTIVE'),
('country', 'KE', 'Kenya', 75, 'ACTIVE'),
('country', 'KW', 'Kuwait', 76, 'ACTIVE'),
('country', 'KG', 'Kyrgyzstan', 77, 'ACTIVE'),
('country', 'LA', 'Laos', 78, 'ACTIVE'),
('country', 'LV', 'Latvia', 79, 'ACTIVE'),
('country', 'LB', 'Lebanon', 80, 'ACTIVE'),
('country', 'LR', 'Liberia', 81, 'ACTIVE'),
('country', 'LY', 'Libya', 82, 'ACTIVE'),
('country', 'LT', 'Lithuania', 83, 'ACTIVE'),
('country', 'LU', 'Luxembourg', 84, 'ACTIVE'),
('country', 'MG', 'Madagascar', 85, 'ACTIVE'),
('country', 'MW', 'Malawi', 86, 'ACTIVE'),
('country', 'MY', 'Malaysia', 87, 'ACTIVE'),
('country', 'MV', 'Maldives', 88, 'ACTIVE'),
('country', 'ML', 'Mali', 89, 'ACTIVE'),
('country', 'MT', 'Malta', 90, 'ACTIVE'),
('country', 'MX', 'Mexico', 91, 'ACTIVE'),
('country', 'MD', 'Moldova', 92, 'ACTIVE'),
('country', 'MC', 'Monaco', 93, 'ACTIVE'),
('country', 'MN', 'Mongolia', 94, 'ACTIVE'),
('country', 'MA', 'Morocco', 95, 'ACTIVE'),
('country', 'MZ', 'Mozambique', 96, 'ACTIVE'),
('country', 'MM', 'Myanmar', 97, 'ACTIVE'),
('country', 'NA', 'Namibia', 98, 'ACTIVE'),
('country', 'NP', 'Nepal', 99, 'ACTIVE'),
('country', 'NL', 'Netherlands', 100, 'ACTIVE'),
('country', 'NZ', 'New Zealand', 101, 'ACTIVE'),
('country', 'NI', 'Nicaragua', 102, 'ACTIVE'),
('country', 'NE', 'Niger', 103, 'ACTIVE'),
('country', 'NG', 'Nigeria', 104, 'ACTIVE'),
('country', 'KP', 'North Korea', 105, 'ACTIVE'),
('country', 'NO', 'Norway', 106, 'ACTIVE'),
('country', 'OM', 'Oman', 107, 'ACTIVE'),
('country', 'PK', 'Pakistan', 108, 'ACTIVE'),
('country', 'PA', 'Panama', 109, 'ACTIVE'),
('country', 'PY', 'Paraguay', 110, 'ACTIVE'),
('country', 'PE', 'Peru', 111, 'ACTIVE'),
('country', 'PH', 'Philippines', 112, 'ACTIVE'),
('country', 'PL', 'Poland', 113, 'ACTIVE'),
('country', 'PT', 'Portugal', 114, 'ACTIVE'),
('country', 'QA', 'Qatar', 115, 'ACTIVE'),
('country', 'RO', 'Romania', 116, 'ACTIVE'),
('country', 'RU', 'Russia', 117, 'ACTIVE'),
('country', 'RW', 'Rwanda', 118, 'ACTIVE'),
('country', 'SA', 'Saudi Arabia', 119, 'ACTIVE'),
('country', 'SN', 'Senegal', 120, 'ACTIVE'),
('country', 'RS', 'Serbia', 121, 'ACTIVE'),
('country', 'SG', 'Singapore', 122, 'ACTIVE'),
('country', 'SK', 'Slovakia', 123, 'ACTIVE'),
('country', 'SI', 'Slovenia', 124, 'ACTIVE'),
('country', 'SO', 'Somalia', 125, 'ACTIVE'),
('country', 'ZA', 'South Africa', 126, 'ACTIVE'),
('country', 'KR', 'South Korea', 127, 'ACTIVE'),
('country', 'ES', 'Spain', 128, 'ACTIVE'),
('country', 'LK', 'Sri Lanka', 129, 'ACTIVE'),
('country', 'SD', 'Sudan', 130, 'ACTIVE'),
('country', 'SE', 'Sweden', 131, 'ACTIVE'),
('country', 'CH', 'Switzerland', 132, 'ACTIVE'),
('country', 'SY', 'Syria', 133, 'ACTIVE'),
('country', 'TW', 'Taiwan', 134, 'ACTIVE'),
('country', 'TJ', 'Tajikistan', 135, 'ACTIVE'),
('country', 'TZ', 'Tanzania', 136, 'ACTIVE'),
('country', 'TH', 'Thailand', 137, 'ACTIVE'),
('country', 'TN', 'Tunisia', 138, 'ACTIVE'),
('country', 'TR', 'Turkey', 139, 'ACTIVE'),
('country', 'UG', 'Uganda', 140, 'ACTIVE'),
('country', 'UA', 'Ukraine', 141, 'ACTIVE'),
('country', 'AE', 'United Arab Emirates', 142, 'ACTIVE'),
('country', 'GB', 'United Kingdom', 143, 'ACTIVE'),
('country', 'US', 'United States', 144, 'ACTIVE'),
('country', 'UY', 'Uruguay', 145, 'ACTIVE'),
('country', 'UZ', 'Uzbekistan', 146, 'ACTIVE'),
('country', 'VE', 'Venezuela', 147, 'ACTIVE'),
('country', 'VN', 'Vietnam', 148, 'ACTIVE'),
('country', 'YE', 'Yemen', 149, 'ACTIVE'),
('country', 'ZM', 'Zambia', 150, 'ACTIVE'),
('country', 'ZW', 'Zimbabwe', 151, 'ACTIVE'),
('ethiopia_regions', 'ADDIS_ABABA', 'Addis Ababa', 1, 'ACTIVE'),
('ethiopia_regions', 'AFAR', 'Afar', 2, 'ACTIVE'),
('ethiopia_regions', 'AMHARA', 'Amhara', 3, 'ACTIVE'),
('ethiopia_regions', 'BENISHANGUL_GUMUZ', 'Benishangul-Gumuz', 4, 'ACTIVE'),
('ethiopia_regions', 'CENTRAL_ETHIOPIA', 'Central Ethiopia', 5, 'ACTIVE'),
('ethiopia_regions', 'DIRE_DAWA', 'Dire Dawa', 6, 'ACTIVE'),
('ethiopia_regions', 'GAMBELA', 'Gambela', 7, 'ACTIVE'),
('ethiopia_regions', 'HARARI', 'Harari', 8, 'ACTIVE'),
('ethiopia_regions', 'OROMIA', 'Oromia', 9, 'ACTIVE'),
('ethiopia_regions', 'SIDAMA', 'Sidama', 10, 'ACTIVE'),
('ethiopia_regions', 'SOMALI', 'Somali', 11, 'ACTIVE'),
('ethiopia_regions', 'SOUTH_ETHIOPIA', 'South Ethiopia', 12, 'ACTIVE'),
('ethiopia_regions', 'SOUTH_WEST_ETHIOPIA_PEOPLES', 'South West Ethiopia Peoples', 13, 'ACTIVE'),
('ethiopia_regions', 'TIGRAY', 'Tigray', 14, 'ACTIVE');

View File

@ -1 +0,0 @@
-- No-op: keep field_options table name on rollback of 070 alone.

View File

@ -1,20 +0,0 @@
-- For databases that already applied 000069 with profile_field_options table name.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'profile_field_options'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'field_options'
) THEN
ALTER TABLE profile_field_options RENAME TO field_options;
ALTER TABLE field_options RENAME CONSTRAINT profile_field_options_field_key_check TO field_options_field_key_check_old;
ALTER TABLE field_options DROP CONSTRAINT IF EXISTS field_options_field_key_check_old;
ALTER TABLE field_options RENAME CONSTRAINT profile_field_options_unique_field_code TO field_options_unique_field_code;
ALTER INDEX IF EXISTS idx_profile_field_options_field_key RENAME TO idx_field_options_field_key;
ALTER INDEX IF EXISTS idx_profile_field_options_status RENAME TO idx_field_options_status;
ALTER INDEX IF EXISTS idx_profile_field_options_display_order RENAME TO idx_field_options_display_order;
ALTER TABLE field_options ADD CONSTRAINT field_options_field_key_format CHECK (field_key ~ '^[a-z][a-z0-9_]*$');
END IF;
END $$;

View File

@ -1,16 +0,0 @@
DROP INDEX IF EXISTS idx_exam_prep_catalog_courses_category;
DROP INDEX IF EXISTS idx_programs_category;
DROP INDEX IF EXISTS idx_subscription_plans_category;
ALTER TABLE exam_prep.catalog_courses
DROP CONSTRAINT IF EXISTS chk_exam_prep_catalog_courses_category,
ALTER COLUMN category DROP DEFAULT,
DROP COLUMN IF EXISTS category;
ALTER TABLE subscription_plans
DROP CONSTRAINT IF EXISTS chk_subscription_plans_category,
DROP COLUMN IF EXISTS category;
ALTER TABLE programs
DROP CONSTRAINT IF EXISTS chk_programs_category,
DROP COLUMN IF EXISTS category;

View File

@ -1,30 +0,0 @@
ALTER TABLE subscription_plans
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'LEARN_ENGLISH',
ADD CONSTRAINT chk_subscription_plans_category
CHECK (category IN ('LEARN_ENGLISH', 'IELTS', 'DUOLINGO'));
ALTER TABLE programs
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'LEARN_ENGLISH',
ADD CONSTRAINT chk_programs_category
CHECK (category IN ('LEARN_ENGLISH', 'IELTS', 'DUOLINGO'));
ALTER TABLE exam_prep.catalog_courses
ADD COLUMN category VARCHAR(32);
UPDATE exam_prep.catalog_courses
SET category = CASE
WHEN upper(name) LIKE '%DUOLINGO%' OR upper(name) LIKE '%DET%' THEN 'DUOLINGO'
WHEN upper(name) LIKE '%IELTS%' THEN 'IELTS'
ELSE 'IELTS'
END
WHERE category IS NULL;
ALTER TABLE exam_prep.catalog_courses
ALTER COLUMN category SET NOT NULL,
ALTER COLUMN category SET DEFAULT 'IELTS',
ADD CONSTRAINT chk_exam_prep_catalog_courses_category
CHECK (category IN ('IELTS', 'DUOLINGO'));
CREATE INDEX idx_subscription_plans_category ON subscription_plans(category);
CREATE INDEX idx_programs_category ON programs(category);
CREATE INDEX idx_exam_prep_catalog_courses_category ON exam_prep.catalog_courses(category);

View File

@ -1,7 +0,0 @@
DELETE FROM field_options
WHERE field_key = 'country'
AND code IN (
'ET', 'ER', 'DJ', 'SO', 'KE', 'SD', 'SS', 'UG', 'RW', 'TZ',
'EG', 'NG', 'ZA', 'US', 'GB', 'CA', 'DE', 'FR', 'IN', 'CN',
'SA', 'AE', 'OTHER'
);

View File

@ -1,25 +0,0 @@
INSERT INTO field_options (field_key, code, label, display_order, status) VALUES
('country', 'ET', 'Ethiopia', 1, 'ACTIVE'),
('country', 'ER', 'Eritrea', 2, 'ACTIVE'),
('country', 'DJ', 'Djibouti', 3, 'ACTIVE'),
('country', 'SO', 'Somalia', 4, 'ACTIVE'),
('country', 'KE', 'Kenya', 5, 'ACTIVE'),
('country', 'SD', 'Sudan', 6, 'ACTIVE'),
('country', 'SS', 'South Sudan', 7, 'ACTIVE'),
('country', 'UG', 'Uganda', 8, 'ACTIVE'),
('country', 'RW', 'Rwanda', 9, 'ACTIVE'),
('country', 'TZ', 'Tanzania', 10, 'ACTIVE'),
('country', 'EG', 'Egypt', 11, 'ACTIVE'),
('country', 'NG', 'Nigeria', 12, 'ACTIVE'),
('country', 'ZA', 'South Africa', 13, 'ACTIVE'),
('country', 'US', 'United States', 20, 'ACTIVE'),
('country', 'GB', 'United Kingdom', 21, 'ACTIVE'),
('country', 'CA', 'Canada', 22, 'ACTIVE'),
('country', 'DE', 'Germany', 23, 'ACTIVE'),
('country', 'FR', 'France', 24, 'ACTIVE'),
('country', 'IN', 'India', 25, 'ACTIVE'),
('country', 'CN', 'China', 26, 'ACTIVE'),
('country', 'SA', 'Saudi Arabia', 27, 'ACTIVE'),
('country', 'AE', 'United Arab Emirates', 28, 'ACTIVE'),
('country', 'OTHER', 'Other', 99, 'ACTIVE')
ON CONFLICT (field_key, code) DO NOTHING;

View File

@ -1,7 +0,0 @@
DELETE FROM field_options
WHERE field_key = 'ethiopia_regions'
AND code IN (
'ADDIS_ABABA', 'DIRE_DAWA', 'TIGRAY', 'AFAR', 'AMHARA', 'OROMIA', 'SOMALI',
'BENISHANGUL_GUMUZ', 'GAMBELA', 'HARARI', 'SIDAMA', 'SOUTH_ETHIOPIA',
'SOUTH_WEST_ETHIOPIA', 'CENTRAL_ETHIOPIA', 'OTHER'
);

View File

@ -1,19 +0,0 @@
INSERT INTO field_options (field_key, code, label, display_order, status) VALUES
('ethiopia_regions', 'ADDIS_ABABA', 'Addis Ababa', 1, 'ACTIVE'),
('ethiopia_regions', 'AFAR', 'Afar', 2, 'ACTIVE'),
('ethiopia_regions', 'AMHARA', 'Amhara', 3, 'ACTIVE'),
('ethiopia_regions', 'BENISHANGUL_GUMUZ', 'Benishangul-Gumuz', 4, 'ACTIVE'),
('ethiopia_regions', 'CENTRAL_ETHIOPIA', 'Central Ethiopia', 5, 'ACTIVE'),
('ethiopia_regions', 'DIRE_DAWA', 'Dire Dawa', 6, 'ACTIVE'),
('ethiopia_regions', 'GAMBELA', 'Gambela', 7, 'ACTIVE'),
('ethiopia_regions', 'HARARI', 'Harari', 8, 'ACTIVE'),
('ethiopia_regions', 'OROMIA', 'Oromia', 9, 'ACTIVE'),
('ethiopia_regions', 'SIDAMA', 'Sidama', 10, 'ACTIVE'),
('ethiopia_regions', 'SOMALI', 'Somali', 11, 'ACTIVE'),
('ethiopia_regions', 'SOUTH_ETHIOPIA', 'South Ethiopia', 12, 'ACTIVE'),
('ethiopia_regions', 'SOUTH_WEST_ETHIOPIA_PEOPLES', 'South West Ethiopia Peoples', 13, 'ACTIVE'),
('ethiopia_regions', 'TIGRAY', 'Tigray', 14, 'ACTIVE')
ON CONFLICT (field_key, code) DO UPDATE SET
label = EXCLUDED.label,
display_order = EXCLUDED.display_order,
status = EXCLUDED.status;

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS user_video_watch_sessions;

Some files were not shown because too many files have changed in this diff Show More