diff --git a/db/query/hierarchy.sql b/db/query/hierarchy.sql index 751507e..94d7643 100644 --- a/db/query/hierarchy.sql +++ b/db/query/hierarchy.sql @@ -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, diff --git a/gen/db/hierarchy.sql.go b/gen/db/hierarchy.sql.go index 6e124c9..a9c7c44 100644 --- a/gen/db/hierarchy.sql.go +++ b/gen/db/hierarchy.sql.go @@ -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 +} diff --git a/internal/web_server/handlers/hierarchy_handler.go b/internal/web_server/handlers/hierarchy_handler.go index 9b0d727..8a272c3 100644 --- a/internal/web_server/handlers/hierarchy_handler.go +++ b/internal/web_server/handlers/hierarchy_handler.go @@ -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: ¤tSet.ShuffleQuestions, + } + + currentStatus := currentSet.Status + input.Status = ¤tStatus + + 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 diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 1b7811c..d83c15c 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)