From 9db9c9899a72f53ad2cc6dccc053dd992f4deb6b Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Thu, 23 Apr 2026 01:59:20 -0700 Subject: [PATCH] module+lesson+practice implementations --- cmd/main.go | 20 + .../000043_seed_default_programs.down.sql | 4 + .../000043_seed_default_programs.up.sql | 6 + db/migrations/000044_lms_courses.down.sql | 1 + db/migrations/000044_lms_courses.up.sql | 13 + db/migrations/000045_lms_modules.down.sql | 2 + db/migrations/000045_lms_modules.up.sql | 22 + db/migrations/000046_lms_lessons.down.sql | 1 + db/migrations/000046_lms_lessons.up.sql | 14 + db/migrations/000047_lms_practices.down.sql | 1 + db/migrations/000047_lms_practices.up.sql | 29 ++ db/query/lms_courses.sql | 38 ++ db/query/lms_lessons.sql | 40 ++ db/query/lms_modules.sql | 40 ++ db/query/lms_practices.sql | 88 ++++ gen/db/lms_courses.sql.go | 176 ++++++++ gen/db/lms_lessons.sql.go | 187 +++++++++ gen/db/lms_modules.sql.go | 191 +++++++++ gen/db/lms_practices.sql.go | 381 ++++++++++++++++++ gen/db/models.go | 47 +++ internal/domain/activity_log.go | 12 + internal/domain/course.go | 26 ++ internal/domain/lesson.go | 29 ++ internal/domain/module.go | 27 ++ internal/domain/practice.go | 47 +++ internal/ports/lms_course.go | 14 + internal/ports/lms_lesson.go | 14 + internal/ports/lms_module.go | 14 + internal/ports/lms_practice.go | 31 ++ internal/repository/lms_courses.go | 109 +++++ internal/repository/lms_lessons.go | 113 ++++++ internal/repository/lms_modules.go | 113 ++++++ internal/repository/lms_practices.go | 232 +++++++++++ internal/services/courses/service.go | 83 ++++ internal/services/lessons/service.go | 88 ++++ internal/services/modules/service.go | 92 +++++ internal/services/practices/service.go | 204 ++++++++++ internal/services/rbac/seeds.go | 48 ++- internal/web_server/app.go | 16 + .../web_server/handlers/course_handler.go | 249 ++++++++++++ internal/web_server/handlers/handlers.go | 16 + .../web_server/handlers/lesson_handler.go | 229 +++++++++++ .../web_server/handlers/module_handler.go | 236 +++++++++++ .../web_server/handlers/practice_handler.go | 237 +++++++++++ internal/web_server/routes.go | 31 ++ 45 files changed, 3607 insertions(+), 4 deletions(-) create mode 100644 db/migrations/000043_seed_default_programs.down.sql create mode 100644 db/migrations/000043_seed_default_programs.up.sql create mode 100644 db/migrations/000044_lms_courses.down.sql create mode 100644 db/migrations/000044_lms_courses.up.sql create mode 100644 db/migrations/000045_lms_modules.down.sql create mode 100644 db/migrations/000045_lms_modules.up.sql create mode 100644 db/migrations/000046_lms_lessons.down.sql create mode 100644 db/migrations/000046_lms_lessons.up.sql create mode 100644 db/migrations/000047_lms_practices.down.sql create mode 100644 db/migrations/000047_lms_practices.up.sql create mode 100644 db/query/lms_courses.sql create mode 100644 db/query/lms_lessons.sql create mode 100644 db/query/lms_modules.sql create mode 100644 db/query/lms_practices.sql create mode 100644 gen/db/lms_courses.sql.go create mode 100644 gen/db/lms_lessons.sql.go create mode 100644 gen/db/lms_modules.sql.go create mode 100644 gen/db/lms_practices.sql.go create mode 100644 internal/domain/course.go create mode 100644 internal/domain/lesson.go create mode 100644 internal/domain/module.go create mode 100644 internal/domain/practice.go create mode 100644 internal/ports/lms_course.go create mode 100644 internal/ports/lms_lesson.go create mode 100644 internal/ports/lms_module.go create mode 100644 internal/ports/lms_practice.go create mode 100644 internal/repository/lms_courses.go create mode 100644 internal/repository/lms_lessons.go create mode 100644 internal/repository/lms_modules.go create mode 100644 internal/repository/lms_practices.go create mode 100644 internal/services/courses/service.go create mode 100644 internal/services/lessons/service.go create mode 100644 internal/services/modules/service.go create mode 100644 internal/services/practices/service.go create mode 100644 internal/web_server/handlers/course_handler.go create mode 100644 internal/web_server/handlers/lesson_handler.go create mode 100644 internal/web_server/handlers/module_handler.go create mode 100644 internal/web_server/handlers/practice_handler.go diff --git a/cmd/main.go b/cmd/main.go index 2be5d47..42e3aa4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,6 +17,10 @@ import ( 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" + 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" @@ -391,6 +395,18 @@ func main() { // 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) + + // LMS practices (under course, module, or lesson) + practiceSvc := practicesservice.NewService(store, store, store, store, store, store) + // Subscriptions service subscriptionsSvc := subscriptions.NewService(store) @@ -433,6 +449,10 @@ func main() { assessmentSvc, questionsSvc, programSvc, + courseSvc, + moduleSvc, + lessonSvc, + practiceSvc, subscriptionsSvc, arifpaySvc, issueReportingSvc, diff --git a/db/migrations/000043_seed_default_programs.down.sql b/db/migrations/000043_seed_default_programs.down.sql new file mode 100644 index 0000000..bb72fe3 --- /dev/null +++ b/db/migrations/000043_seed_default_programs.down.sql @@ -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.'); diff --git a/db/migrations/000043_seed_default_programs.up.sql b/db/migrations/000043_seed_default_programs.up.sql new file mode 100644 index 0000000..d40a9d7 --- /dev/null +++ b/db/migrations/000043_seed_default_programs.up.sql @@ -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); diff --git a/db/migrations/000044_lms_courses.down.sql b/db/migrations/000044_lms_courses.down.sql new file mode 100644 index 0000000..2ccdd25 --- /dev/null +++ b/db/migrations/000044_lms_courses.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS courses; diff --git a/db/migrations/000044_lms_courses.up.sql b/db/migrations/000044_lms_courses.up.sql new file mode 100644 index 0000000..2018ea1 --- /dev/null +++ b/db/migrations/000044_lms_courses.up.sql @@ -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); diff --git a/db/migrations/000045_lms_modules.down.sql b/db/migrations/000045_lms_modules.down.sql new file mode 100644 index 0000000..37206ae --- /dev/null +++ b/db/migrations/000045_lms_modules.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS modules; +ALTER TABLE courses DROP CONSTRAINT IF EXISTS courses_program_id_id_key; diff --git a/db/migrations/000045_lms_modules.up.sql b/db/migrations/000045_lms_modules.up.sql new file mode 100644 index 0000000..5080839 --- /dev/null +++ b/db/migrations/000045_lms_modules.up.sql @@ -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); diff --git a/db/migrations/000046_lms_lessons.down.sql b/db/migrations/000046_lms_lessons.down.sql new file mode 100644 index 0000000..73e12bf --- /dev/null +++ b/db/migrations/000046_lms_lessons.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS lessons; diff --git a/db/migrations/000046_lms_lessons.up.sql b/db/migrations/000046_lms_lessons.up.sql new file mode 100644 index 0000000..05d5edf --- /dev/null +++ b/db/migrations/000046_lms_lessons.up.sql @@ -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); diff --git a/db/migrations/000047_lms_practices.down.sql b/db/migrations/000047_lms_practices.down.sql new file mode 100644 index 0000000..262633c --- /dev/null +++ b/db/migrations/000047_lms_practices.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS lms_practices; diff --git a/db/migrations/000047_lms_practices.up.sql b/db/migrations/000047_lms_practices.up.sql new file mode 100644 index 0000000..c9ec38d --- /dev/null +++ b/db/migrations/000047_lms_practices.up.sql @@ -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); diff --git a/db/query/lms_courses.sql b/db/query/lms_courses.sql new file mode 100644 index 0000000..f248305 --- /dev/null +++ b/db/query/lms_courses.sql @@ -0,0 +1,38 @@ +-- name: CreateCourse :one +INSERT INTO courses (program_id, name, description, thumbnail) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetCourseByID :one +SELECT * +FROM courses +WHERE id = $1; + +-- name: ListCoursesByProgramID :many +SELECT + COUNT(*) OVER () AS total_count, + c.id, + c.program_id, + c.name, + c.description, + c.thumbnail, + c.created_at, + c.updated_at +FROM courses c +WHERE c.program_id = $1 +ORDER BY c.created_at DESC +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), + updated_at = CURRENT_TIMESTAMP +WHERE id = sqlc.arg('id') +RETURNING *; + +-- name: DeleteCourse :exec +DELETE FROM courses +WHERE id = $1; diff --git a/db/query/lms_lessons.sql b/db/query/lms_lessons.sql new file mode 100644 index 0000000..336a161 --- /dev/null +++ b/db/query/lms_lessons.sql @@ -0,0 +1,40 @@ +-- name: CreateLesson :one +INSERT INTO lessons (module_id, title, video_url, thumbnail, description) +VALUES ($1, $2, $3, $4, $5) +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.created_at, + l.updated_at +FROM lessons l +WHERE l.module_id = $1 +ORDER BY l.created_at DESC +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), + updated_at = CURRENT_TIMESTAMP +WHERE id = sqlc.arg('id') +RETURNING *; + +-- name: DeleteLesson :exec +DELETE FROM lessons +WHERE id = $1; diff --git a/db/query/lms_modules.sql b/db/query/lms_modules.sql new file mode 100644 index 0000000..77b2abc --- /dev/null +++ b/db/query/lms_modules.sql @@ -0,0 +1,40 @@ +-- name: CreateModule :one +INSERT INTO modules (program_id, course_id, name, description, icon) +VALUES ($1, $2, $3, $4, $5) +RETURNING *; + +-- name: GetModuleByID :one +SELECT * +FROM modules +WHERE id = $1; + +-- name: ListModulesByProgramAndCourse :many +SELECT + COUNT(*) OVER () AS total_count, + m.id, + m.program_id, + m.course_id, + m.name, + m.description, + m.icon, + m.created_at, + m.updated_at +FROM modules m +WHERE m.program_id = $1 + AND m.course_id = $2 +ORDER BY m.created_at DESC +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), + updated_at = CURRENT_TIMESTAMP +WHERE id = sqlc.arg('id') +RETURNING *; + +-- name: DeleteModule :exec +DELETE FROM modules +WHERE id = $1; diff --git a/db/query/lms_practices.sql b/db/query/lms_practices.sql new file mode 100644 index 0000000..650e1a4 --- /dev/null +++ b/db/query/lms_practices.sql @@ -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; diff --git a/gen/db/lms_courses.sql.go b/gen/db/lms_courses.sql.go new file mode 100644 index 0000000..7505e46 --- /dev/null +++ b/gen/db/lms_courses.sql.go @@ -0,0 +1,176 @@ +// 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) +VALUES ($1, $2, $3, $4) +RETURNING id, program_id, name, description, thumbnail, created_at, updated_at +` + +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, + ) + 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 +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, + ) + return i, err +} + +const ListCoursesByProgramID = `-- name: ListCoursesByProgramID :many +SELECT + COUNT(*) OVER () AS total_count, + c.id, + c.program_id, + c.name, + c.description, + c.thumbnail, + c.created_at, + c.updated_at +FROM courses c +WHERE c.program_id = $1 +ORDER BY c.created_at DESC +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"` + 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.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), + updated_at = CURRENT_TIMESTAMP +WHERE id = $4 +RETURNING id, program_id, name, description, thumbnail, created_at, updated_at +` + +type UpdateCourseParams struct { + Name pgtype.Text `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + 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.ID, + ) + var i Course + err := row.Scan( + &i.ID, + &i.ProgramID, + &i.Name, + &i.Description, + &i.Thumbnail, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/gen/db/lms_lessons.sql.go b/gen/db/lms_lessons.sql.go new file mode 100644 index 0000000..f15bed0 --- /dev/null +++ b/gen/db/lms_lessons.sql.go @@ -0,0 +1,187 @@ +// 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) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, module_id, title, video_url, thumbnail, description, created_at, updated_at +` + +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, + ) + 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 +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, + ) + 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.created_at, + l.updated_at +FROM lessons l +WHERE l.module_id = $1 +ORDER BY l.created_at DESC +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"` + 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.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), + updated_at = CURRENT_TIMESTAMP +WHERE id = $5 +RETURNING id, module_id, title, video_url, thumbnail, description, created_at, updated_at +` + +type UpdateLessonParams struct { + Title pgtype.Text `json:"title"` + VideoUrl pgtype.Text `json:"video_url"` + Thumbnail pgtype.Text `json:"thumbnail"` + Description pgtype.Text `json:"description"` + 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.ID, + ) + var i Lesson + err := row.Scan( + &i.ID, + &i.ModuleID, + &i.Title, + &i.VideoUrl, + &i.Thumbnail, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/gen/db/lms_modules.sql.go b/gen/db/lms_modules.sql.go new file mode 100644 index 0000000..db67d7e --- /dev/null +++ b/gen/db/lms_modules.sql.go @@ -0,0 +1,191 @@ +// 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) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, program_id, course_id, name, description, icon, created_at, updated_at +` + +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, + ) + 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 +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, + ) + return i, err +} + +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.created_at, + m.updated_at +FROM modules m +WHERE m.program_id = $1 + AND m.course_id = $2 +ORDER BY m.created_at DESC +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"` + 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.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), + updated_at = CURRENT_TIMESTAMP +WHERE id = $4 +RETURNING id, program_id, course_id, name, description, icon, created_at, updated_at +` + +type UpdateModuleParams struct { + Name pgtype.Text `json:"name"` + Description pgtype.Text `json:"description"` + Icon pgtype.Text `json:"icon"` + 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.ID, + ) + var i Module + err := row.Scan( + &i.ID, + &i.ProgramID, + &i.CourseID, + &i.Name, + &i.Description, + &i.Icon, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/gen/db/lms_practices.sql.go b/gen/db/lms_practices.sql.go new file mode 100644 index 0000000..5ecb503 --- /dev/null +++ b/gen/db/lms_practices.sql.go @@ -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 +} diff --git a/gen/db/models.go b/gen/db/models.go index 2716bc3..7bcc695 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -22,6 +22,16 @@ type ActivityLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type Course struct { + 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"` +} + type Device struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` @@ -39,11 +49,48 @@ type GlobalSetting struct { UpdatedAt pgtype.Timestamptz `json:"updated_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"` +} + type LevelToSubCourse struct { LevelID int64 `json:"level_id"` 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 Module struct { + 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"` +} + type ModuleToSubCourse struct { ModuleID int64 `json:"module_id"` SubCourseID int64 `json:"sub_course_id"` diff --git a/internal/domain/activity_log.go b/internal/domain/activity_log.go index 1327c3c..5671204 100644 --- a/internal/domain/activity_log.go +++ b/internal/domain/activity_log.go @@ -48,6 +48,15 @@ const ( 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 @@ -66,6 +75,9 @@ const ( ResourceQuestionSet ResourceType = "QUESTION_SET" ResourceIssue ResourceType = "ISSUE" ResourceProgram ResourceType = "PROGRAM" + ResourceModule ResourceType = "MODULE" + ResourceLesson ResourceType = "LESSON" + ResourcePractice ResourceType = "PRACTICE" ) type ActivityLog struct { diff --git a/internal/domain/course.go b/internal/domain/course.go new file mode 100644 index 0000000..fe5260c --- /dev/null +++ b/internal/domain/course.go @@ -0,0 +1,26 @@ +package domain + +import "time" + +// Course belongs to a Program (e.g. A1, A2, … labels are configured separately). +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"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,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"` +} diff --git a/internal/domain/lesson.go b/internal/domain/lesson.go new file mode 100644 index 0000000..6651569 --- /dev/null +++ b/internal/domain/lesson.go @@ -0,0 +1,29 @@ +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"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,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"` +} diff --git a/internal/domain/module.go b/internal/domain/module.go new file mode 100644 index 0000000..39627b8 --- /dev/null +++ b/internal/domain/module.go @@ -0,0 +1,27 @@ +package domain + +import "time" + +// Module belongs to a Course. program_id is the course’s 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"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,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"` +} diff --git a/internal/domain/practice.go b/internal/domain/practice.go new file mode 100644 index 0000000..f900971 --- /dev/null +++ b/internal/domain/practice.go @@ -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"` +} diff --git a/internal/ports/lms_course.go b/internal/ports/lms_course.go new file mode 100644 index 0000000..b37b53d --- /dev/null +++ b/internal/ports/lms_course.go @@ -0,0 +1,14 @@ +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) + UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) + DeleteCourse(ctx context.Context, id int64) error +} diff --git a/internal/ports/lms_lesson.go b/internal/ports/lms_lesson.go new file mode 100644 index 0000000..4bb4a90 --- /dev/null +++ b/internal/ports/lms_lesson.go @@ -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 +} diff --git a/internal/ports/lms_module.go b/internal/ports/lms_module.go new file mode 100644 index 0000000..c50f7b3 --- /dev/null +++ b/internal/ports/lms_module.go @@ -0,0 +1,14 @@ +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) + UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) + DeleteModule(ctx context.Context, id int64) error +} diff --git a/internal/ports/lms_practice.go b/internal/ports/lms_practice.go new file mode 100644 index 0000000..63a827f --- /dev/null +++ b/internal/ports/lms_practice.go @@ -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 +} diff --git a/internal/repository/lms_courses.go b/internal/repository/lms_courses.go new file mode 100644 index 0000000..321172a --- /dev/null +++ b/internal/repository/lms_courses.go @@ -0,0 +1,109 @@ +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 + } + 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) 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, + })) + } + 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), + }) + 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) +} diff --git a/internal/repository/lms_lessons.go b/internal/repository/lms_lessons.go new file mode 100644 index 0000000..6aa9620 --- /dev/null +++ b/internal/repository/lms_lessons.go @@ -0,0 +1,113 @@ +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 + } + 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, + })) + } + 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), + }) + 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) +} diff --git a/internal/repository/lms_modules.go b/internal/repository/lms_modules.go new file mode 100644 index 0000000..d6e5c4a --- /dev/null +++ b/internal/repository/lms_modules.go @@ -0,0 +1,113 @@ +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 + } + 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) 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, + })) + } + 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), + }) + 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) +} diff --git a/internal/repository/lms_practices.go b/internal/repository/lms_practices.go new file mode 100644 index 0000000..2b8774c --- /dev/null +++ b/internal/repository/lms_practices.go @@ -0,0 +1,232 @@ +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 int64PtrToPg8(p *int64) pgtype.Int8 { + if p == nil { + return pgtype.Int8{Valid: false} + } + return pgtype.Int8{Int64: *p, Valid: true} +} + +func fromPgInt8ID(c pgtype.Int8) *int64 { + if !c.Valid { + return nil + } + v := c.Int64 + return &v +} + +func lmsPracticeToDomain(p dbgen.LmsPractice) domain.Practice { + out := domain.Practice{ + ID: p.ID, + Title: p.Title, + QuestionSetID: p.QuestionSetID, + } + if p.CourseID.Valid { + out.ParentKind = domain.ParentKindCourse + out.ParentID = p.CourseID.Int64 + } else if p.ModuleID.Valid { + out.ParentKind = domain.ParentKindModule + out.ParentID = p.ModuleID.Int64 + } else if p.LessonID.Valid { + out.ParentKind = domain.ParentKindLesson + out.ParentID = p.LessonID.Int64 + } + out.StoryDescription = fromPgText(p.StoryDescription) + out.StoryImage = fromPgText(p.StoryImage) + out.QuickTips = fromPgText(p.QuickTips) + out.PersonaID = fromPgInt8ID(p.PersonaID) + out.CreatedAt = p.CreatedAt.Time + if p.UpdatedAt.Valid { + t := p.UpdatedAt.Time + out.UpdatedAt = &t + } + return out +} + +func lmsFromListRow( + id, qid int64, title string, + cid, mid, lid pgtype.Int8, + sd, si, qt pgtype.Text, pid pgtype.Int8, + ca, ua pgtype.Timestamptz, +) domain.Practice { + return lmsPracticeToDomain(dbgen.LmsPractice{ + ID: id, + CourseID: cid, + ModuleID: mid, + LessonID: lid, + Title: title, + StoryDescription: sd, + StoryImage: si, + PersonaID: pid, + QuestionSetID: qid, + QuickTips: qt, + CreatedAt: ca, + UpdatedAt: ua, + }) +} + +// CreateLmsPractice sets exactly one of courseID, moduleID, lessonID (non-nil). +func (s *Store) CreateLmsPractice( + ctx context.Context, + in domain.CreatePracticeInput, + courseID, moduleID, lessonID *int64, +) (domain.Practice, error) { + p, err := s.queries.CreateLmsPractice(ctx, dbgen.CreateLmsPracticeParams{ + CourseID: int64PtrToPg8(courseID), + ModuleID: int64PtrToPg8(moduleID), + LessonID: int64PtrToPg8(lessonID), + Title: in.Title, + StoryDescription: toPgText(in.StoryDescription), + StoryImage: toPgText(in.StoryImage), + PersonaID: int64PtrToPg8(in.PersonaID), + QuestionSetID: in.QuestionSetID, + QuickTips: toPgText(in.QuickTips), + }) + if err != nil { + return domain.Practice{}, err + } + return lmsPracticeToDomain(p), nil +} + +func (s *Store) GetLmsPracticeByID(ctx context.Context, id int64) (domain.Practice, error) { + p, err := s.queries.GetLmsPracticeByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Practice{}, pgx.ErrNoRows + } + return domain.Practice{}, err + } + return lmsPracticeToDomain(p), nil +} + +func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error) { + rows, err := s.queries.ListLmsPracticesByCourseID(ctx, dbgen.ListLmsPracticesByCourseIDParams{ + CourseID: pgtype.Int8{Int64: courseID, Valid: true}, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, 0, err + } + if len(rows) == 0 { + return []domain.Practice{}, 0, nil + } + var total int64 + out := make([]domain.Practice, 0, len(rows)) + for i, r := range rows { + if i == 0 { + total = r.TotalCount + } + out = append(out, lmsFromListRow( + r.ID, r.QuestionSetID, r.Title, + r.CourseID, r.ModuleID, r.LessonID, + r.StoryDescription, r.StoryImage, r.QuickTips, + r.PersonaID, r.CreatedAt, r.UpdatedAt, + )) + } + return out, total, nil +} + +func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error) { + rows, err := s.queries.ListLmsPracticesByModuleID(ctx, dbgen.ListLmsPracticesByModuleIDParams{ + ModuleID: pgtype.Int8{Int64: moduleID, Valid: true}, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, 0, err + } + if len(rows) == 0 { + return []domain.Practice{}, 0, nil + } + var total int64 + out := make([]domain.Practice, 0, len(rows)) + for i, r := range rows { + if i == 0 { + total = r.TotalCount + } + out = append(out, lmsFromListRow( + r.ID, r.QuestionSetID, r.Title, + r.CourseID, r.ModuleID, r.LessonID, + r.StoryDescription, r.StoryImage, r.QuickTips, + r.PersonaID, r.CreatedAt, r.UpdatedAt, + )) + } + return out, total, nil +} + +func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error) { + rows, err := s.queries.ListLmsPracticesByLessonID(ctx, dbgen.ListLmsPracticesByLessonIDParams{ + LessonID: pgtype.Int8{Int64: lessonID, Valid: true}, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, 0, err + } + if len(rows) == 0 { + return []domain.Practice{}, 0, nil + } + var total int64 + out := make([]domain.Practice, 0, len(rows)) + for i, r := range rows { + if i == 0 { + total = r.TotalCount + } + out = append(out, lmsFromListRow( + r.ID, r.QuestionSetID, r.Title, + r.CourseID, r.ModuleID, r.LessonID, + r.StoryDescription, r.StoryImage, r.QuickTips, + r.PersonaID, r.CreatedAt, r.UpdatedAt, + )) + } + return out, total, nil +} + +func optionalInt8UpdateID(val *int64) pgtype.Int8 { + if val == nil { + return pgtype.Int8{Valid: false} + } + return pgtype.Int8{Int64: *val, Valid: true} +} + +func (s *Store) UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) { + var titleText pgtype.Text + if input.Title != nil { + titleText = pgtype.Text{String: *input.Title, Valid: true} + } else { + titleText = pgtype.Text{Valid: false} + } + qs := optionalInt8UpdateID(input.QuestionSetID) + p, err := s.queries.UpdateLmsPractice(ctx, dbgen.UpdateLmsPracticeParams{ + ID: id, + Title: titleText, + StoryDescription: optionalTextUpdate(input.StoryDescription), + StoryImage: optionalTextUpdate(input.StoryImage), + PersonaID: optionalInt8UpdateID(input.PersonaID), + QuestionSetID: qs, + QuickTips: optionalTextUpdate(input.QuickTips), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Practice{}, pgx.ErrNoRows + } + return domain.Practice{}, err + } + return lmsPracticeToDomain(p), nil +} + +func (s *Store) DeleteLmsPractice(ctx context.Context, id int64) error { + return s.queries.DeleteLmsPractice(ctx, id) +} diff --git a/internal/services/courses/service.go b/internal/services/courses/service.go new file mode 100644 index 0000000..de6af9a --- /dev/null +++ b/internal/services/courses/service.go @@ -0,0 +1,83 @@ +package courses + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "Yimaru-Backend/internal/services/programs" + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +var ErrCourseNotFound = errors.New("course not found") + +type Service struct { + courses ports.CourseStore + programs ports.ProgramStore +} + +func NewService(courses ports.CourseStore, programs ports.ProgramStore) *Service { + return &Service{courses: courses, programs: programs} +} + +func (s *Service) Create(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error) { + if _, err := s.programs.GetProgramByID(ctx, programID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Course{}, programs.ErrProgramNotFound + } + return domain.Course{}, err + } + return s.courses.CreateCourse(ctx, programID, input) +} + +func (s *Service) GetByID(ctx context.Context, id int64) (domain.Course, error) { + c, err := s.courses.GetCourseByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Course{}, ErrCourseNotFound + } + return domain.Course{}, err + } + return c, nil +} + +func (s *Service) ListByProgram(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error) { + if _, err := s.programs.GetProgramByID(ctx, programID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, 0, programs.ErrProgramNotFound + } + return nil, 0, err + } + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + return s.courses.ListCoursesByProgramID(ctx, programID, limit, offset) +} + +func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) { + c, err := s.courses.UpdateCourse(ctx, id, input) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Course{}, ErrCourseNotFound + } + return domain.Course{}, err + } + return c, nil +} + +func (s *Service) Delete(ctx context.Context, id int64) error { + if _, err := s.courses.GetCourseByID(ctx, id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrCourseNotFound + } + return err + } + return s.courses.DeleteCourse(ctx, id) +} diff --git a/internal/services/lessons/service.go b/internal/services/lessons/service.go new file mode 100644 index 0000000..2be3f72 --- /dev/null +++ b/internal/services/lessons/service.go @@ -0,0 +1,88 @@ +package lessons + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "Yimaru-Backend/internal/services/modules" + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +var ErrLessonNotFound = errors.New("lesson not found") + +type Service struct { + lessons ports.LessonStore + modules ports.ModuleStore +} + +func NewService(lessons ports.LessonStore, modules ports.ModuleStore) *Service { + return &Service{lessons: lessons, modules: modules} +} + +func (s *Service) getModuleOrErr(ctx context.Context, moduleID int64) error { + _, err := s.modules.GetModuleByID(ctx, moduleID) + if err == nil { + return nil + } + if errors.Is(err, pgx.ErrNoRows) { + return modules.ErrModuleNotFound + } + return err +} + +func (s *Service) Create(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error) { + if err := s.getModuleOrErr(ctx, moduleID); err != nil { + return domain.Lesson{}, err + } + return s.lessons.CreateLesson(ctx, moduleID, input) +} + +func (s *Service) GetByID(ctx context.Context, id int64) (domain.Lesson, error) { + l, err := s.lessons.GetLessonByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Lesson{}, ErrLessonNotFound + } + return domain.Lesson{}, err + } + return l, nil +} + +func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) { + if err := s.getModuleOrErr(ctx, moduleID); err != nil { + return nil, 0, err + } + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + return s.lessons.ListLessonsByModuleID(ctx, moduleID, limit, offset) +} + +func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) { + l, err := s.lessons.UpdateLesson(ctx, id, input) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Lesson{}, ErrLessonNotFound + } + return domain.Lesson{}, err + } + return l, nil +} + +func (s *Service) Delete(ctx context.Context, id int64) error { + if _, err := s.lessons.GetLessonByID(ctx, id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrLessonNotFound + } + return err + } + return s.lessons.DeleteLesson(ctx, id) +} diff --git a/internal/services/modules/service.go b/internal/services/modules/service.go new file mode 100644 index 0000000..f614c7b --- /dev/null +++ b/internal/services/modules/service.go @@ -0,0 +1,92 @@ +package modules + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "Yimaru-Backend/internal/services/courses" + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +var ErrModuleNotFound = errors.New("module not found") + +type Service struct { + modules ports.ModuleStore + courses ports.CourseStore +} + +func NewService(modules ports.ModuleStore, courses ports.CourseStore) *Service { + return &Service{modules: modules, courses: courses} +} + +func (s *Service) getCourseOrErr(ctx context.Context, courseID int64) (domain.Course, error) { + c, err := s.courses.GetCourseByID(ctx, courseID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Course{}, courses.ErrCourseNotFound + } + return domain.Course{}, err + } + return c, nil +} + +// Create loads the course and stores program_id from it (parent program is not taken from the URL). +func (s *Service) Create(ctx context.Context, courseID int64, input domain.CreateModuleInput) (domain.Module, error) { + c, err := s.getCourseOrErr(ctx, courseID) + if err != nil { + return domain.Module{}, err + } + return s.modules.CreateModule(ctx, c.ProgramID, courseID, input) +} + +func (s *Service) GetByID(ctx context.Context, id int64) (domain.Module, error) { + m, err := s.modules.GetModuleByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Module{}, ErrModuleNotFound + } + return domain.Module{}, err + } + return m, nil +} + +// ListByCourse loads the course and lists modules for its program_id and course_id. +func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Module, int64, error) { + c, err := s.getCourseOrErr(ctx, courseID) + if err != nil { + return nil, 0, err + } + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + return s.modules.ListModulesByProgramAndCourse(ctx, c.ProgramID, courseID, limit, offset) +} + +func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) { + m, err := s.modules.UpdateModule(ctx, id, input) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Module{}, ErrModuleNotFound + } + return domain.Module{}, err + } + return m, nil +} + +func (s *Service) Delete(ctx context.Context, id int64) error { + if _, err := s.modules.GetModuleByID(ctx, id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrModuleNotFound + } + return err + } + return s.modules.DeleteModule(ctx, id) +} diff --git a/internal/services/practices/service.go b/internal/services/practices/service.go new file mode 100644 index 0000000..8d6f7fc --- /dev/null +++ b/internal/services/practices/service.go @@ -0,0 +1,204 @@ +package practices + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/lessons" + "Yimaru-Backend/internal/services/modules" + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +var ( + ErrPracticeNotFound = errors.New("practice not found") + ErrQuestionSetNotFound = errors.New("question set not found") + ErrInvalidPracticeParent = errors.New("parent_kind and parent_id do not match an allowed parent") +) + +type Service struct { + practices ports.LmsPracticeStore + courses ports.CourseStore + modules ports.ModuleStore + lessons ports.LessonStore + qs ports.QuestionSetByID + users ports.UserByID +} + +func NewService( + practices ports.LmsPracticeStore, + courses ports.CourseStore, + modules ports.ModuleStore, + lessons ports.LessonStore, + qs ports.QuestionSetByID, + users ports.UserByID, +) *Service { + return &Service{ + practices: practices, + courses: courses, + modules: modules, + lessons: lessons, + qs: qs, + users: users, + } +} + +func (s *Service) validateQuestionSet(ctx context.Context, id int64) error { + _, err := s.qs.GetQuestionSetByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrQuestionSetNotFound + } + return err + } + return nil +} + +func (s *Service) validatePersonaUser(ctx context.Context, id int64) error { + _, err := s.users.GetUserByID(ctx, id) + if err != nil { + if errors.Is(err, domain.ErrUserNotFound) { + return domain.ErrUserNotFound + } + return err + } + return nil +} + +func (s *Service) resolveParent(ctx context.Context, in domain.CreatePracticeInput) (courseID, moduleID, lessonID *int64, err error) { + pid := in.ParentID + switch in.ParentKind { + case domain.ParentKindCourse: + if _, e := s.courses.GetCourseByID(ctx, pid); e != nil { + if errors.Is(e, pgx.ErrNoRows) { + return nil, nil, nil, courses.ErrCourseNotFound + } + return nil, nil, nil, e + } + return &pid, nil, nil, nil + case domain.ParentKindModule: + if _, e := s.modules.GetModuleByID(ctx, pid); e != nil { + if errors.Is(e, pgx.ErrNoRows) { + return nil, nil, nil, modules.ErrModuleNotFound + } + return nil, nil, nil, e + } + return nil, &pid, nil, nil + case domain.ParentKindLesson: + if _, e := s.lessons.GetLessonByID(ctx, pid); e != nil { + if errors.Is(e, pgx.ErrNoRows) { + return nil, nil, nil, lessons.ErrLessonNotFound + } + return nil, nil, nil, e + } + return nil, nil, &pid, nil + default: + return nil, nil, nil, ErrInvalidPracticeParent + } +} + +func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (domain.Practice, error) { + if err := s.validateQuestionSet(ctx, in.QuestionSetID); err != nil { + return domain.Practice{}, err + } + if in.PersonaID != nil { + if err := s.validatePersonaUser(ctx, *in.PersonaID); err != nil { + return domain.Practice{}, err + } + } + courseID, moduleID, lessonID, err := s.resolveParent(ctx, in) + if err != nil { + return domain.Practice{}, err + } + return s.practices.CreateLmsPractice(ctx, in, courseID, moduleID, lessonID) +} + +func (s *Service) GetByID(ctx context.Context, id int64) (domain.Practice, error) { + p, err := s.practices.GetLmsPracticeByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Practice{}, ErrPracticeNotFound + } + return domain.Practice{}, err + } + return p, nil +} + +func clampPracticePage(limit, offset int32) (int32, int32) { + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + return limit, offset +} + +func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error) { + if _, err := s.courses.GetCourseByID(ctx, courseID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, 0, courses.ErrCourseNotFound + } + return nil, 0, err + } + limit, offset = clampPracticePage(limit, offset) + return s.practices.ListLmsPracticesByCourseID(ctx, courseID, limit, offset) +} + +func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error) { + if _, err := s.modules.GetModuleByID(ctx, moduleID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, 0, modules.ErrModuleNotFound + } + return nil, 0, err + } + limit, offset = clampPracticePage(limit, offset) + return s.practices.ListLmsPracticesByModuleID(ctx, moduleID, limit, offset) +} + +func (s *Service) ListByLesson(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error) { + if _, err := s.lessons.GetLessonByID(ctx, lessonID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, 0, lessons.ErrLessonNotFound + } + return nil, 0, err + } + limit, offset = clampPracticePage(limit, offset) + return s.practices.ListLmsPracticesByLessonID(ctx, lessonID, limit, offset) +} + +func (s *Service) Update(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) { + if input.QuestionSetID != nil { + if err := s.validateQuestionSet(ctx, *input.QuestionSetID); err != nil { + return domain.Practice{}, err + } + } + if input.PersonaID != nil { + if err := s.validatePersonaUser(ctx, *input.PersonaID); err != nil { + return domain.Practice{}, err + } + } + p, err := s.practices.UpdateLmsPractice(ctx, id, input) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Practice{}, ErrPracticeNotFound + } + return domain.Practice{}, err + } + return p, nil +} + +func (s *Service) Delete(ctx context.Context, id int64) error { + if _, err := s.practices.GetLmsPracticeByID(ctx, id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrPracticeNotFound + } + return err + } + return s.practices.DeleteLmsPractice(ctx, id) +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 93dfd15..2519c21 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -15,6 +15,7 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "courses.create", Name: "Create Course", Description: "Create a new course", GroupName: "Courses"}, {Key: "courses.get", Name: "Get Course", Description: "Get a course by ID", GroupName: "Courses"}, {Key: "courses.list_by_category", Name: "List Courses by Category", Description: "List courses by category", GroupName: "Courses"}, + {Key: "courses.list_by_program", Name: "List Courses by Program", Description: "List courses under a program", GroupName: "Courses"}, {Key: "courses.update", Name: "Update Course", Description: "Update a course", GroupName: "Courses"}, {Key: "courses.upload_thumbnail", Name: "Upload Course Thumbnail", Description: "Upload course thumbnail image", GroupName: "Courses"}, {Key: "courses.delete", Name: "Delete Course", Description: "Delete a course", GroupName: "Courses"}, @@ -27,6 +28,27 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "programs.update", Name: "Update Program", Description: "Update a program", GroupName: "Programs"}, {Key: "programs.delete", Name: "Delete Program", Description: "Delete a program", GroupName: "Programs"}, + // Modules (LMS, under a course) + {Key: "modules.create", Name: "Create Module", Description: "Create a module in a course", GroupName: "Modules"}, + {Key: "modules.get", Name: "Get Module", Description: "Get a module by ID", GroupName: "Modules"}, + {Key: "modules.list_by_course", Name: "List Modules by Course", Description: "List modules under a program and course", GroupName: "Modules"}, + {Key: "modules.update", Name: "Update Module", Description: "Update a module", GroupName: "Modules"}, + {Key: "modules.delete", Name: "Delete Module", Description: "Delete a module", GroupName: "Modules"}, + + // Lessons (LMS, under a module) + {Key: "lessons.create", Name: "Create Lesson", Description: "Create a lesson in a module", GroupName: "Lessons"}, + {Key: "lessons.get", Name: "Get Lesson", Description: "Get a lesson by ID", GroupName: "Lessons"}, + {Key: "lessons.list_by_module", Name: "List Lessons by Module", Description: "List lessons under a module", GroupName: "Lessons"}, + {Key: "lessons.update", Name: "Update Lesson", Description: "Update a lesson", GroupName: "Lessons"}, + {Key: "lessons.delete", Name: "Delete Lesson", Description: "Delete a lesson", GroupName: "Lessons"}, + + // Practices (LMS, scoped to course, module, or lesson) + {Key: "practices.create", Name: "Create Practice", Description: "Create a practice", GroupName: "Practices"}, + {Key: "practices.get", Name: "Get Practice", Description: "Get a practice by ID", GroupName: "Practices"}, + {Key: "practices.list", Name: "List Practices", Description: "List practices by course, module, or lesson", GroupName: "Practices"}, + {Key: "practices.update", Name: "Update Practice", Description: "Update a practice", GroupName: "Practices"}, + {Key: "practices.delete", Name: "Delete Practice", Description: "Delete a practice", GroupName: "Practices"}, + // Course Management - Sub-courses {Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"}, {Key: "subcourses.get", Name: "Get Sub-course", Description: "Get a sub-course by ID", GroupName: "Sub-courses"}, @@ -243,7 +265,7 @@ var DefaultRolePermissions = map[string][]string{ "ADMIN": { // Course Management (full access) "course_categories.create", "course_categories.list", "course_categories.get", "course_categories.update", "course_categories.delete", "course_categories.reorder", - "courses.create", "courses.get", "courses.list_by_category", "courses.update", "courses.upload_thumbnail", "courses.delete", "courses.reorder", + "courses.create", "courses.get", "courses.list_by_category", "courses.list_by_program", "courses.update", "courses.upload_thumbnail", "courses.delete", "courses.reorder", "subcourses.create", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "subcourses.update", "subcourses.upload_thumbnail", "subcourses.deactivate", "subcourses.delete", "subcourses.reorder", "videos.create", "videos.create_vimeo", "videos.upload", "videos.import_vimeo", "videos.get", @@ -253,6 +275,15 @@ var DefaultRolePermissions = map[string][]string{ // Programs "programs.create", "programs.list", "programs.get", "programs.update", "programs.delete", + // Modules + "modules.create", "modules.get", "modules.list_by_course", "modules.update", "modules.delete", + + // Lessons + "lessons.create", "lessons.get", "lessons.list_by_module", "lessons.update", "lessons.delete", + + // Practices + "practices.create", "practices.get", "practices.list", "practices.update", "practices.delete", + // Questions (full access) "questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", "question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete", @@ -327,7 +358,10 @@ var DefaultRolePermissions = map[string][]string{ "STUDENT": { // Course browsing "course_categories.list", "course_categories.get", - "courses.get", "courses.list_by_category", + "courses.get", "courses.list_by_program", + "modules.get", "modules.list_by_course", + "lessons.get", "lessons.list_by_module", + "practices.get", "practices.list", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "videos.get", "videos.list_by_subcourse", "videos.list_published", "learning_tree.get", @@ -377,7 +411,10 @@ var DefaultRolePermissions = map[string][]string{ "INSTRUCTOR": { // Course browsing + management "course_categories.list", "course_categories.get", - "courses.get", "courses.list_by_category", + "courses.get", "courses.list_by_program", + "modules.get", "modules.list_by_course", + "lessons.get", "lessons.list_by_module", + "practices.get", "practices.list", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "videos.get", "videos.list_by_subcourse", "videos.list_published", "learning_tree.get", @@ -425,7 +462,10 @@ var DefaultRolePermissions = map[string][]string{ "SUPPORT": { // Course browsing (read-only) "course_categories.list", "course_categories.get", - "courses.get", "courses.list_by_category", + "courses.get", "courses.list_by_program", + "modules.get", "modules.list_by_course", + "lessons.get", "lessons.list_by_module", + "practices.get", "practices.list", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "videos.get", "videos.list_by_subcourse", "videos.list_published", "learning_tree.get", diff --git a/internal/web_server/app.go b/internal/web_server/app.go index a454cc2..2524588 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -11,6 +11,10 @@ import ( minioservice "Yimaru-Backend/internal/services/minio" issuereporting "Yimaru-Backend/internal/services/issue_reporting" notificationservice "Yimaru-Backend/internal/services/notification" + "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/lessons" + "Yimaru-Backend/internal/services/modules" + "Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" ratingsservice "Yimaru-Backend/internal/services/ratings" @@ -41,6 +45,10 @@ type App struct { assessmentSvc *assessment.Service questionsSvc *questions.Service programSvc *programs.Service + courseSvc *courses.Service + moduleSvc *modules.Service + lessonSvc *lessons.Service + practiceSvc *practices.Service subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService issueReportingSvc *issuereporting.Service @@ -73,6 +81,10 @@ func NewApp( assessmentSvc *assessment.Service, questionsSvc *questions.Service, programSvc *programs.Service, + courseSvc *courses.Service, + moduleSvc *modules.Service, + lessonSvc *lessons.Service, + practiceSvc *practices.Service, subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, issueReportingSvc *issuereporting.Service, @@ -117,6 +129,10 @@ func NewApp( assessmentSvc: assessmentSvc, questionsSvc: questionsSvc, programSvc: programSvc, + courseSvc: courseSvc, + moduleSvc: moduleSvc, + lessonSvc: lessonSvc, + practiceSvc: practiceSvc, subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, vimeoSvc: vimeoSvc, diff --git a/internal/web_server/handlers/course_handler.go b/internal/web_server/handlers/course_handler.go new file mode 100644 index 0000000..cb53b55 --- /dev/null +++ b/internal/web_server/handlers/course_handler.go @@ -0,0 +1,249 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/programs" + "context" + "errors" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// CreateCourse godoc +// @Summary Create course +// @Description Create a course under a program +// @Tags courses +// @Accept json +// @Produce json +// @Param id path int true "Program ID" +// @Param body body domain.CreateCourseInput true "Course" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/programs/{id}/courses [post] +func (h *Handler) CreateCourse(c *fiber.Ctx) error { + programID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid program id", + Error: err.Error(), + }) + } + var req domain.CreateCourseInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: firstValidationError(valErrs), + }) + } + course, err := h.courseSvc.Create(c.Context(), programID, req) + if err != nil { + if errors.Is(err, programs.ErrProgramNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Program not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create course", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + rid := course.ID + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseCreated, domain.ResourceCourse, &rid, "Created course: "+course.Name, nil, &ip, &ua) + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Course created successfully", + Data: course, + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// ListCoursesByProgram godoc +// @Summary List courses by program +// @Tags courses +// @Produce json +// @Param id path int true "Program ID" +// @Param limit query int false "Page size" default(20) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} domain.Response +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/programs/{id}/courses [get] +func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error { + programID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid program id", + Error: err.Error(), + }) + } + limit, _ := strconv.Atoi(c.Query("limit", "20")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + items, total, err := h.courseSvc.ListByProgram(c.Context(), programID, int32(limit), int32(offset)) + if err != nil { + if errors.Is(err, programs.ErrProgramNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Program not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to list courses", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Courses retrieved successfully", + Data: fiber.Map{ + "courses": items, + "total_count": total, + "limit": limit, + "offset": offset, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetCourse godoc +// @Summary Get course by ID +// @Tags courses +// @Produce json +// @Param id path int true "Course ID" +// @Success 200 {object} domain.Response +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/courses/{id} [get] +func (h *Handler) GetCourse(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid course id", + Error: err.Error(), + }) + } + course, err := h.courseSvc.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, courses.ErrCourseNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Course not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load course", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Course retrieved successfully", + Data: course, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// UpdateCourse godoc +// @Summary Update course +// @Tags courses +// @Accept json +// @Produce json +// @Param id path int true "Course ID" +// @Param body body domain.UpdateCourseInput true "Fields to update" +// @Success 200 {object} domain.Response +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/courses/{id} [put] +func (h *Handler) UpdateCourse(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid course id", + Error: err.Error(), + }) + } + var req domain.UpdateCourseInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + course, err := h.courseSvc.Update(c.Context(), id, req) + if err != nil { + if errors.Is(err, courses.ErrCourseNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Course not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update course", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + rid := course.ID + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, &rid, "Updated course: "+course.Name, nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Course updated successfully", + Data: course, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// DeleteCourse godoc +// @Summary Delete course +// @Tags courses +// @Param id path int true "Course ID" +// @Success 200 {object} domain.Response +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/courses/{id} [delete] +func (h *Handler) DeleteCourse(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid course id", + Error: err.Error(), + }) + } + if err := h.courseSvc.Delete(c.Context(), id); err != nil { + if errors.Is(err, courses.ErrCourseNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Course not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete course", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseDeleted, domain.ResourceCourse, &id, "Deleted course", nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Course deleted successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 4cea6fa..f803694 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -16,6 +16,10 @@ import ( ratingsservice "Yimaru-Backend/internal/services/ratings" rbacservice "Yimaru-Backend/internal/services/rbac" notificationservice "Yimaru-Backend/internal/services/notification" + "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/lessons" + "Yimaru-Backend/internal/services/modules" + "Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/recommendation" @@ -40,6 +44,10 @@ type Handler struct { assessmentSvc *assessment.Service questionsSvc *questions.Service programSvc *programs.Service + courseSvc *courses.Service + moduleSvc *modules.Service + lessonSvc *lessons.Service + practiceSvc *practices.Service subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService logger *slog.Logger @@ -68,6 +76,10 @@ func New( assessmentSvc *assessment.Service, questionsSvc *questions.Service, programSvc *programs.Service, + courseSvc *courses.Service, + moduleSvc *modules.Service, + lessonSvc *lessons.Service, + practiceSvc *practices.Service, subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, logger *slog.Logger, @@ -95,6 +107,10 @@ func New( assessmentSvc: assessmentSvc, questionsSvc: questionsSvc, programSvc: programSvc, + courseSvc: courseSvc, + moduleSvc: moduleSvc, + lessonSvc: lessonSvc, + practiceSvc: practiceSvc, subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, logger: logger, diff --git a/internal/web_server/handlers/lesson_handler.go b/internal/web_server/handlers/lesson_handler.go new file mode 100644 index 0000000..174a677 --- /dev/null +++ b/internal/web_server/handlers/lesson_handler.go @@ -0,0 +1,229 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/services/lessons" + "Yimaru-Backend/internal/services/modules" + "context" + "errors" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// CreateLesson godoc +// @Summary Create lesson +// @Tags lessons +// @Accept json +// @Produce json +// @Param moduleId path int true "Module ID" +// @Param body body domain.CreateLessonInput true "Lesson" +// @Router /api/v1/modules/{moduleId}/lessons [post] +func (h *Handler) CreateLesson(c *fiber.Ctx) error { + moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid module id", + Error: err.Error(), + }) + } + var req domain.CreateLessonInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: firstValidationError(valErrs), + }) + } + les, err := h.lessonSvc.Create(c.Context(), moduleID, req) + if err != nil { + if errors.Is(err, modules.ErrModuleNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Module not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create lesson", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + rid := les.ID + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionLessonCreated, domain.ResourceLesson, &rid, "Created lesson: "+les.Title, nil, &ip, &ua) + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Lesson created successfully", + Data: les, + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// ListLessonsByModule godoc +// @Tags lessons +// @Param moduleId path int true "Module ID" +// @Param limit query int false "Page size" default(20) +// @Param offset query int false "Offset" default(0) +// @Router /api/v1/modules/{moduleId}/lessons [get] +func (h *Handler) ListLessonsByModule(c *fiber.Ctx) error { + moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid module id", + Error: err.Error(), + }) + } + limit, _ := strconv.Atoi(c.Query("limit", "20")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + items, total, err := h.lessonSvc.ListByModule(c.Context(), moduleID, int32(limit), int32(offset)) + if err != nil { + if errors.Is(err, modules.ErrModuleNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Module not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to list lessons", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Lessons retrieved successfully", + Data: fiber.Map{ + "lessons": items, + "total_count": total, + "limit": limit, + "offset": offset, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetLesson godoc +// @Tags lessons +// @Param id path int true "Lesson ID" +// @Router /api/v1/lessons/{id} [get] +func (h *Handler) GetLesson(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid lesson id", + Error: err.Error(), + }) + } + les, err := h.lessonSvc.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, lessons.ErrLessonNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Lesson not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load lesson", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Lesson retrieved successfully", + Data: les, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// UpdateLesson godoc +// @Tags lessons +// @Param id path int true "Lesson ID" +// @Param body body domain.UpdateLessonInput true "Fields to update" +// @Router /api/v1/lessons/{id} [put] +func (h *Handler) UpdateLesson(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid lesson id", + Error: err.Error(), + }) + } + var req domain.UpdateLessonInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + les, err := h.lessonSvc.Update(c.Context(), id, req) + if err != nil { + if errors.Is(err, lessons.ErrLessonNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Lesson not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update lesson", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + rid := les.ID + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionLessonUpdated, domain.ResourceLesson, &rid, "Updated lesson: "+les.Title, nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Lesson updated successfully", + Data: les, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// DeleteLesson godoc +// @Tags lessons +// @Param id path int true "Lesson ID" +// @Router /api/v1/lessons/{id} [delete] +func (h *Handler) DeleteLesson(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid lesson id", + Error: err.Error(), + }) + } + if err := h.lessonSvc.Delete(c.Context(), id); err != nil { + if errors.Is(err, lessons.ErrLessonNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Lesson not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete lesson", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionLessonDeleted, domain.ResourceLesson, &id, "Deleted lesson", nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Lesson deleted successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/handlers/module_handler.go b/internal/web_server/handlers/module_handler.go new file mode 100644 index 0000000..0160077 --- /dev/null +++ b/internal/web_server/handlers/module_handler.go @@ -0,0 +1,236 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/modules" + "context" + "errors" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// CreateModule godoc +// @Summary Create module +// @Description Create a module under a course; parent program is taken from the course. +// @Tags modules +// @Accept json +// @Produce json +// @Param courseId path int true "Course ID" +// @Param body body domain.CreateModuleInput true "Module" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Router /api/v1/courses/{courseId}/modules [post] +func (h *Handler) CreateModule(c *fiber.Ctx) error { + courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid course id", + Error: err.Error(), + }) + } + var req domain.CreateModuleInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: firstValidationError(valErrs), + }) + } + mod, err := h.moduleSvc.Create(c.Context(), courseID, req) + if err != nil { + if errors.Is(err, courses.ErrCourseNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Course not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create module", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + rid := mod.ID + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionModuleCreated, domain.ResourceModule, &rid, "Created module: "+mod.Name, nil, &ip, &ua) + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Module created successfully", + Data: mod, + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// ListModulesByCourse godoc +// @Summary List modules for a course +// @Tags modules +// @Produce json +// @Param courseId path int true "Course ID" +// @Param limit query int false "Page size" default(20) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} domain.Response +// @Router /api/v1/courses/{courseId}/modules [get] +func (h *Handler) ListModulesByCourse(c *fiber.Ctx) error { + courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid course id", + Error: err.Error(), + }) + } + limit, _ := strconv.Atoi(c.Query("limit", "20")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + items, total, err := h.moduleSvc.ListByCourse(c.Context(), courseID, int32(limit), int32(offset)) + if err != nil { + if errors.Is(err, courses.ErrCourseNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Course not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to list modules", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Modules retrieved successfully", + Data: fiber.Map{ + "modules": items, + "total_count": total, + "limit": limit, + "offset": offset, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetModule godoc +// @Tags modules +// @Param id path int true "Module ID" +// @Success 200 {object} domain.Response +// @Router /api/v1/modules/{id} [get] +func (h *Handler) GetModule(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid module id", + Error: err.Error(), + }) + } + mod, err := h.moduleSvc.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, modules.ErrModuleNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Module not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load module", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Module retrieved successfully", + Data: mod, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// UpdateModule godoc +// @Tags modules +// @Param id path int true "Module ID" +// @Param body body domain.UpdateModuleInput true "Fields to update" +// @Router /api/v1/modules/{id} [put] +func (h *Handler) UpdateModule(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid module id", + Error: err.Error(), + }) + } + var req domain.UpdateModuleInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + mod, err := h.moduleSvc.Update(c.Context(), id, req) + if err != nil { + if errors.Is(err, modules.ErrModuleNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Module not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update module", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + rid := mod.ID + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionModuleUpdated, domain.ResourceModule, &rid, "Updated module: "+mod.Name, nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Module updated successfully", + Data: mod, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// DeleteModule godoc +// @Tags modules +// @Param id path int true "Module ID" +// @Router /api/v1/modules/{id} [delete] +func (h *Handler) DeleteModule(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid module id", + Error: err.Error(), + }) + } + if err := h.moduleSvc.Delete(c.Context(), id); err != nil { + if errors.Is(err, modules.ErrModuleNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Module not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete module", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionModuleDeleted, domain.ResourceModule, &id, "Deleted module", nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Module deleted successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/handlers/practice_handler.go b/internal/web_server/handlers/practice_handler.go new file mode 100644 index 0000000..1cca038 --- /dev/null +++ b/internal/web_server/handlers/practice_handler.go @@ -0,0 +1,237 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/lessons" + "Yimaru-Backend/internal/services/modules" + "Yimaru-Backend/internal/services/practices" + "context" + "errors" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// CreatePractice godoc +// @Tags practices +// @Accept json +// @Param body body domain.CreatePracticeInput true "Practice (parent_kind: COURSE | MODULE | LESSON)" +// @Router /api/v1/practices [post] +func (h *Handler) CreatePractice(c *fiber.Ctx) error { + var req domain.CreatePracticeInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: firstValidationError(valErrs), + }) + } + p, err := h.practiceSvc.Create(c.Context(), req) + if err != nil { + switch { + case errors.Is(err, courses.ErrCourseNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Course not found", Error: err.Error()}) + case errors.Is(err, modules.ErrModuleNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module not found", Error: err.Error()}) + case errors.Is(err, lessons.ErrLessonNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Lesson not found", Error: err.Error()}) + case errors.Is(err, practices.ErrQuestionSetNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Question set not found", Error: err.Error()}) + case errors.Is(err, domain.ErrUserNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona user not found", Error: err.Error()}) + case errors.Is(err, practices.ErrInvalidPracticeParent): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid parent", Error: err.Error()}) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create practice", Error: err.Error()}) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + rid := p.ID + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionPracticeCreated, domain.ResourcePractice, &rid, "Created practice: "+p.Title, nil, &ip, &ua) + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Practice created successfully", + Data: p, + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// ListPracticesByCourse godoc +// @Tags practices +// @Param id path int true "Course ID" +// @Router /api/v1/courses/{id}/practices [get] +func (h *Handler) ListPracticesByCourse(c *fiber.Ctx) error { + courseID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course id", Error: err.Error()}) + } + limit, _ := strconv.Atoi(c.Query("limit", "20")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + items, total, err := h.practiceSvc.ListByCourse(c.Context(), courseID, int32(limit), int32(offset)) + if err != nil { + if errors.Is(err, courses.ErrCourseNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Course not found", Error: err.Error()}) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()}) + } + return c.JSON(domain.Response{ + Message: "Practices retrieved successfully", + Data: fiber.Map{ + "practices": items, + "total_count": total, + "limit": limit, + "offset": offset, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// ListPracticesByModule godoc +// @Tags practices +// @Param id path int true "Module ID" +// @Router /api/v1/modules/{id}/practices [get] +func (h *Handler) ListPracticesByModule(c *fiber.Ctx) error { + moduleID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid module id", Error: err.Error()}) + } + limit, _ := strconv.Atoi(c.Query("limit", "20")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + items, total, err := h.practiceSvc.ListByModule(c.Context(), moduleID, int32(limit), int32(offset)) + if err != nil { + if errors.Is(err, modules.ErrModuleNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module not found", Error: err.Error()}) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()}) + } + return c.JSON(domain.Response{ + Message: "Practices retrieved successfully", + Data: fiber.Map{ + "practices": items, + "total_count": total, + "limit": limit, + "offset": offset, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// ListPracticesByLesson godoc +// @Tags practices +// @Param id path int true "Lesson ID" +// @Router /api/v1/lessons/{id}/practices [get] +func (h *Handler) ListPracticesByLesson(c *fiber.Ctx) error { + lessonID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid lesson id", Error: err.Error()}) + } + limit, _ := strconv.Atoi(c.Query("limit", "20")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + items, total, err := h.practiceSvc.ListByLesson(c.Context(), lessonID, int32(limit), int32(offset)) + if err != nil { + if errors.Is(err, lessons.ErrLessonNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Lesson not found", Error: err.Error()}) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()}) + } + return c.JSON(domain.Response{ + Message: "Practices retrieved successfully", + Data: fiber.Map{ + "practices": items, + "total_count": total, + "limit": limit, + "offset": offset, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetPractice godoc +// @Tags practices +// @Param id path int true "Practice ID" +// @Router /api/v1/practices/{id} [get] +func (h *Handler) GetPractice(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid practice id", Error: err.Error()}) + } + p, err := h.practiceSvc.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, practices.ErrPracticeNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found", Error: err.Error()}) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load practice", Error: err.Error()}) + } + return c.JSON(domain.Response{Message: "Practice retrieved successfully", Data: p, Success: true, StatusCode: fiber.StatusOK}) +} + +// UpdatePractice godoc +// @Tags practices +// @Param id path int true "Practice ID" +// @Param body body domain.UpdatePracticeInput true "Fields to update (parent is immutable)" +// @Router /api/v1/practices/{id} [put] +func (h *Handler) UpdatePractice(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid practice id", Error: err.Error()}) + } + var req domain.UpdatePracticeInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + p, err := h.practiceSvc.Update(c.Context(), id, req) + if err != nil { + if errors.Is(err, practices.ErrPracticeNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found", Error: err.Error()}) + } + if errors.Is(err, practices.ErrQuestionSetNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Question set not found", Error: err.Error()}) + } + if errors.Is(err, domain.ErrUserNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona user not found", Error: err.Error()}) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update practice", Error: err.Error()}) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + rid := p.ID + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionPracticeUpdated, domain.ResourcePractice, &rid, "Updated practice: "+p.Title, nil, &ip, &ua) + return c.JSON(domain.Response{Message: "Practice updated successfully", Data: p, Success: true, StatusCode: fiber.StatusOK}) +} + +// DeletePractice godoc +// @Tags practices +// @Param id path int true "Practice ID" +// @Router /api/v1/practices/{id} [delete] +func (h *Handler) DeletePractice(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid practice id", Error: err.Error()}) + } + if err := h.practiceSvc.Delete(c.Context(), id); err != nil { + if errors.Is(err, practices.ErrPracticeNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found", Error: err.Error()}) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete practice", Error: err.Error()}) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionPracticeDeleted, domain.ResourcePractice, &id, "Deleted practice", nil, &ip, &ua) + return c.JSON(domain.Response{Message: "Practice deleted successfully", Success: true, StatusCode: fiber.StatusOK}) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 2b56bb0..223e49f 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -16,6 +16,10 @@ func (a *App) initAppRoutes() { a.assessmentSvc, a.questionsSvc, a.programSvc, + a.courseSvc, + a.moduleSvc, + a.lessonSvc, + a.practiceSvc, a.subscriptionsSvc, a.arifpaySvc, a.logger, @@ -74,6 +78,33 @@ func (a *App) initAppRoutes() { groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram) groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram) + // Courses + groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) + groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram) + groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByCourse) + groupV1.Get("/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourse) + groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse) + groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse) + groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule) + groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse) + + // /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id + groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson) + groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule) + groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByModule) + groupV1.Get("/modules/:id", a.authMiddleware, a.RequirePermission("modules.get"), h.GetModule) + groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule) + groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule) + groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByLesson) + groupV1.Get("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.get"), h.GetLesson) + groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson) + groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson) + + groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice) + groupV1.Get("/practices/:id", a.authMiddleware, a.RequirePermission("practices.get"), h.GetPractice) + groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice) + groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice) + // File storage (MinIO) groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL) groupV1.Post("/files/upload", a.authMiddleware, h.UploadMedia)