# 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`. --- ## 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`. --- ## `category` | Method | Signature | Returns | | --- | --- | --- | | `createUnderParent` | `({ googleAuthId, parentId, name })` | `{ id, parentId, sort, name }` | | `rename` | `({ googleAuthId, id, name })` | `{ id, 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, fullHash, 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`. `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 }`. For `PATCH /api/category/:id`. --- ## `article` | Method | Signature | Returns | | --- | --- | --- | | `createUnderParent` | `({ googleAuthId, parentId, name })` | `{ id, parentId, name, sku }` | | `getBySku` | `(googleAuthId, sku)` | detail object (see below) | | `updateBySku` | `(googleAuthId, sku, { name?, unitamount?, unitSi? })` | same shape as `getBySku` | | `setPricesBySku` | `(googleAuthId, sku, [{ price, tax, minmultiplier? }])` | same shape as `getBySku` | | `setEansBySku` | `(googleAuthId, sku, string[])` | same shape as `getBySku` | | `setBrandIdBySku` | `(googleAuthId, sku, brandId \| null)` | same shape as `getBySku` | | `setWeightBySku` | `(googleAuthId, sku, weight)` | same shape as `getBySku` | | `setCategoryIdsBySku` | `(googleAuthId, sku, number[])` | 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` / `setPricesBySku` return: ```js { id, sku, name, slug, unitamount, weight, unit: { si, basesi, basesifactor, symbol, name }, 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 `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 the same object as `getBySku`. 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 the same object as `getBySku`. 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 the same object as `getBySku` (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 removes the article from all categories. Returns the same object as `getBySku` (whose `categoryIds` reflects the new list). Errors: `Article not found`, `Not authorized`, `Invalid categoryIds`. `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 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 })` | | `PATCH /api/category/:id` | `category.rename({ googleAuthId, id, name })` | | `DELETE /api/category/:id` | `category.delete({ googleAuthId, id })` | | `GET /api/unit?companyId=` | `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?companyId=` | `brand.list(googleAuthId, companyId)` | | `PUT /api/brand` | `brand.upsert(googleAuthId, companyId, { name, gsprpinfo? })` | | `DELETE /api/brand/:id` | `brand.delete(googleAuthId, companyId, 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?, 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`.