Compare commits
4 Commits
de7afbcbe9
...
ac10840fb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac10840fb6 | ||
|
|
cd6879f3b7 | ||
|
|
daf2d0525f | ||
|
|
848ca0aff5 |
42
.agents/skills/database-schema-updates/SKILL.md
Normal file
42
.agents/skills/database-schema-updates/SKILL.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {}[]`
|
||||
|
||||
@@ -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[] }`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":"…"}` |
|
||||
|
||||
65
docs/api/routes/brand/update.md
Normal file
65
docs/api/routes/brand/update.md
Normal 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`._
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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: ``. 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
|
||||
|
||||
Reference in New Issue
Block a user