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:
@@ -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