seb 831bf52c14 Add POS terminal category visibility system
Add posterminal and posterminalcategory tables to control which categories
are visible on each POS terminal. The existence of a row in
posterminalcategory means the category is visible on that terminal.

Store methods:
- posterminal.list/upsert/delete - CRUD for terminals
- posterminal.setOn/setOff - toggle single category on a terminal
- posterminal.setOnAll/setOffAll - toggle all categories on a terminal
- posterminal.getCategoryPosterminalIds - internal, used by category tree

The category tree (getTreeForCompany) now includes a posterminalIds array
on each node listing which terminals have that category enabled.
2026-08-03 06:14:25 +02:00
2026-08-01 03:46:08 +02:00
u
2026-07-30 20:35:53 +02:00
u
2026-07-31 19:49:07 +02:00
u
2026-07-31 19:49:07 +02:00

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:

  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):

{ 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, token, pairingpin }[]
upsert (googleAuthId, companyId, { id?, token?, pairingpin }) { id, 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. 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 }[]

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.


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:

{
  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.

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 <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 })
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 })
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)
GET /api/posterminal?companyId= posterminal.list(googleAuthId, companyId)
PUT /api/posterminal posterminal.upsert(googleAuthId, companyId, { id?, 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)
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

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.

Description
No description provided
Readme 7.5 MiB
Languages
JavaScript 100%