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:
@@ -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<void>`
|
||||
|
||||
**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 }
|
||||
```
|
||||
|
||||
1
API.md
1
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)
|
||||
|
||||
58
API.numbers.md
Normal file
58
API.numbers.md
Normal 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 }
|
||||
```
|
||||
19
README.md
19
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)` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
37
src/store/numbers/list.js
Normal file
37
src/store/numbers/list.js
Normal 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
100
src/store/numbers/update.js
Normal 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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user