# 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`. ```js 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/.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. Fails if `name` is taken (`Company name not available`) 2. Ensures language by `iso639` (get or create) 3. Ensures tax rows for `taxrate`, `reducedtaxrate`, and `0` → `taxfreerateId` 4. Creates root category (`parentId = null`) 5. Creates POS `customergroup` (name `POS`), then inserts company with `poscustomergroup_id` (**NOT NULL**), then links the group via `customergroup.company_id` 6. 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 / name taken by another company 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): ```js { 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` | ```ts 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[] | WindowedDeliveries` | | `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 }` | ```ts 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` | ```ts 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` | ```ts 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 (`pic.fullbinary`). Thumb always scaled to max 200×200 (`pic.thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `categorypic`. Auth via `resolveCategoryAccess`. Errors: `Category not found`, `Not authorized`. `addImage` returns: ```js { 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 (`pic.fullbinary`). Thumb always scaled to max 200×200 (`pic.thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`. `addImage` returns `ImageResult`: ```ts { 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`. ```ts 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`. ```ts 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`: ```ts 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`. ```js // 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 ```bash 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`.