add lesson and subcategory retrieval/update endpoints

Introduce dedicated APIs for submodule lesson detail/update and subcategory listing (including Human Language), with SQL/query wiring and handler routing updates.

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-17 01:40:47 -07:00
parent 01914cb81e
commit 343ce470cc
4 changed files with 563 additions and 0 deletions

View File

@ -128,6 +128,44 @@ INSERT INTO course_sub_categories (
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: GetCourseSubCategories :many
SELECT
COUNT(*) OVER () AS total_count,
csc.id,
csc.category_id,
cc.name AS category_name,
csc.name,
csc.description,
csc.display_order,
csc.is_active,
csc.created_at
FROM course_sub_categories csc
JOIN course_categories cc ON cc.id = csc.category_id
WHERE csc.is_active = TRUE
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetHumanLanguageCourseSubCategories :many
SELECT
COUNT(*) OVER () AS total_count,
csc.id,
csc.category_id,
cc.name AS category_name,
csc.name,
csc.description,
csc.display_order,
csc.is_active,
csc.created_at
FROM course_sub_categories csc
JOIN course_categories cc ON cc.id = csc.category_id
WHERE csc.is_active = TRUE
AND cc.is_active = TRUE
AND lower(trim(cc.name)) = 'human language'
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: CreateLevel :one
INSERT INTO levels (
course_id,
@ -200,6 +238,17 @@ INSERT INTO sub_module_lessons (
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: UpdateSubModuleLesson :one
UPDATE sub_module_lessons
SET
sub_module_id = $1,
question_set_id = $2,
intro_video_url = $3,
display_order = $4,
is_active = $5
WHERE id = $6
RETURNING *;
-- name: CreateSubModulePractice :one
INSERT INTO sub_module_practices (
sub_module_id,

View File

@ -364,6 +364,72 @@ func (q *Queries) CreateSubModuleVideo(ctx context.Context, arg CreateSubModuleV
return i, err
}
const GetCourseSubCategories = `-- name: GetCourseSubCategories :many
SELECT
COUNT(*) OVER () AS total_count,
csc.id,
csc.category_id,
cc.name AS category_name,
csc.name,
csc.description,
csc.display_order,
csc.is_active,
csc.created_at
FROM course_sub_categories csc
JOIN course_categories cc ON cc.id = csc.category_id
WHERE csc.is_active = TRUE
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT $2::INT
OFFSET $1::INT
`
type GetCourseSubCategoriesParams struct {
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetCourseSubCategoriesRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) GetCourseSubCategories(ctx context.Context, arg GetCourseSubCategoriesParams) ([]GetCourseSubCategoriesRow, error) {
rows, err := q.db.Query(ctx, GetCourseSubCategories, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCourseSubCategoriesRow
for rows.Next() {
var i GetCourseSubCategoriesRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.CategoryName,
&i.Name,
&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 GetCoursesWithHierarchy = `-- name: GetCoursesWithHierarchy :many
SELECT
cc.id AS category_id,
@ -473,6 +539,74 @@ func (q *Queries) GetFullHierarchyByCourseID(ctx context.Context, id int64) ([]G
return items, nil
}
const GetHumanLanguageCourseSubCategories = `-- name: GetHumanLanguageCourseSubCategories :many
SELECT
COUNT(*) OVER () AS total_count,
csc.id,
csc.category_id,
cc.name AS category_name,
csc.name,
csc.description,
csc.display_order,
csc.is_active,
csc.created_at
FROM course_sub_categories csc
JOIN course_categories cc ON cc.id = csc.category_id
WHERE csc.is_active = TRUE
AND cc.is_active = TRUE
AND lower(trim(cc.name)) = 'human language'
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT $2::INT
OFFSET $1::INT
`
type GetHumanLanguageCourseSubCategoriesParams struct {
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetHumanLanguageCourseSubCategoriesRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) GetHumanLanguageCourseSubCategories(ctx context.Context, arg GetHumanLanguageCourseSubCategoriesParams) ([]GetHumanLanguageCourseSubCategoriesRow, error) {
rows, err := q.db.Query(ctx, GetHumanLanguageCourseSubCategories, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetHumanLanguageCourseSubCategoriesRow
for rows.Next() {
var i GetHumanLanguageCourseSubCategoriesRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.CategoryName,
&i.Name,
&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 GetLevelsByCourseID = `-- name: GetLevelsByCourseID :many
SELECT id, course_id, cefr_level, display_order, is_active, created_at
FROM levels
@ -819,3 +953,46 @@ func (q *Queries) GetSubModulesByModuleID(ctx context.Context, moduleID int64) (
}
return items, nil
}
const UpdateSubModuleLesson = `-- name: UpdateSubModuleLesson :one
UPDATE sub_module_lessons
SET
sub_module_id = $1,
question_set_id = $2,
intro_video_url = $3,
display_order = $4,
is_active = $5
WHERE id = $6
RETURNING id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at
`
type UpdateSubModuleLessonParams struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateSubModuleLesson(ctx context.Context, arg UpdateSubModuleLessonParams) (SubModuleLesson, error) {
row := q.db.QueryRow(ctx, UpdateSubModuleLesson,
arg.SubModuleID,
arg.QuestionSetID,
arg.IntroVideoUrl,
arg.DisplayOrder,
arg.IsActive,
arg.ID,
)
var i SubModuleLesson
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}

View File

@ -109,6 +109,29 @@ type attachSubModuleLessonReq struct {
IsActive *bool `json:"is_active"`
}
type updateLessonQuestionReq struct {
QuestionID int64 `json:"question_id"`
DisplayOrder *int32 `json:"display_order"`
}
type updateSubModuleLessonReq struct {
SubModuleID *int64 `json:"sub_module_id"`
QuestionSetID *int64 `json:"question_set_id"`
IntroVideoURL *string `json:"intro_video_url"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
Title *string `json:"title"`
Description *string `json:"description"`
BannerImage *string `json:"banner_image"`
Persona *string `json:"persona"`
TimeLimitMinutes *int32 `json:"time_limit_minutes"`
PassingScore *int32 `json:"passing_score"`
ShuffleQuestions *bool `json:"shuffle_questions"`
Status *string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id"`
Questions []updateLessonQuestionReq `json:"questions"`
}
type createSubModulePracticeReq struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
@ -246,6 +269,90 @@ func (h *Handler) ListCoursesByCategory(c *fiber.Ctx) error {
})
}
// ListCourseSubCategories godoc
// @Summary List course sub-categories
// @Description Returns all active course sub-categories
// @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-categories [get]
func (h *Handler) ListCourseSubCategories(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.GetCourseSubCategories(c.Context(), dbgen.GetCourseSubCategoriesParams{
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-categories", Error: err.Error()})
}
total := 0
if len(rows) > 0 {
total = int(rows[0].TotalCount)
}
return c.JSON(domain.Response{
Message: "Sub-categories retrieved successfully",
Data: map[string]interface{}{
"sub_categories": rows,
"total_count": total,
},
})
}
// ListHumanLanguageCourseSubCategories godoc
// @Summary List Human Language sub-categories
// @Description Returns active sub-categories 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/sub-categories [get]
func (h *Handler) ListHumanLanguageCourseSubCategories(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.GetHumanLanguageCourseSubCategories(c.Context(), dbgen.GetHumanLanguageCourseSubCategoriesParams{
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 sub-categories", Error: err.Error()})
}
total := 0
if len(rows) > 0 {
total = int(rows[0].TotalCount)
}
return c.JSON(domain.Response{
Message: "Human Language sub-categories retrieved successfully",
Data: map[string]interface{}{
"sub_categories": rows,
"total_count": total,
},
})
}
// CreateCourseCategory godoc
// @Summary Create course category
// @Description Legacy-compatible endpoint for creating a course category
@ -1014,6 +1121,233 @@ func (h *Handler) GetSubModuleLessonByID(c *fiber.Ctx) error {
})
}
// UpdateSubModuleLesson godoc
// @Summary Update lesson detail
// @Description Updates lesson metadata, linked question-set metadata, and optionally replaces lesson questions
// @Tags course-management
// @Accept json
// @Produce json
// @Param lessonId path int true "Lesson ID"
// @Param body body updateSubModuleLessonReq true "Update lesson payload"
// @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-module-lessons/{lessonId} [put]
func (h *Handler) UpdateSubModuleLesson(c *fiber.Ctx) error {
lessonID, err := strconv.ParseInt(c.Params("lessonId"), 10, 64)
if err != nil || lessonID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid lesson ID",
Error: "lessonId must be a valid positive integer",
})
}
var req updateSubModuleLessonReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
currentLesson, err := h.analyticsDB.GetSubModuleLessonByID(c.Context(), lessonID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found",
Error: err.Error(),
})
}
targetSubModuleID := currentLesson.SubModuleID
if req.SubModuleID != nil {
if *req.SubModuleID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id must be a positive integer"})
}
targetSubModuleID = *req.SubModuleID
}
targetQuestionSetID := currentLesson.QuestionSetID
if req.QuestionSetID != nil {
if *req.QuestionSetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "question_set_id must be a positive integer"})
}
targetQuestionSetID = *req.QuestionSetID
}
targetIntroVideoURL := currentLesson.IntroVideoUrl
if req.IntroVideoURL != nil {
targetIntroVideoURL = toText(req.IntroVideoURL)
}
targetDisplayOrder := currentLesson.DisplayOrder
if req.DisplayOrder != nil {
targetDisplayOrder = *req.DisplayOrder
}
targetIsActive := currentLesson.IsActive
if req.IsActive != nil {
targetIsActive = *req.IsActive
}
if _, err := h.analyticsDB.UpdateSubModuleLesson(c.Context(), dbgen.UpdateSubModuleLessonParams{
SubModuleID: targetSubModuleID,
QuestionSetID: targetQuestionSetID,
IntroVideoUrl: targetIntroVideoURL,
DisplayOrder: targetDisplayOrder,
IsActive: targetIsActive,
ID: lessonID,
}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update lesson",
Error: err.Error(),
})
}
currentSet, err := h.questionsSvc.GetQuestionSetByID(c.Context(), targetQuestionSetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load linked question set",
Error: err.Error(),
})
}
shouldUpdateSet := req.Title != nil || req.Description != nil || req.BannerImage != nil ||
req.Persona != nil || req.TimeLimitMinutes != nil || req.PassingScore != nil ||
req.ShuffleQuestions != nil || req.Status != nil || req.SubCourseVideoID != nil
if shouldUpdateSet {
title := currentSet.Title
if req.Title != nil {
title = *req.Title
}
input := domain.CreateQuestionSetInput{
Title: title,
Description: currentSet.Description,
BannerImage: currentSet.BannerImage,
Persona: currentSet.Persona,
TimeLimitMinutes: currentSet.TimeLimitMinutes,
PassingScore: currentSet.PassingScore,
SubCourseVideoID: currentSet.SubCourseVideoID,
IntroVideoURL: req.IntroVideoURL,
ShuffleQuestions: &currentSet.ShuffleQuestions,
}
currentStatus := currentSet.Status
input.Status = &currentStatus
if req.Description != nil {
input.Description = req.Description
}
if req.BannerImage != nil {
input.BannerImage = req.BannerImage
}
if req.Persona != nil {
input.Persona = req.Persona
}
if req.TimeLimitMinutes != nil {
input.TimeLimitMinutes = req.TimeLimitMinutes
}
if req.PassingScore != nil {
input.PassingScore = req.PassingScore
}
if req.ShuffleQuestions != nil {
input.ShuffleQuestions = req.ShuffleQuestions
}
if req.Status != nil {
input.Status = req.Status
}
if req.SubCourseVideoID != nil {
input.SubCourseVideoID = req.SubCourseVideoID
}
if err := h.questionsSvc.UpdateQuestionSet(c.Context(), targetQuestionSetID, input); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update linked question set",
Error: err.Error(),
})
}
}
if req.Questions != nil {
seen := make(map[int64]struct{}, len(req.Questions))
for idx, q := range req.Questions {
if q.QuestionID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Each question_id must be a positive integer"})
}
if _, exists := seen[q.QuestionID]; exists {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Duplicate question_id values are not allowed"})
}
seen[q.QuestionID] = struct{}{}
order := q.DisplayOrder
if order == nil {
defaultOrder := int32(idx)
order = &defaultOrder
}
if _, err := h.questionsSvc.GetQuestionByID(c.Context(), q.QuestionID); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_id in questions payload",
Error: err.Error(),
})
}
if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), targetQuestionSetID, q.QuestionID, order); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to upsert lesson question",
Error: err.Error(),
})
}
}
existingItems, err := h.questionsSvc.GetQuestionSetItems(c.Context(), targetQuestionSetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load existing lesson questions",
Error: err.Error(),
})
}
for _, item := range existingItems {
if _, keep := seen[item.QuestionID]; keep {
continue
}
if err := h.questionsSvc.RemoveQuestionFromSet(c.Context(), targetQuestionSetID, item.QuestionID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to remove question from lesson",
Error: err.Error(),
})
}
}
}
updatedLesson, err := h.analyticsDB.GetSubModuleLessonByID(c.Context(), lessonID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Lesson updated but failed to fetch latest detail",
Error: err.Error(),
})
}
updatedQuestions, err := h.questionsSvc.GetQuestionSetItems(c.Context(), updatedLesson.QuestionSetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Lesson updated but failed to fetch latest questions",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Lesson updated successfully",
Data: map[string]interface{}{
"lesson": updatedLesson,
"questions": updatedQuestions,
},
})
}
// CreateSubModulePractice godoc
// @Summary Create practice under sub-module
// @Description Creates a sub-module practice with metadata and linked question set

View File

@ -91,6 +91,8 @@ func (a *App) initAppRoutes() {
groupV1.Get("/course-management/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchy)
groupV1.Get("/course-management/human-language/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchy)
groupV1.Get("/course-management/courses/:courseId/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchyByCourse)
groupV1.Get("/course-management/sub-categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCourseSubCategories)
groupV1.Get("/course-management/human-language/sub-categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListHumanLanguageCourseSubCategories)
groupV1.Post("/course-management/sub-categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseSubCategory)
groupV1.Delete("/course-management/sub-categories/:subCategoryId", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseSubCategory)
groupV1.Post("/course-management/levels", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateLevel)
@ -105,6 +107,7 @@ func (a *App) initAppRoutes() {
groupV1.Delete("/course-management/sub-module-videos/:videoId", a.authMiddleware, a.RequirePermission("videos.delete"), h.DeleteSubModuleVideo)
groupV1.Get("/course-management/sub-modules/:subModuleId/lessons", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetSubModuleLessons)
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.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)