Compare commits

...

32 Commits

Author SHA1 Message Date
seb
2a45b43f50 u 2026-11-04 06:42:57 +01:00
seb
9f61c7dee2 u 2026-11-04 06:30:45 +01:00
seb
980578e1e4 u 2026-11-04 03:30:25 +01:00
seb
ce4dc15aac u 2026-11-04 03:22:20 +01:00
seb
da785da2dd u 2026-11-03 06:20:06 +01:00
seb
24563964a6 u 2026-11-03 04:13:13 +01:00
seb
44e91a6973 u 2026-11-03 03:54:49 +01:00
seb
de2957acf1 u 2026-11-02 00:51:15 +01:00
seb
17df88ced5 u 2026-11-02 00:19:19 +01:00
seb
d3209d73c1 u 2026-11-01 21:24:28 +01:00
seb
518106ede2 u 2026-11-01 21:16:07 +01:00
seb
833cee5040 u 2026-11-01 21:11:38 +01:00
seb
aeb79c1acb u 2026-11-01 06:20:44 +01:00
seb
ff185a59dd u 2026-11-01 06:16:29 +01:00
seb
689f12ba69 u 2026-11-01 05:57:35 +01:00
seb
bf081f875d u 2026-11-01 05:38:45 +01:00
seb
8f9c941699 u 2026-11-01 05:37:15 +01:00
seb
e6c15dd053 u 2026-11-01 05:01:21 +01:00
seb
8a5a307c50 u 2026-11-01 04:58:46 +01:00
seb
43d7049a7b u 2026-11-01 04:50:07 +01:00
seb
c60b899c4d u 2026-11-01 04:33:45 +01:00
seb
e8a5322775 u 2026-11-01 04:33:01 +01:00
seb
cb30352b57 u 2026-11-01 03:33:14 +01:00
seb
5d6b31de15 u 2026-11-01 00:38:04 +01:00
seb
8827d6fc14 u 2026-11-01 00:29:29 +01:00
seb
16f70603bc u 2026-11-01 00:24:43 +01:00
seb
ce7f5aad2a u 2026-11-01 00:20:26 +01:00
seb
26adfc5aa9 u 2026-10-31 22:14:05 +01:00
seb
0480a5dcbe u 2026-10-31 21:46:36 +01:00
seb
dd5c9646b3 u 2026-10-31 21:02:53 +01:00
seb
5a0b603306 u 2026-10-31 20:37:27 +01:00
seb
72d05a34d0 u 2026-10-31 20:28:37 +01:00
551 changed files with 15920 additions and 1503 deletions

View File

@@ -17,6 +17,7 @@ CORS_ORIGINS=http://localhost:5173
# Auth — Google OAuth client ID and JWT signing secret
GOOGLE_CLIENT_ID=
JWT_SECRET=fallback_secret_please_change_in_production
# Auto-create user on first login (when false, only allowlisted or invited emails can sign in / create an account)
IMPLICIT_CREATE=false
# Database Provider — 'sqlite' or 'mssql' (defaults to 'mssql', or 'sqlite' if SQLITE_PATH is set)

View File

@@ -1,18 +1,16 @@
# syntax=docker/dockerfile:1
FROM debian:12-slim AS syslibs
RUN apt-get update && apt-get install -y --no-install-recommends \
libatomic1 libuuid1 libfontconfig1 libfribidi0 \
libatomic1 libuuid1 \
&& rm -rf /var/lib/apt/lists/*
FROM gcr.io/distroless/cc-debian12
WORKDIR /app
# Shared dynamic libraries needed by Node.js SEA and native addons (canvas, better-sqlite3)
# Shared dynamic libraries needed by Node.js SEA and native addons (better-sqlite3)
COPY --from=syslibs /usr/lib/x86_64-linux-gnu/libatomic.so* /usr/lib/x86_64-linux-gnu/
COPY --from=syslibs /usr/lib/x86_64-linux-gnu/libuuid.so* /usr/lib/x86_64-linux-gnu/
COPY --from=syslibs /usr/lib/x86_64-linux-gnu/libfontconfig.so* /usr/lib/x86_64-linux-gnu/
COPY --from=syslibs /usr/lib/x86_64-linux-gnu/libfribidi.so* /usr/lib/x86_64-linux-gnu/
# Set production defaults: port 4208
ENV RUNNING_IN_DOCKER=true \

View File

@@ -41,6 +41,11 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/agent/pair.get.md`](routes/agent/pair.get.md) | `GET /agent/pair` |
| [`routes/agent/pair.post.md`](routes/agent/pair.post.md) | `POST /agent/pair` |
| [`routes/agent/state.md`](routes/agent/state.md) | `POST /agent/state` |
| [`routes/agentprinter.md`](routes/agentprinter.md) | `GET /agentprinter` |
| [`routes/agentprinter/order.md`](routes/agentprinter/order.md) | `PUT /agentprinter/order` |
| [`routes/agentprinter/_id.delete.md`](routes/agentprinter/_id.delete.md) | `DELETE /agentprinter/{id}` |
| [`routes/agentprinter/_id.get.md`](routes/agentprinter/_id.get.md) | `GET /agentprinter/{id}` |
| [`routes/agentprinter/_id.patch.md`](routes/agentprinter/_id.patch.md) | `PATCH /agentprinter/{id}` |
| [`routes/article/create.md`](routes/article/create.md) | `POST /article` |
| [`routes/article/export.zip.md`](routes/article/export.zip.md) | `GET /article/export.zip` |
| [`routes/article/history-list.md`](routes/article/history-list.md) | `GET /article/history` |
@@ -93,6 +98,9 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` |
| [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` |
| [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` |
| [`routes/autoinvite/list.md`](routes/autoinvite/list.md) | `GET /autoinvite` |
| [`routes/autoinvite/create.md`](routes/autoinvite/create.md) | `POST /autoinvite` |
| [`routes/autoinvite/delete.md`](routes/autoinvite/delete.md) | `DELETE /autoinvite/{id}` |
| [`routes/backup.md`](routes/backup.md) | `GET /backup` |
| [`routes/backup/_id/vfs.md`](routes/backup/_id/vfs.md) | `GET /backup/{id}/vfs` |
| [`routes/backup/_id/vfs/recover.md`](routes/backup/_id/vfs/recover.md) | `POST /backup/{id}/vfs/recover` |
@@ -135,6 +143,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/companysetting/set.md`](routes/companysetting/set.md) | `PUT /company/setting/{key}` |
| [`routes/company/delete.md`](routes/company/delete.md) | `DELETE /company/{id}` |
| [`routes/company/update.md`](routes/company/update.md) | `PUT /company/{id}` |
| [`routes/companyuser/list.md`](routes/companyuser/list.md) | `GET /companyuser` |
| [`routes/companyuser/create.md`](routes/companyuser/create.md) | `POST /companyuser` |
| [`routes/companyuser/delete.md`](routes/companyuser/delete.md) | `DELETE /companyuser/{id}` |
| [`routes/companyuser/update.md`](routes/companyuser/update.md) | `PATCH /companyuser/{id}` |
| [`routes/customergroup/list.md`](routes/customergroup/list.md) | `GET /customergroup` |
| [`routes/customergroup/create.md`](routes/customergroup/create.md) | `POST /customergroup` |
| [`routes/customergroup/order.md`](routes/customergroup/order.md) | `PUT /customergroup/order` |
@@ -168,6 +180,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/image/get.md`](routes/image/get.md) | `GET /image/{fullHash}` |
| [`routes/labeltemplate/list.md`](routes/labeltemplate/list.md) | `GET /labeltemplate` |
| [`routes/labeltemplate/create.md`](routes/labeltemplate/create.md) | `POST /labeltemplate` |
| [`routes/labeltemplate/testprint.md`](routes/labeltemplate/testprint.md) | `POST /labeltemplate/testprint` |
| [`routes/labeltemplate/delete.md`](routes/labeltemplate/delete.md) | `DELETE /labeltemplate/{id}` |
| [`routes/labeltemplate/get.md`](routes/labeltemplate/get.md) | `GET /labeltemplate/{id}` |
| [`routes/labeltemplate/update.md`](routes/labeltemplate/update.md) | `PATCH /labeltemplate/{id}` |
@@ -223,14 +236,22 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/printer/list.md`](routes/printer/list.md) | `GET /printer` |
| [`routes/printer/create.md`](routes/printer/create.md) | `POST /printer` |
| [`routes/printer/order.md`](routes/printer/order.md) | `PUT /printer/order` |
| [`routes/printer/state.md`](routes/printer/state.md) | `GET /printer/state` |
| [`routes/printer/delete.md`](routes/printer/delete.md) | `DELETE /printer/{id}` |
| [`routes/printer/get.md`](routes/printer/get.md) | `GET /printer/{id}` |
| [`routes/printer/update.md`](routes/printer/update.md) | `PATCH /printer/{id}` |
| [`routes/printer/_id.put.md`](routes/printer/_id.put.md) | `PUT /printer/{id}` |
| [`routes/printer/_id/state.md`](routes/printer/_id/state.md) | `GET /printer/{id}/state` |
| [`routes/printeragent.get.md`](routes/printeragent.get.md) | `GET /printeragent` |
| [`routes/printeragent.put.md`](routes/printeragent.put.md) | `PUT /printeragent` |
| [`routes/printeragent/_id.delete.md`](routes/printeragent/_id.delete.md) | `DELETE /printeragent/{id}` |
| [`routes/printeragent/_id.patch.md`](routes/printeragent/_id.patch.md) | `PATCH /printeragent/{id}` |
| [`routes/printerjob.get.md`](routes/printerjob.get.md) | `GET /printerjob` |
| [`routes/printerjob.post.md`](routes/printerjob.post.md) | `POST /printerjob` |
| [`routes/printerjob/cancel-all.md`](routes/printerjob/cancel-all.md) | `POST /printerjob/cancel-all` |
| [`routes/printerjob/_id.delete.md`](routes/printerjob/_id.delete.md) | `DELETE /printerjob/{id}` |
| [`routes/printerjob/_id.get.md`](routes/printerjob/_id.get.md) | `GET /printerjob/{id}` |
| [`routes/printerjob/_id/cancel.md`](routes/printerjob/_id/cancel.md) | `POST /printerjob/{id}/cancel` |
| [`routes/propertyname.get.md`](routes/propertyname.get.md) | `GET /propertyname` |
| [`routes/propertyname.post.md`](routes/propertyname.post.md) | `POST /propertyname` |
| [`routes/propertyname/order.md`](routes/propertyname/order.md) | `PUT /propertyname/order` |
@@ -324,6 +345,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/warehouse/get.md`](routes/warehouse/get.md) | `GET /warehouse/{id}` |
| [`routes/warehouse/update.md`](routes/warehouse/update.md) | `PATCH /warehouse/{id}` |
| [`routes/warehouse/_id.put.md`](routes/warehouse/_id.put.md) | `PUT /warehouse/{id}` |
| [`routes/zpljob.md`](routes/zpljob.md) | `POST /zpljob` |
Route overview: [`routes.md`](routes.md) | All routes: [`routes-all.md`](routes-all.md).
@@ -333,13 +355,16 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| Signal | Emitted by |
|--------|------------|
| `agentprinter` | `PUT /agentprinter/order`, `DELETE /agentprinter/{id}`, `PATCH /agentprinter/{id}` |
| `article-<sku>` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight`, `POST /stock/adjust` |
| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}`, `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` |
| `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}`, `DELETE /attributevalue/{id}/image`, `POST /attributevalue/{id}/image` |
| `autoinvite` | `POST /autoinvite`, `DELETE /autoinvite/{id}` |
| `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` |
| `carrier` | `POST /carrier`, `PUT /carrier/order`, `DELETE /carrier/{id}`, `PATCH /carrier/{id}` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image`, `POST /category/{id}/image/generate` |
| `company` | `PUT /auth/selected-company`, `POST /company`, `DELETE /company/{id}`, `PUT /company/{id}` |
| `companyuser` | `POST /companyuser`, `DELETE /companyuser/{id}`, `PATCH /companyuser/{id}` |
| `delivery` | `DELETE /delivery/{id}/file/{fileId}`, `POST /delivery/{id}/pdf`, `POST /stock/accept` |
| `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` |
| `ledger` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 292 API endpoints across all modules.
Overview of all 314 API endpoints across all modules.
### Notation
@@ -9,45 +9,48 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T19[], externalNumber?: str|null, createdAt?: str, shipping?: T21|null, shippings?: T21[], payment?: T30|null, payments?: T30[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T11[] }`
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T19[], shippings: T21[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T11[], state?: str }`
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T19[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T11[] }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T22[], files: T11[], externalNumber?: str|null, createdAt?: str, ledger?: T37 }`
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T20[], externalNumber?: str|null, createdAt?: str, shipping?: T23|null, shippings?: T23[], payment?: T33|null, payments?: T33[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T10[] }`
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T20[], shippings: T23[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T10[], state?: str }`
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T10[] }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T24[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
- `T5` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T6|null }`
- `T6` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T12, totalSizeBytes: int, durationMs: int }`
- `T6` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T11, totalSizeBytes: int, durationMs: int }`
- `T7` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, clientversion?: str|null, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T8` = `{ id: int, name: str, companyId?: int, token?: str|null, pairingpin?: str|null, pairedAt?: str|null, clientserial?: str|null, clientmodel?: str|null, clientstate?: str|null, agentversion?: str|null, lastseenAt?: str|null, updatedAt?: str, sort?: int }`
- `T9` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T39|null, incoming?: { date: str, amount: num }[] }`
- `T10` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T23[] }, thumb?: str|null }`
- `T11` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T12` = `{ vfs: T38, storage: T38, database: T32 }`
- `T13` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T14` = `{ id: int, company_id: int, prompt: str, max_itr: int, cron: str, created_at: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, lastrun_at?: str|null }`
- `T15` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T16` = `{ id: int, name: str, sort: int, exclusive: bool, values: T28[], articleCount?: int }`
- `T17` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }`
- `T18` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T19` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
- `T20` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
- `T21` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T22` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T23` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T24` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T25` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T26` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T27` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T28` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T29` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T30` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T31` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
- `T32` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T33` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T34` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T35` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T36` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T37` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T38` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T39` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }`
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] }, thumb?: str|null }`
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T11` = `{ vfs: T41, storage: T41, database: T35 }`
- `T12` = `{ id: int, name: str, companyId?: int, token?: str|null, pairingpin?: str|null, pairedAt?: str|null, agentversion?: str|null, lastseenAt?: str|null, updatedAt?: str, sort?: int, enabled?: bool }`
- `T13` = `{ id: int, printeragentId: int, type: str, enabled: bool, sort: int, model?: str|null, serial?: str|null, open?: bool, pause?: bool, error?: bool, online?: bool, labelLength?: int|null }`
- `T14` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T15` = `{ id: int, company_id: int, prompt: str, max_itr: int, cron: str, created_at: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, lastrun_at?: str|null }`
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[], articleCount?: int }`
- `T18` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }`
- `T19` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str }`
- `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
- `T21` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
- `T22` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }`
- `T23` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T24` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T25` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T26` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T29` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T30` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T31` = `{ id: int, googleAuthId: int, email: str, role: str, picture?: str|null }`
- `T32` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T33` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T34` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
- `T35` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T36` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T37` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T38` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T40` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T41` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T42` = `{ net: num, taxId: int, taxRate?: num|null }`
### Routes
@@ -86,7 +89,25 @@ _none_
- Returns: `200 { id: int, name: str, companyId: int, token: str, pairedAt: str, clientserial: str|null, clientmodel: str|null, agentversion: str|null, sort: int }`
#### Post agent state — `POST /agent/state`
- Params: `body: { state?: any }`
- Returns: `200 { success: bool, agent: T8 }`
- Returns: `200 { success: bool, agent: T12 }`
### Section: /agentprinter
#### List agent printers — `GET /agentprinter`
- Params: `query: printeragentId?: int`
- Returns: `200 { agentprinters: T13[] }`
#### Reorder agent printers — `PUT /agentprinter/order`
- Params: `body: { agentprinterIds: int[], printeragentId?: int }`
- Returns: `200 { agentprinterIds: int[] }`
#### Delete agent printer — `DELETE /agentprinter/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get agent printer — `GET /agentprinter/{id}`
- Params: `id: int`
- Returns: `200 { agentprinter: T13 }`
#### Update agent printer — `PATCH /agentprinter/{id}`
- Params: `id: int, body: { enabled?: bool, sort?: int }`
- Returns: `200 { agentprinter: T13 }`
### Section: /article
@@ -104,7 +125,7 @@ _none_
- Returns: `204 void`
#### Get article by SKU — `GET /article/{sku}`
- Params: `sku: str`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T27, brand: T26|null, categoryIds: int[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], prices: { minmultiplier: num, net: num, taxRate: num, customergroupId: int }[], suppliers: T9[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T29, brand: T28|null, categoryIds: int[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], prices: { minmultiplier: num, net: num, taxRate: num, customergroupId: int }[], suppliers: T8[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }`
#### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Returns: `204 void`
@@ -162,11 +183,11 @@ _none_
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `204 void`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
#### Replace POS prices by SKU (net > gross) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, taxRate?: num, minmultiplier?: num }[] }`
- Returns: `204 void`
#### Print article label — `POST /article/{sku}/print`
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
- Params: `sku: str, body: { templateId: int, agentprinterId: int, quantity: int }`
- Returns: `200 { success: bool }`
#### List article properties — `GET /article/{sku}/property`
- Params: `sku: str`
@@ -179,10 +200,10 @@ _none_
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T9[] }`
- Returns: `200 { suppliers: T8[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `201 { supplier: T9 }`
- Returns: `201 { supplier: T8 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -191,7 +212,7 @@ _none_
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `200 { supplier: T9 }`
- Returns: `200 { supplier: T8 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `204 void`
@@ -203,19 +224,19 @@ _none_
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T16[] }`
- Returns: `200 { attributenames: T17[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T16 }`
- Returns: `201 { attributename: T17 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T28[] }`
- Returns: `200 { values: T30[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T28 }`
- Returns: `201 { attributevalue: T30 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -224,7 +245,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T16 }`
- Returns: `200 { attributename: T17 }`
### Section: /attributevalue
@@ -233,28 +254,40 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T28 }`
- Returns: `200 { attributevalue: T30 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T34[] } }`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T34[] } }`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
### Section: /auth
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T33 }`
- Returns: `200 { message: str, user: T36 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: T33, googleClientId: str, schemaVersion: int, vhost: bool, vhostSettings: str }`
- Returns: `200 { user: T36, googleClientId: str, schemaVersion: int, vhost: bool, vhostSettings: str }`
#### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }`
- Returns: `200 { user: T33 }`
- Returns: `200 { user: T36 }`
### Section: /autoinvite
#### List auto-invitations — `GET /autoinvite`
_none_
- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }`
#### Create auto-invitation — `POST /autoinvite`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { autoinvite: { id: int, email: str, role: str } }`
#### Delete auto-invitation — `DELETE /autoinvite/{id}`
- Params: `id: int`
- Returns: `204 void`
### Section: /backup
@@ -275,7 +308,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
#### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T34[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T37[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -284,19 +317,19 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T34[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
#### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T34[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
### Section: /carrier
#### List carriers — `GET /carrier`
_none_
- Returns: `200 { carriers: T31[] }`
- Returns: `200 { carriers: T34[] }`
#### Create carrier — `POST /carrier`
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `201 { carrier: T31 }`
- Returns: `201 { carrier: T34 }`
#### Reorder carriers — `PUT /carrier/order`
- Params: `body: { carrierIds: int[] }`
- Returns: `200 { carrierIds: int[] }`
@@ -305,10 +338,10 @@ _none_
- Returns: `204 void`
#### Get carrier by ID — `GET /carrier/{id}`
- Params: `id: int`
- Returns: `200 { carrier: T31 }`
- Returns: `200 { carrier: T34 }`
#### Update carrier — `PATCH /carrier/{id}`
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `200 { carrier: T31 }`
- Returns: `200 { carrier: T34 }`
### Section: /category
@@ -332,7 +365,7 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: int, query: includeChildren?: bool`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null, brand?: T26|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null, brand?: T28|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -362,10 +395,10 @@ _none_
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: T18[] }`
- Returns: `200 { companies: T19[] }`
#### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T18, user: T33 }`
- Returns: `201 { company: T19, user: T36 }`
#### Export company catalog as zip archive (SSE) — `GET /company/export`
- Params: `query: stream?: str`
- Returns: `200 SSE`
@@ -395,7 +428,22 @@ _none_
- Returns: `204 void`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T18 }`
- Returns: `200 { company: T19 }`
### Section: /companyuser
#### List company users — `GET /companyuser`
_none_
- Returns: `200 { companyusers: T31[] }`
#### Add an existing user to the company — `POST /companyuser`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { companyuser: T31 }`
#### Remove a user from the company — `DELETE /companyuser/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Change a company user's role — `PATCH /companyuser/{id}`
- Params: `id: int, body: { role: str }`
- Returns: `200 { companyuser: T31 }`
### Section: /customergroup
@@ -419,7 +467,7 @@ _none_
#### List deliveries — `GET /delivery`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T37 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T40 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, supplierId: int, lines: { amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 { delivery: T4 }`
@@ -440,52 +488,52 @@ _none_
- Returns: `200 str`
#### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T11, fileUrl: str }`
- Returns: `201 { attachment: T10, fileUrl: str }`
### Section: /entity
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T25[] }`
- Returns: `200 { entities: T27[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 { entity: T25 }`
- Returns: `201 { entity: T27 }`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: int`
- Returns: `200 { addresses: T13[] }`
- Returns: `200 { addresses: T14[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `201 { address: T13 }`
- Returns: `201 { address: T14 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `200 { address: T13 }`
- Returns: `200 { address: T14 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T35[] }`
- Returns: `200 { contacts: T38[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T35 }`
- Returns: `201 { contact: T38 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T35 }`
- Returns: `200 { contact: T38 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 { entity: T25 }`
- Returns: `200 { entity: T27 }`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 { entity: T25 }`
- Returns: `200 { entity: T27 }`
### Section: /events
@@ -503,8 +551,13 @@ _none_
- `ledger`: `{}`
- `attributename`: `{}`
- `propertyname`: `{}`
- `companyuser`: `{}`
- `autoinvite`: `{}`
- `labeltemplate`: `{}`
- `printer`: `{}`
- `printeragent`: `{}`
- `agentprinter`: `{ printers } or {}`
- `printerjob`: `{ job } or { jobId, state }`
- `warehouse`: `{}`
- `supplier`: `{}`
- `vfs`: `{}`
@@ -533,35 +586,38 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T23[] } }`
- Returns: `201 { labeltemplate: T10 }`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] } }`
- Returns: `201 { labeltemplate: T9 }`
#### Test print label template — `POST /labeltemplate/testprint`
- Params: `body: { agentprinterId: int, templateId?: int, objects?: any[], width?: int, height?: int, dpi?: int, sku?: str, quantity?: int }`
- Returns: `200 { success: bool }`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int`
- Returns: `200 { labeltemplate: T10 }`
- Returns: `200 { labeltemplate: T9 }`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T23[] } }`
- Returns: `200 { labeltemplate: T10 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T25[] } }`
- Returns: `200 { labeltemplate: T9 }`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T23[] } }`
- Returns: `200 { labeltemplate: T10 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T25[] } }`
- Returns: `200 { labeltemplate: T9 }`
### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 { ledger: T15[] }`
- Returns: `200 { ledger: T16[] }`
#### Query company-wide ledger history — `GET /ledger/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { ledger: { id: int, date: str, transaction_type: str, transaction_text: str, sku?: str|null, name?: str|null, amount?: num }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 { ledger: T15[] }`
- Returns: `200 { ledger: T16[] }`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 { ledger: T15[] }`
- Returns: `200 { ledger: T16[] }`
### Section: /mcp
@@ -591,16 +647,16 @@ _none_
#### List conversations — `GET /modelconversation`
_none_
- Returns: `200 { conversations: T17[] }`
- Returns: `200 { conversations: T18[] }`
#### Create conversation — `POST /modelconversation`
- Params: `body: { provider: str, model: str }`
- Returns: `201 { conversation: T17 }`
- Returns: `201 { conversation: T18 }`
#### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}`
- Params: `id: int`
- Returns: `200 { conversation: T17 }`
- Returns: `200 { conversation: T18 }`
#### List conversation messages — `GET /modelconversation/{id}/message`
- Params: `id: int`
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
@@ -641,7 +697,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T1[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, entityId: int, lines: T20[], externalNumber?: str|null }`
- Params: `body: { number: str, date: str, entityId: int, lines: T21[], externalNumber?: str|null }`
- Returns: `201 { order: T1 }`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
@@ -650,7 +706,7 @@ _none_
- Params: `id: int`
- Returns: `200 { order: T1 }`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T20[] }`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
- Returns: `200 { order: T1 }`
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
- Params: `id: int, fileId: int`
@@ -660,13 +716,13 @@ _none_
- Returns: `200 str`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T30[] }`
- Returns: `200 { payments: T33[] }`
#### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T30 }`
- Returns: `201 { payment: T33 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T11, fileUrl: str }`
- Returns: `201 { attachment: T10, fileUrl: str }`
#### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int`
- Returns: `200 { shipments: T2[] }`
@@ -675,10 +731,10 @@ _none_
- Returns: `201 { shipment: T2 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T21[] }`
- Returns: `200 { shippings: T23[] }`
#### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
- Returns: `201 { shipping: T21 }`
- Returns: `201 { shipping: T23 }`
### Section: /package
@@ -723,49 +779,76 @@ _none_
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T24[] }`
- Returns: `200 { printers: T26[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 { printer: T24 }`
- Returns: `201 { printer: T26 }`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
#### Get live printer states — `GET /printer/state`
- Params: `query: serial?: str, agentId?: int`
- Returns: `200 { printers: {}[] }`
#### Delete printer — `DELETE /printer/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 { printer: T24 }`
- Returns: `200 { printer: T26 }`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 { printer: T24 }`
- Returns: `200 { printer: T26 }`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 { printer: T24 }`
- Returns: `200 { printer: T26 }`
#### Get live state for a printer — `GET /printer/{id}/state`
- Params: `id: str`
- Returns: `200 { printer: {} }`
### Section: /printeragent
#### List printer agents — `GET /printeragent`
_none_
- Returns: `200 { printeragents: T8[] }`
- Returns: `200 { printeragents: T12[] }`
#### Create printer agent — `PUT /printeragent`
- Params: `body: { name: str, sort?: int }`
- Returns: `200 { printeragent: T8 }`
- Params: `body: { name: str, sort?: int, enabled?: bool }`
- Returns: `200 { printeragent: T12 }`
#### Delete printer agent — `DELETE /printeragent/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update printer agent — `PATCH /printeragent/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 { printeragent: T8 }`
- Params: `id: int, body: { name?: str, sort?: int, enabled?: bool }`
- Returns: `200 { printeragent: T12 }`
### Section: /printerjob
#### List printer jobs — `GET /printerjob`
- Params: `query: agentprinterId?: int, state?: str`
- Returns: `200 { printerjobs: T22[] }`
#### Create print job — `POST /printerjob`
- Params: `body: { agentprinterId: int, data: str, type?: str }`
- Returns: `201 { printerjob: T22 }`
#### Cancel all pending and processing printer jobs — `POST /printerjob/cancel-all`
- Params: `body?: { agentprinterId?: int }`
- Returns: `200 { success: bool, cancelledCount: int, jobs: T22[] }`
#### Cancel and delete printer job — `DELETE /printerjob/{id}`
- Params: `id: int`
- Returns: `200 { success: bool, deletedJob: T22 }`
#### Get printer job — `GET /printerjob/{id}`
- Params: `id: int`
- Returns: `200 { printerjob: T22 }`
#### Cancel printer job — `POST /printerjob/{id}/cancel`
- Params: `id: int`
- Returns: `200 { success: bool, printerjob: T22 }`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T29[] }`
- Returns: `200 { propertynames: T32[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T29 }`
- Returns: `201 { propertyname: T32 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -774,7 +857,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T29 }`
- Returns: `200 { propertyname: T32 }`
### Section: /public
@@ -786,7 +869,7 @@ _none_
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num|null, taxRate: num|null, gross: num|null, trackStock: bool, brand: { id: int|null, name: str }, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
#### Get public article detail — `GET /public/{companyId}/articles/{sku}`
- Params: `companyId: int, sku: str`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: T27|null, brand: T26|null, price: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }, prices: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }[], categories: { id: int, name: str, slug: str }[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], isParent: bool, parentSku: str|null, variants: { id: int, sku: str, sort: int, name: str, slug: str, attributeValueId: int|null, attributeValue: str|null, unitamount: num|null, weight: num|null, trackStock: bool }[] } }`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: T29|null, brand: T28|null, price: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }, prices: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }[], categories: { id: int, name: str, slug: str }[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], isParent: bool, parentSku: str|null, variants: { id: int, sku: str, sort: int, name: str, slug: str, attributeValueId: int|null, attributeValue: str|null, unitamount: num|null, weight: num|null, trackStock: bool }[] } }`
#### List public categories — `GET /public/{companyId}/categories`
- Params: `companyId: int`
- Returns: `200 { categories: { id: int, parentId: int|null, sort: int, name: str, slug: str, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
@@ -797,7 +880,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { quotes: T3[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T20[], entityId?: int|null, externalNumber?: str|null }`
- Params: `body: { number: str, date: str, lines: T21[], entityId?: int|null, externalNumber?: str|null }`
- Returns: `201 { quote: T3 }`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
@@ -806,7 +889,7 @@ _none_
- Params: `id: int`
- Returns: `200 { quote: T3 }`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T20[] }`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
- Returns: `200 { quote: T3 }`
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
- Params: `id: int, fileId: int`
@@ -815,11 +898,11 @@ _none_
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### Create order from quote — `POST /quote/{id}/order`
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T20[] }`
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
- Returns: `201 { order: T1 }`
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T11, fileUrl: str }`
- Returns: `201 { attachment: T10, fileUrl: str }`
### Section: /retoure
@@ -906,13 +989,13 @@ _none_
- Returns: `200 str`
#### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T11, fileUrl: str }`
- Returns: `201 { attachment: T10, fileUrl: str }`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T21[] }`
- Returns: `200 { shippings: T23[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
- Returns: `201 { shipping: T21 }`
- Returns: `201 { shipping: T23 }`
### Section: /shipping
@@ -921,7 +1004,7 @@ _none_
- Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrierId?: int, warehouseId?: int }`
- Returns: `200 { shipping: T21 }`
- Returns: `200 { shipping: T23 }`
#### List packages of a shipping — `GET /shipping/{id}/package`
- Params: `id: int`
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
@@ -942,7 +1025,7 @@ _none_
- Returns: `200`
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T15[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T16[] }`
#### Ship shipment manifest item(s) — `POST /stock/ship`
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
- Returns: `200`
@@ -951,40 +1034,40 @@ _none_
#### List subagent schedules — `GET /subagentschedule`
_none_
- Returns: `200 { schedules: T14[] }`
- Returns: `200 { schedules: T15[] }`
#### Create subagent schedule — `POST /subagentschedule`
- Params: `body: { prompt: str, cron: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int }`
- Returns: `201 { schedule: T14 }`
- Returns: `201 { schedule: T15 }`
#### Delete subagent schedule — `DELETE /subagentschedule/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get subagent schedule by ID — `GET /subagentschedule/{id}`
- Params: `id: int`
- Returns: `200 { schedule: T14 }`
- Returns: `200 { schedule: T15 }`
#### Update subagent schedule — `PUT /subagentschedule/{id}`
- Params: `id: int, body: { prompt?: str, cron?: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int, lastrun_at?: str|null }`
- Returns: `200 { schedule: T14 }`
- Returns: `200 { schedule: T15 }`
### Section: /supplier
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T36[] }`
- Returns: `200 { suppliers: T39[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T36 }`
- Returns: `201 { supplier: T39 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T36 }`
- Returns: `200 { supplier: T39 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: str`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T39|null, incoming?: { date: str, amount: num }[] }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T39|null, incoming?: { date: str, amount: num }[] }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }[] }`
### Section: /thumb
@@ -999,10 +1082,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T27[] }`
- Returns: `200 { units: T29[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T27 }`
- Returns: `200 { unit: T29 }`
### Section: /v1
@@ -1093,3 +1176,9 @@ _none_
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 { warehouse: { id: int, name: str, sort: int } }`
### Section: /zpljob
#### Create ZPL print job — `POST /zpljob`
- Params: `body: { agentprinterId: int, data: str, copiescount?: int }`
- Returns: `201 { printerjob: T22 }`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 212 API endpoints across all modules.
Overview of all 196 API endpoints across all modules.
### Notation
@@ -9,54 +9,37 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T13[], externalNumber?: str|null, createdAt?: str, shipping?: T15|null, shippings?: T15[], payment?: T23|null, payments?: T23[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T8[] }`
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T13[], shippings: T15[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T8[], state?: str }`
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T13[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T8[] }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T16[], files: T8[], externalNumber?: str|null, createdAt?: str, ledger?: T28 }`
- `T5` = `{ id: int, name: str, companyId?: int, token?: str|null, pairingpin?: str|null, pairedAt?: str|null, clientserial?: str|null, clientmodel?: str|null, clientstate?: str|null, agentversion?: str|null, lastseenAt?: str|null, updatedAt?: str, sort?: int }`
- `T6` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T29|null, incoming?: { date: str, amount: num }[] }`
- `T7` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T17[] }, thumb?: str|null }`
- `T8` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T9` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T10` = `{ id: int, company_id: int, prompt: str, max_itr: int, cron: str, created_at: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, lastrun_at?: str|null }`
- `T11` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T12` = `{ id: int, name: str, sort: int, exclusive: bool, values: T21[], articleCount?: int }`
- `T13` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
- `T14` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
- `T15` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T16` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T17` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T18` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T19` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T20` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T21` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T22` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T23` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T24` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
- `T25` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T26` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T27` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T28` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T29` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T11[], externalNumber?: str|null, createdAt?: str, shipping?: T13|null, shippings?: T13[], payment?: T21|null, payments?: T21[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T7[] }`
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T11[], shippings: T13[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T7[], state?: str }`
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T11[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T7[] }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T14[], files: T7[], externalNumber?: str|null, createdAt?: str, ledger?: T26 }`
- `T5` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T27|null, incoming?: { date: str, amount: num }[] }`
- `T6` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T15[] }, thumb?: str|null }`
- `T7` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T8` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T9` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T10` = `{ id: int, name: str, sort: int, exclusive: bool, values: T18[], articleCount?: int }`
- `T11` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
- `T12` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
- `T13` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T14` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T15` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T16` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T17` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T18` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T19` = `{ id: int, googleAuthId: int, email: str, role: str, picture?: str|null }`
- `T20` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T21` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T22` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
- `T23` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T24` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T25` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T26` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T27` = `{ net: num, taxId: int, taxRate?: num|null }`
### Routes
### Section: /agent
#### Printer agent SSE stream — `GET /agent/events`
- Params: `query: token?: str, agentversion?: str`
- Returns: `200 SSE`
#### Pair printer agent via query parameters — `GET /agent/pair`
- Params: `query: authCode?: str, pairingpin?: str, serial?: str, model?: str, version?: str`
- Returns: `200`
#### Pair printer agent — `POST /agent/pair`
- Params: `body: { pairingpin: str, clientserial?: str, clientmodel?: str, agentversion?: str }`
- Returns: `200 { id: int, name: str, companyId: int, token: str, pairedAt: str, clientserial: str|null, clientmodel: str|null, agentversion: str|null, sort: int }`
#### Post agent state — `POST /agent/state`
- Params: `body: { state?: any }`
- Returns: `200 { success: bool, agent: T5 }`
### Section: /article
#### Create article in category — `POST /article`
@@ -70,7 +53,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `204 void`
#### Get article by SKU — `GET /article/{sku}`
- Params: `sku: str`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T20, brand: { name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }, categoryIds: int[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], prices: { minmultiplier: num, net: num, taxRate: num, customergroupId: int }[], suppliers: T6[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T17, brand: { name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }, categoryIds: int[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], prices: { minmultiplier: num, net: num, taxRate: num, customergroupId: int }[], suppliers: T5[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }`
#### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Returns: `204 void`
@@ -125,11 +108,11 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `204 void`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
#### Replace POS prices by SKU (net > gross) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, taxRate?: num, minmultiplier?: num }[] }`
- Returns: `204 void`
#### Print article label — `POST /article/{sku}/print`
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
- Params: `sku: str, body: { templateId: int, agentprinterId: int, quantity: int }`
- Returns: `200 { success: bool }`
#### List article properties — `GET /article/{sku}/property`
- Params: `sku: str`
@@ -142,10 +125,10 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T6[] }`
- Returns: `200 { suppliers: T5[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `201 { supplier: T6 }`
- Returns: `201 { supplier: T5 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -154,7 +137,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `200 { supplier: T6 }`
- Returns: `200 { supplier: T5 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `204 void`
@@ -166,19 +149,19 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T12[] }`
- Returns: `200 { attributenames: T10[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T12 }`
- Returns: `201 { attributename: T10 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T21[] }`
- Returns: `200 { values: T18[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T21 }`
- Returns: `201 { attributevalue: T18 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -187,7 +170,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T12 }`
- Returns: `200 { attributename: T10 }`
### Section: /attributevalue
@@ -196,10 +179,22 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T21 }`
- Returns: `200 { attributevalue: T18 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T25[] } }`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T23[] } }`
### Section: /autoinvite
#### List auto-invitations — `GET /autoinvite`
_none_
- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }`
#### Create auto-invitation — `POST /autoinvite`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { autoinvite: { id: int, email: str, role: str } }`
#### Delete auto-invitation — `DELETE /autoinvite/{id}`
- Params: `id: int`
- Returns: `204 void`
### Section: /brand
@@ -208,7 +203,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
#### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T25[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T23[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -217,16 +212,16 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T25[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T23[] } }`
### Section: /carrier
#### List carriers — `GET /carrier`
_none_
- Returns: `200 { carriers: T24[] }`
- Returns: `200 { carriers: T22[] }`
#### Create carrier — `POST /carrier`
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `201 { carrier: T24 }`
- Returns: `201 { carrier: T22 }`
#### Reorder carriers — `PUT /carrier/order`
- Params: `body: { carrierIds: int[] }`
- Returns: `200 { carrierIds: int[] }`
@@ -235,10 +230,10 @@ _none_
- Returns: `204 void`
#### Get carrier by ID — `GET /carrier/{id}`
- Params: `id: int`
- Returns: `200 { carrier: T24 }`
- Returns: `200 { carrier: T22 }`
#### Update carrier — `PATCH /carrier/{id}`
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `200 { carrier: T24 }`
- Returns: `200 { carrier: T22 }`
### Section: /category
@@ -286,7 +281,7 @@ _none_
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }[] }`
- Returns: `200 { companies: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str }[] }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
@@ -307,7 +302,22 @@ _none_
- Returns: `200 { setting: { key: str, value: str|null } }`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num } }`
- Returns: `200 { company: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str } }`
### Section: /companyuser
#### List company users — `GET /companyuser`
_none_
- Returns: `200 { companyusers: T19[] }`
#### Add an existing user to the company — `POST /companyuser`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { companyuser: T19 }`
#### Remove a user from the company — `DELETE /companyuser/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Change a company user's role — `PATCH /companyuser/{id}`
- Params: `id: int, body: { role: str }`
- Returns: `200 { companyuser: T19 }`
### Section: /customergroup
@@ -331,7 +341,7 @@ _none_
#### List deliveries — `GET /delivery`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T28 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T26 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, supplierId: int, lines: { amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 { delivery: T4 }`
@@ -349,58 +359,52 @@ _none_
- Returns: `204 void`
#### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T8, fileUrl: str }`
- Returns: `201 { attachment: T7, fileUrl: str }`
### Section: /entity
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T19[] }`
- Returns: `200 { entities: T16[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 { entity: T19 }`
- Returns: `201 { entity: T16 }`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: int`
- Returns: `200 { addresses: T9[] }`
- Returns: `200 { addresses: T8[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `201 { address: T9 }`
- Returns: `201 { address: T8 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `200 { address: T9 }`
- Returns: `200 { address: T8 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T26[] }`
- Returns: `200 { contacts: T24[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T26 }`
- Returns: `201 { contact: T24 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T26 }`
- Returns: `200 { contact: T24 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 { entity: T19 }`
- Returns: `200 { entity: T16 }`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 { entity: T19 }`
### Section: /health
#### Health latency history — `GET /health/latency`
- Params: `query: range?: str`
- Returns: `200 { raw: { timestamp: int, value: num }[], avg: { timestamp: int, value: num }[], max: { timestamp: int, value: num }[] }`
- Returns: `200 { entity: T16 }`
### Section: /labeltemplate
@@ -408,35 +412,38 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T17[] } }`
- Returns: `201 { labeltemplate: T7 }`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T15[] } }`
- Returns: `201 { labeltemplate: T6 }`
#### Test print label template — `POST /labeltemplate/testprint`
- Params: `body: { agentprinterId: int, templateId?: int, objects?: any[], width?: int, height?: int, dpi?: int, sku?: str, quantity?: int }`
- Returns: `200 { success: bool }`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int`
- Returns: `200 { labeltemplate: T7 }`
- Returns: `200 { labeltemplate: T6 }`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T17[] } }`
- Returns: `200 { labeltemplate: T7 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T15[] } }`
- Returns: `200 { labeltemplate: T6 }`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T17[] } }`
- Returns: `200 { labeltemplate: T7 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T15[] } }`
- Returns: `200 { labeltemplate: T6 }`
### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 { ledger: T11[] }`
- Returns: `200 { ledger: T9[] }`
#### Query company-wide ledger history — `GET /ledger/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { ledger: { id: int, date: str, transaction_type: str, transaction_text: str, sku?: str|null, name?: str|null, amount?: num }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 { ledger: T11[] }`
- Returns: `200 { ledger: T9[] }`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 { ledger: T11[] }`
- Returns: `200 { ledger: T9[] }`
### Section: /number
@@ -453,7 +460,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T1[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, entityId: int, lines: T14[], externalNumber?: str|null }`
- Params: `body: { number: str, date: str, entityId: int, lines: T12[], externalNumber?: str|null }`
- Returns: `201 { order: T1 }`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
@@ -462,20 +469,20 @@ _none_
- Params: `id: int`
- Returns: `200 { order: T1 }`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T14[] }`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T12[] }`
- Returns: `200 { order: T1 }`
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
- Params: `id: int, fileId: int`
- Returns: `204 void`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T23[] }`
- Returns: `200 { payments: T21[] }`
#### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T23 }`
- Returns: `201 { payment: T21 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T8, fileUrl: str }`
- Returns: `201 { attachment: T7, fileUrl: str }`
#### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int`
- Returns: `200 { shipments: T2[] }`
@@ -484,10 +491,10 @@ _none_
- Returns: `201 { shipment: T2 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T15[] }`
- Returns: `200 { shippings: T13[] }`
#### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
- Returns: `201 { shipping: T15 }`
- Returns: `201 { shipping: T13 }`
### Section: /package
@@ -501,53 +508,14 @@ _none_
- Params: `id: int`
- Returns: `204 void`
### Section: /printer
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T18[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 { printer: T18 }`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
#### Delete printer — `DELETE /printer/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 { printer: T18 }`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 { printer: T18 }`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 { printer: T18 }`
### Section: /printeragent
#### List printer agents — `GET /printeragent`
_none_
- Returns: `200 { printeragents: T5[] }`
#### Create printer agent — `PUT /printeragent`
- Params: `body: { name: str, sort?: int }`
- Returns: `200 { printeragent: T5 }`
#### Delete printer agent — `DELETE /printeragent/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update printer agent — `PATCH /printeragent/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 { printeragent: T5 }`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T22[] }`
- Returns: `200 { propertynames: T20[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T22 }`
- Returns: `201 { propertyname: T20 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -556,7 +524,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T22 }`
- Returns: `200 { propertyname: T20 }`
### Section: /quote
@@ -564,7 +532,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { quotes: T3[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T14[], entityId?: int|null, externalNumber?: str|null }`
- Params: `body: { number: str, date: str, lines: T12[], entityId?: int|null, externalNumber?: str|null }`
- Returns: `201 { quote: T3 }`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
@@ -573,17 +541,17 @@ _none_
- Params: `id: int`
- Returns: `200 { quote: T3 }`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T14[] }`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T12[] }`
- Returns: `200 { quote: T3 }`
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
- Params: `id: int, fileId: int`
- Returns: `204 void`
#### Create order from quote — `POST /quote/{id}/order`
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T14[] }`
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T12[] }`
- Returns: `201 { order: T1 }`
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T8, fileUrl: str }`
- Returns: `201 { attachment: T7, fileUrl: str }`
### Section: /retoure
@@ -652,13 +620,13 @@ _none_
- Returns: `204 void`
#### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T8, fileUrl: str }`
- Returns: `201 { attachment: T7, fileUrl: str }`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T15[] }`
- Returns: `200 { shippings: T13[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
- Returns: `201 { shipping: T15 }`
- Returns: `201 { shipping: T13 }`
### Section: /shipping
@@ -667,7 +635,7 @@ _none_
- Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrierId?: int, warehouseId?: int }`
- Returns: `200 { shipping: T15 }`
- Returns: `200 { shipping: T13 }`
#### List packages of a shipping — `GET /shipping/{id}/package`
- Params: `id: int`
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
@@ -675,12 +643,6 @@ _none_
- Params: `id: int, body: { entityaddressId: int, type: str, state: str, details?: str }`
- Returns: `201 { package: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null } }`
### Section: /smallthumb
#### Small thumb image by hash — `GET /smallthumb/{fullHash}`
- Params: `fullHash: str`
- Returns: `200 avif`
### Section: /stock
#### Accept delivery manifest item(s) — `POST /stock/accept`
@@ -688,49 +650,31 @@ _none_
- Returns: `200`
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T11[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T9[] }`
#### Ship shipment manifest item(s) — `POST /stock/ship`
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
- Returns: `200`
### Section: /subagentschedule
#### List subagent schedules — `GET /subagentschedule`
_none_
- Returns: `200 { schedules: T10[] }`
#### Create subagent schedule — `POST /subagentschedule`
- Params: `body: { prompt: str, cron: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int }`
- Returns: `201 { schedule: T10 }`
#### Delete subagent schedule — `DELETE /subagentschedule/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get subagent schedule by ID — `GET /subagentschedule/{id}`
- Params: `id: int`
- Returns: `200 { schedule: T10 }`
#### Update subagent schedule — `PUT /subagentschedule/{id}`
- Params: `id: int, body: { prompt?: str, cron?: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int, lastrun_at?: str|null }`
- Returns: `200 { schedule: T10 }`
### Section: /supplier
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T27[] }`
- Returns: `200 { suppliers: T25[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T27 }`
- Returns: `201 { supplier: T25 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T27 }`
- Returns: `200 { supplier: T25 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: str`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T29|null, incoming?: { date: str, amount: num }[] }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T27|null, incoming?: { date: str, amount: num }[] }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T29|null, incoming?: { date: str, amount: num }[] }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T27|null, incoming?: { date: str, amount: num }[] }[] }`
### Section: /unit
@@ -739,19 +683,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T20[] }`
- Returns: `200 { units: T17[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T20 }`
### Section: /v1
#### JTL-POS deleted entity synchronization — `GET /v1/deletedentity`
- Params: `query: lastChangedDeletedEntity?: str, limit?: int`
- Returns: `200 { entityId: str, entityType: int, lastChanged: int }[]`
#### JTL-POS order upload — `POST /v1/order`
- Params: `query: mandantId?: str, body: { orders?: {}[] }`
- Returns: `200 { status: str, externalOrderId: str, message: str }[]`
- Returns: `200 { unit: T17 }`
### Section: /warehouse

View File

@@ -33,13 +33,11 @@ State recorded
"token": "…",
"pairingpin": "…",
"pairedAt": "…",
"clientserial": "…",
"clientmodel": "…",
"clientstate": "…",
"agentversion": "…",
"lastseenAt": "…",
"updatedAt": "…",
"sort": 1
"sort": 1,
"enabled": true
}
}
```

View File

@@ -0,0 +1,51 @@
# `GET /agentprinter`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List agent printers
Lists agent printers for the authenticated user's selected company,
optionally filtered by printer agent ID.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `printeragentId` | no | integer | Optional printer agent ID filter |
## Response `200`
List of agent printers
```json
{
"agentprinters": [
{
"id": 1,
"printeragentId": 1,
"type": "…",
"model": "…",
"serial": "…",
"enabled": true,
"sort": 1,
"open": true,
"pause": true,
"error": true,
"online": true,
"labelLength": 1
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,35 @@
# `DELETE /agentprinter/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete agent printer
Deletes an agent printer from the selected company.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `agentprinter` (see [`GET /events`](../events.md)).
## Response `204`
Agent printer deleted
_No content_
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Agent printer not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,48 @@
# `GET /agentprinter/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get agent printer
Retrieves a single agent printer by ID.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Response `200`
Agent printer details
```json
{
"agentprinter": {
"id": 1,
"printeragentId": 1,
"type": "…",
"model": "…",
"serial": "…",
"enabled": true,
"sort": 1,
"open": true,
"pause": true,
"error": true,
"online": true,
"labelLength": 1
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Agent printer not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,67 @@
# `PATCH /agentprinter/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Update agent printer
Updates enabled and/or display order (sort) of an agent printer.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `enabled` | no | boolean | |
| `sort` | no | integer | |
```json
{
"enabled": true,
"sort": 1
}
```
## Signals
On success, emits SSE signal: `agentprinter` (see [`GET /events`](../events.md)).
## Response `200`
Updated agent printer
```json
{
"agentprinter": {
"id": 1,
"printeragentId": 1,
"type": "…",
"model": "…",
"serial": "…",
"enabled": true,
"sort": 1,
"open": true,
"pause": true,
"error": true,
"online": true,
"labelLength": 1
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid input or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Agent printer not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,51 @@
# `PUT /agentprinter/order`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Reorder agent printers
Reorders agent printers for a specific printer agent within the selected company.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `printeragentId` | no | integer | Optional agent id. If omitted, reorders all printers in the company. |
| `agentprinterIds` | yes | integer[] | |
```json
{
"printeragentId": 1,
"agentprinterIds": [
1
]
}
```
## Signals
On success, emits SSE signal: `agentprinter` (see [`GET /events`](../events.md)).
## Response `200`
Applied order
```json
{
"agentprinterIds": [
1
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid order or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -17,13 +17,13 @@ Prints a label for the article using a label template.
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `templateId` | yes | integer | |
| `printerId` | yes | integer | |
| `agentprinterId` | yes | integer | |
| `quantity` | yes | integer | |
```json
{
"templateId": 1,
"printerId": 1,
"agentprinterId": 1,
"quantity": 1
}
```

View File

@@ -2,11 +2,11 @@
Protected. Requires a valid session cookie (`credentials: 'include'`).
Replace POS prices by SKU (net > gross; price=net, tax=taxRate %)
Replace POS prices by SKU (net > gross)
Replace-all prices for the company POS customer group.
`net` takes precedence over `price` (legacy alias for net) and `gross`.
`tax` and `taxRate` both specify the tax rate percentage (e.g. 19 for 19%), not monetary tax.
`net` takes precedence over `gross`.
`taxRate` specifies the tax rate percentage (e.g. 19 for 19%), not monetary tax.
When `gross` is provided, net price is computed as `gross / (1 + taxRate / 100)`.
Prices are strictly scoped to the company's designated POS customer group (customergroupId returned on GET).
Empty `prices` clears all POS prices. Returns the same shape as GET by SKU.
@@ -23,11 +23,9 @@ Empty `prices` clears all POS prices. Returns the same shape as GET by SKU.
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `prices` | yes | object[] | |
| `prices[].net` | no | number | Net price (excl. VAT). Either `net`, `gross`, or legacy `price` is required. |
| `prices[].net` | no | number | Net price (excl. VAT). Either `net` or `gross` is required. |
| `prices[].gross` | no | number | Gross price (incl. VAT). Net is computed as `gross / (1 + taxRate / 100)`. |
| `prices[].price` | no | number | Legacy alias for `net`. |
| `prices[].taxRate` | no | number | Tax rate percentage (e.g. 19 for 19%, 7 for 7%, 0 for 0%). |
| `prices[].tax` | no | number | Alias for `taxRate` percentage. |
| `prices[].minmultiplier` | no | number | Defaults to 1; must be unique per request. |
```json

View File

@@ -4,7 +4,8 @@ Public. No cookie required.
Login with Google ID token
Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`),
Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`
or when the email has a pending auto-invitation),
stores Google `sub` and profile image bytes, and sets an HTTP-only `token` cookie.
`user.picture` is the profile image as base64 (same as GET /auth/me).
@@ -43,7 +44,7 @@ Login successful; `token` cookie set
|--------|-------------|
| 400 | Missing token or Google client not configured — `{"error":"…"}` |
| 401 | Invalid Google token or account — `{"error":"…"}` |
| 403 | Email not on allowlist — `{"error":"…"}` |
| 403 | Email not on allowlist or invited — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---

View File

@@ -43,6 +43,7 @@ Updated user
|--------|-------------|
| 400 | Invalid companyId — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (the user is not a member of this company) — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---

View File

@@ -0,0 +1,55 @@
# `POST /autoinvite`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create auto-invitation
Registers an email for automatic membership. When a user with this email registers,
the invitation is removed from the list and the user is added to the company with the given role.
Emails that are already registered are rejected (409); use `POST /companyuser` for those. Admin only.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `email` | yes | string | |
| `role` | no | string | |
```json
{
"email": "newhire@example.com",
"role": "user"
}
```
## Signals
On success, emits SSE signal: `autoinvite` (see [`GET /events`](../events.md)).
## Response `201`
Created invitation
```json
{
"autoinvite": {
"id": 1,
"email": "…",
"role": "admin"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected, invalid email or invalid role — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
| 409 | User already registered or invitation already exists — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,36 @@
# `DELETE /autoinvite/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete auto-invitation
Deletes a pending auto-invitation of the selected company. Admin only.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `autoinvite` (see [`GET /events`](../events.md)).
## Response `204`
Deleted
_No content_
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid id or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
| 404 | Invitation not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,36 @@
# `GET /autoinvite`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List auto-invitations
Lists pending auto-invitations of the selected company. Admin only.
## Response `200`
Pending invitations
```json
{
"autoinvites": [
{
"id": 1,
"email": "…",
"role": "admin",
"createdAt": "…"
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -42,7 +42,8 @@ Created; selected company updated
"iso639": "…",
"taxrate": 1,
"reducedtaxrate": 1,
"taxfreerate": 1
"taxfreerate": 1,
"role": "admin"
},
"user": {
"id": 1,
@@ -59,6 +60,7 @@ Created; selected company updated
|--------|-------------|
| 400 | Validation error — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (invited users cannot create companies when implicit create is disabled) — `{"error":"…"}` |
---

View File

@@ -21,7 +21,8 @@ Company list
"iso639": "…",
"taxrate": 1,
"reducedtaxrate": 1,
"taxfreerate": 1
"taxfreerate": 1,
"role": "admin"
}
]
}

View File

@@ -48,7 +48,8 @@ Updated
"iso639": "…",
"taxrate": 1,
"reducedtaxrate": 1,
"taxfreerate": 1
"taxfreerate": 1,
"role": "admin"
}
}
```

View File

@@ -0,0 +1,58 @@
# `POST /companyuser`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Add an existing user to the company
Adds an existing (registered) user, identified by email, to the selected company. Admin only.
The added user can then switch to this company via `PUT /auth/selected-company`.
For emails that are not registered yet use `POST /autoinvite`.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `email` | yes | string | |
| `role` | no | string | |
```json
{
"email": "colleague@example.com",
"role": "user"
}
```
## Signals
On success, emits SSE signal: `companyuser` (see [`GET /events`](../events.md)).
## Response `201`
Created company user
```json
{
"companyuser": {
"id": 1,
"googleAuthId": 1,
"email": "…",
"picture": "…",
"role": "admin"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected, invalid email or invalid role — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
| 404 | No registered user with this email — `{"error":"…"}` |
| 409 | User already in company — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,40 @@
# `DELETE /companyuser/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Remove a user from the company
Removes a member from the selected company. History entries stay but lose their author link;
if the removed user had this company selected, their selection is cleared.
The last admin cannot be removed. Admin only.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `companyuser` (see [`GET /events`](../events.md)).
## Response `204`
Removed
_No content_
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid id or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
| 404 | Company user not found — `{"error":"…"}` |
| 409 | Cannot remove the last admin — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,37 @@
# `GET /companyuser`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List company users
Lists the members of the selected company with their role. Admin only.
## Response `200`
Company members
```json
{
"companyusers": [
{
"id": 1,
"googleAuthId": 1,
"email": "…",
"picture": "…",
"role": "admin"
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,59 @@
# `PATCH /companyuser/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Change a company user's role
Changes the role of a member of the selected company. The last admin cannot be demoted. Admin only.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `role` | yes | string | |
```json
{
"role": "admin"
}
```
## Signals
On success, emits SSE signal: `companyuser` (see [`GET /events`](../events.md)).
## Response `200`
Updated company user
```json
{
"companyuser": {
"id": 1,
"googleAuthId": 1,
"email": "…",
"picture": "…",
"role": "admin"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid id, role or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
| 404 | Company user not found — `{"error":"…"}` |
| 409 | Cannot demote the last admin — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -46,6 +46,10 @@ emits `{ sender, brandId }`.
| `propertyname` | `{}` | `POST /propertyname`, `PUT /propertyname/order`, `PATCH /propertyname/{id}`, `DELETE /propertyname/{id}` |
| `labeltemplate` | `{}` | `POST /labeltemplate`, `PUT /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `DELETE /labeltemplate/{id}` |
| `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` |
| `printeragent` | `{}` | `PUT /printeragent`, `PATCH /printeragent/{id}`, `DELETE /printeragent/{id}`, printer agent connect/disconnect/state |
| `agentprinter` | `{ printers }` or `{}` | `PATCH /agentprinter/{id}`, `PUT /agentprinter/order`, `DELETE /agentprinter/{id}`, agent state updates / printer discovery |
| `companyuser` | `{}` | `POST /companyuser`, `PATCH /companyuser/{id}`, `DELETE /companyuser/{id}` |
| `autoinvite` | `{}` | `POST /autoinvite`, `DELETE /autoinvite/{id}` |
| `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` |
| `supplier` | `{}` | `POST /supplier`, `PATCH /supplier/{id}`, `DELETE /supplier/{id}` |
| `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` |

View File

@@ -0,0 +1,57 @@
# `POST /labeltemplate/testprint`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Test print label template
Dispatches a test print job for a label template or unsaved canvas objects to a specified ZPL printer.
Placeholders in text and barcodes are interpolated using a sample article or the specified article SKU.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `agentprinterId` | yes | integer | Agent printer ID |
| `templateId` | no | integer | |
| `objects` | no | any[] | |
| `width` | no | integer | |
| `height` | no | integer | |
| `dpi` | no | integer | |
| `sku` | no | string | |
| `quantity` | no | integer | |
```json
{
"agentprinterId": 1,
"templateId": 1,
"objects": [],
"width": 1,
"height": 1,
"dpi": 1,
"sku": "…",
"quantity": 1
}
```
## Response `200`
Print job created
```json
{
"success": true
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid parameters |
| 404 | Printer or template not found |
| 500 | Server error |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,37 @@
# `GET /printer/{id}/state`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get live state for a printer
Retrieves live operational status for a single printer by configured printer ID,
serial number, or device identifier for the authenticated user's selected company.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | string | Configured printer ID, serial number, or device identifier |
## Response `200`
Live printer state
```json
{
"printer": {}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected or invalid ID — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Printer not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,39 @@
# `GET /printer/state`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get live printer states
Retrieves real-time operational status and telemetry for all discovered printers
reported by active printer agents for the authenticated user's selected company.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `serial` | no | string | Optional serial number filter |
| `agentId` | no | integer | Optional agent ID filter |
## Response `200`
Live printer states
```json
{
"printers": [
{}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -20,13 +20,11 @@ Printer agent list
"token": "…",
"pairingpin": "…",
"pairedAt": "…",
"clientserial": "…",
"clientmodel": "…",
"clientstate": "…",
"agentversion": "…",
"lastseenAt": "…",
"updatedAt": "…",
"sort": 1
"sort": 1,
"enabled": true
}
]
}

View File

@@ -14,6 +14,7 @@ The pairingpin is randomly generated server-side and returned in the response.
|-------|----------|------|-------------|
| `name` | yes | string | |
| `sort` | no | integer | |
| `enabled` | no | boolean | |
```json
{
@@ -34,13 +35,11 @@ Created printer agent with generated pairingpin
"token": "…",
"pairingpin": "…",
"pairedAt": "…",
"clientserial": "…",
"clientmodel": "…",
"clientstate": "…",
"agentversion": "…",
"lastseenAt": "…",
"updatedAt": "…",
"sort": 1
"sort": 1,
"enabled": true
}
}
```

View File

@@ -18,11 +18,13 @@ Updates name and/or display order (sort) of a printer agent.
|-------|----------|------|-------------|
| `name` | no | string | |
| `sort` | no | integer | |
| `enabled` | no | boolean | |
```json
{
"name": "…",
"sort": 1
"sort": 1,
"enabled": true
}
```
@@ -39,13 +41,11 @@ Updated printer agent
"token": "…",
"pairingpin": "…",
"pairedAt": "…",
"clientserial": "…",
"clientmodel": "…",
"clientstate": "…",
"agentversion": "…",
"lastseenAt": "…",
"updatedAt": "…",
"sort": 1
"sort": 1,
"enabled": true
}
}
```

View File

@@ -0,0 +1,46 @@
# `GET /printerjob`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List printer jobs
Lists printer jobs for the selected company, optionally filtered by agent printer or state.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `agentprinterId` | no | integer | Optional agent printer ID filter |
| `state` | no | string | Optional job state filter |
## Response `200`
List of printer jobs
```json
{
"printerjobs": [
{
"id": 1,
"agentprinterId": 1,
"printeragentId": 1,
"type": "zpl",
"state": "pending",
"data": "…",
"createdAt": "2026-08-10T00:00:00.000Z",
"completedAt": "2026-08-10T00:00:00.000Z"
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,46 @@
# `POST /printerjob`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create print job
Creates a new print job for an agent printer. Notifies the agent in real time.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `agentprinterId` | yes | integer | |
| `type` | no | string | |
| `data` | yes | string | |
```json
{
"agentprinterId": 1,
"type": "zpl",
"data": "…"
}
```
## Response `201`
Printer job created
```json
{
"printerjob": {
"id": 1,
"agentprinterId": 1,
"printeragentId": 1,
"type": "zpl",
"state": "pending",
"data": "…",
"createdAt": "2026-08-10T00:00:00.000Z",
"completedAt": "2026-08-10T00:00:00.000Z"
}
}
```
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,39 @@
# `DELETE /printerjob/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Cancel and delete printer job
Cancels and deletes a print job. If the job was pending or processing, sends a cancellation
event to the connected printer agent before deletion.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Response `200`
Job cancelled and deleted
```json
{
"success": true,
"deletedJob": {
"id": 1,
"agentprinterId": 1,
"printeragentId": 1,
"type": "zpl",
"state": "pending",
"data": "…",
"createdAt": "2026-08-10T00:00:00.000Z",
"completedAt": "2026-08-10T00:00:00.000Z"
}
}
```
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,44 @@
# `GET /printerjob/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get printer job
Retrieves details of a specific printer job by ID.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Response `200`
Printer job details
```json
{
"printerjob": {
"id": 1,
"agentprinterId": 1,
"printeragentId": 1,
"type": "zpl",
"state": "pending",
"data": "…",
"createdAt": "2026-08-10T00:00:00.000Z",
"completedAt": "2026-08-10T00:00:00.000Z"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Printer job not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,37 @@
# `POST /printerjob/{id}/cancel`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Cancel printer job
Marks a print job as cancelled and notifies the connected agent in real time.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Response `200`
Printer job cancelled
```json
{
"success": true,
"printerjob": {
"id": 1,
"agentprinterId": 1,
"printeragentId": 1,
"type": "zpl",
"state": "pending",
"data": "…",
"createdAt": "2026-08-10T00:00:00.000Z",
"completedAt": "2026-08-10T00:00:00.000Z"
}
}
```
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,48 @@
# `POST /printerjob/cancel-all`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Cancel all pending and processing printer jobs
Cancels all queued or in-flight print jobs for the selected company, optionally filtered by agentprinterId.
Notifies connected agents in real time.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `agentprinterId` | no | integer | |
```json
{
"agentprinterId": 1
}
```
## Response `200`
All matching jobs cancelled
```json
{
"success": true,
"cancelledCount": 1,
"jobs": [
{
"id": 1,
"agentprinterId": 1,
"printeragentId": 1,
"type": "zpl",
"state": "pending",
"data": "…",
"createdAt": "2026-08-10T00:00:00.000Z",
"completedAt": "2026-08-10T00:00:00.000Z"
}
]
}
```
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

56
docs/api/routes/zpljob.md Normal file
View File

@@ -0,0 +1,56 @@
# `POST /zpljob`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create ZPL print job
Issues a new ZPL print job to an agent printer. If copiescount > 1, the ZPL quantity command (^PQ)
is automatically applied. The connected printer agent receives an immediate real-time event.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `agentprinterId` | yes | integer | Target agent printer ID |
| `copiescount` | no | integer | Optional copy count (defaults to 1) |
| `data` | yes | string | Raw ZPL string |
```json
{
"agentprinterId": 1,
"copiescount": 2,
"data": "^XA^FO50,50^ADN,36,20^FDHello Zebra^FS^XZ"
}
```
## Response `201`
Print job created
```json
{
"printerjob": {
"id": 1,
"agentprinterId": 1,
"printeragentId": 1,
"type": "zpl",
"state": "pending",
"data": "…",
"createdAt": "2026-08-10T00:00:00.000Z",
"completedAt": "2026-08-10T00:00:00.000Z"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid input or missing fields — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Agent printer not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

874
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@
"pdfconvert-wasm": "^0.3.0",
"pdfkit": "^0.20.2",
"react": "^19.3.1",
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor#v20.0.0",
"selfsigned": "^5.5.0",
"skelehash-wasm": "^0.1.1",
"swagger-ui-dist": "^5.33.3",
@@ -57,8 +57,7 @@
"swagger-jsdoc": "^6.3.0"
},
"allowScripts": {
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true,
"canvas@3.2.3": true,
"react-canvas-label-editor@20.0.0": true,
"better-sqlite3@13.0.3": true
},
"overrides": {

View File

@@ -39,13 +39,16 @@ const OMITTED_ROUTES = new Set([
'POST /serper/image',
'POST /serper/images',
'GET /shipment/{id}/file/{fileId}',
'GET /smallthumb/{fullHash}',
'GET /thumb/{fullHash}',
'GET /v1/category',
'GET /v1/cimage',
'GET /v1/client',
'GET /v1/customer',
'GET /v1/customergroup',
'GET /v1/deletedentity',
'GET /v1/init',
'POST /v1/order',
'GET /v1/pimage',
'GET /v1/product',
]);
@@ -81,6 +84,36 @@ function isRouteOmitted(method, apiPath) {
if (apiPath === '/auth' || apiPath.startsWith('/auth/')) {
return true;
}
if (apiPath === '/agent' || apiPath.startsWith('/agent/')) {
return true;
}
if (apiPath === '/agentprinter' || apiPath.startsWith('/agentprinter/')) {
return true;
}
if (apiPath === '/printer' || apiPath.startsWith('/printer/')) {
return true;
}
if (apiPath === '/printeragent' || apiPath.startsWith('/printeragent/')) {
return true;
}
if (apiPath === '/printerjob' || apiPath.startsWith('/printerjob/')) {
return true;
}
if (apiPath === '/smallthumb' || apiPath.startsWith('/smallthumb/')) {
return true;
}
if (apiPath === '/v1' || apiPath.startsWith('/v1/')) {
return true;
}
if (apiPath === '/health' || apiPath.startsWith('/health/')) {
return true;
}
if (apiPath === '/subagentschedule' || apiPath.startsWith('/subagentschedule/')) {
return true;
}
if (apiPath === '/zpljob' || apiPath.startsWith('/zpljob/')) {
return true;
}
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
}

View File

@@ -1,8 +1,23 @@
import express from 'express';
import crypto from 'node:crypto';
import * as db from '../../db/printeragent.js';
import * as agentprinterDb from '../../db/agentprinter.js';
import * as printerjobDb from '../../db/printerjob.js';
import { agentAuthMiddleware, extractAgentVersion } from '../middleware/agentAuth.js';
import { emit } from '../sse.js';
import { createLogger } from '../../util/logger.js';
import {
enrichAgentWithState,
getAgentState,
setAgentState,
} from './stateStore.js';
import {
updateAgentPrinters,
removeAgentPrinters,
getCompanyPrinters,
} from '../../services/companyPrinterService.js';
const log = createLogger('agent');
export const agentRouter = express.Router();
@@ -188,6 +203,41 @@ async function handlePair(req, res) {
agentRouter.post('/pair', handlePair);
agentRouter.get('/pair', handlePair);
const activeAgentEvents = new Map();
/**
* Notifies a connected agent of a new or cancelled printer job.
* @param {number} agentId
* @param {'created'|'cancelled'} action
* @param {object} job
* @returns {boolean} True if delivered to at least one active agent connection.
*/
export function notifyAgentJob(agentId, action, job) {
const aId = Number(agentId);
const conns = activeAgentEvents.get(aId);
const count = conns ? conns.size : 0;
log.info(`notifyAgentJob: agentId=${aId} action=${action} jobId=${job?.id} activeConnections=${count}`);
if (!conns || conns.size === 0) {
log.warn(`notifyAgentJob: No active SSE connection for agent ${aId} (jobId=${job?.id}); agent must poll/reconnect`);
return false;
}
const payload = JSON.stringify({
action,
jobId: job.id,
agentprinterId: job.agentprinterId,
type: job.type,
state: job.state,
createdAt: job.createdAt,
});
for (const clientRes of conns) {
if (!clientRes.writableEnded) {
clientRes.write(`event: job\ndata: ${payload}\n\n`);
clientRes.flush?.();
}
}
return true;
}
/**
* @openapi
* /agent/events:
@@ -224,7 +274,7 @@ agentRouter.get('/pair', handlePair);
agentRouter.get('/events', agentAuthMiddleware, async (req, res) => {
const version = extractAgentVersion(req);
if (version) {
db.touchLastseen(req.agent.id, version).catch(() => {});
db.touchLastseen(req.agent.id, version).catch(() => { });
}
res.setHeader('Content-Type', 'text/event-stream');
@@ -233,16 +283,47 @@ agentRouter.get('/events', agentAuthMiddleware, async (req, res) => {
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
const agentVersion = version || req.agent?.agentversion;
const versionSuffix = agentVersion ? ` v${agentVersion}` : '';
const agentName = req.agent?.name || 'unnamed';
log.info(`Agent connected: id=${req.agent.id} ("${agentName}")${versionSuffix}`);
if (!getAgentState(req.agent.id)) {
setAgentState(req.agent.id, 'online');
}
const aId = Number(req.agent.id);
if (!activeAgentEvents.has(aId)) {
activeAgentEvents.set(aId, new Set());
}
activeAgentEvents.get(aId).add(res);
res.write(`event: connected\ndata: ${JSON.stringify({ agentId: req.agent.id, name: req.agent.name })}\n\n`);
const keepAlive = setInterval(() => {
if (res.writableEnded) return;
res.write(': heartbeat\n\n');
db.touchLastseen(req.agent.id).catch(() => {});
db.touchLastseen(req.agent.id).catch(() => { });
}, 15000);
let closed = false;
const cleanup = () => {
if (closed) return;
closed = true;
clearInterval(keepAlive);
const conns = activeAgentEvents.get(aId);
if (conns) {
conns.delete(res);
if (conns.size === 0) activeAgentEvents.delete(aId);
}
log.info(`Agent disconnected: id=${req.agent.id} ("${agentName}")`);
setAgentState(req.agent.id, 'offline');
if (req.agent?.companyId) {
removeAgentPrinters(req.agent.companyId, req.agent.id);
emit('printeragent', { companyId: req.agent.companyId });
}
};
req.on('close', cleanup);
@@ -286,30 +367,154 @@ agentRouter.get('/events', agentAuthMiddleware, async (req, res) => {
*/
async function handleState(req, res) {
try {
const rawState = req.body?.state !== undefined ? req.body.state : (req.body?.clientstate !== undefined ? req.body.clientstate : req.body);
const stateStr = rawState == null ? null : (typeof rawState === 'string' ? rawState : JSON.stringify(rawState));
const updated = await db.updateState(req.agent.id, stateStr);
const rawState = req.body?.state;
setAgentState(req.agent.id, rawState);
const version = extractAgentVersion(req);
if (version) {
await db.touchLastseen(req.agent.id, version);
} else {
await db.touchLastseen(req.agent.id);
}
if (updated.companyId) {
emit('printeragent', { companyId: updated.companyId });
const rawPrinters = Array.isArray(rawState?.printers) ? rawState.printers : null;
let anyAgentPrinterCreated = false;
if (rawPrinters && rawPrinters.length > 0) {
for (const p of rawPrinters) {
try {
const result = await agentprinterDb.findOrCreate(req.agent.id, {
type: p.type || 'zpl',
model: p.model || null,
serial: p.serial || null,
});
if (result?.created) {
anyAgentPrinterCreated = true;
}
if (result?.agentprinter) {
p.agentprinterId = result.agentprinter.id;
p.enabled = result.agentprinter.enabled;
p.sort = result.agentprinter.sort;
}
} catch (err) {
log.warn(`Failed to record agentprinter for agent ${req.agent.id}:`, err);
}
}
}
if (rawState?.job?.id && rawState?.job?.state) {
try {
await printerjobDb.updateState(
{ agentId: req.agent.id },
rawState.job.id,
rawState.job.state
);
} catch (jobErr) {
log.debug(`Agent reported job state update for ${rawState.job.id}:`, jobErr?.message);
}
}
if (rawPrinters && req.agent.companyId) {
updateAgentPrinters(req.agent.companyId, req.agent.id, req.agent.name, rawPrinters);
} else if (anyAgentPrinterCreated && req.agent.companyId) {
emit('agentprinter', { companyId: req.agent.companyId });
}
if (req.agent.companyId) {
emit('printeragent', { companyId: req.agent.companyId });
}
const agent = enrichAgentWithState({
...req.agent,
agentversion: version || req.agent.agentversion,
lastseenAt: new Date().toISOString(),
});
return res.status(200).json({
success: true,
agent: updated,
agent,
});
} catch (err) {
console.error('Agent post state failed:', err);
log.error('Agent post state failed:', err);
return res.status(500).json({ error: 'Internal server error' });
}
}
agentRouter.post('/state', agentAuthMiddleware, handleState);
agentRouter.put('/state', agentAuthMiddleware, handleState);
agentRouter.get('/state', agentAuthMiddleware, (req, res) => {
const memoryState = getAgentState(req.agent.id);
const companyPrinters = req.agent.companyId ? getCompanyPrinters(req.agent.companyId) : [];
const agentPrinters = companyPrinters.filter((p) => p.agentId === req.agent.id);
res.status(200).json({
state: memoryState ?? null,
printers: agentPrinters,
});
});
// #region Agent Jobs
agentRouter.get('/jobs', agentAuthMiddleware, async (req, res) => {
try {
const jobs = await printerjobDb.list({ agentId: req.agent.id }, req.query);
res.status(200).json({ jobs });
} catch (err) {
log.error('Agent get jobs failed:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
agentRouter.get('/jobs/:id', agentAuthMiddleware, async (req, res) => {
try {
const job = await printerjobDb.getById({ agentId: req.agent.id }, req.params.id);
if (!job) {
return res.status(404).json({ error: 'Printerjob not found' });
}
res.status(200).json({ job });
} catch (err) {
log.error('Agent get job failed:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
agentRouter.post('/jobs/:id/processing', agentAuthMiddleware, async (req, res) => {
try {
const job = await printerjobDb.updateState({ agentId: req.agent.id }, req.params.id, 'processing');
res.status(200).json({ success: true, job });
} catch (err) {
if (err.message === 'Printerjob not found') return res.status(404).json({ error: err.message });
log.error('Agent set job processing failed:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
agentRouter.post('/jobs/:id/completed', agentAuthMiddleware, async (req, res) => {
try {
const job = await printerjobDb.updateState({ agentId: req.agent.id }, req.params.id, 'completed');
res.status(200).json({ success: true, job });
} catch (err) {
if (err.message === 'Printerjob not found') return res.status(404).json({ error: err.message });
log.error('Agent set job completed failed:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
agentRouter.patch('/jobs/:id', agentAuthMiddleware, async (req, res) => {
const state = req.body?.state;
if (!state) {
return res.status(400).json({ error: 'state is required' });
}
try {
const job = await printerjobDb.updateState({ agentId: req.agent.id }, req.params.id, state);
res.status(200).json({ success: true, job });
} catch (err) {
if (err.message === 'Printerjob not found') return res.status(404).json({ error: err.message });
if (err.message?.startsWith('Invalid printerjob state')) return res.status(400).json({ error: err.message });
log.error('Agent patch job failed:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// #endregion

View File

@@ -0,0 +1,75 @@
/**
* In-memory state store for connected printer agents.
* Keeps transient agent telemetry out of the database.
*/
/** @type {Map<number, any>} */
const agentStates = new Map();
/**
* Sets the current telemetry/state for an agent.
* @param {number} agentId
* @param {any} state
*/
export function setAgentState(agentId, state) {
if (agentId == null) return;
agentStates.set(Number(agentId), state);
}
/**
* Gets the raw in-memory state for an agent.
* @param {number} agentId
* @returns {any}
*/
export function getAgentState(agentId) {
if (agentId == null) return undefined;
return agentStates.get(Number(agentId));
}
/**
* Formats a state value into a concise clientstate string suitable for display (e.g. 'online', 'offline').
* @param {any} state
* @returns {string|null}
*/
export function formatClientState(state) {
if (state == null) return null;
if (typeof state === 'string') return state;
if (typeof state === 'object') {
if (typeof state.status === 'string' && state.status.trim()) {
return state.status.trim();
}
if (typeof state.state === 'string' && state.state.trim()) {
return state.state.trim();
}
}
return JSON.stringify(state);
}
/**
* Removes in-memory state for an agent (e.g. when deleted).
* @param {number} agentId
*/
export function removeAgentState(agentId) {
if (agentId == null) return;
agentStates.delete(Number(agentId));
}
/**
* Enriches a printeragent object with the in-memory state.
* @template {object} T
* @param {T} agent
* @returns {T}
*/
export function enrichAgentWithState(agent) {
if (!agent || typeof agent !== 'object') return agent;
const memoryState = getAgentState(agent.id);
if (memoryState !== undefined) {
return {
...agent,
state: memoryState,
clientstate: formatClientState(memoryState),
};
}
return agent;
}

View File

@@ -106,7 +106,7 @@ function corsMiddleware(req, res, next) {
const reqHeaders = req.headers['access-control-request-headers'];
res.setHeader(
'Access-Control-Allow-Headers',
reqHeaders || 'Content-Type, Authorization, x-agent-token, x-auth-token, authtoken, auth-token, token, x-token, agentversion, clientversion, version'
reqHeaders || 'Content-Type, Authorization, x-agent-token, agentversion'
);
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
if (req.method === 'OPTIONS') {
@@ -352,7 +352,27 @@ export async function runSelfTest() {
apiLog.warn(`WASM image pipeline warning: ${err.message}`);
}
// 5. Clean up store connection
// 5. Test WASM label rasterizer
try {
const { renderThumb } = await import('../services/labelThumb.js');
const thumb = await renderThumb({
width: 300,
height: 150,
objects: [
{ type: 'textbox', text: 'SELFTEST', x: 10, y: 10, font: 'outfit' },
{ type: 'barcode', format: 'EAN13', code: '4012345678901', x: 10, y: 40, scale: 2 },
],
});
if (!thumb || thumb.length === 0) {
throw new Error('Empty thumbnail rendered');
}
apiLog.info('WASM label rasterizer: OK');
} catch (err) {
apiLog.error(`WASM label rasterizer FAILED: ${err.message}`);
throw err;
}
// 6. Clean up store connection
try {
await closeStore();
} catch {

View File

@@ -10,16 +10,12 @@ export function extractAgentToken(req) {
return trimmed;
}
for (const key of ['x-agent-token', 'x-auth-token', 'authtoken', 'auth-token', 'token', 'x-token']) {
const val = req.headers[key];
if (typeof val === 'string' && val.trim()) {
return val.trim();
}
if (typeof req.headers['x-agent-token'] === 'string' && req.headers['x-agent-token'].trim()) {
return req.headers['x-agent-token'].trim();
}
const queryToken = req.query?.authToken || req.query?.token;
if (typeof queryToken === 'string' && queryToken.trim()) {
return queryToken.trim();
if (typeof req.query?.token === 'string' && req.query.token.trim()) {
return req.query.token.trim();
}
return '';
@@ -27,13 +23,8 @@ export function extractAgentToken(req) {
export function extractAgentVersion(req) {
const raw = req.headers['agentversion'] ||
req.headers['agent-version'] ||
req.headers['clientversion'] ||
req.headers['version'] ||
req.query?.agentversion ||
req.query?.version ||
req.body?.agentversion ||
req.body?.version ||
null;
return raw ? String(raw).trim() : null;
}

View File

@@ -63,7 +63,7 @@ export const authMiddleware = async (req, res, next) => {
return next();
}
if (req.path.startsWith('/api/agent')) {
if (req.path === '/api/agent' || req.path.startsWith('/api/agent/')) {
return next();
}

View File

@@ -59,6 +59,39 @@
* type: number
* taxfreerate:
* type: number
* role:
* type: string
* enum: [admin, user]
* description: The caller's role in this company
* CompanyUser:
* type: object
* required: [id, googleAuthId, email, role]
* properties:
* id:
* type: integer
* googleAuthId:
* type: integer
* email:
* type: string
* picture:
* type: string
* nullable: true
* role:
* type: string
* enum: [admin, user]
* AutoInvite:
* type: object
* required: [id, email, role, createdAt]
* properties:
* id:
* type: integer
* email:
* type: string
* role:
* type: string
* enum: [admin, user]
* createdAt:
* type: string
* SuccessResponse:
* type: object
* required: [ok]
@@ -1735,15 +1768,6 @@
* pairedAt:
* type: string
* nullable: true
* clientserial:
* type: string
* nullable: true
* clientmodel:
* type: string
* nullable: true
* clientstate:
* type: string
* nullable: true
* agentversion:
* type: string
* nullable: true
@@ -1756,6 +1780,86 @@
* type: integer
* description: Display order (ascending)
* default: 0
* enabled:
* type: boolean
* default: true
* Agentprinter:
* type: object
* required: [id, printeragentId, type, enabled, sort]
* properties:
* id:
* type: integer
* printeragentId:
* type: integer
* type:
* type: string
* description: Printer type ("zpl", "esc/pos", etc.)
* model:
* type: string
* nullable: true
* serial:
* type: string
* nullable: true
* enabled:
* type: boolean
* default: true
* sort:
* type: integer
* description: Display order (ascending)
* default: 0
* open:
* type: boolean
* description: Whether the printer head/cover is open
* default: false
* pause:
* type: boolean
* description: Whether the printer is paused
* default: false
* error:
* type: boolean
* description: Whether the printer is in an error state
* default: false
* online:
* type: boolean
* description: Whether the printer is online and reachable
* default: false
* labelLength:
* type: integer
* nullable: true
* description: Discovered label length in dots/pixels reported by the printer
* default: null
* Printerjob:
* type: object
* required: [id, agentprinterId, type, state, data, createdAt]
* properties:
* id:
* type: integer
* agentprinterId:
* type: integer
* description: Target agent printer ID
* printeragentId:
* type: integer
* nullable: true
* description: Parent printer agent ID
* type:
* type: string
* description: Job format type ("zpl", "pdf", "epos")
* example: "zpl"
* state:
* type: string
* enum: [pending, processing, completed, cancelled, failed]
* description: Current processing state of the job
* example: "pending"
* data:
* type: string
* description: Raw job payload
* createdAt:
* type: string
* format: date-time
* completedAt:
* type: string
* format: date-time
* nullable: true
* LabelObject:
* type: object
* required: [id, type, x, y]

View File

@@ -44,6 +44,7 @@ export const openApiOptions = {
{ name: 'Posterminal', description: 'Company-scoped POS terminals' },
{ name: 'Printer', description: 'Company-scoped receipt and label printers' },
{ name: 'Printeragent', description: 'Company-scoped printer agents (create, rename, delete)' },
{ name: 'Agentprinter', description: 'Company-scoped agent printers (enable, sort, delete)' },
{ name: 'Agent', description: 'Printer agent device endpoints (pairing, SSE events, state)' },
{ name: 'Ledger', description: 'Append-only ledger transactions' },
{ name: 'Stock', description: 'Manual stock additions and removals' },

View File

@@ -0,0 +1,413 @@
import * as db from '../../db/agentprinter.js';
import { emit } from '../sse.js';
import { enrichAgentPrinter } from '../../services/companyPrinterService.js';
// #region Helpers
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
function parsePositiveInt(value, field) {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
return { error: `${field} is required` };
}
return { id };
}
function agentprinterErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Printeragent not found') return 404;
if (error.message === 'Agentprinter not found') return 404;
if (error.message === 'Invalid agentprinter') return 400;
if (error.message === 'Invalid agentprinter order') return 400;
return 500;
}
// #endregion
/**
* Registers agentprinter routes for the authenticated user's selected company.
* @param {import('express').Application} app - Express app
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
*/
export function register(app, basePath = '') {
/**
* @openapi
* /agentprinter:
* get:
* tags: [Agentprinter]
* summary: List agent printers
* description: |
* Lists agent printers for the authenticated user's selected company,
* optionally filtered by printer agent ID.
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: printeragentId
* schema:
* type: integer
* description: Optional printer agent ID filter
* responses:
* 200:
* description: List of agent printers
* content:
* application/json:
* schema:
* type: object
* properties:
* agentprinters:
* type: array
* items:
* $ref: '#/components/schemas/Agentprinter'
* 400:
* description: No company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /agentprinter
app.get(`${basePath}/agentprinter`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const printeragentId = req.query?.printeragentId ? Number(req.query.printeragentId) : undefined;
const agentprinters = await db.list(req.user.id, printeragentId);
const enriched = agentprinters.map((p) => enrichAgentPrinter(selected.companyId, p));
res.status(200).json({ agentprinters: enriched });
} catch (error) {
const status = agentprinterErrorStatus(error);
if (status === 500) console.error('List agentprinters failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /agentprinter/order:
* put:
* tags: [Agentprinter]
* summary: Reorder agent printers
* description: |
* Reorders agent printers for a specific printer agent within the selected company.
* security:
* - cookieAuth: []
* x-signals: [agentprinter]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [agentprinterIds]
* properties:
* printeragentId:
* type: integer
* description: Optional agent id. If omitted, reorders all printers in the company.
* agentprinterIds:
* type: array
* items:
* type: integer
* responses:
* 200:
* description: Applied order
* content:
* application/json:
* schema:
* type: object
* properties:
* agentprinterIds:
* type: array
* items:
* type: integer
* 400:
* description: Invalid order or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region PUT /agentprinter/order
app.put(`${basePath}/agentprinter/order`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const { printeragentId, agentprinterIds } = req.body ?? {};
try {
const result = await db.order(req.user.id, { printeragentId, agentprinterIds });
emit('agentprinter', { companyId: selected.companyId });
res.status(200).json(result);
} catch (error) {
const status = agentprinterErrorStatus(error);
if (status === 500) console.error('Order agentprinters failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /agentprinter/{id}:
* get:
* tags: [Agentprinter]
* summary: Get agent printer
* description: Retrieves a single agent printer by ID.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Agent printer details
* content:
* application/json:
* schema:
* type: object
* properties:
* agentprinter:
* $ref: '#/components/schemas/Agentprinter'
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Agent printer not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /agentprinter/:id
app.get(`${basePath}/agentprinter/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsed = parsePositiveInt(req.params.id, 'id');
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
try {
const agentprinter = await db.getById(req.user.id, parsed.id);
if (!agentprinter) {
return res.status(404).json({ error: 'Agentprinter not found' });
}
res.status(200).json({ agentprinter: enrichAgentPrinter(selected.companyId, agentprinter) });
} catch (error) {
const status = agentprinterErrorStatus(error);
if (status === 500) console.error('Get agentprinter failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /agentprinter/{id}:
* patch:
* tags: [Agentprinter]
* summary: Update agent printer
* description: |
* Updates enabled and/or display order (sort) of an agent printer.
* security:
* - cookieAuth: []
* x-signals: [agentprinter]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* sort:
* type: integer
* responses:
* 200:
* description: Updated agent printer
* content:
* application/json:
* schema:
* type: object
* properties:
* agentprinter:
* $ref: '#/components/schemas/Agentprinter'
* 400:
* description: Invalid input or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Agent printer not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region PATCH /agentprinter/:id
app.patch(`${basePath}/agentprinter/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsed = parsePositiveInt(req.params.id, 'id');
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
const payload = { id: parsed.id };
if (req.body?.enabled !== undefined) payload.enabled = Boolean(req.body.enabled);
if (req.body?.sort !== undefined) {
const sort = Number(req.body.sort);
if (!Number.isInteger(sort)) {
return res.status(400).json({ error: 'sort must be an integer' });
}
payload.sort = sort;
}
if (payload.enabled === undefined && payload.sort === undefined) {
return res.status(400).json({ error: 'At least one field (enabled, sort) is required' });
}
try {
const agentprinter = await db.update(req.user.id, payload);
emit('agentprinter', { companyId: selected.companyId });
res.status(200).json({ agentprinter: enrichAgentPrinter(selected.companyId, agentprinter) });
} catch (error) {
const status = agentprinterErrorStatus(error);
if (status === 500) console.error('Update agentprinter failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /agentprinter/{id}:
* delete:
* tags: [Agentprinter]
* summary: Delete agent printer
* description: Deletes an agent printer from the selected company.
* security:
* - cookieAuth: []
* x-signals: [agentprinter]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Agent printer deleted
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Agent printer not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region DELETE /agentprinter/:id
app.delete(`${basePath}/agentprinter/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsed = parsePositiveInt(req.params.id, 'id');
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
try {
await db.remove(req.user.id, parsed.id);
emit('agentprinter', { companyId: selected.companyId });
res.sendStatus(204);
} catch (error) {
const status = agentprinterErrorStatus(error);
if (status === 500) console.error('Delete agentprinter failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -17,11 +17,11 @@ export function register(app, basePath = '') {
* /article/{sku}/prices:
* put:
* tags: [Article]
* summary: Replace POS prices by SKU (net > gross; price=net, tax=taxRate %)
* summary: Replace POS prices by SKU (net > gross)
* description: |
* Replace-all prices for the company POS customer group.
* `net` takes precedence over `price` (legacy alias for net) and `gross`.
* `tax` and `taxRate` both specify the tax rate percentage (e.g. 19 for 19%), not monetary tax.
* `net` takes precedence over `gross`.
* `taxRate` specifies the tax rate percentage (e.g. 19 for 19%), not monetary tax.
* When `gross` is provided, net price is computed as `gross / (1 + taxRate / 100)`.
* Prices are strictly scoped to the company's designated POS customer group (customergroupId returned on GET).
* Empty `prices` clears all POS prices. Returns the same shape as GET by SKU.
@@ -50,19 +50,13 @@ export function register(app, basePath = '') {
* properties:
* net:
* type: number
* description: Net price (excl. VAT). Either `net`, `gross`, or legacy `price` is required.
* description: Net price (excl. VAT). Either `net` or `gross` is required.
* gross:
* type: number
* description: Gross price (incl. VAT). Net is computed as `gross / (1 + taxRate / 100)`.
* price:
* type: number
* description: Legacy alias for `net`.
* taxRate:
* type: number
* description: Tax rate percentage (e.g. 19 for 19%, 7 for 7%, 0 for 0%).
* tax:
* type: number
* description: Alias for `taxRate` percentage.
* minmultiplier:
* type: number
* description: Defaults to 1; must be unique per request.

View File

@@ -1,10 +1,12 @@
import fs from 'fs/promises';
import path from 'path';
import express from 'express';
import { renderLabel } from 'react-canvas-label-editor';
import * as db from '../../../db/article.js';
import * as printerDb from '../../../db/printer.js';
import * as agentprinterDb from '../../../db/agentprinter.js';
import * as labeltemplateDb from '../../../db/labeltemplate.js';
import * as printerjobDb from '../../../db/printerjob.js';
import { notifyAgentJob } from '../../agent/index.js';
import { pngToZpl } from '../../../services/zplService.js';
import { applyZplCopies } from '../zpljob.js';
import {
AVIF_BODY_LIMIT,
emitArticleSignal,
@@ -12,6 +14,8 @@ import {
parseFullHash,
articleErrorStatus,
interpolateString,
withMandatedOptionsPrice,
interpolateObjects,
} from './shared.js';
/**
@@ -325,11 +329,11 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* type: object
* required: [templateId, printerId, quantity]
* required: [templateId, agentprinterId, quantity]
* properties:
* templateId:
* type: integer
* printerId:
* agentprinterId:
* type: integer
* quantity:
* type: integer
@@ -364,7 +368,8 @@ export function register(app, basePath = '') {
if (skuError) return res.status(400).json({ error: skuError });
const sku = parsedSku;
const { templateId, printerId, quantity } = req.body ?? {};
const { templateId, quantity } = req.body ?? {};
const agentprinterId = req.body?.agentprinterId ?? req.body?.printerId;
if (templateId === undefined || templateId === null) {
return res.status(400).json({ error: 'templateId is required' });
@@ -373,11 +378,11 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: 'templateId must be an integer' });
}
if (printerId === undefined || printerId === null) {
return res.status(400).json({ error: 'printerId is required' });
if (agentprinterId === undefined || agentprinterId === null) {
return res.status(400).json({ error: 'agentprinterId is required' });
}
if (typeof printerId !== 'number' || !Number.isInteger(printerId) || printerId <= 0) {
return res.status(400).json({ error: 'printerId must be an integer' });
if (typeof agentprinterId !== 'number' || !Number.isInteger(agentprinterId) || agentprinterId <= 0) {
return res.status(400).json({ error: 'agentprinterId must be an integer' });
}
if (quantity === undefined || quantity === null) {
@@ -387,28 +392,24 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: 'quantity must be an integer' });
}
const parsedPrinterId = { id: printerId };
const parsedTemplateId = { id: templateId };
const parsedQuantity = { id: quantity };
try {
const printer = await printerDb.getById(req.user.id, parsedPrinterId.id);
if (!printer || printer.company_id !== companyId) {
const printer = await agentprinterDb.getById(req.user.id, agentprinterId);
if (!printer) {
return res.status(404).json({ error: 'Printer not found' });
}
if (printer.type !== 'zpl') {
if (printer.type && printer.type.toLowerCase() !== 'zpl') {
return res.status(400).json({ error: 'Printer is not a ZPL printer' });
}
const template = await labeltemplateDb.getById(req.user.id, parsedTemplateId.id);
const template = await labeltemplateDb.getById(req.user.id, templateId);
if (!template || template.company_id !== companyId) {
return res.status(404).json({ error: 'Label template not found' });
}
let article;
try {
const result = await db.getBySku(req.user.id, sku);
article = result?.article;
article = result?.article ? await withMandatedOptionsPrice(req.user.id, result.article) : undefined;
} catch (err) {
const status = articleErrorStatus(err);
if (status === 500) console.error('Get article by sku failed for print:', err);
@@ -418,48 +419,50 @@ export function register(app, basePath = '') {
return res.status(404).json({ error: 'Article not found' });
}
// Extract and interpolate canvas objects
// Extract and interpolate canvas objects or raw ZPL
const content = template.content;
const objects = Array.isArray(content?.objects)
? content.objects
: Array.isArray(content)
? content
: [];
let zplData;
const interpolatedObjects = objects.map(obj => {
const cloned = { ...obj };
if (typeof cloned.text === 'string') {
cloned.text = interpolateString(cloned.text, article);
if (typeof content === 'string' && content.includes('^XA')) {
let interpolated = interpolateString(content, article);
if (quantity > 1) {
interpolated = applyZplCopies(interpolated, quantity);
}
if (typeof cloned.code === 'string') {
cloned.code = interpolateString(cloned.code, article);
}
return cloned;
});
zplData = interpolated;
} else {
const objects = Array.isArray(content?.objects)
? content.objects
: Array.isArray(content)
? content
: [];
const printPayload = await renderLabel({
width: template.width,
height: template.height,
objects: interpolatedObjects,
});
const interpolatedObjects = interpolateObjects(objects, article);
// Mock network dispatch
const printDir = path.join(process.cwd(), 'print_spool', `${printer.address}_${printer.type}`);
await fs.mkdir(printDir, { recursive: true });
for (let i = 0; i < parsedQuantity.id; i++) {
const timestamp = Date.now();
const filename = parsedQuantity.id > 1 ? `${timestamp}_${i + 1}.png` : `${timestamp}.png`;
const filePath = path.join(printDir, filename);
await fs.writeFile(filePath, printPayload);
const printPayload = await renderLabel({
width: template.width,
height: template.height,
objects: interpolatedObjects,
});
zplData = await pngToZpl(printPayload, quantity);
}
res.status(200).json({ success: true });
const job = await printerjobDb.create(req.user.id, {
agentprinterId: printer.id,
type: 'zpl',
data: zplData,
});
if (job.printeragentId) {
notifyAgentJob(job.printeragentId, 'created', job);
}
return res.status(200).json({ success: true, printerjob: job });
} catch (error) {
console.error('Print article label failed:', error);
res.status(500).json({ error: error.message });
// #endregion
// #endregion
}
});
}

View File

@@ -1,4 +1,5 @@
import { emit } from '../../sse.js';
import * as articleDb from '../../../db/article.js';
// #region Helpers
@@ -117,20 +118,111 @@ export function articleErrorStatus(error) {
return 500;
}
const CURRENCY_FORMATTER = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
export function formatPrice(amount) {
if (amount == null || !Number.isFinite(Number(amount))) return '';
return CURRENCY_FORMATTER.format(Number(amount));
}
const BASEPRICE_FORMATTER = new Intl.NumberFormat('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/** Formats the price per base unit, e.g. "€ 4,05 / L". Empty when unit data or price is missing. */
export function formatBasePrice(grossRaw, article) {
const unit = article?.unit;
const unitamount = Number(article?.unitamount);
const factor = Number(unit?.basesifactor);
if (grossRaw == null || !unit?.basesi || !(unitamount > 0) || !(factor > 0)) return '';
const baseAmount = unitamount * factor;
// Price per base unit equals the price itself: nothing to add.
if (Math.abs(baseAmount - 1) < 1e-9) return '';
const perBase = grossRaw / baseAmount;
const label = factor === 1 && unit.symbol ? unit.symbol : unit.basesi;
return `€ ${BASEPRICE_FORMATTER.format(perBase)} / ${label}`;
}
function grossOf(price) {
const net = Number(price?.net);
if (price?.net == null || !Number.isFinite(net)) return null;
const tax = Number(price.taxRate);
return net * (1 + (Number.isFinite(tax) ? tax : 0) / 100);
}
/**
* Returns the article with `mandatedOptionsGross` set to the summed gross price
* (price × count) of its mandated options, for label placeholders.
*/
export async function withMandatedOptionsPrice(userId, article) {
const mandated = Array.isArray(article?.options) ? article.options.filter((o) => o.mandated) : [];
if (mandated.length === 0) return article;
let extra = 0;
for (const opt of mandated) {
const result = await articleDb.getBySku(userId, opt.sku);
const gross = grossOf(result?.article?.prices?.[0]);
if (gross != null) extra += gross * (Number(opt.count) || 1);
}
return { ...article, mandatedOptionsGross: extra };
}
export function interpolateString(str, article) {
if (typeof str !== 'string') return str;
const net = article.net_price ?? article.prices?.[0]?.net;
const taxRate = article.prices?.[0]?.taxRate ?? 0;
const gross = net != null ? +(net * (1 + taxRate / 100)).toFixed(2) : null;
const ean = (article.eans && article.eans.length > 0) ? article.eans[0] : '';
const basePrice = Array.isArray(article?.prices) && article.prices.length > 0
? article.prices[0]
: null;
const net = basePrice?.net != null && Number.isFinite(Number(basePrice.net))
? Number(basePrice.net)
: null;
const taxRate = basePrice?.taxRate != null && Number.isFinite(Number(basePrice.taxRate))
? Number(basePrice.taxRate)
: 0;
const extra = Number(article?.mandatedOptionsGross) || 0;
const grossRaw = net != null ? net * (1 + taxRate / 100) + extra : null;
const gross = grossRaw != null
? Math.round((grossRaw + Number.EPSILON) * 100) / 100
: null;
return str
.replace(/\{\{(?:ARTICLE_NAME|article_name|name)\}\}/gi, article.name || '')
.replace(/\{\{SKU\}\}/gi, article.sku || '')
.replace(/\{\{(?:NET_PRICE|net_price)\}\}/gi, net != null ? net.toString() : '')
.replace(/\{\{(?:GROSS_PRICE|gross_price)\}\}/gi, gross != null ? gross.toString() : '')
.replace(/\{\{(?:PRICE|price)\}\}/gi, gross != null ? gross.toString() : (net != null ? net.toString() : ''))
.replace(/\{\{(?:EAN|ean)\}\}/gi, ean);
.replace(/\{\{name\}\}/gi, article?.name || '')
.replace(/\{\{baseprice\}\}/gi, formatBasePrice(grossRaw, article))
.replace(/\{\{price\}\}/gi, formatPrice(gross));
}
export function interpolateObjects(objects, article) {
if (!Array.isArray(objects)) return [];
const ean = Array.isArray(article?.eans) && article.eans.length > 0 ? String(article.eans[0]).trim() : '';
const sku = String(article?.sku ?? '').trim();
return objects.map((obj) => {
const cloned = { ...obj };
if (typeof cloned.text === 'string') {
cloned.text = interpolateString(cloned.text, article);
}
if (cloned.type === 'barcode' || typeof cloned.code === 'string') {
const rawCode = String(cloned.code ?? '').trim();
if (rawCode === '0' || /^0+$/.test(rawCode)) {
if (ean) {
cloned.code = ean;
if (ean.length === 8) {
cloned.format = 'EAN8';
} else if (ean.length === 12 || ean.length === 13) {
cloned.format = 'EAN13';
}
} else if (sku) {
cloned.code = sku;
cloned.format = 'CODE128';
}
} else if (typeof cloned.code === 'string') {
cloned.code = interpolateString(cloned.code, article);
}
}
return cloned;
});
}
// #endregion

View File

@@ -253,7 +253,7 @@ export function register(app, basePath = '') {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
if (req.body?.sku || req.body?.childSku) {
if (req.body?.sku) {
return res.status(400).json({
error: 'sku is not allowed when creating a child article; omit sku to auto-generate or use PUT to attach an existing article',
});
@@ -282,9 +282,7 @@ export function register(app, basePath = '') {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const childSku = typeof req.body?.sku === 'string' && req.body.sku.trim()
? req.body.sku.trim()
: (typeof req.body?.childSku === 'string' && req.body.childSku.trim() ? req.body.childSku.trim() : '');
const childSku = typeof req.body?.sku === 'string' ? req.body.sku.trim() : '';
if (!childSku) {
return res.status(400).json({ error: 'sku is required' });
}
@@ -354,7 +352,7 @@ export function register(app, basePath = '') {
const parsedChild = parseSku(req.params.childSku);
if (parsedChild.error) return res.status(400).json({ error: 'childSku is required' });
const deleteArticle = req.query.deleteArticle === 'true' || req.body?.deleteArticle === true;
const deleteArticle = req.query.deleteArticle === 'true';
try {
const result = await db.removeChild(req.user.id, parsed.sku, parsedChild.sku, {

View File

@@ -141,7 +141,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: skuParsed.error });
}
const propertiesInput = req.body?.properties ?? req.body;
const propertiesInput = req.body?.properties;
if (!Array.isArray(propertiesInput)) {
return res.status(400).json({ error: 'properties array required' });
}

View File

@@ -2,6 +2,8 @@ import jwt from 'jsonwebtoken';
import { OAuth2Client } from 'google-auth-library';
import env from '../../../config/env.js';
import * as db from '../../../db/googleAuth.js';
import * as autoinviteDb from '../../../db/autoinvite.js';
import * as companyDb from '../../../db/company.js';
import { clearCachedUser } from '../../middleware/auth.js';
import { downloadPicture, toPictureBuffer } from '../../../util/pictureBinary.js';
@@ -20,7 +22,8 @@ export function register(app, basePath = '') {
* tags: [Auth]
* summary: Login with Google ID token
* description: |
* Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`),
* Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`
* or when the email has a pending auto-invitation),
* stores Google `sub` and profile image bytes, and sets an HTTP-only `token` cookie.
* `user.picture` is the profile image as base64 (same as GET /auth/me).
* security: []
@@ -64,7 +67,7 @@ export function register(app, basePath = '') {
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Email not on allowlist
* description: Email not on allowlist or invited
* content:
* application/json:
* schema:
@@ -104,9 +107,16 @@ export function register(app, basePath = '') {
}
let allowed = await db.getAllowedEmail(email);
if (!allowed && env.implicitCreate) {
if (!allowed && (env.implicitCreate || (await autoinviteDb.hasPendingInvite(email)))) {
await db.addAllowedEmail(email);
allowed = await db.getAllowedEmail(email);
if (allowed && allowed.selectedCompanyId == null) {
const companies = await companyDb.listCompanies(allowed.id);
if (companies.length > 0) {
await db.setSelectedCompany(allowed.id, companies[0].id);
allowed = await db.getAllowedEmail(email);
}
}
}
if (!allowed) {
console.warn(`Login rejected: email not authorized (${email})`);

View File

@@ -53,6 +53,12 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (the user is not a member of this company)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
@@ -79,6 +85,9 @@ export function register(app, basePath = '') {
res.status(200).json({ user });
} catch (error) {
if (error.message === 'Not authorized') {
return res.status(403).json({ error: error.message });
}
console.error('Set selected company failed:', error);
res.status(500).json({ error: 'Failed to set selected company' });
}

View File

@@ -0,0 +1,271 @@
import * as db from '../../db/autoinvite.js';
import { emit } from '../sse.js';
// #region Helpers
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
function parsePositiveInt(val, field = 'id') {
const id = Number(val);
if (!Number.isInteger(id) || id <= 0) {
return { error: `${field} is required` };
}
return { id };
}
function autoinviteErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Invitation not found') return 404;
if (error.message === 'Invalid role') return 400;
if (error.message === 'Invalid email') return 400;
if (error.message === 'User already registered') return 409;
if (error.message === 'Invitation already exists') return 409;
return 500;
}
// #endregion
/**
* Registers auto-invitation routes. All routes require the caller to be an
* admin of the selected company.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /autoinvite:
* get:
* tags: [AutoInvite]
* summary: List auto-invitations
* description: Lists pending auto-invitations of the selected company. Admin only.
* security:
* - cookieAuth: []
* x-markdown: autoinvite/list.md
* responses:
* 200:
* description: Pending invitations
* content:
* application/json:
* schema:
* type: object
* properties:
* autoinvites:
* type: array
* items:
* $ref: '#/components/schemas/AutoInvite'
* 400:
* description: No company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /autoinvite
app.get(`${basePath}/autoinvite`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const autoinvites = await db.list(req.user.id);
res.status(200).json({ autoinvites });
} catch (error) {
const status = autoinviteErrorStatus(error);
if (status === 500) console.error('List auto-invitations failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /autoinvite:
* post:
* tags: [AutoInvite]
* summary: Create auto-invitation
* description: |
* Registers an email for automatic membership. When a user with this email registers,
* the invitation is removed from the list and the user is added to the company with the given role.
* Emails that are already registered are rejected (409); use `POST /companyuser` for those. Admin only.
* security:
* - cookieAuth: []
* x-signals: [autoinvite]
* x-markdown: autoinvite/create.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email]
* properties:
* email:
* type: string
* role:
* type: string
* enum: [admin, user]
* default: user
* example:
* email: newhire@example.com
* role: user
* responses:
* 201:
* description: Created invitation
* content:
* application/json:
* schema:
* type: object
* properties:
* autoinvite:
* type: object
* properties:
* id:
* type: integer
* email:
* type: string
* role:
* type: string
* enum: [admin, user]
* 400:
* description: No company selected, invalid email or invalid role
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: User already registered or invitation already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /autoinvite
app.post(`${basePath}/autoinvite`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const autoinvite = await db.add(req.user.id, {
email: req.body?.email,
role: req.body?.role,
});
emit('autoinvite', { companyId: selected.companyId });
res.status(201).json({ autoinvite });
} catch (error) {
const status = autoinviteErrorStatus(error);
if (status === 500) console.error('Create auto-invitation failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /autoinvite/{id}:
* delete:
* tags: [AutoInvite]
* summary: Delete auto-invitation
* description: Deletes a pending auto-invitation of the selected company. Admin only.
* security:
* - cookieAuth: []
* x-signals: [autoinvite]
* x-markdown: autoinvite/delete.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Deleted
* 400:
* description: Invalid id or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Invitation not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region DELETE /autoinvite/:id
app.delete(`${basePath}/autoinvite/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id);
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
await db.remove(req.user.id, parsedId.id);
emit('autoinvite', { companyId: selected.companyId });
res.sendStatus(204);
} catch (error) {
const status = autoinviteErrorStatus(error);
if (status === 500) console.error('Delete auto-invitation failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -252,12 +252,12 @@ export function register(app, basePath = '') {
// #region POST /backup/:id/vfs/recover
// MARK: handles POST /backup/:id/vfs/recover to restore a VFS file from a backup
const handleRecover = async (req, res) => {
const filePath = req.params.path || req.body?.path || req.body?.file || req.body?.filename || req.query?.path;
const filePath = req.body?.path;
if (!filePath) {
return res.status(400).json({ error: 'File path is required' });
}
const requestedCompanyId = req.body?.companyId ?? req.query?.companyId;
const requestedCompanyId = req.body?.companyId;
const authorized = await resolveAuthorizedCompany(req, requestedCompanyId);
if (authorized.error) {
return res.status(authorized.status).json({ error: authorized.error });

View File

@@ -384,8 +384,6 @@ export function register(app, basePath = '') {
let includeChildren = true;
if (req.query.includeChildren !== undefined) {
includeChildren = req.query.includeChildren !== 'false' && req.query.includeChildren !== '0';
} else if (req.query.variants !== undefined) {
includeChildren = req.query.variants !== 'false' && req.query.variants !== '0';
}
try {
@@ -395,7 +393,7 @@ export function register(app, basePath = '') {
.map((a) => ({
...a,
childCount: Number(a.childCount ?? 0),
isParent: Boolean(a.isParent ?? a.is_parent),
isParent: Boolean(a.isParent),
smallThumbW: a.smallThumbW ?? null,
smallThumbH: a.smallThumbH ?? null,
smallThumbBinary: a.smallThumbBinary ?? null,

View File

@@ -153,7 +153,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: idParsed.error });
}
const propertiesInput = req.body?.properties ?? req.body;
const propertiesInput = req.body?.properties;
if (!Array.isArray(propertiesInput)) {
return res.status(400).json({ error: 'properties array required' });
}

View File

@@ -2,13 +2,17 @@ import fs from 'fs';
import path from 'path';
import multer from 'multer';
import JSZip from 'jszip';
import env from '../../config/env.js';
import * as db from '../../db/company.js';
import { requireCompanyAdmin } from '../../db/companyuser.js';
import * as googleAuth from '../../db/googleAuth.js';
import { clearCachedUser } from '../middleware/auth.js';
import { emit } from '../sse.js';
import { initCompanyVfs } from '../../tools/vfs.js';
import { createDefaultLabelTemplates } from '../../services/defaultLabelTemplates.js';
import { executeCompanyImport } from '../../services/companyImport.js';
import { executeCompanyExport } from '../../services/companyExport.js';
import { clearCompanyPrinters } from '../../services/companyPrinterService.js';
// #region Helpers
@@ -85,7 +89,7 @@ function companyErrorStatus(error) {
if (error.message === 'Company not found') return 404;
if (error.message === 'Company name not available') return 409;
if (error.message === 'Invalid tax rate' || error.message === 'Text required' || error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Not authorized' || error.message === 'Invited users cannot create a company') return 403;
return 500;
}
@@ -184,6 +188,12 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (invited users cannot create companies when implicit create is disabled)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /company
@@ -207,6 +217,13 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: body.error });
}
if (!env.implicitCreate) {
const isInvited = await db.isInvitedUser(req.user.id);
if (isInvited) {
return res.status(403).json({ error: 'Invited users cannot create a company' });
}
}
try {
const company = await db.createCompany(req.user.id, body);
await googleAuth.setSelectedCompany(req.user.id, company.id);
@@ -216,6 +233,11 @@ export function register(app, basePath = '') {
} catch (vfsErr) {
console.error('Failed to initialize company VFS:', vfsErr);
}
try {
await createDefaultLabelTemplates(req.user.id);
} catch (tplErr) {
console.error('Failed to create default label templates:', tplErr);
}
const authRow = await googleAuth.getAllowedEmail(req.user.email);
res.status(201).json({ company, user: userPayload(authRow) });
} catch (error) {
@@ -478,13 +500,25 @@ export function register(app, basePath = '') {
app.post(
`${basePath}/company/import`,
async (req, res, next) => {
try {
const companyId = requireSelectedCompany(req);
await requireCompanyAdmin(req.user.id, companyId);
next();
} catch (error) {
const status = companyErrorStatus(error);
return res.status(status).json({ error: error.message });
}
},
uploadCompanyImportFile,
async (req, res) => {
let companyId;
try {
companyId = requireSelectedCompany(req);
await requireCompanyAdmin(req.user.id, companyId);
} catch (error) {
return res.status(error.statusCode || 400).json({ error: error.message });
const status = companyErrorStatus(error);
return res.status(status).json({ error: error.message });
}
if (activeImportsByCompany.has(companyId)) {
@@ -947,7 +981,7 @@ export function register(app, basePath = '') {
}
try {
const company = await db.updateCompany(parsedId.companyId, body);
const company = await db.updateCompany(parsedId.companyId, body, req.user.id);
emit('company', { companyId: parsedId.companyId, data: { companyId: parsedId.companyId } });
res.status(200).json({ company });
} catch (error) {
@@ -968,9 +1002,14 @@ export function register(app, basePath = '') {
}
try {
await db.deleteCompany(parsedId.companyId);
await db.deleteCompany(parsedId.companyId, req.user.id);
clearCompanyPrinters(parsedId.companyId);
res.sendStatus(204);
} catch (error) {
const status = companyErrorStatus(error);
if (status !== 500) {
return res.status(status).json({ error: error.message });
}
console.error('Delete company failed:', error);
res.status(500).json({ error: 'Failed to delete company' });
}

View File

@@ -0,0 +1,377 @@
import * as db from '../../db/companyuser.js';
import { clearCachedUser } from '../middleware/auth.js';
import { emit } from '../sse.js';
// #region Helpers
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
function parsePositiveInt(val, field = 'id') {
const id = Number(val);
if (!Number.isInteger(id) || id <= 0) {
return { error: `${field} is required` };
}
return { id };
}
function companyuserErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Company user not found') return 404;
if (error.message === 'User not found') return 404;
if (error.message === 'Invalid role') return 400;
if (error.message === 'Invalid email') return 400;
if (error.message === 'User already in company') return 409;
if (error.message === 'Cannot demote the last admin') return 409;
if (error.message === 'Cannot remove the last admin') return 409;
return 500;
}
// #endregion
/**
* Registers company user (membership) routes. All routes require the caller
* to be an admin of the selected company.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /companyuser:
* get:
* tags: [CompanyUser]
* summary: List company users
* description: Lists the members of the selected company with their role. Admin only.
* security:
* - cookieAuth: []
* x-markdown: companyuser/list.md
* responses:
* 200:
* description: Company members
* content:
* application/json:
* schema:
* type: object
* properties:
* companyusers:
* type: array
* items:
* $ref: '#/components/schemas/CompanyUser'
* 400:
* description: No company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /companyuser
app.get(`${basePath}/companyuser`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const companyusers = await db.list(req.user.id);
res.status(200).json({ companyusers });
} catch (error) {
const status = companyuserErrorStatus(error);
if (status === 500) console.error('List company users failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /companyuser:
* post:
* tags: [CompanyUser]
* summary: Add an existing user to the company
* description: |
* Adds an existing (registered) user, identified by email, to the selected company. Admin only.
* The added user can then switch to this company via `PUT /auth/selected-company`.
* For emails that are not registered yet use `POST /autoinvite`.
* security:
* - cookieAuth: []
* x-signals: [companyuser]
* x-markdown: companyuser/create.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email]
* properties:
* email:
* type: string
* role:
* type: string
* enum: [admin, user]
* default: user
* example:
* email: colleague@example.com
* role: user
* responses:
* 201:
* description: Created company user
* content:
* application/json:
* schema:
* type: object
* properties:
* companyuser:
* $ref: '#/components/schemas/CompanyUser'
* 400:
* description: No company selected, invalid email or invalid role
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: No registered user with this email
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: User already in company
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /companyuser
app.post(`${basePath}/companyuser`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const companyuser = await db.addByEmail(req.user.id, {
email: req.body?.email,
role: req.body?.role,
});
emit('companyuser', { companyId: selected.companyId });
res.status(201).json({ companyuser });
} catch (error) {
const status = companyuserErrorStatus(error);
if (status === 500) console.error('Add company user failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /companyuser/{id}:
* patch:
* tags: [CompanyUser]
* summary: Change a company user's role
* description: Changes the role of a member of the selected company. The last admin cannot be demoted. Admin only.
* security:
* - cookieAuth: []
* x-signals: [companyuser]
* x-markdown: companyuser/update.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [role]
* properties:
* role:
* type: string
* enum: [admin, user]
* responses:
* 200:
* description: Updated company user
* content:
* application/json:
* schema:
* type: object
* properties:
* companyuser:
* $ref: '#/components/schemas/CompanyUser'
* 400:
* description: Invalid id, role or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Company user not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Cannot demote the last admin
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region PATCH /companyuser/:id
app.patch(`${basePath}/companyuser/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id);
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const companyuser = await db.setRole(req.user.id, parsedId.id, req.body?.role);
clearCachedUser(companyuser.googleAuthId);
emit('companyuser', { companyId: selected.companyId });
res.status(200).json({ companyuser });
} catch (error) {
const status = companyuserErrorStatus(error);
if (status === 500) console.error('Update company user failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /companyuser/{id}:
* delete:
* tags: [CompanyUser]
* summary: Remove a user from the company
* description: |
* Removes a member from the selected company. History entries stay but lose their author link;
* if the removed user had this company selected, their selection is cleared.
* The last admin cannot be removed. Admin only.
* security:
* - cookieAuth: []
* x-signals: [companyuser]
* x-markdown: companyuser/delete.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Removed
* 400:
* description: Invalid id or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Company user not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Cannot remove the last admin
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region DELETE /companyuser/:id
app.delete(`${basePath}/companyuser/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id);
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const removed = await db.remove(req.user.id, parsedId.id);
clearCachedUser(removed.googleAuthId);
emit('companyuser', { companyId: selected.companyId });
res.sendStatus(204);
} catch (error) {
const status = companyuserErrorStatus(error);
if (status === 500) console.error('Remove company user failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -55,6 +55,10 @@ export function register(app, basePath = '') {
* | `propertyname` | `{}` | `POST /propertyname`, `PUT /propertyname/order`, `PATCH /propertyname/{id}`, `DELETE /propertyname/{id}` |
* | `labeltemplate` | `{}` | `POST /labeltemplate`, `PUT /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `DELETE /labeltemplate/{id}` |
* | `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` |
* | `printeragent` | `{}` | `PUT /printeragent`, `PATCH /printeragent/{id}`, `DELETE /printeragent/{id}`, printer agent connect/disconnect/state |
* | `agentprinter` | `{ printers }` or `{}` | `PATCH /agentprinter/{id}`, `PUT /agentprinter/order`, `DELETE /agentprinter/{id}`, agent state updates / printer discovery |
* | `companyuser` | `{}` | `POST /companyuser`, `PATCH /companyuser/{id}`, `DELETE /companyuser/{id}` |
* | `autoinvite` | `{}` | `POST /autoinvite`, `DELETE /autoinvite/{id}` |
* | `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` |
* | `supplier` | `{}` | `POST /supplier`, `PATCH /supplier/{id}`, `DELETE /supplier/{id}` |
* | `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` |
@@ -93,10 +97,20 @@ export function register(app, basePath = '') {
* payload: "{}"
* - name: propertyname
* payload: "{}"
* - name: companyuser
* payload: "{}"
* - name: autoinvite
* payload: "{}"
* - name: labeltemplate
* payload: "{}"
* - name: printer
* payload: "{}"
* - name: printeragent
* payload: "{}"
* - name: agentprinter
* payload: "{ printers } or {}"
* - name: printerjob
* payload: "{ job } or { jobId, state }"
* - name: warehouse
* payload: "{}"
* - name: supplier

View File

@@ -1,7 +1,14 @@
import { validateObjects } from 'react-canvas-label-editor';
import { renderLabel, validateObjects } from 'react-canvas-label-editor';
import * as db from '../../db/labeltemplate.js';
import * as agentprinterDb from '../../db/agentprinter.js';
import * as printerjobDb from '../../db/printerjob.js';
import * as articleDb from '../../db/article.js';
import { renderThumb } from '../../services/labelThumb.js';
import { pngToZpl } from '../../services/zplService.js';
import { notifyAgentJob } from '../agent/index.js';
import { emit } from '../sse.js';
import { applyZplCopies } from './zpljob.js';
import { interpolateString, interpolateObjects, withMandatedOptionsPrice } from './article/shared.js';
// #region Helpers
@@ -89,10 +96,45 @@ export function register(app, basePath = '') {
try {
const templates = await db.list(req.user.id);
res.status(200).json({ labeltemplates: templates.map((t) => {
const { content, ...rest } = serializeTemplate(t);
return rest;
}) });
let article = null;
if (req.query?.sku) {
const skuStr = String(req.query.sku).trim();
const result = await articleDb.getBySku(req.user.id, skuStr);
if (result?.article) {
article = await withMandatedOptionsPrice(req.user.id, result.article);
}
}
const labeltemplates = await Promise.all(
templates.map(async (t) => {
const { content, ...rest } = serializeTemplate(t);
if (article) {
try {
const full = await db.getById(req.user.id, t.id);
const objects = Array.isArray(full?.content?.objects)
? full.content.objects
: Array.isArray(full?.content)
? full.content
: null;
if (objects && objects.length > 0) {
const interpolated = interpolateObjects(objects, article);
const thumbBuffer = await renderThumb({
width: t.width,
height: t.height,
objects: interpolated,
});
rest.thumb = thumbBuffer.toString('base64');
}
} catch (err) {
console.error(`Failed to render preview thumb for template ${t.id} with sku ${req.query.sku}:`, err);
}
}
return rest;
})
);
res.status(200).json({ labeltemplates });
} catch (error) {
const status = labeltemplateErrorStatus(error);
if (status === 500) console.error('List label templates failed:', error);
@@ -166,7 +208,32 @@ export function register(app, basePath = '') {
if (!template) {
return res.status(404).json({ error: 'Label template not found' });
}
res.status(200).json({ labeltemplate: serializeTemplate(template) });
const serialized = serializeTemplate(template);
if (req.query?.sku) {
const skuStr = String(req.query.sku).trim();
const result = await articleDb.getBySku(req.user.id, skuStr);
if (result?.article) {
const objects = Array.isArray(serialized?.content?.objects)
? serialized.content.objects
: Array.isArray(serialized?.content)
? serialized.content
: null;
if (objects && objects.length > 0) {
try {
const interpolated = interpolateObjects(objects, await withMandatedOptionsPrice(req.user.id, result.article));
const thumbBuffer = await renderThumb({
width: template.width,
height: template.height,
objects: interpolated,
});
serialized.thumb = thumbBuffer.toString('base64');
} catch (err) {
console.error(`Failed to render preview thumb for template ${template.id} with sku ${req.query.sku}:`, err);
}
}
}
}
res.status(200).json({ labeltemplate: serialized });
} catch (error) {
const status = labeltemplateErrorStatus(error);
if (status === 500) console.error('Get label template failed:', error);
@@ -482,6 +549,173 @@ export function register(app, basePath = '') {
});
// #endregion
/**
* @openapi
* /labeltemplate/testprint:
* post:
* tags: [LabelTemplate]
* summary: Test print label template
* description: |
* Dispatches a test print job for a label template or unsaved canvas objects to a specified ZPL printer.
* Placeholders in text and barcodes are interpolated using a sample article or the specified article SKU.
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [agentprinterId]
* properties:
* agentprinterId:
* type: integer
* description: Agent printer ID
* templateId:
* type: integer
* objects:
* type: array
* width:
* type: integer
* height:
* type: integer
* dpi:
* type: integer
* sku:
* type: string
* quantity:
* type: integer
* responses:
* 200:
* description: Print job created
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* 400:
* description: Invalid parameters
* 404:
* description: Printer or template not found
* 500:
* description: Server error
*/
// #region POST /labeltemplate/testprint
async function handleTestPrint(req, res) {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const companyId = selected.companyId;
const parsedPrinterId = parsePositiveInt(req.body?.agentprinterId, 'agentprinterId');
if (parsedPrinterId.error) {
return res.status(400).json({ error: parsedPrinterId.error });
}
try {
const printer = await agentprinterDb.getById(req.user.id, parsedPrinterId.id);
if (!printer) {
return res.status(404).json({ error: 'Printer not found' });
}
if (printer.type && printer.type.toLowerCase() !== 'zpl') {
return res.status(400).json({ error: 'Printer is not a ZPL printer' });
}
const rawTemplateId = req.params?.id ?? req.body?.templateId;
let template = null;
if (rawTemplateId != null) {
const parsedTmplId = parsePositiveInt(rawTemplateId, 'templateId');
if (!parsedTmplId.error) {
template = await db.getById(req.user.id, parsedTmplId.id);
if (!template || template.company_id !== companyId) {
return res.status(404).json({ error: 'Label template not found' });
}
}
}
const rawQuantity = req.body?.quantity ?? 1;
const quantity = Number(rawQuantity) > 0 ? Number(rawQuantity) : 1;
let objects = req.body?.objects;
let width = req.body?.width != null ? Number(req.body.width) : template?.width;
let height = req.body?.height != null ? Number(req.body.height) : template?.height;
let content = req.body?.content ?? template?.content;
// Resolve sample article for placeholder interpolation
let article = null;
if (req.body?.sku) {
const skuStr = String(req.body.sku).trim();
const result = await articleDb.getBySku(req.user.id, skuStr);
if (!result?.article) {
return res.status(404).json({ error: 'Article not found' });
}
article = await withMandatedOptionsPrice(req.user.id, result.article);
} else {
article = {
name: template?.name || 'Musterartikel',
sku: 'TEST-12345',
prices: [{ net: 19.99, taxRate: 19 }],
eans: ['4012345678901'],
};
}
let zplData;
if (typeof content === 'string' && content.includes('^XA')) {
let interpolated = interpolateString(content, article);
if (quantity > 1) {
interpolated = applyZplCopies(interpolated, quantity);
}
zplData = interpolated;
} else {
if (!objects && content) {
objects = Array.isArray(content?.objects)
? content.objects
: Array.isArray(content)
? content
: [];
}
if (!Array.isArray(objects)) {
objects = [];
}
const interpolatedObjects = interpolateObjects(objects, article);
const printPayload = await renderLabel({
width: width || 448,
height: height || 200,
objects: interpolatedObjects,
});
zplData = await pngToZpl(printPayload, quantity);
}
const job = await printerjobDb.create(req.user.id, {
agentprinterId: printer.id,
type: 'zpl',
data: zplData,
});
if (job.printeragentId) {
notifyAgentJob(job.printeragentId, 'created', job);
}
return res.status(200).json({ success: true, printerjob: job });
} catch (error) {
console.error('Testprint label template failed:', error);
return res.status(500).json({ error: error.message });
}
}
app.post(`${basePath}/labeltemplate/testprint`, handleTestPrint);
app.post(`${basePath}/labeltemplate/:id/testprint`, handleTestPrint);
// #endregion
}
/**

View File

@@ -101,7 +101,7 @@ export function register(app, basePath = '') {
}
const manifestarticleId = Number(req.params.manifestarticleId);
const transactionType = req.query.transaction_type ?? req.query.transactionType;
const transactionType = req.query.transaction_type;
try {
const ledger = await db.listByManifestarticleId(
@@ -186,7 +186,7 @@ export function register(app, basePath = '') {
}
const sku = req.params.sku;
const transactionType = req.query.transaction_type ?? req.query.transactionType;
const transactionType = req.query.transaction_type;
try {
const ledger = await db.listBySku(req.user.id, sku, transactionType);
@@ -254,9 +254,9 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: selected.error });
}
const rawManifestarticleId = req.query.manifestarticle_id ?? req.query.manifestarticleId;
const rawManifestarticleId = req.query.manifestarticle_id;
const sku = req.query.sku;
const transactionType = req.query.transaction_type ?? req.query.transactionType;
const transactionType = req.query.transaction_type;
if (rawManifestarticleId == null && (sku == null || !String(sku).trim())) {
return res.status(400).json({

View File

@@ -190,12 +190,8 @@ export function register(app, basePath = '') {
}
const pairingpin = String(randomInt(100000, 999999));
const rootCategoryId = req.body.rootCategoryId !== undefined
? req.body.rootCategoryId
: req.body.root_category_id;
const syncRootArticles = req.body.syncRootArticles !== undefined
? req.body.syncRootArticles
: req.body.sync_root_articles;
const rootCategoryId = req.body.rootCategoryId;
const syncRootArticles = req.body.syncRootArticles;
const sort = req.body.sort !== undefined ? Number(req.body.sort) : undefined;
if (sort !== undefined && !Number.isInteger(sort)) {
return res.status(400).json({ error: 'sort must be an integer' });
@@ -319,14 +315,12 @@ export function register(app, basePath = '') {
payload.name = req.body.name.trim();
}
if (req.body.rootCategoryId !== undefined || req.body.root_category_id !== undefined) {
const rawCat = req.body.rootCategoryId !== undefined ? req.body.rootCategoryId : req.body.root_category_id;
payload.rootCategoryId = rawCat != null ? Number(rawCat) : null;
if (req.body.rootCategoryId !== undefined) {
payload.rootCategoryId = req.body.rootCategoryId != null ? Number(req.body.rootCategoryId) : null;
}
if (req.body.syncRootArticles !== undefined || req.body.sync_root_articles !== undefined) {
const rawSync = req.body.syncRootArticles !== undefined ? req.body.syncRootArticles : req.body.sync_root_articles;
payload.syncRootArticles = Boolean(rawSync);
if (req.body.syncRootArticles !== undefined) {
payload.syncRootArticles = Boolean(req.body.syncRootArticles);
}
if (req.body.sort !== undefined) {

View File

@@ -1,5 +1,6 @@
import * as db from '../../db/printer.js';
import { emit } from '../sse.js';
import { getCompanyPrinters, getCompanyPrinter } from '../../services/companyPrinterService.js';
// #region Helpers
@@ -90,7 +91,17 @@ export function register(app, basePath = '') {
try {
const printers = await db.list(req.user.id);
res.status(200).json({ printers });
const livePrinters = getCompanyPrinters(selected.companyId);
const enriched = printers.map((p) => {
const live = livePrinters.find((lp) =>
(p.serial && lp.serial && String(p.serial).toLowerCase() === String(lp.serial).toLowerCase()) ||
(p.address && lp.ip && String(p.address).toLowerCase() === String(lp.ip).toLowerCase()) ||
(p.address && lp.address && String(p.address).toLowerCase() === String(lp.address).toLowerCase()) ||
(p.name && lp.name && String(p.name).toLowerCase() === String(lp.name).toLowerCase())
);
return live ? { ...p, state: live } : p;
});
res.status(200).json({ printers: enriched });
} catch (error) {
const status = printerErrorStatus(error);
if (status === 500) console.error('List printers failed:', error);
@@ -100,6 +111,186 @@ export function register(app, basePath = '') {
// #endregion
/**
* @openapi
* /printer/state:
* get:
* tags: [Printer]
* summary: Get live printer states
* description: |
* Retrieves real-time operational status and telemetry for all discovered printers
* reported by active printer agents for the authenticated user's selected company.
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: serial
* schema:
* type: string
* description: Optional serial number filter
* - in: query
* name: agentId
* schema:
* type: integer
* description: Optional agent ID filter
* responses:
* 200:
* description: Live printer states
* content:
* application/json:
* schema:
* type: object
* properties:
* printers:
* type: array
* items:
* type: object
* 400:
* description: No company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /printer/state
app.get(`${basePath}/printer/state`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
let printers = getCompanyPrinters(selected.companyId);
if (req.query?.serial) {
const serialFilter = String(req.query.serial).trim().toLowerCase();
printers = printers.filter((p) => p.serial && String(p.serial).toLowerCase() === serialFilter);
}
if (req.query?.agentId) {
const aId = Number(req.query.agentId);
if (Number.isInteger(aId)) {
printers = printers.filter((p) => p.agentId === aId);
}
}
res.status(200).json({ printers });
});
// #endregion
/**
* @openapi
* /printer/{id}/state:
* get:
* tags: [Printer]
* summary: Get live state for a printer
* description: |
* Retrieves live operational status for a single printer by configured printer ID,
* serial number, or device identifier for the authenticated user's selected company.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Configured printer ID, serial number, or device identifier
* responses:
* 200:
* description: Live printer state
* content:
* application/json:
* schema:
* type: object
* properties:
* printer:
* type: object
* 400:
* description: No company selected or invalid ID
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Printer not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /printer/:id/state
const handlePrinterStateById = async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const identifier = req.params.id;
if (!identifier) {
return res.status(400).json({ error: 'id is required' });
}
let livePrinter = getCompanyPrinter(selected.companyId, identifier);
const numericId = Number(identifier);
if (!livePrinter && Number.isInteger(numericId) && numericId > 0) {
try {
const dbPrinter = await db.getById(req.user.id, numericId);
if (dbPrinter) {
const allLive = getCompanyPrinters(selected.companyId);
livePrinter = allLive.find((lp) =>
(dbPrinter.serial && lp.serial && String(dbPrinter.serial).toLowerCase() === String(lp.serial).toLowerCase()) ||
(dbPrinter.address && lp.ip && String(dbPrinter.address).toLowerCase() === String(lp.ip).toLowerCase()) ||
(dbPrinter.address && lp.address && String(dbPrinter.address).toLowerCase() === String(lp.address).toLowerCase()) ||
(dbPrinter.name && lp.name && String(dbPrinter.name).toLowerCase() === String(lp.name).toLowerCase())
) || null;
if (!livePrinter) {
return res.status(200).json({
printer: {
id: dbPrinter.id,
companyId: dbPrinter.company_id,
serial: dbPrinter.serial,
address: dbPrinter.address,
model: dbPrinter.model,
status: 'offline',
online: false,
},
});
}
}
} catch {
// Ignore DB error, proceed to 404
}
}
if (!livePrinter) {
return res.status(404).json({ error: 'Printer not found' });
}
res.status(200).json({ printer: livePrinter });
};
app.get(`${basePath}/printer/state/:id`, handlePrinterStateById);
app.get(`${basePath}/printer/:id/state`, handlePrinterStateById);
// #endregion
/**
* @openapi
* /printer/order:

View File

@@ -1,5 +1,7 @@
import { randomInt } from 'node:crypto';
import * as db from '../../db/printeragent.js';
import { emit } from '../sse.js';
import { enrichAgentWithState, removeAgentState } from '../agent/stateStore.js';
// #region Helpers
@@ -92,7 +94,7 @@ export function register(app, basePath = '') {
}
try {
const printeragents = await db.list(req.user.id);
const printeragents = (await db.list(req.user.id)).map(enrichAgentWithState);
res.status(200).json({ printeragents });
} catch (error) {
const status = printeragentErrorStatus(error);
@@ -126,6 +128,8 @@ export function register(app, basePath = '') {
* type: string
* sort:
* type: integer
* enabled:
* type: boolean
* default: 0
* example:
* name: "Bar printer"
@@ -193,7 +197,8 @@ export function register(app, basePath = '') {
pairingpin,
sort,
});
res.status(200).json({ printeragent });
emit('printeragent', { companyId: selected.companyId });
res.status(200).json({ printeragent: enrichAgentWithState(printeragent) });
} catch (error) {
const status = printeragentErrorStatus(error);
if (status === 500) console.error('Create printeragent failed:', error);
@@ -229,6 +234,8 @@ export function register(app, basePath = '') {
* type: string
* sort:
* type: integer
* enabled:
* type: boolean
* responses:
* 200:
* description: Updated printer agent
@@ -278,8 +285,8 @@ export function register(app, basePath = '') {
}
const { name } = req.body ?? {};
if (name === undefined && req.body?.sort === undefined) {
return res.status(400).json({ error: 'name or sort is required' });
if (name === undefined && req.body?.sort === undefined && req.body?.enabled === undefined) {
return res.status(400).json({ error: 'name, sort, or enabled is required' });
}
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
@@ -297,10 +304,12 @@ export function register(app, basePath = '') {
const payload = { id: parsed.id };
if (name !== undefined) payload.name = name.trim();
if (sort !== undefined) payload.sort = sort;
if (req.body?.enabled !== undefined) payload.enabled = Boolean(req.body.enabled);
try {
const printeragent = await db.update(req.user.id, payload);
res.status(200).json({ printeragent });
emit('printeragent', { companyId: selected.companyId });
res.status(200).json({ printeragent: enrichAgentWithState(printeragent) });
} catch (error) {
const status = printeragentErrorStatus(error);
if (status === 500) console.error('Update printeragent failed:', error);
@@ -367,6 +376,8 @@ export function register(app, basePath = '') {
try {
await db.remove(req.user.id, parsed.id);
removeAgentState(parsed.id);
emit('printeragent', { companyId: selected.companyId });
res.sendStatus(204);
} catch (error) {
const status = printeragentErrorStatus(error);

View File

@@ -0,0 +1,455 @@
import * as printerjobDb from '../../db/printerjob.js';
import { notifyAgentJob } from '../agent/index.js';
// #region Helpers
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId: Number(companyId) };
}
function parsePositiveInt(value, field) {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
return { error: `${field} must be a positive integer` };
}
return { id };
}
function printerjobErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Agentprinter not found') return 404;
if (error.message === 'Printerjob not found') return 404;
if (error.message?.includes('is required')) return 400;
if (error.message?.startsWith('Invalid printerjob')) return 400;
return 500;
}
// #endregion
/**
* Registers printerjob routes.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /printerjob:
* get:
* tags: [PrinterJob]
* summary: List printer jobs
* description: Lists printer jobs for the selected company, optionally filtered by agent printer or state.
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: agentprinterId
* schema:
* type: integer
* description: Optional agent printer ID filter
* - in: query
* name: state
* schema:
* type: string
* enum: [pending, processing, completed, cancelled, failed]
* description: Optional job state filter
* responses:
* 200:
* description: List of printer jobs
* content:
* application/json:
* schema:
* type: object
* properties:
* printerjobs:
* type: array
* items:
* $ref: '#/components/schemas/Printerjob'
* 400:
* description: No company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /printerjob
app.get(`${basePath}/printerjob`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const agentprinterId = req.query?.agentprinterId ? Number(req.query.agentprinterId) : undefined;
const state = req.query?.state ? String(req.query.state).trim().toLowerCase() : undefined;
const printerjobs = await printerjobDb.list(req.user.id, { agentprinterId, state });
res.status(200).json({ printerjobs });
} catch (error) {
const status = printerjobErrorStatus(error);
if (status === 500) console.error('List printerjobs failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /printerjob/{id}:
* get:
* tags: [PrinterJob]
* summary: Get printer job
* description: Retrieves details of a specific printer job by ID.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Printer job details
* content:
* application/json:
* schema:
* type: object
* properties:
* printerjob:
* $ref: '#/components/schemas/Printerjob'
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Printer job not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /printerjob/:id
app.get(`${basePath}/printerjob/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsed = parsePositiveInt(req.params.id, 'id');
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
try {
const printerjob = await printerjobDb.getById(req.user.id, parsed.id);
if (!printerjob) {
return res.status(404).json({ error: 'Printerjob not found' });
}
res.status(200).json({ printerjob });
} catch (error) {
const status = printerjobErrorStatus(error);
if (status === 500) console.error('Get printerjob failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /printerjob:
* post:
* tags: [PrinterJob]
* summary: Create print job
* description: Creates a new print job for an agent printer. Notifies the agent in real time.
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [agentprinterId, data]
* properties:
* agentprinterId:
* type: integer
* type:
* type: string
* example: "zpl"
* data:
* type: string
* responses:
* 201:
* description: Printer job created
* content:
* application/json:
* schema:
* type: object
* properties:
* printerjob:
* $ref: '#/components/schemas/Printerjob'
*/
// #region POST /printerjob
app.post(`${basePath}/printerjob`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const rawApId = req.body?.agentprinterId;
const parsedApId = parsePositiveInt(rawApId, 'agentprinterId');
if (parsedApId.error) {
return res.status(400).json({ error: parsedApId.error });
}
const rawData = req.body?.data;
if (!rawData || typeof rawData !== 'string' || !rawData.trim()) {
return res.status(400).json({ error: 'data is required' });
}
const type = typeof req.body?.type === 'string' && req.body.type.trim()
? req.body.type.trim().toLowerCase()
: undefined;
try {
const job = await printerjobDb.create(req.user.id, {
agentprinterId: parsedApId.id,
type,
data: rawData.trim(),
});
if (job.printeragentId) {
notifyAgentJob(job.printeragentId, 'created', job);
}
res.status(201).json({ printerjob: job });
} catch (error) {
const status = printerjobErrorStatus(error);
if (status === 500) console.error('Create printerjob failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /printerjob/cancel-all:
* post:
* tags: [PrinterJob]
* summary: Cancel all pending and processing printer jobs
* description: |
* Cancels all queued or in-flight print jobs for the selected company, optionally filtered by agentprinterId.
* Notifies connected agents in real time.
* security:
* - cookieAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* agentprinterId:
* type: integer
* responses:
* 200:
* description: All matching jobs cancelled
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* cancelledCount:
* type: integer
* jobs:
* type: array
* items:
* $ref: '#/components/schemas/Printerjob'
*/
// #region POST /printerjob/cancel-all
app.post(`${basePath}/printerjob/cancel-all`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const rawApId = req.body?.agentprinterId;
const agentprinterId = rawApId != null ? Number(rawApId) : undefined;
const allJobs = await printerjobDb.list(req.user.id, { agentprinterId });
const cancellable = allJobs.filter((j) => j.state === 'pending' || j.state === 'processing');
const cancelledJobs = [];
for (const j of cancellable) {
try {
const job = await printerjobDb.updateState(req.user.id, j.id, 'cancelled');
if (job.printeragentId) {
notifyAgentJob(job.printeragentId, 'cancelled', job);
}
cancelledJobs.push(job);
} catch (e) {
console.error(`Failed to cancel printerjob #${j.id}:`, e);
}
}
res.status(200).json({
success: true,
cancelledCount: cancelledJobs.length,
jobs: cancelledJobs,
});
} catch (error) {
const status = printerjobErrorStatus(error);
if (status === 500) console.error('Cancel all printerjobs failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /printerjob/{id}/cancel:
* post:
* tags: [PrinterJob]
* summary: Cancel printer job
* description: Marks a print job as cancelled and notifies the connected agent in real time.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Printer job cancelled
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* printerjob:
* $ref: '#/components/schemas/Printerjob'
*/
// #region POST /printerjob/:id/cancel
app.post(`${basePath}/printerjob/:id/cancel`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsed = parsePositiveInt(req.params.id, 'id');
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
try {
const job = await printerjobDb.updateState(req.user.id, parsed.id, 'cancelled');
if (job.printeragentId) {
notifyAgentJob(job.printeragentId, 'cancelled', job);
}
res.status(200).json({ success: true, printerjob: job });
} catch (error) {
const status = printerjobErrorStatus(error);
if (status === 500) console.error('Cancel printerjob failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /printerjob/{id}:
* delete:
* tags: [PrinterJob]
* summary: Cancel and delete printer job
* description: |
* Cancels and deletes a print job. If the job was pending or processing, sends a cancellation
* event to the connected printer agent before deletion.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Job cancelled and deleted
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* deletedJob:
* $ref: '#/components/schemas/Printerjob'
*/
// #region DELETE /printerjob/:id
app.delete(`${basePath}/printerjob/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsed = parsePositiveInt(req.params.id, 'id');
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
try {
const job = await printerjobDb.deleteJob(req.user.id, parsed.id);
if (job.printeragentId) {
notifyAgentJob(job.printeragentId, 'cancelled', job);
}
res.status(200).json({ success: true, deletedJob: job });
} catch (error) {
const status = printerjobErrorStatus(error);
if (status === 500) console.error('Delete printerjob failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -517,7 +517,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: idParsed.error });
}
const skus = parseSkus(req.query.skus ?? req.query.sku);
const skus = parseSkus(req.query.skus);
try {
const articles = await db.listArticlesBySkus(req.user.id, idParsed.id, skus);
@@ -540,7 +540,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: idParsed.error });
}
const skus = parseSkus(req.body?.skus ?? req.body?.sku);
const skus = parseSkus(req.body?.skus);
try {
const articles = await db.listArticlesBySkus(req.user.id, idParsed.id, skus);

View File

@@ -2,10 +2,13 @@ import fs from 'fs';
import path from 'path';
import multer from 'multer';
import { emit } from '../sse.js';
import { requireCompanyAdmin } from '../../db/companyuser.js';
import {
prepareVfs,
resolveVfsRoot,
isReadOnlyDatabasePath,
isProtectedAdminVfsPath,
directoryContainsProtectedAdminVfsFile,
getMimeType,
restoreDefaultSkills,
restoreDefaultApplets,
@@ -586,6 +589,8 @@ export function register(app, basePath = '') {
: '';
const uploaded = [];
const parsedEntries = [];
let touchesProtected = false;
for (const f of files) {
const origName = (f.originalname || 'upload').replace(/[\0\\]/g, '/');
const relPath = targetDir ? `${targetDir}/${origName}` : origName;
@@ -596,6 +601,18 @@ export function register(app, basePath = '') {
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) continue;
if (isProtectedAdminVfsPath(parsed.name)) {
touchesProtected = true;
}
parsedEntries.push({ f, parsed, absPath });
}
if (touchesProtected) {
await requireCompanyAdmin(req.user.id, selected.companyId);
}
for (const { f, parsed, absPath } of parsedEntries) {
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, f.buffer);
@@ -685,7 +702,7 @@ export function register(app, basePath = '') {
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const raw = req.body?.path || req.body?.dir || req.body?.name;
const raw = req.body?.path;
const parsed = parseVfsRef(raw);
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
@@ -787,8 +804,8 @@ export function register(app, basePath = '') {
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const rawOld = req.body?.oldPath || req.body?.from || req.body?.source;
const rawNew = req.body?.newPath || req.body?.to || req.body?.target;
const rawOld = req.body?.oldPath;
const rawNew = req.body?.newPath;
const parsedOld = parseVfsRef(rawOld);
if (parsedOld.error) {
return res.status(400).json({ error: `oldPath: ${parsedOld.error}` });
@@ -815,6 +832,15 @@ export function register(app, basePath = '') {
return res.status(404).json({ error: 'Source file or directory not found' });
}
if (
isProtectedAdminVfsPath(parsedOld.name) ||
isProtectedAdminVfsPath(parsedNew.name) ||
directoryContainsProtectedAdminVfsFile(absOld) ||
directoryContainsProtectedAdminVfsFile(absNew)
) {
await requireCompanyAdmin(req.user.id, selected.companyId);
}
fs.mkdirSync(path.dirname(absNew), { recursive: true });
fs.renameSync(absOld, absNew);
@@ -961,6 +987,9 @@ export function register(app, basePath = '') {
}
try {
if (isProtectedAdminVfsPath(parsed.name)) {
await requireCompanyAdmin(req.user.id, selected.companyId);
}
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) {
@@ -1058,6 +1087,9 @@ export function register(app, basePath = '') {
}
try {
if (isProtectedAdminVfsPath(parsed.name)) {
await requireCompanyAdmin(req.user.id, selected.companyId);
}
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) {
@@ -1066,6 +1098,9 @@ export function register(app, basePath = '') {
if (!fs.existsSync(absPath)) {
return res.status(404).json({ error: 'File not found' });
}
if (directoryContainsProtectedAdminVfsFile(absPath)) {
await requireCompanyAdmin(req.user.id, selected.companyId);
}
const stat = fs.statSync(absPath);
if (stat.isDirectory()) {
fs.rmSync(absPath, { recursive: true, force: true });

172
src/api/routes/zpljob.js Normal file
View File

@@ -0,0 +1,172 @@
import * as printerjobDb from '../../db/printerjob.js';
import { notifyAgentJob } from '../agent/index.js';
// #region Helpers
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId: Number(companyId) };
}
function parsePositiveInt(value, field) {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
return { error: `${field} must be a positive integer` };
}
return { id };
}
function printerjobErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Agentprinter not found') return 404;
if (error.message === 'Printerjob not found') return 404;
if (error.message?.includes('is required')) return 400;
if (error.message?.startsWith('Invalid printerjob')) return 400;
return 500;
}
/**
* Adjusts or inserts the ZPL print quantity command (^PQ) in the ZPL payload.
*
* @param {string} data - Raw ZPL string.
* @param {number|string} [copiescount=1] - Number of copies.
* @returns {string}
*/
export function applyZplCopies(data, copiescount) {
if (!data || typeof data !== 'string') return data;
const copies = Number(copiescount);
if (!Number.isInteger(copies) || copies <= 1) return data;
if (/\^PQ\d+/i.test(data)) {
return data.replace(/\^PQ\d+/gi, `^PQ${copies}`);
}
if (/\^XZ/i.test(data)) {
return data.replace(/\^XZ/gi, `^PQ${copies}\n^XZ`);
}
return `${data}\n^PQ${copies}`;
}
// #endregion
/**
* Registers zpljob routes.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /zpljob:
* post:
* tags: [PrinterJob]
* summary: Create ZPL print job
* description: |
* Issues a new ZPL print job to an agent printer. If copiescount > 1, the ZPL quantity command (^PQ)
* is automatically applied. The connected printer agent receives an immediate real-time event.
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [agentprinterId, data]
* properties:
* agentprinterId:
* type: integer
* description: Target agent printer ID
* example: 1
* copiescount:
* type: integer
* description: Optional copy count (defaults to 1)
* default: 1
* example: 2
* data:
* type: string
* description: Raw ZPL string
* example: "^XA^FO50,50^ADN,36,20^FDHello Zebra^FS^XZ"
* responses:
* 201:
* description: Print job created
* content:
* application/json:
* schema:
* type: object
* properties:
* printerjob:
* $ref: '#/components/schemas/Printerjob'
* 400:
* description: Invalid input or missing fields
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Agent printer not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /zpljob
app.post(`${basePath}/zpljob`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedApId = parsePositiveInt(req.body?.agentprinterId, 'agentprinterId');
if (parsedApId.error) {
return res.status(400).json({ error: parsedApId.error });
}
const rawData = req.body?.data;
if (!rawData || typeof rawData !== 'string' || !rawData.trim()) {
return res.status(400).json({ error: 'data is required' });
}
const rawCopies = req.body?.copiescount;
const copies = rawCopies != null ? Number(rawCopies) : 1;
if (!Number.isInteger(copies) || copies <= 0) {
return res.status(400).json({ error: 'copiescount must be a positive integer' });
}
const finalData = applyZplCopies(rawData.trim(), copies);
try {
const job = await printerjobDb.create(req.user.id, {
agentprinterId: parsedApId.id,
type: 'zpl',
data: finalData,
});
if (job.printeragentId) {
notifyAgentJob(job.printeragentId, 'created', job);
}
res.status(201).json({ printerjob: job });
} catch (error) {
const status = printerjobErrorStatus(error);
if (status === 500) console.error('Create zpljob failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -87,7 +87,7 @@ export function buildStudioApp() {
// 5. Insert row
app.post('/api/tables/:table/row', async (req, res) => {
try {
const result = await dbHelper.insertRow(req.params.table, req.body.row || req.body);
const result = await dbHelper.insertRow(req.params.table, req.body.row);
res.json(result);
} catch (err) {
res.status(400).json({ error: err.message });
@@ -175,7 +175,3 @@ export async function stopBackendMonitor() {
});
monitorLog.info('Backend Monitor Interface stopped');
}
// Aliases for compatibility
export const startSqlStudio = startBackendMonitor;
export const stopSqlStudio = stopBackendMonitor;

View File

@@ -1,4 +1,5 @@
import { getStore } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export async function list(googleAuthId) {
const store = await getStore();
@@ -6,11 +7,13 @@ export async function list(googleAuthId) {
}
export async function create(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.account.create(googleAuthId, data);
}
export async function remove(googleAuthId, id) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.account.delete(googleAuthId, id);
}

36
src/db/agentprinter.js Normal file
View File

@@ -0,0 +1,36 @@
import { getStore } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export async function list(googleAuthId, printeragentId) {
const store = await getStore();
return store.agentprinter.list(googleAuthId, printeragentId);
}
export async function getById(googleAuthId, id) {
const store = await getStore();
return store.agentprinter.getById(googleAuthId, id);
}
export async function findOrCreate(printeragentId, data) {
const store = await getStore();
return store.agentprinter.findOrCreate(printeragentId, data);
}
export async function update(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.agentprinter.update(googleAuthId, data);
}
export async function order(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.agentprinter.order(googleAuthId, data);
}
export async function remove(googleAuthId, id) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.agentprinter.delete(googleAuthId, id);
}

27
src/db/autoinvite.js Normal file
View File

@@ -0,0 +1,27 @@
import { getStore, getProvider } from './store.js';
export async function list(googleAuthId) {
const store = await getStore();
return store.autoinvite.list(googleAuthId);
}
export async function add(googleAuthId, data) {
const store = await getStore();
return store.autoinvite.add(googleAuthId, data);
}
export async function remove(googleAuthId, id) {
const store = await getStore();
return store.autoinvite.remove(googleAuthId, id);
}
export async function hasPendingInvite(email) {
if (!email || typeof email !== 'string') return false;
const normalized = email.trim().toLowerCase();
const provider = await getProvider();
const result = await provider.query(
'SELECT 1 AS found FROM autoinvite WHERE email = @email',
{ email: normalized }
);
return result.rows.length > 0;
}

View File

@@ -1,4 +1,5 @@
import { getStore } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export async function list(googleAuthId) {
const store = await getStore();
@@ -11,21 +12,25 @@ export async function getById(googleAuthId, id) {
}
export async function create(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.carrier.create(googleAuthId, data);
}
export async function update(googleAuthId, id, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.carrier.update(googleAuthId, id, data);
}
export async function order(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.carrier.order(googleAuthId, data);
}
export async function remove(googleAuthId, id) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.carrier.delete(googleAuthId, id);
}

View File

@@ -1,4 +1,5 @@
import { getStore } from './store.js';
import { getStore, getProvider } from './store.js';
import { requireCompanyAdmin } from './companyuser.js';
export async function listCompanies(authId) {
const store = await getStore();
@@ -15,12 +16,18 @@ export async function createCompany(googleAuthId, data) {
return store.company.create({ ...data, googleAuthId });
}
export async function updateCompany(id, data) {
export async function updateCompany(id, data, googleAuthId) {
if (googleAuthId != null) {
await requireCompanyAdmin(googleAuthId, id);
}
const store = await getStore();
return store.company.update(id, data);
}
export async function deleteCompany(companyId) {
export async function deleteCompany(companyId, googleAuthId) {
if (googleAuthId != null) {
await requireCompanyAdmin(googleAuthId, companyId);
}
const store = await getStore();
await store.company.delete(companyId);
}
@@ -34,3 +41,31 @@ export async function addCompanyHistory(googleAuthId, data) {
const store = await getStore();
return store.company.addHistory(googleAuthId, data);
}
export async function isInvitedUser(googleAuthId) {
const provider = await getProvider();
const isSqlite = provider?.dialect === 'sqlite3';
const sql = isSqlite
? `SELECT 1 AS is_creator
FROM companyuser cu
WHERE cu.google_auth_id = @googleAuthId
AND cu.id = (SELECT MIN(cu2.id) FROM companyuser cu2 WHERE cu2.company_id = cu.company_id)
LIMIT 1`
: `SELECT TOP 1 1 AS is_creator
FROM companyuser cu
WHERE cu.google_auth_id = @googleAuthId
AND cu.id = (SELECT MIN(cu2.id) FROM companyuser cu2 WHERE cu2.company_id = cu.company_id)`;
const creatorResult = await provider.query(sql, { googleAuthId });
if (creatorResult.rows.length > 0) {
return false;
}
const memberResult = await provider.query(
isSqlite
? `SELECT 1 AS is_member FROM companyuser WHERE google_auth_id = @googleAuthId LIMIT 1`
: `SELECT TOP 1 1 AS is_member FROM companyuser WHERE google_auth_id = @googleAuthId`,
{ googleAuthId }
);
return memberResult.rows.length > 0;
}

View File

@@ -1,4 +1,15 @@
import { getStore } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export const ADMIN_ONLY_SETTING_KEYS = new Set([
'ui.theme',
'keys.tavily',
'keys.openrouter',
'keys.serper',
'modelslugimage',
'systemprompt-prefix',
'systemprompt-suffix',
]);
export async function list(googleAuthId, options) {
const store = await getStore();
@@ -11,6 +22,9 @@ export async function getByKey(googleAuthId, key, options) {
}
export async function set(googleAuthId, input) {
if (ADMIN_ONLY_SETTING_KEYS.has(input?.key)) {
await requireSelectedCompanyAdmin(googleAuthId);
}
const store = await getStore();
return store.companysetting.set(googleAuthId, input);
}

70
src/db/companyuser.js Normal file
View File

@@ -0,0 +1,70 @@
import { getStore, getProvider } from './store.js';
export async function resolveCompanyAccess(googleAuthId, companyId) {
const provider = await getProvider();
const access = await provider.query(
`SELECT company_id, role FROM companyuser WHERE company_id = @companyId AND google_auth_id = @googleAuthId`,
{ companyId, googleAuthId }
);
const accessRow = access.rows[0] ?? null;
if (!accessRow) {
const exists = await provider.query(
`SELECT id FROM company WHERE id = @companyId`,
{ companyId }
);
if (!exists.rows[0]) {
throw new Error('Company not found');
}
throw new Error('Not authorized');
}
return {
companyId: accessRow.company_id,
role: accessRow.role ?? 'user',
};
}
export async function resolveSelectedCompanyAccess(googleAuthId) {
const store = await getStore();
const user = await store.google_auth.getById(googleAuthId);
const companyId = user?.selectedCompanyId ?? null;
if (companyId == null) {
throw new Error('No company selected');
}
return resolveCompanyAccess(googleAuthId, companyId);
}
export async function requireCompanyAdmin(googleAuthId, companyId) {
const ctx = await resolveCompanyAccess(googleAuthId, companyId);
if (ctx.role !== 'admin') {
throw new Error('Not authorized');
}
return ctx;
}
export async function requireSelectedCompanyAdmin(googleAuthId) {
const ctx = await resolveSelectedCompanyAccess(googleAuthId);
if (ctx.role !== 'admin') {
throw new Error('Not authorized');
}
return ctx;
}
export async function list(googleAuthId) {
const store = await getStore();
return store.companyuser.list(googleAuthId);
}
export async function addByEmail(googleAuthId, data) {
const store = await getStore();
return store.companyuser.addByEmail(googleAuthId, data);
}
export async function setRole(googleAuthId, id, role) {
const store = await getStore();
return store.companyuser.setRole(googleAuthId, id, role);
}
export async function remove(googleAuthId, id) {
const store = await getStore();
return store.companyuser.remove(googleAuthId, id);
}

View File

@@ -1,4 +1,5 @@
import { getStore } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export async function list(googleAuthId) {
const store = await getStore();
@@ -6,21 +7,25 @@ export async function list(googleAuthId) {
}
export async function create(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.customergroup.create(googleAuthId, data);
}
export async function update(googleAuthId, id, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.customergroup.update(googleAuthId, id, data);
}
export async function order(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.customergroup.order(googleAuthId, data);
}
export async function remove(googleAuthId, id) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.customergroup.delete(googleAuthId, id);
}

View File

@@ -1,4 +1,5 @@
import { getStore } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export async function list(googleAuthId) {
const store = await getStore();
@@ -6,6 +7,7 @@ export async function list(googleAuthId) {
}
export async function update(googleAuthId, id, input) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.numbers.update(googleAuthId, id, input);
}

View File

@@ -1,4 +1,5 @@
import { getStore, getProvider } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export async function list(googleAuthId) {
const store = await getStore();
@@ -6,11 +7,13 @@ export async function list(googleAuthId) {
}
export async function upsert(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.posterminal.upsert(googleAuthId, data);
}
export async function remove(googleAuthId, id) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.posterminal.delete(googleAuthId, id);
}

View File

@@ -1,4 +1,5 @@
import { getStore } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export async function list(googleAuthId) {
const store = await getStore();
@@ -11,21 +12,25 @@ export async function getById(googleAuthId, id) {
}
export async function create(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.printer.create(googleAuthId, data);
}
export async function update(googleAuthId, id, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.printer.update(googleAuthId, id, data);
}
export async function order(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.printer.order(googleAuthId, data);
}
export async function remove(googleAuthId, id) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.printer.delete(googleAuthId, id);
}

View File

@@ -1,4 +1,5 @@
import { getStore } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export async function list(googleAuthId) {
const store = await getStore();
@@ -6,16 +7,19 @@ export async function list(googleAuthId) {
}
export async function create(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.printeragent.create(googleAuthId, data);
}
export async function update(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.printeragent.update(googleAuthId, data);
}
export async function remove(googleAuthId, id) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.printeragent.delete(googleAuthId, id);
}

27
src/db/printerjob.js Normal file
View File

@@ -0,0 +1,27 @@
import { getStore } from './store.js';
export async function create(googleAuthId, input) {
const store = await getStore();
return store.printerjob.create(googleAuthId, input);
}
export async function getById(auth, id) {
const store = await getStore();
return store.printerjob.getById(auth, id);
}
export async function list(auth, filters) {
const store = await getStore();
return store.printerjob.list(auth, filters);
}
export async function updateState(auth, id, state) {
const store = await getStore();
return store.printerjob.updateState(auth, id, state);
}
export async function deleteJob(googleAuthId, id) {
const store = await getStore();
return store.printerjob.delete(googleAuthId, id);
}

View File

@@ -14,6 +14,7 @@ let schemaVersion;
const STORE_NAMESPACES = [
'companyuser',
'autoinvite',
'company',
'category',
'article',
@@ -42,6 +43,9 @@ const STORE_NAMESPACES = [
'ledger',
'warehouse',
'printer',
'printeragent',
'agentprinter',
'printerjob',
'labeltemplate',
'stock',
'propertyname',

View File

@@ -1,4 +1,5 @@
import { getStore } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export async function list(googleAuthId) {
const store = await getStore();
@@ -11,21 +12,25 @@ export async function getById(googleAuthId, id) {
}
export async function create(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.warehouse.create(googleAuthId, data);
}
export async function update(googleAuthId, id, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.warehouse.update(googleAuthId, id, data);
}
export async function order(googleAuthId, data) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.warehouse.order(googleAuthId, data);
}
export async function remove(googleAuthId, id) {
await requireSelectedCompanyAdmin(googleAuthId);
const store = await getStore();
return store.warehouse.delete(googleAuthId, id);
}

View File

@@ -56,21 +56,13 @@ function getProjectRoot() {
? JSON.parse(fs.readFileSync(nativeMapFile, 'utf8'))
: {};
// Redirect native .node loading to distDir and preload libvips from distDir if present
// Redirect native .node loading to distDir if present
const origDlopen = process.dlopen;
process.dlopen = function (mod, filename, flags) {
const relFromRuntime = path.relative(runtimeDir, filename);
const mappedName = nativeNodes[relFromRuntime] || path.basename(filename);
const distCandidate = path.join(distDir, mappedName);
const target = fs.existsSync(distCandidate) ? distCandidate : filename;
if (mappedName.startsWith('sharp-')) {
const vipsPath = path.join(distDir, 'libvips-cpp.so.8.18.7');
if (fs.existsSync(vipsPath)) {
try {
origDlopen.call(process, { exports: {} }, vipsPath, os.constants.dlopen.RTLD_NOW | os.constants.dlopen.RTLD_GLOBAL);
} catch {}
}
}
return flags !== undefined ? origDlopen.call(process, mod, target, flags) : origDlopen.call(process, mod, target);
};

View File

@@ -116,25 +116,21 @@ async function snapshotSqliteDatabase(targetFile) {
fs.unlinkSync(targetFile);
}
try {
const provider = await getProvider();
if (provider?.db && typeof provider.db.backup === 'function') {
await provider.db.backup(targetFile);
const stats = fs.statSync(targetFile);
return { success: true, sizeBytes: stats.size };
}
if (provider?.query) {
const escapedPath = targetFile.replace(/'/g, "''");
await provider.query(`VACUUM INTO '${escapedPath}'`);
const stats = fs.statSync(targetFile);
return { success: true, sizeBytes: stats.size };
}
} catch (err) {
backupLog.warn(`Online SQLite backup method failed (${err.message}), falling back to direct copy`);
const provider = await getProvider();
if (provider?.db && typeof provider.db.backup === 'function') {
await provider.db.backup(targetFile);
const stats = fs.statSync(targetFile);
return { success: true, sizeBytes: stats.size };
}
// Direct file fallback
if (provider?.query) {
const escapedPath = targetFile.replace(/'/g, "''");
await provider.query(`VACUUM INTO '${escapedPath}'`);
const stats = fs.statSync(targetFile);
return { success: true, sizeBytes: stats.size };
}
// Direct file copy when provider is offline / not connected
const { sqlitePath } = resolveBackupPaths();
if (fs.existsSync(sqlitePath)) {
fs.copyFileSync(sqlitePath, targetFile);

View File

@@ -0,0 +1,272 @@
import { emit } from '../api/sse.js';
import { createLogger } from '../util/logger.js';
const log = createLogger('printer');
/**
* Global in-memory registry of live printer states per company.
* Structure: Map<companyId, Map<agentId, Array<LivePrinter>>>
*
* @typedef {object} LivePrinter
* @property {number} agentId
* @property {string|null} agentName
* @property {string} updatedAt
* @property {string} [model]
* @property {string} [serial]
* @property {string} [name]
* @property {string} [ip]
* @property {string} [address]
* @property {number} [labelLength]
* @property {object} [errors]
* @property {boolean} [ready]
* @property {string} [status]
*/
/** @type {Map<number, Map<number, LivePrinter[]>>} */
const companyAgents = new Map();
/**
* Normalizes an array of printer objects reported by an agent and registers them for the given company.
* Broadcasts the updated whole company printer state via SSE.
*
* @param {number} companyId
* @param {number} agentId
* @param {string|null} agentName
* @param {Array<object>} rawPrinters
* @returns {LivePrinter[]} The complete list of live printers for this company across all agents.
*/
export function updateAgentPrinters(companyId, agentId, agentName, rawPrinters) {
const cId = Number(companyId);
const aId = Number(agentId);
if (!Number.isInteger(cId) || cId <= 0 || !Number.isInteger(aId) || aId <= 0) {
return [];
}
if (!companyAgents.has(cId)) {
companyAgents.set(cId, new Map());
}
const now = new Date().toISOString();
const printerList = Array.isArray(rawPrinters) ? rawPrinters : [];
const normalized = printerList.map((p) => {
const liveStatus = extractLiveStatus(p);
return {
...p,
agentId: aId,
agentName: agentName || null,
updatedAt: now,
...liveStatus,
};
});
companyAgents.get(cId).set(aId, normalized);
const allCompanyPrinters = getCompanyPrinters(cId);
log.info(`Updated ${normalized.length} live printer(s) for company ${cId} (agent ${aId}, total ${allCompanyPrinters.length})`);
broadcastCompanyPrinters(cId, allCompanyPrinters);
return allCompanyPrinters;
}
/**
* Removes all printers associated with a disconnected agent and broadcasts the updated company printer state.
*
* @param {number} companyId
* @param {number} agentId
* @returns {LivePrinter[]} The remaining live printers for this company.
*/
export function removeAgentPrinters(companyId, agentId) {
const cId = Number(companyId);
const aId = Number(agentId);
if (!Number.isInteger(cId) || cId <= 0 || !Number.isInteger(aId) || aId <= 0) {
return [];
}
const agentsMap = companyAgents.get(cId);
if (!agentsMap || !agentsMap.has(aId)) {
return getCompanyPrinters(cId);
}
const removedCount = agentsMap.get(aId)?.length || 0;
agentsMap.delete(aId);
if (agentsMap.size === 0) {
companyAgents.delete(cId);
}
const remainingPrinters = getCompanyPrinters(cId);
log.info(`Removed ${removedCount} live printer(s) for disconnected agent ${aId} from company ${cId} (${remainingPrinters.length} remaining)`);
broadcastCompanyPrinters(cId, remainingPrinters);
return remainingPrinters;
}
/**
* Returns all active discovered printers for a company across all reporting agents.
*
* @param {number} companyId
* @returns {LivePrinter[]}
*/
export function getCompanyPrinters(companyId) {
const cId = Number(companyId);
const agentsMap = companyAgents.get(cId);
if (!agentsMap) return [];
const result = [];
for (const printers of agentsMap.values()) {
result.push(...printers);
}
return result;
}
/**
* Finds a single live printer by ID, serial, IP/address, or name.
*
* @param {number} companyId
* @param {string|number} identifier
* @returns {LivePrinter|null}
*/
export function getCompanyPrinter(companyId, identifier) {
if (identifier == null) return null;
const key = String(identifier).trim().toLowerCase();
if (!key) return null;
const printers = getCompanyPrinters(companyId);
return printers.find((p) => {
if (p.id != null && String(p.id).toLowerCase() === key) return true;
if (p.serial && String(p.serial).toLowerCase() === key) return true;
if (p.ip && String(p.ip).toLowerCase() === key) return true;
if (p.address && String(p.address).toLowerCase() === key) return true;
if (p.name && String(p.name).toLowerCase() === key) return true;
return false;
}) || null;
}
/**
* Broadcasts the current live printer list for a company to all connected subscribers via SSE.
* Emits `agentprinter` with data `{ printers }`.
*
* @param {number} companyId
* @param {LivePrinter[]} [printers]
*/
export function broadcastCompanyPrinters(companyId, printers) {
const cId = Number(companyId);
if (!Number.isInteger(cId) || cId <= 0) return;
const list = printers ?? getCompanyPrinters(cId);
const payload = { printers: list };
emit('agentprinter', { companyId: cId, data: payload });
}
/**
* Extracts normalized boolean live status indicators and telemetry from live printer telemetry.
*
* @param {object|null|undefined} live
* @returns {{ open: boolean, pause: boolean, error: boolean, online: boolean, labelLength: number|null }}
*/
export function extractLiveStatus(live) {
if (!live) {
return {
open: false,
pause: false,
error: false,
online: false,
labelLength: null,
};
}
const errors = live.errors || {};
const isOffline = live.status === 'offline' || live.unreachable === true || errors.unreachable === true;
const online = !isOffline;
const open = Boolean(live.open || errors.headOpen);
const pause = Boolean(live.pause || errors.pause);
const hasOtherError = Boolean(
live.error ||
errors.other ||
Object.entries(errors).some(([key, val]) => !['headOpen', 'pause', 'unreachable'].includes(key) && Boolean(val))
);
const labelLength = live.labelLength != null && Number.isFinite(Number(live.labelLength))
? Number(live.labelLength)
: null;
return {
open,
pause,
error: hasOtherError,
online,
labelLength,
};
}
/**
* Finds a matching live printer from memory for a given database agentprinter record.
*
* @param {number} companyId
* @param {object} agentprinter
* @returns {LivePrinter|null}
*/
export function getLivePrinterForAgentPrinter(companyId, agentprinter) {
const printers = getCompanyPrinters(companyId);
if (!agentprinter || printers.length === 0) return null;
if (agentprinter.id != null) {
const match = printers.find((p) => p.agentprinterId === agentprinter.id);
if (match) return match;
}
if (agentprinter.printeragentId && agentprinter.serial) {
const match = printers.find((p) =>
Number(p.agentId) === Number(agentprinter.printeragentId) &&
p.serial &&
String(p.serial).trim().toLowerCase() === String(agentprinter.serial).trim().toLowerCase()
);
if (match) return match;
}
if (agentprinter.printeragentId && agentprinter.model) {
const match = printers.find((p) =>
Number(p.agentId) === Number(agentprinter.printeragentId) &&
p.model &&
String(p.model).trim().toLowerCase() === String(agentprinter.model).trim().toLowerCase() &&
(!agentprinter.type || !p.type || String(p.type).trim().toLowerCase() === String(agentprinter.type).trim().toLowerCase())
);
if (match) return match;
}
return null;
}
/**
* Enriches an agentprinter database record with live operational status (open, pause, error, online).
*
* @param {number} companyId
* @param {object} agentprinter
* @returns {object}
*/
export function enrichAgentPrinter(companyId, agentprinter) {
if (!agentprinter) return agentprinter;
const live = getLivePrinterForAgentPrinter(companyId, agentprinter);
const status = extractLiveStatus(live);
return {
...agentprinter,
...status,
};
}
/**
* Clears all live printers for a company (e.g. on company deletion).
*
* @param {number} companyId
*/
export function clearCompanyPrinters(companyId) {
const cId = Number(companyId);
if (companyAgents.has(cId)) {
companyAgents.delete(cId);
broadcastCompanyPrinters(cId, []);
}
}

View File

@@ -0,0 +1,72 @@
import * as labeltemplateDb from '../db/labeltemplate.js';
import { renderThumb } from './labelThumb.js';
const TEXTBOX_DEFAULTS = {
blackpoint: 128,
rotation: 0,
marginBottom: 0,
};
const DEFAULT_TEMPLATES = [
{
name: 'FD057X032Z1',
width: 448,
height: 256,
dpi: 203,
type: 'zpl',
content: {
objects: [
{
id: '4df08336-e936-40bf-b9d9-c7b08c5a1ad6', type: 'textbox', text: '{{name}}', font: 'outfit',
...TEXTBOX_DEFAULTS, minFontSize: 14, maxFontSize: 120, halign: 'center', valign: 'top',
invert: false, cornerRadius: 0, marginLeft: 0, marginTop: 0, marginRight: 0,
x: 9, y: 9, w: 427, h: 133,
},
{
id: 'a2037b05-8124-4a4d-85d1-270a7f9cdb8a', type: 'textbox', text: '{{baseprice}}', font: 'barlow-semi-condensed',
...TEXTBOX_DEFAULTS, minFontSize: 1, maxFontSize: 18, halign: 'right', valign: 'top',
invert: false, cornerRadius: 0, marginLeft: 0, marginTop: 0, marginRight: 0,
x: 12, y: 138, w: 188, h: 20,
},
{
id: '50e81524-04d3-4fa2-bca6-e5f346c27907', type: 'barcode',
x: 10, y: 162, h: 52, scale: 2, format: 'EAN13', code: '0',
},
{
id: '16443d4f-8d86-4253-bfbf-e98c44e6552a', type: 'textbox', text: '{{price}}', font: 'barlow-semi-condensed',
...TEXTBOX_DEFAULTS, minFontSize: 14, maxFontSize: 88, halign: 'center', valign: 'middle',
invert: true, cornerRadius: 30, marginLeft: 10, marginTop: 5, marginRight: 10,
x: 214, y: 146, w: 220, h: 83,
},
],
},
},
{
name: 'E025X013Z1',
width: 200,
height: 104,
dpi: 203,
type: 'zpl',
content: {
objects: [
{
id: 'd666154d-6571-41fb-ad50-1650a6218deb', type: 'textbox', text: '{{price}}', font: 'barlow-semi-condensed',
...TEXTBOX_DEFAULTS, minFontSize: 14, maxFontSize: 66, halign: 'center', valign: 'middle',
invert: true, cornerRadius: 24, marginLeft: 12, marginTop: 6, marginRight: 12,
x: 109, y: 2, w: 210, h: 91,
},
],
},
},
];
/**
* Creates the default label templates for the user's (newly selected) company.
* @param {number} googleAuthId
*/
export async function createDefaultLabelTemplates(googleAuthId) {
for (const tpl of DEFAULT_TEMPLATES) {
const thumb = await renderThumb({ width: tpl.width, height: tpl.height, objects: tpl.content.objects });
await labeltemplateDb.create(googleAuthId, { ...tpl, thumb });
}
}

View File

@@ -344,8 +344,8 @@ export async function runSandboxJob(options) {
throw new Error('Sandbox worker pool is shutting down');
}
const { poolKey, googleAuthId, code, timeoutMs, dashbackToken, appletInput, appletFile } = options;
const job = { code, timeoutMs, dashbackToken, appletInput, appletFile, poolKey, googleAuthId };
const { poolKey, googleAuthId, isAdmin = true, code, timeoutMs, dashbackToken, appletInput, appletFile } = options;
const job = { code, timeoutMs, dashbackToken, appletInput, appletFile, poolKey, googleAuthId, isAdmin };
const entry = await acquireWorker(poolKey, googleAuthId);
if (entry) {

View File

@@ -0,0 +1,76 @@
import { optimizeImage } from 'wasm-image-optimization';
/**
* Converts a PNG image buffer into Zebra ZPL II Graphic Field (^GFA) format.
* Uses Zebra's alternative compression scheme (ACS run-length encoding on hex)
* to minimize payload size over the network and serial/USB ports.
*
* @param {Buffer} pngBuffer - Rendered 1-bit or RGBA PNG buffer.
* @param {number} [quantity=1] - Number of copies to print (^PQ).
* @returns {Promise<string>} Full ZPL string ready to send to a Zebra printer.
*/
export async function pngToZpl(pngBuffer, quantity = 1) {
const raw = await optimizeImage({ image: pngBuffer, format: 'raw' });
const { width: w, height: h, data } = raw;
const bytesPerRow = Math.ceil(w / 8);
const totalBytes = bytesPerRow * h;
const buffer = new Uint8Array(totalBytes);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = (y * w + x) * 4;
const r = data[idx];
const g = data[idx + 1];
const b = data[idx + 2];
const a = data[idx + 3];
// Ink threshold: non-transparent and dark pixels are black (1)
const isBlack = a > 128 && (r * 0.299 + g * 0.587 + b * 0.114) < 128;
if (isBlack) {
const byteIdx = y * bytesPerRow + Math.floor(x / 8);
const bitIdx = 7 - (x % 8);
buffer[byteIdx] |= 1 << bitIdx;
}
}
}
const hexMap = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0').toUpperCase());
let hex = '';
for (let i = 0; i < totalBytes; i++) {
hex += hexMap[buffer[i]];
}
// Zebra ACS run-length encoding:
// Repeated characters are replaced by letters:
// 'G'..'Y' = 1..19 repeats
// 'g'..'y' = 20..380 repeats (step 20)
// 'z' = 400 repeats
let acs = '';
const re = /([0-9A-F])\1{2,}/g;
let match = re.exec(hex);
let offset = 0;
while (match) {
acs += hex.substring(offset, match.index);
let l = match[0].length;
while (l >= 400) {
acs += 'z';
l -= 400;
}
if (l >= 20) {
acs += '_ghijklmnopqrstuvwxy'[Math.floor(l / 20)];
l = l % 20;
}
if (l) {
acs += '_GHIJKLMNOPQRSTUVWXY'[l];
}
acs += match[1];
offset = re.lastIndex;
match = re.exec(hex);
}
acs += hex.substring(offset);
const qty = Number(quantity);
const pq = Number.isInteger(qty) && qty > 1 ? `^PQ${qty}\n` : '';
return `^XA^PW${w}^LL${h}^LS0^FT0,0^GFA,${totalBytes},${totalBytes},${bytesPerRow},${acs}\n${pq}^XZ`;
}

View File

@@ -4,7 +4,7 @@ import { optimizeImage } from 'wasm-image-optimization';
import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import { getImageByHash } from '../db/pic.js';
import { prepareVfs, syncVfsToDb, extractFullHash, isReadOnlyDatabasePath } from './vfs.js';
import { prepareVfs, extractFullHash, isReadOnlyDatabasePath } from './vfs.js';
import { resolveOpenRouterKey, resolveImageModel } from './llm-key.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
@@ -482,11 +482,7 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
fs.mkdirSync(path.dirname(absTarget), { recursive: true });
fs.writeFileSync(absTarget, finalBuffer);
try {
await syncVfsToDb({ googleAuthId, vfsRoot });
} catch (syncErr) {
console.warn('[edit_image] warning: failed to sync output image to modelvfs db:', syncErr.message);
}
const outFinalRel = path.relative(vfsRoot, absTarget).replace(/\\/g, '/');

View File

@@ -544,7 +544,7 @@ async function runJob(job) {
const appletInput = job.appletInput;
const appletFile = job.appletFile;
const vfs = createVfs(vfsDir, { googleAuthId });
const vfs = createVfs(vfsDir, { googleAuthId, isAdmin: job.isAdmin !== false });
const bridgedExcel = bridgeExcelJS(vfs);
const bridgedPDF = bridgePDFKit(vfs);

View File

@@ -4,7 +4,8 @@ import jwt from 'jsonwebtoken';
import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
import { createVfs, prepareVfs, syncVfsToDb } from './vfs.js';
import { createVfs, prepareVfs } from './vfs.js';
import { resolveSelectedCompanyAccess } from '../db/companyuser.js';
import { runSandboxJob } from '../services/javascriptSandbox.js';
const DEFAULT_TIMEOUT_MS = 20_000;
@@ -184,11 +185,21 @@ function toTypedCsv(result, columns) {
*
* @param {{ code: string, timeoutMs: number, vfsRoot: string, googleAuthId: number, appletInput?: object, appletFile?: string }} ctx
*/
function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId, appletInput, appletFile }) {
async function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId, appletInput, appletFile }) {
let isAdmin = true;
if (googleAuthId != null) {
try {
const access = await resolveSelectedCompanyAccess(googleAuthId);
isAdmin = access.role === 'admin';
} catch {
isAdmin = false;
}
}
const dashbackToken = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
return runSandboxJob({
poolKey: vfsRoot,
googleAuthId,
isAdmin,
code,
timeoutMs,
dashbackToken,
@@ -367,19 +378,14 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
const isApplet = hasApplet;
const applet = isApplet ? readApplet(vfsRoot, appletvfsfile) : null;
const source = isApplet ? applet.source : code;
let report;
try {
report = await runInWorker({
code: source,
timeoutMs: hardTimeout,
vfsRoot,
googleAuthId,
appletInput: isApplet ? (applet_input ?? {}) : undefined,
appletFile: isApplet ? appletvfsfile.trim() : undefined,
});
} finally {
await syncVfsToDb({ googleAuthId, vfsRoot });
}
const report = await runInWorker({
code: source,
timeoutMs: hardTimeout,
vfsRoot,
googleAuthId,
appletInput: isApplet ? (applet_input ?? {}) : undefined,
appletFile: isApplet ? appletvfsfile.trim() : undefined,
});
if (report.timedOut) {
throw new ToolError(

View File

@@ -1,9 +1,9 @@
import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { isReadOnlyDatabasePath } from './vfs.js';
import { isReadOnlyDatabasePath, isProtectedAdminVfsPath, prepareVfs } from './vfs.js';
import { requireSelectedCompanyAdmin } from '../db/companyuser.js';
import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js';
import { prepareVfs, syncVfsToDb } from './vfs.js';
import { TOOL_DESCRIPTIONS, PATCH_PARAMETER_DESCRIPTIONS } from './prompts.js';
import { getStore } from '../db/store.js';
@@ -155,6 +155,10 @@ export function createPatchTool({ googleAuthId }) {
);
}
if (isProtectedAdminVfsPath(clean)) {
await requireSelectedCompanyAdmin(googleAuthId);
}
// A SKILL file must be flat — reject an attempted nested path up front.
const looksSkillByExt = clean.toLowerCase().endsWith(SKILL_EXT.toLowerCase());
if (looksSkillByExt && clean.includes('/')) {
@@ -262,17 +266,7 @@ export function createPatchTool({ googleAuthId }) {
if (isSkill) touchedSkills.add(clean);
}
if (applied.length > 0) {
try {
await syncVfsToDb({ googleAuthId, vfsRoot });
} catch (err) {
console.error('[patch] failed to sync VFS to db:', err.message);
throw new ToolError(
`Patch applied to disk but could not be persisted: ${err.message}. ` +
`Files changed: ${applied.join(', ')}.`
);
}
}
const result = {
success: true,

View File

@@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { pdfToText, splitPdfPages, pdfToPng } from 'pdfconvert-wasm';
import { prepareVfs, syncVfsToDb } from './vfs.js';
import { prepareVfs } from './vfs.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
const IMAGE_FOLDER = 'images';
@@ -186,13 +186,7 @@ export function createPdfReadTool({ googleAuthId }) {
});
}
if (savedImages.length > 0) {
try {
await syncVfsToDb({ googleAuthId, vfsRoot });
} catch (err) {
console.warn('[pdf_read] failed to sync rendered images to VFS DB:', err?.message || err);
}
}
result.images = savedImages;
if (!result.totalPages) result.totalPages = images.length;

Some files were not shown because too many files have changed in this diff Show More