From faefa3b9cf56848688d8434537ecea121c96551d Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 3 Aug 2026 03:01:53 +0200 Subject: [PATCH] Add POST /category/:id/move to relocate a category. Wires up the existing category.move store method (validates source and target in the same company, rejects self/descendant cycles, assigns the next sibling sort) behind an authenticated HTTP route, plus @openapi docs and regenerated API docs. --- README.md | 1 + docs/api/README.md | 1 + docs/api/openapi.json | 121 +++++++++++++++++++++++++++++++ docs/api/routes/category/move.md | 48 ++++++++++++ src/api/routes/category.js | 99 +++++++++++++++++++++++++ src/db/category.js | 5 ++ 6 files changed, 275 insertions(+) create mode 100644 docs/api/routes/category/move.md diff --git a/README.md b/README.md index d071dde..967aad2 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ That writes `docs/api/openapi.json` and per-route Markdown under `docs/api/route | --- | --- | --- | | `PATCH` | `/api/category/:id` | `category.rename` (Name + Slug in Firmensprache) | | `PUT` | `/api/category/order` | `category.order` (Geschwister-Sortierung unter einem Elternteil) | +| `POST` | `/api/category/:id/move` | `category.move` (Kategorie unter neues Elternteil verschieben) | ## Users diff --git a/docs/api/README.md b/docs/api/README.md index 41d5567..8e182d1 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -55,6 +55,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/category/delete.md`](routes/category/delete.md) | `DELETE /category/{id}` | | [`routes/category/rename.md`](routes/category/rename.md) | `PATCH /category/{id}` | | [`routes/category/articles.md`](routes/category/articles.md) | `GET /category/{id}/articles` | +| [`routes/category/move.md`](routes/category/move.md) | `POST /category/{id}/move` | | [`routes/company/list.md`](routes/company/list.md) | `GET /company` | | [`routes/company/create.md`](routes/company/create.md) | `POST /company` | | [`routes/company/delete.md`](routes/company/delete.md) | `DELETE /company/{id}` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 5ac14f5..4ec0813 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1424,6 +1424,127 @@ } } }, + "/category/{id}/move": { + "post": { + "tags": [ + "Category" + ], + "summary": "Move category to a new parent", + "description": "Moves a category to a new parent. Both source and target must be in\nthe same authorized company. Moving into itself or into a descendant\n(cycle) fails with `Invalid parent`. The moved category gets the next\nsibling `sort` under the new parent.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "category/move.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Category id to move" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "parentId" + ], + "properties": { + "parentId": { + "type": "integer", + "description": "New parent category id" + } + }, + "example": { + "parentId": 3 + } + } + } + } + }, + "responses": { + "200": { + "description": "Moved category", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "category": { + "type": "object", + "required": [ + "id", + "parentId", + "sort" + ], + "properties": { + "id": { + "type": "integer" + }, + "parentId": { + "type": "integer" + }, + "sort": { + "type": "integer" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid parent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Category not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/brand": { "get": { "tags": [ diff --git a/docs/api/routes/category/move.md b/docs/api/routes/category/move.md new file mode 100644 index 0000000..a32f6e9 --- /dev/null +++ b/docs/api/routes/category/move.md @@ -0,0 +1,48 @@ +# `POST /category/{id}/move` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Move category to a new parent + +Moves a category to a new parent. Both source and target must be in +the same authorized company. Moving into itself or into a descendant +(cycle) fails with `Invalid parent`. The moved category gets the next +sibling `sort` under the new parent. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Category id to move | + +## Body (JSON) + +```json +{ + "parentId": 3 +} +``` + +## Response `200` + +Moved category + +```json +{ + "category": { … } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid parent — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Category not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/routes/category.js b/src/api/routes/category.js index 32d4d10..dabffa9 100644 --- a/src/api/routes/category.js +++ b/src/api/routes/category.js @@ -16,6 +16,7 @@ function categoryErrorStatus(error) { if (error.message === 'Name required') return 400; if (error.message === 'Category not found') return 404; if (error.message === 'Invalid category order') return 400; + if (error.message === 'Invalid parent') return 400; return 500; } @@ -494,4 +495,102 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + /** + * @openapi + * /category/{id}/move: + * post: + * tags: [Category] + * summary: Move category to a new parent + * description: | + * Moves a category to a new parent. Both source and target must be in + * the same authorized company. Moving into itself or into a descendant + * (cycle) fails with `Invalid parent`. The moved category gets the next + * sibling `sort` under the new parent. + * security: + * - cookieAuth: [] + * x-markdown: category/move.md + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Category id to move + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [parentId] + * properties: + * parentId: + * type: integer + * description: New parent category id + * example: + * parentId: 3 + * responses: + * 200: + * description: Moved category + * content: + * application/json: + * schema: + * type: object + * properties: + * category: + * type: object + * required: [id, parentId, sort] + * properties: + * id: + * type: integer + * parentId: + * type: integer + * sort: + * type: integer + * 400: + * description: Invalid parent + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Category not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.post(`${basePath}/category/:id/move`, async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id is required' }); + } + + const parentId = Number(req.body?.parentId); + if (!Number.isInteger(parentId) || parentId <= 0) { + return res.status(400).json({ error: 'parentId is required' }); + } + + try { + const category = await db.move({ googleAuthId: req.user.id, id, parentId }); + res.status(200).json({ category }); + } catch (error) { + const status = categoryErrorStatus(error); + if (status === 500) console.error('Move category failed:', error); + res.status(status).json({ error: error.message }); + } + }); } diff --git a/src/db/category.js b/src/db/category.js index ded8692..7ac46cd 100644 --- a/src/db/category.js +++ b/src/db/category.js @@ -29,3 +29,8 @@ export async function order({ googleAuthId, parentId, categoryIds }) { const store = await getStore(); return store.category.order({ googleAuthId, parentId, categoryIds }); } + +export async function move({ googleAuthId, id, parentId }) { + const store = await getStore(); + return store.category.move({ googleAuthId, id, parentId }); +}