Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1c6b3c15a | |||
| d3225ca61a | |||
| 79fb95ce36 | |||
| 7a4253edf4 | |||
| 82de00b1e7 | |||
| 56cc009579 | |||
| afdd07d65d | |||
| a719c0daca | |||
| 3f73afb4bf | |||
| 56089fa8fd | |||
| e957eacf80 | |||
| f7d4b5c3fb | |||
| a5acd00637 | |||
| 176f78515d | |||
| 215a4bd1dc | |||
| 0ad7f094cf | |||
| 79851d31b3 | |||
| 31bd1e3814 | |||
| 868e5ba001 | |||
| 5937c5505a | |||
| 1f7b38861e | |||
| de8618191c | |||
| f7c9eddef5 | |||
| 14d94ec723 | |||
| 5399d33af6 | |||
| 9ff418247f | |||
| 6ab077b53d | |||
| 9631711090 | |||
| 873be1b482 | |||
| 71bc09a638 | |||
| bd1767d2a6 | |||
| fffdff1031 | |||
| 7e61e34292 | |||
| 83db13bed0 | |||
| 12ad59c409 | |||
| 37aef49e28 | |||
| 1136a166f5 | |||
| d28bddace1 | |||
| 4a681265d7 | |||
| 2f73b60122 | |||
| ecad91d89e | |||
| a80db8afd9 | |||
| 52effaa321 | |||
| 062b1f6151 | |||
| 49bcc22d0d | |||
| 1e62510321 | |||
| f824c16c64 | |||
| 2883561525 | |||
| a1696bf1e0 | |||
| 7f8ef3373c | |||
| 9afc9a4392 | |||
| 024a69b74b | |||
| 8bba318372 | |||
| 4ada908555 | |||
| 86ab4e53d4 | |||
| c711df68b9 | |||
| eae87b40b5 | |||
| 7e75d79dc8 | |||
| 4509fe2dc0 | |||
| 23322c69cc | |||
| 6f1cb24c63 | |||
| cd0ae19d03 | |||
| 75353f8bdd | |||
| b2a72c2f6e | |||
| 6a4fe68628 | |||
| bc2357374b | |||
| 9da9eb77e5 | |||
| 3d1b3ad9b8 | |||
| 9a17f0b3c4 | |||
| 0983589e36 | |||
| 21ce61b910 | |||
| f906862676 | |||
| 73370633ce | |||
| b62d89574e | |||
| 16c3f6b613 | |||
| 4124f98160 | |||
| 10954d88b0 | |||
| eba2b87ed6 | |||
| 60290e5c34 | |||
| 8430b82687 | |||
| cdb0fa1bb3 | |||
| 9027b65011 | |||
| 8c116f4a0b | |||
| 87bf2ed609 | |||
| 9cfd6c524e | |||
| 0d02eb1a24 | |||
| 78f231f222 | |||
| 526426d9f9 | |||
| 5857fce9a0 | |||
| 7e26f15bed | |||
| bc68326a66 | |||
| 33d34f0dd2 | |||
| 5b53929d92 | |||
| dc788c04cb | |||
| 6c672c4b20 | |||
| 9db9c9899a | |||
| 152478a96c | |||
| 9154dec067 | |||
| 5fbca53534 | |||
| 6839d1aa0d | |||
| 72d1a0c3ed | |||
| de95c4d0d2 | |||
| 90baa582be | |||
| bbd919ca12 | |||
| 3e54b5039d | |||
| 24f1aca97a | |||
| ce1b827768 | |||
| 886b62ed68 | |||
| 7ff0b639cf | |||
| c5d3935062 | |||
| 518c3ee751 | |||
| 1026354c24 | |||
| 343ce470cc | |||
| 01914cb81e | |||
| d686bdf8bd | |||
| ea55d9b371 | |||
| 9ee8952d7f | |||
| 1c8d041747 | |||
| a9c6966820 | |||
| 06d86c9098 | |||
| 57f0db269a | |||
| 3889334e3f | |||
| f5e925dc96 | |||
| 83f5541650 | |||
| 542a597f41 | |||
| 9123ff571d | |||
| 0cc813d224 | |||
| a4d1f395da | |||
| 2ff1e89263 | |||
| 5858aeb744 | |||
| b1a1b97a0a | |||
|
|
facaedb8dc | ||
| e3afadf2bb | |||
| bb2f92e5e3 | |||
| be70f87541 | |||
| d9783310d1 | |||
| 69d3d440d0 | |||
| f256ee179a | |||
| f7499cb41a | |||
| d08f92e06c | |||
|
|
84cfc3ac4d | ||
|
|
4b6d3da7bc | ||
| 894e18bcae | |||
| 7613eb583a |
14
.cursor/rules/git-push-commit-if-dirty.mdc
Normal file
14
.cursor/rules/git-push-commit-if-dirty.mdc
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
---
|
||||||
|
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 repo’s 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.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Yimaru Backend
|
# Yimaru Backend API
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
|
||||||
109
cmd/main.go
109
cmd/main.go
|
|
@ -10,31 +10,44 @@ 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"
|
||||||
"Yimaru-Backend/internal/services/course_management"
|
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||||
|
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"
|
||||||
|
|
@ -98,16 +111,16 @@ func main() {
|
||||||
settingSvc := settings.NewService(settingRepo)
|
settingSvc := settings.NewService(settingRepo)
|
||||||
|
|
||||||
messengerSvc := messenger.NewService(settingSvc, cfg)
|
messengerSvc := messenger.NewService(settingSvc, cfg)
|
||||||
// statSvc := stats.NewService(
|
emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store))
|
||||||
// repository.NewCompanyStatStore(store),
|
profileFieldOptionSvc := profilefieldoptions.NewService(repository.NewProfileFieldOptionStore(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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -360,24 +373,10 @@ 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)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Course management service
|
// CloudConvert service for image/video optimization
|
||||||
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)")
|
||||||
|
|
@ -401,11 +400,34 @@ 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 with payment and subscription stores
|
// ArifPay service (direct/legacy payment flows)
|
||||||
arifpaySvc := arifpay.NewArifpayService(
|
arifpaySvc := arifpay.NewArifpayService(
|
||||||
cfg,
|
cfg,
|
||||||
&http.Client{Timeout: 30 * time.Second},
|
&http.Client{Timeout: 30 * time.Second},
|
||||||
|
|
@ -413,8 +435,24 @@ 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(repository.NewTeamStore(store))
|
teamSvc := team.NewService(
|
||||||
|
repository.NewTeamStore(store),
|
||||||
|
cfg.RefreshExpiry,
|
||||||
|
emailTemplateSvc,
|
||||||
|
messengerSvc,
|
||||||
|
cfg.TeamInviteBaseURL,
|
||||||
|
cfg.TeamInviteExpiry,
|
||||||
|
)
|
||||||
|
|
||||||
// santimpayClient := santimpay.NewSantimPayClient(cfg)
|
// santimpayClient := santimpay.NewSantimPayClient(cfg)
|
||||||
|
|
||||||
|
|
@ -442,10 +480,22 @@ func main() {
|
||||||
// Initialize and start HTTP server
|
// Initialize and start HTTP server
|
||||||
app := httpserver.NewApp(
|
app := httpserver.NewApp(
|
||||||
assessmentSvc,
|
assessmentSvc,
|
||||||
courseSvc,
|
|
||||||
questionsSvc,
|
questionsSvc,
|
||||||
|
faqSvc,
|
||||||
|
appVersionSvc,
|
||||||
|
emailTemplateSvc,
|
||||||
|
profileFieldOptionSvc,
|
||||||
|
personasSvc,
|
||||||
|
examPrepSvc,
|
||||||
|
programSvc,
|
||||||
|
courseSvc,
|
||||||
|
moduleSvc,
|
||||||
|
lessonSvc,
|
||||||
|
lmsProgressSvc,
|
||||||
|
practiceSvc,
|
||||||
subscriptionsSvc,
|
subscriptionsSvc,
|
||||||
arifpaySvc,
|
arifpaySvc,
|
||||||
|
chapaSvc,
|
||||||
issueReportingSvc,
|
issueReportingSvc,
|
||||||
vimeoSvc,
|
vimeoSvc,
|
||||||
teamSvc,
|
teamSvc,
|
||||||
|
|
@ -470,6 +520,7 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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,
|
||||||
|
|
@ -136,190 +137,6 @@ 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
|
||||||
|
|
@ -359,7 +176,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
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -471,3 +288,20 @@ 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;
|
||||||
|
|
@ -1,108 +1,25 @@
|
||||||
-- ======================================================
|
-- 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('questions', 'id'),
|
pg_get_serial_sequence('team_members', 'id'),
|
||||||
COALESCE((SELECT MAX(id) FROM questions), 1),
|
COALESCE((SELECT MAX(id) FROM team_members), 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
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1 @@
|
||||||
INSERT INTO activity_logs (actor_id, actor_role, action, resource_type, resource_id, message, metadata, ip_address, user_agent, created_at) VALUES
|
-- Intentionally empty: no demo activity log seed (login-only seed in 001).
|
||||||
(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');
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1 @@
|
||||||
INSERT INTO reported_issues (user_id, user_role, subject, description, issue_type, status, metadata, created_at, updated_at) VALUES
|
-- Intentionally empty: no demo issue-report seed (login-only seed in 001).
|
||||||
(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;
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1 @@
|
||||||
INSERT INTO notifications (
|
-- Intentionally empty: no demo notification seed (login-only seed in 001).
|
||||||
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;
|
|
||||||
|
|
|
||||||
|
|
@ -1,469 +0,0 @@
|
||||||
-- ======================================================
|
|
||||||
-- 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);
|
|
||||||
|
|
@ -1,29 +1 @@
|
||||||
-- Seed account deletion request states for admin panel tracking
|
-- Intentionally empty: no demo account-deletion seed (login-only seed in 001).
|
||||||
-- 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;
|
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1 @@
|
||||||
-- Seed TRUE_FALSE and SHORT_ANSWER question types
|
-- Intentionally empty: no demo question seed (login-only seed in 001).
|
||||||
-- 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);
|
|
||||||
|
|
||||||
|
|
|
||||||
25
db/migrations/000030_unified_hierarchy.down.sql
Normal file
25
db/migrations/000030_unified_hierarchy.down.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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;
|
||||||
|
|
||||||
228
db/migrations/000030_unified_hierarchy.up.sql
Normal file
228
db/migrations/000030_unified_hierarchy.up.sql
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
-- 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;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
DROP INDEX IF EXISTS idx_sub_module_lessons_sub_module_id;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS sub_module_lessons;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- 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);
|
||||||
|
|
||||||
4
db/migrations/000032_add_sub_module_practices.down.sql
Normal file
4
db/migrations/000032_add_sub_module_practices.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
DROP INDEX IF EXISTS idx_sub_module_practices_sub_module_id;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS sub_module_practices;
|
||||||
|
|
||||||
35
db/migrations/000032_add_sub_module_practices.up.sql
Normal file
35
db/migrations/000032_add_sub_module_practices.up.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- 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;
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- 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';
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE levels
|
||||||
|
DROP COLUMN IF EXISTS title,
|
||||||
|
DROP COLUMN IF EXISTS description,
|
||||||
|
DROP COLUMN IF EXISTS thumbnail;
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
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;
|
||||||
12
db/migrations/000035_sub_module_capstones.down.sql
Normal file
12
db/migrations/000035_sub_module_capstones.down.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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'
|
||||||
|
));
|
||||||
29
db/migrations/000035_sub_module_capstones.up.sql
Normal file
29
db/migrations/000035_sub_module_capstones.up.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- 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);
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
DROP INDEX IF EXISTS idx_module_capstones_module_id;
|
||||||
|
DROP TABLE IF EXISTS module_capstones;
|
||||||
|
|
||||||
|
ALTER TABLE modules DROP COLUMN IF EXISTS icon_url;
|
||||||
19
db/migrations/000036_module_icon_and_module_capstones.up.sql
Normal file
19
db/migrations/000036_module_icon_and_module_capstones.up.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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);
|
||||||
3
db/migrations/000037_sub_modules_thumbnail_tips.down.sql
Normal file
3
db/migrations/000037_sub_modules_thumbnail_tips.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE sub_modules
|
||||||
|
DROP COLUMN IF EXISTS tips,
|
||||||
|
DROP COLUMN IF EXISTS thumbnail;
|
||||||
3
db/migrations/000037_sub_modules_thumbnail_tips.up.sql
Normal file
3
db/migrations/000037_sub_modules_thumbnail_tips.up.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE sub_modules
|
||||||
|
ADD COLUMN IF NOT EXISTS thumbnail TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS tips TEXT;
|
||||||
7
db/migrations/000038_levels_flexible_cefr_level.down.sql
Normal file
7
db/migrations/000038_levels_flexible_cefr_level.down.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- 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'));
|
||||||
20
db/migrations/000038_levels_flexible_cefr_level.up.sql
Normal file
20
db/migrations/000038_levels_flexible_cefr_level.up.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- 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);
|
||||||
2
db/migrations/000039_team_refresh_tokens.down.sql
Normal file
2
db/migrations/000039_team_refresh_tokens.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP INDEX IF EXISTS idx_team_refresh_tokens_team_member_id;
|
||||||
|
DROP TABLE IF EXISTS team_refresh_tokens;
|
||||||
11
db/migrations/000039_team_refresh_tokens.up.sql
Normal file
11
db/migrations/000039_team_refresh_tokens.up.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- 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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
-- Restoring the removed course hierarchy is not supported; apply new migrations for the next model.
|
||||||
46
db/migrations/000041_remove_course_management_schema.up.sql
Normal file
46
db/migrations/000041_remove_course_management_schema.up.sql
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
-- 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;
|
||||||
1
db/migrations/000042_programs.down.sql
Normal file
1
db/migrations/000042_programs.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS programs;
|
||||||
11
db/migrations/000042_programs.up.sql
Normal file
11
db/migrations/000042_programs.up.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- 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);
|
||||||
4
db/migrations/000043_seed_default_programs.down.sql
Normal file
4
db/migrations/000043_seed_default_programs.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
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.');
|
||||||
6
db/migrations/000043_seed_default_programs.up.sql
Normal file
6
db/migrations/000043_seed_default_programs.up.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- 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);
|
||||||
1
db/migrations/000044_lms_courses.down.sql
Normal file
1
db/migrations/000044_lms_courses.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS courses;
|
||||||
13
db/migrations/000044_lms_courses.up.sql
Normal file
13
db/migrations/000044_lms_courses.up.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- 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);
|
||||||
2
db/migrations/000045_lms_modules.down.sql
Normal file
2
db/migrations/000045_lms_modules.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS modules;
|
||||||
|
ALTER TABLE courses DROP CONSTRAINT IF EXISTS courses_program_id_id_key;
|
||||||
22
db/migrations/000045_lms_modules.up.sql
Normal file
22
db/migrations/000045_lms_modules.up.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- 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);
|
||||||
1
db/migrations/000046_lms_lessons.down.sql
Normal file
1
db/migrations/000046_lms_lessons.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS lessons;
|
||||||
14
db/migrations/000046_lms_lessons.up.sql
Normal file
14
db/migrations/000046_lms_lessons.up.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- 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);
|
||||||
1
db/migrations/000047_lms_practices.down.sql
Normal file
1
db/migrations/000047_lms_practices.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS lms_practices;
|
||||||
29
db/migrations/000047_lms_practices.up.sql
Normal file
29
db/migrations/000047_lms_practices.up.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- 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);
|
||||||
3
db/migrations/000048_seed_default_courses.down.sql
Normal file
3
db/migrations/000048_seed_default_courses.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
DELETE FROM courses
|
||||||
|
WHERE description = 'Default CEFR level course (system seed).'
|
||||||
|
AND name IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2');
|
||||||
19
db/migrations/000048_seed_default_courses.up.sql
Normal file
19
db/migrations/000048_seed_default_courses.up.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- 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;
|
||||||
18
db/migrations/000049_lms_sequential_learning.down.sql
Normal file
18
db/migrations/000049_lms_sequential_learning.down.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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;
|
||||||
150
db/migrations/000049_lms_sequential_learning.up.sql
Normal file
150
db/migrations/000049_lms_sequential_learning.up.sql
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
-- 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);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
-- Data cleanup is not reversed; restoring the old cross-product seed would be ambiguous.
|
||||||
45
db/migrations/000050_default_courses_per_program.up.sql
Normal file
45
db/migrations/000050_default_courses_per_program.up.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
-- 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
|
||||||
|
);
|
||||||
2
db/migrations/000051_exam_prep_schema.down.sql
Normal file
2
db/migrations/000051_exam_prep_schema.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS exam_prep.catalog_courses;
|
||||||
|
DROP SCHEMA IF EXISTS exam_prep;
|
||||||
15
db/migrations/000051_exam_prep_schema.up.sql
Normal file
15
db/migrations/000051_exam_prep_schema.up.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- 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);
|
||||||
1
db/migrations/000052_exam_prep_units.down.sql
Normal file
1
db/migrations/000052_exam_prep_units.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS exam_prep.units;
|
||||||
14
db/migrations/000052_exam_prep_units.up.sql
Normal file
14
db/migrations/000052_exam_prep_units.up.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- 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);
|
||||||
1
db/migrations/000053_exam_prep_unit_modules.down.sql
Normal file
1
db/migrations/000053_exam_prep_unit_modules.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS exam_prep.unit_modules;
|
||||||
15
db/migrations/000053_exam_prep_unit_modules.up.sql
Normal file
15
db/migrations/000053_exam_prep_unit_modules.up.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- 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);
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP INDEX IF EXISTS uq_exam_prep_unit_module_lessons_sort;
|
||||||
|
DROP TABLE IF EXISTS exam_prep.unit_module_lessons;
|
||||||
17
db/migrations/000054_exam_prep_unit_module_lessons.up.sql
Normal file
17
db/migrations/000054_exam_prep_unit_module_lessons.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- 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);
|
||||||
1
db/migrations/000055_exam_prep_lesson_practices.down.sql
Normal file
1
db/migrations/000055_exam_prep_lesson_practices.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS exam_prep.lesson_practices;
|
||||||
17
db/migrations/000055_exam_prep_lesson_practices.up.sql
Normal file
17
db/migrations/000055_exam_prep_lesson_practices.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- 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);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
DROP INDEX IF EXISTS idx_question_type_definitions_status;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS question_type_definitions;
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
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;
|
||||||
14
db/migrations/000057_questions_dynamic_type_link.up.sql
Normal file
14
db/migrations/000057_questions_dynamic_type_link.up.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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);
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
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'));
|
||||||
13
db/migrations/000058_dynamic_question_builder_runtime.up.sql
Normal file
13
db/migrations/000058_dynamic_question_builder_runtime.up.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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;
|
||||||
5
db/migrations/000059_faqs.down.sql
Normal file
5
db/migrations/000059_faqs.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
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;
|
||||||
14
db/migrations/000059_faqs.up.sql
Normal file
14
db/migrations/000059_faqs.up.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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);
|
||||||
5
db/migrations/000060_practice_publish_status.down.sql
Normal file
5
db/migrations/000060_practice_publish_status.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
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;
|
||||||
8
db/migrations/000060_practice_publish_status.up.sql
Normal file
8
db/migrations/000060_practice_publish_status.up.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- 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'));
|
||||||
5
db/migrations/000061_open_learner_role.down.sql
Normal file
5
db/migrations/000061_open_learner_role.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
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';
|
||||||
79
db/migrations/000061_open_learner_role.up.sql
Normal file
79
db/migrations/000061_open_learner_role.up.sql
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
-- 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;
|
||||||
3
db/migrations/000062_lesson_publish_status.down.sql
Normal file
3
db/migrations/000062_lesson_publish_status.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE lessons DROP CONSTRAINT IF EXISTS chk_lessons_publish_status;
|
||||||
|
|
||||||
|
ALTER TABLE lessons DROP COLUMN IF EXISTS publish_status;
|
||||||
9
db/migrations/000062_lesson_publish_status.up.sql
Normal file
9
db/migrations/000062_lesson_publish_status.up.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- 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';
|
||||||
21
db/migrations/000063_lms_personas.down.sql
Normal file
21
db/migrations/000063_lms_personas.down.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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;
|
||||||
64
db/migrations/000063_lms_personas.up.sql
Normal file
64
db/migrations/000063_lms_personas.up.sql
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
-- 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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE lms_personas
|
||||||
|
RENAME COLUMN profile_picture TO avatar_url;
|
||||||
3
db/migrations/000064_lms_personas_profile_picture.up.sql
Normal file
3
db/migrations/000064_lms_personas_profile_picture.up.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Persona profile image URL stored as profile_picture (replaces avatar_url naming).
|
||||||
|
ALTER TABLE lms_personas
|
||||||
|
RENAME COLUMN avatar_url TO profile_picture;
|
||||||
2
db/migrations/000065_lms_personas_gender.down.sql
Normal file
2
db/migrations/000065_lms_personas_gender.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE lms_personas
|
||||||
|
DROP COLUMN IF EXISTS gender;
|
||||||
2
db/migrations/000065_lms_personas_gender.up.sql
Normal file
2
db/migrations/000065_lms_personas_gender.up.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE lms_personas
|
||||||
|
ADD COLUMN gender TEXT;
|
||||||
1
db/migrations/000066_email_templates.down.sql
Normal file
1
db/migrations/000066_email_templates.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS email_templates;
|
||||||
186
db/migrations/000066_email_templates.up.sql
Normal file
186
db/migrations/000066_email_templates.up.sql
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
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;">© 2026 Yimaru Academy · 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’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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · All rights reserved</p>
|
||||||
|
</td></tr>
|
||||||
|
</table></td></tr></table></body></html>$custom_html$,
|
||||||
|
'["Subject", "Message"]'::jsonb,
|
||||||
|
TRUE,
|
||||||
|
'ACTIVE'
|
||||||
|
)
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
-- No-op: branded template content is not reverted automatically.
|
||||||
156
db/migrations/000067_branded_email_template_seeds.up.sql
Normal file
156
db/migrations/000067_branded_email_template_seeds.up.sql
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
-- 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;">© 2026 Yimaru Academy · 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’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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · 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;">© 2026 Yimaru Academy · All rights reserved</p>
|
||||||
|
</td></tr>
|
||||||
|
</table></td></tr></table></body></html>$custom_html$,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE slug = 'custom_message';
|
||||||
1
db/migrations/000068_team_invitations.down.sql
Normal file
1
db/migrations/000068_team_invitations.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS team_invitations;
|
||||||
18
db/migrations/000068_team_invitations.up.sql
Normal file
18
db/migrations/000068_team_invitations.up.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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);
|
||||||
1
db/migrations/000069_profile_field_options.down.sql
Normal file
1
db/migrations/000069_profile_field_options.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS field_options;
|
||||||
236
db/migrations/000069_profile_field_options.up.sql
Normal file
236
db/migrations/000069_profile_field_options.up.sql
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
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', '13–17', 2, 'ACTIVE'),
|
||||||
|
('age_group', '18_24', '18–24', 3, 'ACTIVE'),
|
||||||
|
('age_group', '25_34', '25–34', 4, 'ACTIVE'),
|
||||||
|
('age_group', '35_44', '35–44', 5, 'ACTIVE'),
|
||||||
|
('age_group', '45_54', '45–54', 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');
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
-- No-op: keep field_options table name on rollback of 070 alone.
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- 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 $$;
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
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;
|
||||||
30
db/migrations/000070_subscription_content_categories.up.sql
Normal file
30
db/migrations/000070_subscription_content_categories.up.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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);
|
||||||
7
db/migrations/000071_seed_country_field_options.down.sql
Normal file
7
db/migrations/000071_seed_country_field_options.down.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
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'
|
||||||
|
);
|
||||||
25
db/migrations/000071_seed_country_field_options.up.sql
Normal file
25
db/migrations/000071_seed_country_field_options.up.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
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'
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
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;
|
||||||
1
db/migrations/000073_user_video_watch_sessions.down.sql
Normal file
1
db/migrations/000073_user_video_watch_sessions.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS user_video_watch_sessions;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user