feat(levels): flexible cefr_level codes up to 64 chars

- Migration 000038 drops fixed A1-C3 check and widens cefr_level column
- CreateLevel validates length and NUL only; preserve client casing
- Regenerate Swagger docs

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-17 09:24:34 -07:00
parent 7ff0b639cf
commit 886b62ed68
6 changed files with 46 additions and 11 deletions

View File

@ -0,0 +1,7 @@
-- Restores fixed CEFR list; fails if any row has cefr_level outside the old set or longer than 2 characters.
ALTER TABLE levels
ALTER COLUMN cefr_level TYPE VARCHAR(2);
ALTER TABLE levels
ADD CONSTRAINT levels_cefr_level_check
CHECK (cefr_level IN ('A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3'));

View File

@ -0,0 +1,20 @@
-- Allow arbitrary level codes/labels per course (not only fixed CEFR bands).
DO $$
DECLARE
con_name text;
BEGIN
SELECT c.conname INTO con_name
FROM pg_constraint c
JOIN pg_class t ON c.conrelid = t.oid
JOIN pg_namespace n ON t.relnamespace = n.oid
WHERE n.nspname = current_schema()
AND t.relname = 'levels'
AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) LIKE '%cefr_level%IN (%A1%';
IF con_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE levels DROP CONSTRAINT %I', con_name);
END IF;
END $$;
ALTER TABLE levels
ALTER COLUMN cefr_level TYPE VARCHAR(64);

View File

@ -1522,7 +1522,7 @@ const docTemplate = `{
} }
}, },
"post": { "post": {
"description": "Creates a CEFR level under a course with optional title (defaults to cefr_level), description, and thumbnail", "description": "Creates a level under a course. cefr_level is a short level code or label (164 characters), unique per course; optional title defaults to that value; optional description and thumbnail",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],

View File

@ -1514,7 +1514,7 @@
} }
}, },
"post": { "post": {
"description": "Creates a CEFR level under a course with optional title (defaults to cefr_level), description, and thumbnail", "description": "Creates a level under a course. cefr_level is a short level code or label (164 characters), unique per course; optional title defaults to that value; optional description and thumbnail",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],

View File

@ -3095,8 +3095,9 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: Creates a CEFR level under a course with optional title (defaults description: Creates a level under a course. cefr_level is a short level code
to cefr_level), description, and thumbnail or label (164 characters), unique per course; optional title defaults to
that value; optional description and thumbnail
parameters: parameters:
- description: Create level payload - description: Create level payload
in: body in: body

View File

@ -5,6 +5,7 @@ import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@ -1494,7 +1495,7 @@ func (h *Handler) DeleteCourseSubCategory(c *fiber.Ctx) error {
// CreateLevel godoc // CreateLevel godoc
// @Summary Create level // @Summary Create level
// @Description Creates a CEFR level under a course with optional title (defaults to cefr_level), description, and thumbnail // @Description Creates a level under a course. cefr_level is a short level code or label (164 characters), unique per course; optional title defaults to that value; optional description and thumbnail
// @Tags course-management // @Tags course-management
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -1508,12 +1509,18 @@ func (h *Handler) CreateLevel(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
} }
req.CEFRLevel = strings.ToUpper(strings.TrimSpace(req.CEFRLevel)) cefr := strings.TrimSpace(req.CEFRLevel)
validCEFR := map[string]bool{"A1": true, "A2": true, "A3": true, "B1": true, "B2": true, "B3": true, "C1": true, "C2": true, "C3": true} if req.CourseID <= 0 || cefr == "" {
if req.CourseID <= 0 || !validCEFR[req.CEFRLevel] { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and cefr_level are required"})
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and valid cefr_level are required"})
} }
title := req.CEFRLevel if strings.Contains(cefr, "\x00") {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "cefr_level must not contain NUL characters"})
}
const maxCefrLevelRunes = 64
if utf8.RuneCountInString(cefr) > maxCefrLevelRunes {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "cefr_level must be at most 64 characters"})
}
title := cefr
if req.Title != nil { if req.Title != nil {
if t := strings.TrimSpace(*req.Title); t != "" { if t := strings.TrimSpace(*req.Title); t != "" {
title = t title = t
@ -1521,7 +1528,7 @@ func (h *Handler) CreateLevel(c *fiber.Ctx) error {
} }
created, err := h.analyticsDB.CreateLevel(c.Context(), dbgen.CreateLevelParams{ created, err := h.analyticsDB.CreateLevel(c.Context(), dbgen.CreateLevelParams{
CourseID: req.CourseID, CourseID: req.CourseID,
CefrLevel: req.CEFRLevel, CefrLevel: cefr,
Title: title, Title: title,
Description: toText(req.Description), Description: toText(req.Description),
Thumbnail: toText(req.Thumbnail), Thumbnail: toText(req.Thumbnail),