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

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