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 <cursoragent@cursor.com>
This commit is contained in:
parent
5399d33af6
commit
14d94ec723
|
|
@ -1,16 +1,17 @@
|
||||||
-- name: ExamPrepCreateUnit :one
|
-- name: ExamPrepCreateUnit :one
|
||||||
INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order)
|
INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order)
|
||||||
SELECT
|
SELECT
|
||||||
$1,
|
sqlc.arg('catalog_course_id'),
|
||||||
$2,
|
sqlc.arg('name'),
|
||||||
$3,
|
sqlc.arg('description'),
|
||||||
$4,
|
sqlc.arg('thumbnail'),
|
||||||
coalesce((
|
COALESCE(sqlc.narg('sort_order')::int,
|
||||||
|
COALESCE((
|
||||||
SELECT
|
SELECT
|
||||||
max(u.sort_order)
|
max(u.sort_order)
|
||||||
FROM exam_prep.units u
|
FROM exam_prep.units u
|
||||||
WHERE
|
WHERE
|
||||||
u.catalog_course_id = $1), 0) + 1
|
u.catalog_course_id = sqlc.arg('catalog_course_id')), 0) + 1)
|
||||||
RETURNING
|
RETURNING
|
||||||
*;
|
*;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1653,7 +1653,7 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"post": {
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|
@ -10595,6 +10595,11 @@ const docTemplate = `{
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"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": {
|
"thumbnail": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1645,7 +1645,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"post": {
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|
@ -10587,6 +10587,11 @@
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"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": {
|
"thumbnail": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,11 @@ definitions:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
type: string
|
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:
|
thumbnail:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
|
|
@ -3663,7 +3668,10 @@ paths:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- 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:
|
parameters:
|
||||||
- description: Catalog course ID
|
- description: Catalog course ID
|
||||||
in: path
|
in: path
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ SELECT
|
||||||
$2,
|
$2,
|
||||||
$3,
|
$3,
|
||||||
$4,
|
$4,
|
||||||
coalesce((
|
COALESCE($5::int,
|
||||||
|
COALESCE((
|
||||||
SELECT
|
SELECT
|
||||||
max(u.sort_order)
|
max(u.sort_order)
|
||||||
FROM exam_prep.units u
|
FROM exam_prep.units u
|
||||||
WHERE
|
WHERE
|
||||||
u.catalog_course_id = $1), 0) + 1
|
u.catalog_course_id = $1), 0) + 1)
|
||||||
RETURNING
|
RETURNING
|
||||||
id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
|
id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
@ -33,6 +34,7 @@ type ExamPrepCreateUnitParams struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
|
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnitParams) (ExamPrepUnit, error) {
|
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.Name,
|
||||||
arg.Description,
|
arg.Description,
|
||||||
arg.Thumbnail,
|
arg.Thumbnail,
|
||||||
|
arg.SortOrder,
|
||||||
)
|
)
|
||||||
var i ExamPrepUnit
|
var i ExamPrepUnit
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ type CreateExamPrepUnitInput struct {
|
||||||
Name string `json:"name" validate:"required"`
|
Name string `json:"name" validate:"required"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Thumbnail *string `json:"thumbnail,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 {
|
type UpdateExamPrepUnitInput struct {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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{
|
u, err := s.queries.ExamPrepCreateUnit(ctx, dbgen.ExamPrepCreateUnitParams{
|
||||||
CatalogCourseID: catalogCourseID,
|
CatalogCourseID: catalogCourseID,
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
Description: toPgText(input.Description),
|
Description: toPgText(input.Description),
|
||||||
Thumbnail: toPgText(input.Thumbnail),
|
Thumbnail: toPgText(input.Thumbnail),
|
||||||
|
SortOrder: pgtype.Int4{Valid: false},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ExamPrepUnit{}, err
|
return domain.ExamPrepUnit{}, err
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
|
|
||||||
// CreateExamPrepUnit godoc
|
// CreateExamPrepUnit godoc
|
||||||
// @Summary Create exam-prep unit
|
// @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
|
// @Tags exam-prep
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "c4e8a921-62f3-4c1e-9bad-1107dfd2a701",
|
"_postman_id": "e2b904c1-a8d7-4132-90c9-4f6619c82b91",
|
||||||
"name": "LMS Personas - Catalog CRUD",
|
"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"
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|
@ -45,9 +45,11 @@
|
||||||
" pm.response.to.have.status(201);",
|
" pm.response.to.have.status(201);",
|
||||||
"});",
|
"});",
|
||||||
"const body = pm.response.json();",
|
"const body = pm.response.json();",
|
||||||
"pm.test(\"Persona returned in data\", function () {",
|
"pm.test(\"Persona in data\", function () {",
|
||||||
" pm.expect(body.data).to.be.an(\"object\");",
|
" pm.expect(body.success).to.be.true;",
|
||||||
" pm.expect(body.data.id).to.be.a(\"number\");",
|
" 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));"
|
"pm.collectionVariables.set(\"persona_id\", String(body.data.id));"
|
||||||
],
|
],
|
||||||
|
|
@ -78,12 +80,13 @@
|
||||||
"v1",
|
"v1",
|
||||||
"personas"
|
"personas"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"description": "Maps to `domain.CreateLmsPersonaInput`: `name` required; others optional. Whitespace-only `gender` omitted on save."
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "List personas (active_only default)",
|
"name": "List personas (default paging)",
|
||||||
"event": [
|
"event": [
|
||||||
{
|
{
|
||||||
"listen": "test",
|
"listen": "test",
|
||||||
|
|
@ -93,9 +96,11 @@
|
||||||
" pm.response.to.have.status(200);",
|
" pm.response.to.have.status(200);",
|
||||||
"});",
|
"});",
|
||||||
"const body = pm.response.json();",
|
"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.personas).to.be.an(\"array\");",
|
||||||
" pm.expect(body.data.total_count).to.be.a(\"number\");",
|
" 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"
|
"type": "text/javascript"
|
||||||
|
|
@ -118,18 +123,20 @@
|
||||||
{
|
{
|
||||||
"key": "active_only",
|
"key": "active_only",
|
||||||
"value": "true",
|
"value": "true",
|
||||||
"disabled": true
|
"description": "Omit or true: only active. false: include inactive."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "limit",
|
"key": "limit",
|
||||||
"value": "20"
|
"value": "20",
|
||||||
|
"description": "Defaults to 20 if invalid omitted; capped at 200 server-side."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "offset",
|
"key": "offset",
|
||||||
"value": "0"
|
"value": "0"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"description": "Query `active_only` defaults server-side to true unless value is literally `false`."
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
},
|
||||||
|
|
@ -176,8 +183,7 @@
|
||||||
" pm.response.to.have.status(200);",
|
" pm.response.to.have.status(200);",
|
||||||
"});",
|
"});",
|
||||||
"const body = pm.response.json();",
|
"const body = pm.response.json();",
|
||||||
"pm.test(\"Stable persona payload keys\", function () {",
|
"pm.test(\"Stable persona keys\", function () {",
|
||||||
" pm.expect(body.data).to.be.an(\"object\");",
|
|
||||||
" pm.expect(body.data.name).to.be.a(\"string\");",
|
" 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(\"profile_picture\");",
|
||||||
" pm.expect(body.data).to.have.property(\"gender\");",
|
" pm.expect(body.data).to.have.property(\"gender\");",
|
||||||
|
|
@ -215,9 +221,10 @@
|
||||||
" pm.response.to.have.status(200);",
|
" pm.response.to.have.status(200);",
|
||||||
"});",
|
"});",
|
||||||
"const body = pm.response.json();",
|
"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.profile_picture).to.include(\"alex-v2\");",
|
||||||
" pm.expect(body.data.gender).to.eql(\"neutral\");",
|
" pm.expect(body.data.gender).to.eql(\"neutral\");",
|
||||||
|
" pm.expect(body.data.name).to.include(\"updated\");",
|
||||||
"});"
|
"});"
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
|
|
@ -248,7 +255,8 @@
|
||||||
"personas",
|
"personas",
|
||||||
"{{persona_id}}"
|
"{{persona_id}}"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"description": "`UpdateLmsPersonaInput`: all fields optional. `\"gender\": \"\"` clears gender. Empty `name` is rejected."
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
},
|
||||||
|
|
@ -267,7 +275,8 @@
|
||||||
"personas",
|
"personas",
|
||||||
"{{persona_id}}"
|
"{{persona_id}}"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"description": "Deletes catalog row; practices with this `persona_id` get it cleared (SET NULL)."
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user