260 lines
11 KiB
Markdown
260 lines
11 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 }` |
|
||
| `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. Inserts company with all FKs set
|
||
6. Links creator as `companyuser` (`googleAuthId` → `company_id`)
|
||
|
||
`company.poscustomergroup_id` (optional) points at the `customergroup` used for POS pricing in `getTreeForCompany` articles.
|
||
|
||
### `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 }[]` |
|
||
|
||
---
|
||
|
||
## `category`
|
||
|
||
| Method | Signature | Returns |
|
||
| --- | --- | --- |
|
||
| `createUnderParent` | `({ googleAuthId, parentId, name })` | `{ id, parentId, sort, name }` |
|
||
| `delete` | `({ googleAuthId, id })` | `void` — blocked by FK if `categoryarticle` or child categories remain |
|
||
| `getTreeForCompany` | `(googleAuthId, companyId)` | `{ languageId, tree, articles }` |
|
||
| `getCategoryArticles` | `(googleAuthId, categoryId)` | `{ id, sku, name, slug, net, taxRate, optionsCount, articleListCount, childCount, fatherArticleId, fatherSku, thumbW, thumbH, thumbHash }[]` |
|
||
|
||
### `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)
|
||
|
||
`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`.
|
||
|
||
---
|
||
|
||
## `article`
|
||
|
||
| Method | Signature | Returns |
|
||
| --- | --- | --- |
|
||
| `createUnderParent` | `({ googleAuthId, parentId, name })` | `{ id, parentId, name, sku }` |
|
||
| `getBySku` | `(googleAuthId, sku)` | detail object (see below) |
|
||
| `updateBySku` | `(googleAuthId, sku, { name })` | same shape as `getBySku` |
|
||
| `addImage` | `(googleAuthId, sku, imageBuffer)` | image result (see below) |
|
||
| `deleteImage` | `(googleAuthId, sku, fullHash)` | `void` |
|
||
| `orderImages` | `(googleAuthId, sku, { fullHashes })` | `{ fullHashes }` |
|
||
| `delete` | `(googleAuthId, sku)` | `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:
|
||
|
||
```js
|
||
{
|
||
picId,
|
||
sort,
|
||
full: { w, h, size }, // AVIF, max 1000×1000 (stored as-is when already AVIF and within bounds)
|
||
thumb: { w, h }, // scaled AVIF, max 200×200 (fit inside)
|
||
fullHash, // random 64-char hex id (pic.hash, unique)
|
||
thumbHash, // base64 thumbhash of thumb preview
|
||
}
|
||
```
|
||
|
||
`getBySku` / `updateBySku` return:
|
||
|
||
```js
|
||
{
|
||
id, sku, name, slug,
|
||
unitamount, weight,
|
||
unit: { si },
|
||
brand: { name, thumbW, thumbH, thumbHash } | null,
|
||
categoryIds: number[],
|
||
photos: { sort, fullHash, thumbW, thumbH, thumbHash }[],
|
||
eans: string[],
|
||
prices: { minmultiplier, net, taxRate, customergroupId }[],
|
||
optionsCount, articleListCount, childCount,
|
||
fatherArticleId, fatherSku,
|
||
}
|
||
```
|
||
|
||
`getCategoryArticles` / `getTreeForCompany().articles[]` keep the flat list shape (`net`, `taxRate`, single `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` updates `articletext` (company default language) in a transaction (`name` + slugified `slug`), then returns the same object as `getBySku`. Errors: `Article not found`, `Not authorized`, `Name required`.
|
||
|
||
`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?companyId=` | `category.getTreeForCompany(googleAuthId, companyId)` |
|
||
| `POST /api/category` | `category.createUnderParent({ googleAuthId, parentId, name })` |
|
||
| `DELETE /api/category/:id` | `category.delete({ googleAuthId, id })` |
|
||
| `POST /api/article` | `article.createUnderParent({ googleAuthId, parentId, name })` |
|
||
| `GET /api/article/:sku` | `article.getBySku(googleAuthId, sku)` |
|
||
| `PATCH /api/article/:sku` | `article.updateBySku(googleAuthId, sku, { name })` |
|
||
| `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`.
|