Compare commits

..

27 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
531 changed files with 13211 additions and 1475 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

@@ -98,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` |
@@ -140,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` |
@@ -173,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}` |
@@ -240,6 +248,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`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` |
@@ -350,10 +359,12 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| `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` |
@@ -361,7 +372,6 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| `modelconversation-<id>` | `POST /modelconversation/{id}/message` |
| `order` | `DELETE /order/{id}/file/{fileId}`, `POST /order/{id}/pdf`, `DELETE /shipment/{id}`, `PATCH /shipment/{id}`, `DELETE /shipment/{id}/file/{fileId}` |
| `printer` | `POST /printer`, `PUT /printer/order`, `DELETE /printer/{id}`, `PATCH /printer/{id}`, `PUT /printer/{id}` |
| `printerjob` | `POST /printerjob`, `DELETE /printerjob/{id}`, `POST /printerjob/{id}/cancel`, `POST /zpljob` |
| `propertyname` | `POST /propertyname`, `PUT /propertyname/order`, `DELETE /propertyname/{id}`, `PATCH /propertyname/{id}` |
| `quote` | `DELETE /quote/{id}/file/{fileId}`, `POST /quote/{id}/pdf` |
| `shipment` | `DELETE /shipment/{id}`, `PATCH /shipment/{id}`, `DELETE /shipment/{id}/file/{fileId}`, `POST /shipment/{id}/pdf` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 305 API endpoints across all modules.
Overview of all 314 API endpoints across all modules.
### Notation
@@ -9,17 +9,17 @@ 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: T20[], externalNumber?: str|null, createdAt?: str, shipping?: T23|null, shippings?: T23[], payment?: T32|null, payments?: T32[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T10[] }`
- `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?: T39 }`
- `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: 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, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T41|null, incoming?: { date: str, amount: num }[] }`
- `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: T40, storage: T40, database: T34 }`
- `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 }`
@@ -27,7 +27,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- `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 }`
- `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 }`
@@ -39,17 +39,18 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- `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, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T32` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T33` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
- `T34` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T35` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T36` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T37` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T38` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T39` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T40` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T41` = `{ net: num, taxId: int, taxRate?: num|null }`
- `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
@@ -96,7 +97,7 @@ _none_
- Params: `query: printeragentId?: int`
- Returns: `200 { agentprinters: T13[] }`
#### Reorder agent printers — `PUT /agentprinter/order`
- Params: `body: { printeragentId: int, agentprinterIds: int[] }`
- Params: `body: { agentprinterIds: int[], printeragentId?: int }`
- Returns: `200 { agentprinterIds: int[] }`
#### Delete agent printer — `DELETE /agentprinter/{id}`
- Params: `id: int`
@@ -182,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`
@@ -256,25 +257,37 @@ _none_
- 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: T36[] } }`
- 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: T36[] } }`
- 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: T35 }`
- Returns: `200 { message: str, user: T36 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: T35, 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: T35 }`
- 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
@@ -295,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: T36[], 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`
@@ -304,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: T36[] } }`
- 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: T36[] } }`
- 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: T33[] }`
- Returns: `200 { carriers: T34[] }`
#### Create carrier — `POST /carrier`
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `201 { carrier: T33 }`
- Returns: `201 { carrier: T34 }`
#### Reorder carriers — `PUT /carrier/order`
- Params: `body: { carrierIds: int[] }`
- Returns: `200 { carrierIds: int[] }`
@@ -325,10 +338,10 @@ _none_
- Returns: `204 void`
#### Get carrier by ID — `GET /carrier/{id}`
- Params: `id: int`
- Returns: `200 { carrier: T33 }`
- Returns: `200 { carrier: T34 }`
#### Update carrier — `PATCH /carrier/{id}`
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `200 { carrier: T33 }`
- Returns: `200 { carrier: T34 }`
### Section: /category
@@ -385,7 +398,7 @@ _none_
- Returns: `200 { companies: T19[] }`
#### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T19, user: T35 }`
- Returns: `201 { company: T19, user: T36 }`
#### Export company catalog as zip archive (SSE) — `GET /company/export`
- Params: `query: stream?: str`
- Returns: `200 SSE`
@@ -417,6 +430,21 @@ _none_
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- 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
#### List customer groups — `GET /customergroup`
@@ -439,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?: T39 }[], 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 }`
@@ -487,16 +515,16 @@ _none_
- Returns: `200 { address: T14 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T37[] }`
- Returns: `200 { contacts: T38[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T37 }`
- 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: T37 }`
- Returns: `200 { contact: T38 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -523,6 +551,8 @@ _none_
- `ledger`: `{}`
- `attributename`: `{}`
- `propertyname`: `{}`
- `companyuser`: `{}`
- `autoinvite`: `{}`
- `labeltemplate`: `{}`
- `printer`: `{}`
- `printeragent`: `{}`
@@ -558,6 +588,9 @@ _none_
#### Create label template — `POST /labeltemplate`
- 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`
@@ -683,10 +716,10 @@ _none_
- Returns: `200 str`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T32[] }`
- 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: T32 }`
- Returns: `201 { payment: T33 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T10, fileUrl: str }`
@@ -795,6 +828,9 @@ _none_
#### 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 }`
@@ -809,10 +845,10 @@ _none_
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T31[] }`
- Returns: `200 { propertynames: T32[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T31 }`
- Returns: `201 { propertyname: T32 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -821,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: T31 }`
- Returns: `200 { propertyname: T32 }`
### Section: /public
@@ -1016,22 +1052,22 @@ _none_
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T38[] }`
- Returns: `200 { suppliers: T39[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T38 }`
- 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: T38 }`
- 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?: T41|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?: T41|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

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 225 API endpoints across all modules.
Overview of all 196 API endpoints across all modules.
### Notation
@@ -9,74 +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: T14[], externalNumber?: str|null, createdAt?: str, shipping?: T17|null, shippings?: T17[], payment?: T25|null, payments?: T25[], 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: T14[], shippings: T17[], 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: T14[], 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: T18[], files: T7[], externalNumber?: str|null, createdAt?: str, ledger?: T30 }`
- `T5` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T31|null, incoming?: { date: str, amount: num }[] }`
- `T6` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T19[] }, thumb?: str|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, 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 }`
- `T9` = `{ 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 }`
- `T10` = `{ 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 }`
- `T11` = `{ 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 }`
- `T12` = `{ 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 }`
- `T13` = `{ id: int, name: str, sort: int, exclusive: bool, values: T23[], articleCount?: int }`
- `T14` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
- `T15` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
- `T16` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }`
- `T17` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T18` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T19` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T20` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T21` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T22` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T23` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T24` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T25` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T26` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
- `T27` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T28` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T29` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T30` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T31` = `{ net: num, taxId: int, taxRate?: num|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: T8 }`
### Section: /agentprinter
#### List agent printers — `GET /agentprinter`
- Params: `query: printeragentId?: int`
- Returns: `200 { agentprinters: T9[] }`
#### Reorder agent printers — `PUT /agentprinter/order`
- Params: `body: { printeragentId: int, agentprinterIds: 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: T9 }`
#### Update agent printer — `PATCH /agentprinter/{id}`
- Params: `id: int, body: { enabled?: bool, sort?: int }`
- Returns: `200 { agentprinter: T9 }`
### Section: /article
#### Create article in category — `POST /article`
@@ -90,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: T22, 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 }[] } }`
- 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`
@@ -145,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`
@@ -186,19 +149,19 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T13[] }`
- Returns: `200 { attributenames: T10[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T13 }`
- 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: T23[] }`
- Returns: `200 { values: T18[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T23 }`
- Returns: `201 { attributevalue: T18 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -207,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: T13 }`
- Returns: `200 { attributename: T10 }`
### Section: /attributevalue
@@ -216,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: T23 }`
- 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: T27[] } }`
- 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
@@ -228,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: T27[], 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`
@@ -237,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: T27[] } }`
- 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: T26[] }`
- Returns: `200 { carriers: T22[] }`
#### Create carrier — `POST /carrier`
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `201 { carrier: T26 }`
- Returns: `201 { carrier: T22 }`
#### Reorder carriers — `PUT /carrier/order`
- Params: `body: { carrierIds: int[] }`
- Returns: `200 { carrierIds: int[] }`
@@ -255,10 +230,10 @@ _none_
- Returns: `204 void`
#### Get carrier by ID — `GET /carrier/{id}`
- Params: `id: int`
- Returns: `200 { carrier: T26 }`
- Returns: `200 { carrier: T22 }`
#### Update carrier — `PATCH /carrier/{id}`
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `200 { carrier: T26 }`
- Returns: `200 { carrier: T22 }`
### Section: /category
@@ -306,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 }`
@@ -327,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
@@ -351,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?: T30 }[], 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 }`
@@ -375,52 +365,46 @@ _none_
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T21[] }`
- Returns: `200 { entities: T16[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 { entity: T21 }`
- 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: T10[] }`
- 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: T10 }`
- 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: T10 }`
- Returns: `200 { address: T8 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T28[] }`
- Returns: `200 { contacts: T24[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T28 }`
- 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: T28 }`
- 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: T21 }`
- Returns: `200 { entity: T16 }`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 { entity: T21 }`
### 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
@@ -428,8 +412,11 @@ _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: T19[] } }`
- 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`
@@ -437,26 +424,26 @@ _none_
- Params: `id: int`
- 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: T19[] } }`
- 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: T19[] } }`
- 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: T12[] }`
- 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: T12[] }`
- Returns: `200 { ledger: T9[] }`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 { ledger: T12[] }`
- Returns: `200 { ledger: T9[] }`
### Section: /number
@@ -473,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: T15[], 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`
@@ -482,17 +469,17 @@ _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?: T15[] }`
- 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: T25[] }`
- 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: T25 }`
- Returns: `201 { payment: T21 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T7, fileUrl: str }`
@@ -504,10 +491,10 @@ _none_
- Returns: `201 { shipment: T2 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T17[] }`
- 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: T17 }`
- Returns: `201 { shipping: T13 }`
### Section: /package
@@ -521,77 +508,14 @@ _none_
- Params: `id: int`
- Returns: `204 void`
### Section: /printer
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T20[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 { printer: T20 }`
#### 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: T20 }`
#### 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: T20 }`
#### 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: T20 }`
#### 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[] }`
#### Create printer agent — `PUT /printeragent`
- Params: `body: { name: str, sort?: int, enabled?: bool }`
- Returns: `200 { printeragent: T8 }`
#### 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, enabled?: bool }`
- Returns: `200 { printeragent: T8 }`
### Section: /printerjob
#### List printer jobs — `GET /printerjob`
- Params: `query: agentprinterId?: int, state?: str`
- Returns: `200 { printerjobs: T16[] }`
#### Create print job — `POST /printerjob`
- Params: `body: { agentprinterId: int, data: str, type?: str }`
- Returns: `201 { printerjob: T16 }`
#### Cancel and delete printer job — `DELETE /printerjob/{id}`
- Params: `id: int`
- Returns: `200 { success: bool, deletedJob: T16 }`
#### Get printer job — `GET /printerjob/{id}`
- Params: `id: int`
- Returns: `200 { printerjob: T16 }`
#### Cancel printer job — `POST /printerjob/{id}/cancel`
- Params: `id: int`
- Returns: `200 { success: bool, printerjob: T16 }`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T24[] }`
- Returns: `200 { propertynames: T20[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T24 }`
- Returns: `201 { propertyname: T20 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -600,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: T24 }`
- Returns: `200 { propertyname: T20 }`
### Section: /quote
@@ -608,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: T15[], 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`
@@ -617,13 +541,13 @@ _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?: T15[] }`
- 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?: T15[] }`
- 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`
@@ -699,10 +623,10 @@ _none_
- Returns: `201 { attachment: T7, fileUrl: str }`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T17[] }`
- 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: T17 }`
- Returns: `201 { shipping: T13 }`
### Section: /shipping
@@ -711,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: T17 }`
- 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 }[] }`
@@ -719,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`
@@ -732,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: T12[] }`
- 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: T11[] }`
#### 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: T11 }`
#### 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: T11 }`
#### 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: T11 }`
### Section: /supplier
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T29[] }`
- Returns: `200 { suppliers: T25[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T29 }`
- 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: T29 }`
- 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?: T31|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?: T31|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
@@ -783,19 +683,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T22[] }`
- Returns: `200 { units: T17[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T22 }`
### 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
@@ -820,12 +711,6 @@ _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: T16 }`
### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)
These routes are NOT in the table above because their payloads (binary request body or binary response) or long-lived streams cannot be handled usefully by the `dashback_request` tool. They ARE reachable from inside `javascript_execute` with `fetch("/route")`:

View File

@@ -11,7 +11,7 @@ Reorders agent printers for a specific printer agent within the selected company
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `printeragentId` | yes | integer | |
| `printeragentId` | no | integer | Optional agent id. If omitted, reorders all printers in the company. |
| `agentprinterIds` | yes | integer[] | |
```json

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

@@ -48,7 +48,8 @@ emits `{ sender, brandId }`.
| `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 |
| `printerjob` | `{ job }` or `{ jobId, state }` | `POST /zpljob`, `POST /printerjob`, `POST /printerjob/{id}/cancel`, `DELETE /printerjob/{id}`, agent job state updates |
| `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

@@ -22,10 +22,6 @@ Creates a new print job for an agent printer. Notifies the agent in real time.
}
```
## Signals
On success, emits SSE signal: `printerjob` (see [`GET /events`](../events.md)).
## Response `201`
Printer job created

View File

@@ -14,10 +14,6 @@ event to the connected printer agent before deletion.
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `printerjob` (see [`GET /events`](../events.md)).
## Response `200`
Job cancelled and deleted

View File

@@ -12,10 +12,6 @@ Marks a print job as cancelled and notifies the connected agent in real time.
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `printerjob` (see [`GET /events`](../events.md)).
## Response `200`
Printer job cancelled

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`._

View File

@@ -24,10 +24,6 @@ is automatically applied. The connected printer agent receives an immediate real
}
```
## Signals
On success, emits SSE signal: `printerjob` (see [`GET /events`](../events.md)).
## Response `201`
Print job created

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

@@ -215,7 +215,12 @@ const activeAgentEvents = new Map();
export function notifyAgentJob(agentId, action, job) {
const aId = Number(agentId);
const conns = activeAgentEvents.get(aId);
if (!conns || conns.size === 0) return false;
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,
@@ -227,7 +232,7 @@ export function notifyAgentJob(agentId, action, job) {
for (const clientRes of conns) {
if (!clientRes.writableEnded) {
clientRes.write(`event: job\ndata: ${payload}\n\n`);
clientRes.write(`event: printerjob\ndata: ${payload}\n\n`);
clientRes.flush?.();
}
}
return true;
@@ -269,7 +274,7 @@ export function notifyAgentJob(agentId, action, job) {
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');
@@ -298,7 +303,7 @@ agentRouter.get('/events', agentAuthMiddleware, async (req, res) => {
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;
@@ -362,7 +367,7 @@ 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 rawState = req.body?.state;
setAgentState(req.agent.id, rawState);
const version = extractAgentVersion(req);
@@ -372,9 +377,7 @@ async function handleState(req, res) {
await db.touchLastseen(req.agent.id);
}
const rawPrinters = Array.isArray(rawState?.printers)
? rawState.printers
: (Array.isArray(req.body?.printers) ? req.body.printers : null);
const rawPrinters = Array.isArray(rawState?.printers) ? rawState.printers : null;
let anyAgentPrinterCreated = false;
if (rawPrinters && rawPrinters.length > 0) {
@@ -399,6 +402,18 @@ async function handleState(req, res) {
}
}
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) {
@@ -465,9 +480,6 @@ agentRouter.get('/jobs/:id', agentAuthMiddleware, async (req, res) => {
agentRouter.post('/jobs/:id/processing', agentAuthMiddleware, async (req, res) => {
try {
const job = await printerjobDb.updateState({ agentId: req.agent.id }, req.params.id, 'processing');
if (req.agent?.companyId) {
emit('printerjob', { companyId: req.agent.companyId, data: { job } });
}
res.status(200).json({ success: true, job });
} catch (err) {
if (err.message === 'Printerjob not found') return res.status(404).json({ error: err.message });
@@ -479,9 +491,6 @@ agentRouter.post('/jobs/:id/processing', agentAuthMiddleware, async (req, res) =
agentRouter.post('/jobs/:id/completed', agentAuthMiddleware, async (req, res) => {
try {
const job = await printerjobDb.updateState({ agentId: req.agent.id }, req.params.id, 'completed');
if (req.agent?.companyId) {
emit('printerjob', { companyId: req.agent.companyId, data: { job } });
}
res.status(200).json({ success: true, job });
} catch (err) {
if (err.message === 'Printerjob not found') return res.status(404).json({ error: err.message });
@@ -497,9 +506,6 @@ agentRouter.patch('/jobs/:id', agentAuthMiddleware, async (req, res) => {
}
try {
const job = await printerjobDb.updateState({ agentId: req.agent.id }, req.params.id, state);
if (req.agent?.companyId) {
emit('printerjob', { companyId: req.agent.companyId, data: { job } });
}
res.status(200).json({ success: true, job });
} catch (err) {
if (err.message === 'Printerjob not found') return res.status(404).json({ error: err.message });

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

@@ -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]

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,11 +2,14 @@ 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';
@@ -86,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;
}
@@ -185,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
@@ -208,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);
@@ -217,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) {
@@ -479,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)) {
@@ -948,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) {
@@ -969,10 +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

@@ -57,7 +57,8 @@ export function register(app, basePath = '') {
* | `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 |
* | `printerjob` | `{ job }` or `{ jobId, state }` | `POST /zpljob`, `POST /printerjob`, `POST /printerjob/{id}/cancel`, `DELETE /printerjob/{id}`, agent job state updates |
* | `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}` |
@@ -96,6 +97,10 @@ export function register(app, basePath = '') {
* payload: "{}"
* - name: propertyname
* payload: "{}"
* - name: companyuser
* payload: "{}"
* - name: autoinvite
* payload: "{}"
* - name: labeltemplate
* payload: "{}"
* - name: printer

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,4 @@
import * as printerjobDb from '../../db/printerjob.js';
import { emit } from '../sse.js';
import { notifyAgentJob } from '../agent/index.js';
// #region Helpers
@@ -188,7 +187,6 @@ export function register(app, basePath = '') {
* description: Creates a new print job for an agent printer. Notifies the agent in real time.
* security:
* - cookieAuth: []
* x-signals: [printerjob]
* requestBody:
* required: true
* content:
@@ -223,7 +221,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: selected.error });
}
const rawApId = req.body?.agentprinterId ?? req.body?.agentprinterid;
const rawApId = req.body?.agentprinterId;
const parsedApId = parsePositiveInt(rawApId, 'agentprinterId');
if (parsedApId.error) {
return res.status(400).json({ error: parsedApId.error });
@@ -249,7 +247,6 @@ export function register(app, basePath = '') {
notifyAgentJob(job.printeragentId, 'created', job);
}
emit('printerjob', { companyId: selected.companyId, data: { job } });
res.status(201).json({ printerjob: job });
} catch (error) {
const status = printerjobErrorStatus(error);
@@ -271,7 +268,6 @@ export function register(app, basePath = '') {
* Notifies connected agents in real time.
* security:
* - cookieAuth: []
* x-signals: [printerjob]
* requestBody:
* content:
* application/json:
@@ -306,7 +302,7 @@ export function register(app, basePath = '') {
}
try {
const rawApId = req.body?.agentprinterId ?? req.query?.agentprinterId;
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');
@@ -318,7 +314,6 @@ export function register(app, basePath = '') {
if (job.printeragentId) {
notifyAgentJob(job.printeragentId, 'cancelled', job);
}
emit('printerjob', { companyId: selected.companyId, data: { job } });
cancelledJobs.push(job);
} catch (e) {
console.error(`Failed to cancel printerjob #${j.id}:`, e);
@@ -348,7 +343,6 @@ export function register(app, basePath = '') {
* description: Marks a print job as cancelled and notifies the connected agent in real time.
* security:
* - cookieAuth: []
* x-signals: [printerjob]
* parameters:
* - in: path
* name: id
@@ -388,7 +382,6 @@ export function register(app, basePath = '') {
notifyAgentJob(job.printeragentId, 'cancelled', job);
}
emit('printerjob', { companyId: selected.companyId, data: { job } });
res.status(200).json({ success: true, printerjob: job });
} catch (error) {
const status = printerjobErrorStatus(error);
@@ -410,7 +403,6 @@ export function register(app, basePath = '') {
* event to the connected printer agent before deletion.
* security:
* - cookieAuth: []
* x-signals: [printerjob]
* parameters:
* - in: path
* name: id
@@ -450,10 +442,6 @@ export function register(app, basePath = '') {
notifyAgentJob(job.printeragentId, 'cancelled', job);
}
emit('printerjob', {
companyId: selected.companyId,
data: { jobId: job.id, state: 'cancelled' },
});
res.status(200).json({ success: true, deletedJob: job });
} catch (error) {
const status = printerjobErrorStatus(error);

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 });

View File

@@ -1,5 +1,4 @@
import * as printerjobDb from '../../db/printerjob.js';
import { emit } from '../sse.js';
import { notifyAgentJob } from '../agent/index.js';
// #region Helpers
@@ -73,7 +72,6 @@ export function register(app, basePath = '') {
* is automatically applied. The connected printer agent receives an immediate real-time event.
* security:
* - cookieAuth: []
* x-signals: [printerjob]
* requestBody:
* required: true
* content:
@@ -132,8 +130,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: selected.error });
}
const rawApId = req.body?.agentprinterId ?? req.body?.agentprinterid;
const parsedApId = parsePositiveInt(rawApId, 'agentprinterId');
const parsedApId = parsePositiveInt(req.body?.agentprinterId, 'agentprinterId');
if (parsedApId.error) {
return res.status(400).json({ error: parsedApId.error });
}
@@ -143,7 +140,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: 'data is required' });
}
const rawCopies = req.body?.copiescount ?? req.body?.copiesCount ?? req.body?.copies;
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' });
@@ -162,7 +159,6 @@ export function register(app, basePath = '') {
notifyAgentJob(job.printeragentId, 'created', job);
}
emit('printerjob', { companyId: selected.companyId, data: { job } });
res.status(201).json({ printerjob: job });
} catch (error) {
const status = printerjobErrorStatus(error);

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);
}

View File

@@ -1,4 +1,5 @@
import { getStore } from './store.js';
import { requireSelectedCompanyAdmin } from './companyuser.js';
export async function list(googleAuthId, printeragentId) {
const store = await getStore();
@@ -16,16 +17,19 @@ export async function 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);
}

View File

@@ -14,6 +14,7 @@ let schemaVersion;
const STORE_NAMESPACES = [
'companyuser',
'autoinvite',
'company',
'category',
'article',

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

@@ -180,19 +180,17 @@ export function extractLiveStatus(live) {
const isOffline = live.status === 'offline' || live.unreachable === true || errors.unreachable === true;
const online = !isOffline;
const open = Boolean(live.open ?? live.headOpen ?? errors.headOpen ?? false);
const pause = Boolean(live.pause ?? live.paused ?? errors.pause ?? false);
const open = Boolean(live.open || errors.headOpen);
const pause = Boolean(live.pause || errors.pause);
const hasOtherError = Boolean(
live.error ||
errors.other ||
errors.error ||
Object.entries(errors).some(([key, val]) => !['headOpen', 'pause', 'unreachable'].includes(key) && Boolean(val))
);
const rawLabelLength = live.labelLength ?? live.labellength;
const labelLength = rawLabelLength != null && Number.isFinite(Number(rawLabelLength))
? Number(rawLabelLength)
const labelLength = live.labelLength != null && Number.isFinite(Number(live.labelLength))
? Number(live.labelLength)
: null;
return {

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;

View File

@@ -2,7 +2,8 @@ import fs from 'fs';
import path from 'path';
import { defineTool } from 'libllmorbiter';
import { isSkillPath } from './skill.js';
import { resolveVfsRoot } from './vfs.js';
import { resolveVfsRoot, isProtectedAdminVfsPath, directoryContainsProtectedAdminVfsFile } from './vfs.js';
import { resolveSelectedCompanyAccess, requireSelectedCompanyAdmin } from '../db/companyuser.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
/**
@@ -40,6 +41,11 @@ export function createResetVfsTool({ googleAuthId }) {
},
},
invoke: async ({ include_skills = false } = {}) => {
if (include_skills === true) {
await requireSelectedCompanyAdmin(googleAuthId);
}
const access = await resolveSelectedCompanyAccess(googleAuthId);
const isAdmin = access.role === 'admin';
const keepSkills = include_skills !== true;
const keptSkills = [];
const vfsRoot = await resolveVfsRoot(googleAuthId);
@@ -51,8 +57,12 @@ export function createResetVfsTool({ googleAuthId }) {
if (keepSkills && isSkillPath(entry)) {
continue;
}
const fullPath = path.join(vfsRoot, entry);
if (!isAdmin && (isProtectedAdminVfsPath(entry) || directoryContainsProtectedAdminVfsFile(fullPath))) {
continue;
}
try {
fs.rmSync(path.join(vfsRoot, entry), { recursive: true, force: true });
fs.rmSync(fullPath, { recursive: true, force: true });
deletedCount += 1;
} catch (err) {
console.warn(`[reset_vfs] could not remove "${entry}": ${err.message}`);

View File

@@ -1,7 +1,8 @@
import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js';
import { isReadOnlyDatabasePath, isProtectedAdminVfsPath, prepareVfs } from './vfs.js';
import { requireSelectedCompanyAdmin } from '../db/companyuser.js';
import { isSkillPath } from './skill.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
@@ -94,6 +95,10 @@ export function createRmTool({ googleAuthId }) {
);
}
if (isProtectedAdminVfsPath(clean)) {
await requireSelectedCompanyAdmin(googleAuthId);
}
const vfsRoot = await prepareVfs({ googleAuthId });
const { abs, size } = deleteVfsFile(vfsRoot, clean);

View File

@@ -3,7 +3,7 @@ import path from 'path';
import { optimizeImage } from 'wasm-image-optimization';
import { defineTool, ToolError } from 'libllmorbiter';
import * as dbCompanySetting from '../db/companysetting.js';
import { prepareVfs, syncVfsToDb } from './vfs.js';
import { prepareVfs } from './vfs.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
const SERPER_ENDPOINT = 'https://google.serper.dev/images';
@@ -249,16 +249,7 @@ export function createSearchImagesTool({ googleAuthId }) {
});
}
if (results.length > 0) {
try {
await syncVfsToDb({ googleAuthId, vfsRoot });
} catch (err) {
// Non-fatal: the files are on disk and will be picked up by the next
// javascript_execute / patch sync. Losing the images because a DB write
// failed would be a worse outcome than a slightly stale store.
console.warn('[search_images] failed to persist candidates:', err.message);
}
}
const result = {
query: term,

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { getStore } from '../db/store.js';
import { requireSelectedCompanyAdmin } from '../db/companyuser.js';
import { getDefaultSkills } from './skill.js';
import { getDefaultApplets, DEFAULT_APPLETS_VERSION } from './applet.js';
@@ -97,6 +98,39 @@ export function isReadOnlyDatabasePath(filePath) {
return lower === 'image' || lower.startsWith('image/') || Boolean(extractFullHash(clean));
}
export function isProtectedAdminVfsPath(filePath) {
if (typeof filePath !== 'string' || !filePath.trim()) return false;
const clean = cleanVirtualPath(filePath, true);
const lower = clean.toLowerCase();
return (
lower.endsWith('.skill') ||
lower.endsWith('.applet.js') ||
lower.endsWith('.apple.js') ||
lower === 'apple.js' ||
lower.endsWith('/apple.js')
);
}
export function directoryContainsProtectedAdminVfsFile(absDirPath) {
try {
if (!fs.existsSync(absDirPath)) return false;
const stat = fs.statSync(absDirPath);
if (!stat.isDirectory()) return isProtectedAdminVfsPath(absDirPath);
const entries = fs.readdirSync(absDirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(absDirPath, entry.name);
if (entry.isDirectory()) {
if (directoryContainsProtectedAdminVfsFile(fullPath)) return true;
} else if (isProtectedAdminVfsPath(entry.name)) {
return true;
}
}
return false;
} catch {
return false;
}
}
function assertNotReadOnlyDatabasePath(filePath) {
if (isReadOnlyDatabasePath(filePath)) {
throw new Error(
@@ -190,6 +224,9 @@ export async function loadDefaultSkills(ctx = {}) {
* @returns {Promise<Array<string>>}
*/
export async function restoreDefaultSkills(ctx = {}) {
if (ctx.googleAuthId != null) {
await requireSelectedCompanyAdmin(ctx.googleAuthId);
}
return seedVfsFiles(ctx, getDefaultSkills(), { overwrite: true, label: 'skill' });
}
@@ -208,6 +245,9 @@ export async function loadDefaultApplets(ctx = {}) {
* @returns {Promise<Array<string>>}
*/
export async function restoreDefaultApplets(ctx = {}) {
if (ctx.googleAuthId != null) {
await requireSelectedCompanyAdmin(ctx.googleAuthId);
}
return seedVfsFiles(ctx, getVersionedApplets(), { overwrite: true, label: 'applet' });
}
@@ -257,14 +297,6 @@ export async function prepareVfs({ googleAuthId }) {
return vfsRoot;
}
/**
* Purely disk-based VFS; no database syncing needed. Kept as no-op for backward compatibility.
* @param {{ googleAuthId?: number, vfsRoot?: string }} [ctx]
*/
export async function syncVfsToDb() {
// Purely disk-based VFS; no database syncing.
}
// #endregion
// #region Sandboxed VFS Factory
@@ -301,7 +333,14 @@ function cbWrap(syncFn, arityWithOpt = 3) {
* @param {{ googleAuthId: number }} [options]
*/
export function createVfs(vfsRoot, options = {}) {
const { googleAuthId } = options;
const { googleAuthId, isAdmin = true } = options;
const assertWritablePath = (filePath) => {
assertNotReadOnlyDatabasePath(filePath);
if (!isAdmin && isProtectedAdminVfsPath(filePath)) {
throw new Error('Not authorized');
}
};
// #region Internal Sandbox Helpers
// MARK: - Internal Sandbox Helpers
@@ -413,7 +452,7 @@ export function createVfs(vfsRoot, options = {}) {
// --- Write & Append Operations ---
writeFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
assertWritablePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
try {
@@ -424,14 +463,14 @@ export function createVfs(vfsRoot, options = {}) {
},
createWriteStream: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
assertWritablePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.createWriteStream(diskPath, opt);
},
appendFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
assertWritablePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
try {
@@ -443,7 +482,7 @@ export function createVfs(vfsRoot, options = {}) {
// --- Copy, Rename & Truncate Operations ---
copyFileSync: (src, dest, mode) => {
assertNotReadOnlyDatabasePath(dest);
assertWritablePath(dest);
const hash = extractFullHash(src);
if (hash) {
const buf = syncFetchImage(hash);
@@ -460,7 +499,7 @@ export function createVfs(vfsRoot, options = {}) {
},
cpSync: (src, dest, opt) => {
assertNotReadOnlyDatabasePath(dest);
assertWritablePath(dest);
const srcDisk = safeResolve(src);
const destDisk = safeResolve(dest);
fs.mkdirSync(path.dirname(destDisk), { recursive: true });
@@ -472,10 +511,13 @@ export function createVfs(vfsRoot, options = {}) {
},
renameSync: (oldPath, newPath) => {
assertNotReadOnlyDatabasePath(oldPath);
assertNotReadOnlyDatabasePath(newPath);
assertWritablePath(oldPath);
assertWritablePath(newPath);
const oldDisk = safeResolve(oldPath);
const newDisk = safeResolve(newPath);
if (!isAdmin && (directoryContainsProtectedAdminVfsFile(oldDisk) || directoryContainsProtectedAdminVfsFile(newDisk))) {
throw new Error('Not authorized');
}
fs.mkdirSync(path.dirname(newDisk), { recursive: true });
try {
return fs.renameSync(oldDisk, newDisk);
@@ -485,7 +527,7 @@ export function createVfs(vfsRoot, options = {}) {
},
truncateSync: (p, len) => {
assertNotReadOnlyDatabasePath(p);
assertWritablePath(p);
const diskPath = safeResolve(p);
try {
return fs.truncateSync(diskPath, len);
@@ -608,7 +650,7 @@ export function createVfs(vfsRoot, options = {}) {
},
unlinkSync: (p) => {
assertNotReadOnlyDatabasePath(p);
assertWritablePath(p);
const diskPath = safeResolve(p);
try {
return fs.unlinkSync(diskPath);
@@ -618,8 +660,11 @@ export function createVfs(vfsRoot, options = {}) {
},
rmdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
assertWritablePath(p);
const diskPath = safeResolve(p);
if (!isAdmin && directoryContainsProtectedAdminVfsFile(diskPath)) {
throw new Error('Not authorized');
}
try {
return fs.rmdirSync(diskPath, opt);
} catch (err) {
@@ -628,8 +673,11 @@ export function createVfs(vfsRoot, options = {}) {
},
rmSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
assertWritablePath(p);
const diskPath = safeResolve(p);
if (!isAdmin && directoryContainsProtectedAdminVfsFile(diskPath)) {
throw new Error('Not authorized');
}
try {
return fs.rmSync(diskPath, opt);
} catch (err) {
@@ -670,7 +718,7 @@ export function createVfs(vfsRoot, options = {}) {
writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt),
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
copyFile: async (src, dest, mode) => {
assertNotReadOnlyDatabasePath(dest);
assertWritablePath(dest);
const hash = extractFullHash(src);
if (hash) {
const buf = await vfs.promises.readFile(src);
@@ -703,7 +751,7 @@ export function createVfs(vfsRoot, options = {}) {
* containment + read-only guards as `writeFileSync`.
*/
resolveWritePath: (p) => {
assertNotReadOnlyDatabasePath(p);
assertWritablePath(p);
return safeResolve(p);
},

View File

@@ -13,7 +13,7 @@ const skeleCache = new Map();
export async function formatAttachment(file) {
if (!file || typeof file !== 'object') return file;
let skeledash = file.skeledash || file.skelehash || file.meta?.skeledash || file.meta?.skelehash || null;
let skeledash = file.skeledash || file.meta?.skeledash || null;
if (!skeledash && file.hash && skeleCache.has(file.hash)) {
skeledash = skeleCache.get(file.hash);
@@ -36,7 +36,6 @@ export async function formatAttachment(file) {
return {
...rest,
skeledash,
skelehash: skeledash,
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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