diff --git a/db/migrations/000038_levels_flexible_cefr_level.down.sql b/db/migrations/000038_levels_flexible_cefr_level.down.sql new file mode 100644 index 0000000..179bd01 --- /dev/null +++ b/db/migrations/000038_levels_flexible_cefr_level.down.sql @@ -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')); diff --git a/db/migrations/000038_levels_flexible_cefr_level.up.sql b/db/migrations/000038_levels_flexible_cefr_level.up.sql new file mode 100644 index 0000000..ad05188 --- /dev/null +++ b/db/migrations/000038_levels_flexible_cefr_level.up.sql @@ -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); diff --git a/docs/docs.go b/docs/docs.go index ad66a02..a6b8cec 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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 (1–64 characters), unique per course; optional title defaults to that value; optional description and thumbnail", "consumes": [ "application/json" ], diff --git a/docs/swagger.json b/docs/swagger.json index 05f404c..acd641e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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 (1–64 characters), unique per course; optional title defaults to that value; optional description and thumbnail", "consumes": [ "application/json" ], diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0a25b12..8338477 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 (1–64 characters), unique per course; optional title defaults to + that value; optional description and thumbnail parameters: - description: Create level payload in: body diff --git a/internal/web_server/handlers/hierarchy_handler.go b/internal/web_server/handlers/hierarchy_handler.go index 5f23866..23fc869 100644 --- a/internal/web_server/handlers/hierarchy_handler.go +++ b/internal/web_server/handlers/hierarchy_handler.go @@ -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 (1–64 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),