From 57420ac0db6b850141ac4973439164d1010436d4 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 8 Aug 2026 09:01:28 +0200 Subject: [PATCH] Add company settings and number sequence API routes - Add CompanySetting routes (list, get, set) backed by company-scoped store - Add Number routes (list, update) for managing number sequences - Register new store namespaces for numbers and companysetting - Add CompanySetting and NumberSequence schemas to OpenAPI - Regenerate API docs and index for the new routes --- docs/api/README.md | 5 + docs/api/openapi.json | 497 +++++++++++++++++++++++++ docs/api/routes/companysetting/get.md | 41 ++ docs/api/routes/companysetting/list.md | 39 ++ docs/api/routes/companysetting/set.md | 51 +++ docs/api/routes/number/list.md | 38 ++ docs/api/routes/number/update.md | 57 +++ scripts/generateApiDocs.js | 1 + src/api/openapi.js | 26 ++ src/api/routes/companysetting.js | 299 +++++++++++++++ src/api/routes/number.js | 196 ++++++++++ src/db/companysetting.js | 16 + src/db/numbers.js | 11 + src/db/store.js | 2 + 14 files changed, 1279 insertions(+) create mode 100644 docs/api/routes/companysetting/get.md create mode 100644 docs/api/routes/companysetting/list.md create mode 100644 docs/api/routes/companysetting/set.md create mode 100644 docs/api/routes/number/list.md create mode 100644 docs/api/routes/number/update.md create mode 100644 src/api/routes/companysetting.js create mode 100644 src/api/routes/number.js create mode 100644 src/db/companysetting.js create mode 100644 src/db/numbers.js diff --git a/docs/api/README.md b/docs/api/README.md index 6d6d83d..41bbaf8 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -63,6 +63,9 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/category/add-image.md`](routes/category/add-image.md) | `POST /category/{id}/image` | | [`routes/company/list.md`](routes/company/list.md) | `GET /company` | | [`routes/company/create.md`](routes/company/create.md) | `POST /company` | +| [`routes/companysetting/list.md`](routes/companysetting/list.md) | `GET /company/setting` | +| [`routes/companysetting/get.md`](routes/companysetting/get.md) | `GET /company/setting/{key}` | +| [`routes/companysetting/set.md`](routes/companysetting/set.md) | `PUT /company/setting/{key}` | | [`routes/company/delete.md`](routes/company/delete.md) | `DELETE /company/{id}` | | [`routes/company/update.md`](routes/company/update.md) | `PUT /company/{id}` | | [`routes/customergroup/list.md`](routes/customergroup/list.md) | `GET /customergroup` | @@ -92,6 +95,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/events.md`](routes/events.md) | `GET /events` | | [`routes/health.md`](routes/health.md) | `GET /health` | | [`routes/image/get.md`](routes/image/get.md) | `GET /image/{fullHash}` | +| [`routes/number/list.md`](routes/number/list.md) | `GET /number` | +| [`routes/number/update.md`](routes/number/update.md) | `PATCH /number/{id}` | | [`routes/order/list.md`](routes/order/list.md) | `GET /order` | | [`routes/order/create.md`](routes/order/create.md) | `POST /order` | | [`routes/order/delete.md`](routes/order/delete.md) | `DELETE /order/{id}` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 4bb0b62..dbaab61 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -36,6 +36,10 @@ "name": "Company", "description": "Company CRUD" }, + { + "name": "CompanySetting", + "description": "Company-scoped settings" + }, { "name": "Unit", "description": "Company-scoped units of measure" @@ -2387,6 +2391,191 @@ } } }, + "/number": { + "get": { + "tags": [ + "Number" + ], + "summary": "List number sequences", + "description": "Lists the number sequences of the authenticated user's selected company.", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "number/list.md", + "responses": { + "200": { + "description": "Number sequence list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "numbers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NumberSequence" + } + } + } + } + } + } + }, + "400": { + "description": "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" + } + } + } + } + } + } + }, + "/number/{id}": { + "patch": { + "tags": [ + "Number" + ], + "summary": "Update number sequence", + "description": "Updates sequence settings for the selected company. Only provided fields\nare changed (`prefix`, `padding`, `next`). `prefix` is trimmed and must\nbe at most 50 characters. `padding` must be an integer `>= 0`. `next`\nmust be an integer `>= 1`. If no field is provided, the row is returned\nunchanged.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "number/update.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prefix": { + "type": "string" + }, + "padding": { + "type": "integer", + "minimum": 0 + }, + "next": { + "type": "integer", + "minimum": 1 + } + }, + "example": { + "prefix": "A-", + "padding": 4, + "next": 100 + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated number sequence", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "number": { + "$ref": "#/components/schemas/NumberSequence" + } + } + } + } + } + }, + "400": { + "description": "Invalid number, invalid id, 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": "Number sequence or company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/health": { "get": { "tags": [ @@ -4757,6 +4946,268 @@ } } }, + "/company/setting": { + "get": { + "tags": [ + "CompanySetting" + ], + "summary": "List company settings", + "description": "Returns the visible settings of the authenticated user's selected\ncompany: the full set of allowed keys (with `value: null` when unset)\nplus any stored settings that are frontend-visible. The `frontend`\nflag is stripped from responses.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "companysetting/list.md", + "responses": { + "200": { + "description": "Company settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "settings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanySetting" + } + } + } + } + } + } + }, + "400": { + "description": "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" + } + } + } + } + } + } + }, + "/company/setting/{key}": { + "get": { + "tags": [ + "CompanySetting" + ], + "summary": "Get company setting by key", + "description": "Returns a single company setting for the authenticated user's selected\ncompany, or `404` if it does not exist.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "companysetting/get.md", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Company setting", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "setting": { + "$ref": "#/components/schemas/CompanySetting" + } + } + } + } + } + }, + "400": { + "description": "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 setting or company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "tags": [ + "CompanySetting" + ], + "summary": "Upsert company setting", + "description": "Sets (upserts) a company setting for the selected company.\nOnly a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`,\n`keys.openrouter`, `labelprinter.addr`, `labelprinter.type`.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "companysetting/set.md", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string" + }, + "frontend": { + "type": "boolean" + } + }, + "example": { + "value": "dark", + "frontend": true + } + } + } + } + }, + "responses": { + "200": { + "description": "Upserted company setting", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "setting": { + "$ref": "#/components/schemas/CompanySetting" + } + } + } + } + } + }, + "400": { + "description": "Invalid setting, invalid key, 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" + } + } + } + } + } + } + }, "/company": { "get": { "tags": [ @@ -8013,6 +8464,22 @@ } } }, + "CompanySetting": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string", + "nullable": true + } + } + }, "Category": { "type": "object", "properties": { @@ -8119,6 +8586,36 @@ } } }, + "NumberSequence": { + "type": "object", + "required": [ + "id", + "type", + "prefix", + "padding", + "next" + ], + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string", + "description": "Sequence type (e.g. entity, article, delivery)" + }, + "prefix": { + "type": "string" + }, + "padding": { + "type": "integer", + "description": "Zero-padding length for the numeric part" + }, + "next": { + "type": "integer", + "description": "Next value to allocate" + } + } + }, "Supplier": { "type": "object", "required": [ diff --git a/docs/api/routes/companysetting/get.md b/docs/api/routes/companysetting/get.md new file mode 100644 index 0000000..8869aed --- /dev/null +++ b/docs/api/routes/companysetting/get.md @@ -0,0 +1,41 @@ +# `GET /company/setting/{key}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Get company setting by key + +Returns a single company setting for the authenticated user's selected +company, or `404` if it does not exist. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `key` | yes | string | | + +## Response `200` + +Company setting + +```json +{ + "setting": { + "key": "…", + "value": "…" + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Company setting or company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/companysetting/list.md b/docs/api/routes/companysetting/list.md new file mode 100644 index 0000000..1b35192 --- /dev/null +++ b/docs/api/routes/companysetting/list.md @@ -0,0 +1,39 @@ +# `GET /company/setting` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List company settings + +Returns the visible settings of the authenticated user's selected +company: the full set of allowed keys (with `value: null` when unset) +plus any stored settings that are frontend-visible. The `frontend` +flag is stripped from responses. + + +## Response `200` + +Company settings + +```json +{ + "settings": [ + { + "key": "…", + "value": "…" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | 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/companysetting/set.md b/docs/api/routes/companysetting/set.md new file mode 100644 index 0000000..4d0b05d --- /dev/null +++ b/docs/api/routes/companysetting/set.md @@ -0,0 +1,51 @@ +# `PUT /company/setting/{key}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Upsert company setting + +Sets (upserts) a company setting for the selected company. +Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, +`keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `key` | yes | string | | + +## Body (JSON) + +```json +{ + "value": "dark", + "frontend": true +} +``` + +## Response `200` + +Upserted company setting + +```json +{ + "setting": { + "key": "…", + "value": "…" + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid setting, invalid key, 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/number/list.md b/docs/api/routes/number/list.md new file mode 100644 index 0000000..76fc8e6 --- /dev/null +++ b/docs/api/routes/number/list.md @@ -0,0 +1,38 @@ +# `GET /number` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List number sequences + +Lists the number sequences of the authenticated user's selected company. + +## Response `200` + +Number sequence list + +```json +{ + "numbers": [ + { + "id": 1, + "type": "…", + "prefix": "…", + "padding": 1, + "next": 1 + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | 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/number/update.md b/docs/api/routes/number/update.md new file mode 100644 index 0000000..d2d07bd --- /dev/null +++ b/docs/api/routes/number/update.md @@ -0,0 +1,57 @@ +# `PATCH /number/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Update number sequence + +Updates sequence settings for the selected company. Only provided fields +are changed (`prefix`, `padding`, `next`). `prefix` is trimmed and must +be at most 50 characters. `padding` must be an integer `>= 0`. `next` +must be an integer `>= 1`. If no field is provided, the row is returned +unchanged. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Body (JSON) + +```json +{ + "prefix": "A-", + "padding": 4, + "next": 100 +} +``` + +## Response `200` + +Updated number sequence + +```json +{ + "number": { + "id": 1, + "type": "…", + "prefix": "…", + "padding": 1, + "next": 1 + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid number, invalid id, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Number sequence or company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index b4bb18b..120bac2 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -34,6 +34,7 @@ const options = { { name: 'Article', description: 'Articles under categories' }, { name: 'Category', description: 'Category tree and membership' }, { name: 'Company', description: 'Company CRUD' }, + { name: 'CompanySetting', description: 'Company-scoped settings' }, { name: 'Unit', description: 'Company-scoped units of measure' }, { name: 'Brand', description: 'Company-scoped brands' }, { name: 'Supplier', description: 'Company-scoped suppliers (Lieferanten)' }, diff --git a/src/api/openapi.js b/src/api/openapi.js index 88e6c7e..1644eaa 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -45,6 +45,15 @@ * type: number * reducedtaxrate: * type: number + * CompanySetting: + * type: object + * required: [key, value] + * properties: + * key: + * type: string + * value: + * type: string + * nullable: true * Category: * type: object * properties: @@ -110,6 +119,23 @@ * gsprpinfo: * type: string * nullable: true + * NumberSequence: + * type: object + * required: [id, type, prefix, padding, next] + * properties: + * id: + * type: integer + * type: + * type: string + * description: Sequence type (e.g. entity, article, delivery) + * prefix: + * type: string + * padding: + * type: integer + * description: Zero-padding length for the numeric part + * next: + * type: integer + * description: Next value to allocate * Supplier: * type: object * required: [id, name, createdAt, updatedAt] diff --git a/src/api/routes/companysetting.js b/src/api/routes/companysetting.js new file mode 100644 index 0000000..1807e8d --- /dev/null +++ b/src/api/routes/companysetting.js @@ -0,0 +1,299 @@ +import * as db from '../../db/companysetting.js'; + +const ALLOWED_KEYS = [ + 'ui.theme', + 'keys.tavily', + 'keys.openrouter', + 'labelprinter.addr', + 'labelprinter.type', +]; + +function requireSelectedCompany(req) { + const companyId = req.user.selectedCompanyId; + if (companyId == null) { + return { error: 'No company selected' }; + } + return { companyId }; +} + +function settingErrorStatus(error) { + if (error.message === 'No company selected') return 400; + if (error.message === 'Not authorized') return 403; + if (error.message === 'Company not found') return 404; + if (error.message === 'Invalid setting') return 400; + return 500; +} + +/** + * Shapes a setting for the API response, stripping the internal `frontend` flag. + * @param {{key: string, value: string|null, frontend?: boolean}|undefined} s + * @returns {{key: string, value: string|null}} + */ +function shapeSetting(s) { + return { key: s.key, value: s.value ?? null }; +} + +/** + * Registers company-settings routes for the authenticated user's selected company. + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /company/setting: + * get: + * tags: [CompanySetting] + * summary: List company settings + * description: | + * Returns the visible settings of the authenticated user's selected + * company: the full set of allowed keys (with `value: null` when unset) + * plus any stored settings that are frontend-visible. The `frontend` + * flag is stripped from responses. + * security: + * - cookieAuth: [] + * x-markdown: companysetting/list.md + * responses: + * 200: + * description: Company settings + * content: + * application/json: + * schema: + * type: object + * properties: + * settings: + * type: array + * items: + * $ref: '#/components/schemas/CompanySetting' + * 400: + * description: 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.get(`${basePath}/company/setting`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + try { + const stored = await db.list(req.user.id, {}); + const byKey = new Map(stored.map((s) => [s.key, s])); + + // Visible = named/allowed keys plus any stored setting with frontend=true. + const visibleKeys = new Set(ALLOWED_KEYS); + for (const s of stored) { + if (s.frontend) visibleKeys.add(s.key); + } + + const settings = [...visibleKeys].map((key) => + byKey.has(key) ? shapeSetting(byKey.get(key)) : shapeSetting({ key, value: null }) + ); + + res.status(200).json({ settings }); + } catch (error) { + const status = settingErrorStatus(error); + if (status === 500) console.error('List company settings failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /company/setting/{key}: + * get: + * tags: [CompanySetting] + * summary: Get company setting by key + * description: | + * Returns a single company setting for the authenticated user's selected + * company, or `404` if it does not exist. + * security: + * - cookieAuth: [] + * x-markdown: companysetting/get.md + * parameters: + * - in: path + * name: key + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Company setting + * content: + * application/json: + * schema: + * type: object + * properties: + * setting: + * $ref: '#/components/schemas/CompanySetting' + * 400: + * description: 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 setting or company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.get(`${basePath}/company/setting/:key`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const key = String(req.params.key ?? ''); + if (!key.trim()) { + return res.status(400).json({ error: 'key is required' }); + } + + try { + const setting = await db.getByKey(req.user.id, key); + // Only frontend-visible settings are exposed; others are treated as absent. + if (!setting || !setting.frontend) { + return res.status(404).json({ error: 'Company setting not found' }); + } + res.status(200).json({ setting: shapeSetting(setting) }); + } catch (error) { + const status = settingErrorStatus(error); + if (status === 500) console.error('Get company setting failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /company/setting/{key}: + * put: + * tags: [CompanySetting] + * summary: Upsert company setting + * description: | + * Sets (upserts) a company setting for the selected company. + * Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, + * `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. + * security: + * - cookieAuth: [] + * x-markdown: companysetting/set.md + * parameters: + * - in: path + * name: key + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [value] + * properties: + * value: + * type: string + * frontend: + * type: boolean + * example: + * value: dark + * frontend: true + * responses: + * 200: + * description: Upserted company setting + * content: + * application/json: + * schema: + * type: object + * properties: + * setting: + * $ref: '#/components/schemas/CompanySetting' + * 400: + * description: Invalid setting, invalid key, 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}/company/setting/:key`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const key = String(req.params.key ?? ''); + if (!key.trim()) { + return res.status(400).json({ error: 'key is required' }); + } + + const value = req.body?.value; + const frontend = req.body?.frontend; + + const input = { key }; + if (typeof value === 'string') { + input.value = value; + } + if (typeof frontend === 'boolean') { + input.frontend = frontend; + } + + try { + const setting = await db.set(req.user.id, input); + res.status(200).json({ setting: shapeSetting(setting) }); + } catch (error) { + const status = settingErrorStatus(error); + if (status === 500) console.error('Set company setting failed:', error); + res.status(status).json({ error: error.message }); + } + }); +} \ No newline at end of file diff --git a/src/api/routes/number.js b/src/api/routes/number.js new file mode 100644 index 0000000..1ac164d --- /dev/null +++ b/src/api/routes/number.js @@ -0,0 +1,196 @@ +import * as db from '../../db/numbers.js'; + +function requireSelectedCompany(req) { + const companyId = req.user.selectedCompanyId; + if (companyId == null) { + return { error: 'No company selected' }; + } + return { companyId }; +} + +function numberErrorStatus(error) { + if (error.message === 'No company selected') return 400; + if (error.message === 'Not authorized') return 403; + if (error.message === 'Company not found') return 404; + if (error.message === 'Number not found') return 404; + if (error.message === 'Invalid number') return 400; + return 500; +} + +/** + * Registers number-sequence routes for the authenticated user's selected company. + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /number: + * get: + * tags: [Number] + * summary: List number sequences + * description: Lists the number sequences of the authenticated user's selected company. + * security: + * - cookieAuth: [] + * x-markdown: number/list.md + * responses: + * 200: + * description: Number sequence list + * content: + * application/json: + * schema: + * type: object + * properties: + * numbers: + * type: array + * items: + * $ref: '#/components/schemas/NumberSequence' + * 400: + * description: 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.get(`${basePath}/number`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + try { + const numbers = await db.list(req.user.id); + res.status(200).json({ numbers }); + } catch (error) { + const status = numberErrorStatus(error); + if (status === 500) console.error('List number sequences failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /number/{id}: + * patch: + * tags: [Number] + * summary: Update number sequence + * description: | + * Updates sequence settings for the selected company. Only provided fields + * are changed (`prefix`, `padding`, `next`). `prefix` is trimmed and must + * be at most 50 characters. `padding` must be an integer `>= 0`. `next` + * must be an integer `>= 1`. If no field is provided, the row is returned + * unchanged. + * security: + * - cookieAuth: [] + * x-markdown: number/update.md + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * prefix: + * type: string + * padding: + * type: integer + * minimum: 0 + * next: + * type: integer + * minimum: 1 + * example: + * prefix: 'A-' + * padding: 4 + * next: 100 + * responses: + * 200: + * description: Updated number sequence + * content: + * application/json: + * schema: + * type: object + * properties: + * number: + * $ref: '#/components/schemas/NumberSequence' + * 400: + * description: Invalid number, invalid id, 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: Number sequence or company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.patch(`${basePath}/number/:id`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id is required' }); + } + + const body = req.body ?? {}; + const input = {}; + if (Object.prototype.hasOwnProperty.call(body, 'prefix')) { + input.prefix = body.prefix; + } + if (Object.prototype.hasOwnProperty.call(body, 'padding')) { + input.padding = body.padding; + } + if (Object.prototype.hasOwnProperty.call(body, 'next')) { + input.next = body.next; + } + + try { + const number = await db.update(req.user.id, id, input); + res.status(200).json({ number }); + } catch (error) { + const status = numberErrorStatus(error); + if (status === 500) console.error('Update number sequence failed:', error); + res.status(status).json({ error: error.message }); + } + }); +} diff --git a/src/db/companysetting.js b/src/db/companysetting.js new file mode 100644 index 0000000..5180a1c --- /dev/null +++ b/src/db/companysetting.js @@ -0,0 +1,16 @@ +import { getStore } from './store.js'; + +export async function list(googleAuthId, options) { + const store = await getStore(); + return store.companysetting.list(googleAuthId, options); +} + +export async function getByKey(googleAuthId, key, options) { + const store = await getStore(); + return store.companysetting.get(googleAuthId, key, options); +} + +export async function set(googleAuthId, input) { + const store = await getStore(); + return store.companysetting.set(googleAuthId, input); +} \ No newline at end of file diff --git a/src/db/numbers.js b/src/db/numbers.js new file mode 100644 index 0000000..faefefc --- /dev/null +++ b/src/db/numbers.js @@ -0,0 +1,11 @@ +import { getStore } from './store.js'; + +export async function list(googleAuthId) { + const store = await getStore(); + return store.numbers.list(googleAuthId); +} + +export async function update(googleAuthId, id, input) { + const store = await getStore(); + return store.numbers.update(googleAuthId, id, input); +} diff --git a/src/db/store.js b/src/db/store.js index e1c633e..3e16611 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -27,6 +27,8 @@ const STORE_NAMESPACES = [ 'entitycontact', 'quote', 'order', + 'numbers', + 'companysetting', ]; /**