Compare commits

...

10 Commits

Author SHA1 Message Date
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
51 changed files with 1767 additions and 320 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

@@ -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` |
@@ -352,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` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 307 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
@@ -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`: `{}`
@@ -686,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 }`
@@ -815,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 }`
@@ -827,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
@@ -1022,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 227 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: { agentprinterIds: int[], printeragentId?: int }`
- Returns: `200 { agentprinterIds: int[] }`
#### Delete agent printer — `DELETE /agentprinter/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get agent printer — `GET /agentprinter/{id}`
- Params: `id: int`
- Returns: `200 { agentprinter: 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`
@@ -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,7 +412,7 @@ _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 }`
@@ -440,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
@@ -476,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`
@@ -485,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 }`
@@ -507,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
@@ -524,80 +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 all pending and processing printer jobs — `POST /printerjob/cancel-all`
- Params: `body?: { agentprinterId?: int }`
- Returns: `200 { success: bool, cancelledCount: int, jobs: 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 }`
@@ -606,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
@@ -614,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`
@@ -623,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`
@@ -705,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
@@ -717,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 }[] }`
@@ -725,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`
@@ -738,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
@@ -789,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
@@ -826,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

@@ -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,6 +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 |
| `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

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

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

@@ -14,6 +14,7 @@ import {
parseFullHash,
articleErrorStatus,
interpolateString,
withMandatedOptionsPrice,
interpolateObjects,
} from './shared.js';
@@ -408,7 +409,7 @@ export function register(app, basePath = '') {
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);

View File

@@ -1,4 +1,5 @@
import { emit } from '../../sse.js';
import * as articleDb from '../../../db/article.js';
// #region Helpers
@@ -127,6 +128,48 @@ export function formatPrice(amount) {
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 basePrice = Array.isArray(article?.prices) && article.prices.length > 0
@@ -138,12 +181,15 @@ export function interpolateString(str, article) {
const taxRate = basePrice?.taxRate != null && Number.isFinite(Number(basePrice.taxRate))
? Number(basePrice.taxRate)
: 0;
const gross = net != null
? Math.round((net * (1 + taxRate / 100) + Number.EPSILON) * 100) / 100
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(/\{\{name\}\}/gi, article?.name || '')
.replace(/\{\{baseprice\}\}/gi, formatBasePrice(grossRaw, article))
.replace(/\{\{price\}\}/gi, formatPrice(gross));
}

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

@@ -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,6 +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 |
* | `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}` |
@@ -95,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

@@ -8,7 +8,7 @@ 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 } from './article/shared.js';
import { interpolateString, interpolateObjects, withMandatedOptionsPrice } from './article/shared.js';
// #region Helpers
@@ -102,7 +102,7 @@ export function register(app, basePath = '') {
const skuStr = String(req.query.sku).trim();
const result = await articleDb.getBySku(req.user.id, skuStr);
if (result?.article) {
article = result.article;
article = await withMandatedOptionsPrice(req.user.id, result.article);
}
}
@@ -220,7 +220,7 @@ export function register(app, basePath = '') {
: null;
if (objects && objects.length > 0) {
try {
const interpolated = interpolateObjects(objects, result.article);
const interpolated = interpolateObjects(objects, await withMandatedOptionsPrice(req.user.id, result.article));
const thumbBuffer = await renderThumb({
width: template.width,
height: template.height,
@@ -655,7 +655,7 @@ export function register(app, basePath = '') {
if (!result?.article) {
return res.status(404).json({ error: 'Article not found' });
}
article = result.article;
article = await withMandatedOptionsPrice(req.user.id, result.article);
} else {
article = {
name: template?.name || 'Musterartikel',

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);
@@ -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,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

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

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

@@ -5,6 +5,7 @@ import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.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,

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 } 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('/')) {

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

@@ -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' });
}
@@ -293,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
@@ -405,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 {
@@ -416,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 {
@@ -435,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);
@@ -452,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 });
@@ -464,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);
@@ -477,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);
@@ -600,7 +650,7 @@ export function createVfs(vfsRoot, options = {}) {
},
unlinkSync: (p) => {
assertNotReadOnlyDatabasePath(p);
assertWritablePath(p);
const diskPath = safeResolve(p);
try {
return fs.unlinkSync(diskPath);
@@ -610,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) {
@@ -620,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) {
@@ -662,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);
@@ -695,7 +751,7 @@ export function createVfs(vfsRoot, options = {}) {
* containment + read-only guards as `writeFileSync`.
*/
resolveWritePath: (p) => {
assertNotReadOnlyDatabasePath(p);
assertWritablePath(p);
return safeResolve(p);
},