From 57fd3b483731624e9d31963c261dcfb6ab755d59 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 7 Aug 2026 03:40:40 +0200 Subject: [PATCH] Add customergroup bulk reorder and list editable flag. Expose PUT /customergroup/order for exact-set sort updates, keep create/update name-only, and mark POS/Lieferanten as non-editable in list responses. --- docs/api/README.md | 1 + docs/api/openapi.json | 141 ++++++++++++++++++++++-- docs/api/routes/customergroup/create.md | 8 +- docs/api/routes/customergroup/list.md | 9 +- docs/api/routes/customergroup/order.md | 48 ++++++++ docs/api/routes/customergroup/update.md | 9 +- src/api/openapi.js | 16 ++- src/api/routes/customergroup.js | 130 ++++++++++++++++++++-- src/db/customergroup.js | 5 + 9 files changed, 344 insertions(+), 23 deletions(-) create mode 100644 docs/api/routes/customergroup/order.md diff --git a/docs/api/README.md b/docs/api/README.md index 6c4a328..23d2e6a 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -67,6 +67,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/company/update.md`](routes/company/update.md) | `PUT /company/{id}` | | [`routes/customergroup/list.md`](routes/customergroup/list.md) | `GET /customergroup` | | [`routes/customergroup/create.md`](routes/customergroup/create.md) | `POST /customergroup` | +| [`routes/customergroup/order.md`](routes/customergroup/order.md) | `PUT /customergroup/order` | | [`routes/customergroup/delete.md`](routes/customergroup/delete.md) | `DELETE /customergroup/{id}` | | [`routes/customergroup/update.md`](routes/customergroup/update.md) | `PATCH /customergroup/{id}` | | [`routes/delivery/list.md`](routes/delivery/list.md) | `GET /delivery` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index d407e55..ad498da 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -4193,7 +4193,7 @@ "Customergroup" ], "summary": "List customer groups", - "description": "Lists customer groups for the authenticated user's selected company.", + "description": "Lists customer groups for the selected company, ordered by `sort`.\nEach item includes `editable`: `false` for implicit `POS` / `Lieferanten`\n(name locked), `true` otherwise. Reorder via `PUT /customergroup/order`.\n", "security": [ { "cookieAuth": [] @@ -4211,7 +4211,7 @@ "customergroups": { "type": "array", "items": { - "$ref": "#/components/schemas/Customergroup" + "$ref": "#/components/schemas/CustomergroupListItem" } } } @@ -4266,7 +4266,7 @@ "Customergroup" ], "summary": "Create customer group", - "description": "Creates a customer group for the selected company.", + "description": "Creates a customer group for the selected company.\nAppends with the next `sort` index. Reserved names `POS` and `Lieferanten`\nare not allowed (`Name not allowed`).\n", "security": [ { "cookieAuth": [] @@ -4311,7 +4311,111 @@ } }, "400": { - "description": "Name required or no company selected", + "description": "Name required, name not allowed, or no company selected", + "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": "Company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/customergroup/order": { + "put": { + "tags": [ + "Customergroup" + ], + "summary": "Reorder customer groups", + "description": "Sets `sort` to each id’s index `0…n-1` in one transaction.\n`customergroupIds` must be exactly the company’s groups (same set, no dups)\nelse `Invalid customer group order`.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "customergroup/order.md", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "customergroupIds" + ], + "properties": { + "customergroupIds": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "example": { + "customergroupIds": [ + 4, + 3, + 1, + 2 + ] + } + } + } + } + }, + "responses": { + "200": { + "description": "New order", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "customergroupIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid customer group order or no company selected", "content": { "application/json": { "schema": { @@ -4359,7 +4463,7 @@ "Customergroup" ], "summary": "Update customer group", - "description": "Renames a customer group for the selected company.", + "description": "Renames a customer group. Implicit `POS` / `Lieferanten` cannot be\nrenamed (`Name cannot be changed`). Reorder via `PUT /customergroup/order`.\n", "security": [ { "cookieAuth": [] @@ -4414,7 +4518,7 @@ } }, "400": { - "description": "Name required, invalid id, or no company selected", + "description": "Name required, name cannot be changed, invalid id, or no company selected", "content": { "application/json": { "schema": { @@ -7925,7 +8029,8 @@ "type": "object", "required": [ "id", - "name" + "name", + "sort" ], "properties": { "id": { @@ -7933,9 +8038,31 @@ }, "name": { "type": "string" + }, + "sort": { + "type": "integer" } } }, + "CustomergroupListItem": { + "allOf": [ + { + "$ref": "#/components/schemas/Customergroup" + }, + { + "type": "object", + "required": [ + "editable" + ], + "properties": { + "editable": { + "type": "boolean", + "description": "`false` for implicit groups `POS` and `Lieferanten` (name locked).\n`true` for user-created groups. Reorder all groups via\n`PUT /customergroup/order`.\n" + } + } + } + ] + }, "Entity": { "type": "object", "required": [ diff --git a/docs/api/routes/customergroup/create.md b/docs/api/routes/customergroup/create.md index 117f01d..a305282 100644 --- a/docs/api/routes/customergroup/create.md +++ b/docs/api/routes/customergroup/create.md @@ -5,6 +5,9 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Create customer group Creates a customer group for the selected company. +Appends with the next `sort` index. Reserved names `POS` and `Lieferanten` +are not allowed (`Name not allowed`). + ## Body (JSON) @@ -22,7 +25,8 @@ Created customer group { "customergroup": { "id": 1, - "name": "…" + "name": "…", + "sort": 1 } } ``` @@ -31,7 +35,7 @@ Created customer group | Status | Description | |--------|-------------| -| 400 | Name required or no company selected — `{"error":"…"}` | +| 400 | Name required, name not allowed, or no company selected — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` | | 404 | Company not found — `{"error":"…"}` | diff --git a/docs/api/routes/customergroup/list.md b/docs/api/routes/customergroup/list.md index e37d59e..a74b5bd 100644 --- a/docs/api/routes/customergroup/list.md +++ b/docs/api/routes/customergroup/list.md @@ -4,7 +4,10 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). List customer groups -Lists customer groups for the authenticated user's selected company. +Lists customer groups for the selected company, ordered by `sort`. +Each item includes `editable`: `false` for implicit `POS` / `Lieferanten` +(name locked), `true` otherwise. Reorder via `PUT /customergroup/order`. + ## Response `200` @@ -15,7 +18,9 @@ Customer group list "customergroups": [ { "id": 1, - "name": "…" + "name": "…", + "sort": 1, + "editable": true } ] } diff --git a/docs/api/routes/customergroup/order.md b/docs/api/routes/customergroup/order.md new file mode 100644 index 0000000..f876e8e --- /dev/null +++ b/docs/api/routes/customergroup/order.md @@ -0,0 +1,48 @@ +# `PUT /customergroup/order` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Reorder customer groups + +Sets `sort` to each id’s index `0…n-1` in one transaction. +`customergroupIds` must be exactly the company’s groups (same set, no dups) +else `Invalid customer group order`. + + +## Body (JSON) + +```json +{ + "customergroupIds": [ + 4, + 3, + 1, + 2 + ] +} +``` + +## Response `200` + +New order + +```json +{ + "customergroupIds": [ + 1 + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid customer group order or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/customergroup/update.md b/docs/api/routes/customergroup/update.md index 1e2412f..9961f34 100644 --- a/docs/api/routes/customergroup/update.md +++ b/docs/api/routes/customergroup/update.md @@ -4,7 +4,9 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Update customer group -Renames a customer group for the selected company. +Renames a customer group. Implicit `POS` / `Lieferanten` cannot be +renamed (`Name cannot be changed`). Reorder via `PUT /customergroup/order`. + ## Path parameters @@ -28,7 +30,8 @@ Updated customer group { "customergroup": { "id": 1, - "name": "…" + "name": "…", + "sort": 1 } } ``` @@ -37,7 +40,7 @@ Updated customer group | Status | Description | |--------|-------------| -| 400 | Name required, invalid id, or no company selected — `{"error":"…"}` | +| 400 | Name required, name cannot be changed, invalid id, or no company selected — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` | | 404 | Customer group or company not found — `{"error":"…"}` | diff --git a/src/api/openapi.js b/src/api/openapi.js index 0811161..3647585 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -126,12 +126,26 @@ * format: date-time * Customergroup: * type: object - * required: [id, name] + * required: [id, name, sort] * properties: * id: * type: integer * name: * type: string + * sort: + * type: integer + * CustomergroupListItem: + * allOf: + * - $ref: '#/components/schemas/Customergroup' + * - type: object + * required: [editable] + * properties: + * editable: + * type: boolean + * description: | + * `false` for implicit groups `POS` and `Lieferanten` (name locked). + * `true` for user-created groups. Reorder all groups via + * `PUT /customergroup/order`. * Entity: * type: object * required: [id, customergroupId, firstName, name, createdAt, updatedAt] diff --git a/src/api/routes/customergroup.js b/src/api/routes/customergroup.js index 06c3b11..bbec8af 100644 --- a/src/api/routes/customergroup.js +++ b/src/api/routes/customergroup.js @@ -1,5 +1,8 @@ import * as db from '../../db/customergroup.js'; +/** Implicit groups created by the system — name locked. */ +const LOCKED_NAMES = new Set(['POS', 'Lieferanten']); + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -13,10 +16,20 @@ function customergroupErrorStatus(error) { if (error.message === 'Company not found') return 404; if (error.message === 'Customer group not found') return 404; if (error.message === 'Name required') return 400; + if (error.message === 'Name not allowed') return 400; + if (error.message === 'Name cannot be changed') return 400; + if (error.message === 'Invalid customer group order') return 400; if (error.message === 'Customer group in use') return 409; return 500; } +function withEditable(row) { + return { + ...row, + editable: !LOCKED_NAMES.has(row.name), + }; +} + /** * Registers customergroup routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -29,7 +42,10 @@ export function register(app, basePath = '') { * get: * tags: [Customergroup] * summary: List customer groups - * description: Lists customer groups for the authenticated user's selected company. + * description: | + * Lists customer groups for the selected company, ordered by `sort`. + * Each item includes `editable`: `false` for implicit `POS` / `Lieferanten` + * (name locked), `true` otherwise. Reorder via `PUT /customergroup/order`. * security: * - cookieAuth: [] * x-markdown: customergroup/list.md @@ -44,7 +60,7 @@ export function register(app, basePath = '') { * customergroups: * type: array * items: - * $ref: '#/components/schemas/Customergroup' + * $ref: '#/components/schemas/CustomergroupListItem' * 400: * description: No company selected * content: @@ -72,7 +88,10 @@ export function register(app, basePath = '') { * post: * tags: [Customergroup] * summary: Create customer group - * description: Creates a customer group for the selected company. + * description: | + * Creates a customer group for the selected company. + * Appends with the next `sort` index. Reserved names `POS` and `Lieferanten` + * are not allowed (`Name not allowed`). * security: * - cookieAuth: [] * x-markdown: customergroup/create.md @@ -99,7 +118,7 @@ export function register(app, basePath = '') { * customergroup: * $ref: '#/components/schemas/Customergroup' * 400: - * description: Name required or no company selected + * description: Name required, name not allowed, or no company selected * content: * application/json: * schema: @@ -130,8 +149,8 @@ export function register(app, basePath = '') { } try { - const customergroups = await db.list(req.user.id, selected.companyId); - res.status(200).json({ customergroups }); + const rows = await db.list(req.user.id, selected.companyId); + res.status(200).json({ customergroups: rows.map(withEditable) }); } catch (error) { const status = customergroupErrorStatus(error); if (status === 500) console.error('List customergroups failed:', error); @@ -157,13 +176,108 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /customergroup/order: + * put: + * tags: [Customergroup] + * summary: Reorder customer groups + * description: | + * Sets `sort` to each id’s index `0…n-1` in one transaction. + * `customergroupIds` must be exactly the company’s groups (same set, no dups) + * else `Invalid customer group order`. + * security: + * - cookieAuth: [] + * x-markdown: customergroup/order.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [customergroupIds] + * properties: + * customergroupIds: + * type: array + * items: + * type: integer + * example: + * customergroupIds: [4, 3, 1, 2] + * responses: + * 200: + * description: New order + * content: + * application/json: + * schema: + * type: object + * properties: + * customergroupIds: + * type: array + * items: + * type: integer + * 400: + * description: Invalid customer group order or no company selected + * 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: Company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.put(`${basePath}/customergroup/order`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const rawIds = req.body?.customergroupIds; + if (!Array.isArray(rawIds)) { + return res.status(400).json({ error: 'customergroupIds is required' }); + } + const customergroupIds = []; + for (const value of rawIds) { + const id = Number(value); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'customergroupIds must be positive integers' }); + } + customergroupIds.push(id); + } + + try { + const result = await db.order(req.user.id, selected.companyId, { customergroupIds }); + res.status(200).json(result); + } catch (error) { + const status = customergroupErrorStatus(error); + if (status === 500) console.error('Order customergroups failed:', error); + res.status(status).json({ error: error.message }); + } + }); + /** * @openapi * /customergroup/{id}: * patch: * tags: [Customergroup] * summary: Update customer group - * description: Renames a customer group for the selected company. + * description: | + * Renames a customer group. Implicit `POS` / `Lieferanten` cannot be + * renamed (`Name cannot be changed`). Reorder via `PUT /customergroup/order`. * security: * - cookieAuth: [] * x-markdown: customergroup/update.md @@ -196,7 +310,7 @@ export function register(app, basePath = '') { * customergroup: * $ref: '#/components/schemas/Customergroup' * 400: - * description: Name required, invalid id, or no company selected + * description: Name required, name cannot be changed, invalid id, or no company selected * content: * application/json: * schema: diff --git a/src/db/customergroup.js b/src/db/customergroup.js index db242e2..e208b71 100644 --- a/src/db/customergroup.js +++ b/src/db/customergroup.js @@ -15,6 +15,11 @@ export async function update(googleAuthId, companyId, id, data) { return store.customergroup.update(googleAuthId, companyId, id, data); } +export async function order(googleAuthId, companyId, data) { + const store = await getStore(); + return store.customergroup.order(googleAuthId, companyId, data); +} + export async function remove(googleAuthId, companyId, id) { const store = await getStore(); return store.customergroup.delete(googleAuthId, companyId, id);