From f302cda39e14c641d6e743cb778d61b3fd62b669 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 8 Aug 2026 09:03:51 +0200 Subject: [PATCH] feat(numbers): add number sequences store; restrict companysetting keys - Add `numbers` store module with `list` and `update` methods backed by the `numbers` table (prefix, padding, next) per company. - Add sql queries LIST_BY_COMPANY, GET_BY_COMPANY_AND_ID, UPDATE_... - Regenerate and wire API docs for `numbers`. companysetting: - Remove the `delete` endpoint (settings are now upsert/read only). - Restrict `set` to a fixed allow-list of keys: ui.theme, keys.tavily, keys.openrouter, labelprinter.addr, labelprinter.type. - Drop DELETE_BY_COMPANY_AND_KEY sql and the delete store file. Docs: update README, API.md, API.companysetting.md, API.numbers.md. --- API.companysetting.md | 33 +--------- API.md | 1 + API.numbers.md | 58 +++++++++++++++++ README.md | 19 +++++- generate-api-doc.js | 1 + src/index.js | 9 ++- src/sql/companysetting.js | 6 -- src/sql/numbers.js | 35 ++++++++++ src/store/companysetting/delete.js | 35 ---------- src/store/companysetting/set.js | 17 ++++- src/store/numbers/list.js | 37 +++++++++++ src/store/numbers/update.js | 100 +++++++++++++++++++++++++++++ 12 files changed, 272 insertions(+), 79 deletions(-) create mode 100644 API.numbers.md delete mode 100644 src/store/companysetting/delete.js create mode 100644 src/store/numbers/list.js create mode 100644 src/store/numbers/update.js diff --git a/API.companysetting.md b/API.companysetting.md index c2e2acd..a9134eb 100644 --- a/API.companysetting.md +++ b/API.companysetting.md @@ -2,33 +2,6 @@ This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. -## `companysetting.delete` - -*Source: `src/store/companysetting/delete.js`* - -Deletes a company setting by key for the user's selected company. - -**Parameters** - -| Name | Type | Description | -| --- | --- | --- | -| `googleAuthId` | `number` | The id of the Google auth user. | -| `key` | `string` | The setting key. | - -**Returns** - -`Promise` - -**Throws** - -`Error` — If the setting is not found. - -**Example** - -```js -await store.companysetting.delete(42, 'theme'); -``` - ## `companysetting.get` *Source: `src/store/companysetting/get.js`* @@ -84,7 +57,7 @@ const settings = await store.companysetting.list(42, { frontendOnly: true }); *Source: `src/store/companysetting/set.js`* -Sets (upserts) a company setting by key for the user's selected company. +Sets (upserts) a company setting by key for the user's selected company. Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. **Parameters** @@ -92,7 +65,7 @@ Sets (upserts) a company setting by key for the user's selected company. | --- | --- | --- | | `googleAuthId` | `number` | The id of the Google auth user. | | `input` | `object` | The setting input. | -| `input.key` | `string` | The setting key. | +| `input.key` | `string` | The setting key (must be in the allow-list). | | `input.value` | `string` | The setting value. | | `[input.frontend]` | `boolean` | Whether the setting is frontend-visible. | @@ -107,6 +80,6 @@ Sets (upserts) a company setting by key for the user's selected company. **Example** ```js -const setting = await store.companysetting.set(42, { key: 'theme', value: 'dark', frontend: true }); +const setting = await store.companysetting.set(42, { key: 'ui.theme', value: 'dark' }); // => { key, value, frontend } ``` diff --git a/API.md b/API.md index 7fe96cf..88ee324 100644 --- a/API.md +++ b/API.md @@ -26,3 +26,4 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - [posterminal](API.posterminal.md) - [google_auth](API.google_auth.md) - [pic](API.pic.md) +- [numbers](API.numbers.md) diff --git a/API.numbers.md b/API.numbers.md new file mode 100644 index 0000000..c030a2d --- /dev/null +++ b/API.numbers.md @@ -0,0 +1,58 @@ +# numbers + +This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. + +## `numbers.list` + +*Source: `src/store/numbers/list.js`* + +Lists the number sequences of the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | + +**Returns** + +`Promise>` — The number sequences. + +**Example** + +```js +const numbers = await store.numbers.list(42); +// => [{ id, type, prefix, padding, next }] +``` + +## `numbers.update` + +*Source: `src/store/numbers/update.js`* + +Updates the sequence settings of a number for the user's selected company. Only the provided fields are changed (all optional). `prefix` is trimmed and must be at most 50 characters. `padding` and `next` must be finite numbers greater than or equal to 0 and 1 respectively. If no field is provided, the row is returned unchanged. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The id of the number sequence to update. | +| `input` | `object` | The update input. | +| `[input.prefix]` | `string` | The new prefix. | +| `[input.padding]` | `number` | The new padding length. | +| `[input.next]` | `number` | The new next value. | + +**Returns** + +`Promise<{id: number, type: string, prefix: string, padding: number, next: number}>` — The updated number sequence. + +**Throws** + +`Error` — If the number is not found or a field is invalid. + +**Example** + +```js +const number = await store.numbers.update(42, 3, { prefix: 'A-', padding: 4, next: 100 }); +// => { id, type, prefix, padding, next } +``` diff --git a/README.md b/README.md index 53113e7..8f70d05 100644 --- a/README.md +++ b/README.md @@ -163,13 +163,14 @@ Company-scoped key/value settings. Auth via selected company (`google_auth.selec | `list` | `(googleAuthId, { frontendOnly? })` | `{ key, value, frontend }[]` | | `get` | `(googleAuthId, key, { frontendOnly? })` | `{ key, value, frontend } \| undefined` | | `set` | `(googleAuthId, { key, value, frontend? })` | `{ key, value, frontend }` | -| `delete` | `(googleAuthId, key)` | `void` | - `value` is always a string (`NVARCHAR(MAX)`). - `frontend: true` → readable by frontend routes (`frontendOnly: true`). Default on `set` is `false` (backend-only). - With `frontendOnly: true`, backend-only keys are omitted / `get` returns `undefined` (no leak via error type beyond missing). +- `set` only allows a fixed allow-list of keys: `ui.theme`, `keys.tavily`, `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. +- No delete endpoint — settings are upsert / read only. -Errors: `No company selected`, `Company not found`, `Not authorized`, `Invalid setting`, `Setting not found`. +Errors: `No company selected`, `Company not found`, `Not authorized`, `Invalid setting`. --- @@ -660,6 +661,19 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer); --- +## `numbers` + +Company-scoped number sequences (`numbers` table). Auth via selected company (`google_auth.selected_company` + membership). + +| Method | Signature | Returns | +| --- | --- | --- | +| `list` | `(googleAuthId)` | `{ id, type, prefix, padding, next }[]` | +| `update` | `(googleAuthId, id, { prefix?, padding?, next? })` | `{ id, type, prefix, padding, next }` | + +`list` returns every sequence for the company, ordered by `type`. `update` updates only the provided fields: `prefix` is trimmed (max 50 chars), `padding` must be an integer `>= 0`, `next` must be an integer `>= 1`. Errors: `No company selected`, `Company not found`, `Not authorized`, `Number not found`, `Invalid number`. + +--- + ## Backend route cheatsheet | Route | Store call | @@ -683,7 +697,6 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer); | `DELETE /api/unit` | `unit.delete(googleAuthId, si)` | | `GET /api/company/:id/settings` | `companysetting.list(googleAuthId, { frontendOnly: true })` | | `PUT /api/company/:id/settings` | `companysetting.set(googleAuthId, { key, value, frontend? })` | -| `DELETE /api/company/:id/settings/:key` | `companysetting.delete(googleAuthId, key)` | | `GET /api/brand` | `brand.list(googleAuthId)` | | `PUT /api/brand` | `brand.upsert(googleAuthId, { name, gsprpinfo? })` | | `DELETE /api/brand/:id` | `brand.delete(googleAuthId, id)` | diff --git a/generate-api-doc.js b/generate-api-doc.js index 03d5ffe..4c8f9e2 100644 --- a/generate-api-doc.js +++ b/generate-api-doc.js @@ -29,6 +29,7 @@ const MODULE_ORDER = [ 'posterminal', 'google_auth', 'pic', + 'numbers', ]; // Files that are internal helpers and should not appear as public API entries. diff --git a/src/index.js b/src/index.js index 11b4dc1..3dcc40a 100644 --- a/src/index.js +++ b/src/index.js @@ -35,7 +35,6 @@ import unitDelete from './store/unit/delete.js'; import companysettingList from './store/companysetting/list.js'; import companysettingGet from './store/companysetting/get.js'; import companysettingSet from './store/companysetting/set.js'; -import companysettingDelete from './store/companysetting/delete.js'; import brandList from './store/brand/list.js'; import brandUpsert from './store/brand/upsert.js'; import brandDelete from './store/brand/delete.js'; @@ -104,6 +103,8 @@ import googleAuthSetSelectedCompany from './store/google_auth/setSelectedCompany import googleAuthSetProfile from './store/google_auth/setProfile.js'; import picGetImageByHash from './store/pic/getImageByHash.js'; import picGetThumbByHash from './store/pic/getThumbByHash.js'; +import numbersList from './store/numbers/list.js'; +import numbersUpdate from './store/numbers/update.js'; import storePurge from './store/purge.js'; import storeInitialize from './store/initialize.js'; @@ -179,7 +180,6 @@ export default class LibDashStore { list: companysettingList(pool), get: companysettingGet(pool), set: companysettingSet(pool), - delete: companysettingDelete(pool), }; this.brand = { @@ -277,6 +277,11 @@ export default class LibDashStore { getThumbByHash: picGetThumbByHash(pool), }; + this.numbers = { + list: numbersList(pool), + update: numbersUpdate(pool), + }; + this.purge = storePurge(pool); this.initialize = storeInitialize(pool); diff --git a/src/sql/companysetting.js b/src/sql/companysetting.js index 85d189c..c02445d 100644 --- a/src/sql/companysetting.js +++ b/src/sql/companysetting.js @@ -39,9 +39,3 @@ WHEN NOT MATCHED THEN INSERT (company_id, [key], value, frontend) VALUES (@companyId, @key, @value, @frontend); `; - -export const DELETE_BY_COMPANY_AND_KEY = ` -DELETE FROM companysetting -WHERE company_id = @companyId - AND [key] = @key -`; diff --git a/src/sql/numbers.js b/src/sql/numbers.js index 4654302..f40e976 100644 --- a/src/sql/numbers.js +++ b/src/sql/numbers.js @@ -9,3 +9,38 @@ BEGIN VALUES (@companyId, @type); END `; + +export const LIST_BY_COMPANY = ` +SELECT + id, + [type], + prefix, + padding, + [next] AS [next] +FROM numbers +WHERE company_id = @companyId +ORDER BY [type] +`; + +export const GET_BY_COMPANY_AND_ID = ` +SELECT + id, + [type], + prefix, + padding, + [next] AS [next] +FROM numbers +WHERE id = @id + AND company_id = @companyId +`; + +export const UPDATE_BY_COMPANY_AND_ID = ` +UPDATE numbers +SET + prefix = @prefix, + padding = @padding, + [next] = @next +OUTPUT INSERTED.id, INSERTED.[type], INSERTED.prefix, INSERTED.padding, INSERTED.[next] +WHERE id = @id + AND company_id = @companyId +`; diff --git a/src/store/companysetting/delete.js b/src/store/companysetting/delete.js deleted file mode 100644 index 3b9046d..0000000 --- a/src/store/companysetting/delete.js +++ /dev/null @@ -1,35 +0,0 @@ -import { DELETE_BY_COMPANY_AND_KEY } from '../../sql/companysetting.js'; -import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; - -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); - - /** - * Deletes a company setting by key for the user's selected company. - * - * @param {number} googleAuthId - The id of the Google auth user. - * @param {string} key - The setting key. - * @returns {Promise} - * @throws {Error} If the setting is not found. - * - * @example - * await store.companysetting.delete(42, 'theme'); - */ - return async (googleAuthId, key) => { - if (!key?.trim()) { - throw new Error('Setting not found'); - } - - const { companyId } = await access(googleAuthId); - - const result = await pool - .request() - .input('companyId', companyId) - .input('key', key.trim()) - .query(DELETE_BY_COMPANY_AND_KEY); - - if (!result.rowsAffected[0]) { - throw new Error('Setting not found'); - } - }; -} diff --git a/src/store/companysetting/set.js b/src/store/companysetting/set.js index a2a5173..85358b4 100644 --- a/src/store/companysetting/set.js +++ b/src/store/companysetting/set.js @@ -2,27 +2,38 @@ import { GET_BY_COMPANY_AND_KEY, UPSERT } from '../../sql/companysetting.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import shapeSetting from './shapeSetting.js'; +const ALLOWED_KEYS = new Set([ + 'ui.theme', + 'keys.tavily', + 'keys.openrouter', + 'labelprinter.addr', + 'labelprinter.type', +]); + export default function set(pool) { const access = resolveSelectedCompanyAccess(pool); /** * Sets (upserts) a company setting by key for the user's selected company. * + * Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, + * `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. + * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} input - The setting input. - * @param {string} input.key - The setting key. + * @param {string} input.key - The setting key (must be in the allow-list). * @param {string} input.value - The setting value. * @param {boolean} [input.frontend] - Whether the setting is frontend-visible. * @returns {Promise<{key: string, value: string, frontend: boolean}>} The upserted setting. * @throws {Error} If the input is invalid. * * @example - * const setting = await store.companysetting.set(42, { key: 'theme', value: 'dark', frontend: true }); + * const setting = await store.companysetting.set(42, { key: 'ui.theme', value: 'dark' }); * // => { key, value, frontend } */ return async (googleAuthId, input = {}) => { const key = input.key?.trim(); - if (!key) { + if (!ALLOWED_KEYS.has(key)) { throw new Error('Invalid setting'); } if (typeof input.value !== 'string') { diff --git a/src/store/numbers/list.js b/src/store/numbers/list.js new file mode 100644 index 0000000..970eaf5 --- /dev/null +++ b/src/store/numbers/list.js @@ -0,0 +1,37 @@ +import { LIST_BY_COMPANY } from '../../sql/numbers.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; + +function shape(row) { + return { + id: row.id, + type: row.type, + prefix: row.prefix, + padding: row.padding, + next: row.next, + }; +} + +export default function list(pool) { + const access = resolveSelectedCompanyAccess(pool); + + /** + * Lists the number sequences of the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @returns {Promise>} The number sequences. + * + * @example + * const numbers = await store.numbers.list(42); + * // => [{ id, type, prefix, padding, next }] + */ + return async (googleAuthId) => { + const { companyId } = await access(googleAuthId); + + const result = await pool + .request() + .input('companyId', companyId) + .query(LIST_BY_COMPANY); + + return result.recordset.map(shape); + }; +} \ No newline at end of file diff --git a/src/store/numbers/update.js b/src/store/numbers/update.js new file mode 100644 index 0000000..2f90f7b --- /dev/null +++ b/src/store/numbers/update.js @@ -0,0 +1,100 @@ +import { + GET_BY_COMPANY_AND_ID, + UPDATE_BY_COMPANY_AND_ID, +} from '../../sql/numbers.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; + +function shape(row) { + return { + id: row.id, + type: row.type, + prefix: row.prefix, + padding: row.padding, + next: row.next, + }; +} + +export default function update(pool) { + const access = resolveSelectedCompanyAccess(pool); + + /** + * Updates the sequence settings of a number for the user's selected company. + * + * Only the provided fields are changed (all optional). `prefix` is trimmed and + * must be at most 50 characters. `padding` and `next` must be finite numbers + * greater than or equal to 0 and 1 respectively. If no field is provided, the + * row is returned unchanged. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The id of the number sequence to update. + * @param {object} input - The update input. + * @param {string} [input.prefix] - The new prefix. + * @param {number} [input.padding] - The new padding length. + * @param {number} [input.next] - The new next value. + * @returns {Promise<{id: number, type: string, prefix: string, padding: number, next: number}>} The updated number sequence. + * @throws {Error} If the number is not found or a field is invalid. + * + * @example + * const number = await store.numbers.update(42, 3, { prefix: 'A-', padding: 4, next: 100 }); + * // => { id, type, prefix, padding, next } + */ + return async (googleAuthId, id, input = {}) => { + if (!Number.isFinite(id)) { + throw new Error('Number not found'); + } + + const { companyId } = await access(googleAuthId); + + const existing = await pool + .request() + .input('id', id) + .input('companyId', companyId) + .query(GET_BY_COMPANY_AND_ID); + + const current = existing.recordset[0]; + if (!current) { + throw new Error('Number not found'); + } + + const patch = { ...current }; + + if ('prefix' in input) { + const prefix = input.prefix == null ? '' : String(input.prefix).trim(); + if (prefix.length > 50) { + throw new Error('Invalid number'); + } + patch.prefix = prefix; + } + + if ('padding' in input) { + const padding = input.padding; + if (!Number.isInteger(padding) || padding < 0) { + throw new Error('Invalid number'); + } + patch.padding = padding; + } + + if ('next' in input) { + const next = input.next; + if (!Number.isInteger(next) || next < 1) { + throw new Error('Invalid number'); + } + patch.next = next; + } + + const result = await pool + .request() + .input('id', id) + .input('companyId', companyId) + .input('prefix', patch.prefix) + .input('padding', patch.padding) + .input('next', patch.next) + .query(UPDATE_BY_COMPANY_AND_ID); + + const row = result.recordset[0]; + if (!row) { + throw new Error('Number not found'); + } + return shape(row); + }; +} \ No newline at end of file