Compare commits

..

6 Commits

Author SHA1 Message Date
seb
446f465a48 u 2026-10-07 22:39:27 +02:00
seb
1a236b568a u 2026-10-07 21:43:51 +02:00
seb
85635cc0a5 u 2026-10-07 21:31:33 +02:00
seb
7d537aaa6b u 2026-10-07 21:23:10 +02:00
seb
a29ad50b74 u 2026-10-07 20:36:50 +02:00
seb
857963b1c3 u 2026-10-07 19:16:42 +02:00
73 changed files with 2994 additions and 929 deletions

264
SANDBOX.md Normal file
View File

@@ -0,0 +1,264 @@
# Proposal: Dockerized Bash Sandbox for Dashback Agent Tools
## 1. Overview & Motivation
Dashback currently equips LLM agents with code execution capabilities via `javascript.js` running inside a Node.js `worker_threads` VM with a virtualized file system (`vfs.js`). While effective for lightweight JavaScript transformations, this approach has limitations:
1. **Language & Tooling Constraints:** Agents are restricted to JavaScript. They cannot run shell scripts, Python, `jq`, `ripgrep`, `git`, or standard command-line tools.
2. **Isolation Boundaries:** Worker threads share host process memory and kernel resources. A native or non-VM shell tool running directly on the host would pose severe security and privilege escalation risks.
3. **Reproducibility:** A containerized environment guarantees identical toolchain versions and execution environments across development and production deployments.
This proposal specifies an isolated, container-based **Bash Tool** (`bash.js`) powered by a lightweight Docker image with sandboxed access to the company's virtual file system (VFS).
---
## 2. Sandboxing Architecture
```
┌────────────────────────────────────────┐
│ dashback Host │
│ │
│ 1. prepareVfs(googleAuthId) │
│ └─ hydrates DB -> data/vfs/<cid>/ │
└──────────────────┬─────────────────────┘
│
Mounts host path│ (scoped to company)
data/vfs/<cid> │ as /home/agent/workspace
▼
┌────────────────────────────────────────────────────────────────────────┐
│ Docker Sandbox Container (`dashback-sandbox`) │
│ │
│ User: agent (UID 1000, non-root) │
│ Rootfs: Read-Only (no modifying /usr, /bin, /lib) │
│ Capabilities: None (--cap-drop=ALL) │
│ Privilege Escalation: Blocked (no-new-privileges) │
│ Tmpfs: /tmp (64M, nosuid), /home/agent/.cache (64M, nosuid) │
│ Limits: 512MB RAM, 1.0 CPU, 100 PIDs (fork-bomb proof) │
│ Network: None (--network none) or Internal Bridge │
│ │
│ /home/agent/workspace/ <─ (Shared VFS Mount) │
│ ├── package.json │
│ ├── script.js │
│ └── output.csv │
└────────────────────────────────────┬───────────────────────────────────┘
│
┌──────────────────┴─────────────────────┐
│ 3. syncVfsToDb(googleAuthId) │
│ └─ persists created/updated files │
│ back to modelvfs table │
│ 4. Return stdout/stderr to LLM │
└────────────────────────────────────────┘
```
---
## 3. Docker Image Specification
### Location
`docker/sandbox/Dockerfile`
### `Dockerfile`
```dockerfile
FROM node:22-bookworm-slim
# Prevent interactive prompts during package install
ENV DEBIAN_FRONTEND=noninteractive
# Install essential CLI tools and dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
jq \
ripgrep \
procps \
tree \
tar \
unzip \
gzip \
python3 \
bc \
sed \
gawk \
&& rm -rf /var/lib/apt/lists/*
# Set up unprivileged user
ENV USER=agent
ENV HOME=/home/agent
ENV WORKSPACE=/home/agent/workspace
RUN groupadd -g 1000 ${USER} && \
useradd -u 1000 -g ${USER} -d ${HOME} -s /bin/bash -m ${USER} && \
mkdir -p ${WORKSPACE} && \
chown -R ${USER}:${USER} ${HOME}
# Switch context to agent
WORKDIR ${WORKSPACE}
USER ${USER}
# Provide local node bin in PATH
ENV PATH="${WORKSPACE}/node_modules/.bin:${HOME}/.local/bin:${PATH}"
ENTRYPOINT ["/bin/bash"]
CMD ["-l"]
```
---
## 4. Security & Runtime Constraints
Containers must never be invoked with default privileged parameters. The runner applies strict Linux namespace and cgroup boundaries:
| Flag | Rationale |
| :--- | :--- |
| `--user 1000:1000` | Executes strictly as unprivileged user `agent`. Matches UID with host file permissions. |
| `--cap-drop=ALL` | Drops all Linux capabilities (prevents raw network creation, kernel module loading, chroot, ptrace). |
| `--security-opt=no-new-privileges:true` | Disallows gaining privileges via `setuid`/`setgid` binaries. |
| `--read-only` | The root filesystem is mounted read-only. The container cannot modify OS binaries, libraries, or configs. |
| `--tmpfs /tmp:rw,nosuid,nodev,size=64m` | Provides a volatile, memory-backed scratch space with `nosuid` and `nodev`. |
| `--tmpfs /home/agent/.cache:rw,nosuid,size=64m` | Allows tools like npm or python to write temporary caches without disk persistence. |
| `--pids-limit 100` | Protects host from fork bombs (`:(){ :|:& };:`). |
| `--memory 512m` | Caps RAM usage; terminates process if memory leaks or runaway allocations occur. |
| `--cpus 1.0` | Restricts CPU usage to a single core equivalent to avoid CPU starvation. |
| `--network none` | Prevents unauthorized external data exfiltration by default (can be bridged if specifically required). |
| `-v <vfs_root>:/home/agent/workspace:rw` | Restricts file access solely to the company's workspace directory. |
---
## 5. Integration with Dashback
### 5.1 VFS Lifecycle
Each execution turn synchronizes through the existing Dashback VFS pipeline:
1. **Preparation:**
```javascript
const vfsRoot = await resolveVfsRoot(googleAuthId);
await prepareVfs(googleAuthId); // Hydrates files from `modelvfs` table
```
2. **Mounting:**
The directory `data/vfs/<companyId>` on the host is mounted to `/home/agent/workspace` inside the container.
3. **Execution:**
Command is executed with a hard timeout (e.g. default 30s, max 120s).
4. **Synchronization:**
```javascript
await syncVfsToDb(googleAuthId); // Syncs any new or edited files back to `modelvfs`
```
### 5.2 Tool Implementation (`src/tools/bash.js`)
```javascript
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import path from 'node:path';
import { defineTool, ToolError } from 'libllmorbiter';
import { resolveVfsRoot, prepareVfs, syncVfsToDb } from './vfs.js';
const execFileAsync = promisify(execFile);
const IMAGE_NAME = 'dashback-sandbox:latest';
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_BUFFER = 1024 * 1024; // 1 MB output cap
export function createBashTool(googleAuthId) {
return defineTool({
name: 'bash_execute',
description: 'Execute a bash command in a secure sandboxed Linux environment with Node.js, Python, git, jq, and ripgrep. Working directory is the company workspace.',
parameters: {
type: 'object',
properties: {
command: {
type: 'string',
description: 'The bash command line to execute (e.g., "node script.js", "jq . data.json", "rg pattern").',
},
timeout_ms: {
type: 'integer',
description: 'Execution timeout in milliseconds (default 30000, max 120000).',
},
},
required: ['command'],
additionalProperties: false,
},
invoke: async ({ command, timeout_ms = DEFAULT_TIMEOUT_MS }) => {
const vfsRoot = await resolveVfsRoot(googleAuthId);
await prepareVfs(googleAuthId);
const effectiveTimeout = Math.min(Math.max(timeout_ms, 1000), 120000);
const dockerArgs = [
'run', '--rm', '-i',
'--user', '1000:1000',
'--cap-drop=ALL',
'--security-opt=no-new-privileges:true',
'--read-only',
'--tmpfs', '/tmp:rw,nosuid,nodev,size=64m',
'--tmpfs', '/home/agent/.cache:rw,nosuid,size=64m',
'--pids-limit', '100',
'--memory', '512m',
'--cpus', '1.0',
'--network', 'none',
'-v', `${path.resolve(vfsRoot)}:/home/agent/workspace:rw`,
IMAGE_NAME,
'-c', command,
];
try {
const { stdout, stderr } = await execFileAsync('docker', dockerArgs, {
timeout: effectiveTimeout,
maxBuffer: MAX_BUFFER,
});
await syncVfsToDb(googleAuthId);
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: 0,
};
} catch (err) {
// Ensure partial file modifications are synced even on exit error
try { await syncVfsToDb(googleAuthId); } catch (_) {}
if (err.killed || err.signal === 'SIGTERM') {
throw new ToolError(`Execution timed out after ${effectiveTimeout}ms.`);
}
return {
stdout: err.stdout ? err.stdout.trim() : '',
stderr: err.stderr ? err.stderr.trim() : err.message,
exitCode: err.code ?? 1,
};
}
},
});
}
```
---
## 6. Execution Models: Ephemeral vs. Persistent Daemon
| Consideration | Ephemeral (`docker run --rm`) | Persistent Container (`docker exec`) |
| :--- | :--- | :--- |
| **Startup Overhead** | ~200–400ms per command | ~20–50ms per command |
| **State Cleanliness** | Completely fresh OS environment every call | Processes/background jobs remain running |
| **Lifecycle Complexity** | None. Docker handles lifecycle and cleanup | Requires container pool management and reaper |
| **Failure Recovery** | Automatic | If container crashes, must be recreated |
| **Recommendation** | **Adopt for Phase 1** | Evaluate if high execution frequency warrants it |
---
## 7. Implementation Roadmap
1. **Phase 1: Docker Setup & Scripting**
- Add `docker/sandbox/Dockerfile`.
- Add build script: `npm run docker:sandbox:build` to `package.json`.
- Verify image build and base tools (`node -v`, `python3 --version`, `rg --version`, `jq --version`).
2. **Phase 2: Bash Tool Implementation**
- Create `src/tools/bash.js` conforming to `defineTool`.
- Integrate with `prepareVfs` and `syncVfsToDb`.
- Add unit tests validating command execution, timeouts, and file synchronization.
3. **Phase 3: Integration into Tool Registries**
- Register `createBashTool` in the agent execution registry alongside existing tools (`javascript.js`, `read-file.js`, `patch.js`).
4. **Phase 4: Optional Network & API Bridge**
- If commands inside the container need to invoke Dashback endpoints (`http://host.docker.internal:4208/api`), introduce an opt-in network mode using Docker custom bridge networks with short-lived JWT tokens.

View File

@@ -207,7 +207,13 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` |
| [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` |
| [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` |
| [`routes/vfs/list.md`](routes/vfs/list.md) | `GET /vfs` |
| [`routes/vfs/mkdir.md`](routes/vfs/mkdir.md) | `POST /vfs/mkdir` |
| [`routes/vfs/rename.md`](routes/vfs/rename.md) | `POST /vfs/rename` |
| [`routes/vfs/upload.md`](routes/vfs/upload.md) | `POST /vfs/upload` |
| [`routes/vfs/delete.md`](routes/vfs/delete.md) | `DELETE /vfs/{*path}` |
| [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /vfs/{*path}` |
| [`routes/vfs/write.md`](routes/vfs/write.md) | `PUT /vfs/{*path}` |
| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` |
| [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` |
@@ -227,6 +233,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| `article-<sku>` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight` |
| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` |
| `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `PUT /vfs/{*path}` |
## DESIGN

View File

@@ -1,58 +1,64 @@
# Dash API Routes Overview (All)
Overview of all 183 API endpoints across all modules.
Overview of all 189 API endpoints across all modules.
### Notation
Types: `str` string, `int` integer, `num` number, `bool` boolean, `obj` object; `T[]` array; `?` optional.
Types: `str` string, `int` integer, `num` number, `bool` boolean; `T[]` array; `?` optional.
Params: path values first, then `query:` and `body:` (`body?` = optional body). Responses: status code + JSON shape; `Tn` refers to a reusable type below.
### Reusable types
- `T1` = `{ article: T2 }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, track_stock: bool, unitamount: num, weight: num, unit: obj, brand: obj, categoryIds: int[], photos: T17[], eans: str[], prices: T28[], suppliers: T9[] }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, track_stock: bool, unitamount: num|null, weight: num|null, unit: T31, brand: T27|null, categoryIds: int[], photos: T25[], eans: str[], prices: T35[], suppliers: T11[] }`
- `T3` = `{ delivery: T4 }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, externalNumber: str, date: str, createdAt: str, ledger: T33, lines: T18[], files: T8[] }`
- `T5` = `{ order: T7 }`
- `T6` = `{ quote: T7 }`
- `T7` = `{ id: int, number: str, type: str, entityId: int, date: str, createdAt: str, lines: T18[] }`
- `T8` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created: str, modified: str, thumbavif: str, thumbx: int, thumby: int, meta: obj }`
- `T9` = `{ id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T36 }`
- `T10` = `{ ledger: T11[] }`
- `T11` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash: str, sku: str, created_at: str }`
- `T12` = `{ id: int, entityId: int, firstName: str, name: str, street: str, streetNumber: str, zip: str, city: str, county: str, addressType: str }`
- `T13` = `{ id: int, name: str, sort: int, exclusive: bool, values: T31[] }`
- `T14` = `{ entity: T20 }`
- `T15` = `{ printer: T21 }`
- `T16` = `{ labeltemplate: T23 }`
- `T17` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T18` = `{ id: int, sku: str, name: str, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str }`
- `T19` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }`
- `T20` = `{ id: int, customergroupId: int, firstName: str, name: str, number: str, createdAt: str, updatedAt: str }`
- `T21` = `{ id: int, company_id: int, address: str, type: str, model: str, serial: str, sort: int, desc: str }`
- `T22` = `{ amount: num, net: num, taxrate: num, sku?: str, name?: str, pu?: num, rebate?: num, comment?: str }`
- `T23` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: obj, thumb: str }`
- `T24` = `{ id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T25` = `{ article: T27 }`
- `T26` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- `T27` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T28` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T29` = `{ id: int, email: str, selectedCompanyId: int, picture: str }`
- `T30` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T31` = `{ id: int, attributeId: int, value: str, sort: int }`
- `T32` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T33` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T34` = `{ id: int, name: str, sort: int, exclusive: bool }`
- `T35` = `{ warehouse: T37 }`
- `T36` = `{ net: num, taxId: int, taxRate: num }`
- `T37` = `{ id: int, name: str, sort: int }`
- `T38` = `{ id: int, categoryIds: int[] }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T19[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T39 }`
- `T5` = `{ labeltemplate: T6 }`
- `T6` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T15, thumb?: str|null }`
- `T7` = `{ order: T10 }`
- `T8` = `{ quote: T10 }`
- `T9` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T10` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T19[], createdAt?: str }`
- `T11` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null }`
- `T12` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T13` = `{ ledger: T14[] }`
- `T14` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T15` = `{ objects: T20[] }`
- `T16` = `{ objects: T21[] }`
- `T17` = `{ printer: T22 }`
- `T18` = `{ entity: T26 }`
- `T19` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T20` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T21` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T22` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T37[] }`
- `T24` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `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, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }`
- `T29` = `{ id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `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` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T37` = `{ id: int, attributeId: int, value: str, sort: int }`
- `T38` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T39` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T40` = `{ id: int, name: str, sort: int, exclusive: bool }`
- `T41` = `{ warehouse: T43 }`
- `T42` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T43` = `{ id: int, name: str, sort: int }`
- `T44` = `{ id: int, categoryIds: int[] }`
### Routes
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, track_stock?: bool }`
- Returns: `201 T25`
- Returns: `201 T30`
#### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int`
- Returns: `200 zip`
@@ -70,15 +76,15 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }`
#### Set Stückliste (bundle items) for an article — `PUT /article/{sku}/articlelist`
- Params: `sku: str, body: { items: { articleId?: int, sku?: str, count?: int, sort?: int }[] }`
- Returns: `200 { items: obj[] }`
- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }`
#### List article attribute values — `GET /article/{sku}/attributevalue`
- Params: `sku: str`
- Returns: `200 { attributevalues: { id: int, articleId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, articleId?: int, exclusive?: bool }[] }`
#### Bulk set article attribute values — `PUT /article/{sku}/attributevalue`
- Params: `sku: str, body: int[]`
- Returns: `200 { attributevalues: { id: int, articleId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }`
#### Set or clear article brand by SKU — `PUT /article/{sku}/brand`
- Params: `sku: str, body: { brandId: int }`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, articleId?: int, exclusive?: bool }[] }`
#### Set or clear article brand by SKU (null to clear) — `PUT /article/{sku}/brand`
- Params: `sku: str, body: { brandId: int|null }`
- Returns: `200 T1`
#### Replace article categories by SKU — `PUT /article/{sku}/categories`
- Params: `sku: str, body: { categoryIds: int[] }`
@@ -88,10 +94,10 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- 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 T25`
- 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: T27, children: obj[] }`
- 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 }`
@@ -115,31 +121,31 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
#### Set options list for an article — `PUT /article/{sku}/optionslist`
- Params: `sku: str, body: { options: { articleId?: int, sku?: str, count?: int, sort?: int, mandated?: bool }[] }`
- Returns: `200 { options: obj[] }`
- 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 T25`
#### Replace POS prices by SKU — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { price: num, tax: num, minmultiplier?: num }[] }`
- 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`
#### Print article label — `POST /article/{sku}/print`
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
- Returns: `200`
- Returns: `200 { success: bool }`
#### List article properties — `GET /article/{sku}/property`
- Params: `sku: str`
- Returns: `200 { properties: { id: int, articleId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, articleId?: int }[] }`
#### Bulk set article properties — `PUT /article/{sku}/property`
- Params: `sku: str, body: { properties: { propertyId: int, value: str, sort?: int }[] }`
- Returns: `200 { properties: { id: int, articleId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, articleId?: int }[] }`
#### Delete article property — `DELETE /article/{sku}/property/{propertyId}`
- Params: `sku: str, propertyId: int`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T9[] }`
- Returns: `200 { suppliers: T11[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: str, body: { entityId: int, sku?: str, pu?: num, stock?: num, sort?: int, netPrice?: num, taxId?: int, taxRate?: num }`
- Returns: `201 { supplier: T9 }`
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `201 { supplier: T11 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -147,8 +153,8 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Params: `sku: str, id: int`
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: str, id: int, body: { sku?: str, pu?: num, stock?: num, sort?: int, netPrice?: num, taxId?: int, taxRate?: num }`
- Returns: `200 { supplier: T9 }`
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `200 { supplier: T11 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T1`
@@ -157,64 +163,64 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `200 T1`
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T13[] }`
- Returns: `200 { attributenames: T23[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T13 }`
- Returns: `201 { attributename: T23 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T31[] }`
- Returns: `200 { values: T37[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T31 }`
- Returns: `201 { attributevalue: T37 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### Delete attribute name — `DELETE /attributename/{id}`
- Params: `id: int`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T13 }`
- Returns: `200 { attributename: T23 }`
#### Delete attribute value — `DELETE /attributevalue/{id}`
- Params: `id: int`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T31 }`
- Returns: `200 { attributevalue: T37 }`
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T29 }`
- Returns: `200 { message: str, user: T34 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: T29, googleClientId: str }`
- Returns: `200 { user: T34, googleClientId: str }`
#### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }`
- Returns: `200 { user: T29 }`
- Returns: `200 { user: T34 }`
#### List brands — `GET /brand`
_none_
- Returns: `200 { brands: { id: int, name: str, gsprpinfo: str, pics: T24[] }[] }`
- Returns: `200 { brands: { id: int, name: str, pics: T29[], gsprpinfo?: str|null }[] }`
#### Upsert brand — `PUT /brand`
- Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `200 { brand: { id: int, name: str, gsprpinfo: str, pics: T24[] } }`
- Returns: `200 { brand: { id: int, name: str, pics: T29[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int, thumbH: int, thumbHash: str, pics: T24[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T29[] } }`
#### 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: T24[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T29[] } }`
#### Create category — `POST /category`
- Params: `body: { parentId: int, name: str }`
- Returns: `201 { category: { id: int, parentId: int, sort: int, name: str } }`
- Returns: `201 { category: { id: int, parentId: int|null, sort: int, name: str } }`
#### Export categories as ZIP — `GET /category/export.zip`
_none_
- Returns: `200 zip`
@@ -223,7 +229,7 @@ _none_
- Returns: `200 { categoryIds: int[] }`
#### Category tree — `GET /category/tree`
_none_
- Returns: `200 { languageId: int, tree: obj[], articles: obj[] }`
- Returns: `200 { languageId: int, tree: { id: int, sort: int, posterminalIds: int[], parentId?: int|null, name?: str|null, slug?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }[], articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T27|null }[] }`
#### Delete category — `DELETE /category/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -232,61 +238,61 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num, taxRate: num, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, brand: obj }[] }`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T27|null }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int`
- Returns: `200 { attributevalues: { id: int, categoryId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
#### Bulk set category attribute values — `PUT /category/{id}/attributevalue`
- Params: `id: int, body: int[]`
- Returns: `200 { attributevalues: { id: int, categoryId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
#### Delete category image — `DELETE /category/{id}/image`
- Params: `id: int`
- Returns: `200 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }`
- Returns: `200 { category: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null } }`
#### Add category image — `POST /category/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }`
#### List category properties — `GET /category/{id}/property`
- Params: `id: int`
- Returns: `200 { properties: { id: int, categoryId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`
#### Bulk set category properties — `PUT /category/{id}/property`
- Params: `id: int, body: { properties: { propertyId: int, value: str, sort?: int }[] }`
- Returns: `200 { properties: { id: int, categoryId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`
#### Delete category property — `DELETE /category/{id}/property/{propertyId}`
- Params: `id: int, propertyId: int`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: T26[] }`
- Returns: `200 { companies: T32[] }`
#### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T26, user: T29 }`
- Returns: `201 { company: T32, user: T34 }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, companyuserId: int, date: str, text: str }[], truncated: bool }`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
#### Add company history entry — `POST /company/history`
- Params: `body: { text: str }`
- Returns: `201 { history: { id: int, companyuserId: int, date: str, text: str } }`
- Returns: `201 { history: { id: int, date: str, text: str, companyuserId?: int|null } }`
#### List company settings — `GET /company/setting`
_none_
- Returns: `200 { settings: { key: str, value: str }[] }`
- Returns: `200 { settings: { key: str, value: str|null }[] }`
#### Get company setting by key — `GET /company/setting/{key}`
- Params: `key: str`
- Returns: `200 { setting: { key: str, value: str } }`
- Returns: `200 { setting: { key: str, value: str|null } }`
#### Upsert company setting — `PUT /company/setting/{key}`
- Params: `key: str, body: { value: str, frontend?: bool }`
- Returns: `200 { setting: { key: str, value: str } }`
- Returns: `200 { setting: { key: str, value: str|null } }`
#### Delete company — `DELETE /company/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T26 }`
- Returns: `200 { company: T32 }`
#### List customer groups — `GET /customergroup`
_none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup`
- Params: `body: { name: str }`
- Returns: `201 { customergroup: T37 }`
- Returns: `201 { customergroup: T43 }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }`
@@ -295,12 +301,12 @@ _none_
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T37 }`
- Returns: `200 { customergroup: T43 }`
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, externalNumber: str, date: str, createdAt: str, ledger: T33 }[] }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T39 }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T22[], supplierId?: int, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: obj }[] }`
- Params: `body: { externalNumber: str, date: str, lines: T24[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T3`
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
@@ -312,17 +318,17 @@ _none_
- Params: `id: int`
- Returns: `200 T3`
#### Update delivery — `PATCH /delivery/{id}`
- Params: `id: int, body: { externalNumber?: str, date?: str, supplierId?: int, lines?: T22[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: obj }[] }`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T24[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T3`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T20[] }`
- Returns: `200 { entities: T26[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str, number?: str }`
- Returns: `201 T14`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T18`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
@@ -330,35 +336,35 @@ _none_
- Params: `entityId: int`
- Returns: `200 { addresses: T12[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: int, body: { addressType: str, firstName?: str, name?: str, street?: str, streetNumber?: str, zip?: str, city?: str, county?: str }`
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `201 { address: T12 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str, name?: str, street?: str, streetNumber?: str, zip?: str, city?: str, county?: str }`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `200 { address: T12 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T30[] }`
- Returns: `200 { contacts: T36[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T30 }`
- Returns: `201 { contact: T36 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T30 }`
- Returns: `200 { contact: T36 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T14`
- Returns: `200 T18`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str, number?: str }`
- Returns: `200 T14`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T18`
#### Subscribe to update signals (SSE) — `GET /events`
_none_
- Returns: `200 SSE`
@@ -370,46 +376,46 @@ _none_
- Returns: `200 avif`
#### List label templates — `GET /labeltemplate`
_none_
- Returns: `200 { labeltemplates: T23[] }`
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: obj }`
- Returns: `201 T16`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T16 }`
- Returns: `201 T5`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int`
- Returns: `200 T16`
- Returns: `200 T5`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }`
- Returns: `200 T16`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T16 }`
- Returns: `200 T5`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }`
- Returns: `200 T16`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T16 }`
- Returns: `200 T5`
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T10`
- Returns: `200 T13`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
- Returns: `201 { ledger: T11 }`
- Returns: `201 { ledger: T14 }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T10`
- Returns: `200 T13`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T10`
- Returns: `200 T13`
#### List conversations — `GET /modelconversation`
_none_
- Returns: `200 { conversations: T19[] }`
- Returns: `200 { conversations: T28[] }`
#### Create conversation — `POST /modelconversation`
- Params: `body: { provider: str, model: str }`
- Returns: `201 { conversation: T19 }`
- Returns: `201 { conversation: T28 }`
#### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}`
- Params: `id: int`
- Returns: `200 { conversation: T19 }`
- Returns: `200 { conversation: T28 }`
#### List conversation messages — `GET /modelconversation/{id}/message`
- Params: `id: int`
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
@@ -427,46 +433,46 @@ _none_
- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
#### List orders — `GET /order`
_none_
- Returns: `200 { orders: { id: int, number: str, type: str, entityId: int, date: str, createdAt: str }[] }`
- Returns: `200 { orders: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T22[], entityId?: int }`
- Returns: `201 T5`
- Params: `body: { number: str, date: str, lines: T24[], entityId?: int|null }`
- Returns: `201 T7`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get order — `GET /order/{id}`
- Params: `id: int`
- Returns: `200 T5`
- Returns: `200 T7`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T22[] }`
- Returns: `200 T5`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T24[] }`
- Returns: `200 T7`
#### List POS terminals — `GET /posterminal`
_none_
- Returns: `200 { posterminals: { id: int, name: str, pairingpin: str, categoryIds: int[] }[] }`
- Returns: `200 { posterminals: { id: int, name: str, pairingpin?: str|null, categoryIds?: int[] }[] }`
#### Create POS terminal — `PUT /posterminal`
- Params: `body: { name: str }`
- Returns: `200 { posterminal: { id: int, name: str, pairingpin: str, categoryIds: int[] } }`
- Returns: `200 { posterminal: { id: int, name: str, pairingpin?: str|null, categoryIds?: int[] } }`
#### Delete POS terminal — `DELETE /posterminal/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T38`
- Returns: `200 T44`
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T38`
- Returns: `200 T44`
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T38`
- Returns: `200 T44`
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T38`
- Returns: `200 T44`
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T21[] }`
- Returns: `200 { printers: T22[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str, serial?: str, sort?: int, desc?: str }`
- Returns: `201 T15`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T17`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -475,52 +481,52 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T15`
- Returns: `200 T17`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }`
- Returns: `200 T15`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T17`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }`
- Returns: `200 T15`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T17`
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T34[] }`
- Returns: `200 { propertynames: T40[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T34 }`
- Returns: `201 { propertyname: T40 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### Delete property name — `DELETE /propertyname/{id}`
- Params: `id: int`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T34 }`
- Returns: `200 { propertyname: T40 }`
#### List public articles — `GET /public/{companyId}/articles`
- Params: `companyId: int, query: categoryId?: int, search?: str, limit?: int, offset?: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num, taxRate: num, gross: num, trackStock: bool, brand: { id: int, name: str }, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[] }`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num|null, taxRate: num|null, gross: num|null, trackStock: bool, brand: { id: int|null, name: str }, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
#### Get public article detail — `GET /public/{companyId}/articles/{sku}`
- Params: `companyId: int, sku: str`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num, weight: num, trackStock: bool, unit: obj, brand: obj, price: obj, prices: obj[], categories: obj[], photos: obj[], eans: str[], isParent: bool, parentSku: str, variants: obj[] } }`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: {}|null, brand: {}|null, price: {}|null, prices: {}[], categories: {}[], photos: {}[], eans: str[], isParent: bool, parentSku: str|null, variants: {}[] } }`
#### List public categories — `GET /public/{companyId}/categories`
- Params: `companyId: int`
- Returns: `200 { categories: { id: int, parentId: int, sort: int, name: str, slug: str, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[] }`
- Returns: `200 { categories: { id: int, parentId: int|null, sort: int, name: str, slug: str, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
#### List quotes — `GET /quote`
_none_
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int, date: str, createdAt: str }[] }`
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T22[], entityId?: int }`
- Returns: `201 T6`
- Params: `body: { number: str, date: str, lines: T24[], entityId?: int|null }`
- Returns: `201 T8`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get quote — `GET /quote/{id}`
- Params: `id: int`
- Returns: `200 T6`
- Returns: `200 T8`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T22[] }`
- Returns: `200 T6`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T24[] }`
- Returns: `200 T8`
#### Crawl image from URL, crop white/transparency borders, and return AVIF — `POST /serper/image`
- Params: `body: { url: str }`
- Returns: `200 avif`
@@ -528,26 +534,26 @@ _none_
- Params: `body: { q: str, gl?: str, hl?: str, num?: int, preferSource?: bool }`
- Returns: `200 SSE`
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: obj[], ledger: T11[] }`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T14[] }`
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T32[] }`
- Returns: `200 { suppliers: T38[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T32 }`
- Returns: `201 { supplier: T38 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T32 }`
- Returns: `200 { supplier: T38 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T36, article: { id: int, sku: str, name: str, slug: str, photos: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[] } }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T36, article: { id: int, sku: str, name: str, slug: str, photos: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[] } }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null }[] }`
#### Thumb image by hash — `GET /thumb/{fullHash}`
- Params: `fullHash: str`
- Returns: `200 avif`
@@ -556,16 +562,16 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }[] }`
- Returns: `200 { units: T31[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: { si: str, basesi: str, basesifactor: num, symbol: str, name: str } }`
- Returns: `200 { unit: T31 }`
#### JTL-POS category synchronization — `GET /v1/category`
- Params: `query: lastChangedCategory?: str, limit?: int`
- Returns: `200 obj[]`
- Returns: `200 {}[]`
#### POS discovery and pairing — `GET /v1/client`
- Params: `query: authCode: str, name?: str`
- Returns: `200 { authCode: str, authToken: str, certificateFingerprint: str, certificateSerialNumber: str, mandantId: str, mandantName: str, mandantDatabase: str, serverFingerprint: str, name: str, serverTimestamp: str }`
- Returns: `200 { authCode: str|null, authToken: str|null, certificateFingerprint: str, certificateSerialNumber: str, mandantId: str, mandantName: str|null, mandantDatabase: str|null, serverFingerprint: str|null, name: str|null, serverTimestamp: str }`
#### POS sync initialization counts — `GET /v1/init`
- Params: `query: lastChangedProduct?: int, lastChangedCategory?: int, lastChangedCustomer?: int, lastChangedCustomerGroup?: int, lastChangedCompositeProduct?: int, lastChangedDeletedEntity?: int`
- Returns: `200 { version: str, product_count: str, category_count: str, customer_count: str, customerGroup_count: str, compositeProduct_count: str, configurationGroup_count: str, configurationItem_count: str, deletedEntity_count: str, max_orderId_count: str }`
@@ -574,16 +580,34 @@ _none_
- Returns: `200 jpeg`
#### JTL-POS product catalog synchronization — `GET /v1/product`
- Params: `query: lastChangedProduct?: str, limit?: int`
- Returns: `200 obj[]`
- Returns: `200 {}[]`
#### List virtual filesystem files — `GET /vfs`
_none_
- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }`
#### Create directory in virtual filesystem — `POST /vfs/mkdir`
- Params: `body: { path: str }`
- Returns: `200 { success: bool, dir: str }`
#### Rename or move a virtual filesystem file or directory — `POST /vfs/rename`
- Params: `body: { oldPath: str, newPath: str }`
- Returns: `200 { success: bool, oldPath: str, newPath: str }`
#### Upload files to virtual filesystem — `POST /vfs/upload`
- Params: `form: { files: str[], targetDir?: str }`
- Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }`
#### Delete a virtual filesystem file — `DELETE /vfs/{*path}`
- Params: `path: str`
- Returns: `200 { success: bool, deleted: str }`
#### Get a virtual filesystem file — `GET /vfs/{*path}`
- Params: `path: str, query: download?: bool, raw?: bool`
- Returns: `200 str`
#### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}`
- Params: `path: str, body: { content: str, mime?: str }`
- Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }`
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T37[] }`
- Returns: `200 { warehouses: T43[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }`
- Returns: `201 T35`
- Returns: `201 T41`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }`
@@ -592,10 +616,10 @@ _none_
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int`
- Returns: `200 T35`
- Returns: `200 T41`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T35`
- Returns: `200 T41`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T35`
- Returns: `200 T41`

View File

@@ -1,54 +1,60 @@
# Dash API Routes Overview
Overview of all 146 API endpoints across all modules.
Overview of all 145 API endpoints across all modules.
### Notation
Types: `str` string, `int` integer, `num` number, `bool` boolean, `obj` object; `T[]` array; `?` optional.
Types: `str` string, `int` integer, `num` number, `bool` boolean; `T[]` array; `?` optional.
Params: path values first, then `query:` and `body:` (`body?` = optional body). Responses: status code + JSON shape; `Tn` refers to a reusable type below.
### Reusable types
- `T1` = `{ article: T2 }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, track_stock: bool, unitamount: num, weight: num, unit: obj, brand: obj, categoryIds: int[], photos: T17[], eans: str[], prices: T26[], suppliers: T9[] }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, track_stock: bool, unitamount: num|null, weight: num|null, unit: T30, brand: T27|null, categoryIds: int[], photos: T25[], eans: str[], prices: T32[], suppliers: T11[] }`
- `T3` = `{ delivery: T4 }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, externalNumber: str, date: str, createdAt: str, ledger: T30, lines: T18[], files: T8[] }`
- `T5` = `{ order: T7 }`
- `T6` = `{ quote: T7 }`
- `T7` = `{ id: int, number: str, type: str, entityId: int, date: str, createdAt: str, lines: T18[] }`
- `T8` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created: str, modified: str, thumbavif: str, thumbx: int, thumby: int, meta: obj }`
- `T9` = `{ id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T33 }`
- `T10` = `{ ledger: T11[] }`
- `T11` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash: str, sku: str, created_at: str }`
- `T12` = `{ id: int, entityId: int, firstName: str, name: str, street: str, streetNumber: str, zip: str, city: str, county: str, addressType: str }`
- `T13` = `{ id: int, name: str, sort: int, exclusive: bool, values: T28[] }`
- `T14` = `{ entity: T19 }`
- `T15` = `{ printer: T20 }`
- `T16` = `{ labeltemplate: T22 }`
- `T17` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T18` = `{ id: int, sku: str, name: str, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str }`
- `T19` = `{ id: int, customergroupId: int, firstName: str, name: str, number: str, createdAt: str, updatedAt: str }`
- `T20` = `{ id: int, company_id: int, address: str, type: str, model: str, serial: str, sort: int, desc: str }`
- `T21` = `{ amount: num, net: num, taxrate: num, sku?: str, name?: str, pu?: num, rebate?: num, comment?: str }`
- `T22` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: obj, thumb: str }`
- `T23` = `{ id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T24` = `{ article: T25 }`
- `T25` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T26` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T27` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T28` = `{ id: int, attributeId: int, value: str, sort: int }`
- `T29` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T30` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T31` = `{ id: int, name: str, sort: int, exclusive: bool }`
- `T32` = `{ warehouse: T34 }`
- `T33` = `{ net: num, taxId: int, taxRate: num }`
- `T34` = `{ id: int, name: str, sort: int }`
- `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 }`
- `T6` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T15, thumb?: str|null }`
- `T7` = `{ order: T10 }`
- `T8` = `{ quote: T10 }`
- `T9` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T10` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T19[], createdAt?: str }`
- `T11` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T39|null }`
- `T12` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T13` = `{ ledger: T14[] }`
- `T14` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T15` = `{ objects: T20[] }`
- `T16` = `{ objects: T21[] }`
- `T17` = `{ printer: T22 }`
- `T18` = `{ entity: T26 }`
- `T19` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T20` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T21` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T22` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T34[] }`
- `T24` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `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 }`
- `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 }`
- `T36` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T37` = `{ id: int, name: str, sort: int, exclusive: bool }`
- `T38` = `{ warehouse: T40 }`
- `T39` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T40` = `{ id: int, name: str, sort: int }`
### Routes
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, track_stock?: bool }`
- Returns: `201 T24`
- Returns: `201 T29`
#### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: str, query: cascade?: bool`
- Returns: `204 void`
@@ -63,15 +69,15 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }`
#### Set Stückliste (bundle items) for an article — `PUT /article/{sku}/articlelist`
- Params: `sku: str, body: { items: { articleId?: int, sku?: str, count?: int, sort?: int }[] }`
- Returns: `200 { items: obj[] }`
- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }`
#### List article attribute values — `GET /article/{sku}/attributevalue`
- Params: `sku: str`
- Returns: `200 { attributevalues: { id: int, articleId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, articleId?: int, exclusive?: bool }[] }`
#### Bulk set article attribute values — `PUT /article/{sku}/attributevalue`
- Params: `sku: str, body: int[]`
- Returns: `200 { attributevalues: { id: int, articleId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }`
#### Set or clear article brand by SKU — `PUT /article/{sku}/brand`
- Params: `sku: str, body: { brandId: int }`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, articleId?: int, exclusive?: bool }[] }`
#### Set or clear article brand by SKU (null to clear) — `PUT /article/{sku}/brand`
- Params: `sku: str, body: { brandId: int|null }`
- Returns: `200 T1`
#### Replace article categories by SKU — `PUT /article/{sku}/categories`
- Params: `sku: str, body: { categoryIds: int[] }`
@@ -81,10 +87,10 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- 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 T24`
- Returns: `201 T29`
#### 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: T25, children: obj[] }`
- Returns: `200 { article: T31, 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 }`
@@ -105,31 +111,31 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
#### Set options list for an article — `PUT /article/{sku}/optionslist`
- Params: `sku: str, body: { options: { articleId?: int, sku?: str, count?: int, sort?: int, mandated?: bool }[] }`
- Returns: `200 { options: obj[] }`
- 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 T24`
#### Replace POS prices by SKU — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { price: num, tax: num, minmultiplier?: num }[] }`
- Returns: `200 T29`
#### 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`
#### Print article label — `POST /article/{sku}/print`
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
- Returns: `200`
- Returns: `200 { success: bool }`
#### List article properties — `GET /article/{sku}/property`
- Params: `sku: str`
- Returns: `200 { properties: { id: int, articleId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, articleId?: int }[] }`
#### Bulk set article properties — `PUT /article/{sku}/property`
- Params: `sku: str, body: { properties: { propertyId: int, value: str, sort?: int }[] }`
- Returns: `200 { properties: { id: int, articleId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, articleId?: int }[] }`
#### Delete article property — `DELETE /article/{sku}/property/{propertyId}`
- Params: `sku: str, propertyId: int`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T9[] }`
- Returns: `200 { suppliers: T11[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: str, body: { entityId: int, sku?: str, pu?: num, stock?: num, sort?: int, netPrice?: num, taxId?: int, taxRate?: num }`
- Returns: `201 { supplier: T9 }`
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `201 { supplier: T11 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -137,8 +143,8 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Params: `sku: str, id: int`
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: str, id: int, body: { sku?: str, pu?: num, stock?: num, sort?: int, netPrice?: num, taxId?: int, taxRate?: num }`
- Returns: `200 { supplier: T9 }`
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `200 { supplier: T11 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T1`
@@ -147,64 +153,64 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `200 T1`
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T13[] }`
- Returns: `200 { attributenames: T23[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T13 }`
- Returns: `201 { attributename: T23 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T28[] }`
- Returns: `200 { values: T34[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T28 }`
- Returns: `201 { attributevalue: T34 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### Delete attribute name — `DELETE /attributename/{id}`
- Params: `id: int`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T13 }`
- Returns: `200 { attributename: T23 }`
#### Delete attribute value — `DELETE /attributevalue/{id}`
- Params: `id: int`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T28 }`
- Returns: `200 { attributevalue: T34 }`
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: { id: int, email: str, selectedCompanyId: int, picture: str } }`
- Returns: `200 { message: str, user: { id: int, email: str, selectedCompanyId: int|null, picture?: str } }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### List brands — `GET /brand`
_none_
- Returns: `200 { brands: { id: int, name: str, gsprpinfo: str, pics: T23[] }[] }`
- Returns: `200 { brands: { id: int, name: str, pics: T28[], gsprpinfo?: str|null }[] }`
#### Upsert brand — `PUT /brand`
- Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `200 { brand: { id: int, name: str, gsprpinfo: str, pics: T23[] } }`
- Returns: `200 { brand: { id: int, name: str, pics: T28[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int, thumbH: int, thumbHash: str, pics: T23[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T28[] } }`
#### 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: T23[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T28[] } }`
#### Create category — `POST /category`
- Params: `body: { parentId: int, name: str }`
- Returns: `201 { category: { id: int, parentId: int, sort: int, name: str } }`
- Returns: `201 { category: { id: int, parentId: int|null, sort: int, name: str } }`
#### Reorder sibling categories — `PUT /category/order`
- Params: `body: { parentId: int, categoryIds: int[] }`
- Returns: `200 { categoryIds: int[] }`
#### Category tree — `GET /category/tree`
_none_
- Returns: `200 { languageId: int, tree: obj[], articles: obj[] }`
- Returns: `200 { languageId: int, tree: { id: int, sort: int, posterminalIds: int[], parentId?: int|null, name?: str|null, slug?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }[], articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T27|null }[] }`
#### Delete category — `DELETE /category/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -213,43 +219,43 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num, taxRate: num, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, brand: obj }[] }`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T27|null }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int`
- Returns: `200 { attributevalues: { id: int, categoryId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
#### Bulk set category attribute values — `PUT /category/{id}/attributevalue`
- Params: `id: int, body: int[]`
- Returns: `200 { attributevalues: { id: int, categoryId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
#### Delete category image — `DELETE /category/{id}/image`
- Params: `id: int`
- Returns: `200 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }`
- Returns: `200 { category: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null } }`
#### List category properties — `GET /category/{id}/property`
- Params: `id: int`
- Returns: `200 { properties: { id: int, categoryId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`
#### Bulk set category properties — `PUT /category/{id}/property`
- Params: `id: int, body: { properties: { propertyId: int, value: str, sort?: int }[] }`
- Returns: `200 { properties: { id: int, categoryId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`
#### Delete category property — `DELETE /category/{id}/property/{propertyId}`
- Params: `id: int, propertyId: int`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: { id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }[] }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, companyuserId: int, date: str, text: str }[], truncated: bool }`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
#### Add company history entry — `POST /company/history`
- Params: `body: { text: str }`
- Returns: `201 { history: { id: int, companyuserId: int, date: str, text: str } }`
- Returns: `201 { history: { id: int, date: str, text: str, companyuserId?: int|null } }`
#### List company settings — `GET /company/setting`
_none_
- Returns: `200 { settings: { key: str, value: str }[] }`
- Returns: `200 { settings: { key: str, value: str|null }[] }`
#### Get company setting by key — `GET /company/setting/{key}`
- Params: `key: str`
- Returns: `200 { setting: { key: str, value: str } }`
- Returns: `200 { setting: { key: str, value: str|null } }`
#### Upsert company setting — `PUT /company/setting/{key}`
- Params: `key: str, body: { value: str, frontend?: bool }`
- Returns: `200 { setting: { key: str, value: str } }`
- Returns: `200 { setting: { key: str, value: str|null } }`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: { id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num } }`
@@ -258,7 +264,7 @@ _none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup`
- Params: `body: { name: str }`
- Returns: `201 { customergroup: T34 }`
- Returns: `201 { customergroup: T40 }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }`
@@ -267,12 +273,12 @@ _none_
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T34 }`
- Returns: `200 { customergroup: T40 }`
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, externalNumber: str, date: str, createdAt: str, ledger: T30 }[] }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T36 }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T21[], supplierId?: int, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: obj }[] }`
- Params: `body: { externalNumber: str, date: str, lines: T24[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T3`
#### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: int`
@@ -281,17 +287,17 @@ _none_
- Params: `id: int`
- Returns: `200 T3`
#### Update delivery — `PATCH /delivery/{id}`
- Params: `id: int, body: { externalNumber?: str, date?: str, supplierId?: int, lines?: T21[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: obj }[] }`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T24[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T3`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T19[] }`
- Returns: `200 { entities: T26[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str, number?: str }`
- Returns: `201 T14`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T18`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
@@ -299,65 +305,65 @@ _none_
- Params: `entityId: int`
- Returns: `200 { addresses: T12[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: int, body: { addressType: str, firstName?: str, name?: str, street?: str, streetNumber?: str, zip?: str, city?: str, county?: str }`
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `201 { address: T12 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str, name?: str, street?: str, streetNumber?: str, zip?: str, city?: str, county?: str }`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `200 { address: T12 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T27[] }`
- Returns: `200 { contacts: T33[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T27 }`
- Returns: `201 { contact: T33 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T27 }`
- Returns: `200 { contact: T33 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T14`
- Returns: `200 T18`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str, number?: str }`
- Returns: `200 T14`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T18`
#### List label templates — `GET /labeltemplate`
_none_
- Returns: `200 { labeltemplates: T22[] }`
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: obj }`
- Returns: `201 T16`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T16 }`
- Returns: `201 T5`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int`
- Returns: `200 T16`
- Returns: `200 T5`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }`
- Returns: `200 T16`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T16 }`
- Returns: `200 T5`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }`
- Returns: `200 T16`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T16 }`
- Returns: `200 T5`
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T10`
- Returns: `200 T13`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
- Returns: `201 { ledger: T11 }`
- Returns: `201 { ledger: T14 }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T10`
- Returns: `200 T13`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T10`
- Returns: `200 T13`
#### List number sequences — `GET /number`
_none_
- Returns: `200 { numbers: { id: int, type: str, prefix: str, padding: int, next: int }[] }`
@@ -366,25 +372,25 @@ _none_
- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
#### List orders — `GET /order`
_none_
- Returns: `200 { orders: { id: int, number: str, type: str, entityId: int, date: str, createdAt: str }[] }`
- Returns: `200 { orders: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T21[], entityId?: int }`
- Returns: `201 T5`
- Params: `body: { number: str, date: str, lines: T24[], entityId?: int|null }`
- Returns: `201 T7`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get order — `GET /order/{id}`
- Params: `id: int`
- Returns: `200 T5`
- Returns: `200 T7`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T21[] }`
- Returns: `200 T5`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T24[] }`
- Returns: `200 T7`
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T20[] }`
- Returns: `200 { printers: T22[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str, serial?: str, sort?: int, desc?: str }`
- Returns: `201 T15`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T17`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -393,82 +399,79 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T15`
- Returns: `200 T17`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }`
- Returns: `200 T15`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T17`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }`
- Returns: `200 T15`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T17`
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T31[] }`
- Returns: `200 { propertynames: T37[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T31 }`
- Returns: `201 { propertyname: T37 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### Delete property name — `DELETE /propertyname/{id}`
- Params: `id: int`
- Returns: `200`
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T31 }`
- Returns: `200 { propertyname: T37 }`
#### List quotes — `GET /quote`
_none_
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int, date: str, createdAt: str }[] }`
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T21[], entityId?: int }`
- Returns: `201 T6`
- Params: `body: { number: str, date: str, lines: T24[], entityId?: int|null }`
- Returns: `201 T8`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get quote — `GET /quote/{id}`
- Params: `id: int`
- Returns: `200 T6`
- Returns: `200 T8`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T21[] }`
- Returns: `200 T6`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T24[] }`
- Returns: `200 T8`
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: obj[], ledger: T11[] }`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T14[] }`
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T29[] }`
- Returns: `200 { suppliers: T35[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T29 }`
- Returns: `201 { supplier: T35 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T29 }`
- Returns: `200 { supplier: T35 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T33, article: { id: int, sku: str, name: str, slug: str, photos: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[] } }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T39|null }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T33, article: { id: int, sku: str, name: str, slug: str, photos: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[] } }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T39|null }[] }`
#### Delete unit — `DELETE /unit`
- Params: `query: si: str`
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }[] }`
- Returns: `200 { units: T30[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: { si: str, basesi: str, basesifactor: num, symbol: str, name: str } }`
#### Get a virtual filesystem file — `GET /vfs/{*path}`
- Params: `path: str, query: download?: bool, raw?: bool`
- Returns: `200 str`
- Returns: `200 { unit: T30 }`
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T34[] }`
- Returns: `200 { warehouses: T40[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }`
- Returns: `201 T32`
- Returns: `201 T38`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }`
@@ -477,13 +480,13 @@ _none_
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int`
- Returns: `200 T32`
- Returns: `200 T38`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T32`
- Returns: `200 T38`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T32`
- Returns: `200 T38`
### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)
These routes are NOT in the table above because their responses are binary or long-lived streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write them to the VFS and embed them in your reply:

View File

@@ -42,7 +42,14 @@ Updated Stückliste items
```json
{
"items": [
{}
{
"id": 1,
"articleId": 1,
"sku": "…",
"name": "…",
"count": 1,
"sort": 1
}
]
}
```

View File

@@ -44,7 +44,14 @@ Attached child article and list of children
"track_stock": true
},
"children": [
{}
{
"id": 1,
"sku": "…",
"name": "…",
"parentsort": 1,
"attributeValueId": 1,
"attributeValue": "…"
}
]
}
```

View File

@@ -44,7 +44,15 @@ Updated options list
```json
{
"options": [
{}
{
"id": 1,
"articleId": 1,
"sku": "…",
"name": "…",
"count": 1,
"sort": 1,
"mandated": true
}
]
}
```

View File

@@ -32,6 +32,12 @@ Prints a label for the article using a label template.
Print job dispatched
```json
{
"success": true
}
```
## Errors
| Status | Description |

View File

@@ -17,6 +17,12 @@ Removes a single property from an article.
Deleted successfully
```json
{
"ok": true
}
```
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -43,8 +43,21 @@ Updated article detail
"track_stock": true,
"unitamount": 1,
"weight": 1,
"unit": {},
"brand": {},
"unit": {
"si": "…",
"basesi": "…",
"basesifactor": 1,
"symbol": "…",
"name": "…"
},
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
},
"categoryIds": [
1
],

View File

@@ -26,8 +26,21 @@ Article detail
"track_stock": true,
"unitamount": 1,
"weight": 1,
"unit": {},
"brand": {},
"unit": {
"si": "…",
"basesi": "…",
"basesifactor": 1,
"symbol": "…",
"name": "…"
},
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
},
"categoryIds": [
1
],

View File

@@ -23,8 +23,8 @@ Sets `articlepic.sort` to `0..n-1` in the given order.
```json
{
"fullHashes": [
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
"aB1cD2eF",
"gH3iJ4kL"
]
}
```

View File

@@ -2,7 +2,7 @@
Protected. Requires a valid session cookie (`credentials: 'include'`).
Set or clear article brand by SKU
Set or clear article brand by SKU (null to clear)
Sets the article's `brandId` (must belong to the article's company).
Pass `null` to clear the brand. Returns the same shape as GET by SKU.
@@ -18,7 +18,7 @@ Pass `null` to clear the brand. Returns the same shape as GET by SKU.
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `brandId` | yes | integer | |
| `brandId` | yes | integer | Brand ID to assign, or null to remove the brand |
```json
{
@@ -44,8 +44,21 @@ Updated article detail
"track_stock": true,
"unitamount": 1,
"weight": 1,
"unit": {},
"brand": {},
"unit": {
"si": "…",
"basesi": "…",
"basesifactor": 1,
"symbol": "…",
"name": "…"
},
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
},
"categoryIds": [
1
],

View File

@@ -5,8 +5,8 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Replace article categories by SKU
Replace-all the article's category list. Each category must belong to
the article's company; ids must be unique. Empty array removes the
article from all categories. Returns the same shape as GET by SKU.
the article's company; ids must be unique. The article must belong to
at least one category (empty array is rejected). Returns the same shape as GET by SKU.
## Path parameters
@@ -48,8 +48,21 @@ Updated article detail
"track_stock": true,
"unitamount": 1,
"weight": 1,
"unit": {},
"brand": {},
"unit": {
"si": "…",
"basesi": "…",
"basesifactor": 1,
"symbol": "…",
"name": "…"
},
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
},
"categoryIds": [
1
],
@@ -102,7 +115,7 @@ Updated article detail
| Status | Description |
|--------|-------------|
| 400 | Invalid categoryIds — `{"error":"…"}` |
| 400 | Invalid categoryIds or empty array — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article not found — `{"error":"…"}` |

View File

@@ -46,8 +46,21 @@ Updated article detail
"track_stock": true,
"unitamount": 1,
"weight": 1,
"unit": {},
"brand": {},
"unit": {
"si": "…",
"basesi": "…",
"basesifactor": 1,
"symbol": "…",
"name": "…"
},
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
},
"categoryIds": [
1
],

View File

@@ -2,11 +2,14 @@
Protected. Requires a valid session cookie (`credentials: 'include'`).
Replace POS prices by SKU
Replace POS prices by SKU (net > gross; price=net, tax=taxRate %)
Replace-all prices for the company POS customer group.
Each entry: `price` (net), `tax` (rate, ensured via tax.ensure), optional `minmultiplier` (default 1).
Empty `prices` clears all POS prices. Returns the same shape as GET by SKU (article `prices` array contains `{ minmultiplier, net, taxRate, customergroupId }`).
`net` takes precedence over `price` (legacy alias for net) and `gross`.
`tax` and `taxRate` both specify the tax rate percentage (e.g. 19 for 19%), not monetary tax.
When `gross` is provided, net price is computed as `gross / (1 + taxRate / 100)`.
Prices are strictly scoped to the company's designated POS customer group (customergroupId returned on GET).
Empty `prices` clears all POS prices. Returns the same shape as GET by SKU.
## Path parameters
@@ -20,20 +23,23 @@ Empty `prices` clears all POS prices. Returns the same shape as GET by SKU (arti
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `prices` | yes | object[] | |
| `prices[].price` | yes | number | Net price |
| `prices[].tax` | yes | number | Tax rate (e.g. 19) |
| `prices[].minmultiplier` | no | number | Defaults to 1; must be unique per request |
| `prices[].net` | no | number | Net price (excl. VAT). Either `net`, `gross`, or legacy `price` is required. |
| `prices[].gross` | no | number | Gross price (incl. VAT). Net is computed as `gross / (1 + taxRate / 100)`. |
| `prices[].price` | no | number | Legacy alias for `net`. |
| `prices[].taxRate` | no | number | Tax rate percentage (e.g. 19 for 19%, 7 for 7%, 0 for 0%). |
| `prices[].tax` | no | number | Alias for `taxRate` percentage. |
| `prices[].minmultiplier` | no | number | Defaults to 1; must be unique per request. |
```json
{
"prices": [
{
"price": 10.5,
"tax": 19
"net": 10.5,
"taxRate": 19
},
{
"price": 9.9,
"tax": 19,
"gross": 11.9,
"taxRate": 19,
"minmultiplier": 10
}
]
@@ -58,8 +64,21 @@ Updated article detail
"track_stock": true,
"unitamount": 1,
"weight": 1,
"unit": {},
"brand": {},
"unit": {
"si": "…",
"basesi": "…",
"basesifactor": 1,
"symbol": "…",
"name": "…"
},
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
},
"categoryIds": [
1
],

View File

@@ -44,8 +44,21 @@ Updated article detail
"track_stock": true,
"unitamount": 1,
"weight": 1,
"unit": {},
"brand": {},
"unit": {
"si": "…",
"basesi": "…",
"basesifactor": 1,
"symbol": "…",
"name": "…"
},
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
},
"categoryIds": [
1
],

View File

@@ -48,8 +48,21 @@ Updated article detail
"track_stock": true,
"unitamount": 1,
"weight": 1,
"unit": {},
"brand": {},
"unit": {
"si": "…",
"basesi": "…",
"basesifactor": 1,
"symbol": "…",
"name": "…"
},
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
},
"categoryIds": [
1
],

View File

@@ -30,6 +30,12 @@ Updates the display sort order of values belonging to an attribute.
Reordered successfully
```json
{
"ok": true
}
```
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -16,6 +16,12 @@ Deletes an attribute name and cascades to attribute values and article assignmen
Deleted successfully
```json
{
"ok": true
}
```
## Errors
| Status | Description |

View File

@@ -24,6 +24,12 @@ Updates the display sort order of attribute names for the selected company.
Reordered successfully
```json
{
"ok": true
}
```
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -16,6 +16,12 @@ Deletes an attribute value and cascades to article assignments.
Deleted successfully
```json
{
"ok": true
}
```
## Errors
| Status | Description |

View File

@@ -17,6 +17,12 @@ Removes a single property from a category.
Deleted successfully
```json
{
"ok": true
}
```
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -30,11 +30,20 @@ Articles list
"optionsCount": 1,
"articleListCount": 1,
"childCount": 1,
"parentArticleId": 1,
"parentSku": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"brand": {}
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
}
}
]
}

View File

@@ -14,10 +14,48 @@ Tree and articles for the selected company
{
"languageId": 1,
"tree": [
{}
{
"id": 1,
"parentId": 1,
"sort": 1,
"name": "…",
"slug": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"posterminalIds": [
1
]
}
],
"articles": [
{}
{
"id": 1,
"sku": "…",
"name": "…",
"slug": "…",
"net": 1,
"taxRate": 1,
"track_stock": true,
"optionsCount": 1,
"articleListCount": 1,
"childCount": 1,
"parentArticleId": 1,
"parentSku": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
}
}
]
}
```

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Full image by hash
Returns the full AVIF by `pic.hash` (SHA-256 hex).
Returns the full AVIF by `pic.hash` (8-char alphanumeric Base62).
Auth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`).
@@ -12,7 +12,7 @@ Auth via article category access. Browser-cacheable (`private`, `immutable`, `ET
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `fullHash` | yes | string | SHA-256 hex of the full image bytes |
| `fullHash` | yes | string | 8-char alphanumeric hash of the full image bytes |
## Response `200`

View File

@@ -18,7 +18,7 @@ and stored with the template.
| `height` | yes | number | |
| `dpi` | yes | integer | |
| `type` | yes | string | |
| `content` | yes | object | |
| `content` | yes | LabelContent | |
```json
{
@@ -27,7 +27,22 @@ and stored with the template.
"height": 1,
"dpi": 1,
"type": "…",
"content": {}
"content": {
"objects": [
{
"id": "…",
"type": "textbox",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"text": "…",
"font": "…",
"fontSize": 1,
"barcodeFormat": "EAN8"
}
]
}
}
```
@@ -44,7 +59,22 @@ Created label template
"height": 1,
"dpi": 1,
"type": "…",
"content": {},
"content": {
"objects": [
{
"id": "…",
"type": "textbox",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"text": "…",
"font": "…",
"fontSize": 1,
"barcodeFormat": "EAN8"
}
]
},
"thumb": "…"
}
}

View File

@@ -25,7 +25,22 @@ Label template details
"height": 1,
"dpi": 1,
"type": "…",
"content": {},
"content": {
"objects": [
{
"id": "…",
"type": "textbox",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"text": "…",
"font": "…",
"fontSize": 1,
"barcodeFormat": "EAN8"
}
]
},
"thumb": "…"
}
}

View File

@@ -20,7 +20,6 @@ List of label templates
"height": 1,
"dpi": 1,
"type": "…",
"content": {},
"thumb": "…"
}
]

View File

@@ -23,7 +23,7 @@ When `content` (or `width`/`height`) is provided, the thumbnail is automatically
| `height` | no | number | |
| `dpi` | no | integer | |
| `type` | no | string | |
| `content` | no | object | |
| `content` | no | LabelContent | |
```json
{
@@ -32,7 +32,22 @@ When `content` (or `width`/`height`) is provided, the thumbnail is automatically
"height": 1,
"dpi": 1,
"type": "…",
"content": {}
"content": {
"objects": [
{
"id": "…",
"type": "textbox",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"text": "…",
"font": "…",
"fontSize": 1,
"barcodeFormat": "EAN8"
}
]
}
}
```
@@ -49,7 +64,22 @@ Updated label template
"height": 1,
"dpi": 1,
"type": "…",
"content": {},
"content": {
"objects": [
{
"id": "…",
"type": "textbox",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"text": "…",
"font": "…",
"fontSize": 1,
"barcodeFormat": "EAN8"
}
]
},
"thumb": "…"
}
}

View File

@@ -16,6 +16,12 @@ Deletes a property name and cascades to article properties.
Deleted successfully
```json
{
"ok": true
}
```
## Errors
| Status | Description |

View File

@@ -24,6 +24,12 @@ Updates the display sort order of property names for the selected company.
Reordered successfully
```json
{
"ok": true
}
```
## Errors
| Status | Description |

View File

@@ -55,7 +55,14 @@ Stock adjusted successfully
"createdAt": "2026-08-10T00:00:00.000Z"
},
"lines": [
{}
{
"manifestarticle_id": 1,
"sku": "…",
"name": "…",
"warehouse_id": 1,
"amount": 1,
"text": "…"
}
],
"ledger": [
{

View File

@@ -12,7 +12,7 @@ Auth via article category access. Browser-cacheable (`private`, `immutable`, `ET
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `fullHash` | yes | string | SHA-256 hex of the full image bytes |
| `fullHash` | yes | string | 8-char alphanumeric hash of the full image bytes |
## Response `200`

View File

@@ -4,42 +4,70 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Get a virtual filesystem file
Returns a file the model produced in the `javascript_execute` sandbox
(virtual filesystem), for the authenticated user's selected company.
Returns a file from the virtual filesystem for the authenticated user's selected company.
Commonly used to retrieve artifacts generated by the model in the `javascript_execute` sandbox or uploaded by users.
Embed the returned URL in chat markdown to display a generated image:
`![title](/api/vfs/example.png)`.
### URL Format
Accepts:
- Relative VFS paths: `example.png`, `reports/summary.pdf`
- Full endpoint paths: `/vfs/example.png`, `/api/vfs/example.png` (leading `api/` and `vfs/` prefixes are automatically stripped)
- Absolute URLs: `https://.../api/vfs/example.png`
- Traversal (`..`), null bytes (`\0`), and backslashes (`\`) are strictly forbidden and rejected with HTTP 400.
Images are returned as a 250x250 aspect-preserving preview unless requested with
`download=1` or `raw=1`.
Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served
inline so they open directly in the browser; other files are sent as downloads.
### Image Previews & Thumbnails
Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`)
are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88):
- Scaled to fit within a 250×250 box without distortion (e.g. 2000×100 becomes 250×13).
- Images whose larger edge is below 100px are upscaled to 100px for icon legibility.
- A translucent dark metadata strip (`#101317`, max 50% height) with monospace text (`DejaVu Sans Mono`)
is rendered along the bottom edge, displaying original dimensions and file size.
- Served with `Content-Security-Policy: default-src 'none'; sandbox`.
- Use `?raw=1` (or `?raw=true`) to bypass preview generation and return the original unmodified image inline.
### Inline vs Attachment
- Browser-compatible types (HTML, CSS, JS, JSON, PDF, text, images, audio, video, fonts, WASM)
are served with `Content-Disposition: inline; filename="..."` so they open directly in modern browsers.
- Non-browser/binary types default to `Content-Disposition: attachment; filename="..."`.
- Use `?download=1` (or `?download=true`) to force file download with `Content-Disposition: attachment`.
### Caching & Conditional Requests
- Generates weak ETags (`W/"thumb-..."` for previews, `W/"<timestamp>-<mode>"` for originals).
- Sets `Cache-Control: private, max-age=0, must-revalidate`.
- Supports `If-None-Match` conditional requests returning HTTP `304 Not Modified`.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | VFS file path (e.g. `example.png`, `labels/a.png`) |
| `path` | yes | string | VFS file path (e.g. `example.png`, `charts/graph.svg`) |
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `download` | no | boolean | Force file download with Content-Disposition: attachment.
|
| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail.
|
| `download` | no | boolean | Force file download with Content-Disposition: attachment (1 or true). |
| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail (1 or true). |
## Response `200`
File bytes
File bytes. Served inline for browser-renderable formats or as a download.
Includes headers:
- `Content-Type`: MIME type of file, or `image/webp` for previews
- `Content-Length`: Size of served payload in bytes
- `Content-Disposition`: `inline; filename="..."` or `attachment; filename="..."`
- `Cache-Control`: `private, max-age=0, must-revalidate`
- `ETag`: Weak ETag identifier
- `X-Content-Type-Options`: `nosniff`
- `Content-Security-Policy`: `default-src 'none'; sandbox` (for preview thumbnails)
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid path, or no company selected — `{"error":"…"}` |
| 304 | Not modified (If-None-Match matched ETag) |
| 400 | Invalid path, path escapes VFS boundary, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | File not found — `{"error":"…"}` |

View File

@@ -0,0 +1,42 @@
# `DELETE /vfs/{*path}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete a virtual filesystem file
Deletes a file from both the on-disk virtual filesystem and database for the authenticated user's selected company.
Deleting read-only database files (`image/*` or filenames containing 8-character image hashes) is blocked with HTTP 403.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | VFS file path to delete (e.g. `temp.png`, `notes.txt`) |
## Signals
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
## Response `200`
File successfully deleted
```json
{
"success": true,
"deleted": "…"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid path, path traversal, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized, or cannot delete read-only database files — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,43 @@
# `GET /vfs`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List virtual filesystem files
Lists all virtual filesystem files for the authenticated user's selected company.
Scans directories recursively (ignoring hidden dotfiles) and returns file
metadata including path, byte size, detected MIME type, timestamps, and classification flags.
## Response `200`
List of virtual filesystem files
```json
{
"files": [
{
"name": "…",
"size": 1,
"mime": "…",
"updated_at": "2026-08-10T00:00:00.000Z",
"created_at": "2026-08-10T00:00:00.000Z",
"isText": true,
"isImage": true,
"isPdf": true
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,48 @@
# `POST /vfs/mkdir`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create directory in virtual filesystem
Creates a directory (and any necessary parent directories) in the virtual filesystem
for the authenticated user's selected company.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | Directory path to create (e.g. `reports/2026/q1`) |
```json
{
"path": "…"
}
```
## Signals
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
## Response `200`
Directory created successfully
```json
{
"success": true,
"dir": "…"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Path is required, invalid characters, traversal, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized, or cannot create directory inside read-only database paths — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,52 @@
# `POST /vfs/rename`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Rename or move a virtual filesystem file or directory
Renames or moves a file or directory within the virtual filesystem for the authenticated user's selected company.
Parent directories for the destination path are created automatically if needed.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `oldPath` | yes | string | Existing VFS file or directory path (e.g. `draft.txt`) |
| `newPath` | yes | string | New destination VFS path (e.g. `archive/final.txt`) |
```json
{
"oldPath": "…",
"newPath": "…"
}
```
## Signals
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
## Response `200`
File or directory successfully renamed
```json
{
"success": true,
"oldPath": "…",
"newPath": "…"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Path is required, invalid characters, traversal, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized, or cannot rename read-only database paths — `{"error":"…"}` |
| 404 | Source file or directory not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,53 @@
# `POST /vfs/upload`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Upload files to virtual filesystem
Uploads one or more files to the virtual filesystem for the authenticated user's selected company.
Files are written to the company's VFS root directory and saved to the database.
An optional `targetDir` form field specifies a subfolder within the VFS.
Writing to read-only database paths (`image/*` or filenames containing 8-character image hashes) is
forbidden and automatically skipped during upload.
## Body (`multipart/form-data`)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `files` | yes | file | One or more files to upload (form field name `files`) |
| `targetDir` | no | string | Optional destination subdirectory within VFS (e.g. `reports` or `charts/q1`) |
## Signals
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
## Response `200`
Successfully uploaded files
```json
{
"success": true,
"uploaded": [
{
"name": "…",
"size": 1,
"mime": "…"
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No files uploaded, invalid path, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,66 @@
# `PUT /vfs/{*path}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create or overwrite a virtual filesystem file
Creates or overwrites a file at the specified virtual filesystem path for the authenticated user's selected company.
Supports multiple request payload formats:
- JSON body: `{"content": "text content", "mime": "optional/mime"}`
- Binary body: raw binary buffer with optional `Content-Type` header
- Plain text: raw UTF-8 string
Modifying read-only database paths (`image/*` or filenames containing 8-character image hashes) is blocked with HTTP 403.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | Destination VFS file path (e.g. `notes.txt`, `data/metrics.json`) |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `content` | yes | string | File text content |
| `mime` | no | string | Optional MIME type override |
```json
{
"content": "…",
"mime": "…"
}
```
## Signals
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
## Response `200`
File successfully written
```json
{
"success": true,
"file": {
"name": "…",
"size": 1,
"mime": "…",
"updated_at": "2026-08-10T00:00:00.000Z"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Path is required, invalid characters/traversal, no content provided, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized, or cannot modify read-only database files — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -50,6 +50,9 @@ function isRouteOmitted(method, apiPath) {
if (apiPath === '/public' || apiPath.startsWith('/public/')) {
return true;
}
if (apiPath === '/vfs' || apiPath.startsWith('/vfs/')) {
return true;
}
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
}
@@ -528,7 +531,7 @@ function summarizeSchema(
skipRootAlias = false,
) {
if (!schema) return '';
if (depth > 6) return aliases?.compactTypes ? 'obj' : 'object';
if (depth > 6) return aliases?.compactTypes ? '{}' : 'object';
const resolved = resolveSchema(schema, components);
if (!resolved) return schemaRefName(schema) || '';
@@ -544,12 +547,19 @@ function summarizeSchema(
const alias = shape && aliases.get(`${markOptional ? 1 : 0}\0${shape}`);
if (alias && !skipRootAlias) return alias;
const hasExplicitRequired = Array.isArray(resolved.required);
const required = new Set(resolved.required || []);
let entries = Object.entries(resolved.properties || {});
if (entries.length === 0) return aliases?.compactTypes ? 'obj' : 'object';
if (entries.length === 0) {
if (resolved.additionalProperties && typeof resolved.additionalProperties === 'object') {
const valType = summarizeSchema(resolved.additionalProperties, components, depth + 1, markOptional, aliases) || 'any';
return `{ [k]: ${valType} }`;
}
return aliases?.compactTypes ? '{}' : 'object';
}
// Required props first, then optional ones (stable within each group).
if (markOptional) {
if (markOptional || hasExplicitRequired) {
entries = entries
.map(([name], i) => ({ name, i }))
.sort((a, b) => (required.has(a.name) ? 0 : 1) - (required.has(b.name) ? 0 : 1) || a.i - b.i)
@@ -557,24 +567,32 @@ function summarizeSchema(
}
const parts = entries.map(([name, prop]) => {
const opt = markOptional && !required.has(name) ? '?' : '';
const type = summarizeSchema(prop, components, depth + 1, markOptional, aliases) || 'any';
const isOpt = markOptional ? !required.has(name) : (hasExplicitRequired && !required.has(name));
const opt = isOpt ? '?' : '';
let type = summarizeSchema(prop, components, depth + 1, markOptional, aliases) || 'any';
if (prop?.nullable && !type.includes('|null')) {
type = `${type}|null`;
}
return `${name}${opt}: ${type}`;
});
return `{ ${parts.join(', ')} }`;
}
if (resolved.type) {
let t = resolved.type;
if (aliases?.compactTypes) {
return {
t = {
string: 'str',
integer: 'int',
number: 'num',
boolean: 'bool',
object: 'obj',
object: '{}',
}[resolved.type] || resolved.type;
}
return resolved.type;
if (resolved.nullable && !t.includes('|null')) {
t = `${t}|null`;
}
return t;
}
return schemaRefName(schema) || '';
}
@@ -738,7 +756,7 @@ function renderRoutesOverview(operations, title = 'Dash API Routes Overview') {
lines.push('');
lines.push('### Notation');
lines.push('');
lines.push('Types: `str` string, `int` integer, `num` number, `bool` boolean, `obj` object; `T[]` array; `?` optional.');
lines.push('Types: `str` string, `int` integer, `num` number, `bool` boolean; `T[]` array; `?` optional.');
lines.push('Params: path values first, then `query:` and `body:` (`body?` = optional body). Responses: status code + JSON shape; `Tn` refers to a reusable type below.');
if (definitions.length) {
lines.push('');

View File

@@ -50,6 +50,13 @@
* type: number
* reducedtaxrate:
* type: number
* SuccessResponse:
* type: object
* required: [ok]
* properties:
* ok:
* type: boolean
* example: true
* CompanySetting:
* type: object
* required: [key, value]
@@ -73,14 +80,18 @@
* type: string
* Unit:
* type: object
* description: Measurement unit with conversion factor to base SI unit (e.g. g -> kg, ml -> l)
* required: [si, basesi, basesifactor]
* properties:
* si:
* type: string
* description: Unit code identifier (e.g. "g", "kg", "ml", "l", "stk")
* basesi:
* type: string
* description: Base SI reference unit (e.g. "kg" for mass, "l" or "m3" for volume, "stk" for counts)
* basesifactor:
* type: number
* description: Conversion factor to base SI (1 si = basesifactor * basesi; e.g. 0.001 for g -> kg)
* symbol:
* type: string
* nullable: true
@@ -130,13 +141,15 @@
* unitamount:
* type: number
* nullable: true
* description: Net package content quantity measured in article unit (e.g. 500 for 500g, 0.75 for 0.75l)
* weight:
* type: number
* nullable: true
* description: Shipping weight in kg (must be >= 0)
* unit:
* type: object
* $ref: '#/components/schemas/Unit'
* brand:
* type: object
* $ref: '#/components/schemas/ArticleBrand'
* nullable: true
* categoryIds:
* type: array
@@ -354,6 +367,157 @@
* description: Brand pictures sorted by sort
* items:
* $ref: '#/components/schemas/BrandPic'
* ArticleBrand:
* type: object
* required: [name]
* properties:
* id:
* type: integer
* nullable: true
* name:
* type: string
* fullHash:
* type: string
* nullable: true
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* StuecklisteItem:
* type: object
* required: [id, articleId, sku, name, count, sort]
* properties:
* id:
* type: integer
* articleId:
* type: integer
* sku:
* type: string
* name:
* type: string
* count:
* type: integer
* sort:
* type: integer
* ArticleChild:
* type: object
* required: [id, sku, name, parentsort, attributeValueId, attributeValue]
* properties:
* id:
* type: integer
* sku:
* type: string
* name:
* type: string
* parentsort:
* type: integer
* attributeValueId:
* type: integer
* attributeValue:
* type: string
* ArticleOptionItem:
* type: object
* required: [id, articleId, sku, name, count, sort, mandated]
* properties:
* id:
* type: integer
* articleId:
* type: integer
* sku:
* type: string
* name:
* type: string
* count:
* type: integer
* sort:
* type: integer
* mandated:
* type: boolean
* CategoryTreeNode:
* type: object
* required: [id, sort, posterminalIds]
* properties:
* id:
* type: integer
* parentId:
* type: integer
* nullable: true
* sort:
* type: integer
* name:
* type: string
* nullable: true
* slug:
* type: string
* nullable: true
* fullHash:
* type: string
* nullable: true
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* posterminalIds:
* type: array
* items:
* type: integer
* CategoryArticle:
* type: object
* required: [id, sku, name, slug, track_stock, optionsCount, articleListCount, childCount]
* properties:
* id:
* type: integer
* sku:
* type: string
* name:
* type: string
* slug:
* type: string
* net:
* type: number
* nullable: true
* taxRate:
* type: number
* nullable: true
* track_stock:
* type: boolean
* optionsCount:
* type: integer
* articleListCount:
* type: integer
* childCount:
* type: integer
* parentArticleId:
* type: integer
* nullable: true
* parentSku:
* type: string
* nullable: true
* fullHash:
* type: string
* nullable: true
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* brand:
* $ref: '#/components/schemas/ArticleBrand'
* nullable: true
* NumberSequence:
* type: object
* required: [id, type, prefix, padding, next]
@@ -1012,6 +1176,60 @@
* type: array
* items:
* type: integer
* LabelObject:
* type: object
* required: [id, type, x, y]
* properties:
* id:
* type: string
* type:
* type: string
* enum: [textbox, barcode, png]
* x:
* type: number
* y:
* type: number
* w:
* type: number
* h:
* type: number
* text:
* type: string
* font:
* type: string
* fontSize:
* type: number
* barcodeFormat:
* type: string
* enum: [EAN8, EAN13, CODE128, QR]
* LabelContent:
* type: object
* required: [objects]
* properties:
* objects:
* type: array
* description: Canvas label editor items
* items:
* $ref: '#/components/schemas/LabelObject'
* LabelTemplateSummary:
* type: object
* required: [id, name, width, height, dpi, type]
* properties:
* id:
* type: integer
* name:
* type: string
* width:
* type: number
* height:
* type: number
* dpi:
* type: integer
* type:
* type: string
* thumb:
* type: string
* nullable: true
* LabelTemplate:
* type: object
* required: [id, name, width, height, dpi, type, content]
@@ -1029,7 +1247,7 @@
* type:
* type: string
* content:
* type: object
* $ref: '#/components/schemas/LabelContent'
* thumb:
* type: string
* nullable: true
@@ -1048,7 +1266,7 @@
* type:
* type: string
* content:
* type: object
* $ref: '#/components/schemas/LabelContent'
* LabelTemplateUpdateInput:
* type: object
* properties:
@@ -1063,7 +1281,7 @@
* type:
* type: string
* content:
* type: object
* $ref: '#/components/schemas/LabelContent'
* Warehouse:
* type: object
* required: [id, name, sort]
@@ -1151,6 +1369,20 @@
* type: array
* items:
* type: object
* required: [manifestarticle_id, sku, name, warehouse_id, amount, text]
* properties:
* manifestarticle_id:
* type: integer
* sku:
* type: string
* name:
* type: string
* warehouse_id:
* type: integer
* amount:
* type: number
* text:
* type: string
* ledger:
* type: array
* items:
@@ -1271,6 +1503,36 @@
* type: string
* sort:
* type: integer
* VfsFile:
* type: object
* required: [name, size, mime, updated_at, created_at, isText, isImage, isPdf]
* properties:
* name:
* type: string
* description: Relative file path within the virtual filesystem
* size:
* type: integer
* description: File size in bytes
* mime:
* type: string
* description: Detected or declared MIME type
* updated_at:
* type: string
* format: date-time
* description: ISO timestamp of last modification
* created_at:
* type: string
* format: date-time
* description: ISO timestamp of creation
* isText:
* type: boolean
* description: True if file is text, code, JSON, Markdown, or tabular data
* isImage:
* type: boolean
* description: True if file is a supported raster image eligible for preview thumbnailing
* isPdf:
* type: boolean
* description: True if file is a PDF document
*/
export {};

View File

@@ -46,6 +46,7 @@ export const openApiOptions = {
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
{ name: 'PublicCatalog', description: 'Public product catalog API' },
{ name: 'Vfs', description: 'Virtual filesystem file management and preview' },
],
},
apis: [

View File

@@ -11,7 +11,7 @@ import { emit } from '../sse.js';
import { buildArtikelCsv, toWindows1252 } from '../../util/artikelCsv.js';
const AVIF_BODY_LIMIT = '20mb';
const FULL_HASH_RE = /^[a-fA-F0-9]{64}$/;
const FULL_HASH_RE = /^[a-zA-Z0-9]{8}$/;
/** Notify SSE subscribers after create / rename-ish list-level article changes. */
function emitArticlesSignal(req) {
@@ -44,9 +44,9 @@ function parseSku(value) {
function parseFullHash(value) {
const fullHash = typeof value === 'string' ? value.trim() : '';
if (!FULL_HASH_RE.test(fullHash)) {
return { error: 'fullHash must be a 64-char hex SHA-256' };
return { error: 'fullHash must be an 8-char alphanumeric string' };
}
return { fullHash: fullHash.toLowerCase() };
return { fullHash };
}
function articleErrorStatus(error) {
@@ -77,7 +77,7 @@ function articleErrorStatus(error) {
if (error.message === 'Invalid attributeValueId') return 400;
if (error.message === 'Invalid weight') return 400;
if (error.message === 'Invalid track_stock') return 400;
if (error.message === 'Invalid categoryIds') return 400;
if (error.message === 'Invalid categoryIds' || error.message === 'Article must have at least one category') return 400;
if (error.message === 'Attribute name not found') return 404;
if (error.message === 'attributeNameId is required to become a parent article') return 400;
if (error.message?.includes('already used by an ancestor parent')) return 400;
@@ -717,11 +717,14 @@ export function register(app, basePath = '') {
* /article/{sku}/prices:
* put:
* tags: [Article]
* summary: Replace POS prices by SKU
* summary: Replace POS prices by SKU (net > gross; price=net, tax=taxRate %)
* description: |
* Replace-all prices for the company POS customer group.
* Each entry: `price` (net), `tax` (rate, ensured via tax.ensure), optional `minmultiplier` (default 1).
* Empty `prices` clears all POS prices. Returns the same shape as GET by SKU (article `prices` array contains `{ minmultiplier, net, taxRate, customergroupId }`).
* `net` takes precedence over `price` (legacy alias for net) and `gross`.
* `tax` and `taxRate` both specify the tax rate percentage (e.g. 19 for 19%), not monetary tax.
* When `gross` is provided, net price is computed as `gross / (1 + taxRate / 100)`.
* Prices are strictly scoped to the company's designated POS customer group (customergroupId returned on GET).
* Empty `prices` clears all POS prices. Returns the same shape as GET by SKU.
* security:
* - cookieAuth: []
* x-signals: [article-<sku>]
@@ -744,23 +747,31 @@ export function register(app, basePath = '') {
* type: array
* items:
* type: object
* required: [price, tax]
* properties:
* net:
* type: number
* description: Net price (excl. VAT). Either `net`, `gross`, or legacy `price` is required.
* gross:
* type: number
* description: Gross price (incl. VAT). Net is computed as `gross / (1 + taxRate / 100)`.
* price:
* type: number
* description: Net price
* description: Legacy alias for `net`.
* taxRate:
* type: number
* description: Tax rate percentage (e.g. 19 for 19%, 7 for 7%, 0 for 0%).
* tax:
* type: number
* description: Tax rate (e.g. 19)
* description: Alias for `taxRate` percentage.
* minmultiplier:
* type: number
* description: Defaults to 1; must be unique per request
* description: Defaults to 1; must be unique per request.
* example:
* prices:
* - price: 10.5
* tax: 19
* - price: 9.9
* tax: 19
* - net: 10.5
* taxRate: 19
* - gross: 11.9
* taxRate: 19
* minmultiplier: 10
* responses:
* 200:
@@ -936,7 +947,7 @@ export function register(app, basePath = '') {
* required: true
* schema:
* type: string
* pattern: '^[a-fA-F0-9]{64}$'
* pattern: '^[a-zA-Z0-9]{8}$'
* responses:
* 204:
* description: Deleted
@@ -1017,11 +1028,11 @@ export function register(app, basePath = '') {
* type: array
* items:
* type: string
* pattern: '^[a-fA-F0-9]{64}$'
* pattern: '^[a-zA-Z0-9]{8}$'
* example:
* fullHashes:
* - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
* - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
* - "aB1cD2eF"
* - "gH3iJ4kL"
* responses:
* 200:
* description: New order
@@ -1190,7 +1201,7 @@ export function register(app, basePath = '') {
* /article/{sku}/brand:
* put:
* tags: [Article]
* summary: Set or clear article brand by SKU
* summary: Set or clear article brand by SKU (null to clear)
* description: |
* Sets the article's `brandId` (must belong to the article's company).
* Pass `null` to clear the brand. Returns the same shape as GET by SKU.
@@ -1215,6 +1226,7 @@ export function register(app, basePath = '') {
* brandId:
* type: integer
* nullable: true
* description: Brand ID to assign, or null to remove the brand
* example:
* brandId: 3
* responses:
@@ -1458,8 +1470,8 @@ export function register(app, basePath = '') {
* summary: Replace article categories by SKU
* description: |
* Replace-all the article's category list. Each category must belong to
* the article's company; ids must be unique. Empty array removes the
* article from all categories. Returns the same shape as GET by SKU.
* the article's company; ids must be unique. The article must belong to
* at least one category (empty array is rejected). Returns the same shape as GET by SKU.
* security:
* - cookieAuth: []
* x-signals: [article-<sku>]
@@ -1480,6 +1492,7 @@ export function register(app, basePath = '') {
* properties:
* categoryIds:
* type: array
* minItems: 1
* items:
* type: integer
* example:
@@ -1495,7 +1508,7 @@ export function register(app, basePath = '') {
* article:
* $ref: '#/components/schemas/ArticleDetail'
* 400:
* description: Invalid categoryIds
* description: Invalid categoryIds or empty array
* content:
* application/json:
* schema:
@@ -1576,6 +1589,15 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Print job dispatched
* content:
* application/json:
* schema:
* type: object
* required: [success]
* properties:
* success:
* type: boolean
* example: true
* 400:
* description: Invalid parameters
* 404:
@@ -1806,20 +1828,7 @@ export function register(app, basePath = '') {
* children:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* sku:
* type: string
* name:
* type: string
* parentsort:
* type: integer
* attributeValueId:
* type: integer
* attributeValue:
* type: string
* $ref: '#/components/schemas/ArticleChild'
* 404:
* description: Article not found
*/
@@ -1930,7 +1939,7 @@ export function register(app, basePath = '') {
* children:
* type: array
* items:
* type: object
* $ref: '#/components/schemas/ArticleChild'
* 400:
* description: Missing sku or attribute value, or invalid input
* 404:
@@ -2080,20 +2089,7 @@ export function register(app, basePath = '') {
* items:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* articleId:
* type: integer
* sku:
* type: string
* name:
* type: string
* count:
* type: integer
* sort:
* type: integer
* $ref: '#/components/schemas/StuecklisteItem'
* 404:
* description: Article not found
*/
@@ -2161,7 +2157,7 @@ export function register(app, basePath = '') {
* items:
* type: array
* items:
* type: object
* $ref: '#/components/schemas/StuecklisteItem'
* 400:
* description: Article is a parent, has options, or contains itself
* 404:
@@ -2216,22 +2212,7 @@ export function register(app, basePath = '') {
* options:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* articleId:
* type: integer
* sku:
* type: string
* name:
* type: string
* count:
* type: integer
* sort:
* type: integer
* mandated:
* type: boolean
* $ref: '#/components/schemas/ArticleOptionItem'
* 404:
* description: Article not found
*/
@@ -2302,7 +2283,7 @@ export function register(app, basePath = '') {
* options:
* type: array
* items:
* type: object
* $ref: '#/components/schemas/ArticleOptionItem'
* 400:
* description: Article is a parent, a Stückliste, or contains itself
* 404:

View File

@@ -178,6 +178,10 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Deleted successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SuccessResponse'
*/
app.delete(`${basePath}/article/:sku/property/:propertyId`, async (req, res) => {
const selected = requireSelectedCompany(req);

View File

@@ -133,6 +133,10 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Reordered successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SuccessResponse'
*/
app.put(`${basePath}/attributename/order`, async (req, res) => {
const selected = requireSelectedCompany(req);
@@ -202,6 +206,10 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Deleted successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SuccessResponse'
* 400:
* description: Bad request or no company selected
* content:

View File

@@ -150,6 +150,10 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Reordered successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SuccessResponse'
*/
app.put(`${basePath}/attributename/:attributeId/value/order`, async (req, res) => {
const selected = requireSelectedCompany(req);
@@ -218,6 +222,10 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Deleted successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SuccessResponse'
* 400:
* description: Bad request or no company selected
* content:

View File

@@ -74,13 +74,11 @@ export function register(app, basePath = '') {
* tree:
* type: array
* items:
* type: object
* additionalProperties: true
* $ref: '#/components/schemas/CategoryTreeNode'
* articles:
* type: array
* items:
* type: object
* additionalProperties: true
* $ref: '#/components/schemas/CategoryArticle'
* 400:
* description: No company selected
* content:
@@ -326,48 +324,7 @@ export function register(app, basePath = '') {
* articles:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* sku:
* type: string
* name:
* type: string
* slug:
* type: string
* net:
* type: number
* nullable: true
* taxRate:
* type: number
* nullable: true
* track_stock:
* type: boolean
* optionsCount:
* type: integer
* articleListCount:
* type: integer
* childCount:
* type: integer
* description: Number of child articles belonging to this article
* fullHash:
* type: string
* nullable: true
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* brand:
* type: object
* nullable: true
* additionalProperties: true
* additionalProperties: true
* $ref: '#/components/schemas/CategoryArticle'
* 400:
* description: Invalid id
* content:

View File

@@ -180,6 +180,10 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Deleted successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SuccessResponse'
*/
app.delete(`${basePath}/category/:id/property/:propertyId`, async (req, res) => {
const selected = requireSelectedCompany(req);

View File

@@ -61,7 +61,7 @@ export function register(app, basePath = '') {
* labeltemplates:
* type: array
* items:
* $ref: '#/components/schemas/LabelTemplate'
* $ref: '#/components/schemas/LabelTemplateSummary'
* 400:
* description: No company selected
* content:

View File

@@ -1,15 +1,15 @@
import * as db from '../../db/pic.js';
const FULL_HASH_RE = /^[a-fA-F0-9]{64}$/;
/** Content-addressed by SHA-256 — safe for long-lived browser cache (auth still required on first fetch). */
const FULL_HASH_RE = /^[a-zA-Z0-9]{8}$/;
/** Content-addressed by SHA-256 (Base62, 8 chars) — safe for long-lived browser cache (auth still required on first fetch). */
const CACHE_CONTROL = 'private, max-age=31536000, immutable';
function parseFullHash(value) {
const fullHash = typeof value === 'string' ? value.trim() : '';
if (!FULL_HASH_RE.test(fullHash)) {
return { error: 'fullHash must be a 64-char hex SHA-256' };
return { error: 'fullHash must be an 8-char alphanumeric string' };
}
return { fullHash: fullHash.toLowerCase() };
return { fullHash };
}
function picErrorStatus(error) {
@@ -47,7 +47,7 @@ export function register(app, basePath = '') {
* tags: [Image]
* summary: Full image by hash
* description: |
* Returns the full AVIF by `pic.hash` (SHA-256 hex).
* Returns the full AVIF by `pic.hash` (8-char alphanumeric Base62).
* Auth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`).
* security:
* - cookieAuth: []
@@ -58,8 +58,8 @@ export function register(app, basePath = '') {
* required: true
* schema:
* type: string
* pattern: '^[a-fA-F0-9]{64}$'
* description: SHA-256 hex of the full image bytes
* pattern: '^[a-zA-Z0-9]{8}$'
* description: 8-char alphanumeric hash of the full image bytes
* responses:
* 200:
* description: AVIF image bytes
@@ -103,7 +103,9 @@ export function register(app, basePath = '') {
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/image/:fullHash`, async (req, res) => {
const imagePaths = [`${basePath}/image/:fullHash`];
if (basePath) imagePaths.push('/image/:fullHash');
app.get(imagePaths, async (req, res) => {
const parsed = parseFullHash(req.params.fullHash);
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
@@ -141,8 +143,8 @@ export function register(app, basePath = '') {
* required: true
* schema:
* type: string
* pattern: '^[a-fA-F0-9]{64}$'
* description: SHA-256 hex of the full image bytes
* pattern: '^[a-zA-Z0-9]{8}$'
* description: 8-char alphanumeric hash of the full image bytes
* responses:
* 200:
* description: AVIF thumb bytes
@@ -186,7 +188,9 @@ export function register(app, basePath = '') {
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/thumb/:fullHash`, async (req, res) => {
const thumbPaths = [`${basePath}/thumb/:fullHash`];
if (basePath) thumbPaths.push('/thumb/:fullHash');
app.get(thumbPaths, async (req, res) => {
const parsed = parseFullHash(req.params.fullHash);
if (parsed.error) {
return res.status(400).json({ error: parsed.error });

View File

@@ -145,6 +145,10 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Reordered successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SuccessResponse'
* 400:
* description: Bad request
*/
@@ -216,6 +220,10 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Deleted successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SuccessResponse'
* 400:
* description: Bad request or no company selected
* content:

View File

@@ -2,10 +2,8 @@ import fs from 'fs';
import path from 'path';
import multer from 'multer';
import { emit } from '../sse.js';
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath } from '../../tools/vfs.js';
import * as db from '../../db/modelvfs.js';
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js';
import sharp from 'sharp';
import { getMimeType } from '../../tools/vfs.js';
// Thumbnail box. The image is fitted inside this box preserving aspect ratio; it is never
// stretched, so a 2000x100 image becomes 250x13 and not 250x250.
@@ -379,61 +377,32 @@ export function register(app, basePath = '') {
/**
* @openapi
* /vfs/{*path}:
* /vfs:
* get:
* tags: [Vfs]
* summary: Get a virtual filesystem file
* summary: List virtual filesystem files
* description: |
* Returns a file the model produced in the `javascript_execute` sandbox
* (virtual filesystem), for the authenticated user's selected company.
*
* Embed the returned URL in chat markdown to display a generated image:
* `![title](/api/vfs/example.png)`.
*
* Images are returned as a 250x250 aspect-preserving preview unless requested with
* `download=1` or `raw=1`.
* Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served
* inline so they open directly in the browser; other files are sent as downloads.
* Lists all virtual filesystem files for the authenticated user's selected company.
* Scans directories recursively (ignoring hidden dotfiles) and returns file
* metadata including path, byte size, detected MIME type, timestamps, and classification flags.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: path
* required: true
* schema:
* type: string
* description: VFS file path (e.g. `example.png`, `labels/a.png`)
* - in: query
* name: download
* required: false
* schema:
* type: boolean
* description: |
* Force file download with Content-Disposition: attachment.
* - in: query
* name: raw
* required: false
* schema:
* type: boolean
* description: |
* Return original unmodified image inline without generating a preview thumbnail.
* x-markdown: vfs/list.md
* responses:
* 200:
* description: File bytes
* headers:
* Content-Disposition:
* schema:
* type: string
* Cache-Control:
* schema:
* type: string
* description: List of virtual filesystem files
* content:
* application/octet-stream:
* application/json:
* schema:
* type: string
* format: binary
* type: object
* required: [files]
* properties:
* files:
* type: array
* items:
* $ref: '#/components/schemas/VfsFile'
* 400:
* description: Invalid path, or no company selected
* description: No company selected
* content:
* application/json:
* schema:
@@ -450,14 +419,7 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: File not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/vfs`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -506,6 +468,85 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /vfs/upload:
* post:
* tags: [Vfs]
* summary: Upload files to virtual filesystem
* description: |
* Uploads one or more files to the virtual filesystem for the authenticated user's selected company.
* Files are written to the company's VFS root directory and saved to the database.
* An optional `targetDir` form field specifies a subfolder within the VFS.
*
* Writing to read-only database paths (`image/*` or filenames containing 8-character image hashes) is
* forbidden and automatically skipped during upload.
* security:
* - cookieAuth: []
* x-signals: [vfs]
* x-markdown: vfs/upload.md
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* required: [files]
* properties:
* files:
* type: array
* items:
* type: string
* format: binary
* description: One or more files to upload (form field name `files`)
* targetDir:
* type: string
* description: Optional destination subdirectory within VFS (e.g. `reports` or `charts/q1`)
* responses:
* 200:
* description: Successfully uploaded files
* content:
* application/json:
* schema:
* type: object
* required: [success, uploaded]
* properties:
* success:
* type: boolean
* uploaded:
* type: array
* items:
* type: object
* required: [name, size, mime]
* properties:
* name:
* type: string
* description: Stored relative path in VFS
* size:
* type: integer
* description: Size in bytes
* mime:
* type: string
* description: Effective MIME type
* 400:
* description: No files uploaded, invalid path, 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'
*/
app.post(`${basePath}/vfs/upload`, vfsUpload.array('files'), async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -531,6 +572,7 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsed.name)) continue;
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) continue;
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, f.buffer);
@@ -538,12 +580,6 @@ export function register(app, basePath = '') {
? f.mimetype
: getMimeType(parsed.name);
await db.write(req.user.id, {
name: parsed.name,
mime: effectiveMime,
content: f.buffer,
});
uploaded.push({
name: parsed.name,
size: f.buffer.length,
@@ -560,6 +596,305 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /vfs/mkdir:
* post:
* tags: [Vfs]
* summary: Create directory in virtual filesystem
* description: |
* Creates a directory (and any necessary parent directories) in the virtual filesystem
* for the authenticated user's selected company.
* security:
* - cookieAuth: []
* x-signals: [vfs]
* x-markdown: vfs/mkdir.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [path]
* properties:
* path:
* type: string
* description: Directory path to create (e.g. `reports/2026/q1`)
* responses:
* 200:
* description: Directory created successfully
* content:
* application/json:
* schema:
* type: object
* required: [success, dir]
* properties:
* success:
* type: boolean
* dir:
* type: string
* description: Created directory path
* 400:
* description: Path is required, invalid characters, traversal, 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, or cannot create directory inside read-only database paths
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.post(`${basePath}/vfs/mkdir`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const raw = req.body?.path || req.body?.dir || req.body?.name;
const parsed = parseVfsRef(raw);
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
if (isReadOnlyDatabasePath(parsed.name)) {
return res.status(403).json({ error: 'Cannot create directories in read-only database paths' });
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) {
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
}
fs.mkdirSync(absPath, { recursive: true });
emit('vfs', { companyId: selected.companyId });
return res.json({ success: true, dir: parsed.name });
} catch (error) {
const status = vfsFileErrorStatus(error);
if (status === 500) console.error('Create vfs directory failed:', error);
return res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /vfs/rename:
* post:
* tags: [Vfs]
* summary: Rename or move a virtual filesystem file or directory
* description: |
* Renames or moves a file or directory within the virtual filesystem for the authenticated user's selected company.
* Parent directories for the destination path are created automatically if needed.
* security:
* - cookieAuth: []
* x-signals: [vfs]
* x-markdown: vfs/rename.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [oldPath, newPath]
* properties:
* oldPath:
* type: string
* description: Existing VFS file or directory path (e.g. `draft.txt`)
* newPath:
* type: string
* description: New destination VFS path (e.g. `archive/final.txt`)
* responses:
* 200:
* description: File or directory successfully renamed
* content:
* application/json:
* schema:
* type: object
* required: [success, oldPath, newPath]
* properties:
* success:
* type: boolean
* oldPath:
* type: string
* newPath:
* type: string
* 400:
* description: Path is required, invalid characters, traversal, 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, or cannot rename read-only database paths
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Source file or directory not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.post(`${basePath}/vfs/rename`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const rawOld = req.body?.oldPath || req.body?.from || req.body?.source;
const rawNew = req.body?.newPath || req.body?.to || req.body?.target;
const parsedOld = parseVfsRef(rawOld);
if (parsedOld.error) {
return res.status(400).json({ error: `oldPath: ${parsedOld.error}` });
}
const parsedNew = parseVfsRef(rawNew);
if (parsedNew.error) {
return res.status(400).json({ error: `newPath: ${parsedNew.error}` });
}
if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) {
return res.status(403).json({ error: 'Cannot rename read-only database paths' });
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absOld = path.resolve(vfsRoot, parsedOld.name);
const absNew = path.resolve(vfsRoot, parsedNew.name);
if (!absOld.startsWith(vfsRoot) || !absNew.startsWith(vfsRoot)) {
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
}
if (!fs.existsSync(absOld)) {
return res.status(404).json({ error: 'Source file or directory not found' });
}
fs.mkdirSync(path.dirname(absNew), { recursive: true });
fs.renameSync(absOld, absNew);
emit('vfs', { companyId: selected.companyId });
return res.json({
success: true,
oldPath: parsedOld.name,
newPath: parsedNew.name,
});
} catch (error) {
const status = vfsFileErrorStatus(error);
if (status === 500) console.error('Rename vfs file failed:', error);
return res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /vfs/{*path}:
* put:
* tags: [Vfs]
* summary: Create or overwrite a virtual filesystem file
* description: |
* Creates or overwrites a file at the specified virtual filesystem path for the authenticated user's selected company.
* Supports multiple request payload formats:
* - JSON body: `{"content": "text content", "mime": "optional/mime"}`
* - Binary body: raw binary buffer with optional `Content-Type` header
* - Plain text: raw UTF-8 string
*
* Modifying read-only database paths (`image/*` or filenames containing 8-character image hashes) is blocked with HTTP 403.
* security:
* - cookieAuth: []
* x-signals: [vfs]
* x-markdown: vfs/write.md
* parameters:
* - in: path
* name: path
* required: true
* schema:
* type: string
* description: Destination VFS file path (e.g. `notes.txt`, `data/metrics.json`)
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [content]
* properties:
* content:
* type: string
* description: File text content
* mime:
* type: string
* description: Optional MIME type override
* application/octet-stream:
* schema:
* type: string
* format: binary
* description: Raw binary payload
* text/plain:
* schema:
* type: string
* description: Raw text payload
* responses:
* 200:
* description: File successfully written
* content:
* application/json:
* schema:
* type: object
* required: [success, file]
* properties:
* success:
* type: boolean
* file:
* type: object
* required: [name, size, mime, updated_at]
* properties:
* name:
* type: string
* description: VFS file path
* size:
* type: integer
* description: Written byte length
* mime:
* type: string
* description: Stored MIME type
* updated_at:
* type: string
* format: date-time
* description: ISO timestamp of update
* 400:
* description: Path is required, invalid characters/traversal, no content provided, 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, or cannot modify read-only database files
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.put(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -593,15 +928,12 @@ export function register(app, basePath = '') {
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) {
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
}
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, buffer);
await db.write(req.user.id, {
name: parsed.name,
mime,
content: buffer,
});
emit('vfs', { companyId: selected.companyId });
return res.json({
success: true,
@@ -619,6 +951,59 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /vfs/{*path}:
* delete:
* tags: [Vfs]
* summary: Delete a virtual filesystem file
* description: |
* Deletes a file from both the on-disk virtual filesystem and database for the authenticated user's selected company.
* Deleting read-only database files (`image/*` or filenames containing 8-character image hashes) is blocked with HTTP 403.
* security:
* - cookieAuth: []
* x-signals: [vfs]
* x-markdown: vfs/delete.md
* parameters:
* - in: path
* name: path
* required: true
* schema:
* type: string
* description: VFS file path to delete (e.g. `temp.png`, `notes.txt`)
* responses:
* 200:
* description: File successfully deleted
* content:
* application/json:
* schema:
* type: object
* required: [success, deleted]
* properties:
* success:
* type: boolean
* deleted:
* type: string
* description: Path of deleted file
* 400:
* description: Invalid path, path traversal, 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, or cannot delete read-only database files
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.delete(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -636,10 +1021,18 @@ export function register(app, basePath = '') {
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (fs.existsSync(absPath)) {
try { fs.unlinkSync(absPath); } catch {}
if (!absPath.startsWith(vfsRoot)) {
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
}
if (!fs.existsSync(absPath)) {
return res.status(404).json({ error: 'File not found' });
}
const stat = fs.statSync(absPath);
if (stat.isDirectory()) {
fs.rmSync(absPath, { recursive: true, force: true });
} else {
fs.unlinkSync(absPath);
}
await db.remove(req.user.id, parsed.name);
emit('vfs', { companyId: selected.companyId });
return res.json({ success: true, deleted: parsed.name });
@@ -650,6 +1043,131 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /vfs/{*path}:
* get:
* tags: [Vfs]
* summary: Get a virtual filesystem file
* description: |
* Returns a file from the virtual filesystem for the authenticated user's selected company.
* Commonly used to retrieve artifacts generated by the model in the `javascript_execute` sandbox or uploaded by users.
*
* ### URL Format
* Accepts:
* - Relative VFS paths: `example.png`, `reports/summary.pdf`
* - Full endpoint paths: `/vfs/example.png`, `/api/vfs/example.png` (leading `api/` and `vfs/` prefixes are automatically stripped)
* - Absolute URLs: `https://.../api/vfs/example.png`
* - Traversal (`..`), null bytes (`\0`), and backslashes (`\`) are strictly forbidden and rejected with HTTP 400.
*
* ### Image Previews & Thumbnails
* Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`)
* are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88):
* - Scaled to fit within a 250×250 box without distortion (e.g. 2000×100 becomes 250×13).
* - Images whose larger edge is below 100px are upscaled to 100px for icon legibility.
* - A translucent dark metadata strip (`#101317`, max 50% height) with monospace text (`DejaVu Sans Mono`)
* is rendered along the bottom edge, displaying original dimensions and file size.
* - Served with `Content-Security-Policy: default-src 'none'; sandbox`.
* - Use `?raw=1` (or `?raw=true`) to bypass preview generation and return the original unmodified image inline.
*
* ### Inline vs Attachment
* - Browser-compatible types (HTML, CSS, JS, JSON, PDF, text, images, audio, video, fonts, WASM)
* are served with `Content-Disposition: inline; filename="..."` so they open directly in modern browsers.
* - Non-browser/binary types default to `Content-Disposition: attachment; filename="..."`.
* - Use `?download=1` (or `?download=true`) to force file download with `Content-Disposition: attachment`.
*
* ### Caching & Conditional Requests
* - Generates weak ETags (`W/"thumb-..."` for previews, `W/"<timestamp>-<mode>"` for originals).
* - Sets `Cache-Control: private, max-age=0, must-revalidate`.
* - Supports `If-None-Match` conditional requests returning HTTP `304 Not Modified`.
* security:
* - cookieAuth: []
* x-markdown: vfs/_*path.md
* parameters:
* - in: path
* name: path
* required: true
* schema:
* type: string
* description: VFS file path (e.g. `example.png`, `charts/graph.svg`)
* - in: query
* name: download
* required: false
* schema:
* type: boolean
* description: 'Force file download with Content-Disposition: attachment (1 or true).'
* - in: query
* name: raw
* required: false
* schema:
* type: boolean
* description: 'Return original unmodified image inline without generating a preview thumbnail (1 or true).'
* responses:
* 200:
* description: |
* File bytes. Served inline for browser-renderable formats or as a download.
* Includes headers:
* - `Content-Type`: MIME type of file, or `image/webp` for previews
* - `Content-Length`: Size of served payload in bytes
* - `Content-Disposition`: `inline; filename="..."` or `attachment; filename="..."`
* - `Cache-Control`: `private, max-age=0, must-revalidate`
* - `ETag`: Weak ETag identifier
* - `X-Content-Type-Options`: `nosniff`
* - `Content-Security-Policy`: `default-src 'none'; sandbox` (for preview thumbnails)
* headers:
* Content-Type:
* schema:
* type: string
* Content-Length:
* schema:
* type: string
* Content-Disposition:
* schema:
* type: string
* Cache-Control:
* schema:
* type: string
* ETag:
* schema:
* type: string
* X-Content-Type-Options:
* schema:
* type: string
* Content-Security-Policy:
* schema:
* type: string
* content:
* application/octet-stream:
* schema:
* type: string
* format: binary
* 304:
* description: Not modified (If-None-Match matched ETag)
* 400:
* description: Invalid path, path escapes VFS boundary, 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: File not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -663,15 +1181,20 @@ export function register(app, basePath = '') {
}
try {
const file = await db.read(req.user.id, parsed.name);
if (!file?.content?.length) {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot) || !fs.existsSync(absPath)) {
return res.status(404).json({ error: 'File not found' });
}
const stat = fs.statSync(absPath);
if (stat.isDirectory()) {
return res.status(400).json({ error: 'Cannot download a directory directly' });
}
const content = fs.readFileSync(absPath);
const updatedAt = stat.mtime.toISOString();
const fallbackName = parsed.name.split('/').pop() || 'download';
const effectiveMime = (file.mime && file.mime !== 'application/octet-stream')
? file.mime
: getMimeType(parsed.name);
const effectiveMime = getMimeType(parsed.name);
const wantsDownload = req.query.download === '1' || req.query.download === 'true';
const wantsRaw = req.query.raw === '1' || req.query.raw === 'true';
@@ -681,7 +1204,7 @@ export function register(app, basePath = '') {
// unless the caller explicitly asks for the raw file (?raw=1) or a download (?download=1).
const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw;
if (shouldThumbnail) {
const rendered = await renderThumbnail(file.content, {
const rendered = await renderThumbnail(content, {
mime: effectiveMime,
filename: fallbackName,
}).catch((err) => {
@@ -696,7 +1219,7 @@ export function register(app, basePath = '') {
res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`);
// Previews are derived data — never cache them immutably, the source file can change.
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.setHeader('ETag', `W/"thumb-${file.updated_at || file.name}"`);
res.setHeader('ETag', `W/"thumb-${updatedAt || parsed.name}"`);
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
return res.status(304).end();
@@ -707,11 +1230,11 @@ export function register(app, basePath = '') {
}
res.setHeader('Content-Type', effectiveMime);
res.setHeader('Content-Length', String(file.content.length));
res.setHeader('Content-Length', String(content.length));
res.setHeader('X-Content-Type-Options', 'nosniff');
// Mutable VFS artifacts are cached with revalidation
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.setHeader('ETag', `W/"${file.updated_at || file.name}-${isInline ? 'i' : 'a'}"`);
res.setHeader('ETag', `W/"${updatedAt || parsed.name}-${isInline ? 'i' : 'a'}"`);
res.setHeader(
'Content-Disposition',
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"`
@@ -722,7 +1245,7 @@ export function register(app, basePath = '') {
return res.status(304).end();
}
return res.status(200).send(file.content);
return res.status(200).send(content);
} catch (error) {
const status = vfsFileErrorStatus(error);
if (status === 500) console.error('Get vfs file failed:', error);

View File

@@ -26,6 +26,8 @@ import { createWebSearchTool } from '../tools/web-search.js';
import { createUrlGetTool } from '../tools/url-get.js';
import { createPdfReadTool } from '../tools/pdf-read.js';
import { createSearchArticlesTool } from '../tools/search-articles.js';
import { createEanChecksumTool } from '../tools/ean-checksum.js';
import { createNetGrossCalcTool } from '../tools/net-gross-calc.js';
import { resolveOpenRouterKey, resolveDefaultModel, resolveModelReasoning, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js';
const KEEP_ALIVE_MS = 20_000;
@@ -127,7 +129,9 @@ async function buildSystemPrompt(vfsRoot, googleAuthId) {
'13. rm: Delete single VFS file (used to delete SKILL files or specific artifacts).',
'14. apisuggestionsorissues: Report missing API endpoints, bugs, or issues for triage.',
'15. search_articles: Search articles across name, brand, EAN, SKU, and supplier SKU. Returns window of full articles (default 3, max 20), offset, and total count.',
'16. edit_image: Generate or edit images using the configured company image model (modelslugimage). Supports attachments (low/high quality), size tiers (1K/2K/4K), and aspect ratios (1:1/4:3/3:4). Writes output to VFS.',
'16. edit_image: Generate or edit images using the configured company image model (modelslugimage). Supports attachments (low/high quality), size tiers (1K/2K/4K), and aspect ratios (1:1/4:3/3:4). Writes output to VFS (always AVIF format).',
'17. ean_checksum: Validate EAN/GTIN/UPC barcode check digits or calculate missing check digit for barcodes (EAN-13, EAN-8, UPC-A, GTIN-14).',
'18. net_gross_calc: Convert between net and gross prices with tax/VAT rate (default 19%). Provides 2-decimal display values and high-precision net prices for API article creation.',
'',
'VIRTUAL FILESYSTEM:',
'- Persistent per-company VFS. Paths are root-relative forward-slash (e.g. "labels/sheet1.png").',
@@ -150,7 +154,7 @@ async function buildSystemPrompt(vfsRoot, googleAuthId) {
'- `image/` is READ-ONLY database storage. Write generated images elsewhere (e.g. "images/…").',
'',
'OPERATING GUIDELINES:',
'- GROSS vs NET PRICES: User prices are almost always GROSS (incl. VAT); API requires NET. Formula: `net = gross / (1 + taxRate / 100)`. Compute in javascript_execute with high precision (4–6 decimals) to prevent rounding error.',
'- GROSS vs NET PRICES: User prices are almost always GROSS (incl. VAT); API requires NET. Use net_gross_calc to compute high-precision (4–6 decimals) NET prices for API requests: `net = gross / (1 + taxRate / 100)`.',
'- DISPLAYING GENERATED IMAGES: Inline markdown images supported. Embed written VFS image paths: `![descriptive alt](artifacts/example.png)`. Other files (PDF, JSON, CSV) do not render inline; provide download links.',
'- LABEL TEMPLATES (/labeltemplate): Must use Dashback JSON schema `{ "objects": [ ... ] }`. Do NOT use ZPL.',
'- Always Format output with markdown and emoji.',
@@ -531,16 +535,6 @@ export class ConversationManager {
} catch (err) {
console.error(`[ConversationManager ${this.conversationId}] failed to write attachment disk file:`, err);
}
try {
await store.modelvfs.write(googleAuthId, {
name: vfsRelPath,
mime,
content: fileBuffer,
});
} catch (err) {
console.error(`[ConversationManager ${this.conversationId}] failed to write attachment to modelvfs:`, err);
}
}
const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val);
@@ -878,6 +872,8 @@ export class ConversationManager {
createResetVfsTool({ googleAuthId }),
createApiSuggestionsOrIssuesTool({ googleAuthId, companyId: this.companyId }),
createSearchArticlesTool({ googleAuthId, companyId: this.companyId }),
createEanChecksumTool(),
createNetGrossCalcTool(),
],
});

View File

@@ -4,7 +4,8 @@ import sharp from 'sharp';
import jwt from 'jsonwebtoken';
import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter';
import env from '../config/env.js';
import { prepareVfs } from './vfs.js';
import { getImageByHash } from '../db/pic.js';
import { prepareVfs, extractFullHash } from './vfs.js';
import { resolveOpenRouterKey, resolveDefaultModel } from './llm-key.js';
/**
@@ -234,20 +235,34 @@ function extractImageInputs(args = {}) {
}
/**
* Fetches a database image by its content hash, authenticated as the calling user.
* @param {string} hash 64-char SHA-256 hex
* Fetches a database image by its content hash using store or authenticated local endpoint.
* @param {string} hash 8-char SHA-256 Base62
* @param {number} googleAuthId
* @returns {Promise<Buffer>}
*/
async function fetchDatabaseImage(hash, googleAuthId) {
const url = `http://127.0.0.1:${env.port}/image/${hash}`;
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
const res = await fetch(url, {
headers: { Cookie: `token=${token}` },
signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return Buffer.from(await res.arrayBuffer());
try {
const image = await getImageByHash(googleAuthId, hash);
if (image?.buffer) {
return image.buffer;
}
} catch (dbErr) {
try {
const url = `http://127.0.0.1:${env.port}/api/image/${hash}`;
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
const res = await fetch(url, {
headers: { Cookie: `token=${token}` },
signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS),
});
if (res.ok) {
return Buffer.from(await res.arrayBuffer());
}
} catch {
// fallback error ignored
}
throw dbErr;
}
throw new Error('Image not found');
}
/**
@@ -269,6 +284,10 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
additionalProperties: false,
required: ['paths'],
properties: {
note: {
type: ['string', 'null'],
description: 'A note explaining what this image inspection is doing (or null). This note will be displayed to the user while the images are being analyzed.',
},
paths: {
type: 'array',
description: '1–9 VFS paths or DB paths ("image/<hash>").',
@@ -277,17 +296,17 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
maxItems: MAX_IMAGES,
},
prompt: {
type: 'string',
description: 'Inspection criteria or question.',
type: ['string', 'null'],
description: 'Inspection criteria or question, or null.',
},
grid: {
type: 'string',
enum: Object.keys(GRID_CONFIGS),
description: 'Grid layout. Omit for auto.',
type: ['string', 'null'],
enum: [...Object.keys(GRID_CONFIGS), null],
description: 'Grid layout or null for auto.',
},
},
},
invoke: async ({ paths, prompt, grid } = {}) => {
invoke: async ({ paths, prompt, grid, note } = {}) => {
const inputs = extractImageInputs({ paths });
if (inputs.length === 0) {
throw new ToolError(
@@ -304,7 +323,7 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
// ── Load the bytes ────────────────────────────────────────────
const items = [];
for (const item of inputs) {
// Only a path that actually carries a 64-char content hash is a database image.
// Only a path that actually carries an 8-char content hash is a database image.
// A bare "images/…" prefix is just the VFS folder, so it must NOT be treated as
// a DB lookup or `images/candidates/x.avif` would be misread as a hash path.
const hash = extractFullHash(item.path);
@@ -325,6 +344,10 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
buf = fs.readFileSync(cached);
} else {
buf = await fetchDatabaseImage(hash, googleAuthId);
try {
fs.mkdirSync(path.join(vfsRoot, 'images'), { recursive: true });
fs.writeFileSync(cached, buf);
} catch { /* ignore cache write failure */ }
}
}
@@ -332,7 +355,7 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
throw new ToolError(
`Image not found or empty: "${item.path}". ` +
(hash
? 'Database images must be addressed as "image/<fullHash>" with a 64-char hash.'
? 'Database images must be addressed as "image/<fullHash>" with an 8-char hash.'
: 'Store it first (search_images writes to "images/…") or check the path with find_files.')
);
}
@@ -397,19 +420,3 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
},
});
}
/**
* Extracts a 64-char hex SHA-256 hash from a VFS image path, if present.
* Mirrors the sandbox's `extractFullHash` so `image/<hash>` resolves identically here.
* @param {string} p
* @returns {string|null}
*/
function extractFullHash(p) {
if (typeof p !== 'string') return null;
const parts = p.trim().replace(/^([a-zA-Z]:|\/)+/, '').split('/');
for (const part of parts) {
const seg = part.replace(/\.[a-zA-Z0-9]+$/, '');
if (/^[a-fA-F0-9]{64}$/.test(seg)) return seg.toLowerCase();
}
return null;
}

View File

@@ -23,13 +23,13 @@ export function createApiSuggestionsOrIssuesTool({ googleAuthId, companyId }) {
description: 'Issue or request details.',
},
type: {
type: 'string',
enum: ['missing_api', 'bug', 'suggestion', 'issue', 'other'],
description: 'Report category.',
type: ['string', 'null'],
enum: ['missing_api', 'bug', 'suggestion', 'issue', 'other', null],
description: 'Report category or null.',
},
title: {
type: 'string',
description: 'Short summary.',
type: ['string', 'null'],
description: 'Short summary or null.',
},
},
},

View File

@@ -26,8 +26,8 @@ export function createAskUserTool() {
description: 'Question prompt.',
},
options: {
type: 'array',
description: 'Up to 3 option labels.',
type: ['array', 'null'],
description: 'Up to 3 option labels, or null.',
items: { type: 'string' },
maxItems: 3,
},

View File

@@ -21,13 +21,17 @@ export function createDashbackRequestTool({ googleAuthId }) {
additionalProperties: false,
required: ['method', 'path'],
properties: {
note: {
type: ['string', 'null'],
description: 'A note explaining what this request is doing (or null). This note will be displayed to the user while the request is executing.',
},
method: { type: 'string', enum: METHODS, description: 'HTTP method.' },
path: { type: 'string', description: 'Endpoint path (e.g. "/article/2").' },
query: { type: 'object', description: 'Query parameters object.' },
body: { type: 'string', description: 'JSON request payload string.' },
query: { type: ['object', 'null'], description: 'Query parameters object or null.' },
body: { type: ['string', 'null'], description: 'JSON request payload string or null.' },
},
},
invoke: async ({ method = 'GET', path, query = null, body = null }) => {
invoke: async ({ method = 'GET', path, query = null, body = null, note }) => {
if (!METHODS.includes(method)) throw new Error(`Unsupported method: ${method}`);
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) {
throw new Error('path must start with a single "/"');

165
src/tools/ean-checksum.js Normal file
View File

@@ -0,0 +1,165 @@
import { defineTool, ToolError } from 'libllmorbiter';
/**
* Calculates the standard GS1 check digit for a string of numeric digits.
* GS1 algorithm: from right to left, alternating weights 3, 1, 3, 1...
*
* @param {string} payload - Numeric string without the check digit.
* @returns {number} The check digit (0-9).
*/
export function calculateGs1CheckDigit(payload) {
let sum = 0;
for (let i = 0; i < payload.length; i++) {
const digit = parseInt(payload[payload.length - 1 - i], 10);
const weight = i % 2 === 0 ? 3 : 1;
sum += digit * weight;
}
return (10 - (sum % 10)) % 10;
}
/**
* Resolves standard barcode format name from length.
*
* @param {number} length
* @returns {string}
*/
function resolveBarcodeFormat(length) {
switch (length) {
case 8:
return 'EAN-8';
case 12:
return 'UPC-A';
case 13:
return 'EAN-13';
case 14:
return 'GTIN-14';
default:
return `Barcode-${length}`;
}
}
/**
* Creates the ean_checksum tool.
*/
export function createEanChecksumTool() {
return defineTool({
name: 'ean_checksum',
description:
'Validate EAN/GTIN/UPC barcode check digits or calculate the missing check digit for barcodes ' +
'(EAN-13, EAN-8, UPC-A, GTIN-14). Strips spaces and hyphens automatically.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['code'],
properties: {
note: {
type: ['string', 'null'],
description: 'A note explaining what this EAN checksum check is doing (or null). This note will be displayed to the user while executing.',
},
code: {
type: 'string',
description: 'Barcode number to validate or complete (e.g. 12 digits for EAN-13 check digit calculation, or 13 digits for EAN-13 validation).',
},
action: {
type: ['string', 'null'],
enum: ['auto', 'validate', 'calculate', null],
description: 'Optional action: "auto" (default: infer from length), "validate" (check last digit), "calculate" (compute check digit for the given payload), or null.',
},
},
},
invoke: async ({ code, action = 'auto', note } = {}) => {
if (typeof code !== 'string' && typeof code !== 'number') {
throw new ToolError('ean_checksum requires a "code" (string or number).');
}
const clean = String(code).trim().replace(/[\s\-]/g, '');
if (!/^\d+$/.test(clean)) {
throw new ToolError(`Invalid barcode code "${code}": must contain only digits (spaces and hyphens are stripped).`);
}
const len = clean.length;
if (len < 6 || len > 18) {
throw new ToolError(`Invalid barcode length (${len} digits): barcodes are typically between 7 and 14 digits.`);
}
// Determine whether to validate or calculate
let mode = action || 'auto';
if (mode === 'auto') {
// If standard complete barcode length (8, 13, 14), default to validate.
// 12 digits is either complete UPC-A (12 digits) or incomplete EAN-13 (12 digits).
// In European retail context (EAN-13), 12 digits is very commonly incomplete EAN-13.
// We provide full results for both when len === 12.
if (len === 8 || len === 13 || len === 14) {
mode = 'validate';
} else if (len === 7 || len === 11) {
mode = 'calculate';
} else if (len === 12) {
mode = 'calculate'; // calculate EAN-13 by default, but also report UPC-A validation
} else {
mode = 'calculate';
}
}
const result = {
input: clean,
length: len,
};
if (mode === 'calculate') {
const checkDigit = calculateGs1CheckDigit(clean);
const fullCode = `${clean}${checkDigit}`;
const format = resolveBarcodeFormat(fullCode.length);
result.action = 'calculate';
result.checkDigit = checkDigit;
result.fullCode = fullCode;
result.format = format;
result.message = `Calculated check digit ${checkDigit} for ${len}-digit payload. Complete ${format} is ${fullCode}.`;
// If 12 digits, also provide UPC-A validation for convenience
if (len === 12) {
const upcPayload = clean.slice(0, 11);
const upcActual = parseInt(clean.slice(11), 10);
const upcExpected = calculateGs1CheckDigit(upcPayload);
result.asUpcAValidation = {
isValid: upcActual === upcExpected,
expectedCheckDigit: upcExpected,
actualCheckDigit: upcActual,
};
}
} else {
// Validate mode
const payload = clean.slice(0, -1);
const actualCheckDigit = parseInt(clean.slice(-1), 10);
const expectedCheckDigit = calculateGs1CheckDigit(payload);
const isValid = actualCheckDigit === expectedCheckDigit;
const format = resolveBarcodeFormat(len);
const correctedCode = `${payload}${expectedCheckDigit}`;
result.action = 'validate';
result.format = format;
result.isValid = isValid;
result.actualCheckDigit = actualCheckDigit;
result.expectedCheckDigit = expectedCheckDigit;
result.correctedCode = correctedCode;
if (isValid) {
result.message = `Valid ${format} barcode. Check digit ${actualCheckDigit} is correct.`;
} else {
result.message = `Invalid ${format} barcode: ends with ${actualCheckDigit}, but expected check digit is ${expectedCheckDigit}. Corrected code: ${correctedCode}.`;
}
// If 12 digits, also provide EAN-13 calculation
if (len === 12) {
const ean13Check = calculateGs1CheckDigit(clean);
result.asEan13Calculation = {
checkDigit: ean13Check,
fullEan13: `${clean}${ean13Check}`,
};
}
}
return JSON.stringify(result, null, 2);
},
});
}

View File

@@ -4,6 +4,7 @@ import sharp from 'sharp';
import jwt from 'jsonwebtoken';
import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import { getImageByHash } from '../db/pic.js';
import { prepareVfs, syncVfsToDb, extractFullHash, isReadOnlyDatabasePath } from './vfs.js';
import { resolveOpenRouterKey, resolveImageModel } from './llm-key.js';
@@ -20,11 +21,12 @@ import { resolveOpenRouterKey, resolveImageModel } from './llm-key.js';
* - attachments: array of { vfsfilename, high: bool } reference images.
* - size: resolution tier — 3 choices ('1K', '2K', '4K').
* - ratio: aspect ratio — 3 choices ('1:1', '4:3', '3:4').
* - vfsoutputfilename: VFS path where the resulting image is saved.
* - vfsoutputfilename: VFS path where the resulting image is saved (always saved as AVIF).
*/
const DEFAULT_SIZE = '1K';
const DEFAULT_RATIO = '1:1';
const AVIF_QUALITY = 80;
const IMAGE_FETCH_TIMEOUT_MS = 10_000;
const GENERATE_TIMEOUT_MS = 120_000;
@@ -132,20 +134,34 @@ function resolveImagesApiUrl() {
}
/**
* Fetches a database image by its 64-char content hash using authenticated local endpoint.
* Fetches a database image by its 8-char content hash using store or authenticated local endpoint.
* @param {string} hash
* @param {number} googleAuthId
* @returns {Promise<Buffer>}
*/
async function fetchDatabaseImage(hash, googleAuthId) {
const url = `http://127.0.0.1:${env.port}/image/${hash}`;
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
const res = await fetch(url, {
headers: { Cookie: `token=${token}` },
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return Buffer.from(await res.arrayBuffer());
try {
const image = await getImageByHash(googleAuthId, hash);
if (image?.buffer) {
return image.buffer;
}
} catch (dbErr) {
try {
const url = `http://127.0.0.1:${env.port}/api/image/${hash}`;
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
const res = await fetch(url, {
headers: { Cookie: `token=${token}` },
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
});
if (res.ok) {
return Buffer.from(await res.arrayBuffer());
}
} catch {
// fallback error ignored
}
throw dbErr;
}
throw new Error('Image not found');
}
/**
@@ -168,7 +184,12 @@ async function loadAttachmentBytes(targetPath, vfsRoot, googleAuthId) {
if (fs.existsSync(cached)) {
return fs.readFileSync(cached);
}
return await fetchDatabaseImage(hash, googleAuthId);
const buf = await fetchDatabaseImage(hash, googleAuthId);
try {
fs.mkdirSync(path.join(vfsRoot, 'images'), { recursive: true });
fs.writeFileSync(cached, buf);
} catch { /* ignore cache write failure */ }
return buf;
}
const clean = targetPath.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
@@ -233,20 +254,24 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
description:
'Generate or edit images using the configured company image model (modelslugimage). ' +
'Supports reference image attachments fed in low or high quality, output size tier, ' +
'aspect ratio, and saving directly to a specified VFS output path.',
'aspect ratio, and saving directly to a specified VFS output path (always saved as AVIF).',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['prompt'],
properties: {
note: {
type: ['string', 'null'],
description: 'A note explaining what this image generation or edit is doing (or null). This note will be displayed to the user while the image is being generated or edited.',
},
prompt: {
type: 'string',
description: 'Description of the image to generate, or edit/transformation instructions when attachments are provided.',
},
attachments: {
type: 'array',
description: 'Optional reference image(s) for editing/image-to-image. Omit for text-to-image generation.',
type: ['array', 'null'],
description: 'Optional reference image(s) for editing/image-to-image or null. Omit for text-to-image generation.',
items: {
type: 'object',
additionalProperties: false,
@@ -257,29 +282,29 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
description: 'VFS path (e.g. "images/sample.png") or database path ("image/<hash>").',
},
high: {
type: 'boolean',
description: 'Feed image in high quality/resolution if true; low quality/resolution if false (default).',
type: ['boolean', 'null'],
description: 'Feed image in high quality/resolution if true; low quality/resolution if false or null (default).',
},
},
},
},
size: {
type: 'string',
enum: VALID_SIZES,
description: 'Resolution tier (3 choices): "1K" (standard, default), "2K" (high quality), "4K" (ultra high).',
type: ['string', 'null'],
enum: [...VALID_SIZES, null],
description: 'Resolution tier (3 choices): "1K" (standard, default), "2K" (high quality), "4K" (ultra high), or null.',
},
ratio: {
type: 'string',
enum: VALID_RATIOS,
description: 'Aspect ratio (3 choices): "1:1" (square, default), "4:3" (landscape), "3:4" (portrait).',
type: ['string', 'null'],
enum: [...VALID_RATIOS, null],
description: 'Aspect ratio (3 choices): "1:1" (square, default), "4:3" (landscape), "3:4" (portrait), or null.',
},
vfsoutputfilename: {
type: 'string',
description: 'VFS path where the output image should be saved (e.g. "images/edited.png"). Defaults to "images/generated-<timestamp>.png".',
type: ['string', 'null'],
description: 'VFS path where the output image should be saved (e.g. "images/edited.avif") or null. Defaults to "images/generated-<timestamp>.avif". Output is always converted to AVIF.',
},
},
},
invoke: async ({ prompt, attachments, size, ratio, vfsoutputfilename } = {}) => {
invoke: async ({ prompt, attachments, size, ratio, vfsoutputfilename, note } = {}) => {
const cleanPrompt = typeof prompt === 'string' ? prompt.trim() : '';
if (!cleanPrompt) {
throw new ToolError('edit_image requires a non-empty "prompt" describing the image or edit instructions.');
@@ -402,21 +427,34 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
throw new ToolError('edit_image: received empty image buffer from model.');
}
// ── Determine target output path ──────────────────────────────
// ── Determine target output path (always .avif) ───────────────
let targetRel = typeof vfsoutputfilename === 'string' && vfsoutputfilename.trim()
? vfsoutputfilename.trim()
: '';
if (!targetRel) {
const prefix = inputReferences.length > 0 ? 'edited' : 'generated';
targetRel = `images/${prefix}-${Date.now()}.png`;
targetRel = `images/${prefix}-${Date.now()}.avif`;
}
const cleanRel = targetRel.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
let cleanRel = targetRel.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
if (isReadOnlyDatabasePath(cleanRel)) {
throw new ToolError(
`Access denied: "${targetRel}" matches read-only database image storage ("image/<hash>"). ` +
'Please specify a normal VFS path such as "images/output.png".'
'Please specify a normal VFS path such as "images/output.avif".'
);
}
// Always enforce .avif extension
const ext = path.extname(cleanRel);
if (ext.toLowerCase() !== '.avif') {
cleanRel = ext ? `${cleanRel.slice(0, -ext.length)}.avif` : `${cleanRel}.avif`;
}
if (isReadOnlyDatabasePath(cleanRel)) {
throw new ToolError(
`Access denied: "${cleanRel}" matches read-only database image storage ("image/<hash>"). ` +
'Please specify a normal VFS path such as "images/output.avif".'
);
}
@@ -426,38 +464,17 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
throw new ToolError(`Access denied: path "${targetRel}" escapes virtual filesystem boundary.`);
}
// ── Format conversion if requested by output extension ────────
let ext = path.extname(cleanRel).toLowerCase();
let finalBuffer = resultBuffer;
let mimeType = item.media_type || 'image/png';
if (!ext) {
// No extension supplied; infer from media_type or Sharp format
const meta = await sharp(resultBuffer).metadata().catch(() => ({}));
const format = meta.format || 'png';
ext = `.${format}`;
targetRel = `${cleanRel}${ext}`;
mimeType = `image/${format}`;
}
// ── Convert output to AVIF format ─────────────────────────────
let finalBuffer;
try {
if (ext === '.avif') {
finalBuffer = await sharp(resultBuffer).avif({ quality: 80 }).toBuffer();
mimeType = 'image/avif';
} else if (ext === '.webp') {
finalBuffer = await sharp(resultBuffer).webp({ quality: 80 }).toBuffer();
mimeType = 'image/webp';
} else if (ext === '.jpg' || ext === '.jpeg') {
finalBuffer = await sharp(resultBuffer).jpeg({ quality: 85 }).toBuffer();
mimeType = 'image/jpeg';
} else if (ext === '.png') {
finalBuffer = await sharp(resultBuffer).png().toBuffer();
mimeType = 'image/png';
}
finalBuffer = await sharp(resultBuffer)
.rotate()
.avif({ quality: AVIF_QUALITY })
.toBuffer();
} catch (convErr) {
console.warn('[edit_image] sharp format conversion warning:', convErr.message);
finalBuffer = resultBuffer;
throw new ToolError(`edit_image: failed to encode output image to AVIF: ${convErr.message}`);
}
const mimeType = 'image/avif';
// ── Write to VFS disk and sync to database ────────────────────
fs.mkdirSync(path.dirname(absTarget), { recursive: true });

View File

@@ -89,41 +89,45 @@ export function createFindFilesTool({ googleAuthId }) {
additionalProperties: false,
properties: {
dirname: {
type: 'string',
description: 'VFS directory to list. Default "." (root). Must be "." or empty if skills=true.',
type: ['string', 'null'],
description: 'VFS directory to list or null. Default "." (root). Must be "." or empty if skills=true.',
},
recursive: {
type: 'boolean',
description: 'Walk subdirectories. Default false.',
type: ['boolean', 'null'],
description: 'Walk subdirectories or null. Default false.',
},
skills: {
type: 'boolean',
description: 'List only root SKILL files with parsed titles. Overrides dirname/recursive. Default false.',
type: ['boolean', 'null'],
description: 'List only root SKILL files with parsed titles or null. Overrides dirname/recursive. Default false.',
},
seek: {
type: 'number',
description: 'Pagination offset. Default 0.',
type: ['number', 'null'],
description: 'Pagination offset or null. Default 0.',
},
limit: {
type: 'number',
description: `Max entries to return. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`,
type: ['number', 'null'],
description: `Max entries to return or null. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`,
},
maxDepth: {
type: 'number',
description: 'Max recursive directory depth. Default 3.',
type: ['number', 'null'],
description: 'Max recursive directory depth or null. Default 3.',
},
},
},
invoke: async ({ dirname = '.', recursive = false, skills = false, seek = 0, limit = DEFAULT_LIMIT, maxDepth = 3 } = {}) => {
if (dirname == null) dirname = '.';
if (typeof dirname !== 'string') {
throw new ToolError(`"dirname" must be a string (received ${JSON.stringify(dirname)}).`);
}
seek = seek != null ? Number(seek) : 0;
if (!Number.isInteger(seek) || seek < 0) {
throw new ToolError(`"seek" must be a non-negative integer (received ${seek}).`);
}
limit = limit != null ? Number(limit) : DEFAULT_LIMIT;
if (!Number.isInteger(limit) || limit < 1) {
throw new ToolError(`"limit" must be a positive integer (received ${limit}).`);
}
maxDepth = maxDepth != null ? Number(maxDepth) : 3;
if (!Number.isInteger(maxDepth) || maxDepth < 1) {
throw new ToolError(`"maxDepth" must be a positive integer (received ${maxDepth}).`);
}

View File

@@ -133,21 +133,25 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
additionalProperties: false,
required: ['code'],
properties: {
note: {
type: ['string', 'null'],
description: 'A note explaining what this code is doing (or null). This note will be displayed to the user while the code is executing.',
},
code: {
type: 'string',
description: 'JavaScript code. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.',
},
timeout: {
type: 'number',
description: `Execution timeout in ms. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
type: ['number', 'null'],
description: `Execution timeout in ms or null. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
},
max_output: {
type: 'number',
description: `Max stdout chars. Default ${DEFAULT_MAX_OUTPUT}.`,
type: ['number', 'null'],
description: `Max stdout chars or null. Default ${DEFAULT_MAX_OUTPUT}.`,
},
},
},
invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT }) => {
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.');
}

190
src/tools/net-gross-calc.js Normal file
View File

@@ -0,0 +1,190 @@
import { defineTool, ToolError } from 'libllmorbiter';
const DEFAULT_TAX_RATE = 19;
const DEFAULT_PRECISION = 6;
/**
* Calculates net-to-gross or gross-to-net for a single price.
*
* @param {object} params
* @param {number} [params.net]
* @param {number} [params.gross]
* @param {number} [params.amount]
* @param {'net'|'gross'} [params.from]
* @param {number} [params.taxRate=19]
* @param {number} [params.precision=6]
* @returns {object}
*/
export function calculatePrice({ net, gross, amount, from, taxRate = DEFAULT_TAX_RATE, precision = DEFAULT_PRECISION }) {
const rate = taxRate != null ? Number(taxRate) : DEFAULT_TAX_RATE;
if (!Number.isFinite(rate) || rate < 0) {
throw new ToolError(`Invalid taxRate: ${taxRate}. Must be a non-negative number.`);
}
const prec = precision != null ? Math.min(8, Math.max(2, Number(precision) || DEFAULT_PRECISION)) : DEFAULT_PRECISION;
const factor = 1 + rate / 100;
let inputType = null;
let inputValue = null;
if (net != null && gross == null) {
inputType = 'net';
inputValue = Number(net);
} else if (gross != null && net == null) {
inputType = 'gross';
inputValue = Number(gross);
} else if (amount != null) {
inputValue = Number(amount);
inputType = from === 'net' ? 'net' : 'gross';
} else if (net != null && gross != null) {
// Both provided: validate consistency
const n = Number(net);
const g = Number(gross);
const calculatedGross = +(n * factor).toFixed(2);
const effectiveRate = n > 0 ? +(((g - n) / n) * 100).toFixed(2) : 0;
return {
direction: 'verify',
net: +n.toFixed(2),
gross: +g.toFixed(2),
taxRate: rate,
effectiveTaxRate: effectiveRate,
isConsistent: Math.abs(calculatedGross - g) <= 0.02,
taxAmount: +(g - n).toFixed(2),
};
} else {
throw new ToolError('net_gross_calc requires "net", "gross", or "amount".');
}
if (!Number.isFinite(inputValue) || inputValue < 0) {
throw new ToolError(`Invalid price amount: ${inputValue}. Must be a non-negative number.`);
}
if (inputType === 'gross') {
const grossVal = +inputValue.toFixed(2);
const exactNet = inputValue / factor;
const netRounded = +exactNet.toFixed(2);
const netPrecision = +exactNet.toFixed(prec);
const taxAmount = +(grossVal - netRounded).toFixed(2);
return {
direction: 'gross_to_net',
gross: grossVal,
net: netRounded,
netPrecision,
netPrecise: exactNet,
taxRate: rate,
taxAmount,
apiNetValue: netPrecision,
formatted: {
gross: `${grossVal.toFixed(2)} €`,
net: `${netRounded.toFixed(2)} €`,
tax: `${taxAmount.toFixed(2)} €`,
},
};
} else {
const netVal = +inputValue.toFixed(2);
const exactGross = inputValue * factor;
const grossRounded = +exactGross.toFixed(2);
const taxAmount = +(grossRounded - netVal).toFixed(2);
return {
direction: 'net_to_gross',
net: netVal,
gross: grossRounded,
grossPrecise: exactGross,
taxRate: rate,
taxAmount,
formatted: {
net: `${netVal.toFixed(2)} €`,
gross: `${grossRounded.toFixed(2)} €`,
tax: `${taxAmount.toFixed(2)} €`,
},
};
}
}
/**
* Creates the net_gross_calc tool.
*/
export function createNetGrossCalcTool() {
return defineTool({
name: 'net_gross_calc',
description:
'Convert between net and gross prices (net to gross, gross to net) with VAT/tax rate (default 19%). ' +
'Computes 2-decimal rounded display values and 6-decimal high-precision net values for API article creation.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
properties: {
note: {
type: 'string',
description: 'A note explaining what this price calculation is doing. This note will be displayed to the user while executing.',
},
gross: {
type: ['number', 'null'],
description: 'Gross price (incl. VAT) or null. When provided, computes net price and tax amount.',
},
net: {
type: ['number', 'null'],
description: 'Net price (excl. VAT) or null. When provided, computes gross price and tax amount.',
},
taxRate: {
type: ['number', 'null'],
description: `Tax/VAT rate percentage (e.g. 19 for 19%, 7 for 7%) or null. Default ${DEFAULT_TAX_RATE}.`,
},
amount: {
type: ['number', 'null'],
description: 'Generic price amount or null when used with "from" ("gross" or "net").',
},
from: {
type: ['string', 'null'],
enum: ['gross', 'net', null],
description: 'Direction when using "amount": "gross" (default) or "net".',
},
precision: {
type: ['number', 'null'],
description: `Decimal precision for net price API value or null (default ${DEFAULT_PRECISION}, between 2 and 8).`,
},
items: {
type: ['array', 'null'],
description: 'Optional array of prices or objects for batch calculation: [19.99, 49.95] or [{ gross: 19.99, taxRate: 7 }].',
items: {
type: 'object',
additionalProperties: false,
properties: {
gross: { type: ['number', 'null'] },
net: { type: ['number', 'null'] },
amount: { type: ['number', 'null'] },
from: { type: ['string', 'null'], enum: ['gross', 'net', null] },
taxRate: { type: ['number', 'null'] },
},
},
},
},
},
invoke: async ({ note, gross, net, amount, from, taxRate = DEFAULT_TAX_RATE, precision = DEFAULT_PRECISION, items } = {}) => {
if (Array.isArray(items) && items.length > 0) {
const results = items.map((item, idx) => {
const itemRate = item.taxRate != null ? item.taxRate : taxRate;
try {
return calculatePrice({
net: item.net,
gross: item.gross,
amount: item.amount,
from: item.from || from,
taxRate: itemRate,
precision,
});
} catch (err) {
return { itemIndex: idx, error: err.message };
}
});
return JSON.stringify({ count: results.length, results }, null, 2);
}
const result = calculatePrice({ net, gross, amount, from, taxRate, precision });
return JSON.stringify(result, null, 2);
},
});
}

View File

@@ -98,12 +98,12 @@ export function createPatchTool({ googleAuthId }) {
description: 'Replacement text.',
},
title: {
type: 'string',
description: 'Optional line-1 title for SKILL files.',
type: ['string', 'null'],
description: 'Optional line-1 title for SKILL files or null.',
},
skiplines: {
type: 'integer',
description: 'Lines to skip before searching.',
type: ['integer', 'null'],
description: 'Lines to skip before searching or null.',
},
},
},

View File

@@ -55,17 +55,17 @@ export function createPdfReadTool({ googleAuthId }) {
description: 'VFS path or HTTP(S) URL.',
},
format: {
type: 'string',
enum: ['txt', 'png', 'both'],
description: 'Output format: "txt" (layout text), "png" (page images), "both". Default "txt".',
type: ['string', 'null'],
enum: ['txt', 'png', 'both', null],
description: 'Output format: "txt" (layout text), "png" (page images), "both", or null. Default "txt".',
},
page: {
type: 'number',
description: '1-based page number. Omit for all pages.',
type: ['number', 'null'],
description: '1-based page number or null for all pages.',
},
dpi: {
type: 'number',
description: 'PNG render DPI. Default 150.',
type: ['number', 'null'],
description: 'PNG render DPI or null. Default 150.',
},
},
},
@@ -76,8 +76,8 @@ export function createPdfReadTool({ googleAuthId }) {
}
const targetFormat = normalizeFormat(format || type);
const targetPage = Number.isInteger(Number(page)) && Number(page) >= 1 ? Number(page) : null;
const targetDpi = Number.isInteger(Number(dpi)) && Number(dpi) >= 50 && Number(dpi) <= 600 ? Number(dpi) : DEFAULT_DPI;
const targetPage = page != null && Number.isInteger(Number(page)) && Number(page) >= 1 ? Number(page) : null;
const targetDpi = dpi != null && Number.isInteger(Number(dpi)) && Number(dpi) >= 50 && Number(dpi) <= 600 ? Number(dpi) : DEFAULT_DPI;
const vfsRoot = await prepareVfs({ googleAuthId });
let buffer = null;

View File

@@ -75,12 +75,12 @@ export function createReadFileTool({ googleAuthId }) {
description: 'VFS file path.',
},
seek: {
type: 'number',
description: 'Line offset (0-indexed). Default 0.',
type: ['number', 'null'],
description: 'Line offset (0-indexed) or null. Default 0.',
},
limit: {
type: 'number',
description: `Max lines to return. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`,
type: ['number', 'null'],
description: `Max lines to return or null. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`,
},
},
},
@@ -88,9 +88,11 @@ export function createReadFileTool({ googleAuthId }) {
if (typeof filename !== 'string' || !filename.trim()) {
throw new ToolError('"filename" must be a non-empty string.');
}
seek = seek != null ? Number(seek) : 0;
if (!Number.isInteger(seek) || seek < 0) {
throw new ToolError(`"seek" must be a non-negative integer (received ${seek}).`);
}
limit = limit != null ? Number(limit) : DEFAULT_LIMIT;
if (!Number.isInteger(limit) || limit < 1) {
throw new ToolError(`"limit" must be a positive integer (received ${limit}).`);
}

View File

@@ -1,7 +1,6 @@
import fs from 'fs';
import path from 'path';
import { defineTool } from 'libllmorbiter';
import { getStore } from '../db/store.js';
import { isSkillPath } from './skill.js';
import { resolveVfsRoot } from './vfs.js';
@@ -34,31 +33,14 @@ export function createResetVfsTool({ googleAuthId }) {
additionalProperties: false,
properties: {
include_skills: {
type: 'boolean',
description: 'Delete SKILL files too. Default false.',
type: ['boolean', 'null'],
description: 'Delete SKILL files too (or null). Default false.',
},
},
},
invoke: async ({ include_skills = false } = {}) => {
const store = await getStore();
const keepSkills = include_skills !== true;
const keptSkills = [];
const existing = await store.modelvfs.list(googleAuthId);
// Phase 1 — clear the database, but only for the names we may delete. Done before
// touching disk so a store failure cannot leave the disk wiped while the database
// still claims the files exist.
for (const file of existing) {
if (keepSkills && isSkillPath(file.name)) {
keptSkills.push(file.name);
continue;
}
await store.modelvfs.delete(googleAuthId, file.name);
}
// Phase 2 — mirror the same decision onto the on-disk cache. The root comes from
// the same lookup the store used above, so the two phases cannot drift apart.
const vfsRoot = await resolveVfsRoot(googleAuthId);
let deletedCount = 0;
if (fs.existsSync(vfsRoot)) {

View File

@@ -1,7 +1,6 @@
import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { getStore } from '../db/store.js';
import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js';
import { isSkillPath } from './skill.js';
@@ -97,18 +96,12 @@ export function createRmTool({ googleAuthId }) {
const vfsRoot = await prepareVfs({ googleAuthId });
const { abs, size } = deleteVfsFile(vfsRoot, clean);
// Database first. If this throws, the file is still on disk — recoverable, and the row
// still exists to describe it. Doing it the other way round would leave a blob on
// disk that the next prepareVfs hydration would restore, silently undoing the delete.
const store = await getStore();
const res = await store.modelvfs.delete(googleAuthId, clean);
fs.unlinkSync(abs);
const wasSkill = isSkillPath(clean);
const result = {
success: true,
deleted: res?.deleted !== false,
deleted: true,
filename: clean,
size,
message: `Deleted "${clean}" (${size} bytes). This cannot be undone.`,

View File

@@ -32,24 +32,28 @@ export function createSearchArticlesTool({ googleAuthId, companyId }) {
additionalProperties: false,
properties: {
searchquery: {
type: 'string',
description: 'Words that must all match across name, brand, EAN, SKU, or supplier SKU (AND logic, order-independent).',
type: ['string', 'null'],
description: 'Words that must all match across name, brand, EAN, SKU, or supplier SKU (AND logic, order-independent) or null.',
},
excludewords: {
type: 'string',
description: 'Words that if all match exclude the article (AND logic, order-independent).',
type: ['string', 'null'],
description: 'Words that if all match exclude the article (AND logic, order-independent) or null.',
},
offset: {
type: 'integer',
description: 'Pagination offset. Default 0.',
type: ['integer', 'null'],
description: 'Pagination offset or null. Default 0.',
},
limit: {
type: 'integer',
description: `Result window size (1–${MAX_LIMIT}). Default ${DEFAULT_LIMIT}.`,
type: ['integer', 'null'],
description: `Result window size (1–${MAX_LIMIT}) or null. Default ${DEFAULT_LIMIT}.`,
},
},
},
invoke: async ({ searchquery = '', excludewords = '', offset = 0, limit = DEFAULT_LIMIT } = {}) => {
const queryStr = typeof searchquery === 'string' ? searchquery : '';
const excludeStr = typeof excludewords === 'string' ? excludewords : '';
const resolvedOffset = offset != null ? Number(offset) : 0;
const resolvedLimit = limit != null ? Number(limit) : DEFAULT_LIMIT;
const store = await getStore();
const provider = await getProvider();
@@ -133,8 +137,8 @@ export function createSearchArticlesTool({ googleAuthId, companyId }) {
};
});
const searchWords = parseWords(searchquery);
const excludeWords = parseWords(excludewords);
const searchWords = parseWords(queryStr);
const excludeWords = parseWords(excludeStr);
const filtered = searchableArticles.filter((art) => {
if (searchWords.length > 0) {
@@ -151,8 +155,8 @@ export function createSearchArticlesTool({ googleAuthId, companyId }) {
filtered.sort((a, b) => (a.name || '').localeCompare(b.name || '', 'de', { sensitivity: 'base' }));
const total = filtered.length;
const startOffset = Math.max(0, parseInt(offset, 10) || 0);
const pageSize = Math.min(Math.max(1, parseInt(limit, 10) || DEFAULT_LIMIT), MAX_LIMIT);
const startOffset = Math.max(0, parseInt(resolvedOffset, 10) || 0);
const pageSize = Math.min(Math.max(1, parseInt(resolvedLimit, 10) || DEFAULT_LIMIT), MAX_LIMIT);
const windowSlice = filtered.slice(startOffset, startOffset + pageSize);
const fullArticles = (

View File

@@ -144,33 +144,40 @@ export function createSearchImagesTool({ googleAuthId }) {
additionalProperties: false,
required: ['query'],
properties: {
note: {
type: ['string', 'null'],
description: 'A note explaining what this image search is doing (or null). This note will be displayed to the user while executing.',
},
query: {
type: 'string',
description: 'Search query.',
},
count: {
type: 'number',
description: `Candidate count. Default ${DEFAULT_COUNT}, max ${MAX_COUNT}.`,
type: ['number', 'null'],
description: `Candidate count or null. Default ${DEFAULT_COUNT}, max ${MAX_COUNT}.`,
},
gl: {
type: 'string',
description: 'Country code. Default "de".',
type: ['string', 'null'],
description: 'Country code or null. Default "de".',
},
hl: {
type: 'string',
description: 'Language code. Default "de".',
type: ['string', 'null'],
description: 'Language code or null. Default "de".',
},
},
},
invoke: async ({ query, count = DEFAULT_COUNT, gl = 'de', hl = 'de' } = {}) => {
invoke: async ({ query, count = DEFAULT_COUNT, gl = 'de', hl = 'de', note } = {}) => {
const term = typeof query === 'string' ? query.trim() : '';
if (!term) {
throw new ToolError('search_images requires a non-empty "query" (e.g. " Mineralwasser Flasche Glas ").');
}
if (!Number.isInteger(count) || count < 1) {
const resolvedCount = count != null ? Number(count) : DEFAULT_COUNT;
if (!Number.isInteger(resolvedCount) || resolvedCount < 1) {
throw new ToolError(`"count" must be a positive integer (received ${count}).`);
}
const wanted = Math.min(count, MAX_COUNT);
const wanted = Math.min(resolvedCount, MAX_COUNT);
const resolvedGl = gl || 'de';
const resolvedHl = hl || 'de';
let apiKey = null;
try {
@@ -187,7 +194,7 @@ export function createSearchImagesTool({ googleAuthId }) {
);
}
const raw = await serperImageSearch(term, apiKey, { gl, hl });
const raw = await serperImageSearch(term, apiKey, { gl: resolvedGl, hl: resolvedHl });
if (raw.length === 0) {
return {
query: term,

View File

@@ -4,7 +4,7 @@ import { getStore } from '../db/store.js';
import env from '../config/env.js';
import { getDefaultSkills } from './skill.js';
const FULL_HASH_REGEX = /^[a-fA-F0-9]{64}$/;
const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/;
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
export function getMimeType(filePath) {
@@ -54,19 +54,27 @@ export function getMimeType(filePath) {
export function extractFullHash(p) {
if (typeof p !== 'string' || !p.trim()) return null;
const clean = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
const parts = clean.split('/');
const clean = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '');
const parts = clean.split('/').filter(Boolean);
if (parts.length === 0) return null;
const first = parts[0].toLowerCase();
const last = parts[parts.length - 1];
const ext = path.extname(last).toLowerCase();
const withoutExt = last.replace(/\.[a-zA-Z0-9]+$/, '');
if (FULL_HASH_REGEX.test(withoutExt)) {
return withoutExt.toLowerCase();
}
for (const part of parts) {
const seg = part.replace(/\.[a-zA-Z0-9]+$/, '');
if (FULL_HASH_REGEX.test(seg)) {
return seg.toLowerCase();
// Under virtual image folder (e.g. image/aB1cD2eF, images/aB1cD2eF.avif)
if (VIRTUAL_IMAGE_FOLDERS.has(first)) {
if (parts.length === 2 && FULL_HASH_REGEX.test(withoutExt)) {
return withoutExt;
}
}
// Direct hash (e.g. "aB1cD2eF" or "aB1cD2eF.avif")
if (parts.length === 1 && FULL_HASH_REGEX.test(withoutExt) && (!ext || ext === '.avif')) {
return withoutExt;
}
return null;
}
@@ -126,7 +134,7 @@ async function vfsRootFor(store, googleAuthId) {
}
/**
* Loads default SKILL files into the selected company's VFS (both modelvfs table and disk cache).
* Loads default SKILL files into the selected company's on-disk VFS.
*
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
* @returns {Promise<Array<string>>} List of loaded skill file names
@@ -140,19 +148,12 @@ export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsR
const loaded = [];
for (const skill of defaultSkills) {
try {
await store.modelvfs.write(googleAuthId, {
name: skill.name,
mime: 'text/markdown',
content: Buffer.from(skill.content, 'utf8'),
});
} catch (err) {
console.error(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message);
}
try {
const diskPath = path.resolve(vfsRoot, skill.name);
fs.writeFileSync(diskPath, skill.content, 'utf8');
if (!fs.existsSync(diskPath)) {
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
fs.writeFileSync(diskPath, skill.content, 'utf8');
}
loaded.push(skill.name);
} catch (err) {
console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message);
@@ -178,7 +179,7 @@ export async function initCompanyVfs({ googleAuthId, store: passedStore, vfsRoot
}
/**
* Ensure the local directory for this company's VFS exists and is hydrated from modelvfs.
* Ensure the local directory for this company's VFS exists and default skills are loaded.
*
* @param {{ googleAuthId: number }} ctx
* @returns {Promise<string>} vfsRoot directory path
@@ -189,86 +190,21 @@ export async function prepareVfs({ googleAuthId }) {
fs.mkdirSync(vfsRoot, { recursive: true });
try {
const files = await store.modelvfs.list(googleAuthId);
const existingNames = new Set(files.map((f) => f.name));
const defaultSkills = getDefaultSkills();
for (const skill of defaultSkills) {
if (!existingNames.has(skill.name)) {
try {
await store.modelvfs.write(googleAuthId, {
name: skill.name,
mime: 'text/markdown',
content: Buffer.from(skill.content, 'utf8'),
});
files.push({ name: skill.name });
existingNames.add(skill.name);
} catch (err) {
console.warn(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message);
}
}
}
for (const file of files) {
const diskPath = path.resolve(vfsRoot, file.name);
if (!diskPath.startsWith(vfsRoot)) continue;
if (!fs.existsSync(diskPath)) {
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
const fullRecord = await store.modelvfs.read(googleAuthId, file.name);
if (fullRecord?.content) {
fs.writeFileSync(diskPath, fullRecord.content);
}
}
}
await loadDefaultSkills({ googleAuthId, store, vfsRoot });
} catch (err) {
console.warn('[vfs] hydration warning:', err.message);
console.warn('[vfs] prepare warning:', err.message);
}
return vfsRoot;
}
/**
* Syncs any modified or newly written files from the disk vfsRoot back to the modelvfs table.
* Purely disk-based VFS; no database syncing needed. Kept as no-op for backward compatibility.
*
* The store re-scopes by the currently selected company on every call, so this only stays
* correct while `vfsRoot` was produced by `prepareVfs`/`resolveVfsRoot` in the same turn.
*
* @param {{ googleAuthId: number, vfsRoot: string }} ctx
* @param {{ googleAuthId?: number, vfsRoot?: string }} [ctx]
*/
export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
if (!fs.existsSync(vfsRoot)) return;
const store = await getStore();
function walk(dir, rel = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const list = [];
for (const e of entries) {
const relPath = rel ? `${rel}/${e.name}` : e.name;
const fullPath = path.join(dir, e.name);
if (e.isDirectory()) {
list.push(...walk(fullPath, relPath));
} else if (e.isFile()) {
list.push({ relPath, fullPath });
}
}
return list;
}
const currentFiles = walk(vfsRoot);
for (const f of currentFiles) {
if (isReadOnlyDatabasePath(f.relPath)) continue;
try {
const content = fs.readFileSync(f.fullPath);
const mime = getMimeType(f.relPath);
await store.modelvfs.write(googleAuthId, {
name: f.relPath,
mime,
content,
});
} catch (err) {
console.error(`[vfs] failed to sync file ${f.relPath} to db:`, err.message);
}
}
export async function syncVfsToDb({ googleAuthId, vfsRoot } = {}) {
// Purely disk-based VFS; no database syncing.
}
/**
@@ -342,13 +278,14 @@ export function createVfs(vfsRoot, options = {}) {
readFile: (p, opt, cb) => {
const callback = typeof opt === 'function' ? opt : cb;
const options = typeof opt === 'function' ? undefined : opt;
try {
const data = vfs.readFileSync(p, options);
if (typeof callback === 'function') process.nextTick(callback, null, data);
} catch (err) {
if (typeof callback === 'function') process.nextTick(callback, err);
else throw err;
}
vfs.promises.readFile(p, options)
.then((data) => {
if (typeof callback === 'function') process.nextTick(callback, null, data);
})
.catch((err) => {
if (typeof callback === 'function') process.nextTick(callback, err);
else throw err;
});
},
createReadStream: (p, opt) => {
@@ -476,7 +413,34 @@ export function createVfs(vfsRoot, options = {}) {
},
promises: {
readFile: async (p, opt) => vfs.readFileSync(p, opt),
readFile: async (p, opt) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? buf.toString(encoding) : buf;
}
if (googleAuthId) {
try {
const store = await getStore();
const img = await store.pic.getImageByHash(googleAuthId, hash);
if (img?.buffer) {
try {
const cachePath = path.join(vfsRoot, 'images', `${hash}.avif`);
fs.mkdirSync(path.join(vfsRoot, 'images'), { recursive: true });
fs.writeFileSync(cachePath, img.buffer);
} catch { /* ignore cache write error */ }
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? img.buffer.toString(encoding) : img.buffer;
}
} catch {
// fall through
}
}
}
return vfs.readFileSync(p, opt);
},
writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt),
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
stat: async (p, opt) => vfs.statSync(p, opt),

View File

@@ -96,36 +96,40 @@ export function createWebSearchTool({ googleAuthId }) {
additionalProperties: false,
required: ['query'],
properties: {
note: {
type: ['string', 'null'],
description: 'A note explaining what this search is doing (or null). This note will be displayed to the user while the search is executing.',
},
query: {
type: 'string',
description: 'Search query.',
},
timePeriod: {
type: 'string',
enum: ['day', 'week', 'month', 'year'],
description: 'Time filter: "day", "week", "month", or "year".',
type: ['string', 'null'],
enum: ['day', 'week', 'month', 'year', null],
description: 'Time filter: "day", "week", "month", or "year", or null.',
},
gl: {
type: 'string',
description: 'Country code. Default "de".',
type: ['string', 'null'],
description: 'Country code or null. Default "de".',
},
hl: {
type: 'string',
description: 'Language code. Default "de".',
type: ['string', 'null'],
description: 'Language code or null. Default "de".',
},
page: {
type: 'number',
description: 'Result page number (10/page). Default 1.',
type: ['number', 'null'],
description: 'Result page number (10/page) or null. Default 1.',
},
},
},
invoke: async ({ query, timePeriod, tbs, gl = 'de', hl = 'de', page = 1 } = {}, { signal } = {}) => {
invoke: async ({ query, timePeriod, tbs, gl = 'de', hl = 'de', page = 1, note } = {}, { signal } = {}) => {
const term = typeof query === 'string' ? query.trim() : '';
if (!term) {
throw new ToolError('web_search requires a non-empty "query".');
}
const parsedPage = Number(page);
const parsedPage = page != null ? Number(page) : 1;
if (!Number.isInteger(parsedPage) || parsedPage < 1) {
throw new ToolError(`"page" must be a positive integer (received ${page}).`);
}