module+lesson+practice implementations

This commit is contained in:
Yared Yemane 2026-04-23 01:59:20 -07:00
parent 152478a96c
commit 9db9c9899a
45 changed files with 3607 additions and 4 deletions

View File

@ -17,6 +17,10 @@ import (
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
"Yimaru-Backend/internal/services/messenger" "Yimaru-Backend/internal/services/messenger"
notificationservice "Yimaru-Backend/internal/services/notification" 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" programsservice "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
"Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/recommendation"
@ -391,6 +395,18 @@ func main() {
// LMS programs (top-level hierarchy) // LMS programs (top-level hierarchy)
programSvc := programsservice.NewService(store) 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 // Subscriptions service
subscriptionsSvc := subscriptions.NewService(store) subscriptionsSvc := subscriptions.NewService(store)
@ -433,6 +449,10 @@ func main() {
assessmentSvc, assessmentSvc,
questionsSvc, questionsSvc,
programSvc, programSvc,
courseSvc,
moduleSvc,
lessonSvc,
practiceSvc,
subscriptionsSvc, subscriptionsSvc,
arifpaySvc, arifpaySvc,
issueReportingSvc, issueReportingSvc,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

View File

@ -22,6 +22,16 @@ type ActivityLog struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` 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 { type Device struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
@ -39,11 +49,48 @@ type GlobalSetting struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` 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 { type LevelToSubCourse struct {
LevelID int64 `json:"level_id"` LevelID int64 `json:"level_id"`
SubCourseID int64 `json:"sub_course_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 { type ModuleToSubCourse struct {
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
SubCourseID int64 `json:"sub_course_id"` SubCourseID int64 `json:"sub_course_id"`

View File

@ -48,6 +48,15 @@ const (
ActionProgramCreated ActivityAction = "PROGRAM_CREATED" ActionProgramCreated ActivityAction = "PROGRAM_CREATED"
ActionProgramUpdated ActivityAction = "PROGRAM_UPDATED" ActionProgramUpdated ActivityAction = "PROGRAM_UPDATED"
ActionProgramDeleted ActivityAction = "PROGRAM_DELETED" 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 type ResourceType string
@ -66,6 +75,9 @@ const (
ResourceQuestionSet ResourceType = "QUESTION_SET" ResourceQuestionSet ResourceType = "QUESTION_SET"
ResourceIssue ResourceType = "ISSUE" ResourceIssue ResourceType = "ISSUE"
ResourceProgram ResourceType = "PROGRAM" ResourceProgram ResourceType = "PROGRAM"
ResourceModule ResourceType = "MODULE"
ResourceLesson ResourceType = "LESSON"
ResourcePractice ResourceType = "PRACTICE"
) )
type ActivityLog struct { type ActivityLog struct {

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

@ -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"`
}

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

@ -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"`
}

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

@ -0,0 +1,27 @@
package domain
import "time"
// Module belongs to a Course. program_id is the courses program (stored for querying; not required from the client on create).
type Module struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
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"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "courses.create", Name: "Create Course", Description: "Create a new course", GroupName: "Courses"}, {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.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_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.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.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"}, {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.update", Name: "Update Program", Description: "Update a program", GroupName: "Programs"},
{Key: "programs.delete", Name: "Delete Program", Description: "Delete 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 // Course Management - Sub-courses
{Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "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"}, {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": { "ADMIN": {
// Course Management (full access) // Course Management (full access)
"course_categories.create", "course_categories.list", "course_categories.get", "course_categories.update", "course_categories.delete", "course_categories.reorder", "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.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", "subcourses.update", "subcourses.upload_thumbnail", "subcourses.deactivate", "subcourses.delete", "subcourses.reorder",
"videos.create", "videos.create_vimeo", "videos.upload", "videos.import_vimeo", "videos.get", "videos.create", "videos.create_vimeo", "videos.upload", "videos.import_vimeo", "videos.get",
@ -253,6 +275,15 @@ var DefaultRolePermissions = map[string][]string{
// Programs // Programs
"programs.create", "programs.list", "programs.get", "programs.update", "programs.delete", "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 (full access)
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", "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", "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": { "STUDENT": {
// Course browsing // Course browsing
"course_categories.list", "course_categories.get", "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", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get", "learning_tree.get",
@ -377,7 +411,10 @@ var DefaultRolePermissions = map[string][]string{
"INSTRUCTOR": { "INSTRUCTOR": {
// Course browsing + management // Course browsing + management
"course_categories.list", "course_categories.get", "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", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get", "learning_tree.get",
@ -425,7 +462,10 @@ var DefaultRolePermissions = map[string][]string{
"SUPPORT": { "SUPPORT": {
// Course browsing (read-only) // Course browsing (read-only)
"course_categories.list", "course_categories.get", "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", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get", "learning_tree.get",

View File

@ -11,6 +11,10 @@ import (
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
notificationservice "Yimaru-Backend/internal/services/notification" 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/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
@ -41,6 +45,10 @@ type App struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
questionsSvc *questions.Service questionsSvc *questions.Service
programSvc *programs.Service programSvc *programs.Service
courseSvc *courses.Service
moduleSvc *modules.Service
lessonSvc *lessons.Service
practiceSvc *practices.Service
subscriptionsSvc *subscriptions.Service subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
issueReportingSvc *issuereporting.Service issueReportingSvc *issuereporting.Service
@ -73,6 +81,10 @@ func NewApp(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
programSvc *programs.Service, programSvc *programs.Service,
courseSvc *courses.Service,
moduleSvc *modules.Service,
lessonSvc *lessons.Service,
practiceSvc *practices.Service,
subscriptionsSvc *subscriptions.Service, subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
issueReportingSvc *issuereporting.Service, issueReportingSvc *issuereporting.Service,
@ -117,6 +129,10 @@ func NewApp(
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
programSvc: programSvc, programSvc: programSvc,
courseSvc: courseSvc,
moduleSvc: moduleSvc,
lessonSvc: lessonSvc,
practiceSvc: practiceSvc,
subscriptionsSvc: subscriptionsSvc, subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,
vimeoSvc: vimeoSvc, vimeoSvc: vimeoSvc,

View File

@ -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,
})
}

View File

@ -16,6 +16,10 @@ import (
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
rbacservice "Yimaru-Backend/internal/services/rbac" rbacservice "Yimaru-Backend/internal/services/rbac"
notificationservice "Yimaru-Backend/internal/services/notification" 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/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
"Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/recommendation"
@ -40,6 +44,10 @@ type Handler struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
questionsSvc *questions.Service questionsSvc *questions.Service
programSvc *programs.Service programSvc *programs.Service
courseSvc *courses.Service
moduleSvc *modules.Service
lessonSvc *lessons.Service
practiceSvc *practices.Service
subscriptionsSvc *subscriptions.Service subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
logger *slog.Logger logger *slog.Logger
@ -68,6 +76,10 @@ func New(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
programSvc *programs.Service, programSvc *programs.Service,
courseSvc *courses.Service,
moduleSvc *modules.Service,
lessonSvc *lessons.Service,
practiceSvc *practices.Service,
subscriptionsSvc *subscriptions.Service, subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
logger *slog.Logger, logger *slog.Logger,
@ -95,6 +107,10 @@ func New(
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
programSvc: programSvc, programSvc: programSvc,
courseSvc: courseSvc,
moduleSvc: moduleSvc,
lessonSvc: lessonSvc,
practiceSvc: practiceSvc,
subscriptionsSvc: subscriptionsSvc, subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,
logger: logger, logger: logger,

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

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

View File

@ -16,6 +16,10 @@ func (a *App) initAppRoutes() {
a.assessmentSvc, a.assessmentSvc,
a.questionsSvc, a.questionsSvc,
a.programSvc, a.programSvc,
a.courseSvc,
a.moduleSvc,
a.lessonSvc,
a.practiceSvc,
a.subscriptionsSvc, a.subscriptionsSvc,
a.arifpaySvc, a.arifpaySvc,
a.logger, a.logger,
@ -74,6 +78,33 @@ func (a *App) initAppRoutes() {
groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram) groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram) 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) // File storage (MinIO)
groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL) groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL)
groupV1.Post("/files/upload", a.authMiddleware, h.UploadMedia) groupV1.Post("/files/upload", a.authMiddleware, h.UploadMedia)