From 1026354c24d692fe37c7014a389551bd81d7a283 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 17 Apr 2026 07:52:22 -0700 Subject: [PATCH] Expand course hierarchy read APIs and practice retrieval. Add list/detail endpoints for courses, levels, modules, submodules, and submodule practices; extend course listing queries; add lesson update support and clean up removed route paths. Made-with: Cursor --- db/query/courses.sql | 51 ++ db/query/hierarchy.sql | 62 ++ gen/db/courses.sql.go | 196 ++++++ gen/db/hierarchy.sql.go | 284 +++++++++ .../web_server/handlers/hierarchy_handler.go | 567 ++++++++++++++++++ internal/web_server/routes.go | 17 +- 6 files changed, 1175 insertions(+), 2 deletions(-) diff --git a/db/query/courses.sql b/db/query/courses.sql index 2e5b7eb..7aab0c0 100644 --- a/db/query/courses.sql +++ b/db/query/courses.sql @@ -33,6 +33,57 @@ ORDER BY display_order ASC, id ASC LIMIT sqlc.narg('limit')::INT OFFSET sqlc.narg('offset')::INT; +-- name: GetAllCourses :many +SELECT + COUNT(*) OVER () AS total_count, + c.id, + c.category_id, + c.sub_category_id, + c.title, + c.description, + c.thumbnail, + c.intro_video_url, + c.is_active +FROM courses c +ORDER BY c.display_order ASC, c.id ASC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: GetHumanLanguageCourses :many +SELECT + COUNT(*) OVER () AS total_count, + c.id, + c.category_id, + c.sub_category_id, + c.title, + c.description, + c.thumbnail, + c.intro_video_url, + c.is_active +FROM courses c +JOIN course_categories cc ON cc.id = c.category_id +WHERE lower(trim(cc.name)) = 'human language' +ORDER BY c.display_order ASC, c.id ASC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: GetCoursesBySubCategory :many +SELECT + COUNT(*) OVER () AS total_count, + c.id, + c.category_id, + c.sub_category_id, + c.title, + c.description, + c.thumbnail, + c.intro_video_url, + c.is_active +FROM courses c +WHERE c.sub_category_id = $1 +ORDER BY c.display_order ASC, c.id ASC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + -- name: UpdateCourse :exec UPDATE courses diff --git a/db/query/hierarchy.sql b/db/query/hierarchy.sql index 94d7643..b2d7087 100644 --- a/db/query/hierarchy.sql +++ b/db/query/hierarchy.sql @@ -19,6 +19,20 @@ WHERE course_id = $1 AND is_active = TRUE ORDER BY display_order ASC, id ASC; +-- name: GetAllLevels :many +SELECT + COUNT(*) OVER () AS total_count, + l.* +FROM levels l +ORDER BY l.display_order ASC, l.id ASC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: GetLevelByID :one +SELECT * +FROM levels +WHERE id = $1; + -- name: GetModulesByLevelID :many SELECT * FROM modules @@ -26,6 +40,20 @@ WHERE level_id = $1 AND is_active = TRUE ORDER BY display_order ASC, id ASC; +-- name: GetAllModules :many +SELECT + COUNT(*) OVER () AS total_count, + m.* +FROM modules m +ORDER BY m.display_order ASC, m.id ASC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: GetModuleByID :one +SELECT * +FROM modules +WHERE id = $1; + -- name: GetSubModulesByModuleID :many SELECT * FROM sub_modules @@ -33,6 +61,20 @@ WHERE module_id = $1 AND is_active = TRUE ORDER BY display_order ASC, id ASC; +-- name: GetAllSubModules :many +SELECT + COUNT(*) OVER () AS total_count, + sm.* +FROM sub_modules sm +ORDER BY sm.display_order ASC, sm.id ASC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: GetSubModuleByID :one +SELECT * +FROM sub_modules +WHERE id = $1; + -- name: GetSubModuleVideos :many SELECT * FROM sub_module_videos @@ -100,6 +142,26 @@ WHERE smp.sub_module_id = $1 AND qs.set_type = 'PRACTICE' ORDER BY smp.display_order ASC, smp.id ASC; +-- name: GetSubModulePracticeByID :one +SELECT + smp.id, + smp.sub_module_id, + smp.title, + smp.description, + smp.thumbnail, + smp.intro_video_url, + smp.question_set_id, + smp.display_order, + smp.is_active, + qs.status, + qs.set_type, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM sub_module_practices smp +JOIN question_sets qs ON qs.id = smp.question_set_id +WHERE smp.id = $1 + AND smp.is_active = TRUE + AND qs.set_type = 'PRACTICE'; + -- name: GetFullHierarchyByCourseID :many SELECT c.id AS course_id, diff --git a/gen/db/courses.sql.go b/gen/db/courses.sql.go index f3347cc..58b72f4 100644 --- a/gen/db/courses.sql.go +++ b/gen/db/courses.sql.go @@ -67,6 +67,70 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error { return err } +const GetAllCourses = `-- name: GetAllCourses :many +SELECT + COUNT(*) OVER () AS total_count, + c.id, + c.category_id, + c.sub_category_id, + c.title, + c.description, + c.thumbnail, + c.intro_video_url, + c.is_active +FROM courses c +ORDER BY c.display_order ASC, c.id ASC +LIMIT $2::INT +OFFSET $1::INT +` + +type GetAllCoursesParams struct { + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetAllCoursesRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + CategoryID int64 `json:"category_id"` + SubCategoryID pgtype.Int8 `json:"sub_category_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + IsActive bool `json:"is_active"` +} + +func (q *Queries) GetAllCourses(ctx context.Context, arg GetAllCoursesParams) ([]GetAllCoursesRow, error) { + rows, err := q.db.Query(ctx, GetAllCourses, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllCoursesRow + for rows.Next() { + var i GetAllCoursesRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.CategoryID, + &i.SubCategoryID, + &i.Title, + &i.Description, + &i.Thumbnail, + &i.IntroVideoUrl, + &i.IsActive, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetCourseByID = `-- name: GetCourseByID :one SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id FROM courses @@ -153,6 +217,138 @@ func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCate return items, nil } +const GetCoursesBySubCategory = `-- name: GetCoursesBySubCategory :many +SELECT + COUNT(*) OVER () AS total_count, + c.id, + c.category_id, + c.sub_category_id, + c.title, + c.description, + c.thumbnail, + c.intro_video_url, + c.is_active +FROM courses c +WHERE c.sub_category_id = $1 +ORDER BY c.display_order ASC, c.id ASC +LIMIT $3::INT +OFFSET $2::INT +` + +type GetCoursesBySubCategoryParams struct { + SubCategoryID pgtype.Int8 `json:"sub_category_id"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetCoursesBySubCategoryRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + CategoryID int64 `json:"category_id"` + SubCategoryID pgtype.Int8 `json:"sub_category_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + IsActive bool `json:"is_active"` +} + +func (q *Queries) GetCoursesBySubCategory(ctx context.Context, arg GetCoursesBySubCategoryParams) ([]GetCoursesBySubCategoryRow, error) { + rows, err := q.db.Query(ctx, GetCoursesBySubCategory, arg.SubCategoryID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetCoursesBySubCategoryRow + for rows.Next() { + var i GetCoursesBySubCategoryRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.CategoryID, + &i.SubCategoryID, + &i.Title, + &i.Description, + &i.Thumbnail, + &i.IntroVideoUrl, + &i.IsActive, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetHumanLanguageCourses = `-- name: GetHumanLanguageCourses :many +SELECT + COUNT(*) OVER () AS total_count, + c.id, + c.category_id, + c.sub_category_id, + c.title, + c.description, + c.thumbnail, + c.intro_video_url, + c.is_active +FROM courses c +JOIN course_categories cc ON cc.id = c.category_id +WHERE lower(trim(cc.name)) = 'human language' +ORDER BY c.display_order ASC, c.id ASC +LIMIT $2::INT +OFFSET $1::INT +` + +type GetHumanLanguageCoursesParams struct { + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetHumanLanguageCoursesRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + CategoryID int64 `json:"category_id"` + SubCategoryID pgtype.Int8 `json:"sub_category_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + IsActive bool `json:"is_active"` +} + +func (q *Queries) GetHumanLanguageCourses(ctx context.Context, arg GetHumanLanguageCoursesParams) ([]GetHumanLanguageCoursesRow, error) { + rows, err := q.db.Query(ctx, GetHumanLanguageCourses, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetHumanLanguageCoursesRow + for rows.Next() { + var i GetHumanLanguageCoursesRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.CategoryID, + &i.SubCategoryID, + &i.Title, + &i.Description, + &i.Thumbnail, + &i.IntroVideoUrl, + &i.IsActive, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ReorderCourses = `-- name: ReorderCourses :exec UPDATE courses SET display_order = bulk.position diff --git a/gen/db/hierarchy.sql.go b/gen/db/hierarchy.sql.go index a9c7c44..66a3bf9 100644 --- a/gen/db/hierarchy.sql.go +++ b/gen/db/hierarchy.sql.go @@ -364,6 +364,171 @@ func (q *Queries) CreateSubModuleVideo(ctx context.Context, arg CreateSubModuleV return i, err } +const GetAllLevels = `-- name: GetAllLevels :many +SELECT + COUNT(*) OVER () AS total_count, + l.id, l.course_id, l.cefr_level, l.display_order, l.is_active, l.created_at +FROM levels l +ORDER BY l.display_order ASC, l.id ASC +LIMIT $2::INT +OFFSET $1::INT +` + +type GetAllLevelsParams struct { + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetAllLevelsRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + CourseID int64 `json:"course_id"` + CefrLevel string `json:"cefr_level"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) GetAllLevels(ctx context.Context, arg GetAllLevelsParams) ([]GetAllLevelsRow, error) { + rows, err := q.db.Query(ctx, GetAllLevels, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllLevelsRow + for rows.Next() { + var i GetAllLevelsRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.CourseID, + &i.CefrLevel, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAllModules = `-- name: GetAllModules :many +SELECT + COUNT(*) OVER () AS total_count, + m.id, m.level_id, m.title, m.description, m.display_order, m.is_active, m.created_at +FROM modules m +ORDER BY m.display_order ASC, m.id ASC +LIMIT $2::INT +OFFSET $1::INT +` + +type GetAllModulesParams struct { + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetAllModulesRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + LevelID int64 `json:"level_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) GetAllModules(ctx context.Context, arg GetAllModulesParams) ([]GetAllModulesRow, error) { + rows, err := q.db.Query(ctx, GetAllModules, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllModulesRow + for rows.Next() { + var i GetAllModulesRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.LevelID, + &i.Title, + &i.Description, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAllSubModules = `-- name: GetAllSubModules :many +SELECT + COUNT(*) OVER () AS total_count, + sm.id, sm.module_id, sm.title, sm.description, sm.display_order, sm.is_active, sm.created_at, sm.legacy_sub_course_id +FROM sub_modules sm +ORDER BY sm.display_order ASC, sm.id ASC +LIMIT $2::INT +OFFSET $1::INT +` + +type GetAllSubModulesParams struct { + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetAllSubModulesRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + ModuleID int64 `json:"module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + LegacySubCourseID pgtype.Int8 `json:"legacy_sub_course_id"` +} + +func (q *Queries) GetAllSubModules(ctx context.Context, arg GetAllSubModulesParams) ([]GetAllSubModulesRow, error) { + rows, err := q.db.Query(ctx, GetAllSubModules, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllSubModulesRow + for rows.Next() { + var i GetAllSubModulesRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.ModuleID, + &i.Title, + &i.Description, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + &i.LegacySubCourseID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetCourseSubCategories = `-- name: GetCourseSubCategories :many SELECT COUNT(*) OVER () AS total_count, @@ -607,6 +772,26 @@ func (q *Queries) GetHumanLanguageCourseSubCategories(ctx context.Context, arg G return items, nil } +const GetLevelByID = `-- name: GetLevelByID :one +SELECT id, course_id, cefr_level, display_order, is_active, created_at +FROM levels +WHERE id = $1 +` + +func (q *Queries) GetLevelByID(ctx context.Context, id int64) (Level, error) { + row := q.db.QueryRow(ctx, GetLevelByID, id) + var i Level + err := row.Scan( + &i.ID, + &i.CourseID, + &i.CefrLevel, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ) + return i, err +} + const GetLevelsByCourseID = `-- name: GetLevelsByCourseID :many SELECT id, course_id, cefr_level, display_order, is_active, created_at FROM levels @@ -642,6 +827,27 @@ func (q *Queries) GetLevelsByCourseID(ctx context.Context, courseID int64) ([]Le return items, nil } +const GetModuleByID = `-- name: GetModuleByID :one +SELECT id, level_id, title, description, display_order, is_active, created_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.LevelID, + &i.Title, + &i.Description, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ) + return i, err +} + const GetModulesByLevelID = `-- name: GetModulesByLevelID :many SELECT id, level_id, title, description, display_order, is_active, created_at FROM modules @@ -678,6 +884,28 @@ func (q *Queries) GetModulesByLevelID(ctx context.Context, levelID int64) ([]Mod return items, nil } +const GetSubModuleByID = `-- name: GetSubModuleByID :one +SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id +FROM sub_modules +WHERE id = $1 +` + +func (q *Queries) GetSubModuleByID(ctx context.Context, id int64) (SubModule, error) { + row := q.db.QueryRow(ctx, GetSubModuleByID, id) + var i SubModule + err := row.Scan( + &i.ID, + &i.ModuleID, + &i.Title, + &i.Description, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + &i.LegacySubCourseID, + ) + return i, err +} + const GetSubModuleLessonByID = `-- name: GetSubModuleLessonByID :one SELECT smp.id, @@ -798,6 +1026,62 @@ func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([ return items, nil } +const GetSubModulePracticeByID = `-- name: GetSubModulePracticeByID :one +SELECT + smp.id, + smp.sub_module_id, + smp.title, + smp.description, + smp.thumbnail, + smp.intro_video_url, + smp.question_set_id, + smp.display_order, + smp.is_active, + qs.status, + qs.set_type, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM sub_module_practices smp +JOIN question_sets qs ON qs.id = smp.question_set_id +WHERE smp.id = $1 + AND smp.is_active = TRUE + AND qs.set_type = 'PRACTICE' +` + +type GetSubModulePracticeByIDRow struct { + ID int64 `json:"id"` + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + QuestionSetID int64 `json:"question_set_id"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + Status string `json:"status"` + SetType string `json:"set_type"` + QuestionCount int64 `json:"question_count"` +} + +func (q *Queries) GetSubModulePracticeByID(ctx context.Context, id int64) (GetSubModulePracticeByIDRow, error) { + row := q.db.QueryRow(ctx, GetSubModulePracticeByID, id) + var i GetSubModulePracticeByIDRow + err := row.Scan( + &i.ID, + &i.SubModuleID, + &i.Title, + &i.Description, + &i.Thumbnail, + &i.IntroVideoUrl, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.Status, + &i.SetType, + &i.QuestionCount, + ) + return i, err +} + const GetSubModulePractices = `-- name: GetSubModulePractices :many SELECT smp.id, diff --git a/internal/web_server/handlers/hierarchy_handler.go b/internal/web_server/handlers/hierarchy_handler.go index 8a272c3..fe5be6b 100644 --- a/internal/web_server/handlers/hierarchy_handler.go +++ b/internal/web_server/handlers/hierarchy_handler.go @@ -269,6 +269,501 @@ func (h *Handler) ListCoursesByCategory(c *fiber.Ctx) error { }) } +// ListAllCourses godoc +// @Summary List all courses +// @Description Returns all courses with pagination +// @Tags course-management +// @Produce json +// @Param offset query int false "Offset" +// @Param limit query int false "Limit" +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses [get] +func (h *Handler) ListAllCourses(c *fiber.Ctx) error { + offset := int32(c.QueryInt("offset", 0)) + if offset < 0 { + offset = 0 + } + limit := int32(c.QueryInt("limit", 10000)) + if limit <= 0 { + limit = 10000 + } + + rows, err := h.analyticsDB.GetAllCourses(c.Context(), dbgen.GetAllCoursesParams{ + Offset: pgtype.Int4{Int32: offset, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load courses", Error: err.Error()}) + } + + total := 0 + if len(rows) > 0 { + total = int(rows[0].TotalCount) + } + + return c.JSON(domain.Response{ + Message: "Courses retrieved successfully", + Data: map[string]interface{}{ + "courses": rows, + "total_count": total, + }, + }) +} + +// ListHumanLanguageCourses godoc +// @Summary List Human Language courses +// @Description Returns all courses under Human Language category +// @Tags course-management +// @Produce json +// @Param offset query int false "Offset" +// @Param limit query int false "Limit" +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/human-language/courses [get] +func (h *Handler) ListHumanLanguageCourses(c *fiber.Ctx) error { + offset := int32(c.QueryInt("offset", 0)) + if offset < 0 { + offset = 0 + } + limit := int32(c.QueryInt("limit", 10000)) + if limit <= 0 { + limit = 10000 + } + + rows, err := h.analyticsDB.GetHumanLanguageCourses(c.Context(), dbgen.GetHumanLanguageCoursesParams{ + Offset: pgtype.Int4{Int32: offset, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load Human Language courses", Error: err.Error()}) + } + + total := 0 + if len(rows) > 0 { + total = int(rows[0].TotalCount) + } + + return c.JSON(domain.Response{ + Message: "Human Language courses retrieved successfully", + Data: map[string]interface{}{ + "courses": rows, + "total_count": total, + }, + }) +} + +// ListCoursesBySubCategory godoc +// @Summary List courses by sub-category +// @Description Returns courses for one sub-category +// @Tags course-management +// @Produce json +// @Param subCategoryId path int true "Sub-category ID" +// @Param offset query int false "Offset" +// @Param limit query int false "Limit" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-categories/{subCategoryId}/courses [get] +func (h *Handler) ListCoursesBySubCategory(c *fiber.Ctx) error { + subCategoryID, err := strconv.ParseInt(c.Params("subCategoryId"), 10, 64) + if err != nil || subCategoryID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid sub-category ID", Error: "subCategoryId must be a positive integer"}) + } + + offset := int32(c.QueryInt("offset", 0)) + if offset < 0 { + offset = 0 + } + limit := int32(c.QueryInt("limit", 10000)) + if limit <= 0 { + limit = 10000 + } + + rows, err := h.analyticsDB.GetCoursesBySubCategory(c.Context(), dbgen.GetCoursesBySubCategoryParams{ + SubCategoryID: pgtype.Int8{Int64: subCategoryID, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load courses", Error: err.Error()}) + } + + total := 0 + if len(rows) > 0 { + total = int(rows[0].TotalCount) + } + + return c.JSON(domain.Response{ + Message: "Courses retrieved successfully", + Data: map[string]interface{}{ + "courses": rows, + "total_count": total, + }, + }) +} + +// GetCourseByID godoc +// @Summary Get course detail +// @Description Returns one course by ID +// @Tags course-management +// @Produce json +// @Param courseId path int true "Course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses/{courseId} [get] +func (h *Handler) GetCourseByID(c *fiber.Ctx) error { + courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) + if err != nil || courseID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid course ID", + Error: "courseId must be a positive integer", + }) + } + + course, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Course not found", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Course retrieved successfully", + Data: course, + }) +} + +// ListAllLevels godoc +// @Summary List all levels +// @Description Returns all levels with pagination +// @Tags course-management +// @Produce json +// @Param offset query int false "Offset" +// @Param limit query int false "Limit" +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/levels [get] +func (h *Handler) ListAllLevels(c *fiber.Ctx) error { + offset := int32(c.QueryInt("offset", 0)) + if offset < 0 { + offset = 0 + } + limit := int32(c.QueryInt("limit", 10000)) + if limit <= 0 { + limit = 10000 + } + + rows, err := h.analyticsDB.GetAllLevels(c.Context(), dbgen.GetAllLevelsParams{ + Offset: pgtype.Int4{Int32: offset, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load levels", Error: err.Error()}) + } + + total := 0 + if len(rows) > 0 { + total = int(rows[0].TotalCount) + } + + return c.JSON(domain.Response{ + Message: "Levels retrieved successfully", + Data: map[string]interface{}{ + "levels": rows, + "total_count": total, + }, + }) +} + +// ListLevelsByCourse godoc +// @Summary List levels by course +// @Description Returns all active levels for one course +// @Tags course-management +// @Produce json +// @Param courseId path int true "Course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses/{courseId}/levels [get] +func (h *Handler) ListLevelsByCourse(c *fiber.Ctx) error { + courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) + if err != nil || courseID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid course ID", + Error: "courseId must be a positive integer", + }) + } + + rows, err := h.analyticsDB.GetLevelsByCourseID(c.Context(), courseID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load levels", Error: err.Error()}) + } + + return c.JSON(domain.Response{ + Message: "Levels retrieved successfully", + Data: map[string]interface{}{ + "levels": rows, + "total_count": len(rows), + }, + }) +} + +// GetLevelByID godoc +// @Summary Get level detail +// @Description Returns one level by ID +// @Tags course-management +// @Produce json +// @Param levelId path int true "Level ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/levels/{levelId} [get] +func (h *Handler) GetLevelByID(c *fiber.Ctx) error { + levelID, err := strconv.ParseInt(c.Params("levelId"), 10, 64) + if err != nil || levelID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid level ID", + Error: "levelId must be a positive integer", + }) + } + + level, err := h.analyticsDB.GetLevelByID(c.Context(), levelID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Level not found", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Level retrieved successfully", + Data: level, + }) +} + +// ListAllModules godoc +// @Summary List all modules +// @Description Returns all modules with pagination +// @Tags course-management +// @Produce json +// @Param offset query int false "Offset" +// @Param limit query int false "Limit" +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/modules [get] +func (h *Handler) ListAllModules(c *fiber.Ctx) error { + offset := int32(c.QueryInt("offset", 0)) + if offset < 0 { + offset = 0 + } + limit := int32(c.QueryInt("limit", 10000)) + if limit <= 0 { + limit = 10000 + } + + rows, err := h.analyticsDB.GetAllModules(c.Context(), dbgen.GetAllModulesParams{ + Offset: pgtype.Int4{Int32: offset, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load modules", Error: err.Error()}) + } + + total := 0 + if len(rows) > 0 { + total = int(rows[0].TotalCount) + } + + return c.JSON(domain.Response{ + Message: "Modules retrieved successfully", + Data: map[string]interface{}{ + "modules": rows, + "total_count": total, + }, + }) +} + +// ListModulesByLevel godoc +// @Summary List modules by level +// @Description Returns all active modules for one level +// @Tags course-management +// @Produce json +// @Param levelId path int true "Level ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/levels/{levelId}/modules [get] +func (h *Handler) ListModulesByLevel(c *fiber.Ctx) error { + levelID, err := strconv.ParseInt(c.Params("levelId"), 10, 64) + if err != nil || levelID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid level ID", + Error: "levelId must be a positive integer", + }) + } + + rows, err := h.analyticsDB.GetModulesByLevelID(c.Context(), levelID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load modules", Error: err.Error()}) + } + + return c.JSON(domain.Response{ + Message: "Modules retrieved successfully", + Data: map[string]interface{}{ + "modules": rows, + "total_count": len(rows), + }, + }) +} + +// GetModuleByID godoc +// @Summary Get module detail +// @Description Returns one module by ID +// @Tags course-management +// @Produce json +// @Param moduleId path int true "Module ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/modules/{moduleId} [get] +func (h *Handler) GetModuleByID(c *fiber.Ctx) error { + moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) + if err != nil || moduleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid module ID", + Error: "moduleId must be a positive integer", + }) + } + + mod, err := h.analyticsDB.GetModuleByID(c.Context(), moduleID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Module not found", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Module retrieved successfully", + Data: mod, + }) +} + +// ListAllSubModules godoc +// @Summary List all sub-modules +// @Description Returns all sub-modules with pagination +// @Tags course-management +// @Produce json +// @Param offset query int false "Offset" +// @Param limit query int false "Limit" +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-modules [get] +func (h *Handler) ListAllSubModules(c *fiber.Ctx) error { + offset := int32(c.QueryInt("offset", 0)) + if offset < 0 { + offset = 0 + } + limit := int32(c.QueryInt("limit", 10000)) + if limit <= 0 { + limit = 10000 + } + + rows, err := h.analyticsDB.GetAllSubModules(c.Context(), dbgen.GetAllSubModulesParams{ + Offset: pgtype.Int4{Int32: offset, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-modules", Error: err.Error()}) + } + + total := 0 + if len(rows) > 0 { + total = int(rows[0].TotalCount) + } + + return c.JSON(domain.Response{ + Message: "Sub-modules retrieved successfully", + Data: map[string]interface{}{ + "sub_modules": rows, + "total_count": total, + }, + }) +} + +// ListSubModulesByModule godoc +// @Summary List sub-modules by module +// @Description Returns all active sub-modules for one module +// @Tags course-management +// @Produce json +// @Param moduleId path int true "Module ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/modules/{moduleId}/sub-modules [get] +func (h *Handler) ListSubModulesByModule(c *fiber.Ctx) error { + moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) + if err != nil || moduleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid module ID", + Error: "moduleId must be a positive integer", + }) + } + + rows, err := h.analyticsDB.GetSubModulesByModuleID(c.Context(), moduleID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-modules", Error: err.Error()}) + } + + return c.JSON(domain.Response{ + Message: "Sub-modules retrieved successfully", + Data: map[string]interface{}{ + "sub_modules": rows, + "total_count": len(rows), + }, + }) +} + +// GetSubModuleByID godoc +// @Summary Get sub-module detail +// @Description Returns one sub-module by ID +// @Tags course-management +// @Produce json +// @Param subModuleId path int true "Sub-module ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-modules/{subModuleId} [get] +func (h *Handler) GetSubModuleByID(c *fiber.Ctx) error { + subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) + if err != nil || subModuleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-module ID", + Error: "subModuleId must be a positive integer", + }) + } + + subModule, err := h.analyticsDB.GetSubModuleByID(c.Context(), subModuleID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Sub-module not found", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Sub-module retrieved successfully", + Data: subModule, + }) +} + // ListCourseSubCategories godoc // @Summary List course sub-categories // @Description Returns all active course sub-categories @@ -1383,6 +1878,78 @@ func (h *Handler) CreateSubModulePractice(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Practice created", Data: created}) } +// GetSubModulePractices godoc +// @Summary Get practices under sub-module +// @Description Returns all active practices attached to a sub-module +// @Tags course-management +// @Accept json +// @Produce json +// @Param subModuleId path int true "Sub-module ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-modules/{subModuleId}/practices [get] +func (h *Handler) GetSubModulePractices(c *fiber.Ctx) error { + subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) + if err != nil || subModuleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-module ID", + Error: "subModuleId must be a positive integer", + }) + } + + practices, err := h.analyticsDB.GetSubModulePractices(c.Context(), subModuleID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load sub-module practices", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Sub-module practices retrieved successfully", + Data: map[string]interface{}{ + "practices": practices, + "total_count": len(practices), + }, + }) +} + +// GetSubModulePracticeByID godoc +// @Summary Get practice detail +// @Description Returns one active practice by practice ID +// @Tags course-management +// @Accept json +// @Produce json +// @Param practiceId path int true "Practice ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/practices/{practiceId} [get] +func (h *Handler) GetSubModulePracticeByID(c *fiber.Ctx) error { + practiceID, err := strconv.ParseInt(c.Params("practiceId"), 10, 64) + if err != nil || practiceID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid practice ID", + Error: "practiceId must be a positive integer", + }) + } + + practice, err := h.analyticsDB.GetSubModulePracticeByID(c.Context(), practiceID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Practice not found", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Practice retrieved successfully", + Data: practice, + }) +} + func (h *Handler) GetSubModuleVideos(c *fiber.Ctx) error { subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) if err != nil || subModuleID <= 0 { diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index d83c15c..7225cec 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -80,7 +80,19 @@ func (a *App) initAppRoutes() { // Unified Course Management (single hierarchy model) groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCourseCategories) - groupV1.Get("/course-management/categories/:categoryId/courses", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCoursesByCategory) + groupV1.Get("/course-management/courses", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllCourses) + groupV1.Get("/course-management/human-language/courses", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListHumanLanguageCourses) + groupV1.Get("/course-management/sub-categories/:subCategoryId/courses", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCoursesBySubCategory) + groupV1.Get("/course-management/courses/:courseId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetCourseByID) + groupV1.Get("/course-management/levels", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllLevels) + groupV1.Get("/course-management/courses/:courseId/levels", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListLevelsByCourse) + groupV1.Get("/course-management/levels/:levelId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetLevelByID) + groupV1.Get("/course-management/modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllModules) + groupV1.Get("/course-management/levels/:levelId/modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListModulesByLevel) + groupV1.Get("/course-management/modules/:moduleId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetModuleByID) + groupV1.Get("/course-management/sub-modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllSubModules) + groupV1.Get("/course-management/modules/:moduleId/sub-modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListSubModulesByModule) + groupV1.Get("/course-management/sub-modules/:subModuleId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetSubModuleByID) groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory) groupV1.Delete("/course-management/categories/:categoryId", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseCategory) groupV1.Post("/course-management/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) @@ -101,7 +113,6 @@ func (a *App) initAppRoutes() { groupV1.Post("/course-management/sub-modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubModule) groupV1.Put("/course-management/sub-modules/:subModuleId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubModule) groupV1.Delete("/course-management/sub-modules/:subModuleId", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteSubModule) - groupV1.Get("/course-management/sub-modules/:subModuleId/videos", a.authMiddleware, a.RequirePermission("videos.list"), h.GetSubModuleVideos) groupV1.Post("/course-management/sub-module-videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubModuleVideo) groupV1.Put("/course-management/sub-module-videos/:videoId", a.authMiddleware, a.RequirePermission("videos.update"), h.UpdateSubModuleVideo) groupV1.Delete("/course-management/sub-module-videos/:videoId", a.authMiddleware, a.RequirePermission("videos.delete"), h.DeleteSubModuleVideo) @@ -109,6 +120,8 @@ func (a *App) initAppRoutes() { groupV1.Get("/course-management/sub-module-lessons/:lessonId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModuleLessonByID) groupV1.Put("/course-management/sub-module-lessons/:lessonId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateSubModuleLesson) groupV1.Post("/course-management/sub-module-lessons", a.authMiddleware, a.RequirePermission("question_sets.update"), h.AttachSubModuleLesson) + groupV1.Get("/course-management/sub-modules/:subModuleId/practices", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetSubModulePractices) + groupV1.Get("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModulePracticeByID) groupV1.Post("/course-management/sub-module-practices", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModulePractice) groupV1.Put("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdatePractice) groupV1.Delete("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeletePractice)