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:
parent
7ff0b639cf
commit
886b62ed68
7
db/migrations/000038_levels_flexible_cefr_level.down.sql
Normal file
7
db/migrations/000038_levels_flexible_cefr_level.down.sql
Normal 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'));
|
||||||
20
db/migrations/000038_levels_flexible_cefr_level.up.sql
Normal file
20
db/migrations/000038_levels_flexible_cefr_level.up.sql
Normal 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);
|
||||||
|
|
@ -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 (1–64 characters), unique per course; optional title defaults to that value; optional description and thumbnail",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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 (1–64 characters), unique per course; optional title defaults to that value; optional description and thumbnail",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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 (1–64 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
|
||||||
|
|
|
||||||
|
|
@ -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 (1–64 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),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user