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.
This commit is contained in:
seb
2026-08-08 09:03:51 +02:00
parent aefd97a171
commit f302cda39e
12 changed files with 272 additions and 79 deletions

View File

@@ -2,33 +2,6 @@
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. 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<void>`
**Throws**
`Error` — If the setting is not found.
**Example**
```js
await store.companysetting.delete(42, 'theme');
```
## `companysetting.get` ## `companysetting.get`
*Source: `src/store/companysetting/get.js`* *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`* *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** **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. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The setting input. | | `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.value` | `string` | The setting value. |
| `[input.frontend]` | `boolean` | Whether the setting is frontend-visible. | | `[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** **Example**
```js ```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 } // => { key, value, frontend }
``` ```

1
API.md
View File

@@ -26,3 +26,4 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- [posterminal](API.posterminal.md) - [posterminal](API.posterminal.md)
- [google_auth](API.google_auth.md) - [google_auth](API.google_auth.md)
- [pic](API.pic.md) - [pic](API.pic.md)
- [numbers](API.numbers.md)

58
API.numbers.md Normal file
View File

@@ -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<Array<{id: number, type: string, prefix: string, padding: number, next: number}>>` — 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 }
```

View File

@@ -163,13 +163,14 @@ Company-scoped key/value settings. Auth via selected company (`google_auth.selec
| `list` | `(googleAuthId, { frontendOnly? })` | `{ key, value, frontend }[]` | | `list` | `(googleAuthId, { frontendOnly? })` | `{ key, value, frontend }[]` |
| `get` | `(googleAuthId, key, { frontendOnly? })` | `{ key, value, frontend } \| undefined` | | `get` | `(googleAuthId, key, { frontendOnly? })` | `{ key, value, frontend } \| undefined` |
| `set` | `(googleAuthId, { key, value, frontend? })` | `{ key, value, frontend }` | | `set` | `(googleAuthId, { key, value, frontend? })` | `{ key, value, frontend }` |
| `delete` | `(googleAuthId, key)` | `void` |
- `value` is always a string (`NVARCHAR(MAX)`). - `value` is always a string (`NVARCHAR(MAX)`).
- `frontend: true` → readable by frontend routes (`frontendOnly: true`). Default on `set` is `false` (backend-only). - `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). - 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 ## Backend route cheatsheet
| Route | Store call | | Route | Store call |
@@ -683,7 +697,6 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer);
| `DELETE /api/unit` | `unit.delete(googleAuthId, si)` | | `DELETE /api/unit` | `unit.delete(googleAuthId, si)` |
| `GET /api/company/:id/settings` | `companysetting.list(googleAuthId, { frontendOnly: true })` | | `GET /api/company/:id/settings` | `companysetting.list(googleAuthId, { frontendOnly: true })` |
| `PUT /api/company/:id/settings` | `companysetting.set(googleAuthId, { key, value, frontend? })` | | `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)` | | `GET /api/brand` | `brand.list(googleAuthId)` |
| `PUT /api/brand` | `brand.upsert(googleAuthId, { name, gsprpinfo? })` | | `PUT /api/brand` | `brand.upsert(googleAuthId, { name, gsprpinfo? })` |
| `DELETE /api/brand/:id` | `brand.delete(googleAuthId, id)` | | `DELETE /api/brand/:id` | `brand.delete(googleAuthId, id)` |

View File

@@ -29,6 +29,7 @@ const MODULE_ORDER = [
'posterminal', 'posterminal',
'google_auth', 'google_auth',
'pic', 'pic',
'numbers',
]; ];
// Files that are internal helpers and should not appear as public API entries. // Files that are internal helpers and should not appear as public API entries.

View File

@@ -35,7 +35,6 @@ import unitDelete from './store/unit/delete.js';
import companysettingList from './store/companysetting/list.js'; import companysettingList from './store/companysetting/list.js';
import companysettingGet from './store/companysetting/get.js'; import companysettingGet from './store/companysetting/get.js';
import companysettingSet from './store/companysetting/set.js'; import companysettingSet from './store/companysetting/set.js';
import companysettingDelete from './store/companysetting/delete.js';
import brandList from './store/brand/list.js'; import brandList from './store/brand/list.js';
import brandUpsert from './store/brand/upsert.js'; import brandUpsert from './store/brand/upsert.js';
import brandDelete from './store/brand/delete.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 googleAuthSetProfile from './store/google_auth/setProfile.js';
import picGetImageByHash from './store/pic/getImageByHash.js'; import picGetImageByHash from './store/pic/getImageByHash.js';
import picGetThumbByHash from './store/pic/getThumbByHash.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 storePurge from './store/purge.js';
import storeInitialize from './store/initialize.js'; import storeInitialize from './store/initialize.js';
@@ -179,7 +180,6 @@ export default class LibDashStore {
list: companysettingList(pool), list: companysettingList(pool),
get: companysettingGet(pool), get: companysettingGet(pool),
set: companysettingSet(pool), set: companysettingSet(pool),
delete: companysettingDelete(pool),
}; };
this.brand = { this.brand = {
@@ -277,6 +277,11 @@ export default class LibDashStore {
getThumbByHash: picGetThumbByHash(pool), getThumbByHash: picGetThumbByHash(pool),
}; };
this.numbers = {
list: numbersList(pool),
update: numbersUpdate(pool),
};
this.purge = storePurge(pool); this.purge = storePurge(pool);
this.initialize = storeInitialize(pool); this.initialize = storeInitialize(pool);

View File

@@ -39,9 +39,3 @@ WHEN NOT MATCHED THEN
INSERT (company_id, [key], value, frontend) INSERT (company_id, [key], value, frontend)
VALUES (@companyId, @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
`;

View File

@@ -9,3 +9,38 @@ BEGIN
VALUES (@companyId, @type); VALUES (@companyId, @type);
END 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
`;

View File

@@ -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<void>}
* @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');
}
};
}

View File

@@ -2,27 +2,38 @@ import { GET_BY_COMPANY_AND_KEY, UPSERT } from '../../sql/companysetting.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shapeSetting from './shapeSetting.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) { export default function set(pool) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(pool);
/** /**
* 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`.
*
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The setting input. * @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 {string} input.value - The setting value.
* @param {boolean} [input.frontend] - Whether the setting is frontend-visible. * @param {boolean} [input.frontend] - Whether the setting is frontend-visible.
* @returns {Promise<{key: string, value: string, frontend: boolean}>} The upserted setting. * @returns {Promise<{key: string, value: string, frontend: boolean}>} The upserted setting.
* @throws {Error} If the input is invalid. * @throws {Error} If the input is invalid.
* *
* @example * @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 } * // => { key, value, frontend }
*/ */
return async (googleAuthId, input = {}) => { return async (googleAuthId, input = {}) => {
const key = input.key?.trim(); const key = input.key?.trim();
if (!key) { if (!ALLOWED_KEYS.has(key)) {
throw new Error('Invalid setting'); throw new Error('Invalid setting');
} }
if (typeof input.value !== 'string') { if (typeof input.value !== 'string') {

37
src/store/numbers/list.js Normal file
View File

@@ -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<Array<{id: number, type: string, prefix: string, padding: number, next: number}>>} 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);
};
}

100
src/store/numbers/update.js Normal file
View File

@@ -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);
};
}