brand now carries a NOT NULL company_id (unique per (company_id, name)) with brand.list/upsert/delete backed by resolveCompanyAccess. Adds companysetting key-value store (list/get/set/delete, frontend flag).
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.
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:
- Fails if
nameis taken (Company name not available) - Ensures language by
iso639(get or create) - Ensures tax rows for
taxrate,reducedtaxrate, and0→taxfreerateId - Creates root category (
parentId = null) - Creates POS
customergroup(namePOS), then inserts company withposcustomergroup_id(NOT NULL), then links the group viacustomergroup.company_id - 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:
- Fails if company missing / name taken by another company
- Ensures lang + tax rates
- 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):
{ 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 |
valueis always a string (NVARCHAR(MAX)).frontend: true→ readable by frontend routes (frontendOnly: true). Default onsetisfalse(backend-only).- With
frontendOnly: true, backend-only keys are omitted /getreturnsundefined(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 } |
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)
parentIdexists, elseParent not found- Walk ancestors to root; find
companywithroot_category_id= root companyuserfor(googleAuthId, companyId), elseNot authorized- Insert
category(next siblingsort) +categorytext(name, slugifiedslug, 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?, unitamount?, unitSi? }) |
same shape as getBySku |
setPricesBySku |
(googleAuthId, sku, [{ price, tax, minmultiplier? }]) |
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:
{
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:
{
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.
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.
// 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 }) |
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? }]) |
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
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.