Rewrite store JSDoc, split quote/order into modules, and generate typed API docs

- Rewrite JSDoc across all store methods to document the actual async
  methods (real params, returns, throws) instead of the internal wrappers
- Split quote/order into individual module files backed by shared
  manifestDoc helpers; remove the createStore factory and thin wrappers
- Deduplicate manifest line helpers into src/store/manifestLines.js
- Extend generate-api-doc.js to render parameter/return/throws types and
  emit per-namespace API.<module>.md files with an API.md index
- Regenerate all API docs
This commit is contained in:
seb
2026-08-07 06:23:17 +02:00
parent 396389457f
commit aefd97a171
127 changed files with 3956 additions and 1684 deletions

View File

@@ -68,8 +68,16 @@ import deliveryGetById from './store/delivery/getById.js';
import deliveryCreate from './store/delivery/create.js';
import deliveryUpdate from './store/delivery/update.js';
import deliveryDelete from './store/delivery/delete.js';
import createQuoteStore from './store/quote/index.js';
import createOrderStore from './store/order/index.js';
import quoteList from './store/quote/list.js';
import quoteGetById from './store/quote/getById.js';
import quoteCreate from './store/quote/create.js';
import quoteUpdate from './store/quote/update.js';
import quoteDelete from './store/quote/delete.js';
import orderList from './store/order/list.js';
import orderGetById from './store/order/getById.js';
import orderCreate from './store/order/create.js';
import orderUpdate from './store/order/update.js';
import orderDelete from './store/order/delete.js';
import posterminalList from './store/posterminal/list.js';
import posterminalUpsert from './store/posterminal/upsert.js';
import posterminalDelete from './store/posterminal/delete.js';
@@ -227,8 +235,21 @@ export default class LibDashStore {
delete: deliveryDelete(pool),
};
this.quote = createQuoteStore(pool);
this.order = createOrderStore(pool);
this.quote = {
list: quoteList(pool),
getById: quoteGetById(pool),
create: quoteCreate(pool),
update: quoteUpdate(pool),
delete: quoteDelete(pool),
};
this.order = {
list: orderList(pool),
getById: orderGetById(pool),
create: orderCreate(pool),
update: orderUpdate(pool),
delete: orderDelete(pool),
};
this.posterminal = {
list: posterminalList(pool),

View File

@@ -5,20 +5,21 @@ import newFullHash from '../../util/newFullHash.js';
import request from '../request.js';
import resolveArticleBySku from './resolveArticleBySku.js';
/**
* Adds an image to an article by its SKU, processing it into AVIF variants.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that adds an article image.
*
* @example
* const fn = addImage(pool);
* const result = await fn(42, 'SKU-1', imageBuffer);
* // => { picId, sort, full: { w, h, size }, thumb: { w, h }, fullHash, thumbHash }
*/
export default function addImage(pool) {
const resolve = resolveArticleBySku(pool);
/**
* Adds an image to an article by its SKU, processing it into AVIF variants.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {Buffer} image - The source image buffer.
* @returns {Promise<{picId: number, sort: number, full: {w: number, h: number, size: number}, thumb: {w: number, h: number}, fullHash: string, thumbHash: string}>} The added image info.
*
* @example
* const result = await store.article.addImage(42, 'SKU-1', imageBuffer);
* // => { picId, sort, full: { w, h, size }, thumb: { w, h }, fullHash, thumbHash }
*/
return async (googleAuthId, sku, image) => {
const { articleId } = await resolve(googleAuthId, sku);

View File

@@ -7,23 +7,25 @@ import nextSku from '../numbers/nextSku.js';
import request from '../request.js';
import slugify from '../slugify.js';
/**
* Creates a new article under a given parent category, allocating a SKU and
* a default unit.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that creates an article.
*
* @example
* const fn = createUnderParent(pool);
* const article = await fn({ googleAuthId: 42, parentId: 5, name: 'Cola' });
* // => { id, parentId, name, sku }
*/
export default function createUnderParent(pool) {
const access = resolveCategoryAccess(pool);
const ensureUnit = ensureDefaultUnit(pool);
const allocateSku = nextSku(pool);
/**
* Creates a new article under a given parent category, allocating a SKU and
* a default unit.
*
* @param {object} input - The article creation input.
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.parentId - The id of the parent category.
* @param {string} input.name - The article name.
* @returns {Promise<{id: number, parentId: number, name: string, sku: string}>} The created article.
*
* @example
* const article = await store.article.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Cola' });
* // => { id, parentId, name, sku }
*/
return async ({ googleAuthId, parentId, name }) => {
const { companyId, languageId } = await access(googleAuthId, parentId);

View File

@@ -11,19 +11,20 @@ import {
import resolveArticleBySku from './resolveArticleBySku.js';
import request from '../request.js';
/**
* Deletes an article by its SKU, refusing when it still has children.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes an article.
*
* @example
* const fn = del(pool);
* await fn(42, 'SKU-1');
*/
export default function del(pool) {
const resolve = resolveArticleBySku(pool);
/**
* Deletes an article by its SKU, refusing when it still has children.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @returns {Promise<void>}
* @throws {Error} If the article has children or is not found.
*
* @example
* await store.article.delete(42, 'SKU-1');
*/
return async (googleAuthId, sku) => {
const { articleId, languageId } = await resolve(googleAuthId, sku);

View File

@@ -1,19 +1,21 @@
import { DELETE_BY_ARTICLE_AND_HASH } from '../../sql/pic.js';
import resolveArticleBySku from './resolveArticleBySku.js';
/**
* Deletes an image from an article by its SKU and full image hash.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes an article image.
*
* @example
* const fn = deleteImage(pool);
* await fn(42, 'SKU-1', 'abc123');
*/
export default function deleteImage(pool) {
const resolve = resolveArticleBySku(pool);
/**
* Deletes an image from an article by its SKU and full image hash.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {string} fullHash - The full image hash to delete.
* @returns {Promise<void>}
* @throws {Error} If the image is not found.
*
* @example
* await store.article.deleteImage(42, 'SKU-1', 'abc123');
*/
return async (googleAuthId, sku, fullHash) => {
const { articleId } = await resolve(googleAuthId, sku);

View File

@@ -6,19 +6,20 @@ function parseJsonArray(raw) {
return Array.isArray(arr) ? arr : [];
}
/**
* Returns the full detail of an article by its SKU, including unit, brand,
* categories, photos, EANs and prices.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches an article by SKU.
*
* @example
* const fn = getBySku(pool);
* const article = await fn(42, 'SKU-1');
* // => { article: { id, sku, name, unit, brand, categoryIds, photos, eans, prices, ... } }
*/
export default function getBySku(pool) {
/**
* Returns the full detail of an article by its SKU, including unit, brand,
* categories, photos, EANs and prices.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @returns {Promise<{article: object}>} The full article detail.
* @throws {Error} If the article is not found or the user is not authorized.
*
* @example
* const article = await store.article.getBySku(42, 'SKU-1');
* // => { article: { id, sku, name, unit, brand, categoryIds, photos, eans, prices, ... } }
*/
return async (googleAuthId, sku) => {
const t0 = performance.now();
const result = await pool

View File

@@ -1,20 +1,20 @@
import { LIST_BY_ARTICLE_ID } from '../../sql/articlehistory.js';
import resolveArticleBySku from './resolveArticleBySku.js';
/**
* Returns the change history of an article by its SKU.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches the article history.
*
* @example
* const fn = getHistoryBySku(pool);
* const history = await fn(42, 'SKU-1');
* // => [{ id, companyuserId, date, text }]
*/
export default function getHistoryBySku(pool) {
const resolve = resolveArticleBySku(pool);
/**
* Returns the change history of an article by its SKU.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @returns {Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>} The article history entries.
*
* @example
* const history = await store.article.getHistoryBySku(42, 'SKU-1');
* // => [{ id, companyuserId, date, text }]
*/
return async (googleAuthId, sku) => {
const { articleId } = await resolve(googleAuthId, sku);

View File

@@ -10,22 +10,23 @@ function parseJsonArray(raw) {
return Array.isArray(arr) ? arr : [];
}
/**
* Returns articles for export, optionally scoped to a category, including
* category breadcrumbs and image hashes.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists articles for export.
*
* @example
* const fn = listExport(pool);
* const articles = await fn(42, null);
* // => [{ id, sku, name, slug, unitSi, net, taxRate, eans, categoryBreadcrumbs, fullHashes, ... }]
*/
export default function listExport(pool) {
const companyAccess = resolveSelectedCompanyAccess(pool);
const categoryAccess = resolveCategoryAccess(pool);
/**
* Returns articles for export, optionally scoped to a category, including
* category breadcrumbs and image hashes.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number|null} [categoryId] - Optional category id to scope the export to; defaults to the company root.
* @returns {Promise<Array<object>>} The articles for export.
* @throws {Error} If the category is not reachable from the selected company.
*
* @example
* const articles = await store.article.listExport(42, null);
* // => [{ id, sku, name, slug, unitSi, net, taxRate, eans, categoryBreadcrumbs, fullHashes, ... }]
*/
return async (googleAuthId, categoryId) => {
const { languageId, posCustomerGroupId, rootCategoryId } =
await companyAccess(googleAuthId);

View File

@@ -1,20 +1,19 @@
import { SEARCH_ARTICLES } from '../../sql/article.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Returns a lightweight search index of all articles for a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists the article search index.
*
* @example
* const fn = listSearchIndex(pool);
* const articles = await fn(42);
* // => [{ id, sku, name, eans, categoryIds }]
*/
export default function listSearchIndex(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Returns a lightweight search index of all articles for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<Array<{id: number, sku: string, name: string, eans: Array<string>, categoryIds: Array<number>}>>} The article search index.
*
* @example
* const articles = await store.article.listSearchIndex(42);
* // => [{ id, sku, name, eans, categoryIds }]
*/
return async (googleAuthId) => {
const { companyId, rootCategoryId, languageId } = await access(googleAuthId);

View File

@@ -6,20 +6,23 @@ import {
import resolveArticleBySku from './resolveArticleBySku.js';
import request from '../request.js';
/**
* Reorders the images of an article by its SKU.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that reorders article images.
*
* @example
* const fn = orderImages(pool);
* const result = await fn(42, 'SKU-1', { fullHashes: ['a', 'b'] });
* // => { fullHashes: ['a', 'b'] }
*/
export default function orderImages(pool) {
const resolve = resolveArticleBySku(pool);
/**
* Reorders the images of an article by its SKU.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {object} input - The reorder input.
* @param {Array<string>} input.fullHashes - The full image hashes in their new order.
* @returns {Promise<{fullHashes: Array<string>}>} The applied order.
* @throws {Error} If the order is invalid.
*
* @example
* const result = await store.article.orderImages(42, 'SKU-1', { fullHashes: ['a', 'b'] });
* // => { fullHashes: ['a', 'b'] }
*/
return async (googleAuthId, sku, { fullHashes }) => {
if (!Array.isArray(fullHashes)) {
throw new Error('Invalid image order');

View File

@@ -5,21 +5,23 @@ import {
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
/**
* Sets (or clears) the brand of an article by its SKU.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that sets the article brand.
*
* @example
* const fn = setBrandIdBySku(pool);
* const article = await fn(42, 'SKU-1', 3);
* // => { id, sku, name, ... }
*/
export default function setBrandIdBySku(pool) {
const resolve = resolveArticleBySku(pool);
const load = queryBySku(pool);
/**
* Sets (or clears) the brand of an article by its SKU.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {number|null} brandId - The brand id, or `null` to clear the brand.
* @returns {Promise<object>} The updated article.
* @throws {Error} If the brand or article is not found.
*
* @example
* const article = await store.article.setBrandIdBySku(42, 'SKU-1', 3);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, brandId) => {
const { articleId, languageId, companyId } = await resolve(
googleAuthId,

View File

@@ -7,22 +7,24 @@ import resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
/**
* Replaces the category membership of an article by its SKU.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that sets the article categories.
*
* @example
* const fn = setCategoryIdsBySku(pool);
* const article = await fn(42, 'SKU-1', [5, 9]);
* // => { id, sku, name, ... }
*/
export default function setCategoryIdsBySku(pool) {
const resolve = resolveArticleBySku(pool);
const categoryAccess = resolveCategoryAccess(pool);
const load = queryBySku(pool);
/**
* Replaces the category membership of an article by its SKU.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {Array<number>} categoryIds - The category ids the article should belong to (at least one).
* @returns {Promise<object>} The updated article.
* @throws {Error} If the category ids are invalid or the article is not found.
*
* @example
* const article = await store.article.setCategoryIdsBySku(42, 'SKU-1', [5, 9]);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, categoryIds) => {
if (!Array.isArray(categoryIds)) {
throw new Error('Invalid categoryIds');

View File

@@ -6,21 +6,23 @@ import request from '../request.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
/**
* Replaces the EAN list of an article by its SKU.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that sets article EANs.
*
* @example
* const fn = setEansBySku(pool);
* const article = await fn(42, 'SKU-1', ['4001234567890']);
* // => { id, sku, name, ... }
*/
export default function setEansBySku(pool) {
const resolve = resolveArticleBySku(pool);
const load = queryBySku(pool);
/**
* Replaces the EAN list of an article by its SKU.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {Array<string>} eans - The EANs to set (must be unique, non-empty strings).
* @returns {Promise<object>} The updated article.
* @throws {Error} If the EANs are invalid or the article is not found.
*
* @example
* const article = await store.article.setEansBySku(42, 'SKU-1', ['4001234567890']);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, eans) => {
if (!Array.isArray(eans)) {
throw new Error('Invalid eans');

View File

@@ -42,22 +42,24 @@ function normalizePrices(prices) {
return normalized;
}
/**
* Replaces the price list of an article by its SKU.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that sets article prices.
*
* @example
* const fn = setPricesBySku(pool);
* const article = await fn(42, 'SKU-1', [{ price: 1.5, tax: 19, minmultiplier: 1 }]);
* // => { id, sku, name, ... }
*/
export default function setPricesBySku(pool) {
const resolve = resolveArticleBySku(pool);
const ensureTax = taxEnsure(pool);
const load = queryBySku(pool);
/**
* Replaces the price list of an article by its SKU.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {Array<{price: number, tax: number, minmultiplier?: number}>} prices - The price entries (minmultiplier defaults to 1).
* @returns {Promise<object>} The updated article.
* @throws {Error} If the prices are invalid or the article is not found.
*
* @example
* const article = await store.article.setPricesBySku(42, 'SKU-1', [{ price: 1.5, tax: 19, minmultiplier: 1 }]);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, prices) => {
const normalized = normalizePrices(prices);

View File

@@ -2,21 +2,23 @@ import { UPDATE_WEIGHT } from '../../sql/article.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
/**
* Sets the weight of an article by its SKU.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that sets the article weight.
*
* @example
* const fn = setWeightBySku(pool);
* const article = await fn(42, 'SKU-1', 1.5);
* // => { id, sku, name, ... }
*/
export default function setWeightBySku(pool) {
const resolve = resolveArticleBySku(pool);
const load = queryBySku(pool);
/**
* Sets the weight of an article by its SKU.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {number} weight - The weight (must be >= 0).
* @returns {Promise<object>} The updated article.
* @throws {Error} If the weight is invalid or the article is not found.
*
* @example
* const article = await store.article.setWeightBySku(42, 'SKU-1', 1.5);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, weight) => {
if (!Number.isFinite(weight) || weight < 0) {
throw new Error('Invalid weight');

View File

@@ -20,21 +20,26 @@ function formatUnitLabel(amount, symbol) {
return `${Number(amount)}${sym}`;
}
/**
* Updates an article's name and/or unit (SI and amount), recording history.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that updates an article by SKU.
*
* @example
* const fn = updateBySku(pool);
* const article = await fn(42, 'SKU-1', { name: 'Cola 1L', unitSi: 'l', unitamount: 1 });
* // => { id, sku, name, ... }
*/
export default function updateBySku(pool) {
const resolve = resolveArticleBySku(pool);
const load = queryBySku(pool);
/**
* Updates an article's name and/or unit (SI and amount), recording history.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {object} patch - The update patch.
* @param {string} [patch.name] - The new article name.
* @param {string} [patch.unitSi] - The new unit SI code.
* @param {number} [patch.unitamount] - The new unit amount (must be > 0).
* @returns {Promise<object>} The updated article.
* @throws {Error} If no valid field is provided or the article is not found.
*
* @example
* const article = await store.article.updateBySku(42, 'SKU-1', { name: 'Cola 1L', unitSi: 'l', unitamount: 1 });
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, patch = {}) => {
const hasName = Object.prototype.hasOwnProperty.call(patch, 'name');
const hasUnitamount = Object.prototype.hasOwnProperty.call(patch, 'unitamount');

View File

@@ -1,19 +1,20 @@
import { DELETE_BY_COMPANY_AND_ID } from '../../sql/brand.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Deletes a brand for a company by its id.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes a brand.
*
* @example
* const fn = del(pool);
* await fn(42, 3);
*/
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Deletes a brand for the user's selected company by its id.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the brand to delete.
* @returns {Promise<void>}
* @throws {Error} If the brand is not found.
*
* @example
* await store.brand.delete(42, 3);
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Brand not found');

View File

@@ -1,20 +1,19 @@
import { LIST_BY_COMPANY } from '../../sql/brand.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Lists the brands of a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists brands.
*
* @example
* const fn = list(pool);
* const brands = await fn(42);
* // => [{ id, name, gsprpinfo }]
*/
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Lists the brands of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<Array<{id: number, name: string, gsprpinfo: string|null}>>} The brands.
*
* @example
* const brands = await store.brand.list(42);
* // => [{ id, name, gsprpinfo }]
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);

View File

@@ -5,20 +5,23 @@ import {
} from '../../sql/brand.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Creates or updates a brand for a company, keyed by name.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that upserts a brand.
*
* @example
* const fn = upsert(pool);
* const brand = await fn(42, { name: 'Coca-Cola', gsprpinfo: 'x' });
* // => { id, name, gsprpinfo }
*/
export default function upsert(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Creates or updates a brand for the user's selected company, keyed by name.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The brand input.
* @param {string} input.name - The brand name.
* @param {string} [input.gsprpinfo] - Optional GSPRP info.
* @returns {Promise<{id: number, name: string, gsprpinfo: string|null}>} The upserted brand.
* @throws {Error} If the brand is invalid.
*
* @example
* const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' });
* // => { id, name, gsprpinfo }
*/
return async (googleAuthId, input = {}) => {
const name = input.name?.trim();
if (!name) {

View File

@@ -5,20 +5,21 @@ import newFullHash from '../../util/newFullHash.js';
import request from '../request.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
/**
* Adds an image to a category, processing it into AVIF full and thumb variants.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that adds a category image.
*
* @example
* const fn = addImage(pool);
* const result = await fn(42, 5, imageBuffer);
* // => { id, fullHash, thumbW, thumbH, thumbHash }
*/
export default function addImage(pool) {
const access = resolveCategoryAccess(pool);
/**
* Adds an image to a category, processing it into AVIF full and thumb variants.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} categoryId - The id of the category.
* @param {Buffer} image - The source image buffer.
* @returns {Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>} The added image info.
*
* @example
* const result = await store.category.addImage(42, 5, imageBuffer);
* // => { id, fullHash, thumbW, thumbH, thumbHash }
*/
return async (googleAuthId, categoryId, image) => {
await access(googleAuthId, categoryId);

View File

@@ -7,20 +7,22 @@ import resolveCategoryAccess from './resolveCategoryAccess.js';
import request from '../request.js';
import slugify from '../slugify.js';
/**
* Creates a new category under a given parent category.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that creates a category.
*
* @example
* const fn = createUnderParent(pool);
* const category = await fn({ googleAuthId: 42, parentId: 5, name: 'Drinks' });
* // => { id, parentId, sort, name, slug }
*/
export default function createUnderParent(pool) {
const access = resolveCategoryAccess(pool);
/**
* Creates a new category under a given parent category.
*
* @param {object} input - The category creation input.
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.parentId - The id of the parent category.
* @param {string} input.name - The category name.
* @returns {Promise<{id: number, parentId: number, sort: number, name: string, slug: string}>} The created category.
*
* @example
* const category = await store.category.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Drinks' });
* // => { id, parentId, sort, name, slug }
*/
return async ({ googleAuthId, parentId, name }) => {
const { languageId } = await access(googleAuthId, parentId);

View File

@@ -1,19 +1,21 @@
import { DELETE } from '../../sql/category.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
/**
* Deletes a category, refusing to delete the company's root category.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes a category.
*
* @example
* const fn = del(pool);
* await fn({ googleAuthId: 42, id: 5 });
*/
export default function del(pool) {
const access = resolveCategoryAccess(pool);
/**
* Deletes a category, refusing to delete the company's root category.
*
* @param {object} input - The delete input.
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.id - The id of the category to delete.
* @returns {Promise<void>}
* @throws {Error} If the category is the root category.
*
* @example
* await store.category.delete({ googleAuthId: 42, id: 5 });
*/
return async ({ googleAuthId, id }) => {
const { rootCategoryId } = await access(googleAuthId, id);

View File

@@ -1,20 +1,21 @@
import { DELETE_BY_CATEGORY } from '../../sql/categorypic.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
/**
* Deletes the image of a category.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes a category image.
*
* @example
* const fn = deleteImage(pool);
* const result = await fn(42, 5);
* // => { id, thumbW: null, thumbH: null, thumbHash: null }
*/
export default function deleteImage(pool) {
const access = resolveCategoryAccess(pool);
/**
* Deletes the image of a category.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} categoryId - The id of the category.
* @returns {Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>} The cleared image info.
* @throws {Error} If the image is not found.
*
* @example
* const result = await store.category.deleteImage(42, 5);
* // => { id: 5, thumbW: null, thumbH: null, thumbHash: null }
*/
return async (googleAuthId, categoryId) => {
await access(googleAuthId, categoryId);

View File

@@ -1,21 +1,21 @@
import resolveCategoryAccess from './resolveCategoryAccess.js';
import queryCategoryArticles from './queryCategoryArticles.js';
/**
* Returns the articles belonging to a single category.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches the articles of a category.
*
* @example
* const fn = getCategoryArticles(pool);
* const articles = await fn(42, 5);
* // => [{ id, sku, name, ... }]
*/
export default function getCategoryArticles(pool) {
const access = resolveCategoryAccess(pool);
const articles = queryCategoryArticles(pool);
/**
* Returns the articles belonging to a single category.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} categoryId - The id of the category.
* @returns {Promise<Array<object>>} The articles in the category.
*
* @example
* const articles = await store.category.getCategoryArticles(42, 5);
* // => [{ id, sku, name, ... }]
*/
return async (googleAuthId, categoryId) => {
const { languageId, posCustomerGroupId } = await access(
googleAuthId,

View File

@@ -3,23 +3,22 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA
import queryCategoryArticles from './queryCategoryArticles.js';
import getCategoryPosterminalIds from '../posterminal/getCategoryPosterminalIds.js';
/**
* Returns the full category tree for a company together with its articles and
* the posterminal ids assigned to each category.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches the category tree.
*
* @example
* const fn = getTreeForCompany(pool);
* const tree = await fn(42);
* // => { languageId, tree: [{ id, posterminalIds, ... }], articles: [...] }
*/
export default function getTreeForCompany(pool) {
const access = resolveSelectedCompanyAccess(pool);
const articles = queryCategoryArticles(pool);
const posterminalIds = getCategoryPosterminalIds(pool);
/**
* Returns the full category tree for the user's selected company together
* with its articles and the posterminal ids assigned to each category.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>} The category tree, articles and language.
*
* @example
* const tree = await store.category.getTreeForCompany(42);
* // => { languageId, tree: [{ id, posterminalIds, ... }], articles: [...] }
*/
return async (googleAuthId) => {
const { companyId, rootCategoryId, languageId, posCustomerGroupId } =
await access(googleAuthId);

View File

@@ -6,20 +6,23 @@ import {
import request from '../request.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
/**
* Moves a category to a new parent, preventing cycles and cross-company moves.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that moves a category.
*
* @example
* const fn = move(pool);
* const result = await fn({ googleAuthId: 42, id: 5, parentId: 9 });
* // => { id, parentId, sort }
*/
export default function move(pool) {
const access = resolveCategoryAccess(pool);
/**
* Moves a category to a new parent, preventing cycles and cross-company moves.
*
* @param {object} input - The move input.
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.id - The id of the category to move.
* @param {number} input.parentId - The id of the new parent category.
* @returns {Promise<{id: number, parentId: number, sort: number}>} The moved category.
* @throws {Error} If the move would create a cycle or cross companies.
*
* @example
* const result = await store.category.move({ googleAuthId: 42, id: 5, parentId: 9 });
* // => { id, parentId, sort }
*/
return async ({ googleAuthId, id, parentId }) => {
// Source category auth (throws if missing / not authorized).
const source = await access(googleAuthId, id);

View File

@@ -5,20 +5,23 @@ import {
import request from '../request.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
/**
* Reorders the child categories of a given parent.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that reorders categories.
*
* @example
* const fn = order(pool);
* const result = await fn({ googleAuthId: 42, parentId: 5, categoryIds: [3, 1, 2] });
* // => { categoryIds: [3, 1, 2] }
*/
export default function order(pool) {
const access = resolveCategoryAccess(pool);
/**
* Reorders the child categories of a given parent.
*
* @param {object} input - The reorder input.
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.parentId - The id of the parent category.
* @param {Array<number>} input.categoryIds - The child category ids in their new order.
* @returns {Promise<{categoryIds: Array<number>}>} The applied order.
* @throws {Error} If the order is invalid.
*
* @example
* const result = await store.category.order({ googleAuthId: 42, parentId: 5, categoryIds: [3, 1, 2] });
* // => { categoryIds: [3, 1, 2] }
*/
return async ({ googleAuthId, parentId, categoryIds }) => {
if (!Array.isArray(categoryIds)) {
throw new Error('Invalid category order');

View File

@@ -2,20 +2,23 @@ import { UPDATE_BY_CATEGORY_AND_LANGUAGE } from '../../sql/categorytext.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
import slugify from '../slugify.js';
/**
* Renames a category and updates its slug.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that renames a category.
*
* @example
* const fn = rename(pool);
* const result = await fn({ googleAuthId: 42, id: 5, name: 'Soft Drinks' });
* // => { id, name, slug }
*/
export default function rename(pool) {
const access = resolveCategoryAccess(pool);
/**
* Renames a category and updates its slug.
*
* @param {object} input - The rename input.
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.id - The id of the category to rename.
* @param {string} input.name - The new category name.
* @returns {Promise<{id: number, name: string, slug: string}>} The renamed category.
* @throws {Error} If the name is empty or the category is not found.
*
* @example
* const result = await store.category.rename({ googleAuthId: 42, id: 5, name: 'Soft Drinks' });
* // => { id, name, slug }
*/
return async ({ googleAuthId, id, name }) => {
if (!name?.trim()) {
throw new Error('Name required');

View File

@@ -1,19 +1,20 @@
import { DELETE_BY_COMPANY_AND_KEY } from '../../sql/companysetting.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Deletes a company setting by key.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes a company setting.
*
* @example
* const fn = del(pool);
* await fn(42, 'theme');
*/
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');

View File

@@ -2,20 +2,22 @@ import { GET_BY_COMPANY_AND_KEY } from '../../sql/companysetting.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shapeSetting from './shapeSetting.js';
/**
* Returns a single company setting by key.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches a company setting.
*
* @example
* const fn = get(pool);
* const setting = await fn(42, 'theme');
* // => { key, value, frontend }
*/
export default function get(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Returns a single 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.
* @param {object} [options] - Optional options.
* @param {boolean} [options.frontendOnly] - Only return the setting if it is frontend-visible.
* @returns {Promise<{key: string, value: string, frontend: boolean}|undefined>} The setting, or `undefined` if not found.
*
* @example
* const setting = await store.companysetting.get(42, 'theme');
* // => { key, value, frontend }
*/
return async (googleAuthId, key, { frontendOnly = false } = {}) => {
if (!key?.trim()) {
return undefined;

View File

@@ -5,20 +5,21 @@ import {
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shapeSetting from './shapeSetting.js';
/**
* Lists the settings of a company, optionally only frontend-visible ones.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists company settings.
*
* @example
* const fn = list(pool);
* const settings = await fn(42, { frontendOnly: true });
* // => [{ key, value, frontend }]
*/
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Lists the settings of the user's selected company, optionally only frontend-visible ones.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [options] - Optional options.
* @param {boolean} [options.frontendOnly] - Only return frontend-visible settings.
* @returns {Promise<Array<{key: string, value: string, frontend: boolean}>>} The settings.
*
* @example
* const settings = await store.companysetting.list(42, { frontendOnly: true });
* // => [{ key, value, frontend }]
*/
return async (googleAuthId, { frontendOnly = false } = {}) => {
const { companyId } = await access(googleAuthId);

View File

@@ -2,20 +2,24 @@ import { GET_BY_COMPANY_AND_KEY, UPSERT } from '../../sql/companysetting.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shapeSetting from './shapeSetting.js';
/**
* Sets (upserts) a company setting by key.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that sets a company setting.
*
* @example
* const fn = set(pool);
* const setting = await fn(42, { key: 'theme', value: 'dark', frontend: true });
* // => { key, value, frontend }
*/
export default function set(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Sets (upserts) a company setting by key for the user's selected company.
*
* @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.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 });
* // => { key, value, frontend }
*/
return async (googleAuthId, input = {}) => {
const key = input.key?.trim();
if (!key) {

View File

@@ -1,14 +1,16 @@
import { GET_BY_ID } from '../../sql/google_auth.js';
import resolveCompanyAccess from './resolveCompanyAccess.js';
/**
* Resolves the user's selected company and checks company membership.
* @param {object} pool
* @returns {(googleAuthId: number) => Promise<object>}
*/
export default function resolveSelectedCompanyAccess(pool) {
const access = resolveCompanyAccess(pool);
/**
* Resolves the user's selected company and checks company membership.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<{companyId: number, rootCategoryId: number, languageId: number, posCustomerGroupId: number|null, companyuserId: number}>} The resolved company access context.
* @throws {Error} If the user is not authorized or has no company selected.
*/
return async (googleAuthId) => {
if (!Number.isFinite(googleAuthId)) {
throw new Error('Not authorized');

View File

@@ -8,20 +8,22 @@ function shape(row) {
return { id: row.id, name: row.name, sort: row.sort };
}
/**
* Creates a new customer group for a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that creates a customer group.
*
* @example
* const fn = create(pool);
* const group = await fn(42, { name: 'Wholesale' });
* // => { id, name, sort }
*/
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Creates a new customer group for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The customer group input.
* @param {string} input.name - The customer group name (must not be a reserved name).
* @returns {Promise<{id: number, name: string, sort: number}>} The created customer group.
* @throws {Error} If the name is empty or reserved.
*
* @example
* const group = await store.customergroup.create(42, { name: 'Wholesale' });
* // => { id, name, sort }
*/
return async (googleAuthId, { name } = {}) => {
const cleaned = name?.trim();
if (!cleaned) {

View File

@@ -1,19 +1,20 @@
import { DELETE_BY_COMPANY_AND_ID } from '../../sql/customergroup.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Deletes a customer group for a company by its id.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes a customer group.
*
* @example
* const fn = del(pool);
* await fn(42, 5);
*/
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Deletes a customer group for the user's selected company by its id.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the customer group to delete.
* @returns {Promise<void>}
* @throws {Error} If the customer group is not found.
*
* @example
* await store.customergroup.delete(42, 5);
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Customer group not found');

View File

@@ -1,20 +1,19 @@
import { LIST_BY_COMPANY } from '../../sql/customergroup.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Lists the customer groups of a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists customer groups.
*
* @example
* const fn = list(pool);
* const groups = await fn(42);
* // => [{ id, name, sort }]
*/
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Lists the customer groups of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<Array<{id: number, name: string, sort: number}>>} The customer groups.
*
* @example
* const groups = await store.customergroup.list(42);
* // => [{ id, name, sort }]
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);

View File

@@ -5,20 +5,22 @@ import {
import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Reorders the customer groups of a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that reorders customer groups.
*
* @example
* const fn = order(pool);
* const result = await fn(42, { customergroupIds: [3, 1, 2] });
* // => { customergroupIds: [3, 1, 2] }
*/
export default function order(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Reorders the customer groups of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The reorder input.
* @param {Array<number>} input.customergroupIds - The customer group ids in their new order.
* @returns {Promise<{customergroupIds: Array<number>}>} The applied order.
* @throws {Error} If the order is invalid.
*
* @example
* const result = await store.customergroup.order(42, { customergroupIds: [3, 1, 2] });
* // => { customergroupIds: [3, 1, 2] }
*/
return async (googleAuthId, { customergroupIds } = {}) => {
if (!Array.isArray(customergroupIds)) {
throw new Error('Invalid customer group order');

View File

@@ -8,20 +8,23 @@ function shape(row) {
return { id: row.id, name: row.name, sort: row.sort };
}
/**
* Updates the name of a customer group for a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that updates a customer group.
*
* @example
* const fn = update(pool);
* const group = await fn(42, 5, { name: 'Retail' });
* // => { id, name, sort }
*/
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Updates the name of a customer group for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the customer group to update.
* @param {object} input - The update input.
* @param {string} input.name - The new customer group name.
* @returns {Promise<{id: number, name: string, sort: number}>} The updated customer group.
* @throws {Error} If the customer group is not found or the name is empty.
*
* @example
* const group = await store.customergroup.update(42, 5, { name: 'Retail' });
* // => { id, name, sort }
*/
return async (googleAuthId, id, { name } = {}) => {
if (!Number.isFinite(id)) {
throw new Error('Customer group not found');

View File

@@ -2,31 +2,33 @@ import { CREATE_MANIFEST, DELIVERY_TYPE } from '../../sql/delivery.js';
import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
import {
loadDelivery,
normalizeLines,
parseDeliveryDate,
} from './helpers.js';
import { assertSupplier, insertLines } from './writeLines.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { loadDelivery } from './helpers.js';
import { assertSupplier } from './writeLines.js';
/**
* Creates a new delivery for a company with its lines.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that creates a delivery.
*
* @example
* const fn = create(pool);
* const delivery = await fn(42, { number: 'D-1', date: '2026-01-01', supplierId: 5, lines: [...] });
* // => { id, number, type, entityId, date, lines: [...] }
*/
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
const ensureTax = taxEnsure(pool);
/**
* Creates a new delivery for the user's selected company with its lines.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} body - The delivery input.
* @param {string} body.number - The delivery number.
* @param {string} body.date - The delivery date.
* @param {number} body.supplierId - The id of the supplier.
* @param {Array<object>} [body.lines] - The delivery lines.
* @returns {Promise<{id: number, number: string, type: string, entityId: number, date: string, lines: Array<object>}>} The created delivery.
* @throws {Error} If the delivery is invalid.
*
* @example
* const delivery = await store.delivery.create(42, { number: 'D-1', date: '2026-01-01', supplierId: 5, lines: [...] });
* // => { id, number, type, entityId, date, lines: [...] }
*/
return async (googleAuthId, body = {}) => {
const number = body.number?.trim?.() ? body.number.trim() : null;
const date = parseDeliveryDate(body.date);
const date = parseDate(body.date);
if (!number || !date) {
throw new Error('Invalid delivery');
}

View File

@@ -1,19 +1,20 @@
import { DELETE_MANIFEST, DELIVERY_TYPE } from '../../sql/delivery.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Deletes a delivery by its id within a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes a delivery.
*
* @example
* const fn = del(pool);
* await fn(42, 5);
*/
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Deletes a delivery by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the delivery to delete.
* @returns {Promise<void>}
* @throws {Error} If the delivery is not found.
*
* @example
* await store.delivery.delete(42, 5);
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Delivery not found');

View File

@@ -1,20 +1,21 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { loadDelivery } from './helpers.js';
/**
* Returns a single delivery by its id within a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches a delivery by id.
*
* @example
* const fn = getById(pool);
* const delivery = await fn(42, 5);
* // => { id, number, type, entityId, date, lines: [...] }
*/
export default function getById(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Returns a single delivery by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the delivery.
* @returns {Promise<{id: number, number: string, type: string, entityId: number, date: string, lines: Array<object>}>} The delivery.
* @throws {Error} If the delivery is not found.
*
* @example
* const delivery = await store.delivery.getById(42, 5);
* // => { id, number, type, entityId, date, lines: [...] }
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Delivery not found');

View File

@@ -4,6 +4,7 @@ import {
GET_DELIVERY_LINES,
} from '../../sql/delivery.js';
import request from '../request.js';
import { shapeLine } from '../manifestLines.js';
export function shapeSummary(row) {
return {
@@ -15,19 +16,6 @@ export function shapeSummary(row) {
};
}
export function shapeLine(row) {
return {
id: row.id,
articleId: row.articleId ?? null,
sku: row.sku ?? null,
amount: row.amount,
net: row.net,
rebate: row.rebate,
tax: row.tax,
comment: row.comment ?? null,
};
}
export async function loadDelivery(pool, tx, companyId, id) {
const header = await request(pool, tx)
.input('id', id)
@@ -49,50 +37,3 @@ export async function loadDelivery(pool, tx, companyId, id) {
lines: lines.recordset.map(shapeLine),
};
}
export function parseDeliveryDate(value) {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const d = new Date(value);
if (!Number.isNaN(d.getTime())) {
return d;
}
}
return null;
}
export function normalizeLines(lines) {
if (!Array.isArray(lines)) {
throw new Error('Invalid lines');
}
return lines.map((entry) => {
const sku = entry?.sku?.trim?.() ? entry.sku.trim() : null;
if (!sku) {
throw new Error('Invalid lines');
}
const amount = entry?.amount;
const net = entry?.net;
const rebate =
entry?.rebate === undefined || entry?.rebate === null ? 0 : entry.rebate;
const tax = entry?.tax;
const comment =
entry?.comment === undefined || entry?.comment === null
? null
: String(entry.comment);
if (
!Number.isFinite(amount) ||
!Number.isFinite(net) ||
!Number.isFinite(rebate) ||
!Number.isFinite(tax)
) {
throw new Error('Invalid lines');
}
return { sku, amount, net, rebate, tax, comment };
});
}

View File

@@ -2,20 +2,19 @@ import { DELIVERY_TYPE, LIST_DELIVERIES } from '../../sql/delivery.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { shapeSummary } from './helpers.js';
/**
* Lists the deliveries of a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists deliveries.
*
* @example
* const fn = list(pool);
* const deliveries = await fn(42);
* // => [{ id, number, type, entityId, date }]
*/
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Lists the deliveries of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<Array<{id: number, number: string, type: string, entityId: number, date: string}>>} The deliveries.
*
* @example
* const deliveries = await store.delivery.list(42);
* // => [{ id, number, type, entityId, date }]
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);

View File

@@ -7,29 +7,31 @@ import {
import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
import {
loadDelivery,
normalizeLines,
parseDeliveryDate,
shapeSummary,
} from './helpers.js';
import { assertSupplier, insertLines } from './writeLines.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { loadDelivery, shapeSummary } from './helpers.js';
import { assertSupplier } from './writeLines.js';
/**
* Updates a delivery's header and/or lines.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that updates a delivery.
*
* @example
* const fn = update(pool);
* const delivery = await fn(42, 5, { number: 'D-2', lines: [...] });
* // => { id, number, type, entityId, date, lines: [...] }
*/
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
const ensureTax = taxEnsure(pool);
/**
* Updates a delivery's header and/or lines.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the delivery to update.
* @param {object} patch - The update patch.
* @param {string} [patch.number] - The new delivery number.
* @param {string} [patch.date] - The new delivery date.
* @param {number} [patch.supplierId] - The new supplier id.
* @param {Array<object>} [patch.lines] - The new delivery lines.
* @returns {Promise<{id: number, number: string, type: string, entityId: number, date: string, lines: Array<object>}>} The updated delivery.
* @throws {Error} If the delivery is not found.
*
* @example
* const delivery = await store.delivery.update(42, 5, { number: 'D-2', lines: [...] });
* // => { id, number, type, entityId, date, lines: [...] }
*/
return async (googleAuthId, id, patch = {}) => {
if (!Number.isFinite(id)) {
throw new Error('Delivery not found');
@@ -81,7 +83,7 @@ export default function update(pool) {
let date = summary.date;
if (hasDate) {
date = parseDeliveryDate(patch.date);
date = parseDate(patch.date);
if (!date) {
throw new Error('Invalid delivery');
}

View File

@@ -1,7 +1,3 @@
import {
CREATE_LINE,
RESOLVE_ARTICLE_ID_BY_SKU,
} from '../../sql/delivery.js';
import { GET_SUPPLIER_BY_ID, SUPPLIER_GROUP_NAME } from '../../sql/entity.js';
import request from '../request.js';
@@ -24,42 +20,3 @@ export async function assertSupplier(pool, tx, companyId, supplierId) {
}
return supplierId;
}
async function resolveArticleId(pool, tx, rootCategoryId, sku) {
const result = await request(pool, tx)
.input('rootCategoryId', rootCategoryId)
.input('sku', sku)
.query(RESOLVE_ARTICLE_ID_BY_SKU);
const articleId = result.recordset[0]?.id;
if (!articleId) {
throw new Error('Article not found');
}
return articleId;
}
export async function insertLines(
pool,
tx,
{ manifestId, rootCategoryId, lines, ensureTax },
) {
for (const line of lines) {
const articleId = await resolveArticleId(
pool,
tx,
rootCategoryId,
line.sku,
);
const taxId = await ensureTax(line.tax, tx);
await request(pool, tx)
.input('manifestId', manifestId)
.input('articleId', articleId)
.input('amount', line.amount)
.input('net', line.net)
.input('comment', line.comment)
.input('rebate', line.rebate)
.input('taxId', taxId)
.query(CREATE_LINE);
}
}

View File

@@ -6,24 +6,29 @@ import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAcc
import nextNumber from '../numbers/nextNumber.js';
import shape from './shape.js';
/**
* Creates a new entity (customer) in a customer group, recording history.
* Optional `number`; if omitted, allocates via `getnextnumberwithprefixandpadding`
* (`type` `entity`).
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that creates an entity.
*
* @example
* const fn = create(pool);
* const entity = await fn(42, { customergroupId: 5, name: 'Doe', firstName: 'John' });
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
const groupAccess = resolveCustomerGroupAccess(pool);
const allocateNumber = nextNumber(pool);
/**
* Creates a new entity (customer) in a customer group, recording history.
* Optional `number`; if omitted, allocates via `getnextnumberwithprefixandpadding`
* (`type` `entity`).
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The entity creation input.
* @param {number} input.customergroupId - The id of the customer group.
* @param {string} input.name - The entity name.
* @param {string} [input.firstName] - The entity first name.
* @param {string} [input.number] - Optional explicit customer number.
* @returns {Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>} The created entity.
* @throws {Error} If the name is empty.
*
* @example
* const entity = await store.entity.create(42, { customergroupId: 5, name: 'Doe', firstName: 'John' });
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/
return async (googleAuthId, { customergroupId, name, firstName = null, number } = {}) => {
const cleaned = name?.trim();
if (!cleaned) {

View File

@@ -1,19 +1,20 @@
import { DELETE_BY_COMPANY_AND_ID } from '../../sql/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Deletes an entity by its id within a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes an entity.
*
* @example
* const fn = del(pool);
* await fn(42, 5);
*/
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Deletes an entity by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the entity to delete.
* @returns {Promise<void>}
* @throws {Error} If the entity is not found.
*
* @example
* await store.entity.delete(42, 5);
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Entity not found');

View File

@@ -2,20 +2,21 @@ import { GET_BY_COMPANY_AND_ID } from '../../sql/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shape from './shape.js';
/**
* Returns a single entity by its id within a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches an entity by id.
*
* @example
* const fn = getById(pool);
* const entity = await fn(42, 5);
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/
export default function getById(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Returns a single entity by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the entity.
* @returns {Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>} The entity.
* @throws {Error} If the entity is not found.
*
* @example
* const entity = await store.entity.getById(42, 5);
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Entity not found');

View File

@@ -1,23 +1,26 @@
import { NUMBER_TAKEN_IN_COMPANY } from '../../sql/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Returns whether a customer number is free within the company.
* Pass `excludeEntityId` when editing so the entity's own number counts as free.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that checks number availability.
*
* @example
* const fn = isNumberFree(pool);
* const free = await fn(42, 'C-100');
* // => true
* const freeOnEdit = await fn(42, 'C-100', { excludeEntityId: 5 });
* // => true
*/
export default function isNumberFree(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Returns whether a customer number is free within the user's selected company.
* Pass `excludeEntityId` when editing so the entity's own number counts as free.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} number - The customer number to check.
* @param {object} [options] - Optional options.
* @param {number} [options.excludeEntityId] - An entity id to exclude from the check.
* @returns {Promise<boolean>} Whether the number is free.
* @throws {Error} If the number is empty.
*
* @example
* const free = await store.entity.isNumberFree(42, 'C-100');
* // => true
* const freeOnEdit = await store.entity.isNumberFree(42, 'C-100', { excludeEntityId: 5 });
* // => true
*/
return async (googleAuthId, number, { excludeEntityId } = {}) => {
const cleaned =
number == null || number === '' ? null : String(number).trim() || null;

View File

@@ -9,22 +9,25 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA
import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAccess.js';
import shape from './shape.js';
/**
* Lists the entities (customers) of a company, optionally filtered by customer
* group or restricted to the 'customer' role.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists entities.
*
* @example
* const fn = list(pool);
* const entities = await fn(42, { customergroupId: 5 });
* // => [{ id, customergroupId, firstName, name, number, createdAt, updatedAt }]
*/
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
const groupAccess = resolveCustomerGroupAccess(pool);
/**
* Lists the entities (customers) of the user's selected company, optionally
* filtered by customer group or restricted to the 'customer' role.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [options] - Optional options.
* @param {number} [options.customergroupId] - Filter by customer group id.
* @param {string} [options.role] - Restrict to `'customer'` role if set.
* @returns {Promise<Array<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>>} The entities.
* @throws {Error} If the role is invalid.
*
* @example
* const entities = await store.entity.list(42, { customergroupId: 5 });
* // => [{ id, customergroupId, firstName, name, number, createdAt, updatedAt }]
*/
return async (googleAuthId, { customergroupId, role } = {}) => {
const { companyId } = await access(googleAuthId);

View File

@@ -1,22 +1,21 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import peekNumber from '../numbers/peekNumber.js';
/**
* Returns the next customer number that would be allocated for the company
* (`numbers` type `entity`) without consuming it.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that peeks the next entity number.
*
* @example
* const fn = peekNextNumber(pool);
* const number = await fn(42);
* // => '1'
*/
export default function peekNextNumber(pool) {
const access = resolveSelectedCompanyAccess(pool);
const peek = peekNumber(pool);
/**
* Returns the next customer number that would be allocated for the user's
* selected company (`numbers` type `entity`) without consuming it.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<string>} The next entity number.
*
* @example
* const number = await store.entity.peekNextNumber(42);
* // => '1'
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);
return peek(companyId, 'entity');

View File

@@ -4,21 +4,26 @@ import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shape from './shape.js';
/**
* Updates name, first name, and/or customer number of an entity, recording history.
* Optional `number` sets the customer number explicitly (does not auto-allocate).
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that updates an entity.
*
* @example
* const fn = update(pool);
* const entity = await fn(42, 5, { name: 'Doe', firstName: 'Jane', number: 'C-42' });
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Updates name, first name, and/or customer number of an entity, recording history.
* Optional `number` sets the customer number explicitly (does not auto-allocate).
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the entity to update.
* @param {object} patch - The update patch.
* @param {string} [patch.name] - The new entity name.
* @param {string} [patch.firstName] - The new first name.
* @param {string} [patch.number] - The new customer number.
* @returns {Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>} The updated entity.
* @throws {Error} If no valid field is provided or the entity is not found.
*
* @example
* const entity = await store.entity.update(42, 5, { name: 'Doe', firstName: 'Jane', number: 'C-42' });
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/
return async (googleAuthId, id, patch = {}) => {
if (!Number.isFinite(id)) {
throw new Error('Entity not found');

View File

@@ -24,21 +24,32 @@ function optionalString(value) {
return s || null;
}
/**
* Creates a new address for an entity.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that creates an entity address.
*
* @example
* const fn = create(pool);
* const address = await fn(42, 5, { addressType: 'billing', street: 'Main St', city: 'Berlin' });
* // => { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }
*/
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
/**
* Creates a new address for an entity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @param {object} input - The address input.
* @param {string} input.addressType - The address type (e.g. `'billing'`).
* @param {string} [input.firstName] - The first name.
* @param {string} [input.name] - The name.
* @param {string} [input.street] - The street.
* @param {string} [input.streetNumber] - The street number.
* @param {string} [input.zip] - The zip code.
* @param {string} [input.city] - The city.
* @param {string} [input.county] - The county.
* @param {number} [input.sort] - Optional sort order.
* @returns {Promise<object>} The created address.
* @throws {Error} If the address type is missing.
*
* @example
* const address = await store.entityaddress.create(42, 5, { addressType: 'billing', street: 'Main St', city: 'Berlin' });
* // => { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }
*/
return async (googleAuthId, entityId, input = {}) => {
const addressType = input.addressType?.trim?.()
? input.addressType.trim()

View File

@@ -2,20 +2,22 @@ import { DELETE_BY_ENTITY_AND_ID } from '../../sql/entityaddress.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
/**
* Deletes an address of an entity.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes an entity address.
*
* @example
* const fn = del(pool);
* await fn(42, 5, 9);
*/
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
/**
* Deletes an address of an entity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @param {number} id - The id of the address to delete.
* @returns {Promise<void>}
* @throws {Error} If the address is not found.
*
* @example
* await store.entityaddress.delete(42, 5, 9);
*/
return async (googleAuthId, entityId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Address not found');

View File

@@ -18,21 +18,21 @@ function shape(row) {
};
}
/**
* Lists the addresses of an entity.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists entity addresses.
*
* @example
* const fn = list(pool);
* const addresses = await fn(42, 5);
* // => [{ id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }]
*/
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
/**
* Lists the addresses of an entity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @returns {Promise<Array<object>>} The addresses.
*
* @example
* const addresses = await store.entityaddress.list(42, 5);
* // => [{ id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }]
*/
return async (googleAuthId, entityId) => {
const { companyId } = await access(googleAuthId);
await entityAccess(companyId, entityId);

View File

@@ -24,21 +24,24 @@ function optionalString(value) {
return s || null;
}
/**
* Updates an address of an entity.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that updates an entity address.
*
* @example
* const fn = update(pool);
* const address = await fn(42, 5, 9, { city: 'Hamburg' });
* // => { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }
*/
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
/**
* Updates an address of an entity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @param {number} id - The id of the address to update.
* @param {object} patch - The update patch (any subset of address fields).
* @returns {Promise<object>} The updated address.
* @throws {Error} If the address is not found.
*
* @example
* const address = await store.entityaddress.update(42, 5, 9, { city: 'Hamburg' });
* // => { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }
*/
return async (googleAuthId, entityId, id, patch = {}) => {
if (!Number.isFinite(id)) {
throw new Error('Address not found');

View File

@@ -12,21 +12,26 @@ function shape(row) {
};
}
/**
* Creates a new contact for an entity.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that creates an entity contact.
*
* @example
* const fn = create(pool);
* const contact = await fn(42, 5, { contactType: 'email', contact: 'a@b.c' });
* // => { id, entityId, contactType, contact, sort }
*/
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
/**
* Creates a new contact for an entity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @param {object} input - The contact input.
* @param {string} input.contactType - The contact type (e.g. `'email'`).
* @param {string} input.contact - The contact value.
* @param {number} [input.sort] - Optional sort order.
* @returns {Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>} The created contact.
* @throws {Error} If the contact type or value is missing.
*
* @example
* const contact = await store.entitycontact.create(42, 5, { contactType: 'email', contact: 'a@b.c' });
* // => { id, entityId, contactType, contact, sort }
*/
return async (googleAuthId, entityId, input = {}) => {
const contactType = input.contactType?.trim?.()
? input.contactType.trim()

View File

@@ -2,20 +2,22 @@ import { DELETE_BY_ENTITY_AND_ID } from '../../sql/entitycontact.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
/**
* Deletes a contact of an entity.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes an entity contact.
*
* @example
* const fn = del(pool);
* await fn(42, 5, 9);
*/
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
/**
* Deletes a contact of an entity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @param {number} id - The id of the contact to delete.
* @returns {Promise<void>}
* @throws {Error} If the contact is not found.
*
* @example
* await store.entitycontact.delete(42, 5, 9);
*/
return async (googleAuthId, entityId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Contact not found');

View File

@@ -12,21 +12,21 @@ function shape(row) {
};
}
/**
* Lists the contacts of an entity.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists entity contacts.
*
* @example
* const fn = list(pool);
* const contacts = await fn(42, 5);
* // => [{ id, entityId, contactType, contact, sort }]
*/
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
/**
* Lists the contacts of an entity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @returns {Promise<Array<{id: number, entityId: number, contactType: string, contact: string, sort: number}>>} The contacts.
*
* @example
* const contacts = await store.entitycontact.list(42, 5);
* // => [{ id, entityId, contactType, contact, sort }]
*/
return async (googleAuthId, entityId) => {
const { companyId } = await access(googleAuthId);
await entityAccess(companyId, entityId);

View File

@@ -12,21 +12,24 @@ function shape(row) {
};
}
/**
* Updates a contact of an entity.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that updates an entity contact.
*
* @example
* const fn = update(pool);
* const contact = await fn(42, 5, 9, { contact: 'b@c.d' });
* // => { id, entityId, contactType, contact, sort }
*/
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
/**
* Updates a contact of an entity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @param {number} id - The id of the contact to update.
* @param {object} patch - The update patch (any subset of contact fields).
* @returns {Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>} The updated contact.
* @throws {Error} If the contact is not found.
*
* @example
* const contact = await store.entitycontact.update(42, 5, 9, { contact: 'b@c.d' });
* // => { id, entityId, contactType, contact, sort }
*/
return async (googleAuthId, entityId, id, patch = {}) => {
if (!Number.isFinite(id)) {
throw new Error('Contact not found');

View File

@@ -1,17 +1,16 @@
import { ADD } from '../../sql/google_auth.js';
/**
* Adds a new allowed Google auth email.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that adds an allowed email.
*
* @example
* const fn = add(pool);
* const id = await fn('user@example.com');
* // => 1
*/
export default function add(pool) {
/**
* Adds a new allowed Google auth email.
*
* @param {string} email - The email address to allow.
* @returns {Promise<number>} The id of the created Google auth row.
*
* @example
* const id = await store.google_auth.add('user@example.com');
* // => 1
*/
return async (email) => {
const result = await pool
.request()

View File

@@ -1,17 +1,16 @@
import { GET_BY_EMAIL } from '../../sql/google_auth.js';
/**
* Returns a Google auth row by email.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches a Google auth row by email.
*
* @example
* const fn = getByEmail(pool);
* const auth = await fn('user@example.com');
* // => { id, email, ... }
*/
export default function getByEmail(pool) {
/**
* Returns a Google auth row by email.
*
* @param {string} email - The email address.
* @returns {Promise<object|undefined>} The Google auth row, or `undefined` if not found.
*
* @example
* const auth = await store.google_auth.getByEmail('user@example.com');
* // => { id, email, ... }
*/
return async (email) => {
const result = await pool
.request()

View File

@@ -1,17 +1,16 @@
import { GET_BY_ID } from '../../sql/google_auth.js';
/**
* Returns a Google auth row by its id.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches a Google auth row by id.
*
* @example
* const fn = getById(pool);
* const auth = await fn(42);
* // => { id, email, ... }
*/
export default function getById(pool) {
/**
* Returns a Google auth row by its id.
*
* @param {number} googleAuthId - The id of the Google auth row.
* @returns {Promise<object|undefined>} The Google auth row, or `undefined` if not found.
*
* @example
* const auth = await store.google_auth.getById(42);
* // => { id, email, ... }
*/
return async (googleAuthId) => {
const result = await pool
.request()

View File

@@ -1,17 +1,15 @@
import { LIST_ALLOWED_EMAILS } from '../../sql/google_auth.js';
/**
* Lists all allowed Google auth emails.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists allowed emails.
*
* @example
* const fn = listAllowedEmails(pool);
* const emails = await fn();
* // => [{ id, email }]
*/
export default function listAllowedEmails(pool) {
/**
* Lists all allowed Google auth emails.
*
* @returns {Promise<Array<{id: number, email: string}>>} The allowed emails.
*
* @example
* const emails = await store.google_auth.listAllowedEmails();
* // => [{ id, email }]
*/
return async () => {
const result = await pool.request().query(LIST_ALLOWED_EMAILS);
return result.recordset;

View File

@@ -1,16 +1,15 @@
import { REMOVE_ALLOWED_EMAIL } from '../../sql/google_auth.js';
/**
* Removes an allowed Google auth email.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that removes an allowed email.
*
* @example
* const fn = removeAllowedEmail(pool);
* await fn('user@example.com');
*/
export default function removeAllowedEmail(pool) {
/**
* Removes an allowed Google auth email.
*
* @param {string} email - The email address to remove.
* @returns {Promise<void>}
*
* @example
* await store.google_auth.removeAllowedEmail('user@example.com');
*/
return async (email) => {
await pool
.request()

View File

@@ -1,16 +1,20 @@
import { SET_PROFILE } from '../../sql/google_auth.js';
/**
* Updates the profile (picture, picture binary, sub) of a Google auth user.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that sets the user profile.
*
* @example
* const fn = setProfile(pool);
* await fn(42, { picture: 'url', pictureBinary: buffer, sub: 'sub' });
*/
export default function setProfile(pool) {
/**
* Updates the profile (picture, picture binary, sub) of a Google auth user.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The profile input.
* @param {string} [input.picture] - The picture URL.
* @param {Buffer} [input.pictureBinary] - The picture binary.
* @param {string} [input.sub] - The Google sub identifier.
* @returns {Promise<void>}
* @throws {Error} If the user is not found or the picture binary is invalid.
*
* @example
* await store.google_auth.setProfile(42, { picture: 'url', pictureBinary: buffer, sub: 'sub' });
*/
return async (
googleAuthId,
{ picture = null, pictureBinary = null, sub = null } = {},

View File

@@ -1,16 +1,16 @@
import { SET_SELECTED_COMPANY } from '../../sql/google_auth.js';
/**
* Sets the currently selected company for a Google auth user.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that sets the selected company.
*
* @example
* const fn = setSelectedCompany(pool);
* await fn(42, 1);
*/
export default function setSelectedCompany(pool) {
/**
* Sets the currently selected company for a Google auth user.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} companyId - The id of the company to select.
* @returns {Promise<void>}
*
* @example
* await store.google_auth.setSelectedCompany(42, 1);
*/
return async (googleAuthId, companyId) => {
await pool
.request()

View File

@@ -1,16 +1,14 @@
import { SCHEMA_SCRIPTS } from '../schema.js';
/**
* Initializes the database schema by running all schema scripts.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that initializes the schema.
*
* @example
* const fn = initialize(pool);
* await fn();
*/
export default function initialize(pool) {
/**
* Initializes the database schema by running all schema scripts.
*
* @returns {Promise<void>}
*
* @example
* await store.initialize();
*/
return async () => {
for (const script of SCHEMA_SCRIPTS) {
await pool.request().query(script);

View File

@@ -1,424 +0,0 @@
import {
CREATE_LINE,
CREATE_MANIFEST,
DELETE_LINES_BY_MANIFEST,
DELETE_MANIFEST,
GET_MANIFEST_HEADER,
GET_MANIFEST_LINES,
LIST_MANIFESTS,
RESOLVE_ARTICLE_ID_BY_SKU,
UPDATE_MANIFEST,
} from '../../sql/manifestDoc.js';
import {
GET_CUSTOMER_BY_ID,
POS_GROUP_NAME,
SUPPLIER_GROUP_NAME,
} from '../../sql/entity.js';
import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
function shapeSummary(type, row) {
return {
id: row.id,
number: row.number,
type,
entityId: row.entityId ?? null,
date: row.date,
};
}
function shapeLine(row) {
return {
id: row.id,
articleId: row.articleId ?? null,
sku: row.sku ?? null,
amount: row.amount,
net: row.net,
rebate: row.rebate,
tax: row.tax,
comment: row.comment ?? null,
};
}
function parseDate(value) {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const d = new Date(value);
if (!Number.isNaN(d.getTime())) {
return d;
}
}
return null;
}
function normalizeLines(lines) {
if (!Array.isArray(lines)) {
throw new Error('Invalid lines');
}
return lines.map((entry) => {
const sku = entry?.sku?.trim?.() ? entry.sku.trim() : null;
if (!sku) {
throw new Error('Invalid lines');
}
const amount = entry?.amount;
const net = entry?.net;
const rebate =
entry?.rebate === undefined || entry?.rebate === null ? 0 : entry.rebate;
const tax = entry?.tax;
const comment =
entry?.comment === undefined || entry?.comment === null
? null
: String(entry.comment);
if (
!Number.isFinite(amount) ||
!Number.isFinite(net) ||
!Number.isFinite(rebate) ||
!Number.isFinite(tax)
) {
throw new Error('Invalid lines');
}
return { sku, amount, net, rebate, tax, comment };
});
}
async function assertCustomerEntity(pool, tx, companyId, entityId) {
if (entityId == null) {
return null;
}
if (!Number.isFinite(entityId)) {
throw new Error('Entity not found');
}
const result = await request(pool, tx)
.input('id', entityId)
.input('companyId', companyId)
.input('supplierGroupName', SUPPLIER_GROUP_NAME)
.input('posGroupName', POS_GROUP_NAME)
.query(GET_CUSTOMER_BY_ID);
if (!result.recordset[0]) {
throw new Error('Entity not found');
}
return entityId;
}
async function resolveArticleId(pool, tx, rootCategoryId, sku) {
const result = await request(pool, tx)
.input('rootCategoryId', rootCategoryId)
.input('sku', sku)
.query(RESOLVE_ARTICLE_ID_BY_SKU);
const articleId = result.recordset[0]?.id;
if (!articleId) {
throw new Error('Article not found');
}
return articleId;
}
async function insertLines(
pool,
tx,
{ manifestId, rootCategoryId, lines, ensureTax },
) {
for (const line of lines) {
const articleId = await resolveArticleId(
pool,
tx,
rootCategoryId,
line.sku,
);
const taxId = await ensureTax(line.tax, tx);
await request(pool, tx)
.input('manifestId', manifestId)
.input('articleId', articleId)
.input('amount', line.amount)
.input('net', line.net)
.input('comment', line.comment)
.input('rebate', line.rebate)
.input('taxId', taxId)
.query(CREATE_LINE);
}
}
async function loadDoc(pool, tx, { type, companyId, id }) {
const header = await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', type)
.query(GET_MANIFEST_HEADER);
const row = header.recordset[0];
if (!row) {
return null;
}
const lines = await request(pool, tx)
.input('manifestId', id)
.query(GET_MANIFEST_LINES);
return {
...shapeSummary(type, row),
lines: lines.recordset.map(shapeLine),
};
}
/**
* Factory for quote / order (and similar) stores on manifest / manifestarticle.
*
* @param {object} pool
* @param {{ type: string, label: string }} options
* `type` → manifest.type (`quote` / `order`)
* `label` → error prefix (`Quote` / `Order` → "Quote not found")
*/
export default function createManifestDocStore(pool, { type, label }) {
const access = resolveSelectedCompanyAccess(pool);
const ensureTax = taxEnsure(pool);
const notFound = `${label} not found`;
const invalid = `Invalid ${label.toLowerCase()}`;
/**
* Lists the quote/order documents of a company.
*
* @param {number} googleAuthId - The Google auth user id.
* @returns {Promise<Array<object>>} The document summaries.
*/
async function list(googleAuthId) {
const { companyId } = await access(googleAuthId);
const result = await pool
.request()
.input('companyId', companyId)
.input('type', type)
.query(LIST_MANIFESTS);
return result.recordset.map((row) => shapeSummary(type, row));
}
/**
* Returns a single quote/order document by its id within a company.
*
* @param {number} googleAuthId - The Google auth user id.
* @param {number} id - The document id.
* @returns {Promise<object|null>} The document with its lines, or null if not found.
*/
async function getById(googleAuthId, id) {
if (!Number.isFinite(id)) {
throw new Error(notFound);
}
const { companyId } = await access(googleAuthId);
const doc = await loadDoc(pool, null, { type, companyId, id });
if (!doc) {
throw new Error(notFound);
}
return doc;
}
/**
* Creates a new quote/order document with its lines.
*
* @param {number} googleAuthId - The Google auth user id.
* @param {object} body - The document payload (`number`, `date`, `entityId`, `lines`).
* @returns {Promise<object>} The created document with its lines.
*/
async function create(googleAuthId, body = {}) {
const number = body.number?.trim?.() ? body.number.trim() : null;
const date = parseDate(body.date);
if (!number || !date) {
throw new Error(invalid);
}
const lines = normalizeLines(body.lines ?? []);
const { companyId, rootCategoryId } = await access(googleAuthId);
const tx = pool.transaction();
await tx.begin();
try {
const entityId = await assertCustomerEntity(
pool,
tx,
companyId,
body.entityId ?? null,
);
const created = await request(pool, tx)
.input('companyId', companyId)
.input('number', number)
.input('type', type)
.input('entityId', entityId)
.input('date', date)
.query(CREATE_MANIFEST);
const manifestId = created.recordset[0].id;
await insertLines(pool, tx, {
manifestId,
rootCategoryId,
lines,
ensureTax,
});
const doc = await loadDoc(pool, tx, { type, companyId, id: manifestId });
await tx.commit();
return doc;
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
}
}
/**
* Updates a quote/order document's header and/or lines.
*
* @param {number} googleAuthId - The Google auth user id.
* @param {number} id - The document id.
* @param {object} patch - The fields to update (`number`, `date`, `entityId`, `lines`).
* @returns {Promise<object>} The updated document with its lines.
*/
async function update(googleAuthId, id, patch = {}) {
if (!Number.isFinite(id)) {
throw new Error(notFound);
}
const hasNumber = Object.prototype.hasOwnProperty.call(patch, 'number');
const hasDate = Object.prototype.hasOwnProperty.call(patch, 'date');
const hasEntityId = Object.prototype.hasOwnProperty.call(patch, 'entityId');
const hasLines = Object.prototype.hasOwnProperty.call(patch, 'lines');
if (!hasNumber && !hasDate && !hasEntityId && !hasLines) {
throw new Error(invalid);
}
let normalizedLines;
if (hasLines) {
normalizedLines = normalizeLines(patch.lines);
}
const { companyId, rootCategoryId } = await access(googleAuthId);
const tx = pool.transaction();
await tx.begin();
try {
const existing = await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', type)
.query(GET_MANIFEST_HEADER);
const current = existing.recordset[0];
if (!current) {
throw new Error(notFound);
}
const summary = shapeSummary(type, current);
let number = summary.number;
if (hasNumber) {
number = patch.number?.trim?.() ? patch.number.trim() : null;
if (!number) {
throw new Error(invalid);
}
}
let date = summary.date;
if (hasDate) {
date = parseDate(patch.date);
if (!date) {
throw new Error(invalid);
}
}
let entityId = summary.entityId;
if (hasEntityId) {
entityId = await assertCustomerEntity(
pool,
tx,
companyId,
patch.entityId ?? null,
);
}
await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', type)
.input('number', number)
.input('entityId', entityId)
.input('date', date)
.query(UPDATE_MANIFEST);
if (hasLines) {
await request(pool, tx)
.input('manifestId', id)
.query(DELETE_LINES_BY_MANIFEST);
await insertLines(pool, tx, {
manifestId: id,
rootCategoryId,
lines: normalizedLines,
ensureTax,
});
}
const doc = await loadDoc(pool, tx, { type, companyId, id });
await tx.commit();
return doc;
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
}
}
/**
* Deletes a quote/order document by its id within a company.
*
* @param {number} googleAuthId - The Google auth user id.
* @param {number} id - The document id.
* @returns {Promise<void>}
*/
async function del(googleAuthId, id) {
if (!Number.isFinite(id)) {
throw new Error(notFound);
}
const { companyId } = await access(googleAuthId);
const result = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.input('type', type)
.query(DELETE_MANIFEST);
if (!result.rowsAffected[0]) {
throw new Error(notFound);
}
}
return {
list,
getById,
create,
update,
delete: del,
};
}

View File

@@ -0,0 +1,64 @@
import {
GET_MANIFEST_HEADER,
GET_MANIFEST_LINES,
} from '../../sql/manifestDoc.js';
import {
GET_CUSTOMER_BY_ID,
POS_GROUP_NAME,
SUPPLIER_GROUP_NAME,
} from '../../sql/entity.js';
import request from '../request.js';
import { shapeLine } from '../manifestLines.js';
export function shapeSummary(type, row) {
return {
id: row.id,
number: row.number,
type,
entityId: row.entityId ?? null,
date: row.date,
};
}
export async function assertCustomerEntity(pool, tx, companyId, entityId) {
if (entityId == null) {
return null;
}
if (!Number.isFinite(entityId)) {
throw new Error('Entity not found');
}
const result = await request(pool, tx)
.input('id', entityId)
.input('companyId', companyId)
.input('supplierGroupName', SUPPLIER_GROUP_NAME)
.input('posGroupName', POS_GROUP_NAME)
.query(GET_CUSTOMER_BY_ID);
if (!result.recordset[0]) {
throw new Error('Entity not found');
}
return entityId;
}
export async function loadDoc(pool, tx, { type, companyId, id }) {
const header = await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', type)
.query(GET_MANIFEST_HEADER);
const row = header.recordset[0];
if (!row) {
return null;
}
const lines = await request(pool, tx)
.input('manifestId', id)
.query(GET_MANIFEST_LINES);
return {
...shapeSummary(type, row),
lines: lines.recordset.map(shapeLine),
};
}

133
src/store/manifestLines.js Normal file
View File

@@ -0,0 +1,133 @@
import { CREATE_LINE, RESOLVE_ARTICLE_ID_BY_SKU } from '../sql/manifestDoc.js';
import request from './request.js';
/**
* Normalizes and validates an array of manifest line inputs.
*
* @param {Array<object>} lines - The raw line inputs.
* @returns {Array<{sku: string, amount: number, net: number, rebate: number, tax: number, comment: string|null}>} The normalized lines.
* @throws {Error} If the lines are invalid.
*/
export function normalizeLines(lines) {
if (!Array.isArray(lines)) {
throw new Error('Invalid lines');
}
return lines.map((entry) => {
const sku = entry?.sku?.trim?.() ? entry.sku.trim() : null;
if (!sku) {
throw new Error('Invalid lines');
}
const amount = entry?.amount;
const net = entry?.net;
const rebate =
entry?.rebate === undefined || entry?.rebate === null ? 0 : entry.rebate;
const tax = entry?.tax;
const comment =
entry?.comment === undefined || entry?.comment === null
? null
: String(entry.comment);
if (
!Number.isFinite(amount) ||
!Number.isFinite(net) ||
!Number.isFinite(rebate) ||
!Number.isFinite(tax)
) {
throw new Error('Invalid lines');
}
return { sku, amount, net, rebate, tax, comment };
});
}
/**
* Parses a date value (Date instance or ISO string) into a Date.
*
* @param {Date|string} value - The date value.
* @returns {Date|null} The parsed date, or null if invalid.
*/
export function parseDate(value) {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const d = new Date(value);
if (!Number.isNaN(d.getTime())) {
return d;
}
}
return null;
}
/**
* Shapes a manifestarticle row into a line object.
*
* @param {object} row - The manifestarticle row.
* @returns {object} The shaped line.
*/
export function shapeLine(row) {
return {
id: row.id,
articleId: row.articleId ?? null,
sku: row.sku ?? null,
amount: row.amount,
net: row.net,
rebate: row.rebate,
tax: row.tax,
comment: row.comment ?? null,
};
}
async function resolveArticleId(pool, tx, rootCategoryId, sku) {
const result = await request(pool, tx)
.input('rootCategoryId', rootCategoryId)
.input('sku', sku)
.query(RESOLVE_ARTICLE_ID_BY_SKU);
const articleId = result.recordset[0]?.id;
if (!articleId) {
throw new Error('Article not found');
}
return articleId;
}
/**
* Inserts manifest lines for a document, resolving each SKU to an article id
* and ensuring the tax rate.
*
* @param {object} pool - The mssql connection pool.
* @param {object} tx - The transaction.
* @param {object} params - The insert parameters.
* @param {number} params.manifestId - The manifest id.
* @param {number} params.rootCategoryId - The company root category id.
* @param {Array<object>} params.lines - The normalized lines.
* @param {Function} params.ensureTax - The tax ensure function.
* @returns {Promise<void>}
*/
export async function insertLines(
pool,
tx,
{ manifestId, rootCategoryId, lines, ensureTax },
) {
for (const line of lines) {
const articleId = await resolveArticleId(
pool,
tx,
rootCategoryId,
line.sku,
);
const taxId = await ensureTax(line.tax, tx);
await request(pool, tx)
.input('manifestId', manifestId)
.input('articleId', articleId)
.input('amount', line.amount)
.input('net', line.net)
.input('comment', line.comment)
.input('rebate', line.rebate)
.input('taxId', taxId)
.query(CREATE_LINE);
}
}

View File

@@ -2,6 +2,18 @@ import { ENSURE_TYPE } from '../../sql/numbers.js';
import request from '../request.js';
export default function nextNumber(pool) {
/**
* Allocates and returns the next number for a company and type, consuming it.
*
* @param {number} companyId - The id of the company.
* @param {string} type - The number type (e.g. `'entity'`, `'sku'`).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<string>} The allocated number.
*
* @example
* const number = await store.numbers.next(1, 'entity');
* // => '1'
*/
return async (companyId, type, tx) => {
await request(pool, tx)
.input('companyId', companyId)

View File

@@ -2,5 +2,16 @@ import nextNumber from './nextNumber.js';
export default function nextSku(pool) {
const allocate = nextNumber(pool);
/**
* Allocates and returns the next SKU for a company, consuming it.
*
* @param {number} companyId - The id of the company.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<string>} The allocated SKU.
*
* @example
* const sku = await store.numbers.nextSku(1);
* // => '1'
*/
return (companyId, tx) => allocate(companyId, 'sku', tx);
}

View File

@@ -2,6 +2,18 @@ import { ENSURE_TYPE } from '../../sql/numbers.js';
import request from '../request.js';
export default function peekNumber(pool) {
/**
* Returns the next number for a company and type without consuming it.
*
* @param {number} companyId - The id of the company.
* @param {string} type - The number type (e.g. `'entity'`, `'sku'`).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<string>} The next number.
*
* @example
* const number = await store.numbers.peek(1, 'entity');
* // => '1'
*/
return async (companyId, type, tx) => {
await request(pool, tx)
.input('companyId', companyId)

82
src/store/order/create.js Normal file
View File

@@ -0,0 +1,82 @@
import { CREATE_MANIFEST, ORDER_TYPE } from '../../sql/manifestDoc.js';
import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js';
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
const ensureTax = taxEnsure(pool);
/**
* Creates a new order for the user's selected company with its lines.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} body - The order input.
* @param {string} body.number - The order number.
* @param {string} body.date - The order date.
* @param {number} [body.entityId] - The id of the customer entity.
* @param {Array<object>} [body.lines] - The order lines.
* @returns {Promise<{id: number, number: string, type: string, entityId: number|null, date: string, lines: Array<object>}>} The created order.
* @throws {Error} If the order is invalid.
*
* @example
* const order = await store.order.create(42, { number: 'B-2026-001', date: '2026-09-15', entityId: 12, lines: [...] });
* // => { id, number, type, entityId, date, lines: [...] }
*/
return async (googleAuthId, body = {}) => {
const number = body.number?.trim?.() ? body.number.trim() : null;
const date = parseDate(body.date);
if (!number || !date) {
throw new Error('Invalid order');
}
const lines = normalizeLines(body.lines ?? []);
const { companyId, rootCategoryId } = await access(googleAuthId);
const tx = pool.transaction();
await tx.begin();
try {
const entityId = await assertCustomerEntity(
pool,
tx,
companyId,
body.entityId ?? null,
);
const created = await request(pool, tx)
.input('companyId', companyId)
.input('number', number)
.input('type', ORDER_TYPE)
.input('entityId', entityId)
.input('date', date)
.query(CREATE_MANIFEST);
const manifestId = created.recordset[0].id;
await insertLines(pool, tx, {
manifestId,
rootCategoryId,
lines,
ensureTax,
});
const doc = await loadDoc(pool, tx, {
type: ORDER_TYPE,
companyId,
id: manifestId,
});
await tx.commit();
return doc;
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
}
};
}

36
src/store/order/delete.js Normal file
View File

@@ -0,0 +1,36 @@
import { DELETE_MANIFEST, ORDER_TYPE } from '../../sql/manifestDoc.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Deletes an order by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the order to delete.
* @returns {Promise<void>}
* @throws {Error} If the order is not found.
*
* @example
* await store.order.delete(42, 5);
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Order not found');
}
const { companyId } = await access(googleAuthId);
const result = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.input('type', ORDER_TYPE)
.query(DELETE_MANIFEST);
if (!result.rowsAffected[0]) {
throw new Error('Order not found');
}
};
}

View File

@@ -0,0 +1,33 @@
import { ORDER_TYPE } from '../../sql/manifestDoc.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { loadDoc } from '../manifestDoc/helpers.js';
export default function getById(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Returns a single order by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the order.
* @returns {Promise<{id: number, number: string, type: string, entityId: number|null, date: string, lines: Array<object>}>} The order with its lines.
* @throws {Error} If the order is not found.
*
* @example
* const order = await store.order.getById(42, 5);
* // => { id, number, type, entityId, date, lines: [...] }
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Order not found');
}
const { companyId } = await access(googleAuthId);
const doc = await loadDoc(pool, null, { type: ORDER_TYPE, companyId, id });
if (!doc) {
throw new Error('Order not found');
}
return doc;
};
}

View File

@@ -1,18 +0,0 @@
import { ORDER_TYPE } from '../../sql/manifestDoc.js';
import createManifestDocStore from '../manifestDoc/createStore.js';
/**
* Creates an order store (list, getById, create, update, delete) backed by the
* manifest / manifestarticle tables.
*
* @param {object} pool - The mssql connection pool.
* @returns {object} An object with `list`, `getById`, `create`, `update` and `delete` functions.
*
* @example
* const store = createOrderStore(pool);
* const orders = await store.list(42);
* // => [{ id, number, type, entityId, date }]
*/
export default function createOrderStore(pool) {
return createManifestDocStore(pool, { type: ORDER_TYPE, label: 'Order' });
}

29
src/store/order/list.js Normal file
View File

@@ -0,0 +1,29 @@
import { LIST_MANIFESTS, ORDER_TYPE } from '../../sql/manifestDoc.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { shapeSummary } from '../manifestDoc/helpers.js';
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Lists the orders of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<Array<{id: number, number: string, type: string, entityId: number|null, date: string}>>} The orders.
*
* @example
* const orders = await store.order.list(42);
* // => [{ id, number, type, entityId, date }]
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);
const result = await pool
.request()
.input('companyId', companyId)
.input('type', ORDER_TYPE)
.query(LIST_MANIFESTS);
return result.recordset.map((row) => shapeSummary(ORDER_TYPE, row));
};
}

132
src/store/order/update.js Normal file
View File

@@ -0,0 +1,132 @@
import {
DELETE_LINES_BY_MANIFEST,
GET_MANIFEST_HEADER,
ORDER_TYPE,
UPDATE_MANIFEST,
} from '../../sql/manifestDoc.js';
import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { assertCustomerEntity, loadDoc, shapeSummary } from '../manifestDoc/helpers.js';
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
const ensureTax = taxEnsure(pool);
/**
* Updates an order's header and/or lines.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the order to update.
* @param {object} patch - The update patch.
* @param {string} [patch.number] - The new order number.
* @param {string} [patch.date] - The new order date.
* @param {number} [patch.entityId] - The new customer entity id.
* @param {Array<object>} [patch.lines] - The new order lines.
* @returns {Promise<{id: number, number: string, type: string, entityId: number|null, date: string, lines: Array<object>}>} The updated order.
* @throws {Error} If the order is not found.
*
* @example
* const order = await store.order.update(42, 5, { number: 'B-2026-002', lines: [...] });
* // => { id, number, type, entityId, date, lines: [...] }
*/
return async (googleAuthId, id, patch = {}) => {
if (!Number.isFinite(id)) {
throw new Error('Order not found');
}
const hasNumber = Object.prototype.hasOwnProperty.call(patch, 'number');
const hasDate = Object.prototype.hasOwnProperty.call(patch, 'date');
const hasEntityId = Object.prototype.hasOwnProperty.call(patch, 'entityId');
const hasLines = Object.prototype.hasOwnProperty.call(patch, 'lines');
if (!hasNumber && !hasDate && !hasEntityId && !hasLines) {
throw new Error('Invalid order');
}
let normalizedLines;
if (hasLines) {
normalizedLines = normalizeLines(patch.lines);
}
const { companyId, rootCategoryId } = await access(googleAuthId);
const tx = pool.transaction();
await tx.begin();
try {
const existing = await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', ORDER_TYPE)
.query(GET_MANIFEST_HEADER);
const current = existing.recordset[0];
if (!current) {
throw new Error('Order not found');
}
const summary = shapeSummary(ORDER_TYPE, current);
let number = summary.number;
if (hasNumber) {
number = patch.number?.trim?.() ? patch.number.trim() : null;
if (!number) {
throw new Error('Invalid order');
}
}
let date = summary.date;
if (hasDate) {
date = parseDate(patch.date);
if (!date) {
throw new Error('Invalid order');
}
}
let entityId = summary.entityId;
if (hasEntityId) {
entityId = await assertCustomerEntity(
pool,
tx,
companyId,
patch.entityId ?? null,
);
}
await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', ORDER_TYPE)
.input('number', number)
.input('entityId', entityId)
.input('date', date)
.query(UPDATE_MANIFEST);
if (hasLines) {
await request(pool, tx)
.input('manifestId', id)
.query(DELETE_LINES_BY_MANIFEST);
await insertLines(pool, tx, {
manifestId: id,
rootCategoryId,
lines: normalizedLines,
ensureTax,
});
}
const doc = await loadDoc(pool, tx, { type: ORDER_TYPE, companyId, id });
await tx.commit();
return doc;
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
}
};
}

View File

@@ -1,20 +1,21 @@
import { GET_FULL_BY_HASH } from '../../sql/pic.js';
import resolvePicAccess from './resolvePicAccess.js';
/**
* Returns the full-size image (AVIF) for a given hash.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches a full image by hash.
*
* @example
* const fn = getImageByHash(pool);
* const image = await fn(42, 'abc123');
* // => { buffer, w, h, size, contentType: 'image/avif' }
*/
export default function getImageByHash(pool) {
const authorize = resolvePicAccess(pool);
/**
* Returns the full-size image (AVIF) for a given hash.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} fullHash - The full image hash.
* @returns {Promise<{buffer: Buffer, w: number, h: number, size: number, contentType: string}>} The full image.
* @throws {Error} If the image is not found.
*
* @example
* const image = await store.pic.getImageByHash(42, 'abc123');
* // => { buffer, w, h, size, contentType: 'image/avif' }
*/
return async (googleAuthId, fullHash) => {
await authorize(googleAuthId, fullHash);

View File

@@ -1,20 +1,21 @@
import { GET_THUMB_BY_HASH } from '../../sql/pic.js';
import resolvePicAccess from './resolvePicAccess.js';
/**
* Returns the thumbnail image (AVIF) for a given hash.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that fetches a thumbnail by hash.
*
* @example
* const fn = getThumbByHash(pool);
* const thumb = await fn(42, 'abc123');
* // => { buffer, w, h, contentType: 'image/avif' }
*/
export default function getThumbByHash(pool) {
const authorize = resolvePicAccess(pool);
/**
* Returns the thumbnail image (AVIF) for a given hash.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} fullHash - The full image hash.
* @returns {Promise<{buffer: Buffer, w: number, h: number, contentType: string}>} The thumbnail image.
* @throws {Error} If the image is not found.
*
* @example
* const thumb = await store.pic.getThumbByHash(42, 'abc123');
* // => { buffer, w, h, contentType: 'image/avif' }
*/
return async (googleAuthId, fullHash) => {
await authorize(googleAuthId, fullHash);

View File

@@ -1,19 +1,20 @@
import { DELETE_BY_COMPANY_AND_ID } from '../../sql/posterminal.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Deletes a posterminal by its id within a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes a posterminal.
*
* @example
* const fn = del(pool);
* await fn(42, 5);
*/
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Deletes a posterminal by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the posterminal to delete.
* @returns {Promise<void>}
* @throws {Error} If the posterminal is not found.
*
* @example
* await store.posterminal.delete(42, 5);
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Posterminal not found');

View File

@@ -1,18 +1,17 @@
import { POSTERMINAL_IDS_BY_COMPANY } from '../../sql/posterminal.js';
/**
* Returns a map of category id to the posterminal ids assigned to it for a
* company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that builds the category→posterminal map.
*
* @example
* const fn = getCategoryPosterminalIds(pool);
* const map = await fn(1);
* // => { 5: [1, 2], 9: [1] }
*/
export default function getCategoryPosterminalIds(pool) {
/**
* Returns a map of category id to the posterminal ids assigned to it for a
* company.
*
* @param {number} companyId - The id of the company.
* @returns {Promise<Record<number, Array<number>>>} A map of category id to posterminal ids.
*
* @example
* const map = await getCategoryPosterminalIds(pool)(1);
* // => { 5: [1, 2], 9: [1] }
*/
return async (companyId) => {
const result = await pool
.request()

View File

@@ -1,20 +1,19 @@
import { LIST_BY_COMPANY } from '../../sql/posterminal.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Lists the posterminals of a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists posterminals.
*
* @example
* const fn = list(pool);
* const terminals = await fn(42);
* // => [{ id, name, token, pairingpin }]
*/
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Lists the posterminals of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<Array<{id: number, name: string, token: string, pairingpin: string}>>} The posterminals.
*
* @example
* const terminals = await store.posterminal.list(42);
* // => [{ id, name, token, pairingpin }]
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);

View File

@@ -1,20 +1,22 @@
import { GET_BY_ID, SET_OFF, LIST_CATEGORY_IDS } from '../../sql/posterminal.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Removes a category assignment from a posterminal (turns it off).
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that removes a category from a posterminal.
*
* @example
* const fn = setOff(pool);
* const result = await fn(42, 5, 9);
* // => { id, categoryIds: [...] }
*/
export default function setOff(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Removes a category assignment from a posterminal (turns it off).
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} posterminalId - The id of the posterminal.
* @param {number} categoryId - The id of the category to remove.
* @returns {Promise<{id: number, categoryIds: Array<number>}>} The posterminal with its remaining category ids.
* @throws {Error} If the posterminal is not found.
*
* @example
* const result = await store.posterminal.setOff(42, 5, 9);
* // => { id, categoryIds: [...] }
*/
return async (googleAuthId, posterminalId, categoryId) => {
if (!Number.isFinite(posterminalId) || !Number.isFinite(categoryId)) {
throw new Error('Invalid posterminal or category');

View File

@@ -1,20 +1,21 @@
import { GET_BY_ID, SET_OFF_ALL, LIST_CATEGORY_IDS } from '../../sql/posterminal.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Removes all category assignments from a posterminal.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that clears all categories from a posterminal.
*
* @example
* const fn = setOffAll(pool);
* const result = await fn(42, 5);
* // => { id, categoryIds: [] }
*/
export default function setOffAll(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Removes all category assignments from a posterminal.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} posterminalId - The id of the posterminal.
* @returns {Promise<{id: number, categoryIds: Array<number>}>} The posterminal with an empty category list.
* @throws {Error} If the posterminal is not found.
*
* @example
* const result = await store.posterminal.setOffAll(42, 5);
* // => { id, categoryIds: [] }
*/
return async (googleAuthId, posterminalId) => {
if (!Number.isFinite(posterminalId)) {
throw new Error('Invalid posterminal');

View File

@@ -1,20 +1,22 @@
import { GET_BY_ID, SET_ON, LIST_CATEGORY_IDS } from '../../sql/posterminal.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Assigns a category to a posterminal (turns it on for that category).
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that assigns a category to a posterminal.
*
* @example
* const fn = setOn(pool);
* const result = await fn(42, 5, 9);
* // => { id, categoryIds: [9, ...] }
*/
export default function setOn(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Assigns a category to a posterminal (turns it on for that category).
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} posterminalId - The id of the posterminal.
* @param {number} categoryId - The id of the category to assign.
* @returns {Promise<{id: number, categoryIds: Array<number>}>} The posterminal with its category ids.
* @throws {Error} If the posterminal is not found.
*
* @example
* const result = await store.posterminal.setOn(42, 5, 9);
* // => { id, categoryIds: [9, ...] }
*/
return async (googleAuthId, posterminalId, categoryId) => {
if (!Number.isFinite(posterminalId) || !Number.isFinite(categoryId)) {
throw new Error('Invalid posterminal or category');

View File

@@ -2,20 +2,21 @@ import { GET_BY_ID, SET_ON_ALL, LIST_CATEGORY_IDS } from '../../sql/posterminal.
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { GET_COMPANY_ROOT_CATEGORY } from '../../sql/company.js';
/**
* Assigns all categories of a company to a posterminal.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that assigns all categories to a posterminal.
*
* @example
* const fn = setOnAll(pool);
* const result = await fn(42, 5);
* // => { id, categoryIds: [...] }
*/
export default function setOnAll(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Assigns all categories of the user's selected company to a posterminal.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} posterminalId - The id of the posterminal.
* @returns {Promise<{id: number, categoryIds: Array<number>}>} The posterminal with all category ids.
* @throws {Error} If the posterminal is not found.
*
* @example
* const result = await store.posterminal.setOnAll(42, 5);
* // => { id, categoryIds: [...] }
*/
return async (googleAuthId, posterminalId) => {
if (!Number.isFinite(posterminalId)) {
throw new Error('Invalid posterminal');

View File

@@ -1,20 +1,25 @@
import { GET_BY_ID, CREATE, UPDATE } from '../../sql/posterminal.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Creates or updates a posterminal for a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that upserts a posterminal.
*
* @example
* const fn = upsert(pool);
* const terminal = await fn(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' });
* // => { id, name, token, pairingpin }
*/
export default function upsert(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Creates or updates a posterminal for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The posterminal input.
* @param {string} input.name - The posterminal name.
* @param {string} input.pairingpin - The pairing pin.
* @param {string} [input.token] - Optional token.
* @param {number} [input.id] - Optional id to update an existing posterminal.
* @returns {Promise<{id: number, name: string, token: string, pairingpin: string}>} The upserted posterminal.
* @throws {Error} If the posterminal is invalid.
*
* @example
* const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' });
* // => { id, name, token, pairingpin }
*/
return async (googleAuthId, input = {}) => {
const name = input.name?.trim();
if (!name) {

View File

@@ -1,16 +1,14 @@
import { buildPurgeSql } from '../sql/purge.js';
/**
* Purges (wipes) all data from the database.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that purges the database.
*
* @example
* const fn = purge(pool);
* await fn();
*/
export default function purge(pool) {
/**
* Purges (wipes) all data from the database.
*
* @returns {Promise<void>}
*
* @example
* await store.purge();
*/
return async () => {
await pool.request().query(buildPurgeSql());
};

82
src/store/quote/create.js Normal file
View File

@@ -0,0 +1,82 @@
import { CREATE_MANIFEST, QUOTE_TYPE } from '../../sql/manifestDoc.js';
import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js';
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
const ensureTax = taxEnsure(pool);
/**
* Creates a new quote for the user's selected company with its lines.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} body - The quote input.
* @param {string} body.number - The quote number.
* @param {string} body.date - The quote date.
* @param {number} [body.entityId] - The id of the customer entity.
* @param {Array<object>} [body.lines] - The quote lines.
* @returns {Promise<{id: number, number: string, type: string, entityId: number|null, date: string, lines: Array<object>}>} The created quote.
* @throws {Error} If the quote is invalid.
*
* @example
* const quote = await store.quote.create(42, { number: 'A-2026-001', date: '2026-09-01', entityId: 12, lines: [...] });
* // => { id, number, type, entityId, date, lines: [...] }
*/
return async (googleAuthId, body = {}) => {
const number = body.number?.trim?.() ? body.number.trim() : null;
const date = parseDate(body.date);
if (!number || !date) {
throw new Error('Invalid quote');
}
const lines = normalizeLines(body.lines ?? []);
const { companyId, rootCategoryId } = await access(googleAuthId);
const tx = pool.transaction();
await tx.begin();
try {
const entityId = await assertCustomerEntity(
pool,
tx,
companyId,
body.entityId ?? null,
);
const created = await request(pool, tx)
.input('companyId', companyId)
.input('number', number)
.input('type', QUOTE_TYPE)
.input('entityId', entityId)
.input('date', date)
.query(CREATE_MANIFEST);
const manifestId = created.recordset[0].id;
await insertLines(pool, tx, {
manifestId,
rootCategoryId,
lines,
ensureTax,
});
const doc = await loadDoc(pool, tx, {
type: QUOTE_TYPE,
companyId,
id: manifestId,
});
await tx.commit();
return doc;
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
}
};
}

36
src/store/quote/delete.js Normal file
View File

@@ -0,0 +1,36 @@
import { DELETE_MANIFEST, QUOTE_TYPE } from '../../sql/manifestDoc.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Deletes a quote by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the quote to delete.
* @returns {Promise<void>}
* @throws {Error} If the quote is not found.
*
* @example
* await store.quote.delete(42, 5);
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Quote not found');
}
const { companyId } = await access(googleAuthId);
const result = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.input('type', QUOTE_TYPE)
.query(DELETE_MANIFEST);
if (!result.rowsAffected[0]) {
throw new Error('Quote not found');
}
};
}

View File

@@ -0,0 +1,33 @@
import { QUOTE_TYPE } from '../../sql/manifestDoc.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { loadDoc } from '../manifestDoc/helpers.js';
export default function getById(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Returns a single quote by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the quote.
* @returns {Promise<{id: number, number: string, type: string, entityId: number|null, date: string, lines: Array<object>}>} The quote with its lines.
* @throws {Error} If the quote is not found.
*
* @example
* const quote = await store.quote.getById(42, 5);
* // => { id, number, type, entityId, date, lines: [...] }
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Quote not found');
}
const { companyId } = await access(googleAuthId);
const doc = await loadDoc(pool, null, { type: QUOTE_TYPE, companyId, id });
if (!doc) {
throw new Error('Quote not found');
}
return doc;
};
}

View File

@@ -1,18 +0,0 @@
import { QUOTE_TYPE } from '../../sql/manifestDoc.js';
import createManifestDocStore from '../manifestDoc/createStore.js';
/**
* Creates a quote store (list, getById, create, update, delete) backed by the
* manifest / manifestarticle tables.
*
* @param {object} pool - The mssql connection pool.
* @returns {object} An object with `list`, `getById`, `create`, `update` and `delete` functions.
*
* @example
* const store = createQuoteStore(pool);
* const quotes = await store.list(42);
* // => [{ id, number, type, entityId, date }]
*/
export default function createQuoteStore(pool) {
return createManifestDocStore(pool, { type: QUOTE_TYPE, label: 'Quote' });
}

29
src/store/quote/list.js Normal file
View File

@@ -0,0 +1,29 @@
import { LIST_MANIFESTS, QUOTE_TYPE } from '../../sql/manifestDoc.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { shapeSummary } from '../manifestDoc/helpers.js';
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Lists the quotes of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<Array<{id: number, number: string, type: string, entityId: number|null, date: string}>>} The quotes.
*
* @example
* const quotes = await store.quote.list(42);
* // => [{ id, number, type, entityId, date }]
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);
const result = await pool
.request()
.input('companyId', companyId)
.input('type', QUOTE_TYPE)
.query(LIST_MANIFESTS);
return result.recordset.map((row) => shapeSummary(QUOTE_TYPE, row));
};
}

132
src/store/quote/update.js Normal file
View File

@@ -0,0 +1,132 @@
import {
DELETE_LINES_BY_MANIFEST,
GET_MANIFEST_HEADER,
QUOTE_TYPE,
UPDATE_MANIFEST,
} from '../../sql/manifestDoc.js';
import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { assertCustomerEntity, loadDoc, shapeSummary } from '../manifestDoc/helpers.js';
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
const ensureTax = taxEnsure(pool);
/**
* Updates a quote's header and/or lines.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the quote to update.
* @param {object} patch - The update patch.
* @param {string} [patch.number] - The new quote number.
* @param {string} [patch.date] - The new quote date.
* @param {number} [patch.entityId] - The new customer entity id.
* @param {Array<object>} [patch.lines] - The new quote lines.
* @returns {Promise<{id: number, number: string, type: string, entityId: number|null, date: string, lines: Array<object>}>} The updated quote.
* @throws {Error} If the quote is not found.
*
* @example
* const quote = await store.quote.update(42, 5, { number: 'A-2026-002', lines: [...] });
* // => { id, number, type, entityId, date, lines: [...] }
*/
return async (googleAuthId, id, patch = {}) => {
if (!Number.isFinite(id)) {
throw new Error('Quote not found');
}
const hasNumber = Object.prototype.hasOwnProperty.call(patch, 'number');
const hasDate = Object.prototype.hasOwnProperty.call(patch, 'date');
const hasEntityId = Object.prototype.hasOwnProperty.call(patch, 'entityId');
const hasLines = Object.prototype.hasOwnProperty.call(patch, 'lines');
if (!hasNumber && !hasDate && !hasEntityId && !hasLines) {
throw new Error('Invalid quote');
}
let normalizedLines;
if (hasLines) {
normalizedLines = normalizeLines(patch.lines);
}
const { companyId, rootCategoryId } = await access(googleAuthId);
const tx = pool.transaction();
await tx.begin();
try {
const existing = await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', QUOTE_TYPE)
.query(GET_MANIFEST_HEADER);
const current = existing.recordset[0];
if (!current) {
throw new Error('Quote not found');
}
const summary = shapeSummary(QUOTE_TYPE, current);
let number = summary.number;
if (hasNumber) {
number = patch.number?.trim?.() ? patch.number.trim() : null;
if (!number) {
throw new Error('Invalid quote');
}
}
let date = summary.date;
if (hasDate) {
date = parseDate(patch.date);
if (!date) {
throw new Error('Invalid quote');
}
}
let entityId = summary.entityId;
if (hasEntityId) {
entityId = await assertCustomerEntity(
pool,
tx,
companyId,
patch.entityId ?? null,
);
}
await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', QUOTE_TYPE)
.input('number', number)
.input('entityId', entityId)
.input('date', date)
.query(UPDATE_MANIFEST);
if (hasLines) {
await request(pool, tx)
.input('manifestId', id)
.query(DELETE_LINES_BY_MANIFEST);
await insertLines(pool, tx, {
manifestId: id,
rootCategoryId,
lines: normalizedLines,
ensureTax,
});
}
const doc = await loadDoc(pool, tx, { type: QUOTE_TYPE, companyId, id });
await tx.commit();
return doc;
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
}
};
}

View File

@@ -4,20 +4,22 @@ import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import ensureLieferantenGroup from './ensureLieferantenGroup.js';
/**
* Creates a new supplier for a company, recording history.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that creates a supplier.
*
* @example
* const fn = create(pool);
* const supplier = await fn(42, { name: 'Acme Supplies' });
* // => { id, name, createdAt, updatedAt }
*/
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Creates a new supplier for the user's selected company, recording history.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The supplier input.
* @param {string} input.name - The supplier name.
* @returns {Promise<{id: number, name: string, createdAt: string, updatedAt: string}>} The created supplier.
* @throws {Error} If the name is empty.
*
* @example
* const supplier = await store.supplier.create(42, { name: 'Acme Supplies' });
* // => { id, name, createdAt, updatedAt }
*/
return async (googleAuthId, { name } = {}) => {
const cleaned = name?.trim();
if (!cleaned) {

View File

@@ -1,19 +1,20 @@
import { DELETE_BY_ID, SUPPLIER_GROUP_NAME } from '../../sql/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Deletes a supplier by its id within a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that deletes a supplier.
*
* @example
* const fn = del(pool);
* await fn(42, 5);
*/
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Deletes a supplier by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the supplier to delete.
* @returns {Promise<void>}
* @throws {Error} If the supplier is not found.
*
* @example
* await store.supplier.delete(42, 5);
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Supplier not found');

View File

@@ -1,20 +1,19 @@
import { LIST_SUPPLIERS_BY_COMPANY, SUPPLIER_GROUP_NAME } from '../../sql/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
/**
* Lists the suppliers of a company.
*
* @param {object} pool - The mssql connection pool.
* @returns {Function} An async function that lists suppliers.
*
* @example
* const fn = list(pool);
* const suppliers = await fn(42);
* // => [{ id, name, createdAt, updatedAt }]
*/
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
/**
* Lists the suppliers of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<Array<{id: number, name: string, createdAt: string, updatedAt: string}>>} The suppliers.
*
* @example
* const suppliers = await store.supplier.list(42);
* // => [{ id, name, createdAt, updatedAt }]
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);

Some files were not shown because too many files have changed in this diff Show More