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": {
"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": [
"application/json"
],

View File

@ -1514,7 +1514,7 @@
}
},
"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": [
"application/json"
],

View File

@ -3095,8 +3095,9 @@ paths:
post:
consumes:
- application/json
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
parameters:
- description: Create level payload
in: body

View File

@ -5,6 +5,7 @@ import (
"Yimaru-Backend/internal/domain"
"strconv"
"strings"
"unicode/utf8"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgtype"
@ -1494,7 +1495,7 @@ func (h *Handler) DeleteCourseSubCategory(c *fiber.Ctx) error {
// CreateLevel godoc
// @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
// @Accept json
// @Produce json
@ -1508,12 +1509,18 @@ func (h *Handler) CreateLevel(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
req.CEFRLevel = strings.ToUpper(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 || !validCEFR[req.CEFRLevel] {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and valid cefr_level are required"})
cefr := strings.TrimSpace(req.CEFRLevel)
if req.CourseID <= 0 || cefr == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and 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 t := strings.TrimSpace(*req.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{
CourseID: req.CourseID,
CefrLevel: req.CEFRLevel,
CefrLevel: cefr,
Title: title,
Description: toText(req.Description),
Thumbnail: toText(req.Thumbnail),