Compare commits

..

36 Commits

Author SHA1 Message Date
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
133 changed files with 10800 additions and 6228 deletions

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

@ -14,10 +14,15 @@ import (
"Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication"
"Yimaru-Backend/internal/services/course_management"
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
"Yimaru-Backend/internal/services/messenger"
notificationservice "Yimaru-Backend/internal/services/notification"
coursesservice "Yimaru-Backend/internal/services/courses"
lessonsservice "Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
moduleservice "Yimaru-Backend/internal/services/modules"
practicesservice "Yimaru-Backend/internal/services/practices"
programsservice "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions"
"Yimaru-Backend/internal/services/recommendation"
"Yimaru-Backend/internal/services/settings"
@ -360,24 +365,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)")
@ -402,6 +393,23 @@ func main() {
// Questions service (unified questions system)
questionsSvc := questions.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)
// LMS practices (under course, module, or lesson)
practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
// Subscriptions service
subscriptionsSvc := subscriptions.NewService(store)
@ -414,7 +422,7 @@ func main() {
)
// Team management service
teamSvc := team.NewService(repository.NewTeamStore(store))
teamSvc := team.NewService(repository.NewTeamStore(store), cfg.RefreshExpiry)
// santimpayClient := santimpay.NewSantimPayClient(cfg)
@ -442,8 +450,13 @@ func main() {
// Initialize and start HTTP server
app := httpserver.NewApp(
assessmentSvc,
courseSvc,
questionsSvc,
programSvc,
courseSvc,
moduleSvc,
lessonSvc,
lmsProgressSvc,
practiceSvc,
subscriptionsSvc,
arifpaySvc,
issueReportingSvc,

View File

@ -1,2 +0,0 @@
-- Intentionally empty: course hierarchy is not seeded from SQL.
-- Use admin/API or migrations to create content.

View File

@ -1,6 +1,4 @@
ALTER TABLE IF EXISTS sub_module_lessons
RENAME TO sub_module_practices;
DROP INDEX IF EXISTS idx_sub_module_lessons_sub_module_id;
ALTER INDEX IF EXISTS idx_sub_module_lessons_sub_module_id
RENAME TO idx_sub_module_practices_sub_module_id;
DROP TABLE IF EXISTS sub_module_lessons;

View File

@ -1,6 +1,15 @@
ALTER TABLE IF EXISTS sub_module_practices
RENAME TO sub_module_lessons;
-- 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)
);
ALTER INDEX IF EXISTS idx_sub_module_practices_sub_module_id
RENAME TO idx_sub_module_lessons_sub_module_id;
CREATE INDEX IF NOT EXISTS idx_sub_module_lessons_sub_module_id
ON sub_module_lessons(sub_module_id);

View File

@ -12,6 +12,24 @@ CREATE TABLE IF NOT EXISTS sub_module_practices (
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,18 @@
-- Default CEFR-style course names per program (custom courses can still be created via the API with any name).
-- Matches hierarchy note on courses: CEFR labels A1..C2, plus ad-hoc names allowed.
INSERT INTO courses (program_id, name, description, thumbnail)
SELECT
p.id,
v.name,
'Default CEFR level course (system seed).',
NULL
FROM programs AS p
CROSS JOIN (
VALUES
('A1'),
('A2'),
('B1'),
('B2'),
('C1'),
('C2')
) AS v (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

@ -163,10 +163,10 @@ ORDER BY d.date;
-- name: AnalyticsCourseCounts :one
SELECT
(SELECT COUNT(*)::bigint FROM course_categories) AS total_categories,
(SELECT COUNT(*)::bigint FROM courses) AS total_courses,
(SELECT COUNT(*)::bigint FROM sub_courses) AS total_sub_courses,
(SELECT COUNT(*)::bigint FROM sub_course_videos) AS total_videos;
0::bigint AS total_categories,
0::bigint AS total_courses,
0::bigint AS total_sub_courses,
0::bigint AS total_videos;
-- =====================
-- Content Analytics

View File

@ -1,47 +0,0 @@
-- name: CreateCourseCategory :one
INSERT INTO course_categories (
name,
is_active
)
VALUES ($1, COALESCE($2, true))
RETURNING *;
-- name: GetCourseCategoryByID :one
SELECT *
FROM course_categories
WHERE id = $1;
-- name: GetAllCourseCategories :many
SELECT
COUNT(*) OVER () AS total_count,
id,
name,
is_active,
created_at
FROM course_categories
ORDER BY display_order ASC, created_at DESC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: UpdateCourseCategory :exec
UPDATE course_categories
SET
name = COALESCE($1, name),
is_active = COALESCE($2, is_active)
WHERE id = $3;
-- name: DeleteCourseCategory :exec
DELETE FROM course_categories
WHERE id = $1;
-- name: ReorderCourseCategories :exec
UPDATE course_categories
SET display_order = bulk.position
FROM (
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
) AS bulk
WHERE course_categories.id = bulk.id;

View File

@ -1,58 +0,0 @@
-- name: CreateCourse :one
INSERT INTO courses (
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
RETURNING *;
-- name: GetCourseByID :one
SELECT *
FROM courses
WHERE id = $1;
-- name: GetCoursesByCategory :many
SELECT
COUNT(*) OVER () AS total_count,
id,
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
FROM courses
WHERE category_id = $1
ORDER BY display_order ASC, id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: UpdateCourse :exec
UPDATE courses
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
thumbnail = COALESCE($3, thumbnail),
intro_video_url = COALESCE($4, intro_video_url),
is_active = COALESCE($5, is_active)
WHERE id = $6;
-- name: DeleteCourse :exec
DELETE FROM courses
WHERE id = $1;
-- name: ReorderCourses :exec
UPDATE courses
SET display_order = bulk.position
FROM (
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
) AS bulk
WHERE courses.id = bulk.id;

View File

@ -1,195 +0,0 @@
-- name: GetCoursesWithHierarchy :many
SELECT
cc.id AS category_id,
cc.name AS category_name,
csc.id AS sub_category_id,
csc.name AS sub_category_name,
c.id AS course_id,
c.title AS course_title
FROM course_categories cc
LEFT JOIN course_sub_categories csc ON csc.category_id = cc.id AND csc.is_active = TRUE
LEFT JOIN courses c ON c.sub_category_id = csc.id AND c.is_active = TRUE
WHERE cc.is_active = TRUE
ORDER BY cc.id, csc.display_order, csc.id, c.id;
-- name: GetLevelsByCourseID :many
SELECT *
FROM levels
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetModulesByLevelID :many
SELECT *
FROM modules
WHERE level_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetSubModulesByModuleID :many
SELECT *
FROM sub_modules
WHERE module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleVideos :many
SELECT *
FROM sub_module_videos
WHERE sub_module_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleLessons :many
SELECT
smp.id,
smp.sub_module_id,
smp.question_set_id,
smp.intro_video_url,
smp.display_order,
smp.is_active,
qs.title,
qs.description,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_lessons smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
ORDER BY smp.display_order ASC, smp.id ASC;
-- name: GetSubModulePractices :many
SELECT
smp.id,
smp.sub_module_id,
smp.title,
smp.description,
smp.thumbnail,
smp.intro_video_url,
smp.question_set_id,
smp.display_order,
smp.is_active,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_practices smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
ORDER BY smp.display_order ASC, smp.id ASC;
-- name: GetFullHierarchyByCourseID :many
SELECT
c.id AS course_id,
c.title AS course_title,
l.id AS level_id,
l.cefr_level,
m.id AS module_id,
m.title AS module_title,
sm.id AS sub_module_id,
sm.title AS sub_module_title
FROM courses c
LEFT JOIN levels l ON l.course_id = c.id AND l.is_active = TRUE
LEFT JOIN modules m ON m.level_id = l.id AND m.is_active = TRUE
LEFT JOIN sub_modules sm ON sm.module_id = m.id AND sm.is_active = TRUE
WHERE c.id = $1
ORDER BY l.display_order, l.id, m.display_order, m.id, sm.display_order, sm.id;
-- name: CreateCourseSubCategory :one
INSERT INTO course_sub_categories (
category_id,
name,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: CreateLevel :one
INSERT INTO levels (
course_id,
cefr_level,
display_order,
is_active
)
VALUES ($1, $2, COALESCE($3, 0), COALESCE($4, TRUE))
RETURNING *;
-- name: CreateModule :one
INSERT INTO modules (
level_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: CreateSubModule :one
INSERT INTO sub_modules (
module_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: CreateSubModuleVideo :one
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
)
VALUES (
$1, $2, $3, $4, $5, $6,
COALESCE($7, FALSE), $8, $9, $10, $11,
COALESCE($12, 0), COALESCE($13, 'DRAFT'),
$14, $15, $16, $17, COALESCE($18, 'DIRECT')
)
RETURNING *;
-- name: AttachQuestionSetLessonToSubModule :one
INSERT INTO sub_module_lessons (
sub_module_id,
question_set_id,
intro_video_url,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: CreateSubModulePractice :one
INSERT INTO sub_module_practices (
sub_module_id,
title,
description,
thumbnail,
intro_video_url,
question_set_id,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
RETURNING *;

67
db/query/lms_courses.sql Normal file
View File

@ -0,0 +1,67 @@
-- name: CreateCourse :one
INSERT INTO courses (program_id, name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
$4,
coalesce((
SELECT
max(c.sort_order)
FROM courses c
WHERE
c.program_id = $1), 0) + 1
RETURNING
*;
-- name: GetCourseByID :one
SELECT *
FROM courses
WHERE id = $1;
-- name: ListCourseIDsByProgram :many
SELECT
c.id
FROM
courses AS c
WHERE
c.program_id = $1
ORDER BY
c.id;
-- name: ListCoursesByProgramID :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.program_id,
c.name,
c.description,
c.thumbnail,
c.sort_order,
c.created_at,
c.updated_at
FROM
courses c
WHERE
c.program_id = $1
ORDER BY
c.sort_order ASC,
c.id ASC
LIMIT $2 OFFSET $3;
-- name: UpdateCourse :one
UPDATE courses
SET
name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteCourse :exec
DELETE FROM courses
WHERE id = $1;

61
db/query/lms_lessons.sql Normal file
View File

@ -0,0 +1,61 @@
-- name: CreateLesson :one
INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(l.sort_order)
FROM lessons l
WHERE
l.module_id = $1), 0) + 1
RETURNING
*;
-- name: GetLessonByID :one
SELECT *
FROM lessons
WHERE id = $1;
-- name: ListLessonsByModuleID :many
SELECT
COUNT(*) OVER () AS total_count,
l.id,
l.module_id,
l.title,
l.video_url,
l.thumbnail,
l.description,
l.sort_order,
l.created_at,
l.updated_at
FROM
lessons l
WHERE
l.module_id = $1
ORDER BY
l.sort_order ASC,
l.id ASC
LIMIT $2
OFFSET $3;
-- name: UpdateLesson :one
UPDATE lessons
SET
title = COALESCE(sqlc.narg('title')::varchar, title),
video_url = COALESCE(sqlc.narg('video_url')::text, video_url),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
description = COALESCE(sqlc.narg('description')::text, description),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteLesson :exec
DELETE FROM lessons
WHERE id = $1;

71
db/query/lms_modules.sql Normal file
View File

@ -0,0 +1,71 @@
-- name: CreateModule :one
INSERT INTO modules (program_id, course_id, name, description, icon, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(m.sort_order)
FROM modules m
WHERE
m.course_id = $2), 0) + 1
RETURNING
*;
-- name: GetModuleByID :one
SELECT *
FROM modules
WHERE id = $1;
-- name: ListModuleIDsByCourse :many
SELECT
m.id
FROM
modules AS m
WHERE
m.course_id = $1
ORDER BY
m.id;
-- name: ListModulesByProgramAndCourse :many
SELECT
COUNT(*) OVER () AS total_count,
m.id,
m.program_id,
m.course_id,
m.name,
m.description,
m.icon,
m.sort_order,
m.created_at,
m.updated_at
FROM
modules m
WHERE
m.program_id = $1
AND m.course_id = $2
ORDER BY
m.sort_order ASC,
m.id ASC
LIMIT $3
OFFSET $4;
-- name: UpdateModule :one
UPDATE modules
SET
name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description),
icon = COALESCE(sqlc.narg('icon')::text, icon),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteModule :exec
DELETE FROM modules
WHERE id = $1;

View File

@ -0,0 +1,88 @@
-- name: CreateLmsPractice :one
INSERT INTO lms_practices (
course_id, module_id, lesson_id,
title, story_description, story_image, persona_id, question_set_id, quick_tips
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *;
-- name: GetLmsPracticeByID :one
SELECT *
FROM lms_practices
WHERE id = $1;
-- name: ListLmsPracticesByCourseID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.course_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3;
-- name: ListLmsPracticesByModuleID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.module_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3;
-- name: ListLmsPracticesByLessonID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.lesson_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3;
-- name: UpdateLmsPractice :one
UPDATE lms_practices
SET
title = COALESCE(sqlc.narg('title')::varchar, title),
story_description = COALESCE(sqlc.narg('story_description')::text, story_description),
story_image = COALESCE(sqlc.narg('story_image')::text, story_image),
persona_id = COALESCE(sqlc.narg('persona_id')::bigint, persona_id),
question_set_id = COALESCE(sqlc.narg('question_set_id')::bigint, question_set_id),
quick_tips = COALESCE(sqlc.narg('quick_tips')::text, quick_tips),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING *;
-- name: DeleteLmsPractice :exec
DELETE FROM lms_practices
WHERE id = $1;

248
db/query/lms_progress.sql Normal file
View File

@ -0,0 +1,248 @@
-- name: GetPreviousProgram :one
SELECT
p2.*
FROM
programs AS p1
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
WHERE
p1.id = $1;
-- name: GetPreviousCourseInProgram :one
SELECT
c2.*
FROM
courses AS c1
INNER JOIN courses AS c2 ON c2.program_id = c1.program_id
AND c2.sort_order = c1.sort_order - 1
WHERE
c1.id = $1;
-- name: GetPreviousModuleInCourse :one
SELECT
m2.*
FROM
modules AS m1
INNER JOIN modules AS m2 ON m2.course_id = m1.course_id
AND m2.sort_order = m1.sort_order - 1
WHERE
m1.id = $1;
-- name: GetPreviousLessonInModule :one
SELECT
l2.*
FROM
lessons AS l1
INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id
AND l2.sort_order = l1.sort_order - 1
WHERE
l1.id = $1;
-- name: UserHasProgramProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_program_progress
WHERE
user_id = $1
AND program_id = $2) AS v;
-- name: UserHasCourseProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_course_progress
WHERE
user_id = $1
AND course_id = $2) AS v;
-- name: UserHasModuleProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_module_progress
WHERE
user_id = $1
AND module_id = $2) AS v;
-- name: UserHasLessonProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_lesson_progress
WHERE
user_id = $1
AND lesson_id = $2) AS v;
-- name: InsertUserLessonProgress :exec
INSERT INTO lms_user_lesson_progress (user_id, lesson_id)
VALUES ($1, $2)
ON CONFLICT (user_id, lesson_id)
DO NOTHING;
-- name: InsertUserModuleProgress :exec
INSERT INTO lms_user_module_progress (user_id, module_id)
VALUES ($1, $2)
ON CONFLICT (user_id, module_id)
DO NOTHING;
-- name: InsertUserCourseProgress :exec
INSERT INTO lms_user_course_progress (user_id, course_id)
VALUES ($1, $2)
ON CONFLICT (user_id, course_id)
DO NOTHING;
-- name: InsertUserProgramProgress :exec
INSERT INTO lms_user_program_progress (user_id, program_id)
VALUES ($1, $2)
ON CONFLICT (user_id, program_id)
DO NOTHING;
-- name: CountLessonsInModule :one
SELECT
count(*)::int AS n
FROM
lessons
WHERE
module_id = $1;
-- name: CountUserCompletedLessonsInModule :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
WHERE
l.module_id = $1
AND ulp.user_id = $2;
-- name: CountModulesInCourse :one
SELECT
count(*)::int AS n
FROM
modules
WHERE
course_id = $1;
-- name: CountUserCompletedModulesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_user_module_progress ump
INNER JOIN modules m ON m.id = ump.module_id
WHERE
m.course_id = $1
AND ump.user_id = $2;
-- name: CountCoursesInProgram :one
SELECT
count(*)::int AS n
FROM
courses
WHERE
program_id = $1;
-- name: CountUserCompletedCoursesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_user_course_progress ucp
INNER JOIN courses c ON c.id = ucp.course_id
WHERE
c.program_id = $1
AND ucp.user_id = $2;
-- name: ListLMSCompletedLessonIDsByUser :many
SELECT
ulp.lesson_id
FROM
lms_user_lesson_progress AS ulp
WHERE
ulp.user_id = $1
ORDER BY
ulp.completed_at ASC,
ulp.lesson_id ASC;
-- name: ListLMSCompletedModuleIDsByUser :many
SELECT
ump.module_id
FROM
lms_user_module_progress AS ump
WHERE
ump.user_id = $1
ORDER BY
ump.completed_at ASC,
ump.module_id ASC;
-- name: ListLMSCompletedCourseIDsByUser :many
SELECT
ucp.course_id
FROM
lms_user_course_progress AS ucp
WHERE
ucp.user_id = $1
ORDER BY
ucp.completed_at ASC,
ucp.course_id ASC;
-- name: ListLMSCompletedProgramIDsByUser :many
SELECT
upp.program_id
FROM
lms_user_program_progress AS upp
WHERE
upp.user_id = $1
ORDER BY
upp.completed_at ASC,
upp.program_id ASC;
-- Lesson-based progress within a course (all modules).
-- name: CountLessonsInCourse :one
SELECT
count(*)::int AS n
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1;
-- name: CountUserCompletedLessonsInCourse :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1
AND ulp.user_id = $2;
-- Lesson-based progress within a program (all courses).
-- name: CountLessonsInProgram :one
SELECT
count(*)::int AS n
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1;
-- name: CountUserCompletedLessonsInProgram :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1
AND ulp.user_id = $2;

View File

@ -29,27 +29,16 @@ LIMIT 1;
-- name: MarkPracticeCompleted :execrows
INSERT INTO user_practice_progress (
user_id,
sub_course_id,
question_set_id,
completed_at,
updated_at
)
SELECT
VALUES (
@user_id::BIGINT,
CASE
WHEN qs.owner_type = 'SUB_COURSE' THEN qs.owner_id
WHEN qs.owner_type = 'SUB_MODULE' THEN sm.legacy_sub_course_id
ELSE NULL
END,
qs.id,
@question_set_id::BIGINT,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM question_sets qs
LEFT JOIN sub_modules sm
ON qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
WHERE qs.id = @question_set_id::BIGINT
)
ON CONFLICT (user_id, question_set_id) DO UPDATE
SET completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at;

56
db/query/programs.sql Normal file
View File

@ -0,0 +1,56 @@
-- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
coalesce((
SELECT
max(p.sort_order)
FROM programs AS p), 0) + 1
RETURNING
*;
-- name: GetProgramByID :one
SELECT *
FROM programs
WHERE id = $1;
-- name: ListAllProgramIDs :many
SELECT
p.id
FROM
programs AS p
ORDER BY
p.id;
-- name: ListPrograms :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.name,
p.description,
p.thumbnail,
p.sort_order,
p.created_at,
p.updated_at
FROM programs p
ORDER BY p.sort_order ASC, p.id ASC
LIMIT $1 OFFSET $2;
-- name: UpdateProgram :one
UPDATE programs
SET
name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteProgram :exec
DELETE FROM programs
WHERE id = $1;

View File

@ -11,10 +11,9 @@ INSERT INTO question_sets (
passing_score,
shuffle_questions,
status,
sub_course_video_id,
intro_video_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12, $13)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
RETURNING *;
-- name: GetQuestionSetByID :one
@ -61,9 +60,8 @@ SET
shuffle_questions = COALESCE($7, shuffle_questions),
status = COALESCE($8, status),
intro_video_url = COALESCE($9, intro_video_url),
sub_course_video_id = COALESCE($10, sub_course_video_id),
updated_at = CURRENT_TIMESTAMP
WHERE id = $11;
WHERE id = $10;
-- name: ArchiveQuestionSet :exec
UPDATE question_sets
@ -82,16 +80,6 @@ WHERE set_type = 'INITIAL_ASSESSMENT'
ORDER BY created_at DESC
LIMIT 1;
-- name: GetSubCourseInitialAssessmentSet :one
SELECT *
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND owner_type = 'SUB_COURSE'
AND owner_id = $1
AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 1;
-- name: AddUserPersonaToQuestionSet :one
INSERT INTO question_set_personas (
question_set_id,
@ -120,13 +108,6 @@ INNER JOIN question_set_personas qsp ON qsp.user_id = u.id
WHERE qsp.question_set_id = $1
ORDER BY qsp.display_order ASC;
-- name: UpdateQuestionSetVideoLink :exec
UPDATE question_sets
SET
sub_course_video_id = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2;
-- name: ReorderQuestionSets :exec
UPDATE question_sets
SET display_order = bulk.position

View File

@ -0,0 +1,19 @@
-- name: RevokeAllActiveTeamRefreshTokensForMember :exec
UPDATE team_refresh_tokens
SET revoked = TRUE
WHERE team_member_id = $1
AND revoked = FALSE;
-- name: CreateTeamRefreshToken :exec
INSERT INTO team_refresh_tokens (team_member_id, token, expires_at, revoked, created_at)
VALUES ($1, $2, $3, $4, $5);
-- name: GetTeamRefreshTokenByToken :one
SELECT *
FROM team_refresh_tokens
WHERE token = $1;
-- name: RevokeTeamRefreshTokenByToken :exec
UPDATE team_refresh_tokens
SET revoked = TRUE
WHERE token = $1;

View File

@ -1,292 +0,0 @@
# Human Language Mobile Integration Guide
This guide explains how to integrate the new **Human Language** feature in the **Yimaru learner mobile app** (not admin).
It is designed to keep the existing non-language hierarchy intact while introducing a dedicated CEFR-based flow for language learning.
---
## 1) Scope and Goals
### What is new
- A dedicated backend API namespace for Human Language:
- `GET /api/v1/course-management/human-language/courses/:courseId/lessons?cefr_level=A1..C3`
- `POST /api/v1/course-management/human-language/lessons` (admin/instructor side)
- `PATCH /api/v1/course-management/human-language/lessons/:id` (admin/instructor side)
- CEFR levels are fixed to:
- `A1, A2, A3, B1, B2, B3, C1, C2, C3`
- No custom sub-levels in Human Language flow.
### What remains unchanged
- Existing non-human-language content hierarchy and APIs.
- Existing course/category/sub-course endpoints for non-language domains (programming, etc.).
---
## 2) Target Hierarchy for Human Language
- Course Category (Human Language)
- Course (e.g., English)
- CEFR Lesson Unit (A1..C3 only)
- Intro/lesson videos
- Practices
- Audio questions
Backend implementation stores CEFR lesson units using the existing sub-course model, with CEFR mapped internally and validated strictly.
---
## 3) Authentication and Access
All Human Language endpoints are under `/api/v1` and require bearer token auth.
- Header:
- `Authorization: Bearer <access_token>`
### Permission notes
- **Learner mobile app** typically needs only the `GET` endpoint.
- `POST` and `PATCH` are content-management endpoints and should generally be used by admin/instructor clients, not learner clients.
If learner roles do not currently have `learning_tree.get`, coordinate RBAC assignment for read-only access to the `GET` endpoint.
---
## 4) Endpoint Contracts
## 4.1 Fetch lessons by CEFR level (learner-facing)
### Request
`GET /api/v1/course-management/human-language/courses/{courseId}/lessons?cefr_level={A1|A2|A3|B1|B2|B3|C1|C2|C3}`
### Example
`GET /api/v1/course-management/human-language/courses/12/lessons?cefr_level=B1`
### Response (success)
```json
{
"message": "Human-language lessons retrieved successfully",
"data": {
"course_id": 12,
"course_title": "English",
"cefr_level": "B1",
"lessons": [
{
"id": 201,
"course_id": 12,
"title": "B1 Module 1",
"description": "Intermediate conversational patterns",
"thumbnail": "https://.../thumb.jpg",
"display_order": 1,
"level": "B1",
"video_count": 3,
"practice_count": 2,
"videos": [
{
"id": 9001,
"title": "B1 Intro",
"description": "Lesson intro",
"video_url": "https://...",
"duration": 420,
"resolution": "1080p",
"display_order": 1
}
],
"practices": [
{
"id": 4401,
"title": "B1 Speaking Practice",
"status": "PUBLISHED",
"question_count": 10
}
]
}
]
},
"success": true,
"status_code": 200,
"metadata": null
}
```
### Validation errors
- Invalid CEFR level:
- `400` with error message: `Use one of: A1,A2,A3,B1,B2,B3,C1,C2,C3`
- Course not in human language category:
- `400` with error message indicating invalid human-language course.
---
## 4.2 Create Human Language lesson unit (admin/instructor)
### Request
`POST /api/v1/course-management/human-language/lessons`
```json
{
"course_id": 12,
"title": "A2 Module 1",
"description": "A2 speaking fundamentals",
"thumbnail": "https://...",
"display_order": 1,
"cefr_level": "A2"
}
```
### Response
Returns created lesson metadata with CEFR-normalized level behavior.
---
## 4.3 Update Human Language lesson unit (admin/instructor)
### Request
`PATCH /api/v1/course-management/human-language/lessons/{lessonId}`
```json
{
"title": "A2 Module 1 - Updated",
"description": "Updated description",
"cefr_level": "A3",
"is_active": true
}
```
---
## 5) Recommended Mobile App Integration Flow
## Step 1: Discover courses under Human Language category
Use existing endpoints:
1. `GET /course-management/categories`
2. Pick category where name indicates human language.
3. `GET /course-management/categories/:categoryId/courses`
## Step 2: Select CEFR level in UI
Use static list in app:
- `A1, A2, A3, B1, B2, B3, C1, C2, C3`
Do not allow custom levels in mobile UI.
## Step 3: Fetch lessons by selected CEFR level
Call:
- `GET /course-management/human-language/courses/:courseId/lessons?cefr_level=<selected>`
Render grouped lessons with nested videos/practices from response.
## Step 4: Navigate to learning/practice screens
- Lesson video playback uses returned `videos[]`.
- Practice entry uses returned `practices[]` IDs and existing practice-question endpoints.
---
## 6) Mobile UI/UX Recommendations
- Use CEFR tabs/segmented control (`A1`...`C3`) at top.
- Cache last selected level per course.
- Show empty state per level:
- "No lessons available for this level yet."
- Sort by `display_order` and maintain backend order.
- Show badges:
- video count
- practice count
---
## 7) Error Handling and Resilience
- `400` invalid level: reset selection to previous valid CEFR value and show toast/snackbar.
- `401/403`: trigger token refresh / re-login flow.
- `5xx`: show retry UI with exponential backoff.
- If category/course fetch succeeds but level fetch fails, keep course visible and allow manual retry.
---
## 8) Data Model Mapping (Mobile DTO)
Suggested DTO for learner app:
```ts
type CefrLevel = "A1"|"A2"|"A3"|"B1"|"B2"|"B3"|"C1"|"C2"|"C3";
interface HumanLanguageLessonDTO {
id: number;
courseId: number;
title: string;
description?: string;
thumbnail?: string;
order: number;
level: CefrLevel;
videoCount: number;
practiceCount: number;
videos: {
id: number;
title: string;
url: string;
duration: number;
order: number;
}[];
practices: {
id: number;
title: string;
status: string;
questionCount: number;
}[];
}
```
---
## 9) Backward Compatibility
- Existing non-language content (programming and other categories) continues to use current APIs and hierarchy unchanged.
- New Human Language endpoint is additive and isolated.
- Mobile app can progressively enable the new flow for language categories only.
---
## 10) QA Checklist for Mobile Team
- [ ] Category discovery correctly identifies Human Language category.
- [ ] CEFR selector only allows A1..C3.
- [ ] Fetch by CEFR level returns only matching lessons.
- [ ] Video/practice counts match rendered lists.
- [ ] Empty-level state works.
- [ ] Unauthorized/session-expired flow works.
- [ ] Non-language courses still load via existing app flow.
---
## 11) Example Call Matrix
- Load language categories/courses:
- `GET /course-management/categories`
- `GET /course-management/categories/:id/courses`
- Load A1 lessons:
- `GET /course-management/human-language/courses/:courseId/lessons?cefr_level=A1`
- Load B2 lessons:
- `GET /course-management/human-language/courses/:courseId/lessons?cefr_level=B2`
---
For backend ownership questions, refer to:
- `internal/web_server/handlers/course_management.go`
- `internal/web_server/routes.go`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -12,10 +12,10 @@ import (
const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one
SELECT
(SELECT COUNT(*)::bigint FROM course_categories) AS total_categories,
(SELECT COUNT(*)::bigint FROM courses) AS total_courses,
(SELECT COUNT(*)::bigint FROM sub_courses) AS total_sub_courses,
(SELECT COUNT(*)::bigint FROM sub_course_videos) AS total_videos
0::bigint AS total_categories,
0::bigint AS total_courses,
0::bigint AS total_sub_courses,
0::bigint AS total_videos
`
type AnalyticsCourseCountsRow struct {

View File

@ -1,113 +0,0 @@
package dbgen
import (
"context"
)
func (q *Queries) GetSubModuleByIDCompat(ctx context.Context, id int64) (SubModule, error) {
row := q.db.QueryRow(ctx, `
SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id
FROM sub_modules
WHERE id = $1
`, id)
var i SubModule
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.LegacySubCourseID,
)
return i, err
}
func (q *Queries) UpdateSubModuleCompat(ctx context.Context, id int64, title string, description string, isActive bool) error {
_, err := q.db.Exec(ctx, `
UPDATE sub_modules
SET
title = $1,
description = NULLIF($2, ''),
is_active = $3
WHERE id = $4
`, title, description, isActive, id)
return err
}
func (q *Queries) DeleteSubModuleCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM sub_modules WHERE id = $1`, id)
return err
}
func (q *Queries) UpdateSubModuleVideoCompat(ctx context.Context, id int64, title string, description string, videoURL string) error {
_, err := q.db.Exec(ctx, `
UPDATE sub_module_videos
SET
title = $1,
description = NULLIF($2, ''),
video_url = $3
WHERE id = $4
`, title, description, videoURL, id)
return err
}
func (q *Queries) DeleteSubModuleVideoCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM sub_module_videos WHERE id = $1`, id)
return err
}
func (q *Queries) UpdatePracticeCompat(ctx context.Context, id int64, title string, description string, persona string) error {
_, err := q.db.Exec(ctx, `
UPDATE question_sets
SET
title = $1,
description = NULLIF($2, ''),
persona = NULLIF($3, ''),
updated_at = CURRENT_TIMESTAMP
WHERE id = $4
`, title, description, persona, id)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `
UPDATE sub_module_practices
SET
title = $1,
description = NULLIF($2, '')
WHERE question_set_id = $3
`, title, description, id)
return err
}
func (q *Queries) UpdatePracticeStatusCompat(ctx context.Context, id int64, isActive bool) error {
status := "ARCHIVED"
if isActive {
status = "PUBLISHED"
}
_, err := q.db.Exec(ctx, `
UPDATE question_sets
SET
status = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`, status, id)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `
UPDATE sub_module_practices
SET is_active = $1
WHERE question_set_id = $2
`, isActive, id)
return err
}
func (q *Queries) DeletePracticeCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM question_sets WHERE id = $1`, id)
return err
}

View File

@ -1,158 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: course_catagories.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateCourseCategory = `-- name: CreateCourseCategory :one
INSERT INTO course_categories (
name,
is_active
)
VALUES ($1, COALESCE($2, true))
RETURNING id, name, is_active, created_at, display_order
`
type CreateCourseCategoryParams struct {
Name string `json:"name"`
Column2 interface{} `json:"column_2"`
}
func (q *Queries) CreateCourseCategory(ctx context.Context, arg CreateCourseCategoryParams) (CourseCategory, error) {
row := q.db.QueryRow(ctx, CreateCourseCategory, arg.Name, arg.Column2)
var i CourseCategory
err := row.Scan(
&i.ID,
&i.Name,
&i.IsActive,
&i.CreatedAt,
&i.DisplayOrder,
)
return i, err
}
const DeleteCourseCategory = `-- name: DeleteCourseCategory :exec
DELETE FROM course_categories
WHERE id = $1
`
func (q *Queries) DeleteCourseCategory(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteCourseCategory, id)
return err
}
const GetAllCourseCategories = `-- name: GetAllCourseCategories :many
SELECT
COUNT(*) OVER () AS total_count,
id,
name,
is_active,
created_at
FROM course_categories
ORDER BY display_order ASC, created_at DESC
LIMIT $2::INT
OFFSET $1::INT
`
type GetAllCourseCategoriesParams struct {
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetAllCourseCategoriesRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) GetAllCourseCategories(ctx context.Context, arg GetAllCourseCategoriesParams) ([]GetAllCourseCategoriesRow, error) {
rows, err := q.db.Query(ctx, GetAllCourseCategories, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllCourseCategoriesRow
for rows.Next() {
var i GetAllCourseCategoriesRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.Name,
&i.IsActive,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCourseCategoryByID = `-- name: GetCourseCategoryByID :one
SELECT id, name, is_active, created_at, display_order
FROM course_categories
WHERE id = $1
`
func (q *Queries) GetCourseCategoryByID(ctx context.Context, id int64) (CourseCategory, error) {
row := q.db.QueryRow(ctx, GetCourseCategoryByID, id)
var i CourseCategory
err := row.Scan(
&i.ID,
&i.Name,
&i.IsActive,
&i.CreatedAt,
&i.DisplayOrder,
)
return i, err
}
const ReorderCourseCategories = `-- name: ReorderCourseCategories :exec
UPDATE course_categories
SET display_order = bulk.position
FROM (
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
) AS bulk
WHERE course_categories.id = bulk.id
`
type ReorderCourseCategoriesParams struct {
Ids []int64 `json:"ids"`
Positions []int32 `json:"positions"`
}
func (q *Queries) ReorderCourseCategories(ctx context.Context, arg ReorderCourseCategoriesParams) error {
_, err := q.db.Exec(ctx, ReorderCourseCategories, arg.Ids, arg.Positions)
return err
}
const UpdateCourseCategory = `-- name: UpdateCourseCategory :exec
UPDATE course_categories
SET
name = COALESCE($1, name),
is_active = COALESCE($2, is_active)
WHERE id = $3
`
type UpdateCourseCategoryParams struct {
Name string `json:"name"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateCourseCategory(ctx context.Context, arg UpdateCourseCategoryParams) error {
_, err := q.db.Exec(ctx, UpdateCourseCategory, arg.Name, arg.IsActive, arg.ID)
return err
}

View File

@ -1,205 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: courses.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateCourse = `-- name: CreateCourse :one
INSERT INTO courses (
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
`
type CreateCourseParams struct {
CategoryID int64 `json:"category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
Column6 interface{} `json:"column_6"`
}
func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) {
row := q.db.QueryRow(ctx, CreateCourse,
arg.CategoryID,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.IntroVideoUrl,
arg.Column6,
)
var i Course
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.IsActive,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.SubCategoryID,
)
return i, err
}
const DeleteCourse = `-- name: DeleteCourse :exec
DELETE FROM courses
WHERE id = $1
`
func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteCourse, id)
return err
}
const GetCourseByID = `-- name: GetCourseByID :one
SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
FROM courses
WHERE id = $1
`
func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
row := q.db.QueryRow(ctx, GetCourseByID, id)
var i Course
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.IsActive,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.SubCategoryID,
)
return i, err
}
const GetCoursesByCategory = `-- name: GetCoursesByCategory :many
SELECT
COUNT(*) OVER () AS total_count,
id,
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
FROM courses
WHERE category_id = $1
ORDER BY display_order ASC, id ASC
LIMIT $3::INT
OFFSET $2::INT
`
type GetCoursesByCategoryParams struct {
CategoryID int64 `json:"category_id"`
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetCoursesByCategoryRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCategoryParams) ([]GetCoursesByCategoryRow, error) {
rows, err := q.db.Query(ctx, GetCoursesByCategory, arg.CategoryID, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCoursesByCategoryRow
for rows.Next() {
var i GetCoursesByCategoryRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ReorderCourses = `-- name: ReorderCourses :exec
UPDATE courses
SET display_order = bulk.position
FROM (
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
) AS bulk
WHERE courses.id = bulk.id
`
type ReorderCoursesParams struct {
Ids []int64 `json:"ids"`
Positions []int32 `json:"positions"`
}
func (q *Queries) ReorderCourses(ctx context.Context, arg ReorderCoursesParams) error {
_, err := q.db.Exec(ctx, ReorderCourses, arg.Ids, arg.Positions)
return err
}
const UpdateCourse = `-- name: UpdateCourse :exec
UPDATE courses
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
thumbnail = COALESCE($3, thumbnail),
intro_video_url = COALESCE($4, intro_video_url),
is_active = COALESCE($5, is_active)
WHERE id = $6
`
type UpdateCourseParams struct {
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) error {
_, err := q.db.Exec(ctx, UpdateCourse,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.IntroVideoUrl,
arg.IsActive,
arg.ID,
)
return err
}

View File

@ -1,766 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: hierarchy.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AttachQuestionSetLessonToSubModule = `-- name: AttachQuestionSetLessonToSubModule :one
INSERT INTO sub_module_lessons (
sub_module_id,
question_set_id,
intro_video_url,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at
`
type AttachQuestionSetLessonToSubModuleParams struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) AttachQuestionSetLessonToSubModule(ctx context.Context, arg AttachQuestionSetLessonToSubModuleParams) (SubModuleLesson, error) {
row := q.db.QueryRow(ctx, AttachQuestionSetLessonToSubModule,
arg.SubModuleID,
arg.QuestionSetID,
arg.IntroVideoUrl,
arg.Column4,
arg.Column5,
)
var i SubModuleLesson
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateCourseSubCategory = `-- name: CreateCourseSubCategory :one
INSERT INTO course_sub_categories (
category_id,
name,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, category_id, name, description, is_active, display_order, created_at
`
type CreateCourseSubCategoryParams struct {
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) CreateCourseSubCategory(ctx context.Context, arg CreateCourseSubCategoryParams) (CourseSubCategory, error) {
row := q.db.QueryRow(ctx, CreateCourseSubCategory,
arg.CategoryID,
arg.Name,
arg.Description,
arg.Column4,
arg.Column5,
)
var i CourseSubCategory
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Name,
&i.Description,
&i.IsActive,
&i.DisplayOrder,
&i.CreatedAt,
)
return i, err
}
const CreateLevel = `-- name: CreateLevel :one
INSERT INTO levels (
course_id,
cefr_level,
display_order,
is_active
)
VALUES ($1, $2, COALESCE($3, 0), COALESCE($4, TRUE))
RETURNING id, course_id, cefr_level, display_order, is_active, created_at
`
type CreateLevelParams struct {
CourseID int64 `json:"course_id"`
CefrLevel string `json:"cefr_level"`
Column3 interface{} `json:"column_3"`
Column4 interface{} `json:"column_4"`
}
func (q *Queries) CreateLevel(ctx context.Context, arg CreateLevelParams) (Level, error) {
row := q.db.QueryRow(ctx, CreateLevel,
arg.CourseID,
arg.CefrLevel,
arg.Column3,
arg.Column4,
)
var i Level
err := row.Scan(
&i.ID,
&i.CourseID,
&i.CefrLevel,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateModule = `-- name: CreateModule :one
INSERT INTO modules (
level_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, level_id, title, description, display_order, is_active, created_at
`
type CreateModuleParams struct {
LevelID int64 `json:"level_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) {
row := q.db.QueryRow(ctx, CreateModule,
arg.LevelID,
arg.Title,
arg.Description,
arg.Column4,
arg.Column5,
)
var i Module
err := row.Scan(
&i.ID,
&i.LevelID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateSubModule = `-- name: CreateSubModule :one
INSERT INTO sub_modules (
module_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id
`
type CreateSubModuleParams struct {
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) CreateSubModule(ctx context.Context, arg CreateSubModuleParams) (SubModule, error) {
row := q.db.QueryRow(ctx, CreateSubModule,
arg.ModuleID,
arg.Title,
arg.Description,
arg.Column4,
arg.Column5,
)
var i SubModule
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.LegacySubCourseID,
)
return i, err
}
const CreateSubModulePractice = `-- name: CreateSubModulePractice :one
INSERT INTO sub_module_practices (
sub_module_id,
title,
description,
thumbnail,
intro_video_url,
question_set_id,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
RETURNING id, sub_module_id, title, description, thumbnail, intro_video_url, question_set_id, display_order, is_active, created_at
`
type CreateSubModulePracticeParams struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
Column7 interface{} `json:"column_7"`
Column8 interface{} `json:"column_8"`
}
func (q *Queries) CreateSubModulePractice(ctx context.Context, arg CreateSubModulePracticeParams) (SubModulePractice, error) {
row := q.db.QueryRow(ctx, CreateSubModulePractice,
arg.SubModuleID,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.IntroVideoUrl,
arg.QuestionSetID,
arg.Column7,
arg.Column8,
)
var i SubModulePractice
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.QuestionSetID,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateSubModuleVideo = `-- name: CreateSubModuleVideo :one
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
)
VALUES (
$1, $2, $3, $4, $5, $6,
COALESCE($7, FALSE), $8, $9, $10, $11,
COALESCE($12, 0), COALESCE($13, 'DRAFT'),
$14, $15, $16, $17, COALESCE($18, 'DIRECT')
)
RETURNING id, 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, created_at
`
type CreateSubModuleVideoParams struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration pgtype.Int4 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
Column7 interface{} `json:"column_7"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
Column12 interface{} `json:"column_12"`
Column13 interface{} `json:"column_13"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
VimeoStatus pgtype.Text `json:"vimeo_status"`
Column18 interface{} `json:"column_18"`
}
func (q *Queries) CreateSubModuleVideo(ctx context.Context, arg CreateSubModuleVideoParams) (SubModuleVideo, error) {
row := q.db.QueryRow(ctx, CreateSubModuleVideo,
arg.SubModuleID,
arg.Title,
arg.Description,
arg.VideoUrl,
arg.Duration,
arg.Resolution,
arg.Column7,
arg.PublishDate,
arg.Visibility,
arg.InstructorID,
arg.Thumbnail,
arg.Column12,
arg.Column13,
arg.VimeoID,
arg.VimeoEmbedUrl,
arg.VimeoPlayerHtml,
arg.VimeoStatus,
arg.Column18,
)
var i SubModuleVideo
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
&i.CreatedAt,
)
return i, err
}
const GetCoursesWithHierarchy = `-- name: GetCoursesWithHierarchy :many
SELECT
cc.id AS category_id,
cc.name AS category_name,
csc.id AS sub_category_id,
csc.name AS sub_category_name,
c.id AS course_id,
c.title AS course_title
FROM course_categories cc
LEFT JOIN course_sub_categories csc ON csc.category_id = cc.id AND csc.is_active = TRUE
LEFT JOIN courses c ON c.sub_category_id = csc.id AND c.is_active = TRUE
WHERE cc.is_active = TRUE
ORDER BY cc.id, csc.display_order, csc.id, c.id
`
type GetCoursesWithHierarchyRow struct {
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
SubCategoryName pgtype.Text `json:"sub_category_name"`
CourseID pgtype.Int8 `json:"course_id"`
CourseTitle pgtype.Text `json:"course_title"`
}
func (q *Queries) GetCoursesWithHierarchy(ctx context.Context) ([]GetCoursesWithHierarchyRow, error) {
rows, err := q.db.Query(ctx, GetCoursesWithHierarchy)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCoursesWithHierarchyRow
for rows.Next() {
var i GetCoursesWithHierarchyRow
if err := rows.Scan(
&i.CategoryID,
&i.CategoryName,
&i.SubCategoryID,
&i.SubCategoryName,
&i.CourseID,
&i.CourseTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetFullHierarchyByCourseID = `-- name: GetFullHierarchyByCourseID :many
SELECT
c.id AS course_id,
c.title AS course_title,
l.id AS level_id,
l.cefr_level,
m.id AS module_id,
m.title AS module_title,
sm.id AS sub_module_id,
sm.title AS sub_module_title
FROM courses c
LEFT JOIN levels l ON l.course_id = c.id AND l.is_active = TRUE
LEFT JOIN modules m ON m.level_id = l.id AND m.is_active = TRUE
LEFT JOIN sub_modules sm ON sm.module_id = m.id AND sm.is_active = TRUE
WHERE c.id = $1
ORDER BY l.display_order, l.id, m.display_order, m.id, sm.display_order, sm.id
`
type GetFullHierarchyByCourseIDRow struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
LevelID pgtype.Int8 `json:"level_id"`
CefrLevel pgtype.Text `json:"cefr_level"`
ModuleID pgtype.Int8 `json:"module_id"`
ModuleTitle pgtype.Text `json:"module_title"`
SubModuleID pgtype.Int8 `json:"sub_module_id"`
SubModuleTitle pgtype.Text `json:"sub_module_title"`
}
func (q *Queries) GetFullHierarchyByCourseID(ctx context.Context, id int64) ([]GetFullHierarchyByCourseIDRow, error) {
rows, err := q.db.Query(ctx, GetFullHierarchyByCourseID, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetFullHierarchyByCourseIDRow
for rows.Next() {
var i GetFullHierarchyByCourseIDRow
if err := rows.Scan(
&i.CourseID,
&i.CourseTitle,
&i.LevelID,
&i.CefrLevel,
&i.ModuleID,
&i.ModuleTitle,
&i.SubModuleID,
&i.SubModuleTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetLevelsByCourseID = `-- name: GetLevelsByCourseID :many
SELECT id, course_id, cefr_level, display_order, is_active, created_at
FROM levels
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetLevelsByCourseID(ctx context.Context, courseID int64) ([]Level, error) {
rows, err := q.db.Query(ctx, GetLevelsByCourseID, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Level
for rows.Next() {
var i Level
if err := rows.Scan(
&i.ID,
&i.CourseID,
&i.CefrLevel,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetModulesByLevelID = `-- name: GetModulesByLevelID :many
SELECT id, level_id, title, description, display_order, is_active, created_at
FROM modules
WHERE level_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetModulesByLevelID(ctx context.Context, levelID int64) ([]Module, error) {
rows, err := q.db.Query(ctx, GetModulesByLevelID, levelID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Module
for rows.Next() {
var i Module
if err := rows.Scan(
&i.ID,
&i.LevelID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModuleLessons = `-- name: GetSubModuleLessons :many
SELECT
smp.id,
smp.sub_module_id,
smp.question_set_id,
smp.intro_video_url,
smp.display_order,
smp.is_active,
qs.title,
qs.description,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_lessons smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
ORDER BY smp.display_order ASC, smp.id ASC
`
type GetSubModuleLessonsRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([]GetSubModuleLessonsRow, error) {
rows, err := q.db.Query(ctx, GetSubModuleLessons, subModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubModuleLessonsRow
for rows.Next() {
var i GetSubModuleLessonsRow
if err := rows.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.Title,
&i.Description,
&i.Status,
&i.SetType,
&i.QuestionCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModulePractices = `-- name: GetSubModulePractices :many
SELECT
smp.id,
smp.sub_module_id,
smp.title,
smp.description,
smp.thumbnail,
smp.intro_video_url,
smp.question_set_id,
smp.display_order,
smp.is_active,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_practices smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
ORDER BY smp.display_order ASC, smp.id ASC
`
type GetSubModulePracticesRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModulePractices(ctx context.Context, subModuleID int64) ([]GetSubModulePracticesRow, error) {
rows, err := q.db.Query(ctx, GetSubModulePractices, subModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubModulePracticesRow
for rows.Next() {
var i GetSubModulePracticesRow
if err := rows.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.QuestionSetID,
&i.DisplayOrder,
&i.IsActive,
&i.Status,
&i.SetType,
&i.QuestionCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModuleVideos = `-- name: GetSubModuleVideos :many
SELECT id, 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, created_at
FROM sub_module_videos
WHERE sub_module_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetSubModuleVideos(ctx context.Context, subModuleID int64) ([]SubModuleVideo, error) {
rows, err := q.db.Query(ctx, GetSubModuleVideos, subModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SubModuleVideo
for rows.Next() {
var i SubModuleVideo
if err := rows.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModulesByModuleID = `-- name: GetSubModulesByModuleID :many
SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id
FROM sub_modules
WHERE module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetSubModulesByModuleID(ctx context.Context, moduleID int64) ([]SubModule, error) {
rows, err := q.db.Query(ctx, GetSubModulesByModuleID, moduleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SubModule
for rows.Next() {
var i SubModule
if err := rows.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.LegacySubCourseID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

233
gen/db/lms_courses.sql.go Normal file
View File

@ -0,0 +1,233 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_courses.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateCourse = `-- name: CreateCourse :one
INSERT INTO courses (program_id, name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
$4,
coalesce((
SELECT
max(c.sort_order)
FROM courses c
WHERE
c.program_id = $1), 0) + 1
RETURNING
id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
`
type CreateCourseParams struct {
ProgramID int64 `json:"program_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
}
func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) {
row := q.db.QueryRow(ctx, CreateCourse,
arg.ProgramID,
arg.Name,
arg.Description,
arg.Thumbnail,
)
var i Course
err := row.Scan(
&i.ID,
&i.ProgramID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const DeleteCourse = `-- name: DeleteCourse :exec
DELETE FROM courses
WHERE id = $1
`
func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteCourse, id)
return err
}
const GetCourseByID = `-- name: GetCourseByID :one
SELECT id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
FROM courses
WHERE id = $1
`
func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
row := q.db.QueryRow(ctx, GetCourseByID, id)
var i Course
err := row.Scan(
&i.ID,
&i.ProgramID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const ListCourseIDsByProgram = `-- name: ListCourseIDsByProgram :many
SELECT
c.id
FROM
courses AS c
WHERE
c.program_id = $1
ORDER BY
c.id
`
func (q *Queries) ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListCourseIDsByProgram, programID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListCoursesByProgramID = `-- name: ListCoursesByProgramID :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.program_id,
c.name,
c.description,
c.thumbnail,
c.sort_order,
c.created_at,
c.updated_at
FROM
courses c
WHERE
c.program_id = $1
ORDER BY
c.sort_order ASC,
c.id ASC
LIMIT $2 OFFSET $3
`
type ListCoursesByProgramIDParams struct {
ProgramID int64 `json:"program_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListCoursesByProgramIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByProgramIDParams) ([]ListCoursesByProgramIDRow, error) {
rows, err := q.db.Query(ctx, ListCoursesByProgramID, arg.ProgramID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListCoursesByProgramIDRow
for rows.Next() {
var i ListCoursesByProgramIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.ProgramID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateCourse = `-- name: UpdateCourse :one
UPDATE courses
SET
name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description),
thumbnail = COALESCE($3::text, thumbnail),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = $5
RETURNING
id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
`
type UpdateCourseParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Course, error) {
row := q.db.QueryRow(ctx, UpdateCourse,
arg.Name,
arg.Description,
arg.Thumbnail,
arg.SortOrder,
arg.ID,
)
var i Course
err := row.Scan(
&i.ID,
&i.ProgramID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}

215
gen/db/lms_lessons.sql.go Normal file
View File

@ -0,0 +1,215 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_lessons.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateLesson = `-- name: CreateLesson :one
INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(l.sort_order)
FROM lessons l
WHERE
l.module_id = $1), 0) + 1
RETURNING
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
`
type CreateLessonParams struct {
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
}
func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Lesson, error) {
row := q.db.QueryRow(ctx, CreateLesson,
arg.ModuleID,
arg.Title,
arg.VideoUrl,
arg.Thumbnail,
arg.Description,
)
var i Lesson
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const DeleteLesson = `-- name: DeleteLesson :exec
DELETE FROM lessons
WHERE id = $1
`
func (q *Queries) DeleteLesson(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteLesson, id)
return err
}
const GetLessonByID = `-- name: GetLessonByID :one
SELECT id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
FROM lessons
WHERE id = $1
`
func (q *Queries) GetLessonByID(ctx context.Context, id int64) (Lesson, error) {
row := q.db.QueryRow(ctx, GetLessonByID, id)
var i Lesson
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const ListLessonsByModuleID = `-- name: ListLessonsByModuleID :many
SELECT
COUNT(*) OVER () AS total_count,
l.id,
l.module_id,
l.title,
l.video_url,
l.thumbnail,
l.description,
l.sort_order,
l.created_at,
l.updated_at
FROM
lessons l
WHERE
l.module_id = $1
ORDER BY
l.sort_order ASC,
l.id ASC
LIMIT $2
OFFSET $3
`
type ListLessonsByModuleIDParams struct {
ModuleID int64 `json:"module_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListLessonsByModuleIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByModuleIDParams) ([]ListLessonsByModuleIDRow, error) {
rows, err := q.db.Query(ctx, ListLessonsByModuleID, arg.ModuleID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLessonsByModuleIDRow
for rows.Next() {
var i ListLessonsByModuleIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.ModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateLesson = `-- name: UpdateLesson :one
UPDATE lessons
SET
title = COALESCE($1::varchar, title),
video_url = COALESCE($2::text, video_url),
thumbnail = COALESCE($3::text, thumbnail),
description = COALESCE($4::text, description),
sort_order = coalesce($5::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = $6
RETURNING
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
`
type UpdateLessonParams struct {
Title pgtype.Text `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Lesson, error) {
row := q.db.QueryRow(ctx, UpdateLesson,
arg.Title,
arg.VideoUrl,
arg.Thumbnail,
arg.Description,
arg.SortOrder,
arg.ID,
)
var i Lesson
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}

250
gen/db/lms_modules.sql.go Normal file
View File

@ -0,0 +1,250 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_modules.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateModule = `-- name: CreateModule :one
INSERT INTO modules (program_id, course_id, name, description, icon, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(m.sort_order)
FROM modules m
WHERE
m.course_id = $2), 0) + 1
RETURNING
id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
`
type CreateModuleParams struct {
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
}
func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) {
row := q.db.QueryRow(ctx, CreateModule,
arg.ProgramID,
arg.CourseID,
arg.Name,
arg.Description,
arg.Icon,
)
var i Module
err := row.Scan(
&i.ID,
&i.ProgramID,
&i.CourseID,
&i.Name,
&i.Description,
&i.Icon,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const DeleteModule = `-- name: DeleteModule :exec
DELETE FROM modules
WHERE id = $1
`
func (q *Queries) DeleteModule(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteModule, id)
return err
}
const GetModuleByID = `-- name: GetModuleByID :one
SELECT id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
FROM modules
WHERE id = $1
`
func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) {
row := q.db.QueryRow(ctx, GetModuleByID, id)
var i Module
err := row.Scan(
&i.ID,
&i.ProgramID,
&i.CourseID,
&i.Name,
&i.Description,
&i.Icon,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const ListModuleIDsByCourse = `-- name: ListModuleIDsByCourse :many
SELECT
m.id
FROM
modules AS m
WHERE
m.course_id = $1
ORDER BY
m.id
`
func (q *Queries) ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListModuleIDsByCourse, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListModulesByProgramAndCourse = `-- name: ListModulesByProgramAndCourse :many
SELECT
COUNT(*) OVER () AS total_count,
m.id,
m.program_id,
m.course_id,
m.name,
m.description,
m.icon,
m.sort_order,
m.created_at,
m.updated_at
FROM
modules m
WHERE
m.program_id = $1
AND m.course_id = $2
ORDER BY
m.sort_order ASC,
m.id ASC
LIMIT $3
OFFSET $4
`
type ListModulesByProgramAndCourseParams struct {
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListModulesByProgramAndCourseRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListModulesByProgramAndCourseParams) ([]ListModulesByProgramAndCourseRow, error) {
rows, err := q.db.Query(ctx, ListModulesByProgramAndCourse,
arg.ProgramID,
arg.CourseID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListModulesByProgramAndCourseRow
for rows.Next() {
var i ListModulesByProgramAndCourseRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.ProgramID,
&i.CourseID,
&i.Name,
&i.Description,
&i.Icon,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateModule = `-- name: UpdateModule :one
UPDATE modules
SET
name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description),
icon = COALESCE($3::text, icon),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = $5
RETURNING
id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
`
type UpdateModuleParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Module, error) {
row := q.db.QueryRow(ctx, UpdateModule,
arg.Name,
arg.Description,
arg.Icon,
arg.SortOrder,
arg.ID,
)
var i Module
err := row.Scan(
&i.ID,
&i.ProgramID,
&i.CourseID,
&i.Name,
&i.Description,
&i.Icon,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}

381
gen/db/lms_practices.sql.go Normal file
View File

@ -0,0 +1,381 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_practices.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateLmsPractice = `-- name: CreateLmsPractice :one
INSERT INTO lms_practices (
course_id, module_id, lesson_id,
title, story_description, story_image, persona_id, question_set_id, quick_tips
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
`
type CreateLmsPracticeParams struct {
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
}
func (q *Queries) CreateLmsPractice(ctx context.Context, arg CreateLmsPracticeParams) (LmsPractice, error) {
row := q.db.QueryRow(ctx, CreateLmsPractice,
arg.CourseID,
arg.ModuleID,
arg.LessonID,
arg.Title,
arg.StoryDescription,
arg.StoryImage,
arg.PersonaID,
arg.QuestionSetID,
arg.QuickTips,
)
var i LmsPractice
err := row.Scan(
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteLmsPractice = `-- name: DeleteLmsPractice :exec
DELETE FROM lms_practices
WHERE id = $1
`
func (q *Queries) DeleteLmsPractice(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteLmsPractice, id)
return err
}
const GetLmsPracticeByID = `-- name: GetLmsPracticeByID :one
SELECT id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
FROM lms_practices
WHERE id = $1
`
func (q *Queries) GetLmsPracticeByID(ctx context.Context, id int64) (LmsPractice, error) {
row := q.db.QueryRow(ctx, GetLmsPracticeByID, id)
var i LmsPractice
err := row.Scan(
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ListLmsPracticesByCourseID = `-- name: ListLmsPracticesByCourseID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.course_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`
type ListLmsPracticesByCourseIDParams struct {
CourseID pgtype.Int8 `json:"course_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListLmsPracticesByCourseIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListLmsPracticesByCourseID(ctx context.Context, arg ListLmsPracticesByCourseIDParams) ([]ListLmsPracticesByCourseIDRow, error) {
rows, err := q.db.Query(ctx, ListLmsPracticesByCourseID, arg.CourseID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLmsPracticesByCourseIDRow
for rows.Next() {
var i ListLmsPracticesByCourseIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLmsPracticesByLessonID = `-- name: ListLmsPracticesByLessonID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.lesson_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`
type ListLmsPracticesByLessonIDParams struct {
LessonID pgtype.Int8 `json:"lesson_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListLmsPracticesByLessonIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListLmsPracticesByLessonID(ctx context.Context, arg ListLmsPracticesByLessonIDParams) ([]ListLmsPracticesByLessonIDRow, error) {
rows, err := q.db.Query(ctx, ListLmsPracticesByLessonID, arg.LessonID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLmsPracticesByLessonIDRow
for rows.Next() {
var i ListLmsPracticesByLessonIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLmsPracticesByModuleID = `-- name: ListLmsPracticesByModuleID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.module_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`
type ListLmsPracticesByModuleIDParams struct {
ModuleID pgtype.Int8 `json:"module_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListLmsPracticesByModuleIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListLmsPracticesByModuleID(ctx context.Context, arg ListLmsPracticesByModuleIDParams) ([]ListLmsPracticesByModuleIDRow, error) {
rows, err := q.db.Query(ctx, ListLmsPracticesByModuleID, arg.ModuleID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLmsPracticesByModuleIDRow
for rows.Next() {
var i ListLmsPracticesByModuleIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateLmsPractice = `-- name: UpdateLmsPractice :one
UPDATE lms_practices
SET
title = COALESCE($1::varchar, title),
story_description = COALESCE($2::text, story_description),
story_image = COALESCE($3::text, story_image),
persona_id = COALESCE($4::bigint, persona_id),
question_set_id = COALESCE($5::bigint, question_set_id),
quick_tips = COALESCE($6::text, quick_tips),
updated_at = CURRENT_TIMESTAMP
WHERE id = $7
RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
`
type UpdateLmsPracticeParams struct {
Title pgtype.Text `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID pgtype.Int8 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateLmsPractice(ctx context.Context, arg UpdateLmsPracticeParams) (LmsPractice, error) {
row := q.db.QueryRow(ctx, UpdateLmsPractice,
arg.Title,
arg.StoryDescription,
arg.StoryImage,
arg.PersonaID,
arg.QuestionSetID,
arg.QuickTips,
arg.ID,
)
var i LmsPractice
err := row.Scan(
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

613
gen/db/lms_progress.sql.go Normal file
View File

@ -0,0 +1,613 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_progress.sql
package dbgen
import (
"context"
)
const CountCoursesInProgram = `-- name: CountCoursesInProgram :one
SELECT
count(*)::int AS n
FROM
courses
WHERE
program_id = $1
`
func (q *Queries) CountCoursesInProgram(ctx context.Context, programID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountCoursesInProgram, programID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountLessonsInCourse = `-- name: CountLessonsInCourse :one
SELECT
count(*)::int AS n
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1
`
// Lesson-based progress within a course (all modules).
func (q *Queries) CountLessonsInCourse(ctx context.Context, courseID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountLessonsInCourse, courseID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountLessonsInModule = `-- name: CountLessonsInModule :one
SELECT
count(*)::int AS n
FROM
lessons
WHERE
module_id = $1
`
func (q *Queries) CountLessonsInModule(ctx context.Context, moduleID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountLessonsInModule, moduleID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountLessonsInProgram = `-- name: CountLessonsInProgram :one
SELECT
count(*)::int AS n
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1
`
// Lesson-based progress within a program (all courses).
func (q *Queries) CountLessonsInProgram(ctx context.Context, programID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountLessonsInProgram, programID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountModulesInCourse = `-- name: CountModulesInCourse :one
SELECT
count(*)::int AS n
FROM
modules
WHERE
course_id = $1
`
func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountModulesInCourse, courseID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedCoursesInProgram = `-- name: CountUserCompletedCoursesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_user_course_progress ucp
INNER JOIN courses c ON c.id = ucp.course_id
WHERE
c.program_id = $1
AND ucp.user_id = $2
`
type CountUserCompletedCoursesInProgramParams struct {
ProgramID int64 `json:"program_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedCoursesInProgram(ctx context.Context, arg CountUserCompletedCoursesInProgramParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedCoursesInProgram, arg.ProgramID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedLessonsInCourse = `-- name: CountUserCompletedLessonsInCourse :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1
AND ulp.user_id = $2
`
type CountUserCompletedLessonsInCourseParams struct {
CourseID int64 `json:"course_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedLessonsInCourse(ctx context.Context, arg CountUserCompletedLessonsInCourseParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedLessonsInCourse, arg.CourseID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedLessonsInModule = `-- name: CountUserCompletedLessonsInModule :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
WHERE
l.module_id = $1
AND ulp.user_id = $2
`
type CountUserCompletedLessonsInModuleParams struct {
ModuleID int64 `json:"module_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedLessonsInModule(ctx context.Context, arg CountUserCompletedLessonsInModuleParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedLessonsInModule, arg.ModuleID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedLessonsInProgram = `-- name: CountUserCompletedLessonsInProgram :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1
AND ulp.user_id = $2
`
type CountUserCompletedLessonsInProgramParams struct {
ProgramID int64 `json:"program_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedLessonsInProgram(ctx context.Context, arg CountUserCompletedLessonsInProgramParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedLessonsInProgram, arg.ProgramID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedModulesInCourse = `-- name: CountUserCompletedModulesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_user_module_progress ump
INNER JOIN modules m ON m.id = ump.module_id
WHERE
m.course_id = $1
AND ump.user_id = $2
`
type CountUserCompletedModulesInCourseParams struct {
CourseID int64 `json:"course_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedModulesInCourse(ctx context.Context, arg CountUserCompletedModulesInCourseParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedModulesInCourse, arg.CourseID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const GetPreviousCourseInProgram = `-- name: GetPreviousCourseInProgram :one
SELECT
c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order
FROM
courses AS c1
INNER JOIN courses AS c2 ON c2.program_id = c1.program_id
AND c2.sort_order = c1.sort_order - 1
WHERE
c1.id = $1
`
func (q *Queries) GetPreviousCourseInProgram(ctx context.Context, id int64) (Course, error) {
row := q.db.QueryRow(ctx, GetPreviousCourseInProgram, id)
var i Course
err := row.Scan(
&i.ID,
&i.ProgramID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const GetPreviousLessonInModule = `-- name: GetPreviousLessonInModule :one
SELECT
l2.id, l2.module_id, l2.title, l2.video_url, l2.thumbnail, l2.description, l2.created_at, l2.updated_at, l2.sort_order
FROM
lessons AS l1
INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id
AND l2.sort_order = l1.sort_order - 1
WHERE
l1.id = $1
`
func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Lesson, error) {
row := q.db.QueryRow(ctx, GetPreviousLessonInModule, id)
var i Lesson
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const GetPreviousModuleInCourse = `-- name: GetPreviousModuleInCourse :one
SELECT
m2.id, m2.program_id, m2.course_id, m2.name, m2.description, m2.icon, m2.created_at, m2.updated_at, m2.sort_order
FROM
modules AS m1
INNER JOIN modules AS m2 ON m2.course_id = m1.course_id
AND m2.sort_order = m1.sort_order - 1
WHERE
m1.id = $1
`
func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Module, error) {
row := q.db.QueryRow(ctx, GetPreviousModuleInCourse, id)
var i Module
err := row.Scan(
&i.ID,
&i.ProgramID,
&i.CourseID,
&i.Name,
&i.Description,
&i.Icon,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const GetPreviousProgram = `-- name: GetPreviousProgram :one
SELECT
p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order
FROM
programs AS p1
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
WHERE
p1.id = $1
`
func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, error) {
row := q.db.QueryRow(ctx, GetPreviousProgram, id)
var i Program
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const InsertUserCourseProgress = `-- name: InsertUserCourseProgress :exec
INSERT INTO lms_user_course_progress (user_id, course_id)
VALUES ($1, $2)
ON CONFLICT (user_id, course_id)
DO NOTHING
`
type InsertUserCourseProgressParams struct {
UserID int64 `json:"user_id"`
CourseID int64 `json:"course_id"`
}
func (q *Queries) InsertUserCourseProgress(ctx context.Context, arg InsertUserCourseProgressParams) error {
_, err := q.db.Exec(ctx, InsertUserCourseProgress, arg.UserID, arg.CourseID)
return err
}
const InsertUserLessonProgress = `-- name: InsertUserLessonProgress :exec
INSERT INTO lms_user_lesson_progress (user_id, lesson_id)
VALUES ($1, $2)
ON CONFLICT (user_id, lesson_id)
DO NOTHING
`
type InsertUserLessonProgressParams struct {
UserID int64 `json:"user_id"`
LessonID int64 `json:"lesson_id"`
}
func (q *Queries) InsertUserLessonProgress(ctx context.Context, arg InsertUserLessonProgressParams) error {
_, err := q.db.Exec(ctx, InsertUserLessonProgress, arg.UserID, arg.LessonID)
return err
}
const InsertUserModuleProgress = `-- name: InsertUserModuleProgress :exec
INSERT INTO lms_user_module_progress (user_id, module_id)
VALUES ($1, $2)
ON CONFLICT (user_id, module_id)
DO NOTHING
`
type InsertUserModuleProgressParams struct {
UserID int64 `json:"user_id"`
ModuleID int64 `json:"module_id"`
}
func (q *Queries) InsertUserModuleProgress(ctx context.Context, arg InsertUserModuleProgressParams) error {
_, err := q.db.Exec(ctx, InsertUserModuleProgress, arg.UserID, arg.ModuleID)
return err
}
const InsertUserProgramProgress = `-- name: InsertUserProgramProgress :exec
INSERT INTO lms_user_program_progress (user_id, program_id)
VALUES ($1, $2)
ON CONFLICT (user_id, program_id)
DO NOTHING
`
type InsertUserProgramProgressParams struct {
UserID int64 `json:"user_id"`
ProgramID int64 `json:"program_id"`
}
func (q *Queries) InsertUserProgramProgress(ctx context.Context, arg InsertUserProgramProgressParams) error {
_, err := q.db.Exec(ctx, InsertUserProgramProgress, arg.UserID, arg.ProgramID)
return err
}
const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many
SELECT
ucp.course_id
FROM
lms_user_course_progress AS ucp
WHERE
ucp.user_id = $1
ORDER BY
ucp.completed_at ASC,
ucp.course_id ASC
`
func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedCourseIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var course_id int64
if err := rows.Scan(&course_id); err != nil {
return nil, err
}
items = append(items, course_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLMSCompletedLessonIDsByUser = `-- name: ListLMSCompletedLessonIDsByUser :many
SELECT
ulp.lesson_id
FROM
lms_user_lesson_progress AS ulp
WHERE
ulp.user_id = $1
ORDER BY
ulp.completed_at ASC,
ulp.lesson_id ASC
`
func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedLessonIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var lesson_id int64
if err := rows.Scan(&lesson_id); err != nil {
return nil, err
}
items = append(items, lesson_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many
SELECT
ump.module_id
FROM
lms_user_module_progress AS ump
WHERE
ump.user_id = $1
ORDER BY
ump.completed_at ASC,
ump.module_id ASC
`
func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedModuleIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var module_id int64
if err := rows.Scan(&module_id); err != nil {
return nil, err
}
items = append(items, module_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLMSCompletedProgramIDsByUser = `-- name: ListLMSCompletedProgramIDsByUser :many
SELECT
upp.program_id
FROM
lms_user_program_progress AS upp
WHERE
upp.user_id = $1
ORDER BY
upp.completed_at ASC,
upp.program_id ASC
`
func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedProgramIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var program_id int64
if err := rows.Scan(&program_id); err != nil {
return nil, err
}
items = append(items, program_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UserHasCourseProgress = `-- name: UserHasCourseProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_course_progress
WHERE
user_id = $1
AND course_id = $2) AS v
`
type UserHasCourseProgressParams struct {
UserID int64 `json:"user_id"`
CourseID int64 `json:"course_id"`
}
func (q *Queries) UserHasCourseProgress(ctx context.Context, arg UserHasCourseProgressParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasCourseProgress, arg.UserID, arg.CourseID)
var v bool
err := row.Scan(&v)
return v, err
}
const UserHasLessonProgress = `-- name: UserHasLessonProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_lesson_progress
WHERE
user_id = $1
AND lesson_id = $2) AS v
`
type UserHasLessonProgressParams struct {
UserID int64 `json:"user_id"`
LessonID int64 `json:"lesson_id"`
}
func (q *Queries) UserHasLessonProgress(ctx context.Context, arg UserHasLessonProgressParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasLessonProgress, arg.UserID, arg.LessonID)
var v bool
err := row.Scan(&v)
return v, err
}
const UserHasModuleProgress = `-- name: UserHasModuleProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_module_progress
WHERE
user_id = $1
AND module_id = $2) AS v
`
type UserHasModuleProgressParams struct {
UserID int64 `json:"user_id"`
ModuleID int64 `json:"module_id"`
}
func (q *Queries) UserHasModuleProgress(ctx context.Context, arg UserHasModuleProgressParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasModuleProgress, arg.UserID, arg.ModuleID)
var v bool
err := row.Scan(&v)
return v, err
}
const UserHasProgramProgress = `-- name: UserHasProgramProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_program_progress
WHERE
user_id = $1
AND program_id = $2) AS v
`
type UserHasProgramProgressParams struct {
UserID int64 `json:"user_id"`
ProgramID int64 `json:"program_id"`
}
func (q *Queries) UserHasProgramProgress(ctx context.Context, arg UserHasProgramProgressParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasProgramProgress, arg.UserID, arg.ProgramID)
var v bool
err := row.Scan(&v)
return v, err
}

View File

@ -23,33 +23,14 @@ type ActivityLog struct {
}
type Course struct {
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
IsActive bool `json:"is_active"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
}
type CourseCategory struct {
ID int64 `json:"id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
DisplayOrder int32 `json:"display_order"`
}
type CourseSubCategory struct {
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
IsActive bool `json:"is_active"`
DisplayOrder int32 `json:"display_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
}
type Device struct {
@ -69,13 +50,16 @@ type GlobalSetting struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Level struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
CefrLevel string `json:"cefr_level"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
type Lesson struct {
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
}
type LevelToSubCourse struct {
@ -83,14 +67,55 @@ type LevelToSubCourse struct {
SubCourseID int64 `json:"sub_course_id"`
}
type LmsPractice struct {
ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type LmsUserCourseProgress struct {
UserID int64 `json:"user_id"`
CourseID int64 `json:"course_id"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type LmsUserLessonProgress struct {
UserID int64 `json:"user_id"`
LessonID int64 `json:"lesson_id"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type LmsUserModuleProgress struct {
UserID int64 `json:"user_id"`
ModuleID int64 `json:"module_id"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type LmsUserProgramProgress struct {
UserID int64 `json:"user_id"`
ProgramID int64 `json:"program_id"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type Module struct {
ID int64 `json:"id"`
LevelID int64 `json:"level_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
}
type ModuleToSubCourse struct {
@ -154,6 +179,16 @@ type Permission struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Program struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
}
type Question struct {
ID int64 `json:"id"`
QuestionText string `json:"question_text"`
@ -201,7 +236,6 @@ type QuestionSet struct {
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
DisplayOrder int32 `json:"display_order"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
}
@ -298,109 +332,6 @@ type ScheduledNotification struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type SubCourse struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
IsActive bool `json:"is_active"`
SubLevel string `json:"sub_level"`
}
type SubCoursePrerequisite struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubCourseVideo struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
IsPublished bool `json:"is_published"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
// Vimeo video ID for videos hosted on Vimeo
VimeoID pgtype.Text `json:"vimeo_id"`
// Vimeo player embed URL
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
// Vimeo iframe embed HTML code
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
// Vimeo video status: pending, uploading, transcoding, available, error
VimeoStatus pgtype.Text `json:"vimeo_status"`
// Video hosting provider: DIRECT or VIMEO
VideoHostProvider pgtype.Text `json:"video_host_provider"`
}
type SubModule struct {
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
LegacySubCourseID pgtype.Int8 `json:"legacy_sub_course_id"`
}
type SubModuleLesson struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubModulePractice struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubModuleVideo struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration pgtype.Int4 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
IsPublished bool `json:"is_published"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
VimeoStatus pgtype.Text `json:"vimeo_status"`
VideoHostProvider pgtype.Text `json:"video_host_provider"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubscriptionPlan struct {
ID int64 `json:"id"`
Name string `json:"name"`
@ -440,6 +371,15 @@ type TeamMember struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type TeamRefreshToken struct {
ID int64 `json:"id"`
TeamMemberID int64 `json:"team_member_id"`
Token string `json:"token"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
Revoked bool `json:"revoked"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type User struct {
ID int64 `json:"id"`
FirstName pgtype.Text `json:"first_name"`
@ -489,35 +429,12 @@ type UserAudioResponse struct {
}
type UserPracticeProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
QuestionSetID int64 `json:"question_set_id"`
CompletedAt pgtype.Timestamp `json:"completed_at"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
}
type UserSubCourseProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
Status string `json:"status"`
ProgressPercentage int16 `json:"progress_percentage"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type UserSubCourseVideoProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
VideoID int64 `json:"video_id"`
CompletedAt pgtype.Timestamp `json:"completed_at"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
QuestionSetID int64 `json:"question_set_id"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type UserSubscription struct {

View File

@ -59,26 +59,16 @@ func (q *Queries) GetFirstIncompletePreviousPractice(ctx context.Context, arg Ge
const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :execrows
INSERT INTO user_practice_progress (
user_id,
sub_course_id,
question_set_id,
completed_at,
updated_at
)
SELECT
VALUES (
$1::BIGINT,
CASE
WHEN qs.owner_type = 'SUB_COURSE' THEN qs.owner_id
WHEN qs.owner_type = 'SUB_MODULE' THEN sm.legacy_sub_course_id
ELSE NULL
END,
qs.id,
$2::BIGINT,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM question_sets qs
LEFT JOIN sub_modules sm
ON qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
WHERE qs.id = $2::BIGINT
)
ON CONFLICT (user_id, question_set_id) DO UPDATE
SET completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at

210
gen/db/programs.sql.go Normal file
View File

@ -0,0 +1,210 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: programs.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateProgram = `-- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
coalesce((
SELECT
max(p.sort_order)
FROM programs AS p), 0) + 1
RETURNING
id, name, description, thumbnail, created_at, updated_at, sort_order
`
type CreateProgramParams struct {
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
}
func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (Program, error) {
row := q.db.QueryRow(ctx, CreateProgram, arg.Name, arg.Description, arg.Thumbnail)
var i Program
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const DeleteProgram = `-- name: DeleteProgram :exec
DELETE FROM programs
WHERE id = $1
`
func (q *Queries) DeleteProgram(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteProgram, id)
return err
}
const GetProgramByID = `-- name: GetProgramByID :one
SELECT id, name, description, thumbnail, created_at, updated_at, sort_order
FROM programs
WHERE id = $1
`
func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error) {
row := q.db.QueryRow(ctx, GetProgramByID, id)
var i Program
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const ListAllProgramIDs = `-- name: ListAllProgramIDs :many
SELECT
p.id
FROM
programs AS p
ORDER BY
p.id
`
func (q *Queries) ListAllProgramIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.Query(ctx, ListAllProgramIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListPrograms = `-- name: ListPrograms :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.name,
p.description,
p.thumbnail,
p.sort_order,
p.created_at,
p.updated_at
FROM programs p
ORDER BY p.sort_order ASC, p.id ASC
LIMIT $1 OFFSET $2
`
type ListProgramsParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListProgramsRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]ListProgramsRow, error) {
rows, err := q.db.Query(ctx, ListPrograms, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProgramsRow
for rows.Next() {
var i ListProgramsRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateProgram = `-- name: UpdateProgram :one
UPDATE programs
SET
name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description),
thumbnail = COALESCE($3::text, thumbnail),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = $5
RETURNING
id, name, description, thumbnail, created_at, updated_at, sort_order
`
type UpdateProgramParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (Program, error) {
row := q.db.QueryRow(ctx, UpdateProgram,
arg.Name,
arg.Description,
arg.Thumbnail,
arg.SortOrder,
arg.ID,
)
var i Program
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}

View File

@ -295,7 +295,7 @@ func (q *Queries) GetQuestionSetItemsPaginated(ctx context.Context, arg GetQuest
}
const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many
SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url
SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.display_order, qs.intro_video_url
FROM question_sets qs
JOIN question_set_items qsi ON qsi.set_id = qs.id
WHERE qsi.question_id = $1
@ -326,7 +326,6 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
); err != nil {

View File

@ -64,11 +64,10 @@ INSERT INTO question_sets (
passing_score,
shuffle_questions,
status,
sub_course_video_id,
intro_video_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12, $13)
RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
`
type CreateQuestionSetParams struct {
@ -83,7 +82,6 @@ type CreateQuestionSetParams struct {
PassingScore pgtype.Int4 `json:"passing_score"`
Column10 interface{} `json:"column_10"`
Column11 interface{} `json:"column_11"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
}
@ -100,7 +98,6 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa
arg.PassingScore,
arg.Column10,
arg.Column11,
arg.SubCourseVideoID,
arg.IntroVideoUrl,
)
var i QuestionSet
@ -119,7 +116,6 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
)
@ -137,7 +133,7 @@ func (q *Queries) DeleteQuestionSet(ctx context.Context, id int64) error {
}
const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND status = 'PUBLISHED'
@ -163,7 +159,6 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
)
@ -171,7 +166,7 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err
}
const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets
WHERE owner_type = $1
AND owner_id = $2
@ -208,7 +203,6 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
); err != nil {
@ -223,7 +217,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
}
const GetQuestionSetByID = `-- name: GetQuestionSetByID :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets
WHERE id = $1
`
@ -246,7 +240,6 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
)
@ -254,7 +247,7 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet
}
const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets
WHERE owner_type = $1
AND owner_id = $2
@ -291,7 +284,6 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
); err != nil {
@ -308,7 +300,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many
SELECT
COUNT(*) OVER () AS total_count,
qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url
qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.display_order, qs.intro_video_url
FROM question_sets qs
WHERE set_type = $1
AND status != 'ARCHIVED'
@ -339,7 +331,6 @@ type GetQuestionSetsByTypeRow struct {
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
DisplayOrder int32 `json:"display_order"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
}
@ -369,7 +360,6 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
); err != nil {
@ -383,42 +373,6 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
return items, nil
}
const GetSubCourseInitialAssessmentSet = `-- name: GetSubCourseInitialAssessmentSet :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND owner_type = 'SUB_COURSE'
AND owner_id = $1
AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 1
`
func (q *Queries) GetSubCourseInitialAssessmentSet(ctx context.Context, ownerID pgtype.Int8) (QuestionSet, error) {
row := q.db.QueryRow(ctx, GetSubCourseInitialAssessmentSet, ownerID)
var i QuestionSet
err := row.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.SetType,
&i.OwnerType,
&i.OwnerID,
&i.BannerImage,
&i.Persona,
&i.TimeLimitMinutes,
&i.PassingScore,
&i.ShuffleQuestions,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
)
return i, err
}
const GetUserPersonasByQuestionSetID = `-- name: GetUserPersonasByQuestionSetID :many
SELECT
u.id,
@ -519,9 +473,8 @@ SET
shuffle_questions = COALESCE($7, shuffle_questions),
status = COALESCE($8, status),
intro_video_url = COALESCE($9, intro_video_url),
sub_course_video_id = COALESCE($10, sub_course_video_id),
updated_at = CURRENT_TIMESTAMP
WHERE id = $11
WHERE id = $10
`
type UpdateQuestionSetParams struct {
@ -534,7 +487,6 @@ type UpdateQuestionSetParams struct {
ShuffleQuestions bool `json:"shuffle_questions"`
Status string `json:"status"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
ID int64 `json:"id"`
}
@ -549,26 +501,7 @@ func (q *Queries) UpdateQuestionSet(ctx context.Context, arg UpdateQuestionSetPa
arg.ShuffleQuestions,
arg.Status,
arg.IntroVideoUrl,
arg.SubCourseVideoID,
arg.ID,
)
return err
}
const UpdateQuestionSetVideoLink = `-- name: UpdateQuestionSetVideoLink :exec
UPDATE question_sets
SET
sub_course_video_id = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`
type UpdateQuestionSetVideoLinkParams struct {
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateQuestionSetVideoLink(ctx context.Context, arg UpdateQuestionSetVideoLinkParams) error {
_, err := q.db.Exec(ctx, UpdateQuestionSetVideoLink, arg.SubCourseVideoID, arg.ID)
return err
}

View File

@ -0,0 +1,79 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: team_refresh_tokens.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateTeamRefreshToken = `-- name: CreateTeamRefreshToken :exec
INSERT INTO team_refresh_tokens (team_member_id, token, expires_at, revoked, created_at)
VALUES ($1, $2, $3, $4, $5)
`
type CreateTeamRefreshTokenParams struct {
TeamMemberID int64 `json:"team_member_id"`
Token string `json:"token"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
Revoked bool `json:"revoked"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) CreateTeamRefreshToken(ctx context.Context, arg CreateTeamRefreshTokenParams) error {
_, err := q.db.Exec(ctx, CreateTeamRefreshToken,
arg.TeamMemberID,
arg.Token,
arg.ExpiresAt,
arg.Revoked,
arg.CreatedAt,
)
return err
}
const GetTeamRefreshTokenByToken = `-- name: GetTeamRefreshTokenByToken :one
SELECT id, team_member_id, token, expires_at, revoked, created_at
FROM team_refresh_tokens
WHERE token = $1
`
func (q *Queries) GetTeamRefreshTokenByToken(ctx context.Context, token string) (TeamRefreshToken, error) {
row := q.db.QueryRow(ctx, GetTeamRefreshTokenByToken, token)
var i TeamRefreshToken
err := row.Scan(
&i.ID,
&i.TeamMemberID,
&i.Token,
&i.ExpiresAt,
&i.Revoked,
&i.CreatedAt,
)
return i, err
}
const RevokeAllActiveTeamRefreshTokensForMember = `-- name: RevokeAllActiveTeamRefreshTokensForMember :exec
UPDATE team_refresh_tokens
SET revoked = TRUE
WHERE team_member_id = $1
AND revoked = FALSE
`
func (q *Queries) RevokeAllActiveTeamRefreshTokensForMember(ctx context.Context, teamMemberID int64) error {
_, err := q.db.Exec(ctx, RevokeAllActiveTeamRefreshTokensForMember, teamMemberID)
return err
}
const RevokeTeamRefreshTokenByToken = `-- name: RevokeTeamRefreshTokenByToken :exec
UPDATE team_refresh_tokens
SET revoked = TRUE
WHERE token = $1
`
func (q *Queries) RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error {
_, err := q.db.Exec(ctx, RevokeTeamRefreshTokenByToken, token)
return err
}

View File

@ -140,6 +140,9 @@ type Config struct {
AccountDeletionPurgeEnabled bool
AccountDeletionPurgeInterval time.Duration
AccountDeletionPurgeBatchSize int32
InactiveSubModuleContentPurgeEnabled bool
InactiveSubModuleContentPurgeInterval time.Duration
InactiveSubModuleContentRetentionDays int
DBResetReseedEnabled bool
DBResetReseedToken string
DBSeedDir string
@ -565,6 +568,38 @@ func (c *Config) loadEnv() error {
}
}
// Hard-delete inactive submodule lessons / practices / capstones after a retention period
inactiveContentPurge := strings.TrimSpace(os.Getenv("INACTIVE_SUBMODULE_CONTENT_PURGE_ENABLED"))
if inactiveContentPurge == "" {
c.InactiveSubModuleContentPurgeEnabled = false
} else {
c.InactiveSubModuleContentPurgeEnabled = inactiveContentPurge == "true" || inactiveContentPurge == "1"
}
inactiveContentPurgeInterval := strings.TrimSpace(os.Getenv("INACTIVE_SUBMODULE_CONTENT_PURGE_INTERVAL"))
if inactiveContentPurgeInterval == "" {
c.InactiveSubModuleContentPurgeInterval = 24 * time.Hour
} else {
interval, err := time.ParseDuration(inactiveContentPurgeInterval)
if err != nil || interval <= 0 {
c.InactiveSubModuleContentPurgeInterval = 24 * time.Hour
} else {
c.InactiveSubModuleContentPurgeInterval = interval
}
}
retentionDaysStr := strings.TrimSpace(os.Getenv("INACTIVE_SUBMODULE_CONTENT_RETENTION_DAYS"))
if retentionDaysStr == "" {
c.InactiveSubModuleContentRetentionDays = 30
} else {
days, err := strconv.Atoi(retentionDaysStr)
if err != nil || days < 1 {
c.InactiveSubModuleContentRetentionDays = 30
} else {
c.InactiveSubModuleContentRetentionDays = days
}
}
// Dangerous DB reset+reseed endpoint configuration
// Enabled by default and does not require .env variables.
// Optional token can still be set programmatically if needed.

View File

@ -45,6 +45,18 @@ const (
ActionIssueCreated ActivityAction = "ISSUE_CREATED"
ActionIssueStatusUpdated ActivityAction = "ISSUE_STATUS_UPDATED"
ActionIssueDeleted ActivityAction = "ISSUE_DELETED"
ActionProgramCreated ActivityAction = "PROGRAM_CREATED"
ActionProgramUpdated ActivityAction = "PROGRAM_UPDATED"
ActionProgramDeleted ActivityAction = "PROGRAM_DELETED"
ActionModuleCreated ActivityAction = "MODULE_CREATED"
ActionModuleUpdated ActivityAction = "MODULE_UPDATED"
ActionModuleDeleted ActivityAction = "MODULE_DELETED"
ActionLessonCreated ActivityAction = "LESSON_CREATED"
ActionLessonUpdated ActivityAction = "LESSON_UPDATED"
ActionLessonDeleted ActivityAction = "LESSON_DELETED"
ActionPracticeCreated ActivityAction = "PRACTICE_CREATED"
ActionPracticeUpdated ActivityAction = "PRACTICE_UPDATED"
ActionPracticeDeleted ActivityAction = "PRACTICE_DELETED"
)
type ResourceType string
@ -62,6 +74,10 @@ const (
ResourceQuestion ResourceType = "QUESTION"
ResourceQuestionSet ResourceType = "QUESTION_SET"
ResourceIssue ResourceType = "ISSUE"
ResourceProgram ResourceType = "PROGRAM"
ResourceModule ResourceType = "MODULE"
ResourceLesson ResourceType = "LESSON"
ResourcePractice ResourceType = "PRACTICE"
)
type ActivityLog struct {

33
internal/domain/course.go Normal file
View File

@ -0,0 +1,33 @@
package domain
import "time"
// DefaultCEFRCourseNames are the standard course names seeded for each program (migration 000048).
// Creating a course via the API may use any of these or a custom name.
var DefaultCEFRCourseNames = []string{"A1", "A2", "B1", "B2", "C1", "C2"}
// Course belongs to a Program.
type Course struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
}
type CreateCourseInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
}
type UpdateCourseInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -1,169 +0,0 @@
package domain
import "time"
type SubCourseLevel string
const (
SubCourseLevelBeginner SubCourseLevel = "BEGINNER"
SubCourseLevelIntermediate SubCourseLevel = "INTERMEDIATE"
SubCourseLevelAdvanced SubCourseLevel = "ADVANCED"
)
type SubCourseSubLevel string
const (
SubCourseSubLevelA1 SubCourseSubLevel = "A1"
SubCourseSubLevelA2 SubCourseSubLevel = "A2"
SubCourseSubLevelA3 SubCourseSubLevel = "A3"
SubCourseSubLevelB1 SubCourseSubLevel = "B1"
SubCourseSubLevelB2 SubCourseSubLevel = "B2"
SubCourseSubLevelB3 SubCourseSubLevel = "B3"
SubCourseSubLevelC1 SubCourseSubLevel = "C1"
SubCourseSubLevelC2 SubCourseSubLevel = "C2"
SubCourseSubLevelC3 SubCourseSubLevel = "C3"
)
type ContentStatus string
const (
ContentStatusDraft ContentStatus = "DRAFT"
ContentStatusPublished ContentStatus = "PUBLISHED"
ContentStatusInactive ContentStatus = "INACTIVE"
ContentStatusArchived ContentStatus = "ARCHIVED"
)
type TreeSubCourse struct {
ID int64
Title string
Level string
SubLevel string
}
type TreeCourse struct {
ID int64
Title string
SubCourses []TreeSubCourse
}
type CourseCategory struct {
ID int64
Name string
IsActive bool
CreatedAt time.Time
}
type Course struct {
ID int64
CategoryID int64
Title string
Description *string
Thumbnail *string
IntroVideoURL *string
IsActive bool
}
type SubCourse struct {
ID int64
CourseID int64
Title string
Description *string
Thumbnail *string
DisplayOrder int32
Level string
SubLevel string
IsActive bool
}
type SubCourseVideo struct {
ID int64
SubCourseID int64
Title string
Description *string
VideoURL string
Duration int32
Resolution *string
InstructorID *string
Thumbnail *string
Visibility *string
DisplayOrder int32
IsPublished bool
PublishDate *time.Time
Status string
// Vimeo-specific fields
VimeoID *string
VimeoEmbedURL *string
VimeoPlayerHTML *string
VimeoStatus *string
}
type VideoHostProvider string
const (
VideoHostProviderDirect VideoHostProvider = "DIRECT"
VideoHostProviderVimeo VideoHostProvider = "VIMEO"
)
type VideoAccessBlock struct {
VideoID int64
Title string
DisplayOrder int32
}
// Learning Path types — full nested structure for a course
type LearningPathVideo struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
VideoURL string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution *string `json:"resolution,omitempty"`
DisplayOrder int32 `json:"display_order"`
VimeoID *string `json:"vimeo_id,omitempty"`
VimeoEmbedURL *string `json:"vimeo_embed_url,omitempty"`
VideoHostProvider *string `json:"video_host_provider,omitempty"`
}
type LearningPathPractice struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Persona *string `json:"persona,omitempty"`
Status string `json:"status"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
QuestionCount int64 `json:"question_count"`
}
type LearningPathPrerequisite struct {
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
}
type LearningPathSubCourse struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
PrerequisiteCount int64 `json:"prerequisite_count"`
VideoCount int64 `json:"video_count"`
PracticeCount int64 `json:"practice_count"`
Prerequisites []LearningPathPrerequisite `json:"prerequisites"`
Videos []LearningPathVideo `json:"videos"`
Practices []LearningPathPractice `json:"practices"`
}
type LearningPath struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCourses []LearningPathSubCourse `json:"sub_courses"`
}

32
internal/domain/lesson.go Normal file
View File

@ -0,0 +1,32 @@
package domain
import "time"
// Lesson belongs to a Module.
type Lesson struct {
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
}
type CreateLessonInput struct {
Title string `json:"title" validate:"required"`
VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
}
type UpdateLessonInput struct {
Title *string `json:"title,omitempty"`
VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -0,0 +1,22 @@
package domain
// LMSEntityAccess describes learner gating for a program, course, module, or lesson.
// It is omitted (nil) for non-learner roles in API responses.
// Progress fields count completed lessons vs total lessons in that entitys scope (lesson: 0 or 1 of 1).
type LMSEntityAccess struct {
IsAccessible bool `json:"is_accessible"`
IsCompleted bool `json:"is_completed"`
Reason string `json:"reason,omitempty"`
CompletedCount int `json:"completed_count"`
TotalCount int `json:"total_count"`
ProgressPercent int `json:"progress_percent"`
}
// LMSUserProgress lists entity IDs the authenticated user has fully completed
// (lessons as marked complete; module/course/program when rollup conditions were met).
type LMSUserProgress struct {
LessonIDs []int64 `json:"lesson_ids"`
ModuleIDs []int64 `json:"module_ids"`
CourseIDs []int64 `json:"course_ids"`
ProgramIDs []int64 `json:"program_ids"`
}

30
internal/domain/module.go Normal file
View File

@ -0,0 +1,30 @@
package domain
import "time"
// Module belongs to a Course. program_id is the courses program (stored for querying; not required from the client on create).
type Module struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
}
type CreateModuleInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
}
type UpdateModuleInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -0,0 +1,47 @@
package domain
import "time"
// ParentKind identifies which hierarchy entity owns a practice (exactly one).
type ParentKind string
const (
ParentKindCourse ParentKind = "COURSE"
ParentKindModule ParentKind = "MODULE"
ParentKindLesson ParentKind = "LESSON"
)
// Practice is question-set content (story, persona, tips) scoped to a course, module, or lesson.
type Practice struct {
ID int64 `json:"id"`
ParentKind ParentKind `json:"parent_kind"`
ParentID int64 `json:"parent_id"`
Title string `json:"title"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips *string `json:"quick_tips,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type CreatePracticeInput struct {
ParentKind ParentKind `json:"parent_kind" validate:"required,oneof=COURSE MODULE LESSON"`
ParentID int64 `json:"parent_id" validate:"required,gt=0"`
Title string `json:"title" validate:"required"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"`
QuickTips *string `json:"quick_tips,omitempty"`
}
type UpdatePracticeInput struct {
Title *string `json:"title,omitempty"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID *int64 `json:"question_set_id,omitempty"`
QuickTips *string `json:"quick_tips,omitempty"`
}

View File

@ -0,0 +1,28 @@
package domain
import "time"
// Program is the top-level container in the LMS hierarchy (e.g. tracks like Beginner / Intermediate / Advanced).
type Program struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
}
type CreateProgramInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
}
type UpdateProgramInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -1,84 +0,0 @@
package domain
import (
"errors"
"time"
)
var (
ErrPrerequisiteNotMet = errors.New("prerequisites not completed")
ErrProgressNotFound = errors.New("progress record not found")
ErrPrerequisiteExists = errors.New("prerequisite already exists")
ErrSelfPrerequisite = errors.New("sub-course cannot be its own prerequisite")
ErrSubCourseAlreadyStarted = errors.New("sub-course already started")
)
type SubCoursePrerequisite struct {
ID int64
SubCourseID int64
PrerequisiteSubCourseID int64
PrerequisiteTitle string
PrerequisiteLevel string
PrerequisiteDisplayOrder int32
CreatedAt time.Time
}
type SubCourseDependent struct {
ID int64
SubCourseID int64
PrerequisiteSubCourseID int64
DependentTitle string
DependentLevel string
CreatedAt time.Time
}
type ProgressStatus string
const (
ProgressStatusNotStarted ProgressStatus = "NOT_STARTED"
ProgressStatusInProgress ProgressStatus = "IN_PROGRESS"
ProgressStatusCompleted ProgressStatus = "COMPLETED"
)
type UserSubCourseProgress struct {
ID int64
UserID int64
SubCourseID int64
Status ProgressStatus
ProgressPercentage int16
StartedAt *time.Time
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt *time.Time
}
type SubCourseWithProgress struct {
SubCourseID int64
Title string
Description *string
Thumbnail *string
DisplayOrder int32
Level string
IsActive bool
ProgressStatus ProgressStatus
ProgressPercentage int16
StartedAt *time.Time
CompletedAt *time.Time
UnmetPrerequisitesCount int64
IsLocked bool
}
type UserCourseProgressItem struct {
ID int64
UserID int64
SubCourseID int64
Status ProgressStatus
ProgressPercentage int16
StartedAt *time.Time
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt *time.Time
SubCourseTitle string
SubCourseLevel string
SubCourseDisplayOrder int32
}

View File

@ -27,6 +27,7 @@ const (
QuestionSetTypeQuiz QuestionSetType = "QUIZ"
QuestionSetTypeExam QuestionSetType = "EXAM"
QuestionSetTypeSurvey QuestionSetType = "SURVEY"
QuestionSetTypeCapstone QuestionSetType = "CAPSTONE"
)
type PracticeAccessBlock struct {
@ -103,7 +104,6 @@ type QuestionSet struct {
PassingScore *int32
ShuffleQuestions bool
Status string
SubCourseVideoID *int64
IntroVideoURL *string
UserPersonas []UserPersona
CreatedAt time.Time
@ -172,7 +172,6 @@ type CreateQuestionSetInput struct {
PassingScore *int32
ShuffleQuestions *bool
Status *string
SubCourseVideoID *int64
IntroVideoURL *string
}

View File

@ -0,0 +1,46 @@
package domain
import (
"errors"
"fmt"
"sort"
)
// ErrReorderInvalidIDSet means ordered_ids is not an exact permutation of the current entities in scope.
var ErrReorderInvalidIDSet = errors.New("ordered_ids must list every id in this scope exactly once, with no duplicates")
// ReorderIDsRequest is the body for batch reorder endpoints (drag-and-drop UI).
// Send "ordered_ids": [] in display order. Must include every id in that scope (use GET list) when there is at least one entity.
type ReorderIDsRequest struct {
OrderedIDs []int64 `json:"ordered_ids"`
}
// ValidateReorderPermutation checks that ordered contains the same multiset of ids as expected (new order vs current scope).
func ValidateReorderPermutation(ordered, expected []int64) error {
if len(expected) == 0 {
if len(ordered) == 0 {
return nil
}
return fmt.Errorf("%w: no entities exist in this scope", ErrReorderInvalidIDSet)
}
if len(ordered) != len(expected) {
return fmt.Errorf("%w: want %d ids, got %d", ErrReorderInvalidIDSet, len(expected), len(ordered))
}
seen := make(map[int64]struct{}, len(ordered))
for _, id := range ordered {
if _, dup := seen[id]; dup {
return fmt.Errorf("%w: duplicate id %d", ErrReorderInvalidIDSet, id)
}
seen[id] = struct{}{}
}
a := append([]int64(nil), expected...)
b := append([]int64(nil), ordered...)
sort.Slice(a, func(i, j int) bool { return a[i] < a[j] })
sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })
for i := range a {
if a[i] != b[i] {
return fmt.Errorf("%w: id set does not match current scope", ErrReorderInvalidIDSet)
}
}
return nil
}

View File

@ -6,12 +6,14 @@ import (
)
var (
ErrTeamMemberNotFound = errors.New("team member not found")
ErrTeamMemberEmailExists = errors.New("team member email already exists")
ErrInvalidTeamRole = errors.New("invalid team role")
ErrInvalidTeamMemberStatus = errors.New("invalid team member status")
ErrInvalidEmploymentType = errors.New("invalid employment type")
ErrTeamMemberNotFound = errors.New("team member not found")
ErrTeamMemberEmailExists = errors.New("team member email already exists")
ErrInvalidTeamRole = errors.New("invalid team role")
ErrInvalidTeamMemberStatus = errors.New("invalid team member status")
ErrInvalidEmploymentType = errors.New("invalid employment type")
ErrTeamMemberEmailNotVerified = errors.New("team member email not verified")
ErrTeamRefreshTokenNotFound = errors.New("team refresh token not found")
ErrTeamRefreshTokenExpired = errors.New("team refresh token expired")
)
type TeamRole string
@ -80,6 +82,16 @@ func (e EmploymentType) IsValid() bool {
}
}
// TeamRefreshToken is a persisted refresh token for team member sessions (separate from users.refresh_tokens).
type TeamRefreshToken struct {
ID int64
TeamMemberID int64
Token string
ExpiresAt time.Time
Revoked bool
CreatedAt time.Time
}
type TeamMember struct {
ID int64
FirstName string

View File

@ -1,216 +0,0 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type CourseStore interface {
// Course Categories
CreateCourseCategory(
ctx context.Context,
name string,
) (domain.CourseCategory, error)
GetCourseCategoryByID(
ctx context.Context,
id int64,
) (domain.CourseCategory, error)
GetAllCourseCategories(
ctx context.Context,
limit int32,
offset int32,
) ([]domain.CourseCategory, int64, error)
UpdateCourseCategory(
ctx context.Context,
id int64,
name *string,
isActive *bool,
) error
DeleteCourseCategory(
ctx context.Context,
id int64,
) error
// Courses
CreateCourse(
ctx context.Context,
categoryID int64,
title string,
description *string,
thumbnail *string,
introVideoURL *string,
) (domain.Course, error)
GetCourseByID(
ctx context.Context,
id int64,
) (domain.Course, error)
GetCoursesByCategory(
ctx context.Context,
categoryID int64,
limit int32,
offset int32,
) ([]domain.Course, int64, error)
UpdateCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
introVideoURL *string,
isActive *bool,
) error
DeleteCourse(
ctx context.Context,
id int64,
) error
// Sub-courses
CreateSubCourse(
ctx context.Context,
courseID int64,
title string,
description *string,
thumbnail *string,
displayOrder *int32,
level string,
subLevel string,
) (domain.SubCourse, error)
GetSubCourseByID(
ctx context.Context,
id int64,
) (domain.SubCourse, error)
GetSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, int64, error)
ListSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, error)
ListActiveSubCourses(
ctx context.Context,
) ([]domain.SubCourse, error)
UpdateSubCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
displayOrder *int32,
level *string,
subLevel *string,
isActive *bool,
) error
DeactivateSubCourse(
ctx context.Context,
id int64,
) error
DeleteSubCourse(
ctx context.Context,
id int64,
) (domain.SubCourse, error)
// Sub-course Videos
CreateSubCourseVideo(
ctx context.Context,
subCourseID int64,
title string,
description *string,
videoURL string,
duration int32,
resolution *string,
instructorID *string,
thumbnail *string,
visibility *string,
displayOrder *int32,
status *string,
vimeoID *string,
vimeoEmbedURL *string,
vimeoPlayerHTML *string,
vimeoStatus *string,
videoHostProvider *string,
) (domain.SubCourseVideo, error)
GetSubCourseVideoByID(
ctx context.Context,
id int64,
) (domain.SubCourseVideo, error)
GetVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, int64, error)
GetPublishedVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, error)
GetFirstIncompletePreviousVideo(
ctx context.Context,
userID int64,
videoID int64,
) (*domain.VideoAccessBlock, error)
MarkVideoCompleted(
ctx context.Context,
userID int64,
videoID int64,
) error
PublishSubCourseVideo(
ctx context.Context,
videoID int64,
) error
UpdateSubCourseVideo(
ctx context.Context,
id int64,
title *string,
description *string,
videoURL *string,
duration *int32,
resolution *string,
visibility *string,
thumbnail *string,
displayOrder *int32,
status *string,
) error
ArchiveSubCourseVideo(
ctx context.Context,
id int64,
) error
DeleteSubCourseVideo(
ctx context.Context,
id int64,
) error
// Vimeo integration
UpdateVimeoStatus(ctx context.Context, videoID int64, status string) error
GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.SubCourseVideo, error)
// Learning Tree
GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error)
// Learning Path (full nested structure for a course)
GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error)
// Reorder (drag-and-drop support)
ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error
ReorderCourses(ctx context.Context, ids []int64, positions []int32) error
ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error
ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error
ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error
}
type ProgressionStore interface {
// Prerequisites (admin)
AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error
RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error
GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error)
GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error)
CountUnmetPrerequisites(ctx context.Context, subCourseID, userID int64) (int64, error)
DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error
// User progress
StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error)
UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error
CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error
RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error
GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error)
GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error)
GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error)
}

View File

@ -0,0 +1,16 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type CourseStore interface {
CreateCourse(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error)
GetCourseByID(ctx context.Context, id int64) (domain.Course, error)
ListCoursesByProgramID(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error)
ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error)
ReorderCoursesInProgram(ctx context.Context, programID int64, orderedIDs []int64) error
UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error)
DeleteCourse(ctx context.Context, id int64) error
}

View File

@ -0,0 +1,14 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type LessonStore interface {
CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error)
GetLessonByID(ctx context.Context, id int64) (domain.Lesson, error)
ListLessonsByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error)
UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error)
DeleteLesson(ctx context.Context, id int64) error
}

View File

@ -0,0 +1,16 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type ModuleStore interface {
CreateModule(ctx context.Context, programID, courseID int64, input domain.CreateModuleInput) (domain.Module, error)
GetModuleByID(ctx context.Context, id int64) (domain.Module, error)
ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, limit, offset int32) ([]domain.Module, int64, error)
ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error)
ReorderModulesInCourse(ctx context.Context, courseID int64, orderedIDs []int64) error
UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error)
DeleteModule(ctx context.Context, id int64) error
}

View File

@ -0,0 +1,31 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
// QuestionSetByID is implemented by the questions store.
type QuestionSetByID interface {
GetQuestionSetByID(ctx context.Context, id int64) (domain.QuestionSet, error)
}
// UserByID is implemented by the user store.
type UserByID interface {
GetUserByID(ctx context.Context, id int64) (domain.User, error)
}
type LmsPracticeStore interface {
// courseID, moduleID, lessonID: exactly one non-nil, matching in.ParentKind / in.ParentID.
CreateLmsPractice(
ctx context.Context,
in domain.CreatePracticeInput,
courseID, moduleID, lessonID *int64,
) (domain.Practice, error)
GetLmsPracticeByID(ctx context.Context, id int64) (domain.Practice, error)
ListLmsPracticesByCourseID(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error)
ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error)
ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error)
UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error)
DeleteLmsPractice(ctx context.Context, id int64) error
}

16
internal/ports/program.go Normal file
View File

@ -0,0 +1,16 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type ProgramStore interface {
CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error)
GetProgramByID(ctx context.Context, id int64) (domain.Program, error)
ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error)
ListAllProgramIDs(ctx context.Context) ([]int64, error)
ReorderPrograms(ctx context.Context, orderedIDs []int64) error
UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error)
DeleteProgram(ctx context.Context, id int64) error
}

View File

@ -37,7 +37,6 @@ type QuestionStore interface {
GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error)
GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error)
GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error)
GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error)
GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error)
MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error
UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error

View File

@ -2,6 +2,7 @@ package ports
import (
"context"
"time"
"Yimaru-Backend/internal/domain"
)
@ -30,4 +31,9 @@ type TeamStore interface {
GetTeamMembersByRole(ctx context.Context, role string) ([]domain.TeamMember, error)
CountTeamMembersByStatus(ctx context.Context) (domain.TeamMemberStats, error)
UpdateTeamMemberEmailVerified(ctx context.Context, memberID int64, verified bool) error
RevokeAllActiveTeamRefreshTokensForMember(ctx context.Context, memberID int64) error
CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error
GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error)
RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error
}

View File

@ -1,128 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func NewCourseStore(s *Store) *Store { return s }
func NewProgressionStore(s *Store) *Store { return s }
func (s *Store) CreateCourseCategory(
ctx context.Context,
name string,
) (domain.CourseCategory, error) {
row, err := s.queries.CreateCourseCategory(ctx, dbgen.CreateCourseCategoryParams{
Name: name,
Column2: true,
})
if err != nil {
return domain.CourseCategory{}, err
}
return domain.CourseCategory{
ID: row.ID,
Name: row.Name,
IsActive: row.IsActive,
CreatedAt: row.CreatedAt.Time,
}, nil
}
func (s *Store) GetCourseCategoryByID(
ctx context.Context,
id int64,
) (domain.CourseCategory, error) {
row, err := s.queries.GetCourseCategoryByID(ctx, id)
if err != nil {
return domain.CourseCategory{}, err
}
return domain.CourseCategory{
ID: row.ID,
Name: row.Name,
IsActive: row.IsActive,
CreatedAt: row.CreatedAt.Time,
}, nil
}
func (s *Store) GetAllCourseCategories(
ctx context.Context,
limit int32,
offset int32,
) ([]domain.CourseCategory, int64, error) {
rows, err := s.queries.GetAllCourseCategories(ctx, dbgen.GetAllCourseCategoriesParams{
Limit: pgtype.Int4{Int32: limit},
Offset: pgtype.Int4{Int32: offset},
})
if err != nil {
return nil, 0, err
}
var (
categories []domain.CourseCategory
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
categories = append(categories, domain.CourseCategory{
ID: row.ID,
Name: row.Name,
IsActive: row.IsActive,
CreatedAt: row.CreatedAt.Time,
})
}
return categories, totalCount, nil
}
func (s *Store) UpdateCourseCategory(
ctx context.Context,
id int64,
name *string,
isActive *bool,
) error {
var (
nameVal string
isActiveVal bool
)
if name != nil {
nameVal = *name
}
if isActive != nil {
isActiveVal = *isActive
}
return s.queries.UpdateCourseCategory(ctx, dbgen.UpdateCourseCategoryParams{
Name: nameVal,
IsActive: isActiveVal,
ID: id,
})
}
func (s *Store) DeleteCourseCategory(
ctx context.Context,
id int64,
) error {
return s.queries.DeleteCourseCategory(ctx, id)
}
func (s *Store) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error {
return s.queries.ReorderCourseCategories(ctx, dbgen.ReorderCourseCategoriesParams{
Ids: ids,
Positions: positions,
})
}

View File

@ -1,173 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) CreateCourse(
ctx context.Context,
categoryID int64,
title string,
description *string,
thumbnail *string,
introVideoURL *string,
) (domain.Course, error) {
var descVal, thumbVal, introVideoVal string
if description != nil {
descVal = *description
}
if thumbnail != nil {
thumbVal = *thumbnail
}
if introVideoURL != nil {
introVideoVal = *introVideoURL
}
row, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{
CategoryID: categoryID,
Title: title,
Description: pgtype.Text{String: descVal, Valid: description != nil},
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil},
Column6: true,
})
if err != nil {
return domain.Course{}, err
}
return mapCourse(row), nil
}
func (s *Store) GetCourseByID(
ctx context.Context,
id int64,
) (domain.Course, error) {
row, err := s.queries.GetCourseByID(ctx, id)
if err != nil {
return domain.Course{}, err
}
return mapCourse(row), nil
}
func (s *Store) GetCoursesByCategory(
ctx context.Context,
categoryID int64,
limit int32,
offset int32,
) ([]domain.Course, int64, error) {
rows, err := s.queries.GetCoursesByCategory(ctx, dbgen.GetCoursesByCategoryParams{
CategoryID: categoryID,
Limit: pgtype.Int4{Int32: limit},
Offset: pgtype.Int4{Int32: offset},
})
if err != nil {
return nil, 0, err
}
var (
courses []domain.Course
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
courses = append(courses, domain.Course{
ID: row.ID,
CategoryID: row.CategoryID,
Title: row.Title,
Description: ptrText(row.Description),
Thumbnail: ptrText(row.Thumbnail),
IntroVideoURL: ptrText(row.IntroVideoUrl),
IsActive: row.IsActive,
})
}
return courses, totalCount, nil
}
func (s *Store) UpdateCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
introVideoURL *string,
isActive *bool,
) error {
var (
titleVal string
descriptionVal string
thumbnailVal string
introVideoVal string
isActiveVal bool
)
if title != nil {
titleVal = *title
}
if description != nil {
descriptionVal = *description
}
if thumbnail != nil {
thumbnailVal = *thumbnail
}
if introVideoURL != nil {
introVideoVal = *introVideoURL
}
if isActive != nil {
isActiveVal = *isActive
}
return s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{
Title: titleVal,
Description: pgtype.Text{String: descriptionVal, Valid: description != nil},
Thumbnail: pgtype.Text{String: thumbnailVal, Valid: thumbnail != nil},
IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil},
IsActive: isActiveVal,
ID: id,
})
}
func (s *Store) DeleteCourse(
ctx context.Context,
id int64,
) error {
return s.queries.DeleteCourse(ctx, id)
}
func mapCourse(row dbgen.Course) domain.Course {
return domain.Course{
ID: row.ID,
CategoryID: row.CategoryID,
Title: row.Title,
Description: ptrText(row.Description),
Thumbnail: ptrText(row.Thumbnail),
IntroVideoURL: ptrText(row.IntroVideoUrl),
IsActive: row.IsActive,
}
}
func (s *Store) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error {
return s.queries.ReorderCourses(ctx, dbgen.ReorderCoursesParams{
Ids: ids,
Positions: positions,
})
}
func ptrText(t pgtype.Text) *string {
if t.Valid {
return &t.String
}
return nil
}

View File

@ -0,0 +1,87 @@
package repository
import (
"context"
dbgen "Yimaru-Backend/gen/db"
)
func (s *Store) LmsGetPreviousProgram(ctx context.Context, programID int64) (dbgen.Program, error) {
return s.queries.GetPreviousProgram(ctx, programID)
}
func (s *Store) LmsGetPreviousCourseInProgram(ctx context.Context, courseID int64) (dbgen.Course, error) {
return s.queries.GetPreviousCourseInProgram(ctx, courseID)
}
func (s *Store) LmsGetPreviousModuleInCourse(ctx context.Context, moduleID int64) (dbgen.Module, error) {
return s.queries.GetPreviousModuleInCourse(ctx, moduleID)
}
func (s *Store) LmsGetPreviousLessonInModule(ctx context.Context, lessonID int64) (dbgen.Lesson, error) {
return s.queries.GetPreviousLessonInModule(ctx, lessonID)
}
func (s *Store) LmsUserHasProgramProgress(ctx context.Context, userID, programID int64) (bool, error) {
return s.queries.UserHasProgramProgress(ctx, dbgen.UserHasProgramProgressParams{UserID: userID, ProgramID: programID})
}
func (s *Store) LmsUserHasCourseProgress(ctx context.Context, userID, courseID int64) (bool, error) {
return s.queries.UserHasCourseProgress(ctx, dbgen.UserHasCourseProgressParams{UserID: userID, CourseID: courseID})
}
func (s *Store) LmsUserHasModuleProgress(ctx context.Context, userID, moduleID int64) (bool, error) {
return s.queries.UserHasModuleProgress(ctx, dbgen.UserHasModuleProgressParams{UserID: userID, ModuleID: moduleID})
}
func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID int64) (bool, error) {
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
}
// LmsUserLessonProgressInModule returns completed and total lesson counts in a module (for progress UI).
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInModule(ctx, moduleID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: moduleID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
// LmsUserLessonProgressInCourse returns completed and total lesson counts in a course (all modules).
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInCourse(ctx, courseID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{
CourseID: courseID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
// LmsUserLessonProgressInProgram returns completed and total lesson counts in a program (all courses).
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInProgram(ctx, programID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}

View File

@ -0,0 +1,116 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func courseToDomain(c dbgen.Course) domain.Course {
out := domain.Course{
ID: c.ID,
ProgramID: c.ProgramID,
Name: c.Name,
}
out.Description = fromPgText(c.Description)
out.Thumbnail = fromPgText(c.Thumbnail)
out.CreatedAt = c.CreatedAt.Time
if c.UpdatedAt.Valid {
t := c.UpdatedAt.Time
out.UpdatedAt = &t
}
out.SortOrder = int(c.SortOrder)
return out
}
func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error) {
c, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{
ProgramID: programID,
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
})
if err != nil {
return domain.Course{}, err
}
return courseToDomain(c), nil
}
func (s *Store) ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) {
return s.queries.ListCourseIDsByProgram(ctx, programID)
}
func (s *Store) GetCourseByID(ctx context.Context, id int64) (domain.Course, error) {
c, err := s.queries.GetCourseByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Course{}, pgx.ErrNoRows
}
return domain.Course{}, err
}
return courseToDomain(c), nil
}
func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error) {
rows, err := s.queries.ListCoursesByProgramID(ctx, dbgen.ListCoursesByProgramIDParams{
ProgramID: programID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Course{}, 0, nil
}
var total int64
out := make([]domain.Course, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, courseToDomain(dbgen.Course{
ID: r.ID,
ProgramID: r.ProgramID,
Name: r.Name,
Description: r.Description,
Thumbnail: r.Thumbnail,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
}))
}
return out, total, nil
}
func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) {
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
c, err := s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Course{}, pgx.ErrNoRows
}
return domain.Course{}, err
}
return courseToDomain(c), nil
}
func (s *Store) DeleteCourse(ctx context.Context, id int64) error {
return s.queries.DeleteCourse(ctx, id)
}

View File

@ -0,0 +1,116 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func lessonToDomain(l dbgen.Lesson) domain.Lesson {
out := domain.Lesson{
ID: l.ID,
ModuleID: l.ModuleID,
Title: l.Title,
}
out.VideoURL = fromPgText(l.VideoUrl)
out.Thumbnail = fromPgText(l.Thumbnail)
out.Description = fromPgText(l.Description)
out.CreatedAt = l.CreatedAt.Time
if l.UpdatedAt.Valid {
t := l.UpdatedAt.Time
out.UpdatedAt = &t
}
out.SortOrder = int(l.SortOrder)
return out
}
func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error) {
l, err := s.queries.CreateLesson(ctx, dbgen.CreateLessonParams{
ModuleID: moduleID,
Title: input.Title,
VideoUrl: toPgText(input.VideoURL),
Thumbnail: toPgText(input.Thumbnail),
Description: toPgText(input.Description),
})
if err != nil {
return domain.Lesson{}, err
}
return lessonToDomain(l), nil
}
func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, error) {
l, err := s.queries.GetLessonByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Lesson{}, pgx.ErrNoRows
}
return domain.Lesson{}, err
}
return lessonToDomain(l), nil
}
func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) {
rows, err := s.queries.ListLessonsByModuleID(ctx, dbgen.ListLessonsByModuleIDParams{
ModuleID: moduleID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Lesson{}, 0, nil
}
var total int64
out := make([]domain.Lesson, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, lessonToDomain(dbgen.Lesson{
ID: r.ID,
ModuleID: r.ModuleID,
Title: r.Title,
VideoUrl: r.VideoUrl,
Thumbnail: r.Thumbnail,
Description: r.Description,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
}))
}
return out, total, nil
}
func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) {
var titleText pgtype.Text
if input.Title != nil {
titleText = pgtype.Text{String: *input.Title, Valid: true}
} else {
titleText = pgtype.Text{Valid: false}
}
l, err := s.queries.UpdateLesson(ctx, dbgen.UpdateLessonParams{
ID: id,
Title: titleText,
VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Lesson{}, pgx.ErrNoRows
}
return domain.Lesson{}, err
}
return lessonToDomain(l), nil
}
func (s *Store) DeleteLesson(ctx context.Context, id int64) error {
return s.queries.DeleteLesson(ctx, id)
}

View File

@ -0,0 +1,120 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func moduleToDomain(m dbgen.Module) domain.Module {
out := domain.Module{
ID: m.ID,
ProgramID: m.ProgramID,
CourseID: m.CourseID,
Name: m.Name,
}
out.Description = fromPgText(m.Description)
out.Icon = fromPgText(m.Icon)
out.CreatedAt = m.CreatedAt.Time
if m.UpdatedAt.Valid {
t := m.UpdatedAt.Time
out.UpdatedAt = &t
}
out.SortOrder = int(m.SortOrder)
return out
}
func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, input domain.CreateModuleInput) (domain.Module, error) {
m, err := s.queries.CreateModule(ctx, dbgen.CreateModuleParams{
ProgramID: programID,
CourseID: courseID,
Name: input.Name,
Description: toPgText(input.Description),
Icon: toPgText(input.Icon),
})
if err != nil {
return domain.Module{}, err
}
return moduleToDomain(m), nil
}
func (s *Store) ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) {
return s.queries.ListModuleIDsByCourse(ctx, courseID)
}
func (s *Store) GetModuleByID(ctx context.Context, id int64) (domain.Module, error) {
m, err := s.queries.GetModuleByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Module{}, pgx.ErrNoRows
}
return domain.Module{}, err
}
return moduleToDomain(m), nil
}
func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, limit, offset int32) ([]domain.Module, int64, error) {
rows, err := s.queries.ListModulesByProgramAndCourse(ctx, dbgen.ListModulesByProgramAndCourseParams{
ProgramID: programID,
CourseID: courseID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Module{}, 0, nil
}
var total int64
out := make([]domain.Module, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, moduleToDomain(dbgen.Module{
ID: r.ID,
ProgramID: r.ProgramID,
CourseID: r.CourseID,
Name: r.Name,
Description: r.Description,
Icon: r.Icon,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
}))
}
return out, total, nil
}
func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) {
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
m, err := s.queries.UpdateModule(ctx, dbgen.UpdateModuleParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Icon: optionalTextUpdate(input.Icon),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Module{}, pgx.ErrNoRows
}
return domain.Module{}, err
}
return moduleToDomain(m), nil
}
func (s *Store) DeleteModule(ctx context.Context, id int64) error {
return s.queries.DeleteModule(ctx, id)
}

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