2026-10-31 21:02:50 +01:00
u
2026-10-23 04:59:04 +02:00
u
2026-10-31 20:28:39 +01:00
u
2026-10-31 21:02:50 +01:00
u
2026-10-30 06:43:49 +01:00
2026-09-20 04:09:22 +02:00
u
2026-10-01 21:28:27 +02:00
u
2026-10-09 10:41:17 +02:00
u
2026-10-31 20:28:39 +01:00
u
2026-10-23 22:22:32 +02:00
u
2026-09-29 16:16:05 +02:00
u
2026-10-18 14:39:03 +02:00
u
2026-09-29 20:10:49 +02:00
u
2026-10-04 19:38:12 +02:00
u
2026-10-07 16:46:41 +02:00
u
2026-10-17 23:32:42 +02:00
u
2026-10-11 18:55:08 +02:00
u
2026-10-11 18:55:08 +02:00
u
2026-10-11 03:30:26 +02:00
u
2026-10-17 05:10:28 +02:00
u
2026-10-24 06:19:37 +02:00
u
2026-10-01 17:40:04 +02:00
u
2026-10-01 17:40:04 +02:00
u
2026-10-19 23:56:56 +02:00
u
2026-09-27 21:01:30 +02:00
u
2026-09-24 23:26:25 +02:00
u
2026-09-24 23:26:25 +02:00
u
2026-10-16 22:09:12 +02:00
u
2026-10-17 23:32:42 +02:00
u
2026-09-24 23:26:25 +02:00
u
2026-09-24 23:26:25 +02:00
u
2026-10-18 20:48:47 +02:00
u
2026-09-28 06:50:10 +02:00
u
2026-10-18 03:15:20 +02:00
u
2026-09-24 23:26:25 +02:00
u
2026-10-13 00:36:36 +02:00
u
2026-10-31 21:02:50 +01:00
u
2026-10-12 18:09:54 +02:00
u
2026-10-05 23:57:01 +02:00
u
2026-10-03 23:13:40 +02:00
u
2026-10-28 18:33:32 +01:00
u
2026-09-29 20:10:49 +02:00
u
2026-10-21 11:55:21 +02:00
u
2026-10-09 10:41:17 +02:00
u
2026-10-09 10:41:17 +02:00
u
2026-10-10 02:14:03 +02:00
u
2026-10-29 03:28:39 +01:00
u
2026-09-27 16:47:37 +02:00
u
2026-10-30 18:09:33 +01:00
u
2026-10-31 21:02:50 +01:00
u
2026-10-11 18:55:08 +02:00
u
2026-10-14 13:31:23 +02:00
u
2026-10-26 19:22:04 +01:00
u
2026-10-26 19:22:04 +01:00
u
2026-10-23 20:31:39 +02:00
u
2026-10-19 01:32:42 +02:00
u
2026-10-23 20:31:39 +02:00
u
2026-10-17 02:59:37 +02:00
u
2026-10-17 23:32:42 +02:00
u
2026-09-24 23:26:25 +02:00
u
2026-10-16 12:35:07 +02:00
u
2026-10-09 21:37:42 +02:00
u
2026-10-04 21:22:05 +02:00
u
2026-10-08 01:37:26 +02:00
x
2026-10-27 06:48:35 +00:00
x
2026-10-27 06:48:35 +00:00
2026-10-28 01:46:48 +01:00
u
2026-10-29 03:17:47 +01:00
2026-10-28 01:46:48 +01:00
x
2026-10-27 06:48:35 +00:00
x
2026-10-27 06:48:35 +00:00
u
2026-10-18 20:54:00 +02:00
2026-09-22 07:25:16 +02:00

libdashstore

MSSQL schema + domain API for Dash. One class, methods mirror the file path under src/store/.

mssql is a peer dependency — the host app (e.g. dashback) owns ConnectionPool and import sql from 'mssql'. libdashstore store code uses only the passed pool (no own mssql install at runtime). Scripts here (init-schema, test-api, …) use mssql from devDependencies.

import sql from 'mssql';
import LibDashStore from 'libdashstore';

const pool = await sql.connect({ /* … */ });
const client = await new LibDashStore(pool); // runs initialize() (ensure schema, no purge)

SQL lives in src/sql/<table>.js. Schema DDL: src/schema.js / import 'libdashstore/schema'.

ID fields in the JS API use camelCase …Id (DB columns stay snake_case). googleAuthId is always google_auth.id (the logged-in user), not companyuser.id.

Company-scoped methods take no companyId argument: they use google_auth.selected_company (set via google_auth.setSelectedCompany). Missing selection → No company selected. Exceptions that still take companyId: setSelectedCompany, companyuser.add, company.delete, and internal helpers.


Schema

Method Description
initialize() Runs all SCHEMA_SCRIPTS (IF NOT EXISTS). Does not purge.
purge() Drops all known tables + number procedure.

company

Method Signature Returns
create ({ iso639, name, taxrate, reducedtaxrate, googleAuthId }) { id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId, posCustomerGroupId }
update (id, { iso639, name, taxrate, reducedtaxrate }) { id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId }
delete (companyId) void
list (googleAuthId) { id, name, rootCategoryId, languageId, taxrate, reducedtaxrate, taxfreerate }[]

create (transaction)

For POST /api/company:

  1. Ensures language by iso639 (get or create)
  2. Ensures tax rows for taxrate, reducedtaxrate, and 0 → taxfreerateId
  3. Creates root category (parentId = null)
  4. Creates POS customergroup (name POS), then inserts company with poscustomergroup_id (NOT NULL), then links the group via customergroup.company_id
  5. Links creator as companyuser (googleAuthId → company_id)

company.poscustomergroup_id is required (POS pricing in getTreeForCompany / category articles). Circular FK with customergroup.company_id: the group is inserted first with company_id null, then linked after the company row exists.

list

Returns only companies where googleAuthId has a companyuser row.

update (transaction)

For PATCH /api/company/:id:

  1. Fails if company missing
  2. Ensures lang + tax rates
  3. Updates name, default_language_id, taxrate, reducedtaxrate
    (taxfreerateId / root category unchanged)

google_auth

Method Signature Returns
add (email) id
getByEmail (email) { id, email, picture, pictureBinary, sub, createdAt, selectedCompanyId } | undefined
getById (googleAuthId) { id, email, picture, pictureBinary, sub, createdAt, selectedCompanyId } | undefined
listAllowedEmails () { id, email, picture, sub, createdAt, selectedCompanyId }[] (no pictureBinary)
removeAllowedEmail (email) void
setSelectedCompany (googleAuthId, companyId) void
setProfile (googleAuthId, { picture?: string | null, pictureBinary?: Buffer | null, sub?: string | null }) void

picture is the Google profile image URL (kept for change detection / re-fetch). picturebinary stores the downloaded image bytes (pictureBinary in JS, Buffer from mssql). sub is Google’s stable subject id (unique when set). setProfile overwrites picture, pictureBinary, and sub (pass null to clear). Non-Buffer pictureBinary → Invalid pictureBinary. Errors: User not found.

Backend mapping:

  • Login: getByEmail(email) then download payload.picture → setProfile(id, { picture, pictureBinary, sub })
  • /auth/me: getById(googleAuthId) → base64-encode pictureBinary for the response
  • CLI user manager: listAllowedEmails() / removeAllowedEmail(email) / add(email)
  • PUT /api/auth/selected-company: setSelectedCompany(googleAuthId, companyId)

companyuser

Method Signature Returns
add (companyId, googleAuthId) id
getByEmail (email) id[]
delete (id) void

lang

Method Signature Returns
create (iso639) id
getByIso639 (iso639) { id, iso639 } | undefined
ensure (iso639, tx?) id
list () { id, iso639 }[]
delete (id) void

tax

Method Signature Returns
create (rate) id
getByRate (rate) { id, rate } | undefined
ensure (rate, tx?) id
list () { id, rate }[]

unit

Method Signature Returns
list (googleAuthId) { si, basesi, basesifactor, symbol, name }[]
upsert (googleAuthId, { si, basesi, basesifactor, symbol, name }) { si, basesi, basesifactor, symbol, name }
delete (googleAuthId, si) void

Shape (überall):

{ si, basesi, basesifactor, symbol, name }

symbol / name come from unittext in the company default language (LEFT JOIN; missing → null). Sorted by symbol, name, si. Units are company-scoped (unit.company_id); Unique is (company_id, si).

list

Auth via selected company (like getTreeForCompany). Returns only units of that company. Errors: No company selected, Company not found, Not authorized.

upsert

Match key (companyId, si). Inserts unit + unittext, or updates basesi / basesifactor and upserts unittext for the company language. basesifactor must be finite and > 0. Errors: No company selected, Company not found, Not authorized, Invalid unit.

Default article unit pc is ensured per company on article create (with unittext symbol/name pc / Piece for the company language).

delete

Deletes the unit for (companyId, si). Blocked by FK if any article still references it (Unit in use). Missing → Unit not found. Errors: No company selected, Company not found, Not authorized, Unit not found, Unit in use.


companysetting

Company-scoped key/value settings. Auth via selected company (google_auth.selected_company + membership).

Method Signature Returns
list (googleAuthId, { frontendOnly?, maskWriteonly? }) { key, value, frontend, writeonly }[]
get (googleAuthId, key, { frontendOnly?, maskWriteonly? }) { key, value, frontend, writeonly } | undefined
set (googleAuthId, { key, value, frontend?, writeonly? }) { key, value, frontend, writeonly }
  • value is always a string (NVARCHAR(MAX)).
  • frontend: true → readable by frontend routes (frontendOnly: true). Default on set is false (backend-only).
  • With frontendOnly: true, backend-only keys are omitted / get returns undefined (no leak via error type beyond missing).
  • writeonly: true → usergui gets a "***" scrambled version when retrieving the key (in list, or in get when maskWriteonly is enabled / frontendOnly: true). The backend receives the full value.
  • keys.tavily, keys.openrouter, and keys.serper default to writeonly: true on set.
  • set only allows a fixed allow-list of keys: ui.theme, keys.tavily, keys.openrouter, keys.serper, systemprompt-prefix, systemprompt-suffix, default-model, modelslug, modelslugflag, modelreasoning, modelslugimage.
  • No delete endpoint — settings are upsert / read only.

Errors: No company selected, Company not found, Not authorized, Invalid setting.


brand

Company-scoped brands. Auth via selected company (google_auth.selected_company + membership).

Method Signature Returns
list (googleAuthId) { id, name, gsprpinfo }[]
upsert (googleAuthId, { name, gsprpinfo? }) { id, name, gsprpinfo }
delete (googleAuthId, id) void

brand.company_id NOT NULL, unique (company_id, name). upsert matches by (companyId, name). delete blocked by FK if any article references it (Brand in use). Errors: No company selected, Company not found, Not authorized, Invalid brand, Brand not found, Brand in use.


customergroup

Company-scoped customer groups. Auth via selected company (google_auth.selected_company + membership).

Method Signature Returns
list (googleAuthId) { id, name, sort }[]
create (googleAuthId, { name }) { id, name, sort }
update (googleAuthId, id, { name }) { id, name, sort }
order (googleAuthId, { customergroupIds }) { customergroupIds }
delete (googleAuthId, id) void

List ordered by sort, name, id. create appends with next sort (0…n). order sets sort to each id’s index 0…n-1; customergroupIds must be exactly the company’s groups (same set, no dups) else Invalid customer group order. Implicit groups POS (company create) and Lieferanten (first supplier) cannot be renamed (Name cannot be changed). Creating a group with those reserved names → Name not allowed.

Empty name → Name required. Deleting the POS group (or any group still referenced with NO ACTION) → Customer group in use. Cascades entities when the group is deleted. Errors: No company selected, Company not found, Not authorized, Name required, Name not allowed, Name cannot be changed, Invalid customer group order, Customer group not found, Customer group in use.


entity

Entities belong to a customergroup of the selected company. Company scope comes from google_auth.selected_company (not a companyId argument).

Method Signature Returns
list (googleAuthId, { customergroupId?, role? }) Entity[]
getById (googleAuthId, id) Entity
create (googleAuthId, { customergroupId, name, firstName?, number? }) Entity
update (googleAuthId, id, { name?, firstName?, number? }) Entity
delete (googleAuthId, id) void
peekNextNumber (googleAuthId) string
isNumberFree (googleAuthId, number, { excludeEntityId? }) boolean
type Entity = {
  id: number,
  customergroupId: number,
  firstName: string | null,
  name: string,
  number: string | null,
  createdAt: Date,
  updatedAt: Date,
}

list optional filters: customergroupId (must belong to the company), or role: 'customer' (entities whose customergroup name is not Lieferanten — for quote/order pickers). role wins over customergroupId when both are set. Unknown role → Invalid role.

number is optional on the row (e.g. suppliers leave it null). Non-null numbers are unique per company (via customergroup). create allocates via getnextnumberwithprefixandpadding (type entity) when number is omitted. update only changes number when the key is present (no auto-allocate). peekNextNumber returns what the next allocate would yield without consuming it (peeknextnumberwithprefixandpadding; ensures the numbers row exists). isNumberFree checks occupancy company-wide; use excludeEntityId on edit so the current entity’s number stays free. Empty number → Number required.

create / update write entityhistory (Erstellt, or name/firstName/number change lines). customergroupId must belong to the company. Errors: No company selected, Company not found, Not authorized, Name required, Customer group not found, Entity not found, Invalid role, Number already in use, Number required.


entityaddress

Addresses for an entity. Auth via selected company + entity ownership.

Method Signature Returns
list (googleAuthId, entityId) EntityAddress[]
create (googleAuthId, entityId, { addressType, firstName?, name?, street?, streetNumber?, zip?, city?, county?, sort? }) EntityAddress
update (googleAuthId, entityId, id, patch) EntityAddress
delete (googleAuthId, entityId, id) void

addressType required. sort defaults to next index for the entity (0…n). List ordered by sort, id. Errors: No company selected, Company not found, Not authorized, Entity not found, Address type required, Address not found, Invalid sort.


entitycontact

Contacts for an entity (contactType e.g. email, phone). Auth via selected company + entity ownership.

Method Signature Returns
list (googleAuthId, entityId) { id, entityId, contactType, contact, sort }[]
create (googleAuthId, entityId, { contactType, contact, sort? }) row
update (googleAuthId, entityId, id, { contactType?, contact?, sort? }) row
delete (googleAuthId, entityId, id) void

sort defaults to next index for the entity. List ordered by sort, id. Errors: No company selected, Company not found, Not authorized, Entity not found, Contact required, Contact not found, Invalid sort.


supplier

Suppliers are entity rows in the company customergroup named Lieferanten (created implicitly on first supplier). Auth via selected company (google_auth.selected_company + membership).

Method Signature Returns
list (googleAuthId) { id, name, createdAt, updatedAt }[]
create (googleAuthId, { name }) { id, name, createdAt, updatedAt }
update (googleAuthId, id, { name }) { id, name, createdAt, updatedAt }
delete (googleAuthId, id) void

create ensures customergroup Lieferanten for the company, inserts entity (name only; first_name null), writes entityhistory (Erstellt) with the auth companyuser_id. update renames and writes Name geändert von "…" auf "…" when the name actually changes; bumps updated_at. Empty name → Name required. delete removes the entity (entityhistory cascades; manifest.entity_id set null). Errors: No company selected, Company not found, Not authorized, Name required, Supplier not found.


articlesupplier

Maps articles to suppliers (entity) with purchase pricing, stock, packaging unit, and supplier SKU:

Column Type Constraints / Description
id INT Primary Key (IDENTITY(1,1))
article_id INT FK → article(id) (ON DELETE CASCADE)
entity_id INT FK → entity(id) (ON DELETE CASCADE)
net FLOAT Purchase net price, nullable
tax_id INT FK → tax(id) (ON DELETE NO ACTION), nullable
sku NVARCHAR(100) Supplier's SKU code, nullable
stock FLOAT Stock available at supplier, nullable
pu FLOAT Packaging unit (Verpackungseinheit), default 1
sort INT Display sort order (default 0)

Unique constraints: UQ_articlesupplier_article_entity (article_id, entity_id), UQ_articlesupplier_article_sort (article_id, sort)

Method Signature Returns
listByArticleSku (googleAuthId, sku) ArticleSupplierSummary[]
create (googleAuthId, sku, { entityId, sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }) ArticleSupplierSummary
update (googleAuthId, sku, id, { sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }) ArticleSupplierSummary
order (googleAuthId, sku, { ids }) { ids: number[] }
delete (googleAuthId, sku, id) void

Errors: Not authorized, Article not found, Supplier required, Supplier not found, Supplier assignment not found, Supplier already assigned, Invalid pu, Invalid stock, Invalid sort, Invalid supplier order, Invalid price.


delivery

Company-scoped goods receipts on manifest / manifestarticle with fixed type = 'delivery'. Auth via selected company (google_auth.selected_company + membership). manifest.company_id scopes the list (ON DELETE NO ACTION — avoid SQL Server multiple cascade paths with entity_id).

Method Signature Returns
list (googleAuthId, opts?: DeliveryListOpts) `DeliverySummary[]
getById (googleAuthId, id) Delivery
create (googleAuthId, { date, supplierId?, externalNumber?, lines }) Delivery
update (googleAuthId, id, { number?, date?, supplierId?, externalNumber?, lines? }) Delivery
delete (googleAuthId, id) void
rebuildSummaries (googleAuthId) { ok: boolean, companyId: number }
type DeliveryListOpts = {
  offset?: number,
  limit?: number,
  search?: string,
  onlyUnbooked?: boolean,
  supplierId?: number,
  seekMonths?: number,
}

type WindowedDeliveries = {
  deliveries: DeliverySummary[],
  total: number,
  unfilteredTotal: number,
  offset: number,
  limit: number,
}

type DeliverySummary = {
  id: number,
  number: string,
  type: 'delivery',
  supplierId: number | null,   // manifest.entity_id
  externalNumber: string | null,   // manifest.external_number
  date: Date,
  createdAt?: string,
  supplierName?: string | null,
  summary?: {
    positions: number,
    grossTotal: number,
    netTotal: number,
    totalQuantity: number,
    stockPositions: number,
    bookedPositions: number,
    unbookedPositions: number,
    stockQuantity: number,
    bookedQuantity: number,
    unbookedQuantity: number,
    isBooked: boolean,
  },
  ledger?: {
    totalPositions: number,
    completelyOnLedgerPositions: number,
    bookedPositions: number,
    unbookedPositions: number,
    totalQuantity: number,
    bookedQuantity: number,
    unbookedQuantity: number,
    isBooked: boolean,
  },
}

type DeliveryLine = {
  id: number,
  sku: string | null,
  name: string | null,
  pu: number,
  amount: number,
  net: number,
  rebate: number,
  taxrate: number,              // manifestarticle.taxrate
  comment: string | null,
}

type Delivery = DeliverySummary & { lines: DeliveryLine[] }

create inserts header + lines in one transaction. The number is auto-allocated via getnextnumberwithprefixandpadding (numbers type delivery); supply externalNumber for an external (supplier) reference. supplierId null or a Lieferanten entity of the company (else Supplier not found). Each line requires either sku or name. For delivery manifests, sku points to articlesupplier.sku; for quote and order manifests, sku points to article.sku. Lines are stored without FKs so manifest articles are preserved even if articles or suppliers are deleted. amount, net, taxrate (rate → tax.ensure); rebate defaults 0. Empty/invalid date → Invalid delivery; bad line payload → Invalid lines.

update patches header fields that are present. If lines is present, replace-all lines; if omitted, leave lines unchanged. Returns full Delivery.

delete removes the manifest (manifestarticle cascades). Errors: No company selected, Company not found, Not authorized, Delivery not found, Supplier not found, Invalid delivery, Invalid lines.


quote

Company-scoped quotes on manifest / manifestarticle with fixed type = 'quote'. Auth via selected company (google_auth.selected_company + membership). Same line shape as delivery (no transactedAmount — ledger owns movements). manifest.date is the document date; HTTP/OpenAPI may describe it as valid-until.

Method Signature Returns
list (googleAuthId) QuoteSummary[]
getById (googleAuthId, id) Quote
create (googleAuthId, { number, date, entityId?, externalNumber?, lines }) Quote
update (googleAuthId, id, { number?, date?, entityId?, externalNumber?, lines? }) Quote
delete (googleAuthId, id) void
type QuoteSummary = {
  id: number,
  number: string,
  type: 'quote',
  entityId: number | null,   // manifest.entity_id — customer
  externalNumber: string | null,   // manifest.external_number
  date: Date,
}

type ManifestLine = {
  id: number,
  articleId: number | null,
  sku: string | null,
  amount: number,
  net: number,
  rebate: number,
  taxrate: number,                 // tax.rate
  comment: string | null,
}

type Quote = QuoteSummary & { lines: ManifestLine[] }

entityId null or a company entity whose customergroup is not named Lieferanten (else Entity not found). Lines: sku (category tree → Article not found), amount, net, tax (tax.ensure); rebate defaults 0. Empty/invalid number or date → Invalid quote; bad lines → Invalid lines.

update: present fields only; lines present → replace-all. delete cascades lines. Errors: No company selected, Company not found, Not authorized, Quote not found, Entity not found, Article not found, Invalid quote, Invalid lines.


order

Company-scoped orders on manifest / manifestarticle with fixed type = 'order'. Same API shape as quote (entityId customer, not Lieferanten). manifest.date may be documented as expected delivery in HTTP docs.

Method Signature Returns
list (googleAuthId) OrderSummary[]
getById (googleAuthId, id) Order
create (googleAuthId, { number, date, entityId?, externalNumber?, lines }) Order
update (googleAuthId, id, { number?, date?, entityId?, externalNumber?, lines? }) Order
delete (googleAuthId, id) void
type OrderSummary = {
  id: number,
  number: string,
  type: 'order',
  entityId: number | null,
  externalNumber: string | null,   // manifest.external_number
  date: Date,
}

type Order = OrderSummary & { lines: ManifestLine[] }  // ManifestLine as in quote

Errors: No company selected, Company not found, Not authorized, Order not found, Entity not found, Article not found, Invalid order, Invalid lines.


ledger

Append-only SQL Server ledger table (WITH (LEDGER = ON (APPEND_ONLY = ON))). Rows cannot be updated or deleted. References manifestarticle with ON DELETE NO ACTION (a line with ledger entries cannot be removed). Auth via selected company (google_auth.selected_company + membership; line must belong to company).

Method Signature Returns
add (googleAuthId, { manifestarticle_id, transaction_type, amount, transaction_text }) { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }
listByManifestarticleId (googleAuthId, manifestarticleId, [transactionType]) { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }[]
listBySku (googleAuthId, sku, [transactionType]) { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }[]
  • hash_version: 1
  • prev_hash: For the first entry in the database, TODO_TSE_BASED_INITHASH. For subsequent entries, SHA-512 hex of canonical JSON JSON.stringify({ id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }) of the entry with the highest id.
Column Type Notes
id INT IDENTITY PK
manifestarticle_id INT FK → manifestarticle
transaction_type NVARCHAR(100)
amount FLOAT
transaction_text NVARCHAR(MAX)
hash_version INT hash algorithm / payload version (1)
prev_hash NVARCHAR(128) SHA-512 hex of previous row or init hash
created_at DATETIME2 Creation timestamp

Engine also maintains ledger_start_transaction_id / ledger_start_sequence_number. purge/reset-db soft-drops the table (MSSQL_DroppedLedgerTable_* remains; a new ledger can still be created).


posterminal

Company-scoped POS terminals. Auth via selected company (google_auth.selected_company + membership).

Method Signature Returns
list (googleAuthId) { id, name, token, pairingpin }[]
upsert (googleAuthId, { id?, name, token?, pairingpin }) { id, name, token, pairingpin }
delete (googleAuthId, id) void
setOn (googleAuthId, posterminalId, categoryId) { id, categoryIds[] }
setOff (googleAuthId, posterminalId, categoryId) { id, categoryIds[] }
setOnAll (googleAuthId, posterminalId) { id, categoryIds[] }
setOffAll (googleAuthId, posterminalId) { id, categoryIds[] }
getCategoryPosterminalIds (companyId) { [categoryId]: posterminalId[] } (internal, used by category tree)

posterminal.company_id NOT NULL. name is required. token is nullable and unique (pairing code assigned during setup). pairingpin is required.

upsert: if id is provided, updates the existing posterminal (must belong to company); otherwise creates a new one. Errors: No company selected, Company not found, Not authorized, Invalid posterminal, Posterminal not found.

setOn / setOff: toggle a single category's visibility on a single terminal. setOn inserts the row (idempotent — no error if already present); setOff deletes it (idempotent). Both return the updated categoryIds for that terminal. Errors: No company selected, Company not found, Not authorized, Posterminal not found, Invalid posterminal or category.

setOnAll / setOffAll: toggle all company categories on a single terminal. setOnAll walks the company's category tree and inserts any missing links; setOffAll removes all category links for the terminal. Both return the updated categoryIds for that terminal. Errors: No company selected, Company not found, Not authorized, Posterminal not found, Invalid posterminal.

delete cascades to posterminalcategory. Errors: No company selected, Company not found, Not authorized, Posterminal not found.

Category tree integration

getTreeForCompany returns a posterminalIds array on each tree node — the list of posterminal ids (of the current company) for which that category is visible. A category with an empty posterminalIds array is visible on all terminals (or has no terminal-specific filtering).


category

Method Signature Returns
createUnderParent ({ googleAuthId, parentId, name }) { id, parentId, sort, name, slug }
rename ({ googleAuthId, id, name }) { id, name, slug }
move ({ googleAuthId, id, parentId }) { id, parentId, sort }
order ({ googleAuthId, parentId, categoryIds }) { categoryIds }
delete ({ googleAuthId, id }) void — Category has children if child categories remain; FK categoryarticle also blocks deletion
getTreeForCompany (googleAuthId) { languageId, tree, articles } — tree rows carry name + slug
getCategoryArticles (googleAuthId, categoryId) { id, sku, name, slug, net, taxRate, optionsCount, articleListCount, childCount, parentArticleId, parentSku, fullHash, thumbW, thumbH, thumbHash }[]
addImage (googleAuthId, categoryId, imageBuffer) { id, thumbW, thumbH, thumbHash }
deleteImage (googleAuthId, categoryId) { id, thumbW: null, thumbH: null, thumbHash: null }

createUnderParent (transaction)

  1. parentId exists, else Parent not found
  2. Walk ancestors to root; find company with root_category_id = root
  3. companyuser for selected company via googleAuthId, else Not authorized
  4. Insert category (next sibling sort) + categorytext (name, slugified slug, company default language); returns { id, parentId, sort, name, slug }

getTreeForCompany throws Not authorized if googleAuthId is not a companyuser of companyId, and Company not found if the company is missing, has no root_category_id, or no default_language_id.

rename updates the categorytext name (and re-slugifies slug) for the category in the company default language. Empty name → Name required. Auth via resolveCategoryAccess on the category (Parent not found / Not authorized). Missing text row → Category not found. Returns { id, name, slug }. For PATCH /api/category/:id.

order reorders the direct children of parentId to the given categoryIds order (each child's sort set to its index 0..n-1). Since a category can have multiple parents, parentId scopes which sibling list is reordered. categoryIds must be exactly the current children of parentId (same set, no dups) else Invalid category order. Auth via resolveCategoryAccess on parentId. Returns { categoryIds }. For PUT /api/category/order.

move moves a category to a new parent. Both id (source) and parentId (target) are validated via resolveCategoryAccess — the target must be a real category of the same authorized company, else Parent not found. Moving into itself or into a descendant (cycle) → Invalid parent. The moved category gets the next sibling sort under the new parent. Returns { id, parentId, sort }. For POST /api/category/:id/move.

addImage accepts a full-size AVIF Buffer. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (storageDir/fullbinary). Thumb always scaled to max 200×200 (storageDir/thumbbinary) via wasm-image-optimization. Assigns random fullHash (pic.hash, unique), computes thumbhash, links categorypic. Auth via resolveCategoryAccess. Errors: Category not found, Not authorized.

addImage returns:

{
  id,           // category id
  thumbW,       // thumb width
  thumbH,       // thumb height
  thumbHash,    // base64 thumbhash of thumb preview
}

deleteImage removes all categorypic rows for the category. Returns { id, thumbW: null, thumbH: null, thumbHash: null }. Errors: Category not found, Image not found, Not authorized.


article

Method Signature Returns
listSearchIndex (googleAuthId: string, companyId: number) SearchIndexEntry[] (see below)
listExport (googleAuthId: string, companyId: number, categoryId?: number) ArticleExportEntry[] (see below)
createUnderParent ({ googleAuthId: string, parentId: number, name: string, track_stock?: boolean }) { id: number, parentId: number, name: string, sku: string, track_stock: boolean }
getBySku (googleAuthId: string, sku: string) ArticleDetail (see below)
getHistoryBySku (googleAuthId: string, sku: string) { id: number, companyuserId: number | null, date: Date, text: string }[]
updateBySku (googleAuthId: string, sku: string, { name?: string, unitamount?: number, unitSi?: string, track_stock?: boolean }) ArticleDetail
setPricesBySku (googleAuthId: string, sku: string, PriceInput[]) ArticleDetail
setEansBySku (googleAuthId: string, sku: string, string[]) ArticleDetail
setBrandIdBySku (googleAuthId: string, sku: string, number | null) ArticleDetail
setWeightBySku (googleAuthId: string, sku: string, number) ArticleDetail
setTrackStockBySku (googleAuthId: string, sku: string, boolean) ArticleDetail
setCategoryIdsBySku (googleAuthId: string, sku: string, number[]) ArticleDetail
addImage (googleAuthId: string, sku: string, Buffer) ImageResult (see below)
deleteImage (googleAuthId: string, sku: string, fullHash: string) void
orderImages (googleAuthId: string, sku: string, { fullHashes: string[] }) { fullHashes: string[] }
delete (googleAuthId: string, sku: string) void

addImage accepts a full-size AVIF Buffer. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (storageDir/fullbinary). Thumb always scaled to max 200×200 (storageDir/thumbbinary) via wasm-image-optimization. Assigns random fullHash (pic.hash, unique), computes thumbhash, links articlepic. Auth like getBySku. Errors: Article not found, Not authorized.

addImage returns ImageResult:

{
  picId: number,
  sort: number,
  full: { w: number, h: number, size: number },   // AVIF, max 1000×1000
  thumb: { w: number, h: number },                  // scaled AVIF, max 200×200
  fullHash: string,                                 // random 64-char hex, unique
  thumbHash: string,                                // base64 thumbhash of thumb
}

listSearchIndex returns every article in the company (reachable via the category tree). Designed for client-side search / autocomplete preload. Auth via selected company (google_auth.selected_company + membership). Errors: No company selected, Company not found, Not authorized.

type SearchIndexEntry = {
  id: number,
  sku: string,
  name: string | null,            // null when articletext row is missing
  eans: string[],                 // all EANs, empty array when none
  categoryIds: number[],          // all category ids, at least one
}

listExport returns articles in a category subtree (root = company when categoryId omitted). categoryId must be reachable via the selected company's root category (else Not authorized). Auth via selected company + resolveCategoryAccess. Errors: No company selected, Company not found, Parent not found, Not authorized.

type ArticleExportEntry = {
  id: number,
  sku: string,
  name: string | null,            // null when articletext row is missing
  slug: string | null,
  unitSi: string | null,
  unitSymbol: string | null,
  unitamount: number,
  brandName: string | null,
  net: number | null,             // null when no POS price
  taxRate: number | null,         // null when no POS price
  eans: string[],                 // all EANs, empty array when none
  categoryIds: number[],          // all category ids, at least one
  categoryBreadcrumbs: string[],  // one middot-separated breadcrumb ("Home·Cat·Sub") per category
  fullHashes: string[],           // all photo fullHashes in sort order, empty when none
}

getBySku / updateBySku / setPricesBySku / setEansBySku / setBrandIdBySku / setWeightBySku / setTrackStockBySku / setCategoryIdsBySku all return ArticleDetail:

type ArticleDetail = {
  id: number,
  sku: string,
  name: string | null,            // null when articletext row is missing
  slug: string | null,
  unitamount: number,
  weight: number,
  track_stock: boolean,
  unit: {
    si: string,                   // e.g. "pc"
    basesi: string,               // e.g. "pc"
    basesifactor: number,         // e.g. 1
    symbol: string | null,        // e.g. "Stk", null when unittext missing
    name: string | null,          // e.g. "Stück", null when unittext missing
  },
  brand: {
    name: string,
    thumbW: number,
    thumbH: number,
    thumbHash: string,
  } | null,                       // null when no brand assigned
  categoryIds: number[],          // at least one
  photos: {
    sort: number,
    fullHash: string,
    thumbW: number,
    thumbH: number,
    thumbHash: string,
  }[],                            // empty array when no photos
  eans: string[],                 // empty array when none
  prices: PriceEntry[],           // empty array when none
  optionsCount: number,           // 0 when no optionslist
  articleListCount: number,       // 0 when no articlelist
  childCount: number,             // 0 when no children
  parentArticleId: number | null, // null when root article
  parentSku: string | null,       // null when root article
}

type PriceEntry = {
  minmultiplier: number,
  net: number,
  taxRate: number,
  customergroupId: number,
}

getCategoryArticles / getTreeForCompany().articles[] return a flat list shape (net, taxRate, single fullHash/thumbW/thumbH/thumbHash).

parentId is the category id. Creates article + articletext (company default language) + categoryarticle in one transaction. Allocates sku via getnextnumberwithprefixandpadding (type sku; creates numbers row with empty prefix if missing). Default unit pc is ensured if missing (unitamount 1, weight 0). Auth via resolveCategoryAccess(googleAuthId, parentId) — Parent not found / Not authorized.

Auth via the article's categoryarticle category + resolveCategoryAccess. Errors: Article not found, Not authorized.

updateBySku patches article fields in a transaction. At least one of name, unitamount, unitSi required. name → articletext (slugified slug); empty name → Name required. unitSi → existing unit.si lookup (no silent ensure); unknown → Unit not found. unitamount must be finite and > 0 → Invalid unitamount. When name and/or unit (unitamount/unitSi) actually change, inserts one articlehistory row in the same transaction with companyuser_id set to the auth user (text lines joined by newline), e.g. Name geändert von "Alt" auf "Neu" / Einheit geändert von "500g" auf "1kg" (label = unitamount + unittext.symbol, fallback unit.si). Returns ArticleDetail. Errors: Article not found, Not authorized, Name required, Unit not found, Invalid unitamount.

getHistoryBySku returns articlehistory for the article, newest first (date DESC, id DESC). companyuserId is null when the user was deleted (ON DELETE SET NULL). Auth like getBySku. Errors: Article not found, Not authorized.

setPricesBySku replaces all price rows for the article’s company poscustomergroup_id in one transaction. Each entry: price → net, tax → tax rate (ensured via tax.ensure), minmultiplier defaults to 1. Empty array clears all POS prices. Returns ArticleDetail. Errors: Article not found, Not authorized, POS customer group required, Invalid prices.

setEansBySku replaces all articleean rows for the article in one transaction (replace-all semantics). EANs must be non-empty unique strings; empty array clears all EANs. Returns ArticleDetail (whose eans reflects the new list). Errors: Article not found, Not authorized, Invalid eans.

setBrandIdBySku sets the article’s brand_id. Pass null to clear the brand. Requires the brand to belong to the article’s company (else Brand not found). Non-null must be a finite id. Errors: Article not found, Not authorized, Brand not found.

setWeightBySku sets the article’s weight. Must be finite and >= 0 (else Invalid weight). Errors: Article not found, Not authorized, Invalid weight.

setTrackStockBySku sets the article’s track_stock flag. Must be boolean or 0/1 (else Invalid track_stock). Errors: Article not found, Not authorized, Invalid track_stock.

setCategoryIdsBySku replaces the article’s category list (categoryarticle) in one transaction. Each category must belong to the article’s company and ids must be unique (else Invalid categoryIds); empty array is rejected — an article must have at least one category (else Article must have at least one category). Returns ArticleDetail (whose categoryIds reflects the new list). Errors: Article not found, Not authorized, Invalid categoryIds, Article must have at least one category.

deleteImage removes articlepic and the pic row for this article (fullHash). Errors: Article not found, Image not found, Not authorized.

orderImages sets articlepic.sort to 0..n-1 in the given order. fullHashes must list every image of the article exactly once. Errors: Article not found, Invalid image order, Not authorized.

delete removes the article when it has no child articles (parentarticle_id FK is ON DELETE NO ACTION). Cascades: articlehistory, articleean, categoryarticle, price, articlesupplier, articletext, articlelistarticle, optionslistarticle, articlepic; deletes linked pic rows and owned articlelist / optionslist. Errors: Article not found, Article has children, Not authorized.


pic

Lookup by pic.hash (fullHash from addImage.fullHash), not thumbhash. Random 64-char hex (crypto.randomBytes(32)), unique in DB.

Method Signature Returns
getImageByHash (googleAuthId, fullHash) { buffer, w, h, size, contentType }
getThumbByHash (googleAuthId, fullHash) { buffer, w, h, contentType }

Auth via articlepic → categoryarticle → resolveCategoryAccess. Errors: Image not found, Not authorized.

contentType is always image/avif.

// GET /api/image/:fullHash
const { buffer, contentType } = await store.pic.getImageByHash(req.user.id, fullHash);
res.set('Content-Type', contentType).send(buffer);

// GET /api/thumb/:fullHash
const thumb = await store.pic.getThumbByHash(req.user.id, fullHash);
res.set('Content-Type', thumb.contentType).send(thumb.buffer);

numbers

Company-scoped number sequences (numbers table). Auth via selected company (google_auth.selected_company + membership).

Method Signature Returns
list (googleAuthId) { id, type, prefix, padding, next }[]
update (googleAuthId, id, { prefix?, padding?, next? }) { id, type, prefix, padding, next }

list returns every sequence for the company, ordered by type. update updates only the provided fields: prefix is trimmed (max 50 chars), padding must be an integer >= 0, next must be an integer >= 1. Errors: No company selected, Company not found, Not authorized, Number not found, Invalid number.


Backend route cheatsheet

Route Store call
POST /api/company company.create({ iso639, name, taxrate, reducedtaxrate, googleAuthId })
GET /api/company company.list(googleAuthId)
PATCH /api/company/:id company.update(id, { iso639, name, taxrate, reducedtaxrate })
DELETE /api/company/:id company.delete(id)
PUT /api/auth/selected-company google_auth.setSelectedCompany(googleAuthId, companyId)
GET /api/category/tree category.getTreeForCompany(googleAuthId)
GET /api/category/export.zip category.getTreeForCompany(googleAuthId) + pic.getImageByHash
POST /api/category category.createUnderParent({ googleAuthId, parentId, name })
PATCH /api/category/:id category.rename({ googleAuthId, id, name })
PUT /api/category/order category.order({ googleAuthId, parentId, categoryIds })
POST /api/category/:id/move category.move({ googleAuthId, id, parentId })
DELETE /api/category/:id category.delete({ googleAuthId, id })
POST /api/category/:id/image category.addImage(googleAuthId, categoryId, imageBuffer)
DELETE /api/category/:id/image category.deleteImage(googleAuthId, categoryId)
GET /api/unit unit.list(googleAuthId)
PUT /api/unit unit.upsert(googleAuthId, { si, basesi, basesifactor, symbol, name })
DELETE /api/unit unit.delete(googleAuthId, si)
GET /api/company/:id/settings companysetting.list(googleAuthId, { frontendOnly: true })
PUT /api/company/:id/settings companysetting.set(googleAuthId, { key, value, frontend? })
GET /api/brand brand.list(googleAuthId)
PUT /api/brand brand.upsert(googleAuthId, { name, gsprpinfo? })
DELETE /api/brand/:id brand.delete(googleAuthId, id)
GET /api/customergroup customergroup.list(googleAuthId)
POST /api/customergroup customergroup.create(googleAuthId, { name })
PATCH /api/customergroup/:id customergroup.update(googleAuthId, id, { name })
PUT /api/customergroup/order customergroup.order(googleAuthId, { customergroupIds })
DELETE /api/customergroup/:id customergroup.delete(googleAuthId, id)
GET /api/entity entity.list(googleAuthId, { customergroupId?, role? })
GET /api/entity/next-number entity.peekNextNumber(googleAuthId)
GET /api/entity/number-free entity.isNumberFree(googleAuthId, number, { excludeEntityId? })
GET /api/entity/:id entity.getById(googleAuthId, id)
POST /api/entity entity.create(googleAuthId, { customergroupId, name, firstName?, number? })
PATCH /api/entity/:id entity.update(googleAuthId, id, { name?, firstName?, number? })
DELETE /api/entity/:id entity.delete(googleAuthId, id)
GET /api/entity/:entityId/address entityaddress.list(googleAuthId, entityId)
POST /api/entity/:entityId/address entityaddress.create(googleAuthId, entityId, { addressType, … })
PATCH /api/entity/:entityId/address/:id entityaddress.update(googleAuthId, entityId, id, patch)
DELETE /api/entity/:entityId/address/:id entityaddress.delete(googleAuthId, entityId, id)
GET /api/entity/:entityId/contact entitycontact.list(googleAuthId, entityId)
POST /api/entity/:entityId/contact entitycontact.create(googleAuthId, entityId, { contactType, contact })
PATCH /api/entity/:entityId/contact/:id entitycontact.update(googleAuthId, entityId, id, patch)
DELETE /api/entity/:entityId/contact/:id entitycontact.delete(googleAuthId, entityId, id)
GET /api/supplier supplier.list(googleAuthId)
POST /api/supplier supplier.create(googleAuthId, { name })
PATCH /api/supplier/:id supplier.update(googleAuthId, id, { name })
DELETE /api/supplier/:id supplier.delete(googleAuthId, id)
GET /api/article/:sku/supplier articlesupplier.listByArticleSku(googleAuthId, sku)
POST /api/article/:sku/supplier articlesupplier.create(googleAuthId, sku, { entityId, sku?, pu?, stock?, netPrice?, taxId?, taxRate? })
PATCH /api/article/:sku/supplier/:id articlesupplier.update(googleAuthId, sku, id, patch)
PUT /api/article/:sku/supplier/order articlesupplier.order(googleAuthId, sku, { ids })
DELETE /api/article/:sku/supplier/:id articlesupplier.delete(googleAuthId, sku, id)
GET /api/delivery delivery.list(googleAuthId, opts)
POST /api/delivery/rebuild-summaries delivery.rebuildSummaries(googleAuthId)
GET /api/delivery/:id delivery.getById(googleAuthId, id)
POST /api/delivery delivery.create(googleAuthId, { number, date, supplierId?, lines })
PATCH /api/delivery/:id delivery.update(googleAuthId, id, { number?, date?, supplierId?, lines? })
DELETE /api/delivery/:id delivery.delete(googleAuthId, id)
GET /api/quote quote.list(googleAuthId)
GET /api/quote/:id quote.getById(googleAuthId, id)
POST /api/quote quote.create(googleAuthId, { number, date, entityId?, lines })
PATCH /api/quote/:id quote.update(googleAuthId, id, { number?, date?, entityId?, lines? })
DELETE /api/quote/:id quote.delete(googleAuthId, id)
GET /api/order order.list(googleAuthId)
GET /api/order/:id order.getById(googleAuthId, id)
POST /api/order order.create(googleAuthId, { number, date, entityId?, lines })
PATCH /api/order/:id order.update(googleAuthId, id, { number?, date?, entityId?, lines? })
DELETE /api/order/:id order.delete(googleAuthId, id)
GET /api/posterminal posterminal.list(googleAuthId)
PUT /api/posterminal posterminal.upsert(googleAuthId, { id?, name, token?, pairingpin })
DELETE /api/posterminal/:id posterminal.delete(googleAuthId, id)
PUT /api/posterminal/:id/category/:categoryId posterminal.setOn(googleAuthId, id, categoryId)
DELETE /api/posterminal/:id/category/:categoryId posterminal.setOff(googleAuthId, id, categoryId)
PUT /api/posterminal/:id/categories posterminal.setOnAll(googleAuthId, id)
DELETE /api/posterminal/:id/categories posterminal.setOffAll(googleAuthId, id)
POST /api/article article.createUnderParent({ googleAuthId, parentId, name })
GET /api/article/:sku article.getBySku(googleAuthId, sku)
GET /api/article/:sku/history article.getHistoryBySku(googleAuthId, sku)
GET /api/articleIndex article.listSearchIndex(googleAuthId)
GET /api/article/export.zip article.listExport(googleAuthId, categoryId?)
PATCH /api/article/:sku article.updateBySku(googleAuthId, sku, { name?, unitamount?, unitSi?, track_stock? })
PUT /api/article/:sku/prices article.setPricesBySku(googleAuthId, sku, [{ price, tax, minmultiplier? }])
PUT /api/article/:sku/eans article.setEansBySku(googleAuthId, sku, string[])
PUT /api/article/:sku/brand article.setBrandIdBySku(googleAuthId, sku, brandId | null)
PUT /api/article/:sku/weight article.setWeightBySku(googleAuthId, sku, weight)
PUT /api/article/:sku/track_stock article.setTrackStockBySku(googleAuthId, sku, track_stock)
PUT /api/article/:sku/categories article.setCategoryIdsBySku(googleAuthId, sku, number[])
DELETE /api/article/:sku article.delete(googleAuthId, sku)
POST /api/article/:sku/image article.addImage(googleAuthId, sku, imageBuffer)
DELETE /api/article/:sku/image/:fullHash article.deleteImage(googleAuthId, sku, fullHash)
PUT /api/article/:sku/images/order article.orderImages(googleAuthId, sku, { fullHashes })
GET /api/image/:fullHash pic.getImageByHash(googleAuthId, fullHash)
GET /api/thumb/:fullHash pic.getThumbByHash(googleAuthId, fullHash)

Scripts

npm run purge-db      # drop all tables + procedure (wipe data)
npm run reset-db      # purge + init-schema (empty DB, fresh schema)
npm run init-schema   # ensure schema only (does NOT delete data)
npm run test:api      # interactive API tester
npm run schema-graph  # regenerate schema-graph.svg

Env: MSSQL_SERVER, MSSQL_DATABASE, MSSQL_USER, MSSQL_PASSWORD.

Description
No description provided
Readme 7.5 MiB
Languages
JavaScript 100%