Files
dashlibstorage/README.md
2026-08-04 01:24:27 +02:00

492 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<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`.
---
## Schema
**Kein Migrations-Code:** Wir gehen von einer frischen DB aus (`npm run reset-db`). Schema nur in `src/schema.js`; Anpassungen an einer laufenden DB manuell per `sqlcmd`, nicht im Repo.
| 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, createdAt, selectedCompanyId } \| undefined` |
| `listAllowedEmails` | `()` | `{ id, email, createdAt, selectedCompanyId }[]` |
| `removeAllowedEmail` | `(email)` | `void` |
| `setSelectedCompany` | `(googleAuthId, companyId)` | `void` |
Backend mapping:
- Login: `getByEmail(email)`
- 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, companyId)` | `{ si, basesi, basesifactor, symbol, name }[]` |
| `upsert` | `(googleAuthId, companyId, { si, basesi, basesifactor, symbol, name })` | `{ si, basesi, basesifactor, symbol, name }` |
| `delete` | `(googleAuthId, companyId, 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 `resolveCompanyAccess` (like `getTreeForCompany`). Returns only units of that company. Errors: `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: `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: `Company not found`, `Not authorized`, `Unit not found`, `Unit in use`.
---
## `companysetting`
Company-scoped key/value settings. Auth via `resolveCompanyAccess`.
| Method | Signature | Returns |
| --- | --- | --- |
| `list` | `(googleAuthId, companyId, { frontendOnly? })` | `{ key, value, frontend }[]` |
| `get` | `(googleAuthId, companyId, key, { frontendOnly? })` | `{ key, value, frontend } \| undefined` |
| `set` | `(googleAuthId, companyId, { key, value, frontend? })` | `{ key, value, frontend }` |
| `delete` | `(googleAuthId, companyId, key)` | `void` |
- `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).
Errors: `Company not found`, `Not authorized`, `Invalid setting`, `Setting not found`.
---
## `brand`
Company-scoped brands. Auth via `resolveCompanyAccess`.
| Method | Signature | Returns |
| --- | --- | --- |
| `list` | `(googleAuthId, companyId)` | `{ id, name, gsprpinfo }[]` |
| `upsert` | `(googleAuthId, companyId, { name, gsprpinfo? })` | `{ id, name, gsprpinfo }` |
| `delete` | `(googleAuthId, companyId, 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: `Company not found`, `Not authorized`, `Invalid brand`, `Brand not found`, `Brand in use`.
---
## `posterminal`
Company-scoped POS terminals. Auth via `resolveCompanyAccess`.
| Method | Signature | Returns |
| --- | --- | --- |
| `list` | `(googleAuthId, companyId)` | `{ id, name, token, pairingpin }[]` |
| `upsert` | `(googleAuthId, companyId, { id?, name, token?, pairingpin })` | `{ id, name, token, pairingpin }` |
| `delete` | `(googleAuthId, companyId, id)` | `void` |
| `setOn` | `(googleAuthId, companyId, posterminalId, categoryId)` | `{ id, categoryIds[] }` |
| `setOff` | `(googleAuthId, companyId, posterminalId, categoryId)` | `{ id, categoryIds[] }` |
| `setOnAll` | `(googleAuthId, companyId, posterminalId)` | `{ id, categoryIds[] }` |
| `setOffAll` | `(googleAuthId, companyId, 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: `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: `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: `Company not found`, `Not authorized`, `Posterminal not found`, `Invalid posterminal`.
`delete` cascades to `posterminalcategory`. Errors: `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, companyId)` | `{ languageId, tree, articles }` — tree rows carry `name` + `slug` |
| `getCategoryArticles` | `(googleAuthId, categoryId)` | `{ id, sku, name, slug, net, taxRate, optionsCount, articleListCount, childCount, fatherArticleId, fatherSku, 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 `(googleAuthId, companyId)`, 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 })` | `{ id: number, parentId: number, name: string, sku: string }` |
| `getBySku` | `(googleAuthId: string, sku: string)` | `ArticleDetail` (see below) |
| `updateBySku` | `(googleAuthId: string, sku: string, { name?: string, unitamount?: number, unitSi?: string })` | `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` |
| `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 `resolveCompanyAccess`. Errors: `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 `resolveCompanyAccess` + `resolveCategoryAccess`. Errors: `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` / `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,
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
fatherArticleId: number | null, // null when root article
fatherSku: 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`. Returns `ArticleDetail`. Errors: `Article not found`, `Not authorized`, `Name required`, `Unit not found`, `Invalid unitamount`.
`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`.
`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 (`fatherarticle_id` FK is `ON DELETE NO ACTION`). Cascades: `articlehistory`, `articleean`, `categoryarticle`, `price`, `articletext`, `articlelistarticle`, `optionslistarticle`, `articlepic`; deletes linked `pic` rows and owned `articlelist` / `optionslist`. `manifestarticle.article_id` is set to `NULL` and `comment` gets `article <name> deleted` appended. 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);
```
---
## 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, companyId)` |
| `GET /api/category/export.zip` | `category.getTreeForCompany(googleAuthId, companyId)` + `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, companyId)` |
| `PUT /api/unit` | `unit.upsert(googleAuthId, companyId, { si, basesi, basesifactor, symbol, name })` |
| `DELETE /api/unit` | `unit.delete(googleAuthId, companyId, si)` |
| `GET /api/company/:id/settings` | `companysetting.list(googleAuthId, companyId, { frontendOnly: true })` |
| `PUT /api/company/:id/settings` | `companysetting.set(googleAuthId, companyId, { key, value, frontend? })` |
| `DELETE /api/company/:id/settings/:key` | `companysetting.delete(googleAuthId, companyId, key)` |
| `GET /api/brand` | `brand.list(googleAuthId, companyId)` |
| `PUT /api/brand` | `brand.upsert(googleAuthId, companyId, { name, gsprpinfo? })` |
| `DELETE /api/brand/:id` | `brand.delete(googleAuthId, companyId, id)` |
| `GET /api/posterminal` | `posterminal.list(googleAuthId, companyId)` |
| `PUT /api/posterminal` | `posterminal.upsert(googleAuthId, companyId, { id?, name, token?, pairingpin })` |
| `DELETE /api/posterminal/:id` | `posterminal.delete(googleAuthId, companyId, id)` |
| `PUT /api/posterminal/:id/category/:categoryId` | `posterminal.setOn(googleAuthId, companyId, id, categoryId)` |
| `DELETE /api/posterminal/:id/category/:categoryId` | `posterminal.setOff(googleAuthId, companyId, id, categoryId)` |
| `PUT /api/posterminal/:id/categories` | `posterminal.setOnAll(googleAuthId, companyId, id)` |
| `DELETE /api/posterminal/:id/categories` | `posterminal.setOffAll(googleAuthId, companyId, id)` |
| `POST /api/article` | `article.createUnderParent({ googleAuthId, parentId, name })` |
| `GET /api/article/:sku` | `article.getBySku(googleAuthId, sku)` |
| `GET /api/articleIndex` | `article.listSearchIndex(googleAuthId, companyId)` |
| `GET /api/article/export.zip` | `article.listExport(googleAuthId, companyId, categoryId?)` |
| `PATCH /api/article/:sku` | `article.updateBySku(googleAuthId, sku, { name?, unitamount?, unitSi? })` |
| `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/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`.