Compare commits

..

4 Commits

Author SHA1 Message Date
seb
ac10840fb6 u 2026-10-09 19:02:15 +02:00
seb
cd6879f3b7 u 2026-10-09 18:25:56 +02:00
seb
daf2d0525f u 2026-10-09 18:10:49 +02:00
seb
848ca0aff5 u 2026-10-09 17:55:45 +02:00
51 changed files with 929 additions and 140 deletions

View File

@@ -0,0 +1,42 @@
---
name: database-schema-updates
description: "Use when changing or diagnosing the Dash database schema, applying a local schema patch, or rerunning the off-repository development migration script."
---
# Database Schema Updates
Use this workflow for schema changes and local database updates in the Dashback + `dashdbschema` setup.
## Project map
- The schema/store repository is the sibling [`dashdbschema`](../../../../dashdbschema) (`libdashstore`).
- MSSQL schema DDL: [`schema.js`](../../../../dashdbschema/src/providers/mssql/schema.js).
- SQLite schema DDL: [`schema.js`](../../../../dashdbschema/src/providers/sqlite3/schema.js).
- Provider behavior: `../../../../dashdbschema/src/providers/{mssql,sqlite3}/`.
- Store implementation: `../../../../dashdbschema/src/store/`; SQL queries: `../../../../dashdbschema/src/providers/mssql/queries/`.
- Schema/store overview and conventions: [`README.md`](../../../../dashdbschema/README.md) and [`AGENTS.md`](../../../../dashdbschema/AGENTS.md).
- Store API documentation index: [`API.md`](../../../../dashdbschema/API.md), with per-domain details in `../../../../dashdbschema/API.<module>.md`.
- Dashback database configuration: `src/config/env.js` and the local, untracked `.env`. SQLite defaults to `data/dash.db` unless `SQLITE_PATH` or `SQLITE_FILENAME` overrides it; `DB_PROVIDER` determines the active provider.
## Rules
- Do not add migration/upgrade code, migration scripts, or migration-only DDL to a Git repository. The checked-in schema must describe the desired state for a fresh database.
- Existing local-development databases may need separate schema-patch scripts maintained in the Git-ignored `data/` folder. Name them `NNN-description.mjs`, using a three-digit sequence that increments from the highest existing number (for example, `001-schema-patch.mjs`, then `002-add-index.mjs`). Never reuse a number or renumber existing scripts; retain older scripts so patches can be tracked and rerun in order. Do not put these scripts in Git-tracked folders.
- In this dashback workspace, sibling repositories are read-only. Read their schema and conventions, but do not edit, install dependencies, or run their scripts unless the user explicitly authorizes work in that sibling repository.
- Never use `purge-db` or `reset-db` on a database that contains data: those commands purge tables. Do not assume that `initialize()` upgrades existing tables.
- Do not print credentials or expose `.env` values in tool output.
## Workflow
1. Confirm which schema/provider and environment the change concerns. Read the applicable schema, provider, and domain query/store code, plus the relevant repository instructions. For SQL changes, check both MSSQL and SQLite behavior where applicable.
2. Make the checked-in schema represent the desired fresh-database shape. Respect the sibling-repository read-only boundary; ask the user to coordinate or authorize a `dashdbschema` change rather than editing across that boundary.
3. For an existing local dev database, identify the numbered patch script in `data/` and verify its exact path and contents before running it. Create new local-only patch scripts with the next consecutive three-digit number; never overwrite or renumber existing patches. Confirm the target is the local development database by checking the active provider and configured database path/name without displaying secrets. If the script, target, or safety of rerunning it is unclear, ask the user rather than guessing.
4. Preserve local data. For a file-backed SQLite database, make a backup before applying a script that changes it. Do not run against production, a shared database, or an unconfirmed target. Do not stop or start the API as part of this workflow.
5. Run or rerun the requested numbered script against the confirmed local dev database from its established location and documented invocation; do not embed credentials in command arguments or logs. Report the result and any errors explicitly.
6. Verify the intended schema change against the local dev database using a targeted, read-only schema check. Do not claim success if the script failed or verification was not possible.
## Important notes
- The schema source is DDL for new databases, not an upgrade mechanism. Keep fresh database initialization aligned with any manual/local schema changes.
- [`no-schema-migrations.mdc`](../../../../dashdbschema/.cursor/rules/no-schema-migrations.mdc) documents the no-migration-code convention. Its reset-db workflow is destructive; it is not a substitute for the separate local dev migration script.
- Local patches belong in the Git-ignored `data/` directory and use continuous three-digit numbering. If the correct database target or script operation cannot be verified, ask the user rather than guessing.

View File

@@ -85,8 +85,9 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`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/brand/list.md`](routes/brand/list.md) | `GET /brand` |
| [`routes/brand/upsert.md`](routes/brand/upsert.md) | `PUT /brand` |
| [`routes/brand/create.md`](routes/brand/create.md) | `POST /brand` |
| [`routes/brand/delete.md`](routes/brand/delete.md) | `DELETE /brand/{id}` |
| [`routes/brand/update.md`](routes/brand/update.md) | `PATCH /brand/{id}` |
| [`routes/brand/_id/image.delete.md`](routes/brand/_id/image.delete.md) | `DELETE /brand/{id}/image` |
| [`routes/brand/_id/image.post.md`](routes/brand/_id/image.post.md) | `POST /brand/{id}/image` |
| [`routes/category/create.md`](routes/category/create.md) | `POST /category` |
@@ -245,10 +246,20 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| Signal | Emitted by |
|--------|------------|
| `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` |
| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}` |
| `article-<sku>` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight`, `POST /stock/adjust` |
| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}`, `POST /stock/adjust` |
| `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}` |
| `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` |
| `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` |
| `ledger` | `POST /stock/adjust` |
| `modelconversation` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
| `modelconversation-<id>` | `POST /modelconversation/{id}/message` |
| `printer` | `POST /printer`, `PUT /printer/order`, `DELETE /printer/{id}`, `PATCH /printer/{id}`, `PUT /printer/{id}` |
| `propertyname` | `POST /propertyname`, `PUT /propertyname/order`, `DELETE /propertyname/{id}`, `PATCH /propertyname/{id}` |
| `stock` | `POST /stock/adjust` |
| `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `PUT /vfs/{*path}` |
| `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` |
## DESIGN

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 204 API endpoints across all modules.
Overview of all 205 API endpoints across all modules.
### Notation
@@ -10,7 +10,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ article: T2 }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T32, brand: T28|null, categoryIds: int[], photos: T26[], eans: str[], prices: T36[], suppliers: T12[] }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T31, brand: T28|null, categoryIds: int[], photos: T26[], eans: str[], prices: T35[], suppliers: T12[] }`
- `T3` = `{ delivery: T4 }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T20[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
- `T5` = `{ labeltemplate: T6 }`
@@ -38,13 +38,13 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T29` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }`
- `T30` = `{ id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T31` = `{ article: T34 }`
- `T32` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T33` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- `T34` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T35` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T36` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T30` = `{ article: T33 }`
- `T31` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T32` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- `T33` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T34` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T35` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T36` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T37` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T38` = `{ id: int, attributeId: int, value: str, sort: int }`
- `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
@@ -68,7 +68,7 @@ _none_
- Returns: `204 void`
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T31`
- Returns: `201 T30`
#### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int`
- Returns: `200 zip`
@@ -104,10 +104,10 @@ _none_
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
- Returns: `201 T31`
- Returns: `201 T30`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
- Returns: `200 { article: T34, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
- Returns: `200 { article: T33, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
- Returns: `200 { success: bool, deleted: bool }`
@@ -134,7 +134,7 @@ _none_
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `200 T31`
- Returns: `200 T30`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
- Returns: `200 T1`
@@ -203,31 +203,34 @@ _none_
- Returns: `200 { attributevalue: T38 }`
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T35 }`
- Returns: `200 { message: str, user: T34 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: T35, googleClientId: str }`
- Returns: `200 { user: T34, googleClientId: str }`
#### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }`
- Returns: `200 { user: T35 }`
- Returns: `200 { user: T34 }`
#### List brands — `GET /brand`
_none_
- Returns: `200 { brands: { id: int, name: str, pics: T30[], gsprpinfo?: str|null }[] }`
#### Upsert brand — `PUT /brand`
- Params: `query: offset?: int, limit?: int, includeSmallThumbs?: bool`
- Returns: `200 { brands: { id: int, name: str, 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: `200 { brand: { id: int, name: str, pics: T30[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T36[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Rename brand — `PATCH /brand/{id}`
- Params: `id: int, body: { name?: str, gsprpinfo?: str }`
- 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: T30[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T36[] } }`
#### 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: T30[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T36[] } }`
#### Create category — `POST /category`
- Params: `body: { parentId: int, name: str }`
- Returns: `201 { category: { id: int, parentId: int|null, sort: int, name: str } }`
@@ -272,10 +275,10 @@ _none_
- Returns: `200 { ok: bool }`
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: T33[] }`
- Returns: `200 { companies: T32[] }`
#### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T33, user: T35 }`
- Returns: `201 { company: T32, user: T34 }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
@@ -299,7 +302,7 @@ _none_
- Returns: `204 void`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T33 }`
- Returns: `200 { company: T32 }`
#### List customer groups — `GET /customergroup`
_none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
@@ -324,6 +327,15 @@ _none_
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
- Returns: `200 SSE`
- Events:
- `status`: `{ "message": "…" }`
- `llm_call`: `{ "model": "…", "promptSize": 0, "messages": 0 }`
- `tool_call`: `{ "name": "output_manifest" }`
- `usage`: `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }`
- `progress`: `ManifestResult`
- `result`: `ManifestResult`
- `error`: `{ "message": "…" }`
#### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -381,6 +393,22 @@ _none_
#### Subscribe to update signals (SSE) — `GET /events`
_none_
- Returns: `200 SSE`
- Events:
- `category`: `{}`
- `articles`: `{}`
- `article-<sku>`: `{}`
- `stock`: `{}`
- `ledger`: `{}`
- `attributename`: `{}`
- `propertyname`: `{}`
- `labeltemplate`: `{}`
- `printer`: `{}`
- `warehouse`: `{}`
- `vfs`: `{}`
- `modelconversation`: `{}`
- `modelconversation-<id>`: `{}`
- `brand`: `{ sender, brandId }`
#### Database health check — `GET /health`
_none_
- Returns: `200 { ok: bool }`
@@ -570,6 +598,12 @@ _none_
#### Search images via Serper.dev and stream WebP thumbnails (SSE) — `POST /serper/images`
- Params: `body: { q: str, gl?: str, hl?: str, num?: int, preferSource?: bool }`
- Returns: `200 SSE`
- Events:
- `images`: `{ "images": [ { "id": 0, "url": "…", "width": 1000, "height": 800 } ] }`
- `thumb`: `{ "id": 0, "dataUrl": "data:image/webp;base64,…" }`
- `done`: `{ "total": 100 }`
- `error`: `{ "error": "…" }`
#### Delete shipping — `DELETE /shipping/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -608,10 +642,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T32[] }`
- Returns: `200 { units: T31[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T32 }`
- Returns: `200 { unit: T31 }`
#### JTL-POS category synchronization — `GET /v1/category`
- Params: `query: lastChangedCategory?: str, limit?: int`
- Returns: `200 {}[]`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 158 API endpoints across all modules.
Overview of all 159 API endpoints across all modules.
### Notation
@@ -10,7 +10,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ article: T2 }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T30, brand: T27|null, categoryIds: int[], photos: T25[], eans: str[], prices: T32[], suppliers: T11[] }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T29, brand: T27|null, categoryIds: int[], photos: T25[], eans: str[], prices: T31[], suppliers: T11[] }`
- `T3` = `{ delivery: T4 }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T19[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T36 }`
- `T5` = `{ labeltemplate: T6 }`
@@ -36,11 +36,11 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- `T25` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T26` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T27` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T28` = `{ id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T29` = `{ article: T31 }`
- `T30` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T31` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T32` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T28` = `{ article: T30 }`
- `T29` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T30` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T31` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T32` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T33` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T34` = `{ id: int, attributeId: int, value: str, sort: int }`
- `T35` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
@@ -63,7 +63,7 @@ _none_
- Returns: `204 void`
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T29`
- Returns: `201 T28`
#### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: str, query: cascade?: bool`
- Returns: `204 void`
@@ -96,10 +96,10 @@ _none_
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
- Returns: `201 T29`
- Returns: `201 T28`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
- Returns: `200 { article: T31, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
- Returns: `200 { article: T30, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
- Returns: `200 { success: bool, deleted: bool }`
@@ -123,7 +123,7 @@ _none_
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `200 T29`
- Returns: `200 T28`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
- Returns: `200 T1`
@@ -197,20 +197,23 @@ _none_
_none_
- Returns: `200 { message: str }`
#### List brands — `GET /brand`
_none_
- Returns: `200 { brands: { id: int, name: str, pics: T28[], gsprpinfo?: str|null }[] }`
#### Upsert brand — `PUT /brand`
- Params: `query: offset?: int, limit?: int, includeSmallThumbs?: bool`
- Returns: `200 { brands: { id: int, name: str, 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: `200 { brand: { id: int, name: str, pics: T28[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T32[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Rename brand — `PATCH /brand/{id}`
- Params: `id: int, body: { name?: str, gsprpinfo?: str }`
- 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: T28[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T32[] } }`
#### 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: T28[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T32[] } }`
#### Create category — `POST /category`
- Params: `body: { parentId: int, name: str }`
- Returns: `201 { category: { id: int, parentId: int|null, sort: int, name: str } }`
@@ -501,10 +504,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T30[] }`
- Returns: `200 { units: T29[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T30 }`
- Returns: `200 { unit: T29 }`
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T40[] }`

View File

@@ -22,6 +22,10 @@ Creates an attribute name for the selected company.
}
```
## Signals
On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)).
## Response `201`
Created attribute name

View File

@@ -12,6 +12,10 @@ Deletes an attribute name and cascades to attribute values and article assignmen
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)).
## Response `200`
Deleted successfully

View File

@@ -28,6 +28,10 @@ Updates name, sort, and/or exclusive flag for an attribute name.
}
```
## Signals
On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)).
## Response `200`
Updated attribute name

View File

@@ -20,6 +20,10 @@ Updates the display sort order of attribute names for the selected company.
}
```
## Signals
On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)).
## Response `200`
Reordered successfully

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete brand image
Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null.
Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null. Emits SSE signal `brand` with payload `{ sender, brandId }`.
## Path parameters
@@ -12,6 +12,10 @@ Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null.
|-------|----------|------|-------------|
| `id` | yes | integer | Brand id |
## Signals
On success, emits SSE signal: `brand` (see [`GET /events`](../events.md)).
## Response `200`
Image removed
@@ -25,8 +29,6 @@ Image removed
"thumbHash": "…",
"pics": [
{
"id": 1,
"sort": 1,
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,

View File

@@ -5,7 +5,8 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Add or replace brand image
Uploads an image for the brand (AVIF, WebP, or octet-stream). Converted to AVIF full and thumbnail.
Replaces any existing image for this brand.
Replaces any existing image for this brand. Emits SSE signal `brand`
with payload `{ sender, brandId }`.
## Path parameters
@@ -22,6 +23,10 @@ Raw binary (`image/webp`).
Raw binary (`application/octet-stream`).
## Signals
On success, emits SSE signal: `brand` (see [`GET /events`](../events.md)).
## Response `201`
Image stored and linked
@@ -36,8 +41,6 @@ Image stored and linked
"thumbHash": "…",
"pics": [
{
"id": 1,
"sort": 1,
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,

View File

@@ -1,10 +1,12 @@
# `PUT /brand`
# `POST /brand`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Upsert brand
Create brand
Insert or update brand by `name` for the selected company. `gsprpinfo` optional.
Creates a brand for the selected company. Fails with `Brand already exists`
if the name is taken. Emits SSE signal `brand` with payload
`{ sender, brandId }` (`sender` echoes the caller's `x-client-id`).
## Body (JSON)
@@ -21,9 +23,13 @@ Insert or update brand by `name` for the selected company. `gsprpinfo` optional.
}
```
## Response `200`
## Signals
Upserted brand
On success, emits SSE signal: `brand` (see [`GET /events`](../events.md)).
## Response `201`
Created brand
```json
{
@@ -33,8 +39,6 @@ Upserted brand
"gsprpinfo": "…",
"pics": [
{
"id": 1,
"sort": 1,
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
@@ -53,6 +57,7 @@ Upserted brand
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Company not found — `{"error":"…"}` |
| 409 | Brand already exists — `{"error":"…"}` |
---

View File

@@ -5,7 +5,8 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete brand
Deletes the brand for the selected company. Fails with `Brand in use`
if any article still references it.
if any article still references it. Emits SSE signal `brand`
with payload `{ sender, brandId }`.
## Path parameters
@@ -14,6 +15,10 @@ if any article still references it.
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `brand` (see [`GET /events`](../events.md)).
## Response `204`
Deleted

View File

@@ -4,7 +4,15 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
List brands
Lists brands for the authenticated user's selected company, sorted by name.
Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count. Small-thumbnail fields are omitted by default. Set `includeSmallThumbs=true` to include each picture's `smallThumbW`, `smallThumbH`, and `smallThumbBinary` (base64-encoded AVIF bytes, resized with a 40px maximum edge).
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `offset` | no | integer | Number of brands to skip |
| `limit` | no | integer | Number of brands to return |
| `includeSmallThumbs` | no | boolean | Include each picture's small thumbnail dimensions and base64 AVIF bytes. |
## Response `200`
@@ -19,16 +27,20 @@ Brand list
"gsprpinfo": "…",
"pics": [
{
"id": 1,
"sort": 1,
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
"thumbHash": "…",
"smallThumbW": 1,
"smallThumbH": 1,
"smallThumbBinary": "…"
}
]
}
]
],
"total": 1,
"offset": 1,
"limit": 1
}
```
@@ -36,7 +48,7 @@ Brand list
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 400 | Invalid offset, invalid limit, invalid includeSmallThumbs, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Company not found — `{"error":"…"}` |

View File

@@ -0,0 +1,65 @@
# `PATCH /brand/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Rename brand
Renames/updates a brand by id for the selected company. Fails with
`Brand already exists` if the name is taken by another brand.
When the name changes, linked articles (`brand_id`) get their
`updated_at` bumped; the response reports `touchedArticleCount`.
Emits SSE signal `brand` with payload `{ sender, brandId }`
(`sender` echoes the caller's `x-client-id`).
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `name` | no | string | |
| `gsprpinfo` | no | string | |
```json
{
"name": "Acme GmbH"
}
```
## Signals
On success, emits SSE signal: `brand` (see [`GET /events`](../events.md)).
## Response `200`
Updated brand
```json
{
"brand": {
"id": 1,
"name": "…",
"gsprpinfo": "…",
"touchedArticleCount": 1
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid brand, invalid id, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Brand or company not found — `{"error":"…"}` |
| 409 | Brand already exists — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -24,21 +24,43 @@ to the company at connect time).
## Signals
| Signal | Emitted after successful |
|--------|--------------------------|
| `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` |
| `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` |
| `article-<sku>` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` |
Every SSE event uses the event name as the signal id; payloads are
JSON and most events send an empty object `{}`. Only `brand` emits a
richer payload.
| Signal | Payload | Emitted after successful |
|--------|---------|--------------------------|
| `category` | `{}` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image`, `PUT /category/{id}/property` |
| `articles` | `{}` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}`, `PUT /article/{sku}/articles`-style list-level changes, `POST /category/{id}/article` and related article-assigning category mutations |
| `article-<sku>` | `{}` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, supplier/article-property mutations affecting the article |
| `stock` | `{}` | `POST /stock/adjust` |
| `ledger` | `{}` | `POST /stock/adjust` |
| `attributename` | `{}` | `POST /attributename`, `PUT /attributename/order`, `PATCH /attributename/{id}`, `DELETE /attributename/{id}` |
| `propertyname` | `{}` | `POST /propertyname`, `PUT /propertyname/order`, `PATCH /propertyname/{id}`, `DELETE /propertyname/{id}` |
| `labeltemplate` | `{}` | `POST /labeltemplate`, `PUT /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `DELETE /labeltemplate/{id}` |
| `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` |
| `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` |
| `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` |
| `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
| `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
| `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
The `brand` payload is `{ sender, brandId }`: `sender` echoes the
caller's `x-client-id` header (empty string if absent) so the sender
can ignore its own event; `brandId` is the affected brand id (`null`
only if unknown). All other signals send `{}`.
Signals are only sent when the mutation actually succeeds (2xx).
Failed or no-op validation errors do not emit. The `article-<sku>`
event name uses the concrete SKU (e.g. `article-42000045`).
and `modelconversation-<id>` event names use the concrete item id at
runtime (for example `article-42000045` and `modelconversation-123`).
## Response `200`
SSE stream (`text/event-stream`). Each signal is an SSE event
whose name is the signal id and whose data is `{}`.
whose name is the signal id. Most signals send `{}` as data;
`brand` sends `{ "sender": "<x-client-id>", "brandId": 12 }`.
## Errors

View File

@@ -46,6 +46,10 @@ and stored with the template.
}
```
## Signals
On success, emits SSE signal: `labeltemplate` (see [`GET /events`](../events.md)).
## Response `201`
Created label template

View File

@@ -12,6 +12,10 @@ Deletes a label template for the authenticated user's selected company.
|-------|----------|------|-------------|
| `id` | yes | integer | Label template ID |
## Signals
On success, emits SSE signal: `labeltemplate` (see [`GET /events`](../events.md)).
## Response `204`
Label template deleted

View File

@@ -51,6 +51,10 @@ When `content` (or `width`/`height`) is provided, the thumbnail is automatically
}
```
## Signals
On success, emits SSE signal: `labeltemplate` (see [`GET /events`](../events.md)).
## Response `200`
Updated label template

View File

@@ -20,6 +20,10 @@ Creates a new conversation in state `idle`.
}
```
## Signals
On success, emits SSE signal: `modelconversation` (see [`GET /events`](../events.md)).
## Response `201`
Created conversation

View File

@@ -12,6 +12,10 @@ Deletes one of the authenticated user's model conversations and all its messages
|-------|----------|------|-------------|
| `id` | yes | integer | Conversation ID |
## Signals
On success, emits SSE signal: `modelconversation` (see [`GET /events`](../events.md)).
## Response `204`
Conversation deleted

View File

@@ -34,6 +34,10 @@ Refused with 409 unless the conversation state is `idle`.
}
```
## Signals
On success, emits SSE signal: `modelconversation-<id>` (see [`GET /events`](../events.md)).
## Response `201`
Created message

View File

@@ -34,6 +34,10 @@ Updates specified fields of an existing printer for the selected company.
}
```
## Signals
On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)).
## Response `200`
Updated printer

View File

@@ -30,6 +30,10 @@ Creates a new printer for the selected company.
}
```
## Signals
On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)).
## Response `201`
Created printer

View File

@@ -12,6 +12,10 @@ Deletes a printer for the authenticated user's selected company.
|-------|----------|------|-------------|
| `id` | yes | integer | Printer ID |
## Signals
On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)).
## Response `204`
Printer deleted

View File

@@ -22,6 +22,10 @@ Reorders printers for the selected company.
}
```
## Signals
On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)).
## Response `200`
Applied printer order

View File

@@ -34,6 +34,10 @@ Updates specified fields of an existing printer for the selected company.
}
```
## Signals
On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)).
## Response `200`
Updated printer

View File

@@ -22,6 +22,10 @@ Creates a new property name for the selected company.
}
```
## Signals
On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)).
## Response `201`
Created property name

View File

@@ -12,6 +12,10 @@ Deletes a property name and cascades to article properties.
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)).
## Response `200`
Deleted successfully

View File

@@ -28,6 +28,10 @@ Updates name and/or sort for a property name.
}
```
## Signals
On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)).
## Response `200`
Updated property name

View File

@@ -20,6 +20,10 @@ Updates the display sort order of property names for the selected company.
}
```
## Signals
On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)).
## Response `200`
Reordered successfully

View File

@@ -41,6 +41,10 @@ of type "adjustment", allocating a number from the "adjustment" number scheme.
}
```
## Signals
On success, emits SSE signals: `stock`, `ledger`, `articles`, `article-<sku>` (see [`GET /events`](../events.md)).
## Response `201`
Stock adjusted successfully

View File

@@ -26,6 +26,10 @@ Updates specified fields of an existing warehouse for the selected company.
}
```
## Signals
On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)).
## Response `200`
Updated warehouse

View File

@@ -22,6 +22,10 @@ Sort order will be automatically appended if omitted.
}
```
## Signals
On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)).
## Response `201`
Created warehouse

View File

@@ -12,6 +12,10 @@ Deletes a warehouse if not in use in the ledger.
|-------|----------|------|-------------|
| `id` | yes | integer | Warehouse ID |
## Signals
On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)).
## Response `204`
Warehouse deleted

View File

@@ -22,6 +22,10 @@ Reorders warehouses for the selected company.
}
```
## Signals
On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)).
## Response `200`
Applied warehouse order

View File

@@ -361,7 +361,7 @@ function renderOperation(apiPath, method, operation) {
}
const sseEvents = operation['x-sse-events'];
if (Array.isArray(sseEvents) && sseEvents.length) {
if (apiPath !== '/events' && Array.isArray(sseEvents) && sseEvents.length) {
lines.push('## SSE Events');
lines.push('');
lines.push('The response is a `text/event-stream`. Events are emitted in order until the stream closes.');
@@ -773,12 +773,23 @@ function renderRoutesOverview(operations, title = 'Dash API Routes Overview') {
for (const op of operations) {
const params = summarizeOperationParams(op.operation, aliases);
const returns = summarizeOperationReturns(op.operation, aliases);
const hasSseResponse = Object.values(op.operation.responses || {}).some((response) =>
Object.hasOwn(response.content || {}, 'text/event-stream'),
);
const method = op.method.toUpperCase();
const summary = (op.summary || '').replace(/[\r\n]+/g, ' ').trim();
const heading = summary ? `${summary} — \`${method} ${op.path}\`` : `\`${method} ${op.path}\``;
lines.push(`#### ${heading}`);
lines.push(params === '—' ? '_none_' : `- Params: \`${params}\``);
lines.push(returns === '—' ? '_no response body_' : `- Returns: \`${returns}\``);
const sseEvents = op.operation['x-sse-events'];
if (hasSseResponse && Array.isArray(sseEvents) && sseEvents.length) {
lines.push('');
lines.push('- Events:');
for (const event of sseEvents) {
lines.push(` - \`${event.name}\`: \`${event.payload || '{}'}\``);
}
}
}
lines.push('');
return lines.join('\n');

View File

@@ -343,12 +343,8 @@
* $ref: '#/components/schemas/SupplierArticleInfo'
* BrandPic:
* type: object
* required: [id, sort, fullHash, thumbW, thumbH, thumbHash]
* required: [fullHash, thumbW, thumbH, thumbHash]
* properties:
* id:
* type: integer
* sort:
* type: integer
* fullHash:
* type: string
* thumbW:
@@ -373,6 +369,47 @@
* description: Brand pictures sorted by sort
* items:
* $ref: '#/components/schemas/BrandPic'
* BrandListPic:
* type: object
* required: [fullHash, thumbW, thumbH, thumbHash]
* properties:
* fullHash:
* type: string
* thumbW:
* type: integer
* thumbH:
* type: integer
* thumbHash:
* type: string
* smallThumbW:
* type: integer
* nullable: true
* description: Width in pixels of the small AVIF thumbnail
* smallThumbH:
* type: integer
* nullable: true
* description: Height in pixels of the small AVIF thumbnail
* smallThumbBinary:
* type: string
* format: byte
* nullable: true
* description: Base64-encoded small AVIF thumbnail bytes
* BrandList:
* type: object
* required: [id, name, pics]
* properties:
* id:
* type: integer
* name:
* type: string
* gsprpinfo:
* type: string
* nullable: true
* pics:
* type: array
* description: Brand pictures sorted by sort, including the 40px-edge small thumbnail
* items:
* $ref: '#/components/schemas/BrandListPic'
* ArticleBrand:
* type: object
* required: [name]
@@ -1623,4 +1660,3 @@
*/
export {};

View File

@@ -55,6 +55,7 @@ export function register(app, basePath = '') {
* description: Creates an attribute name for the selected company.
* security:
* - cookieAuth: []
* x-signals: [attributename]
* requestBody:
* required: true
* content:
@@ -118,6 +119,7 @@ export function register(app, basePath = '') {
* description: Updates the display sort order of attribute names for the selected company.
* security:
* - cookieAuth: []
* x-signals: [attributename]
* requestBody:
* required: true
* content:
@@ -162,6 +164,7 @@ export function register(app, basePath = '') {
* description: Updates name, sort, and/or exclusive flag for an attribute name.
* security:
* - cookieAuth: []
* x-signals: [attributename]
* parameters:
* - in: path
* name: id
@@ -197,6 +200,7 @@ export function register(app, basePath = '') {
* description: Deletes an attribute name and cascades to attribute values and article assignments.
* security:
* - cookieAuth: []
* x-signals: [attributename]
* parameters:
* - in: path
* name: id

View File

@@ -1,5 +1,6 @@
import express from 'express';
import * as db from '../../db/brand.js';
import { emit } from '../sse.js';
const AVIF_BODY_LIMIT = 10 * 1024 * 1024;
@@ -18,11 +19,22 @@ function brandErrorStatus(error) {
if (error.message === 'Company not found') return 404;
if (error.message === 'Brand not found') return 404;
if (error.message === 'Invalid brand') return 400;
if (error.message === 'Invalid offset') return 400;
if (error.message === 'Invalid limit') return 400;
if (error.message === 'Brand already exists') return 409;
if (error.message === 'Brand in use') return 409;
if (error.message === 'Image not found') return 404;
return 500;
}
/** Notify SSE subscribers after brand change; payload is `{ sender, brandId }` so receivers know which brand changed and the sender can ignore its own event. */
function emitBrandSignal(req, brandId = null) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) return;
const clientId = req.headers?.['x-client-id'];
emit('brand', { companyId, data: { sender: typeof clientId === 'string' ? clientId : '', brandId } });
}
/**
* Registers brand routes for the authenticated user's selected company.
* @param {import('express').Application} app
@@ -35,9 +47,31 @@ export function register(app, basePath = '') {
* get:
* tags: [Brand]
* summary: List brands
* description: Lists brands for the authenticated user's selected company, sorted by name.
* description: Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count. Small-thumbnail fields are omitted by default. Set `includeSmallThumbs=true` to include each picture's `smallThumbW`, `smallThumbH`, and `smallThumbBinary` (base64-encoded AVIF bytes, resized with a 40px maximum edge).
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: offset
* schema:
* type: integer
* default: 0
* minimum: 0
* description: Number of brands to skip
* - in: query
* name: limit
* schema:
* type: integer
* default: 10
* minimum: 1
* maximum: 100
* description: Number of brands to return
* - in: query
* name: includeSmallThumbs
* schema:
* type: boolean
* default: false
* description: Include each picture's small thumbnail dimensions and base64 AVIF bytes.
* x-markdown: brand/list.md
* responses:
* 200:
@@ -46,13 +80,23 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* type: object
* required: [brands, total, offset, limit]
* properties:
* brands:
* type: array
* items:
* $ref: '#/components/schemas/Brand'
* $ref: '#/components/schemas/BrandList'
* total:
* type: integer
* description: Total number of unpaged brands
* offset:
* type: integer
* description: Number of brands skipped
* limit:
* type: integer
* description: Number of brands returned
* 400:
* description: No company selected
* description: Invalid offset, invalid limit, invalid includeSmallThumbs, or no company selected
* content:
* application/json:
* schema:
@@ -75,14 +119,77 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* put:
*/
app.get(`${basePath}/brand`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const rawOffset = req.query.offset;
const rawLimit = req.query.limit;
const rawIncludeSmallThumbs = req.query.includeSmallThumbs;
if (
rawIncludeSmallThumbs !== undefined
&& rawIncludeSmallThumbs !== 'true'
&& rawIncludeSmallThumbs !== 'false'
) {
return res.status(400).json({ error: 'includeSmallThumbs must be true or false' });
}
let offset = 0;
let limit = 10;
if (rawOffset !== undefined) {
offset = parseInt(rawOffset, 10);
if (!Number.isInteger(offset) || offset < 0) {
return res.status(400).json({ error: 'offset must be an integer >= 0' });
}
}
if (rawLimit !== undefined) {
limit = parseInt(rawLimit, 10);
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
return res.status(400).json({ error: 'limit must be an integer between 1 and 100' });
}
}
try {
const result = await db.list(req.user.id, {
offset,
limit,
includeSmallThumbs: rawIncludeSmallThumbs === 'true',
});
res.status(200).json(result);
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('List brands failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /brand:
* post:
* tags: [Brand]
* summary: Upsert brand
* summary: Create brand
* description: |
* Insert or update brand by `name` for the selected company. `gsprpinfo` optional.
* Creates a brand for the selected company. Fails with `Brand already exists`
* if the name is taken. Emits SSE signal `brand` with payload
* `{ sender, brandId }` (`sender` echoes the caller's `x-client-id`).
* security:
* - cookieAuth: []
* x-markdown: brand/upsert.md
* x-signals: [brand]
* x-markdown: brand/create.md
* parameters:
* - in: header
* name: x-client-id
* required: false
* schema:
* type: string
* description: Sender id echoed as SSE payload so the sender can ignore its own event.
* requestBody:
* required: true
* content:
@@ -99,8 +206,8 @@ export function register(app, basePath = '') {
* name: Acme
* gsprpinfo: 'Acme GmbH'
* responses:
* 200:
* description: Upserted brand
* 201:
* description: Created brand
* content:
* application/json:
* schema:
@@ -132,24 +239,14 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Brand already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/brand`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const brands = await db.list(req.user.id);
res.status(200).json({ brands });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('List brands failed:', error);
res.status(status).json({ error: error.message });
}
});
app.put(`${basePath}/brand`, async (req, res) => {
app.post(`${basePath}/brand`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
@@ -158,14 +255,137 @@ export function register(app, basePath = '') {
const { name, gsprpinfo } = req.body ?? {};
try {
const brand = await db.upsert(req.user.id, {
const brand = await db.create(req.user.id, {
name,
gsprpinfo,
});
emitBrandSignal(req, brand.id);
res.status(201).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Create brand failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /brand/{id}:
* patch:
* tags: [Brand]
* summary: Rename brand
* description: |
* Renames/updates a brand by id for the selected company. Fails with
* `Brand already exists` if the name is taken by another brand.
* When the name changes, linked articles (`brand_id`) get their
* `updated_at` bumped; the response reports `touchedArticleCount`.
* Emits SSE signal `brand` with payload `{ sender, brandId }`
* (`sender` echoes the caller's `x-client-id`).
* security:
* - cookieAuth: []
* x-signals: [brand]
* x-markdown: brand/update.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: header
* name: x-client-id
* required: false
* schema:
* type: string
* description: Sender id echoed as SSE payload so the sender can ignore its own event.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* gsprpinfo:
* type: string
* example:
* name: Acme GmbH
* responses:
* 200:
* description: Updated brand
* content:
* application/json:
* schema:
* type: object
* properties:
* brand:
* type: object
* required: [id, name, touchedArticleCount]
* properties:
* id:
* type: integer
* name:
* type: string
* gsprpinfo:
* type: string
* nullable: true
* touchedArticleCount:
* type: integer
* description: Number of linked articles whose updated_at was bumped.
* 400:
* description: Invalid brand, 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
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Brand or company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Brand already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.patch(`${basePath}/brand/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
const { name, gsprpinfo } = req.body ?? {};
try {
const brand = await db.update(req.user.id, id, {
name,
gsprpinfo,
});
emitBrandSignal(req, id);
res.status(200).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Upsert brand failed:', error);
if (status === 500) console.error('Update brand failed:', error);
res.status(status).json({ error: error.message });
}
});
@@ -178,9 +398,11 @@ export function register(app, basePath = '') {
* summary: Delete brand
* description: |
* Deletes the brand for the selected company. Fails with `Brand in use`
* if any article still references it.
* if any article still references it. Emits SSE signal `brand`
* with payload `{ sender, brandId }`.
* security:
* - cookieAuth: []
* x-signals: [brand]
* x-markdown: brand/delete.md
* parameters:
* - in: path
@@ -235,6 +457,7 @@ export function register(app, basePath = '') {
try {
await db.remove(req.user.id, id);
emitBrandSignal(req, id);
res.sendStatus(204);
} catch (error) {
const status = brandErrorStatus(error);
@@ -251,9 +474,11 @@ export function register(app, basePath = '') {
* summary: Add or replace brand image
* description: |
* Uploads an image for the brand (AVIF, WebP, or octet-stream). Converted to AVIF full and thumbnail.
* Replaces any existing image for this brand.
* Replaces any existing image for this brand. Emits SSE signal `brand`
* with payload `{ sender, brandId }`.
* security:
* - cookieAuth: []
* x-signals: [brand]
* parameters:
* - in: path
* name: id
@@ -328,9 +553,10 @@ export function register(app, basePath = '') {
* delete:
* tags: [Brand]
* summary: Delete brand image
* description: Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null.
* description: Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null. Emits SSE signal `brand` with payload `{ sender, brandId }`.
* security:
* - cookieAuth: []
* x-signals: [brand]
* parameters:
* - in: path
* name: id
@@ -410,6 +636,7 @@ export function register(app, basePath = '') {
try {
const brand = await db.addImage(req.user.id, id, imageBuffer);
emitBrandSignal(req, id);
res.status(201).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
@@ -432,6 +659,7 @@ export function register(app, basePath = '') {
try {
const brand = await db.deleteImage(req.user.id, id);
emitBrandSignal(req, id);
res.status(200).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);

View File

@@ -33,23 +33,74 @@ export function register(app, basePath = '') {
*
* ## Signals
*
* | Signal | Emitted after successful |
* |--------|--------------------------|
* | `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` |
* | `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` |
* | `article-<sku>` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` |
* Every SSE event uses the event name as the signal id; payloads are
* JSON and most events send an empty object `{}`. Only `brand` emits a
* richer payload.
*
* | Signal | Payload | Emitted after successful |
* |--------|---------|--------------------------|
* | `category` | `{}` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image`, `PUT /category/{id}/property` |
* | `articles` | `{}` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}`, `PUT /article/{sku}/articles`-style list-level changes, `POST /category/{id}/article` and related article-assigning category mutations |
* | `article-<sku>` | `{}` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, supplier/article-property mutations affecting the article |
* | `stock` | `{}` | `POST /stock/adjust` |
* | `ledger` | `{}` | `POST /stock/adjust` |
* | `attributename` | `{}` | `POST /attributename`, `PUT /attributename/order`, `PATCH /attributename/{id}`, `DELETE /attributename/{id}` |
* | `propertyname` | `{}` | `POST /propertyname`, `PUT /propertyname/order`, `PATCH /propertyname/{id}`, `DELETE /propertyname/{id}` |
* | `labeltemplate` | `{}` | `POST /labeltemplate`, `PUT /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `DELETE /labeltemplate/{id}` |
* | `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` |
* | `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` |
* | `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` |
* | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
* | `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
* | `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
*
* The `brand` payload is `{ sender, brandId }`: `sender` echoes the
* caller's `x-client-id` header (empty string if absent) so the sender
* can ignore its own event; `brandId` is the affected brand id (`null`
* only if unknown). All other signals send `{}`.
*
* Signals are only sent when the mutation actually succeeds (2xx).
* Failed or no-op validation errors do not emit. The `article-<sku>`
* event name uses the concrete SKU (e.g. `article-42000045`).
* and `modelconversation-<id>` event names use the concrete item id at
* runtime (for example `article-42000045` and `modelconversation-123`).
* security:
* - cookieAuth: []
* x-markdown: events.md
* x-sse-events:
* - name: category
* payload: "{}"
* - name: articles
* payload: "{}"
* - name: article-<sku>
* payload: "{}"
* - name: stock
* payload: "{}"
* - name: ledger
* payload: "{}"
* - name: attributename
* payload: "{}"
* - name: propertyname
* payload: "{}"
* - name: labeltemplate
* payload: "{}"
* - name: printer
* payload: "{}"
* - name: warehouse
* payload: "{}"
* - name: vfs
* payload: "{}"
* - name: modelconversation
* payload: "{}"
* - name: modelconversation-<id>
* payload: "{}"
* - name: brand
* payload: "{ sender, brandId }"
* responses:
* 200:
* description: |
* SSE stream (`text/event-stream`). Each signal is an SSE event
* whose name is the signal id and whose data is `{}`.
* whose name is the signal id. Most signals send `{}` as data;
* `brand` sends `{ "sender": "<x-client-id>", "brandId": 12 }`.
* content:
* text/event-stream:
* schema:
@@ -63,6 +114,9 @@ export function register(app, basePath = '') {
*
* event: article-42000045
* data: {}
*
* event: brand
* data: {"sender":"tab-1","brandId":12}
* 400:
* description: No company selected
* content:

View File

@@ -176,6 +176,7 @@ export function register(app, basePath = '') {
* and stored with the template.
* security:
* - cookieAuth: []
* x-signals: [labeltemplate]
* x-markdown: labeltemplate/create.md
* requestBody:
* required: true
@@ -293,6 +294,7 @@ export function register(app, basePath = '') {
* When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered.
* security:
* - cookieAuth: []
* x-signals: [labeltemplate]
* x-markdown: labeltemplate/update.md
* parameters:
* - in: path
@@ -343,6 +345,7 @@ export function register(app, basePath = '') {
* When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered.
* security:
* - cookieAuth: []
* x-signals: [labeltemplate]
* x-markdown: labeltemplate/update.md
* parameters:
* - in: path
@@ -398,6 +401,7 @@ export function register(app, basePath = '') {
* description: Deletes a label template for the authenticated user's selected company.
* security:
* - cookieAuth: []
* x-signals: [labeltemplate]
* x-markdown: labeltemplate/delete.md
* parameters:
* - in: path

View File

@@ -104,6 +104,7 @@ export function register(app, basePath = '') {
* description: Creates a new conversation in state `idle`.
* security:
* - cookieAuth: []
* x-signals: [modelconversation]
* requestBody:
* required: true
* content:
@@ -248,6 +249,7 @@ export function register(app, basePath = '') {
* description: Deletes one of the authenticated user's model conversations and all its messages.
* security:
* - cookieAuth: []
* x-signals: [modelconversation]
* parameters:
* - in: path
* name: id
@@ -493,6 +495,7 @@ export function register(app, basePath = '') {
* Refused with 409 unless the conversation state is `idle`.
* security:
* - cookieAuth: []
* x-signals: [modelconversation-<id>]
* parameters:
* - in: path
* name: id

View File

@@ -103,6 +103,7 @@ export function register(app, basePath = '') {
* `printerIds` must include all existing printer IDs exactly once.
* security:
* - cookieAuth: []
* x-signals: [printer]
* x-markdown: printer/order.md
* requestBody:
* required: true
@@ -253,6 +254,7 @@ export function register(app, basePath = '') {
* `type` must be `"esc/pos"` (lines), `"zpl"` (pages), or `"ipp"`.
* security:
* - cookieAuth: []
* x-signals: [printer]
* x-markdown: printer/create.md
* requestBody:
* required: true
@@ -355,6 +357,7 @@ export function register(app, basePath = '') {
* description: Updates specified fields of an existing printer for the selected company.
* security:
* - cookieAuth: []
* x-signals: [printer]
* parameters:
* - in: path
* name: id
@@ -409,6 +412,7 @@ export function register(app, basePath = '') {
* description: Updates specified fields of an existing printer for the selected company.
* security:
* - cookieAuth: []
* x-signals: [printer]
* parameters:
* - in: path
* name: id
@@ -469,6 +473,7 @@ export function register(app, basePath = '') {
* description: Deletes a printer for the authenticated user's selected company.
* security:
* - cookieAuth: []
* x-signals: [printer]
* parameters:
* - in: path
* name: id

View File

@@ -61,6 +61,7 @@ export function register(app, basePath = '') {
* description: Creates a new property name for the selected company.
* security:
* - cookieAuth: []
* x-signals: [propertyname]
* requestBody:
* required: true
* content:
@@ -130,6 +131,7 @@ export function register(app, basePath = '') {
* description: Updates the display sort order of property names for the selected company.
* security:
* - cookieAuth: []
* x-signals: [propertyname]
* requestBody:
* required: true
* content:
@@ -176,6 +178,7 @@ export function register(app, basePath = '') {
* description: Updates name and/or sort for a property name.
* security:
* - cookieAuth: []
* x-signals: [propertyname]
* parameters:
* - in: path
* name: id
@@ -211,6 +214,7 @@ export function register(app, basePath = '') {
* description: Deletes a property name and cascades to article properties.
* security:
* - cookieAuth: []
* x-signals: [propertyname]
* parameters:
* - in: path
* name: id

View File

@@ -71,6 +71,7 @@ export function register(app, basePath = '') {
* **Note:** `sku` must be a string (e.g. `"25"`), not a number. For single-item adjustments, provide `sku` and `amount` at top-level. For batch adjustments, provide `lines: [{ sku, amount, ... }]`.
* security:
* - cookieAuth: []
* x-signals: [stock, ledger, articles, article-<sku>]
* x-markdown: stock/adjust.md
* requestBody:
* required: true

View File

@@ -104,6 +104,7 @@ export function register(app, basePath = '') {
* `warehouseIds` must include all existing warehouse IDs exactly once.
* security:
* - cookieAuth: []
* x-signals: [warehouse]
* x-markdown: warehouse/order.md
* requestBody:
* required: true
@@ -254,6 +255,7 @@ export function register(app, basePath = '') {
* Sort order will be automatically appended if omitted.
* security:
* - cookieAuth: []
* x-signals: [warehouse]
* x-markdown: warehouse/create.md
* requestBody:
* required: true
@@ -408,6 +410,7 @@ export function register(app, basePath = '') {
* description: Updates specified fields of an existing warehouse for the selected company.
* security:
* - cookieAuth: []
* x-signals: [warehouse]
* parameters:
* - in: path
* name: id
@@ -474,6 +477,7 @@ export function register(app, basePath = '') {
* description: Deletes a warehouse if not in use in the ledger.
* security:
* - cookieAuth: []
* x-signals: [warehouse]
* parameters:
* - in: path
* name: id

View File

@@ -57,12 +57,13 @@ export function closeAll() {
/**
* Broadcast a named signal to subscribers of the given company.
* @param {string} signal - SSE event name (e.g. `category`)
* @param {{ companyId: number }} options
* @param {{ companyId: number, data?: string|object }} options
*/
export function emit(signal, { companyId }) {
export function emit(signal, { companyId, data }) {
if (companyId == null) return;
const frame = `event: ${signal}\ndata: {}\n\n`;
const payload = data == null ? '{}' : (typeof data === 'string' ? data : JSON.stringify(data));
const frame = `event: ${signal}\ndata: ${payload}\n\n`;
for (const client of clients) {
if (client.companyId !== companyId) continue;
if (client.res.writableEnded) continue;

View File

@@ -1,8 +1,18 @@
import { getStore } from './store.js';
export async function list(googleAuthId) {
export async function list(googleAuthId, opts) {
const store = await getStore();
return store.brand.list(googleAuthId);
return store.brand.list(googleAuthId, opts);
}
export async function create(googleAuthId, data) {
const store = await getStore();
return store.brand.create(googleAuthId, data);
}
export async function update(googleAuthId, id, data) {
const store = await getStore();
return store.brand.update(googleAuthId, id, data);
}
export async function upsert(googleAuthId, data) {

View File

@@ -360,9 +360,9 @@ export async function executeCompanyImport({
log.info(`Processing ${brandEntries.length} brands (Hersteller)...`);
// Fetch existing brands to accurately track new vs existing
const existingBrands = await store.brand.list(googleAuthId);
const { brands: existingBrandRows } = await store.brand.list(googleAuthId, { offset: 0, limit: 100 });
const existingBrandNames = new Set(
existingBrands.map((b) => (b.name || '').trim().toLowerCase()).filter(Boolean)
existingBrandRows.map((b) => (b.name || '').trim().toLowerCase()).filter(Boolean)
);
for (let i = 0; i < brandEntries.length; i++) {

View File

@@ -10,7 +10,19 @@ import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
import { createVfs } from './vfs.js';
const { code, root, vmTimeout, bufferLimit, network, dashbackUrl, dashbackToken, vfsDir, googleAuthId } = workerData;
const {
code,
root,
vmTimeout,
bufferLimit,
network,
dashbackUrl,
dashbackToken,
vfsDir,
googleAuthId,
appletInput,
appletFile,
} = workerData;
function resolveDashbackUrl(inputUrl, baseApiUrl) {
const raw = String(inputUrl || '').trim();
@@ -582,7 +594,13 @@ try {
let value;
let failed = null;
try {
value = vm.run(code, 'javascript_execute.js');
value = vm.run(code, appletFile || 'javascript_execute.js');
if (appletFile) {
if (typeof value !== 'function') {
throw new TypeError('Applet must assign an exported function to module.exports.');
}
value = value(appletInput);
}
if (value && typeof value.then === 'function') {
const settleTimeout = Math.max(1000, vmTimeout - 500);
value = await Promise.race([

View File

@@ -1,10 +1,11 @@
import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
import fs from 'fs';
import path from 'path';
import jwt from 'jsonwebtoken';
import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import { prepareVfs, syncVfsToDb } from './vfs.js';
import { createVfs, prepareVfs, syncVfsToDb } from './vfs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, '../..');
@@ -20,7 +21,7 @@ const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024;
// so warning per message floods the log. We warn once per distinct key instead.
const WATCH_NOISE_WARNED = new Set();
function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId, appletInput, appletFile }) {
return new Promise((resolve) => {
let settled = false;
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
@@ -35,6 +36,8 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
dashbackToken: token,
vfsDir: vfsRoot,
googleAuthId,
appletInput,
appletFile,
},
});
@@ -104,6 +107,67 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
});
}
function readApplet(vfsRoot, appletvfsfile) {
if (typeof appletvfsfile !== 'string' || !appletvfsfile.trim()) {
throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.');
}
const appletPath = appletvfsfile.trim();
if (path.isAbsolute(appletPath) || /^[a-zA-Z]:/.test(appletPath) || appletPath.startsWith('vfs:')) {
throw new ToolError('"appletvfsfile" must be a VFS-relative file path.');
}
if (!appletPath.toLowerCase().endsWith('.applet.js')) {
throw new ToolError('"appletvfsfile" must refer to a .applet.js file.');
}
const vfs = createVfs(vfsRoot);
let source;
try {
const diskPath = vfs.resolveReadPath(appletPath);
const realRoot = fs.realpathSync(vfsRoot);
const realPath = fs.realpathSync(diskPath);
const relativePath = path.relative(realRoot, realPath);
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
throw new ToolError(`Applet "${appletPath}" resolves outside the VFS.`);
}
if (!fs.statSync(realPath).isFile()) {
throw new ToolError(`Applet "${appletPath}" is not a file.`);
}
source = fs.readFileSync(realPath, 'utf8');
} catch (err) {
if (err instanceof ToolError) throw err;
throw new ToolError(`Unable to read applet "${appletPath}" from the VFS: ${err.message}`);
}
const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/, 3);
const inputHeader = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/);
const outputHeader = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/);
if (!inputHeader || !outputHeader) {
throw new ToolError(
'Applet metadata must be on the first two lines: ' +
'`// @applet-input-schema {JSON Schema}` followed by ' +
'`// @applet-output-schema {JSON Schema}`.'
);
}
let inputSchema;
let outputSchema;
try {
inputSchema = JSON.parse(inputHeader[1]);
outputSchema = JSON.parse(outputHeader[1]);
} catch (err) {
throw new ToolError(`Applet metadata must contain valid JSON schemas: ${err.message}`);
}
if (!inputSchema || typeof inputSchema !== 'object' || Array.isArray(inputSchema) ||
inputSchema.type !== 'object') {
throw new ToolError('Applet input schema must be a JSON Schema with type "object".');
}
if (!outputSchema || typeof outputSchema !== 'object' || Array.isArray(outputSchema)) {
throw new ToolError('Applet output schema must be a JSON Schema object.');
}
return source;
}
function formatSandboxError(code, report) {
const errName = report.errorName || 'Error';
const msg = report.error || 'Unknown error';
@@ -122,7 +186,10 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
return defineTool({
name: 'javascript_execute',
description:
'Execute JS in isolated worker sandbox. Assign `module.exports` to return value (sync value or Promise; wrap async in IIFE: `module.exports = (async () => { ... })()`). ' +
'Execute JS in an isolated worker sandbox using either inline `code` or a VFS applet. Provide exactly one of `code` and `appletvfsfile`. ' +
'Applets are the preferred method when the first execution may throw: save a `.applet.js` file in the VFS so it can be inspected and further edited with `patch`, then call it by VFS path. ' +
'Applet files must start with `// @applet-input-schema {JSON Schema}` and `// @applet-output-schema {JSON Schema}` on their first two lines. The input schema describes the GUI form passed as `applet_input`; the output schema describes the exported function result. Export a function that receives `applet_input`. ' +
'Inline code: assign `module.exports` to return value (sync value or Promise; wrap async in IIFE: `module.exports = (async () => { ... })()`). ' +
'REST API: `await fetch("/path")` (authenticated). ' +
'VFS: directly use global `fs.readFileSync`/`writeFileSync` for binary artifacts/buffers/images (`image/` is read-only; paths must be relative paths, root is not allowed). VFS can host a static website (HTML, CSS, JS, images, etc.). ' +
'Embed generated images/links in markdown: `![alt](relative/path)`. Internet links use `https://...` or `hostname.com`/`hostname.com/filename`; a pure relative file with no dotted hostname is always a VFS relative path. Use find_files/read_file/patch for text. ' +
@@ -131,7 +198,6 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
parameters: {
type: 'object',
additionalProperties: false,
required: ['code'],
properties: {
note: {
type: ['string', 'null'],
@@ -139,7 +205,16 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
},
code: {
type: 'string',
description: 'JavaScript code. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.',
description: 'Optional inline JavaScript code. Provide exactly one of this and appletvfsfile. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.',
},
appletvfsfile: {
type: 'string',
description: 'Optional VFS-relative path to a .applet.js file. Provide exactly one of this and code. The file must begin with the two applet JSON Schema metadata lines described in the tool description.',
},
applet_input: {
type: 'object',
description: 'Input values passed to the exported function when running appletvfsfile. Omit to pass an empty object.',
additionalProperties: true,
},
timeout: {
type: ['number', 'null'],
@@ -151,17 +226,48 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
},
},
},
invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT, note }) => {
if (typeof code !== 'string' || !code.trim()) {
throw new ToolError('"code" must be a non-empty string.');
invoke: async ({
code,
appletvfsfile,
applet_input,
timeout = DEFAULT_TIMEOUT_MS,
max_output = DEFAULT_MAX_OUTPUT,
note,
}) => {
const hasCode = typeof code === 'string' && Boolean(code.trim());
const hasApplet = typeof appletvfsfile === 'string' && Boolean(appletvfsfile.trim());
if (hasCode === hasApplet) {
throw new ToolError('Provide exactly one of "code" or "appletvfsfile".');
}
if (code != null && !hasCode) {
throw new ToolError('"code" must be a non-empty string when provided.');
}
if (appletvfsfile != null && !hasApplet) {
throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.');
}
if (hasCode && applet_input !== undefined) {
throw new ToolError('"applet_input" can only be provided with "appletvfsfile".');
}
if (applet_input !== undefined &&
(!applet_input || typeof applet_input !== 'object' || Array.isArray(applet_input))) {
throw new ToolError('"applet_input" must be an object.');
}
const hardTimeout = Math.min(Math.max(1000, Number(timeout) || DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
const maxOutput = Math.max(1000, Number(max_output) || DEFAULT_MAX_OUTPUT);
const vfsRoot = await prepareVfs({ googleAuthId });
const isApplet = hasApplet;
const source = isApplet ? readApplet(vfsRoot, appletvfsfile) : code;
let report;
try {
report = await runInWorker({ code, timeoutMs: hardTimeout, vfsRoot, googleAuthId });
report = await runInWorker({
code: source,
timeoutMs: hardTimeout,
vfsRoot,
googleAuthId,
appletInput: isApplet ? (applet_input ?? {}) : undefined,
appletFile: isApplet ? appletvfsfile.trim() : undefined,
});
} finally {
await syncVfsToDb({ googleAuthId, vfsRoot });
}
@@ -173,7 +279,7 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
}
if (report.error) {
throw new ToolError(formatSandboxError(code, report));
throw new ToolError(formatSandboxError(source, report));
}
// The worker ALWAYS reports a `result` key (javascript-worker.js). Its absence