feat: list sub-categories by course category ID

- GET /api/v1/course-management/categories/:categoryId/sub-categories

- SQL GetCourseSubCategoriesByCategoryID; swagger refresh

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-20 08:32:19 -07:00
parent de95c4d0d2
commit 72d1a0c3ed
7 changed files with 316 additions and 0 deletions

View File

@ -246,6 +246,25 @@ ORDER BY csc.display_order ASC, csc.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetCourseSubCategoriesByCategoryID :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.category_id = $1
AND 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,

View File

@ -979,6 +979,65 @@ const docTemplate = `{
}
}
},
"/api/v1/course-management/categories/{categoryId}/sub-categories": {
"get": {
"description": "Returns active sub-categories for the given category ID",
"produces": [
"application/json"
],
"tags": [
"course-management"
],
"summary": "List sub-categories for a course category",
"parameters": [
{
"type": "integer",
"description": "Category ID",
"name": "categoryId",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Offset",
"name": "offset",
"in": "query"
},
{
"type": "integer",
"description": "Limit",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/course-management/courses": {
"get": {
"description": "Returns all courses with pagination",

View File

@ -971,6 +971,65 @@
}
}
},
"/api/v1/course-management/categories/{categoryId}/sub-categories": {
"get": {
"description": "Returns active sub-categories for the given category ID",
"produces": [
"application/json"
],
"tags": [
"course-management"
],
"summary": "List sub-categories for a course category",
"parameters": [
{
"type": "integer",
"description": "Category ID",
"name": "categoryId",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Offset",
"name": "offset",
"in": "query"
},
{
"type": "integer",
"description": "Limit",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/course-management/courses": {
"get": {
"description": "Returns all courses with pagination",

View File

@ -2742,6 +2742,45 @@ paths:
summary: List courses by category
tags:
- course-management
/api/v1/course-management/categories/{categoryId}/sub-categories:
get:
description: Returns active sub-categories for the given category ID
parameters:
- description: Category ID
in: path
name: categoryId
required: true
type: integer
- description: Offset
in: query
name: offset
type: integer
- description: Limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List sub-categories for a course category
tags:
- course-management
/api/v1/course-management/courses:
get:
description: Returns all courses with pagination

View File

@ -775,6 +775,74 @@ func (q *Queries) GetCourseSubCategories(ctx context.Context, arg GetCourseSubCa
return items, nil
}
const GetCourseSubCategoriesByCategoryID = `-- name: GetCourseSubCategoriesByCategoryID :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.category_id = $1
AND csc.is_active = TRUE
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT $3::INT
OFFSET $2::INT
`
type GetCourseSubCategoriesByCategoryIDParams struct {
CategoryID int64 `json:"category_id"`
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetCourseSubCategoriesByCategoryIDRow 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) GetCourseSubCategoriesByCategoryID(ctx context.Context, arg GetCourseSubCategoriesByCategoryIDParams) ([]GetCourseSubCategoriesByCategoryIDRow, error) {
rows, err := q.db.Query(ctx, GetCourseSubCategoriesByCategoryID, arg.CategoryID, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCourseSubCategoriesByCategoryIDRow
for rows.Next() {
var i GetCourseSubCategoriesByCategoryIDRow
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,

View File

@ -3,11 +3,13 @@ package handlers
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"errors"
"strconv"
"strings"
"unicode/utf8"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
@ -908,6 +910,75 @@ func (h *Handler) ListCourseSubCategories(c *fiber.Ctx) error {
})
}
// ListCourseSubCategoriesByCategory godoc
// @Summary List sub-categories for a course category
// @Description Returns active sub-categories for the given category ID
// @Tags course-management
// @Produce json
// @Param categoryId path int true "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 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/categories/{categoryId}/sub-categories [get]
func (h *Handler) ListCourseSubCategoriesByCategory(c *fiber.Ctx) error {
categoryID, err := strconv.ParseInt(c.Params("categoryId"), 10, 64)
if err != nil || categoryID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid category ID",
Error: "categoryId must be a positive integer",
})
}
if _, err := h.analyticsDB.GetCourseCategoryByID(c.Context(), categoryID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Category not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load category",
Error: err.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.GetCourseSubCategoriesByCategoryID(c.Context(), dbgen.GetCourseSubCategoriesByCategoryIDParams{
CategoryID: categoryID,
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

View File

@ -80,6 +80,7 @@ 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/sub-categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCourseSubCategoriesByCategory)
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)