520 lines
30 KiB
Markdown
520 lines
30 KiB
Markdown
# 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, 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, 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`.
|
||
|
||
---
|
||
|
||
## `supplier`
|
||
|
||
Suppliers are `entity` rows in the company `customergroup` named `Lieferanten` (created implicitly on first supplier). Auth via `resolveCompanyAccess`.
|
||
|
||
| Method | Signature | Returns |
|
||
| --- | --- | --- |
|
||
| `list` | `(googleAuthId, companyId)` | `{ id, name, createdAt, updatedAt }[]` |
|
||
| `create` | `(googleAuthId, companyId, { name })` | `{ id, name, createdAt, updatedAt }` |
|
||
| `update` | `(googleAuthId, companyId, id, { name })` | `{ id, name, createdAt, updatedAt }` |
|
||
| `delete` | `(googleAuthId, companyId, 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: `Company not found`, `Not authorized`, `Name required`, `Supplier not found`.
|
||
|
||
---
|
||
|
||
## `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) |
|
||
| `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 })` | `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`. 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`.
|
||
|
||
`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/supplier` | `supplier.list(googleAuthId, companyId)` |
|
||
| `POST /api/supplier` | `supplier.create(googleAuthId, companyId, { name })` |
|
||
| `PATCH /api/supplier/:id` | `supplier.update(googleAuthId, companyId, id, { name })` |
|
||
| `DELETE /api/supplier/:id` | `supplier.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/article/:sku/history` | `article.getHistoryBySku(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`.
|