Compare commits

...

144 Commits

Author SHA1 Message Date
a1c6b3c15a progress precentage fix 2026-05-27 09:18:25 -07:00
d3225ca61a Improve Google Android login error diagnostics.
Preserve the underlying Google token validation failure and log safe request context so ID token issues are easier to debug.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 06:32:15 -07:00
79fb95ce36 Add category-based subscription controls for LMS and exam prep.
Introduce plan and content categories across programs and exam-prep catalog roots, wire category-aware checkout and access checks, and keep learner gating temporarily bypassed until data migration is ready.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 06:20:49 -07:00
7a4253edf4 Add explicit payment provider selection for subscriptions.
Require the client to choose CHAPA or ARIFPAY in the subscription checkout request body and route payment initiation and verification through the matching provider.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 04:18:24 -07:00
82de00b1e7 Add LMS progress summary endpoint.
Expose a single learner endpoint that returns the nested LMS hierarchy with the same access-based progress percentages used across the existing content APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 04:07:19 -07:00
56cc009579 progress tracking fix 2026-05-26 03:50:46 -07:00
afdd07d65d Update learner progress to use practice completions only.
Remove lesson completion from learner progress percentages, access completion snapshots, and LMS rollups while keeping generated SQLC and Swagger artifacts in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 03:27:54 -07:00
a719c0daca Add mobile app version management and refresh profile field seeds.
Introduce admin CRUD and public version check APIs for Play Store/App Store releases with force or optional update policies, and update profile dropdown seed data for countries, regions, and learner profile fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 06:52:20 -07:00
3f73afb4bf Add video engagement tracking and analytics metrics.
Record playback heartbeats via POST /api/v1/videos/engagement/heartbeat and expose completion, replay, and drop-off rates on the analytics dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:59:46 -07:00
56089fa8fd Add users by country to analytics dashboard.
Expose by_country breakdown on GET /api/v1/analytics/dashboard from users.country.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 01:47:41 -07:00
e957eacf80 Add profile field breakdowns to analytics dashboard.
Expose user counts by education_level, occupation, learning_goal, and language_challange on GET /api/v1/analytics/dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:54:47 -07:00
f7d4b5c3fb Seed country and ethiopia_regions field options for dropdowns.
Add ISO-style country codes and Ethiopian regional states to initial and follow-up migrations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:45:39 -07:00
a5acd00637 Add admin-managed field options API for configurable dropdowns.
Store options in field_options with public /field-options and admin CRUD; validate learner profile values on update.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:21:36 -07:00
176f78515d Fix partial team member updates clearing team_role on invite accept.
Use nullable sqlc.narg fields so empty strings are not written to team_role and other optional columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 07:54:44 -07:00
215a4bd1dc Simplify team invite to email and role; collect profile on accept
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 06:49:28 -07:00
0ad7f094cf Include access metadata for OPEN_LEARNER with is_accessible always true
Keeps the same response shape as STUDENT while skipping sequential locks; progress fields are still populated for completion UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 05:45:44 -07:00
79851d31b3 email invitation 2026-05-22 05:17:19 -07:00
31bd1e3814 Add team member email invitations for admin panel onboarding
Introduces invite, verify, accept, resend, and revoke flows using team_members and invitation tokens, sends the branded invitation template, and requires account activation before team login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 03:43:00 -07:00
868e5ba001 Apply Yimaru Academy branding to email template seeds
Adds branded HTML layout matching the admin portal purple palette, updates 000066 seeds, and adds 000067 migration to refresh existing template rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 02:12:45 -07:00
5937c5505a Add admin-managed email templates and use them for OTP delivery
Adds CRUD and preview APIs, RBAC permissions, seeded system templates, and migrates OTP email/SMS to template rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 01:28:48 -07:00
1f7b38861e Integrate Chapa for learner subscription payments
Add Chapa checkout, verify, webhook, and callback flows so subscriptions activate only after confirmed payment. Route subscription checkout through Chapa while keeping ArifPay for direct payments. Include integration docs and a Postman collection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 03:35:57 -07:00
de8618191c Normalize broken FCM service account JSON (.env PEM newlines).
Repair multiline PEM inside private_key before Firebase init; add unit test; use normalized JSON for credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 08:57:26 -07:00
f7c9eddef5 Improve FCM service account loading and diagnostics.
Support FCM_SERVICE_ACCOUNT_KEY_FILE, clearer JSON parse errors for common .env mistakes, stop logging credential contents.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 08:41:27 -07:00
14d94ec723 Honor optional sort_order when creating exam-prep units.
Expose sort_order on CreateExamPrepUnitInput; insert applies explicit index with sibling shifting (aligned with LMS course create). Updated Swagger and LMS-Personas Postman collection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 07:18:35 -07:00
5399d33af6 Add optional gender to LMS personas.
Migration 000065 adds nullable gender text column; persona API and Postman expose it alongside profile_picture.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:37:21 -07:00
9ff418247f Always include profile_picture in persona JSON responses.
Remove omitempty on LmsPersona.profile_picture so list/get return null when unset. Add LMS-Personas Postman collection matching current API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:25:55 -07:00
6ab077b53d Rename LMS persona image field to profile_picture.
Add migration 000064 renaming avatar_url column; expose profile_picture in API, sqlc, and Swagger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:17:15 -07:00
9631711090 Seed default LMS personas in migration 063.
Insert ids 1-3 catalog rows and sync sequence on up; delete seed ids on down before dropping lms_personas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:11:09 -07:00
873be1b482 Add LMS personas catalog and CRUD API.
Introduce lms_personas table, repoint practice persona_id FKs off users, validate persona refs on LMS and exam-prep practice flows, personas.* RBAC permissions, and OpenAPI docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:06:42 -07:00
71bc09a638 Make practice title optional on create.
POST /practices and exam-prep practice create accept missing or null title; persist as empty string. Refresh OpenAPI and document the behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 04:11:09 -07:00
bd1767d2a6 Add LMS lesson draft and publish visibility.
Migration 000062 adds lessons.publish_status (DRAFT default for new rows; existing rows published). Editors see all lessons; learners see published-only lists and GET by id. Sequential prerequisites and completion counts ignore drafts. Course lesson_count counts published lessons only. Swagger documents publish_status on create/update bodies.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 02:16:42 -07:00
fffdff1031 Honor optional sort_order on lesson create under a module.
Accept sort_order on CreateLessonInput; SQL falls back to max+1. When set, shift sibling lessons and insert at that position (same pattern as module create). Regenerate sqlc and update Swagger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 01:56:15 -07:00
7e61e34292 Add OPEN_LEARNER role without LMS sequential gating.
Migration 000061 inserts the RBAC role and demo user (openlearner@yimaru.com). STUDENT keeps sequential ApplyAccess and practice ordering; OPEN_LEARNER shares learner permissions and customer flows. Document the role in Swagger and point initial seed SQL at the migration for the demo account.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 10:26:25 -07:00
83db13bed0 Honor optional sort_order on module create under a course.
Accept sort_order in CreateModuleInput, shift siblings when set, and default to max+1 when omitted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 04:15:18 -07:00
12ad59c409 Add draft vs published status for LMS and exam-prep practices.
Expose publish_status on create/update, filter learner-facing lists and gates, and add migration 000060.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 03:57:43 -07:00
37aef49e28 Honor optional sort_order on course create under a program.
Parses body sort_order, shifts sibling courses in-program, and inserts at the requested slot; omitting it keeps append-after-max behavior. Swagger/sqlc regenerated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 02:54:17 -07:00
1136a166f5 Shift sibling sort_order when creating or updating LMS hierarchy rows.
Sequential reorder uses a temporary negative id slot plus range shifts so UNIQUE constraints on programs, courses, modules, and lessons stay valid; replaces module pairwise swap-only behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 02:38:29 -07:00
d28bddace1 Accept optional sort_order when creating LMS programs.
Preserve append-after-max ordering when omitting sort_order and keep global uniqueness enforced by DB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 02:10:49 -07:00
4a681265d7 Resolve bulk role path segment from RBAC roles.id.
Admin bulk deactivate/reactivate accepts decimal path segments matching GET /rbac/roles IDs, resolving RoleRecord.name to the platform key. Document 404 when id is unknown. Add Cursor rule: on push, commit dirty tree first without secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 01:16:28 -07:00
2f73b60122 Allow ADMIN users to bulk deactivate and reactivate by role.
Platform ADMIN callers no longer hit 403 on these endpoints; bulk changes to platform users.role ADMIN remain restricted to SUPER_ADMIN, while team_members.team_role ADMIN is still eligible under path ADMIN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 01:09:42 -07:00
ecad91d89e Add SUPER_ADMIN bulk deactivate and reactivate by role.
Expose POST /admin/roles/:role/bulk-deactivate and bulk-reactivate for platform users and team_members, mirroring deactivate/reactivate semantics and optional team member exclusions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:52:14 -07:00
a80db8afd9 Add admin recent-activity timeline for learner profile UIs.
Expose GET /api/v1/admin/users/:user_id/recent-activity (progress.get_any_user) merging account creation and LMS completion milestones, with optional practice rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 01:13:21 -07:00
52effaa321 Add admin endpoint for nested user LMS completion activity.
Expose GET /api/v1/admin/users/:user_id/lms-learning-activity for progress.get_any_user so admins see program/course/module/lesson completions and practices from stored completion rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:58:49 -07:00
062b1f6151 Add country, region, and subscription_status filters to GET /users.
Filtering matches user profile country/region (case-insensitive trim) and derived subscription state in SQL so pagination totals stay correct.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:37:11 -07:00
49bcc22d0d Expose subscription_status on user profile responses instead of active_subscription.
Users see ACTIVE, PENDING, or Unsubscribed via new batch and single SQL helpers; Swagger refreshed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:28:19 -07:00
1e62510321 Always serialize active_subscription on profile responses.
Null encodes when there is no active plan so clients see explicit subscription state; Swagger regenerated and GET /users description updated accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:16:10 -07:00
f824c16c64 user list response fix 2026-05-18 00:09:26 -07:00
2883561525 Add monthly revenue trend for analytics when year is specified.
Exposes payments.revenue_monthly with Jan–Dec SUCCESS totals (UTC) per currency for dashboard charts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 23:32:36 -07:00
a1696bf1e0 Fix analytics dashboard course counts for LMS and exam_prep hierarchies.
Replace stub AnalyticsCourseCounts query and expose lms / exam_prep inventory in the courses section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 22:34:25 -07:00
7f8ef3373c Add paginated Vimeo video list API (GET /me/videos).
Exposes the Vimeo account library for admin workflows and syncs swagger docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 22:23:50 -07:00
9afc9a4392 date filter for dashborad analytics 2026-05-15 02:16:26 -07:00
024a69b74b Add date-range filtering to analytics dashboard API.
Support all-time, year, year+month, and custom from/to query params with filtered metrics and time-series charts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 02:15:15 -07:00
8bba318372 Fix practice completion roll-up for non-module scopes.
Resolve practice hierarchy using module, lesson, or course linkage and only run module-level cascade when module context exists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 03:58:39 -07:00
4ada908555 Allow completion for existing practice sets.
Treat existing PRACTICE sets as completable even when not published, while keeping sequence enforcement only for published practices.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 03:43:50 -07:00
86ab4e53d4 Fix practice completion lookup for progress endpoint.
Prioritize resolving lms_practices.id before falling back to question_set.id to avoid false 404 responses caused by cross-table ID collisions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 03:36:39 -07:00
c711df68b9 Fix practice completion lookup for progress endpoint.
Accept either question-set IDs or LMS practice IDs and recognize LMS owner types so valid practice completions no longer return practice-not-found responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 02:57:05 -07:00
eae87b40b5 Add practice completion progress endpoint.
Expose POST /progress/practices/:id/complete so practice completions are recorded through the existing CompletePractice flow and included in learner progress tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:20:33 -07:00
7e75d79dc8 Pass Firebase project_id explicitly from service account JSON.
Parse FCM_SERVICE_ACCOUNT_KEY, validate project_id, and provide firebase.Config{ProjectID} during FCM client initialization to prevent missing-project-id messaging failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:02:01 -07:00
4509fe2dc0 Initialize FCM client lazily during push send.
Add ensureFCMClient() so push APIs retry FCM initialization at request time and return actionable initialization errors when the service account key is empty or invalid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:58:42 -07:00
23322c69cc Remove stale commented env_file lines from compose app service.
Clean up docker-compose by dropping commented env_file entries that were only used during temporary runtime-env debugging.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:28:12 -07:00
6f1cb24c63 Add runtime config debug logging for test push flow.
Log DB_URL alongside FCM_SERVICE_ACCOUNT_KEY during test-push requests and keep compose env-file wiring aligned with current local debug setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:25:53 -07:00
cd0ae19d03 Log FCM env value on test-push requests.
Add request-time logging in the test push endpoint so FCM_SERVICE_ACCOUNT_KEY can be verified during each API call, not only at service startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:56:45 -07:00
75353f8bdd Log FCM service account env value at startup.
Add a notification-service startup log to print FCM_SERVICE_ACCOUNT_KEY for runtime verification during push notification troubleshooting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:54:22 -07:00
b2a72c2f6e Fix device registration error mapping for invalid user IDs.
Validate device registration input and translate devices_user_fk violations into a clear bad-request response so invalid auth contexts no longer return opaque 500 errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 08:32:42 -07:00
6a4fe68628 Add full FAQ management APIs and integration assets.
Implement public FAQ read endpoints and admin CRUD with RBAC, persistence, and migrations, then regenerate Swagger and add a complete Postman collection so frontend/admin teams can integrate and validate the feature end-to-end.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 07:58:17 -07:00
bc2357374b Add practice-existence flags and refresh API contracts.
Expose has_practice booleans for LMS and pre-exam hierarchy entities, wire SQL/repository mappings, and regenerate SQLC/Swagger artifacts. Also update the Resend sender display name for outbound emails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 11:57:11 -07:00
9da9eb77e5 fix dynamic builder runtime mapping for option responses
Allow builder-native response kinds like OPTION to resolve to DYNAMIC so schema-driven definition creation succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 10:56:41 -07:00
3d1b3ad9b8 dynamic question type builder completion 2026-05-08 10:12:02 -07:00
9a17f0b3c4 use descriptive top-level message for direct payments
Keep provider-specific details in data.message and return a stable, human-readable top-level success message for /payments/direct responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 09:26:07 -07:00
0983589e36 extend full-payload direct proxy flow to MPESA
Align MPESA direct payment with TELEBIRR_USSD by routing it through the provider's full checkout payload proxy endpoint for consistent gateway behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 09:21:43 -07:00
21ce61b910 telebirr-ussd direct payment fix 2026-05-07 09:08:43 -07:00
f906862676 partly implemented dynamic question builder + payment routes fix 2026-05-07 08:10:21 -07:00
73370633ce temporarily disabled subscription check 2026-05-06 05:10:36 -07:00
b62d89574e Include nested lesson and practice counts in exam-prep modules list response.
Return per-module lesson and practice aggregates under unit modules listing so clients can render module depth without additional queries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 07:05:35 -07:00
16c3f6b613 Include nested module, lesson, and practice counts in exam-prep units list response.
Expose per-unit aggregate counts under catalog-course units listing so clients can display unit depth without extra chained requests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 06:35:13 -07:00
4124f98160 Include nested content counts in exam-prep catalog list response.
Add units, modules, and lessons aggregate counts per catalog course so clients can render hierarchy depth without extra API calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 06:13:01 -07:00
10954d88b0 subscription management fix + duolingo hierarchy implementation 2026-05-04 10:44:18 -07:00
eba2b87ed6 Use initial assessment description as normalized level.
Made-with: Cursor
2026-04-29 08:12:09 -07:00
60290e5c34 Swap module sort_order on conflict during update.
When updating a module sort_order to an occupied position in the same course, perform an atomic swap in a transaction instead of failing with a unique constraint error.

Made-with: Cursor
2026-04-29 03:36:45 -07:00
8430b82687 Fix partial question-set updates preserving existing values.
When PUT payload omits title, status, or shuffle_questions, reuse current persisted values so updates do not write invalid empty status values.

Made-with: Cursor
2026-04-29 03:02:13 -07:00
cdb0fa1bb3 Enforce strict initial assessment set validation.
Require INITIAL_ASSESSMENT titles to follow the Level Test A1/A2/B1/B2 format and ensure passing_score is always present on create and update.

Made-with: Cursor
2026-04-29 02:47:21 -07:00
9027b65011 Require lesson and practice completion for LMS rollups.
Update lesson and practice completion flows to cascade module/course/program progress only when both lesson completion and related published practice completion criteria are met, and align progress counters with the new rule.

Made-with: Cursor
2026-04-28 09:56:53 -07:00
8c116f4a0b GET question sets API fix 2026-04-28 09:41:09 -07:00
87bf2ed609 data loss fix 2026-04-28 09:30:48 -07:00
9cfd6c524e Allow INITIAL_ASSESSMENT question sets without owner_type and owner_id
Made-with: Cursor
2026-04-27 10:46:33 -07:00
0d02eb1a24 add MinIO media URL refresh endpoint
Add POST /api/v1/files/refresh-url to issue fresh presigned URLs from object keys, minio:// references, or stale presigned URLs so clients can refresh media links before render.

Made-with: Cursor
2026-04-27 05:25:16 -07:00
78f231f222 fix OTP verification by submitted code
Resolve false OTP already used/expired responses during registration by loading OTP rows using user_id plus submitted otp code and validating usage/expiry on the matched row.

Made-with: Cursor
2026-04-25 05:07:19 -07:00
526426d9f9 course practice count fix 2026-04-25 02:41:34 -07:00
5857fce9a0 count data for course 2026-04-25 02:36:52 -07:00
7e26f15bed early otp expiration fix 2026-04-25 00:16:05 -07:00
bc68326a66 fix: return sample_answer_voice_prompt and audio_correct_answer_text in question set items list
Extend GetQuestionSetItems and GetPublishedQuestionsInSet queries to match
paginated fields; map audio answer join in repository instead of nils.

Made-with: Cursor
2026-04-24 03:11:47 -07:00
33d34f0dd2 fix: map default CEFR courses to Beginner/Intermediate/Advanced programs
Seed A1-A2, B1-B2, and C1-C2 only on their matching programs; add migration
000050 for existing databases. Document mapping in domain.

Made-with: Cursor
2026-04-24 01:14:50 -07:00
5b53929d92 learning progress implementation 2026-04-23 03:58:27 -07:00
dc788c04cb updated swagger 2026-04-23 02:11:20 -07:00
6c672c4b20 static data for Courses 2026-04-23 02:07:32 -07:00
9db9c9899a module+lesson+practice implementations 2026-04-23 01:59:20 -07:00
152478a96c added program 2026-04-23 00:59:01 -07:00
9154dec067 fix: course-management practice detail without sub_module_practices row
- resolveCourseManagementPractice falls back to SUB_MODULE PRACTICE question_sets
- Synthetic practice uses id 0 and question_set_id for orphan sets
- Align GET practice and /detail with resolver; sync question_count after load

Made-with: Cursor
2026-04-21 09:59:41 -07:00
5fbca53534 fix: resolve practice by question set id; set Response flags on question-sets by-owner
- GetSubModulePracticeByID matches sub_module_practices.id or question_set_id
- Prefer primary id when both could match (ORDER BY + LIMIT 1)
- Set Success/StatusCode on practice GET/detail and GetQuestionSetsByOwner

Made-with: Cursor
2026-04-21 09:55:11 -07:00
6839d1aa0d fix: sub-module practices list excludes non-PRACTICE sets and bad Response flags
- Drop question_sets.set_type = PRACTICE filter so sub_module_practices rows list correctly
- Set Success and StatusCode on GET sub-modules/:id/practices response
- Return empty JSON array instead of null for no practices

Made-with: Cursor
2026-04-21 09:31:22 -07:00
72d1a0c3ed feat: list sub-categories by course category ID
- GET /api/v1/course-management/categories/:categoryId/sub-categories

- SQL GetCourseSubCategoriesByCategoryID; swagger refresh

Made-with: Cursor
2026-04-20 08:32:19 -07:00
de95c4d0d2 feat: practice detail API, inactive purge tracking, and related plumbing
- Add GET /api/v1/course-management/practices/:practiceId/detail with full question items

- Add migration 000040 for sub-module content inactive purge tracking

- Hierarchy queries, sqlc gen, config/app purge job, swagger refresh

Made-with: Cursor
2026-04-20 08:24:59 -07:00
90baa582be fix: load sub-module lesson by ID regardless of active flag
Course-management GET/PUT used GetSubModuleLessonByID with is_active=TRUE,
which returned no row for inactive lessons. Align with other ByID lookups
and allow admins to view and edit inactive lessons.

Made-with: Cursor
2026-04-20 00:48:13 -07:00
bbd919ca12 feat: optional include_inactive for sub-module lessons list
GET .../sub-modules/:id/lessons?include_inactive=true returns all lessons;
default remains active-only.

Made-with: Cursor
2026-04-18 03:25:28 -07:00
3e54b5039d fix: surface DB error when team login refresh token issuance fails
Return err.Error() in the response so operators see e.g. missing
team_refresh_tokens table instead of a generic message.

Made-with: Cursor
2026-04-18 03:13:34 -07:00
24f1aca97a fix: return updated lesson from UpdateSubModuleLesson after is_active false
GetSubModuleLessonByID filters is_active=true, so refetch failed with 500
after soft-deactivating. Use RETURNING row from the update instead.

Made-with: Cursor
2026-04-18 02:54:47 -07:00
ce1b827768 refresh token fix 2026-04-17 10:16:25 -07:00
886b62ed68 feat(levels): flexible cefr_level codes up to 64 chars
- Migration 000038 drops fixed A1-C3 check and widens cefr_level column
- CreateLevel validates length and NUL only; preserve client casing
- Regenerate Swagger docs

Made-with: Cursor
2026-04-17 09:24:34 -07:00
7ff0b639cf added more structure to submodules 2026-04-17 09:07:25 -07:00
c5d3935062 added more structure to levels 2026-04-17 08:33:58 -07:00
518c3ee751 added more structure to lessons 2026-04-17 08:27:40 -07:00
1026354c24 Expand course hierarchy read APIs and practice retrieval.
Add list/detail endpoints for courses, levels, modules, submodules, and submodule practices; extend course listing queries; add lesson update support and clean up removed route paths.

Made-with: Cursor
2026-04-17 07:52:22 -07:00
343ce470cc add lesson and subcategory retrieval/update endpoints
Introduce dedicated APIs for submodule lesson detail/update and subcategory listing (including Human Language), with SQL/query wiring and handler routing updates.

Made-with: Cursor
2026-04-17 01:40:47 -07:00
01914cb81e Add lesson detail retrieval endpoints.
Expose APIs to list lessons by submodule and fetch a single lesson by ID, including title, description, intro video URL, and question count.

Made-with: Cursor
2026-04-16 02:42:21 -07:00
d686bdf8bd compose file port update 2026-04-16 02:09:39 -07:00
ea55d9b371 Empty commit to trigger CI/CD - 1 2026-04-16 02:08:32 -07:00
9ee8952d7f Empty commit to trigger CI/CD - 1 2026-04-16 02:03:29 -07:00
1c8d041747 README update 2026-04-16 01:59:32 -07:00
a9c6966820 add legacy learning-path GET endpoint for flows compatibility
Expose GET /course-management/courses/:courseId/learning-path and build response from unified hierarchy tables so first-time Flows tab loads no longer fail with Cannot GET.

Made-with: Cursor
2026-04-14 10:05:53 -07:00
06d86c9098 readme update 2026-04-14 09:33:52 -07:00
57f0db269a add legacy category courses GET endpoint for compatibility
Expose GET /course-management/categories/:categoryId/courses so legacy tab/API callers no longer fail with Cannot GET during initial content load.

Made-with: Cursor
2026-04-14 09:21:13 -07:00
3889334e3f query fix 2026-04-14 08:42:19 -07:00
f5e925dc96 separate lessons schema from practices in hierarchy
Replace rename-based lesson migration with additive sub_module_lessons creation, preserve sub_module_practices as its own model, and enforce QUIZ/PRACTICE filtering in hierarchy reads to prevent cross-mixing.

Made-with: Cursor
2026-04-14 07:13:50 -07:00
83f5541650 add course category deletion endpoint
Expose delete support for top-level course categories and cascade removal of linked sub-categories/courses for content-management cleanup.

Made-with: Cursor
2026-04-14 06:22:36 -07:00
542a597f41 fix module removal to delete actual module records
Add a module delete API route and handler so level/module removal actions remove modules directly instead of only deleting sub-modules.

Made-with: Cursor
2026-04-14 05:58:37 -07:00
9123ff571d add sub-category deletion endpoint for course management
Introduce a compatibility delete route and handler for course sub-categories and cascade-delete their linked courses to support admin content cleanup flows.

Made-with: Cursor
2026-04-14 05:45:16 -07:00
0cc813d224 fix course creation linkage to sub-categories
Allow course creation payloads to include sub_category_id and persist it so newly created language paths appear in unified hierarchy views.

Made-with: Cursor
2026-04-14 05:23:24 -07:00
a4d1f395da add legacy human-language hierarchy route alias
Expose /course-management/human-language/hierarchy as an alias to the unified hierarchy handler so older admin clients continue working without 404s.

Made-with: Cursor
2026-04-14 05:15:42 -07:00
2ff1e89263 more course CRUD fix 2026-04-14 05:04:38 -07:00
5858aeb744 course CRUD fix 2026-04-14 04:06:49 -07:00
b1a1b97a0a Merge remote-tracking branch 'origin/production' 2026-04-14 04:03:21 -07:00
Kerod-Fresenbet-Gebremedhin2660
facaedb8dc Empty commit to trigger CI/CD - 3 2026-04-14 14:50:13 +03:00
e3afadf2bb 404 errors fix 2026-04-14 03:56:49 -07:00
bb2f92e5e3 readme file update 2026-04-14 02:18:27 -07:00
be70f87541 readme file update 2026-04-14 02:15:38 -07:00
d9783310d1 Add legacy hierarchy fallback for pre-migration databases.
Handle missing course_sub_categories table by serving hierarchy data from legacy categories/courses queries so content pages keep loading until unified hierarchy migration is applied.

Made-with: Cursor
2026-04-14 01:02:31 -07:00
69d3d440d0 permissions data seed fix 2026-04-14 00:39:42 -07:00
f256ee179a permissions data seed 2026-04-14 00:35:10 -07:00
f7499cb41a data seed fix 2026-04-14 00:24:30 -07:00
d08f92e06c Merge branch 'production' of https://gitea.yaltopia.com/Yimaru/Yimaru-BackEnd 2026-04-14 00:11:30 -07:00
Kerod-Fresenbet-Gebremedhin2660
84cfc3ac4d Empty commit to trigger CI/CD - 2 2026-04-13 17:23:12 +03:00
Kerod-Fresenbet-Gebremedhin2660
4b6d3da7bc Empty commit to trigger CI/CD - 1 2026-04-13 17:05:50 +03:00
894e18bcae removed all unnecessary data seed 2026-04-10 03:32:07 -07:00
7613eb583a new course management hierarchy 2026-04-10 03:06:30 -07:00
382 changed files with 51378 additions and 16866 deletions

View 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 repos commit message conventions.
3. Then run `git push` to the tracked upstream.
If nothing is staged and the working tree is clean, pushing without a commit is fine.

View File

@ -1,4 +1,4 @@
# Yimaru Backend
# 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.

View File

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

View File

@ -4,6 +4,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ======================================================
-- Customer/Learner Users (login via /api/v1/auth/customer-login)
-- Credentials: email + password@123
-- OPEN_LEARNER demo user is seeded by migration 000061_open_learner_role (not here).
-- ======================================================
INSERT INTO users (
id,
@ -136,190 +137,6 @@ VALUES
)
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)
-- Credentials: email + password@123
@ -359,7 +176,7 @@ VALUES
'Administrative staff managing day-to-day operations.',
'active',
TRUE,
'[*]'::jsonb,
'["*"]'::jsonb,
CURRENT_TIMESTAMP
),
(
@ -471,3 +288,20 @@ VALUES
CURRENT_TIMESTAMP
)
ON CONFLICT (id) DO NOTHING;
-- Legacy team_members row may pre-exist; align admin permissions with seed expectations.
UPDATE team_members
SET permissions = '["*"]'::jsonb
WHERE id = 2 OR email = 'admin@yimaru.com';
-- ======================================================
-- RBAC safety seed: ensure ADMIN has permission grants
-- NOTE: API authorization uses RBAC role_permissions, not
-- team_members.permissions JSON.
-- ======================================================
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'ADMIN'
ON CONFLICT (role_id, permission_id) DO NOTHING;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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';

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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

View 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.');

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

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

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

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

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

View 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';

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

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

View 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';

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

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

View File

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

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

View File

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

View File

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

View File

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

View 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;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$otp_html$,
'["OTP", "FirstName", "ExpiresMinutes"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'invitation',
'User Invitation',
'You are invited to Yimaru Academy',
'Hi{{if .FirstName}} {{.FirstName}}{{end}},
You have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Academy.
Accept your invitation: {{.InviteLink}}',
$invite_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">You&rsquo;re invited</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}you have been invited{{if .InviterName}} by <strong style="color:#9d2a83;">{{.InviterName}}</strong>{{end}} to join Yimaru Academy.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.InviteLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Accept invitation</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">Or copy this link: <a href="{{.InviteLink}}" style="color:#9d2a83;">{{.InviteLink}}</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$invite_html$,
'["FirstName", "InviterName", "InviteLink"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'password_reset',
'Password Reset',
'Reset your Yimaru Academy password',
'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Reset your password: {{.ResetLink}}
This link expires in {{.ExpiresMinutes}} minutes.',
$reset_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Reset your password</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}we received a request to reset your Yimaru Academy password. The link below expires in {{.ExpiresMinutes}} minutes.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.ResetLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Reset password</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">If you did not request a reset, ignore this email.</p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$reset_html$,
'["FirstName", "ResetLink", "ExpiresMinutes"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'welcome',
'Welcome Email',
'Welcome to Yimaru Academy',
'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Welcome to Yimaru Academy! Sign in to get started: {{.LoginURL}}',
$welcome_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Welcome aboard</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}your Yimaru Academy account is ready. Start learning at your own pace.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.LoginURL}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Sign in to Yimaru Academy</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$welcome_html$,
'["FirstName", "LoginURL"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'custom_message',
'Custom Message',
'{{.Subject}}',
'{{.Message}}',
$custom_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">{{.Subject}}</h1>
<div style="margin:0;color:#666666;font-size:15px;line-height:1.6;">{{.Message}}</div>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$custom_html$,
'["Subject", "Message"]'::jsonb,
TRUE,
'ACTIVE'
)
ON CONFLICT (slug) DO NOTHING;

View File

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

View 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;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$otp_html$,
updated_at = NOW()
WHERE slug = 'otp';
UPDATE email_templates SET
name = 'User Invitation',
subject = 'You are invited to Yimaru Academy',
body_text = 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
You have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Academy.
Accept your invitation: {{.InviteLink}}',
body_html = $invite_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">You&rsquo;re invited</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}you have been invited{{if .InviterName}} by <strong style="color:#9d2a83;">{{.InviterName}}</strong>{{end}} to join Yimaru Academy.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.InviteLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Accept invitation</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">Or copy this link: <a href="{{.InviteLink}}" style="color:#9d2a83;">{{.InviteLink}}</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$invite_html$,
updated_at = NOW()
WHERE slug = 'invitation';
UPDATE email_templates SET
name = 'Password Reset',
subject = 'Reset your Yimaru Academy password',
body_text = 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Reset your password: {{.ResetLink}}
This link expires in {{.ExpiresMinutes}} minutes.',
body_html = $reset_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Reset your password</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}we received a request to reset your Yimaru Academy password. The link below expires in {{.ExpiresMinutes}} minutes.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.ResetLink}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Reset password</a></p>
<p style="margin:24px 0 0;color:#666666;font-size:14px;line-height:1.6;">If you did not request a reset, ignore this email.</p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$reset_html$,
updated_at = NOW()
WHERE slug = 'password_reset';
UPDATE email_templates SET
name = 'Welcome Email',
subject = 'Welcome to Yimaru Academy',
body_text = 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Welcome to Yimaru Academy! Sign in to get started: {{.LoginURL}}',
body_html = $welcome_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">Welcome aboard</h1>
<p style="margin:0 0 20px;color:#666666;font-size:15px;line-height:1.6;">{{if .FirstName}}Hi <strong style="color:#333333;">{{.FirstName}}</strong>, {{end}}your Yimaru Academy account is ready. Start learning at your own pace.</p>
<p style="margin:0 0 24px;text-align:center;"><a href="{{.LoginURL}}" style="display:inline-block;background-color:#9d2a83;color:#ffffff !important;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:600;font-size:15px;">Sign in to Yimaru Academy</a></p>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$welcome_html$,
updated_at = NOW()
WHERE slug = 'welcome';
UPDATE email_templates SET
name = 'Custom Message',
body_html = $custom_html$<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Yimaru Academy</title></head>
<body style="margin:0;padding:0;background-color:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6fb;padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(157,42,131,0.14);">
<tr><td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 52%,#c43a9a 100%);padding:28px 32px;text-align:center;">
<p style="margin:0;color:#ffffff;font-size:22px;font-weight:700;">Yimaru Academy</p>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.88);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;">Online Learning Platform</p>
</td></tr>
<tr><td style="padding:32px;">
<h1 style="margin:0 0 8px;color:#333333;font-size:24px;font-weight:700;">{{.Subject}}</h1>
<div style="margin:0;color:#666666;font-size:15px;line-height:1.6;">{{.Message}}</div>
</td></tr>
<tr><td style="padding:20px 32px;background-color:#fafafa;border-top:1px solid #eeeeee;text-align:center;">
<p style="margin:0;color:#999999;font-size:12px;">&copy; 2026 Yimaru Academy &middot; All rights reserved</p>
</td></tr>
</table></td></tr></table></body></html>$custom_html$,
updated_at = NOW()
WHERE slug = 'custom_message';

View File

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

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

View File

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

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

View File

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

View File

@ -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 $$;

View File

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

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

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

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

View File

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

View File

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

View 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