From 14d94ec723cc697c47b287a9bd2a560fa9bd1180 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Wed, 20 May 2026 07:18:35 -0700 Subject: [PATCH] Honor optional sort_order when creating exam-prep units. Expose sort_order on CreateExamPrepUnitInput; insert applies explicit index with sibling shifting (aligned with LMS course create). Updated Swagger and LMS-Personas Postman collection. Co-authored-by: Cursor --- db/query/exam_prep_units.sql | 21 +++++----- docs/docs.go | 7 +++- docs/swagger.json | 7 +++- docs/swagger.yaml | 10 ++++- gen/db/exam_prep_units.sql.go | 15 ++++--- internal/domain/exam_prep_unit.go | 2 + internal/repository/exam_prep_units.go | 30 ++++++++++++++ .../handlers/exam_prep_unit_handler.go | 2 +- postman/LMS-Personas.postman_collection.json | 39 ++++++++++++------- 9 files changed, 98 insertions(+), 35 deletions(-) diff --git a/db/query/exam_prep_units.sql b/db/query/exam_prep_units.sql index a30d162..6ddaac5 100644 --- a/db/query/exam_prep_units.sql +++ b/db/query/exam_prep_units.sql @@ -1,16 +1,17 @@ -- name: ExamPrepCreateUnit :one INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order) SELECT - $1, - $2, - $3, - $4, - coalesce(( - SELECT - max(u.sort_order) - FROM exam_prep.units u - WHERE - u.catalog_course_id = $1), 0) + 1 + sqlc.arg('catalog_course_id'), + sqlc.arg('name'), + sqlc.arg('description'), + sqlc.arg('thumbnail'), + COALESCE(sqlc.narg('sort_order')::int, + COALESCE(( + SELECT + max(u.sort_order) + FROM exam_prep.units u + WHERE + u.catalog_course_id = sqlc.arg('catalog_course_id')), 0) + 1) RETURNING *; diff --git a/docs/docs.go b/docs/docs.go index 3411d8f..213846d 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1653,7 +1653,7 @@ const docTemplate = `{ } }, "post": { - "description": "Unit under a catalog course (e.g. chapter title)", + "description": "Unit under a catalog course (e.g. chapter title). Optional sort_order assigns position within that catalog course (siblings at or after that index are shifted); omit to append after the current highest sort_order in the catalog course.", "consumes": [ "application/json" ], @@ -10595,6 +10595,11 @@ const docTemplate = `{ "name": { "type": "string" }, + "sort_order": { + "description": "SortOrder within the catalog course when set; omit to append after current max sort_order within catalog_course_id.", + "type": "integer", + "minimum": 0 + }, "thumbnail": { "type": "string" } diff --git a/docs/swagger.json b/docs/swagger.json index 67402dd..651e3b9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1645,7 +1645,7 @@ } }, "post": { - "description": "Unit under a catalog course (e.g. chapter title)", + "description": "Unit under a catalog course (e.g. chapter title). Optional sort_order assigns position within that catalog course (siblings at or after that index are shifted); omit to append after the current highest sort_order in the catalog course.", "consumes": [ "application/json" ], @@ -10587,6 +10587,11 @@ "name": { "type": "string" }, + "sort_order": { + "description": "SortOrder within the catalog course when set; omit to append after current max sort_order within catalog_course_id.", + "type": "integer", + "minimum": 0 + }, "thumbnail": { "type": "string" } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5a87ce0..cb356d9 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -415,6 +415,11 @@ definitions: type: string name: type: string + sort_order: + description: SortOrder within the catalog course when set; omit to append + after current max sort_order within catalog_course_id. + minimum: 0 + type: integer thumbnail: type: string required: @@ -3663,7 +3668,10 @@ paths: post: consumes: - application/json - description: Unit under a catalog course (e.g. chapter title) + description: Unit under a catalog course (e.g. chapter title). Optional sort_order + assigns position within that catalog course (siblings at or after that index + are shifted); omit to append after the current highest sort_order in the catalog + course. parameters: - description: Catalog course ID in: path diff --git a/gen/db/exam_prep_units.sql.go b/gen/db/exam_prep_units.sql.go index 3bb5014..5ebe664 100644 --- a/gen/db/exam_prep_units.sql.go +++ b/gen/db/exam_prep_units.sql.go @@ -18,12 +18,13 @@ SELECT $2, $3, $4, - coalesce(( - SELECT - max(u.sort_order) - FROM exam_prep.units u - WHERE - u.catalog_course_id = $1), 0) + 1 + COALESCE($5::int, + COALESCE(( + SELECT + max(u.sort_order) + FROM exam_prep.units u + WHERE + u.catalog_course_id = $1), 0) + 1) RETURNING id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at ` @@ -33,6 +34,7 @@ type ExamPrepCreateUnitParams struct { Name string `json:"name"` Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder pgtype.Int4 `json:"sort_order"` } func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnitParams) (ExamPrepUnit, error) { @@ -41,6 +43,7 @@ func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnit arg.Name, arg.Description, arg.Thumbnail, + arg.SortOrder, ) var i ExamPrepUnit err := row.Scan( diff --git a/internal/domain/exam_prep_unit.go b/internal/domain/exam_prep_unit.go index 8510438..07e2cc2 100644 --- a/internal/domain/exam_prep_unit.go +++ b/internal/domain/exam_prep_unit.go @@ -22,6 +22,8 @@ type CreateExamPrepUnitInput struct { Name string `json:"name" validate:"required"` Description *string `json:"description,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` + // SortOrder within the catalog course when set; omit to append after current max sort_order within catalog_course_id. + SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"` } type UpdateExamPrepUnitInput struct { diff --git a/internal/repository/exam_prep_units.go b/internal/repository/exam_prep_units.go index 296dd01..3a7d5c4 100644 --- a/internal/repository/exam_prep_units.go +++ b/internal/repository/exam_prep_units.go @@ -29,11 +29,41 @@ func examPrepUnitToDomain(u dbgen.ExamPrepUnit) domain.ExamPrepUnit { } func (s *Store) CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error) { + if input.SortOrder != nil { + q, tx, err := s.BeginTx(ctx) + if err != nil { + return domain.ExamPrepUnit{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + target := int32(*input.SortOrder) + if _, err := tx.Exec(ctx, + `UPDATE exam_prep.units SET sort_order = sort_order + 1 WHERE catalog_course_id = $1 AND sort_order >= $2`, + catalogCourseID, target, + ); err != nil { + return domain.ExamPrepUnit{}, err + } + u, err := q.ExamPrepCreateUnit(ctx, dbgen.ExamPrepCreateUnitParams{ + CatalogCourseID: catalogCourseID, + Name: input.Name, + Description: toPgText(input.Description), + Thumbnail: toPgText(input.Thumbnail), + SortOrder: pgtype.Int4{Int32: target, Valid: true}, + }) + if err != nil { + return domain.ExamPrepUnit{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ExamPrepUnit{}, err + } + return examPrepUnitToDomain(u), nil + } + u, err := s.queries.ExamPrepCreateUnit(ctx, dbgen.ExamPrepCreateUnitParams{ CatalogCourseID: catalogCourseID, Name: input.Name, Description: toPgText(input.Description), Thumbnail: toPgText(input.Thumbnail), + SortOrder: pgtype.Int4{Valid: false}, }) if err != nil { return domain.ExamPrepUnit{}, err diff --git a/internal/web_server/handlers/exam_prep_unit_handler.go b/internal/web_server/handlers/exam_prep_unit_handler.go index 81948f6..23ce9e5 100644 --- a/internal/web_server/handlers/exam_prep_unit_handler.go +++ b/internal/web_server/handlers/exam_prep_unit_handler.go @@ -11,7 +11,7 @@ import ( // CreateExamPrepUnit godoc // @Summary Create exam-prep unit -// @Description Unit under a catalog course (e.g. chapter title) +// @Description Unit under a catalog course (e.g. chapter title). Optional sort_order assigns position within that catalog course (siblings at or after that index are shifted); omit to append after the current highest sort_order in the catalog course. // @Tags exam-prep // @Accept json // @Produce json diff --git a/postman/LMS-Personas.postman_collection.json b/postman/LMS-Personas.postman_collection.json index 455e036..1dbc2c3 100644 --- a/postman/LMS-Personas.postman_collection.json +++ b/postman/LMS-Personas.postman_collection.json @@ -1,8 +1,8 @@ { "info": { - "_postman_id": "c4e8a921-62f3-4c1e-9bad-1107dfd2a701", + "_postman_id": "e2b904c1-a8d7-4132-90c9-4f6619c82b91", "name": "LMS Personas - Catalog CRUD", - "description": "Admin API for LMS persona catalog (`/api/v1/personas`). Requires bearer token with permissions: personas.list, personas.create, personas.get, personas.update, personas.delete. Personas use `persona_id` on practices; payloads include optional `profile_picture` (URL) and optional `gender` (free-form text, nullable in responses).", + "description": "Regenerated against `/api/v1/personas`. Bearer auth; permissions: personas.list, personas.create, personas.get, personas.update, personas.delete.\n\nResponse persona objects include `profile_picture` and `gender` keys always (JSON `null` when unset). Practices reference catalog rows via `persona_id`.\n\nRun folder top-to-bottom: Create captures `persona_id`; Delete removes that row.\nOptional: set collection variable `persona_id` manually (e.g. `1`) for read-only tests without Create/Delete.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "auth": { @@ -45,9 +45,11 @@ " pm.response.to.have.status(201);", "});", "const body = pm.response.json();", - "pm.test(\"Persona returned in data\", function () {", - " pm.expect(body.data).to.be.an(\"object\");", + "pm.test(\"Persona in data\", function () {", + " pm.expect(body.success).to.be.true;", " pm.expect(body.data.id).to.be.a(\"number\");", + " pm.expect(body.data).to.have.property(\"profile_picture\");", + " pm.expect(body.data).to.have.property(\"gender\");", "});", "pm.collectionVariables.set(\"persona_id\", String(body.data.id));" ], @@ -78,12 +80,13 @@ "v1", "personas" ] - } + }, + "description": "Maps to `domain.CreateLmsPersonaInput`: `name` required; others optional. Whitespace-only `gender` omitted on save." }, "response": [] }, { - "name": "List personas (active_only default)", + "name": "List personas (default paging)", "event": [ { "listen": "test", @@ -93,9 +96,11 @@ " pm.response.to.have.status(200);", "});", "const body = pm.response.json();", - "pm.test(\"Paged list shape\", function () {", + "pm.test(\"List envelope\", function () {", " pm.expect(body.data.personas).to.be.an(\"array\");", " pm.expect(body.data.total_count).to.be.a(\"number\");", + " pm.expect(body.data.limit).to.be.a(\"number\");", + " pm.expect(body.data.offset).to.be.a(\"number\");", "});" ], "type": "text/javascript" @@ -118,18 +123,20 @@ { "key": "active_only", "value": "true", - "disabled": true + "description": "Omit or true: only active. false: include inactive." }, { "key": "limit", - "value": "20" + "value": "20", + "description": "Defaults to 20 if invalid omitted; capped at 200 server-side." }, { "key": "offset", "value": "0" } ] - } + }, + "description": "Query `active_only` defaults server-side to true unless value is literally `false`." }, "response": [] }, @@ -176,8 +183,7 @@ " pm.response.to.have.status(200);", "});", "const body = pm.response.json();", - "pm.test(\"Stable persona payload keys\", function () {", - " pm.expect(body.data).to.be.an(\"object\");", + "pm.test(\"Stable persona keys\", function () {", " pm.expect(body.data.name).to.be.a(\"string\");", " pm.expect(body.data).to.have.property(\"profile_picture\");", " pm.expect(body.data).to.have.property(\"gender\");", @@ -215,9 +221,10 @@ " pm.response.to.have.status(200);", "});", "const body = pm.response.json();", - "pm.test(\"Updated data returned\", function () {", + "pm.test(\"Updated persona\", function () {", " pm.expect(body.data.profile_picture).to.include(\"alex-v2\");", " pm.expect(body.data.gender).to.eql(\"neutral\");", + " pm.expect(body.data.name).to.include(\"updated\");", "});" ], "type": "text/javascript" @@ -248,7 +255,8 @@ "personas", "{{persona_id}}" ] - } + }, + "description": "`UpdateLmsPersonaInput`: all fields optional. `\"gender\": \"\"` clears gender. Empty `name` is rejected." }, "response": [] }, @@ -267,7 +275,8 @@ "personas", "{{persona_id}}" ] - } + }, + "description": "Deletes catalog row; practices with this `persona_id` get it cleared (SET NULL)." }, "response": [] }