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/init.md`](routes/pos/init.md) | `GET /v1/init` |
| [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` | | [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` |
| [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` | | [`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/_*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/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` | | [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` |
| [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` | | [`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` | | `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}` | | `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` | | `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 ## DESIGN

View File

@@ -1,58 +1,64 @@
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 183 API endpoints across all modules. Overview of all 189 API endpoints across all modules.
### Notation ### 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. 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 ### Reusable types
- `T1` = `{ article: T2 }` - `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 }` - `T3` = `{ delivery: T4 }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, externalNumber: str, date: str, createdAt: str, ledger: T33, lines: T18[], files: T8[] }` - `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T19[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T39 }`
- `T5` = `{ order: T7 }` - `T5` = `{ labeltemplate: T6 }`
- `T6` = `{ quote: T7 }` - `T6` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T15, thumb?: str|null }`
- `T7` = `{ id: int, number: str, type: str, entityId: int, date: str, createdAt: str, lines: T18[] }` - `T7` = `{ order: T10 }`
- `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 }` - `T8` = `{ quote: T10 }`
- `T9` = `{ id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T36 }` - `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` = `{ ledger: T11[] }` - `T10` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T19[], createdAt?: str }`
- `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 }` - `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, firstName: str, name: str, street: str, streetNumber: str, zip: str, city: str, county: str, addressType: str }` - `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` = `{ id: int, name: str, sort: int, exclusive: bool, values: T31[] }` - `T13` = `{ ledger: T14[] }`
- `T14` = `{ entity: T20 }` - `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` = `{ printer: T21 }` - `T15` = `{ objects: T20[] }`
- `T16` = `{ labeltemplate: T23 }` - `T16` = `{ objects: T21[] }`
- `T17` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }` - `T17` = `{ printer: T22 }`
- `T18` = `{ id: int, sku: str, name: str, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str }` - `T18` = `{ entity: T26 }`
- `T19` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }` - `T19` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T20` = `{ id: int, customergroupId: int, firstName: str, name: str, number: str, createdAt: str, updatedAt: str }` - `T20` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T21` = `{ id: int, company_id: int, address: str, type: str, model: str, serial: str, sort: int, desc: str }` - `T21` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T22` = `{ amount: num, net: num, taxrate: num, sku?: str, name?: str, pu?: num, rebate?: num, comment?: 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, width: num, height: num, dpi: int, type: str, content: obj, thumb: str }` - `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T37[] }`
- `T24` = `{ id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }` - `T24` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T25` = `{ article: T27 }` - `T25` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T26` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - `T26` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T27` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }` - `T27` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T28` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }` - `T28` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }`
- `T29` = `{ id: int, email: str, selectedCompanyId: int, picture: str }` - `T29` = `{ id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T30` = `{ id: int, entityId: int, contactType: str, contact: str }` - `T30` = `{ article: T33 }`
- `T31` = `{ id: int, attributeId: int, value: str, sort: int }` - `T31` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T32` = `{ id: int, name: str, createdAt: str, updatedAt: str }` - `T32` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- `T33` = `{ totalPositions: int, completelyOnLedgerPositions: int }` - `T33` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T34` = `{ id: int, name: str, sort: int, exclusive: bool }` - `T34` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T35` = `{ warehouse: T37 }` - `T35` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T36` = `{ net: num, taxId: int, taxRate: num }` - `T36` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T37` = `{ id: int, name: str, sort: int }` - `T37` = `{ id: int, attributeId: int, value: str, sort: int }`
- `T38` = `{ id: int, categoryIds: 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 ### Routes
#### Create article in category — `POST /article` #### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, track_stock?: bool }` - Params: `body: { categoryId: int, name: str, track_stock?: bool }`
- Returns: `201 T25` - Returns: `201 T30`
#### Export articles as ZIP — `GET /article/export.zip` #### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int` - Params: `query: categoryId?: int`
- Returns: `200 zip` - 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 }[] }` - 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` #### 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 }[] }` - 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` #### List article attribute values — `GET /article/{sku}/attributevalue`
- Params: `sku: str` - 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` #### Bulk set article attribute values — `PUT /article/{sku}/attributevalue`
- Params: `sku: str, body: int[]` - Params: `sku: str, body: int[]`
- 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 }[] }`
#### Set or clear article brand by SKU — `PUT /article/{sku}/brand` #### Set or clear article brand by SKU (null to clear) — `PUT /article/{sku}/brand`
- Params: `sku: str, body: { brandId: int }` - Params: `sku: str, body: { brandId: int|null }`
- Returns: `200 T1` - Returns: `200 T1`
#### Replace article categories by SKU — `PUT /article/{sku}/categories` #### Replace article categories by SKU — `PUT /article/{sku}/categories`
- Params: `sku: str, body: { categoryIds: int[] }` - 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 }[] }` - 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` #### 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 }` - 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` #### 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 }` - 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}` #### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: str, childSku: str, query: deleteArticle?: bool` - Params: `sku: str, childSku: str, query: deleteArticle?: bool`
- Returns: `200 { success: bool, deleted: 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 }[] }` - 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` #### 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 }[] }` - 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` #### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }` - Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `200 T25` - Returns: `200 T30`
#### Replace POS prices by SKU — `PUT /article/{sku}/prices` #### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { price: num, tax: num, minmultiplier?: num }[] }` - Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
- Returns: `200 T1` - Returns: `200 T1`
#### Print article label — `POST /article/{sku}/print` #### Print article label — `POST /article/{sku}/print`
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }` - Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
- Returns: `200` - Returns: `200 { success: bool }`
#### List article properties — `GET /article/{sku}/property` #### List article properties — `GET /article/{sku}/property`
- Params: `sku: str` - 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` #### Bulk set article properties — `PUT /article/{sku}/property`
- Params: `sku: str, body: { properties: { propertyId: int, value: str, sort?: int }[] }` - 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}` #### Delete article property — `DELETE /article/{sku}/property/{propertyId}`
- Params: `sku: str, propertyId: int` - Params: `sku: str, propertyId: int`
- Returns: `200` - Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier` #### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str` - Params: `sku: str`
- Returns: `200 { suppliers: T9[] }` - Returns: `200 { suppliers: T11[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier` #### 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 }` - 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: T9 }` - Returns: `201 { supplier: T11 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order` #### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }` - Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { 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` - Params: `sku: str, id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}` #### 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 }` - 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: T9 }` - Returns: `200 { supplier: T11 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock` #### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }` - Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T1` - Returns: `200 T1`
@@ -157,64 +163,64 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `200 T1` - Returns: `200 T1`
#### List attribute names — `GET /attributename` #### List attribute names — `GET /attributename`
_none_ _none_
- Returns: `200 { attributenames: T13[] }` - Returns: `200 { attributenames: T23[] }`
#### Create attribute name — `POST /attributename` #### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }` - Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T13 }` - Returns: `201 { attributename: T23 }`
#### Reorder attribute names — `PUT /attributename/order` #### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }` - Params: `body: { attributenameIds: int[] }`
- Returns: `200` - Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value` #### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int` - Params: `attributeId: int`
- Returns: `200 { values: T31[] }` - Returns: `200 { values: T37[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value` #### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }` - 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` #### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }` - Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200` - Returns: `200 { ok: bool }`
#### Delete attribute name — `DELETE /attributename/{id}` #### Delete attribute name — `DELETE /attributename/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200` - Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}` #### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T13 }` - Returns: `200 { attributename: T23 }`
#### Delete attribute value — `DELETE /attributevalue/{id}` #### Delete attribute value — `DELETE /attributevalue/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200` - Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}` #### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }` - Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T31 }` - Returns: `200 { attributevalue: T37 }`
#### Login with Google ID token — `POST /auth/login` #### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }` - Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T29 }` - Returns: `200 { message: str, user: T34 }`
#### Logout — `POST /auth/logout` #### Logout — `POST /auth/logout`
_none_ _none_
- Returns: `200 { message: str }` - Returns: `200 { message: str }`
#### Current user — `GET /auth/me` #### Current user — `GET /auth/me`
_none_ _none_
- Returns: `200 { user: T29, googleClientId: str }` - Returns: `200 { user: T34, googleClientId: str }`
#### Set selected company — `PUT /auth/selected-company` #### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }` - Params: `body: { companyId: int }`
- Returns: `200 { user: T29 }` - Returns: `200 { user: T34 }`
#### List brands — `GET /brand` #### List brands — `GET /brand`
_none_ _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` #### Upsert brand — `PUT /brand`
- Params: `body: { name: str, gsprpinfo?: str }` - 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}` #### Delete brand — `DELETE /brand/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Delete brand image — `DELETE /brand/{id}/image` #### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int` - 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` #### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif` - 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` #### Create category — `POST /category`
- Params: `body: { parentId: int, name: str }` - 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` #### Export categories as ZIP — `GET /category/export.zip`
_none_ _none_
- Returns: `200 zip` - Returns: `200 zip`
@@ -223,7 +229,7 @@ _none_
- Returns: `200 { categoryIds: int[] }` - Returns: `200 { categoryIds: int[] }`
#### Category tree — `GET /category/tree` #### Category tree — `GET /category/tree`
_none_ _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}` #### Delete category — `DELETE /category/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
@@ -232,61 +238,61 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }` - Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles` #### Articles in category — `GET /category/{id}/articles`
- Params: `id: int` - 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` #### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int` - 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` #### Bulk set category attribute values — `PUT /category/{id}/attributevalue`
- Params: `id: int, body: int[]` - 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` #### Delete category image — `DELETE /category/{id}/image`
- Params: `id: int` - 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` #### Add category image — `POST /category/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }` - Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }`
#### List category properties — `GET /category/{id}/property` #### List category properties — `GET /category/{id}/property`
- Params: `id: int` - 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` #### Bulk set category properties — `PUT /category/{id}/property`
- Params: `id: int, body: { properties: { propertyId: int, value: str, sort?: int }[] }` - 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}` #### Delete category property — `DELETE /category/{id}/property/{propertyId}`
- Params: `id: int, propertyId: int` - Params: `id: int, propertyId: int`
- Returns: `200` - Returns: `200 { ok: bool }`
#### List companies — `GET /company` #### List companies — `GET /company`
_none_ _none_
- Returns: `200 { companies: T26[] }` - Returns: `200 { companies: T32[] }`
#### Create company — `POST /company` #### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - 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` #### Get company history — `GET /company/history`
- Params: `query: limit?: int` - 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` #### Add company history entry — `POST /company/history`
- Params: `body: { text: str }` - 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` #### List company settings — `GET /company/setting`
_none_ _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}` #### Get company setting by key — `GET /company/setting/{key}`
- Params: `key: str` - 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}` #### Upsert company setting — `PUT /company/setting/{key}`
- Params: `key: str, body: { value: str, frontend?: bool }` - 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}` #### Delete company — `DELETE /company/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update company — `PUT /company/{id}` #### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - 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` #### List customer groups — `GET /customergroup`
_none_ _none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }` - Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup` #### Create customer group — `POST /customergroup`
- Params: `body: { name: str }` - Params: `body: { name: str }`
- Returns: `201 { customergroup: T37 }` - Returns: `201 { customergroup: T43 }`
#### Reorder customer groups — `PUT /customergroup/order` #### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }` - Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }` - Returns: `200 { customergroupIds: int[] }`
@@ -295,12 +301,12 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}` #### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }` - Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T37 }` - Returns: `200 { customergroup: T43 }`
#### List deliveries — `GET /delivery` #### List deliveries — `GET /delivery`
_none_ _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` #### 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` - Returns: `201 T3`
#### Parse document (SSE) — `POST /delivery/parse` #### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }` - Params: `form: { file: str }`
@@ -312,17 +318,17 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `200 T3` - Returns: `200 T3`
#### Update delivery — `PATCH /delivery/{id}` #### 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` - Returns: `200 T3`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}` #### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool` - Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str` - Returns: `200 str`
#### List entities — `GET /entity` #### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str` - Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T20[] }` - Returns: `200 { entities: T26[] }`
#### Create entity — `POST /entity` #### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str, number?: str }` - Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T14` - Returns: `201 T18`
#### Peek next customer number — `GET /entity/next-number` #### Peek next customer number — `GET /entity/next-number`
_none_ _none_
- Returns: `200 { number: str }` - Returns: `200 { number: str }`
@@ -330,35 +336,35 @@ _none_
- Params: `entityId: int` - Params: `entityId: int`
- Returns: `200 { addresses: T12[] }` - Returns: `200 { addresses: T12[] }`
#### Create entity address — `POST /entity/{entityId}/address` #### 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 }` - Returns: `201 { address: T12 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}` #### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int` - Params: `entityId: int, id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}` #### 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 }` - Returns: `200 { address: T12 }`
#### List entity contacts — `GET /entity/{entityId}/contact` #### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int` - Params: `entityId: int`
- Returns: `200 { contacts: T30[] }` - Returns: `200 { contacts: T36[] }`
#### Create entity contact — `POST /entity/{entityId}/contact` #### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }` - Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T30 }` - Returns: `201 { contact: T36 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}` #### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int` - Params: `entityId: int, id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}` #### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }` - Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T30 }` - Returns: `200 { contact: T36 }`
#### Delete entity — `DELETE /entity/{id}` #### Delete entity — `DELETE /entity/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get entity — `GET /entity/{id}` #### Get entity — `GET /entity/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T14` - Returns: `200 T18`
#### Update entity — `PATCH /entity/{id}` #### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str, number?: str }` - Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T14` - Returns: `200 T18`
#### Subscribe to update signals (SSE) — `GET /events` #### Subscribe to update signals (SSE) — `GET /events`
_none_ _none_
- Returns: `200 SSE` - Returns: `200 SSE`
@@ -370,46 +376,46 @@ _none_
- Returns: `200 avif` - Returns: `200 avif`
#### List label templates — `GET /labeltemplate` #### List label templates — `GET /labeltemplate`
_none_ _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` #### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: obj }` - Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T16 }`
- Returns: `201 T16` - Returns: `201 T5`
#### Delete label template — `DELETE /labeltemplate/{id}` #### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}` #### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T16` - Returns: `200 T5`
#### Update label template (partial) — `PATCH /labeltemplate/{id}` #### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T16 }`
- Returns: `200 T16` - Returns: `200 T5`
#### Update label template — `PUT /labeltemplate/{id}` #### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T16 }`
- Returns: `200 T16` - Returns: `200 T5`
#### Query ledger by manifest article ID or SKU — `GET /ledger` #### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str` - Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T10` - Returns: `200 T13`
#### Add ledger entry — `POST /ledger` #### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }` - 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}` #### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str` - Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T10` - Returns: `200 T13`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}` #### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str` - Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T10` - Returns: `200 T13`
#### List conversations — `GET /modelconversation` #### List conversations — `GET /modelconversation`
_none_ _none_
- Returns: `200 { conversations: T19[] }` - Returns: `200 { conversations: T28[] }`
#### Create conversation — `POST /modelconversation` #### Create conversation — `POST /modelconversation`
- Params: `body: { provider: str, model: str }` - Params: `body: { provider: str, model: str }`
- Returns: `201 { conversation: T19 }` - Returns: `201 { conversation: T28 }`
#### Delete conversation — `DELETE /modelconversation/{id}` #### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}` #### Get conversation — `GET /modelconversation/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { conversation: T19 }` - Returns: `200 { conversation: T28 }`
#### List conversation messages — `GET /modelconversation/{id}/message` #### List conversation messages — `GET /modelconversation/{id}/message`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }` - 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 } }` - Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
#### List orders — `GET /order` #### List orders — `GET /order`
_none_ _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` #### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T22[], entityId?: int }` - Params: `body: { number: str, date: str, lines: T24[], entityId?: int|null }`
- Returns: `201 T5` - Returns: `201 T7`
#### Delete order — `DELETE /order/{id}` #### Delete order — `DELETE /order/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get order — `GET /order/{id}` #### Get order — `GET /order/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T5` - Returns: `200 T7`
#### Update order — `PATCH /order/{id}` #### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T22[] }` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T24[] }`
- Returns: `200 T5` - Returns: `200 T7`
#### List POS terminals — `GET /posterminal` #### List POS terminals — `GET /posterminal`
_none_ _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` #### Create POS terminal — `PUT /posterminal`
- Params: `body: { name: str }` - 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}` #### Delete POS terminal — `DELETE /posterminal/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all` #### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T38` - Returns: `200 T44`
#### Enable all categories on terminal — `PUT /posterminal/{id}/all` #### Enable all categories on terminal — `PUT /posterminal/{id}/all`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T38` - Returns: `200 T44`
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}` #### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int` - Params: `id: int, categoryId: int`
- Returns: `200 T38` - Returns: `200 T44`
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}` #### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int` - Params: `id: int, categoryId: int`
- Returns: `200 T38` - Returns: `200 T44`
#### List printers — `GET /printer` #### List printers — `GET /printer`
_none_ _none_
- Returns: `200 { printers: T21[] }` - Returns: `200 { printers: T22[] }`
#### Create printer — `POST /printer` #### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str, serial?: str, sort?: int, desc?: str }` - Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T15` - Returns: `201 T17`
#### Reorder printers — `PUT /printer/order` #### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }` - Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }` - Returns: `200 { printerIds: int[] }`
@@ -475,52 +481,52 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}` #### Get printer by ID — `GET /printer/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T15` - Returns: `200 T17`
#### Update printer (partial) — `PATCH /printer/{id}` #### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }` - Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T15` - Returns: `200 T17`
#### Update printer — `PUT /printer/{id}` #### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }` - Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T15` - Returns: `200 T17`
#### List property names — `GET /propertyname` #### List property names — `GET /propertyname`
_none_ _none_
- Returns: `200 { propertynames: T34[] }` - Returns: `200 { propertynames: T40[] }`
#### Create property name — `POST /propertyname` #### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }` - Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T34 }` - Returns: `201 { propertyname: T40 }`
#### Reorder property names — `PUT /propertyname/order` #### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }` - Params: `body: { propertynameIds: int[] }`
- Returns: `200` - Returns: `200 { ok: bool }`
#### Delete property name — `DELETE /propertyname/{id}` #### Delete property name — `DELETE /propertyname/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200` - Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}` #### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - 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` #### List public articles — `GET /public/{companyId}/articles`
- Params: `companyId: int, query: categoryId?: int, search?: str, limit?: int, offset?: int` - 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}` #### Get public article detail — `GET /public/{companyId}/articles/{sku}`
- Params: `companyId: int, sku: str` - 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` #### List public categories — `GET /public/{companyId}/categories`
- Params: `companyId: int` - 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` #### List quotes — `GET /quote`
_none_ _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` #### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T22[], entityId?: int }` - Params: `body: { number: str, date: str, lines: T24[], entityId?: int|null }`
- Returns: `201 T6` - Returns: `201 T8`
#### Delete quote — `DELETE /quote/{id}` #### Delete quote — `DELETE /quote/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get quote — `GET /quote/{id}` #### Get quote — `GET /quote/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T6` - Returns: `200 T8`
#### Update quote — `PATCH /quote/{id}` #### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T22[] }` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T24[] }`
- Returns: `200 T6` - Returns: `200 T8`
#### Crawl image from URL, crop white/transparency borders, and return AVIF — `POST /serper/image` #### Crawl image from URL, crop white/transparency borders, and return AVIF — `POST /serper/image`
- Params: `body: { url: str }` - Params: `body: { url: str }`
- Returns: `200 avif` - Returns: `200 avif`
@@ -528,26 +534,26 @@ _none_
- Params: `body: { q: str, gl?: str, hl?: str, num?: int, preferSource?: bool }` - Params: `body: { q: str, gl?: str, hl?: str, num?: int, preferSource?: bool }`
- Returns: `200 SSE` - Returns: `200 SSE`
#### Adjust stock manually — `POST /stock/adjust` #### 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 }[] }` - 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: obj[], ledger: T11[] }` - 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` #### List suppliers — `GET /supplier`
_none_ _none_
- Returns: `200 { suppliers: T32[] }` - Returns: `200 { suppliers: T38[] }`
#### Create supplier — `POST /supplier` #### Create supplier — `POST /supplier`
- Params: `body: { name: str }` - Params: `body: { name: str }`
- Returns: `201 { supplier: T32 }` - Returns: `201 { supplier: T38 }`
#### Delete supplier — `DELETE /supplier/{id}` #### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}` #### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }` - Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T32 }` - Returns: `200 { supplier: T38 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles` #### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string` - 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` #### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }` - 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}` #### Thumb image by hash — `GET /thumb/{fullHash}`
- Params: `fullHash: str` - Params: `fullHash: str`
- Returns: `200 avif` - Returns: `200 avif`
@@ -556,16 +562,16 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### List units — `GET /unit` #### List units — `GET /unit`
_none_ _none_
- Returns: `200 { units: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }[] }` - Returns: `200 { units: T31[] }`
#### Upsert unit — `PUT /unit` #### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` - 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` #### JTL-POS category synchronization — `GET /v1/category`
- Params: `query: lastChangedCategory?: str, limit?: int` - Params: `query: lastChangedCategory?: str, limit?: int`
- Returns: `200 obj[]` - Returns: `200 {}[]`
#### POS discovery and pairing — `GET /v1/client` #### POS discovery and pairing — `GET /v1/client`
- Params: `query: authCode: str, name?: str` - 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` #### POS sync initialization counts — `GET /v1/init`
- Params: `query: lastChangedProduct?: int, lastChangedCategory?: int, lastChangedCustomer?: int, lastChangedCustomerGroup?: int, lastChangedCompositeProduct?: int, lastChangedDeletedEntity?: int` - 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 }` - 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` - Returns: `200 jpeg`
#### JTL-POS product catalog synchronization — `GET /v1/product` #### JTL-POS product catalog synchronization — `GET /v1/product`
- Params: `query: lastChangedProduct?: str, limit?: int` - 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}` #### Get a virtual filesystem file — `GET /vfs/{*path}`
- Params: `path: str, query: download?: bool, raw?: bool` - Params: `path: str, query: download?: bool, raw?: bool`
- Returns: `200 str` - 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` #### List warehouses — `GET /warehouse`
_none_ _none_
- Returns: `200 { warehouses: T37[] }` - Returns: `200 { warehouses: T43[] }`
#### Create warehouse — `POST /warehouse` #### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }` - Params: `body: { name: str, sort?: int }`
- Returns: `201 T35` - Returns: `201 T41`
#### Reorder warehouses — `PUT /warehouse/order` #### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }` - Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }` - Returns: `200 { warehouseIds: int[] }`
@@ -592,10 +616,10 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}` #### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T35` - Returns: `200 T41`
#### Update warehouse (partial) — `PATCH /warehouse/{id}` #### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }` - Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T35` - Returns: `200 T41`
#### Update warehouse — `PUT /warehouse/{id}` #### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }` - Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T35` - Returns: `200 T41`

View File

@@ -1,54 +1,60 @@
# Dash API Routes Overview # Dash API Routes Overview
Overview of all 146 API endpoints across all modules. Overview of all 145 API endpoints across all modules.
### Notation ### 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. 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 ### Reusable types
- `T1` = `{ article: T2 }` - `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 }` - `T3` = `{ delivery: T4 }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, externalNumber: str, date: str, createdAt: str, ledger: T30, lines: T18[], files: T8[] }` - `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T19[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T36 }`
- `T5` = `{ order: T7 }` - `T5` = `{ labeltemplate: T6 }`
- `T6` = `{ quote: T7 }` - `T6` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T15, thumb?: str|null }`
- `T7` = `{ id: int, number: str, type: str, entityId: int, date: str, createdAt: str, lines: T18[] }` - `T7` = `{ order: T10 }`
- `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 }` - `T8` = `{ quote: T10 }`
- `T9` = `{ id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T33 }` - `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` = `{ ledger: T11[] }` - `T10` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T19[], createdAt?: str }`
- `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 }` - `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, firstName: str, name: str, street: str, streetNumber: str, zip: str, city: str, county: str, addressType: str }` - `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` = `{ id: int, name: str, sort: int, exclusive: bool, values: T28[] }` - `T13` = `{ ledger: T14[] }`
- `T14` = `{ entity: T19 }` - `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` = `{ printer: T20 }` - `T15` = `{ objects: T20[] }`
- `T16` = `{ labeltemplate: T22 }` - `T16` = `{ objects: T21[] }`
- `T17` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }` - `T17` = `{ printer: T22 }`
- `T18` = `{ id: int, sku: str, name: str, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str }` - `T18` = `{ entity: T26 }`
- `T19` = `{ id: int, customergroupId: int, firstName: str, name: str, number: str, createdAt: str, updatedAt: str }` - `T19` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T20` = `{ id: int, company_id: int, address: str, type: str, model: str, serial: str, sort: int, desc: str }` - `T20` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T21` = `{ amount: num, net: num, taxrate: num, sku?: str, name?: str, pu?: num, rebate?: num, comment?: 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, name: str, width: num, height: num, dpi: int, type: str, content: obj, thumb: 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, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }` - `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T34[] }`
- `T24` = `{ article: T25 }` - `T24` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T25` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }` - `T25` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T26` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }` - `T26` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T27` = `{ id: int, entityId: int, contactType: str, contact: str }` - `T27` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T28` = `{ id: int, attributeId: int, value: str, sort: int }` - `T28` = `{ id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T29` = `{ id: int, name: str, createdAt: str, updatedAt: str }` - `T29` = `{ article: T31 }`
- `T30` = `{ totalPositions: int, completelyOnLedgerPositions: int }` - `T30` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T31` = `{ id: int, name: str, sort: int, exclusive: bool }` - `T31` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T32` = `{ warehouse: T34 }` - `T32` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T33` = `{ net: num, taxId: int, taxRate: num }` - `T33` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T34` = `{ id: int, name: str, sort: int }` - `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 ### Routes
#### Create article in category — `POST /article` #### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, track_stock?: bool }` - Params: `body: { categoryId: int, name: str, track_stock?: bool }`
- Returns: `201 T24` - Returns: `201 T29`
#### Delete article by SKU — `DELETE /article/{sku}` #### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: str, query: cascade?: bool` - Params: `sku: str, query: cascade?: bool`
- Returns: `204 void` - 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 }[] }` - 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` #### 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 }[] }` - 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` #### List article attribute values — `GET /article/{sku}/attributevalue`
- Params: `sku: str` - 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` #### Bulk set article attribute values — `PUT /article/{sku}/attributevalue`
- Params: `sku: str, body: int[]` - Params: `sku: str, body: int[]`
- 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 }[] }`
#### Set or clear article brand by SKU — `PUT /article/{sku}/brand` #### Set or clear article brand by SKU (null to clear) — `PUT /article/{sku}/brand`
- Params: `sku: str, body: { brandId: int }` - Params: `sku: str, body: { brandId: int|null }`
- Returns: `200 T1` - Returns: `200 T1`
#### Replace article categories by SKU — `PUT /article/{sku}/categories` #### Replace article categories by SKU — `PUT /article/{sku}/categories`
- Params: `sku: str, body: { categoryIds: int[] }` - 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 }[] }` - 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` #### 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 }` - 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` #### 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 }` - 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}` #### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: str, childSku: str, query: deleteArticle?: bool` - Params: `sku: str, childSku: str, query: deleteArticle?: bool`
- Returns: `200 { success: bool, deleted: 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 }[] }` - 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` #### 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 }[] }` - 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` #### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }` - Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `200 T24` - Returns: `200 T29`
#### Replace POS prices by SKU — `PUT /article/{sku}/prices` #### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { price: num, tax: num, minmultiplier?: num }[] }` - Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
- Returns: `200 T1` - Returns: `200 T1`
#### Print article label — `POST /article/{sku}/print` #### Print article label — `POST /article/{sku}/print`
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }` - Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
- Returns: `200` - Returns: `200 { success: bool }`
#### List article properties — `GET /article/{sku}/property` #### List article properties — `GET /article/{sku}/property`
- Params: `sku: str` - 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` #### Bulk set article properties — `PUT /article/{sku}/property`
- Params: `sku: str, body: { properties: { propertyId: int, value: str, sort?: int }[] }` - 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}` #### Delete article property — `DELETE /article/{sku}/property/{propertyId}`
- Params: `sku: str, propertyId: int` - Params: `sku: str, propertyId: int`
- Returns: `200` - Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier` #### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str` - Params: `sku: str`
- Returns: `200 { suppliers: T9[] }` - Returns: `200 { suppliers: T11[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier` #### 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 }` - 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: T9 }` - Returns: `201 { supplier: T11 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order` #### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }` - Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { 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` - Params: `sku: str, id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}` #### 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 }` - 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: T9 }` - Returns: `200 { supplier: T11 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock` #### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }` - Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T1` - Returns: `200 T1`
@@ -147,64 +153,64 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Returns: `200 T1` - Returns: `200 T1`
#### List attribute names — `GET /attributename` #### List attribute names — `GET /attributename`
_none_ _none_
- Returns: `200 { attributenames: T13[] }` - Returns: `200 { attributenames: T23[] }`
#### Create attribute name — `POST /attributename` #### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }` - Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T13 }` - Returns: `201 { attributename: T23 }`
#### Reorder attribute names — `PUT /attributename/order` #### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }` - Params: `body: { attributenameIds: int[] }`
- Returns: `200` - Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value` #### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int` - Params: `attributeId: int`
- Returns: `200 { values: T28[] }` - Returns: `200 { values: T34[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value` #### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }` - 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` #### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }` - Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200` - Returns: `200 { ok: bool }`
#### Delete attribute name — `DELETE /attributename/{id}` #### Delete attribute name — `DELETE /attributename/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200` - Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}` #### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T13 }` - Returns: `200 { attributename: T23 }`
#### Delete attribute value — `DELETE /attributevalue/{id}` #### Delete attribute value — `DELETE /attributevalue/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200` - Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}` #### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }` - Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T28 }` - Returns: `200 { attributevalue: T34 }`
#### Login with Google ID token — `POST /auth/login` #### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }` - 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` #### Logout — `POST /auth/logout`
_none_ _none_
- Returns: `200 { message: str }` - Returns: `200 { message: str }`
#### List brands — `GET /brand` #### List brands — `GET /brand`
_none_ _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` #### Upsert brand — `PUT /brand`
- Params: `body: { name: str, gsprpinfo?: str }` - 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}` #### Delete brand — `DELETE /brand/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Delete brand image — `DELETE /brand/{id}/image` #### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int` - 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` #### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif` - 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` #### Create category — `POST /category`
- Params: `body: { parentId: int, name: str }` - 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` #### Reorder sibling categories — `PUT /category/order`
- Params: `body: { parentId: int, categoryIds: int[] }` - Params: `body: { parentId: int, categoryIds: int[] }`
- Returns: `200 { categoryIds: int[] }` - Returns: `200 { categoryIds: int[] }`
#### Category tree — `GET /category/tree` #### Category tree — `GET /category/tree`
_none_ _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}` #### Delete category — `DELETE /category/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
@@ -213,43 +219,43 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }` - Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles` #### Articles in category — `GET /category/{id}/articles`
- Params: `id: int` - 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` #### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int` - 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` #### Bulk set category attribute values — `PUT /category/{id}/attributevalue`
- Params: `id: int, body: int[]` - 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` #### Delete category image — `DELETE /category/{id}/image`
- Params: `id: int` - 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` #### List category properties — `GET /category/{id}/property`
- Params: `id: int` - 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` #### Bulk set category properties — `PUT /category/{id}/property`
- Params: `id: int, body: { properties: { propertyId: int, value: str, sort?: int }[] }` - 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}` #### Delete category property — `DELETE /category/{id}/property/{propertyId}`
- Params: `id: int, propertyId: int` - Params: `id: int, propertyId: int`
- Returns: `200` - Returns: `200 { ok: bool }`
#### List companies — `GET /company` #### List companies — `GET /company`
_none_ _none_
- Returns: `200 { companies: { id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }[] }` - Returns: `200 { companies: { id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }[] }`
#### Get company history — `GET /company/history` #### Get company history — `GET /company/history`
- Params: `query: limit?: int` - 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` #### Add company history entry — `POST /company/history`
- Params: `body: { text: str }` - 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` #### List company settings — `GET /company/setting`
_none_ _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}` #### Get company setting by key — `GET /company/setting/{key}`
- Params: `key: str` - 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}` #### Upsert company setting — `PUT /company/setting/{key}`
- Params: `key: str, body: { value: str, frontend?: bool }` - 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}` #### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - 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 } }` - 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 }[] }` - Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup` #### Create customer group — `POST /customergroup`
- Params: `body: { name: str }` - Params: `body: { name: str }`
- Returns: `201 { customergroup: T34 }` - Returns: `201 { customergroup: T40 }`
#### Reorder customer groups — `PUT /customergroup/order` #### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }` - Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }` - Returns: `200 { customergroupIds: int[] }`
@@ -267,12 +273,12 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}` #### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }` - Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T34 }` - Returns: `200 { customergroup: T40 }`
#### List deliveries — `GET /delivery` #### List deliveries — `GET /delivery`
_none_ _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` #### 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` - Returns: `201 T3`
#### Delete delivery — `DELETE /delivery/{id}` #### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: int` - Params: `id: int`
@@ -281,17 +287,17 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `200 T3` - Returns: `200 T3`
#### Update delivery — `PATCH /delivery/{id}` #### 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` - Returns: `200 T3`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}` #### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool` - Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str` - Returns: `200 str`
#### List entities — `GET /entity` #### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str` - Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T19[] }` - Returns: `200 { entities: T26[] }`
#### Create entity — `POST /entity` #### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str, number?: str }` - Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T14` - Returns: `201 T18`
#### Peek next customer number — `GET /entity/next-number` #### Peek next customer number — `GET /entity/next-number`
_none_ _none_
- Returns: `200 { number: str }` - Returns: `200 { number: str }`
@@ -299,65 +305,65 @@ _none_
- Params: `entityId: int` - Params: `entityId: int`
- Returns: `200 { addresses: T12[] }` - Returns: `200 { addresses: T12[] }`
#### Create entity address — `POST /entity/{entityId}/address` #### 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 }` - Returns: `201 { address: T12 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}` #### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int` - Params: `entityId: int, id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}` #### 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 }` - Returns: `200 { address: T12 }`
#### List entity contacts — `GET /entity/{entityId}/contact` #### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int` - Params: `entityId: int`
- Returns: `200 { contacts: T27[] }` - Returns: `200 { contacts: T33[] }`
#### Create entity contact — `POST /entity/{entityId}/contact` #### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }` - Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T27 }` - Returns: `201 { contact: T33 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}` #### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int` - Params: `entityId: int, id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}` #### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }` - Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T27 }` - Returns: `200 { contact: T33 }`
#### Delete entity — `DELETE /entity/{id}` #### Delete entity — `DELETE /entity/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get entity — `GET /entity/{id}` #### Get entity — `GET /entity/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T14` - Returns: `200 T18`
#### Update entity — `PATCH /entity/{id}` #### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str, number?: str }` - Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T14` - Returns: `200 T18`
#### List label templates — `GET /labeltemplate` #### List label templates — `GET /labeltemplate`
_none_ _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` #### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: obj }` - Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T16 }`
- Returns: `201 T16` - Returns: `201 T5`
#### Delete label template — `DELETE /labeltemplate/{id}` #### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}` #### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T16` - Returns: `200 T5`
#### Update label template (partial) — `PATCH /labeltemplate/{id}` #### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T16 }`
- Returns: `200 T16` - Returns: `200 T5`
#### Update label template — `PUT /labeltemplate/{id}` #### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T16 }`
- Returns: `200 T16` - Returns: `200 T5`
#### Query ledger by manifest article ID or SKU — `GET /ledger` #### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str` - Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T10` - Returns: `200 T13`
#### Add ledger entry — `POST /ledger` #### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }` - 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}` #### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str` - Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T10` - Returns: `200 T13`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}` #### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str` - Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T10` - Returns: `200 T13`
#### List number sequences — `GET /number` #### List number sequences — `GET /number`
_none_ _none_
- Returns: `200 { numbers: { id: int, type: str, prefix: str, padding: int, next: int }[] }` - 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 } }` - Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
#### List orders — `GET /order` #### List orders — `GET /order`
_none_ _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` #### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T21[], entityId?: int }` - Params: `body: { number: str, date: str, lines: T24[], entityId?: int|null }`
- Returns: `201 T5` - Returns: `201 T7`
#### Delete order — `DELETE /order/{id}` #### Delete order — `DELETE /order/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get order — `GET /order/{id}` #### Get order — `GET /order/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T5` - Returns: `200 T7`
#### Update order — `PATCH /order/{id}` #### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T21[] }` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T24[] }`
- Returns: `200 T5` - Returns: `200 T7`
#### List printers — `GET /printer` #### List printers — `GET /printer`
_none_ _none_
- Returns: `200 { printers: T20[] }` - Returns: `200 { printers: T22[] }`
#### Create printer — `POST /printer` #### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str, serial?: str, sort?: int, desc?: str }` - Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T15` - Returns: `201 T17`
#### Reorder printers — `PUT /printer/order` #### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }` - Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }` - Returns: `200 { printerIds: int[] }`
@@ -393,82 +399,79 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}` #### Get printer by ID — `GET /printer/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T15` - Returns: `200 T17`
#### Update printer (partial) — `PATCH /printer/{id}` #### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }` - Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T15` - Returns: `200 T17`
#### Update printer — `PUT /printer/{id}` #### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }` - Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T15` - Returns: `200 T17`
#### List property names — `GET /propertyname` #### List property names — `GET /propertyname`
_none_ _none_
- Returns: `200 { propertynames: T31[] }` - Returns: `200 { propertynames: T37[] }`
#### Create property name — `POST /propertyname` #### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }` - Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T31 }` - Returns: `201 { propertyname: T37 }`
#### Reorder property names — `PUT /propertyname/order` #### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }` - Params: `body: { propertynameIds: int[] }`
- Returns: `200` - Returns: `200 { ok: bool }`
#### Delete property name — `DELETE /propertyname/{id}` #### Delete property name — `DELETE /propertyname/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200` - Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}` #### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T31 }` - Returns: `200 { propertyname: T37 }`
#### List quotes — `GET /quote` #### List quotes — `GET /quote`
_none_ _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` #### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T21[], entityId?: int }` - Params: `body: { number: str, date: str, lines: T24[], entityId?: int|null }`
- Returns: `201 T6` - Returns: `201 T8`
#### Delete quote — `DELETE /quote/{id}` #### Delete quote — `DELETE /quote/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get quote — `GET /quote/{id}` #### Get quote — `GET /quote/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T6` - Returns: `200 T8`
#### Update quote — `PATCH /quote/{id}` #### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T21[] }` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T24[] }`
- Returns: `200 T6` - Returns: `200 T8`
#### Adjust stock manually — `POST /stock/adjust` #### 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 }[] }` - 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: obj[], ledger: T11[] }` - 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` #### List suppliers — `GET /supplier`
_none_ _none_
- Returns: `200 { suppliers: T29[] }` - Returns: `200 { suppliers: T35[] }`
#### Create supplier — `POST /supplier` #### Create supplier — `POST /supplier`
- Params: `body: { name: str }` - Params: `body: { name: str }`
- Returns: `201 { supplier: T29 }` - Returns: `201 { supplier: T35 }`
#### Delete supplier — `DELETE /supplier/{id}` #### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}` #### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }` - Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T29 }` - Returns: `200 { supplier: T35 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles` #### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string` - 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` #### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }` - 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` #### Delete unit — `DELETE /unit`
- Params: `query: si: str` - Params: `query: si: str`
- Returns: `204 void` - Returns: `204 void`
#### List units — `GET /unit` #### List units — `GET /unit`
_none_ _none_
- Returns: `200 { units: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }[] }` - Returns: `200 { units: T30[] }`
#### Upsert unit — `PUT /unit` #### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` - 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: T30 }`
#### Get a virtual filesystem file — `GET /vfs/{*path}`
- Params: `path: str, query: download?: bool, raw?: bool`
- Returns: `200 str`
#### List warehouses — `GET /warehouse` #### List warehouses — `GET /warehouse`
_none_ _none_
- Returns: `200 { warehouses: T34[] }` - Returns: `200 { warehouses: T40[] }`
#### Create warehouse — `POST /warehouse` #### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }` - Params: `body: { name: str, sort?: int }`
- Returns: `201 T32` - Returns: `201 T38`
#### Reorder warehouses — `PUT /warehouse/order` #### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }` - Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }` - Returns: `200 { warehouseIds: int[] }`
@@ -477,13 +480,13 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}` #### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T32` - Returns: `200 T38`
#### Update warehouse (partial) — `PATCH /warehouse/{id}` #### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }` - Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T32` - Returns: `200 T38`
#### Update warehouse — `PUT /warehouse/{id}` #### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }` - Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T32` - Returns: `200 T38`
### Binary & streaming routes (reachable only via `fetch()` in javascript_execute) ### 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: 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 ```json
{ {
"items": [ "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 "track_stock": true
}, },
"children": [ "children": [
{} {
"id": 1,
"sku": "…",
"name": "…",
"parentsort": 1,
"attributeValueId": 1,
"attributeValue": "…"
}
] ]
} }
``` ```

View File

@@ -44,7 +44,15 @@ Updated options list
```json ```json
{ {
"options": [ "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 Print job dispatched
```json
{
"success": true
}
```
## Errors ## Errors
| Status | Description | | Status | Description |

View File

@@ -17,6 +17,12 @@ Removes a single property from an article.
Deleted successfully Deleted successfully
```json
{
"ok": true
}
```
--- ---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ _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, "track_stock": true,
"unitamount": 1, "unitamount": 1,
"weight": 1, "weight": 1,
"unit": {}, "unit": {
"brand": {}, "si": "…",
"basesi": "…",
"basesifactor": 1,
"symbol": "…",
"name": "…"
},
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
},
"categoryIds": [ "categoryIds": [
1 1
], ],

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
Protected. Requires a valid session cookie (`credentials: 'include'`). 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). 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. 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 | | Field | Required | Type | Description |
|-------|----------|------|-------------| |-------|----------|------|-------------|
| `brandId` | yes | integer | | | `brandId` | yes | integer | Brand ID to assign, or null to remove the brand |
```json ```json
{ {
@@ -44,8 +44,21 @@ Updated article detail
"track_stock": true, "track_stock": true,
"unitamount": 1, "unitamount": 1,
"weight": 1, "weight": 1,
"unit": {}, "unit": {
"brand": {}, "si": "…",
"basesi": "…",
"basesifactor": 1,
"symbol": "…",
"name": "…"
},
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
},
"categoryIds": [ "categoryIds": [
1 1
], ],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,12 @@ Updates the display sort order of values belonging to an attribute.
Reordered successfully Reordered successfully
```json
{
"ok": true
}
```
--- ---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ _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 Deleted successfully
```json
{
"ok": true
}
```
## Errors ## Errors
| Status | Description | | Status | Description |

View File

@@ -24,6 +24,12 @@ Updates the display sort order of attribute names for the selected company.
Reordered successfully Reordered successfully
```json
{
"ok": true
}
```
--- ---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ _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 Deleted successfully
```json
{
"ok": true
}
```
## Errors ## Errors
| Status | Description | | Status | Description |

View File

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

View File

@@ -30,11 +30,20 @@ Articles list
"optionsCount": 1, "optionsCount": 1,
"articleListCount": 1, "articleListCount": 1,
"childCount": 1, "childCount": 1,
"parentArticleId": 1,
"parentSku": "…",
"fullHash": "…", "fullHash": "…",
"thumbW": 1, "thumbW": 1,
"thumbH": 1, "thumbH": 1,
"thumbHash": "…", "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, "languageId": 1,
"tree": [ "tree": [
{} {
"id": 1,
"parentId": 1,
"sort": 1,
"name": "…",
"slug": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"posterminalIds": [
1
]
}
], ],
"articles": [ "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 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`). 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 | | 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` ## Response `200`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ Auth via article category access. Browser-cacheable (`private`, `immutable`, `ET
| Param | Required | Type | Description | | 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` ## Response `200`

View File

@@ -4,42 +4,70 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Get a virtual filesystem file Get a virtual filesystem file
Returns a file the model produced in the `javascript_execute` sandbox Returns a file from the virtual filesystem for the authenticated user's selected company.
(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: ### URL Format
`![title](/api/vfs/example.png)`. 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 ### Image Previews & Thumbnails
`download=1` or `raw=1`. Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`)
Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88):
inline so they open directly in the browser; other files are sent as downloads. - 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 ## Path parameters
| Param | Required | Type | Description | | 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 ## Query parameters
| Param | Required | Type | Description | | Param | Required | Type | Description |
|-------|----------|------|-------------| |-------|----------|------|-------------|
| `download` | no | boolean | Force file download with Content-Disposition: attachment. | `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). |
| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail.
|
## Response `200` ## 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 ## Errors
| Status | Description | | 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":"…"}` | | 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` |
| 404 | File not found — `{"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/')) { if (apiPath === '/public' || apiPath.startsWith('/public/')) {
return true; return true;
} }
if (apiPath === '/vfs' || apiPath.startsWith('/vfs/')) {
return true;
}
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`); return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
} }
@@ -528,7 +531,7 @@ function summarizeSchema(
skipRootAlias = false, skipRootAlias = false,
) { ) {
if (!schema) return ''; if (!schema) return '';
if (depth > 6) return aliases?.compactTypes ? 'obj' : 'object'; if (depth > 6) return aliases?.compactTypes ? '{}' : 'object';
const resolved = resolveSchema(schema, components); const resolved = resolveSchema(schema, components);
if (!resolved) return schemaRefName(schema) || ''; if (!resolved) return schemaRefName(schema) || '';
@@ -544,12 +547,19 @@ function summarizeSchema(
const alias = shape && aliases.get(`${markOptional ? 1 : 0}\0${shape}`); const alias = shape && aliases.get(`${markOptional ? 1 : 0}\0${shape}`);
if (alias && !skipRootAlias) return alias; if (alias && !skipRootAlias) return alias;
const hasExplicitRequired = Array.isArray(resolved.required);
const required = new Set(resolved.required || []); const required = new Set(resolved.required || []);
let entries = Object.entries(resolved.properties || {}); 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). // Required props first, then optional ones (stable within each group).
if (markOptional) { if (markOptional || hasExplicitRequired) {
entries = entries entries = entries
.map(([name], i) => ({ name, i })) .map(([name], i) => ({ name, i }))
.sort((a, b) => (required.has(a.name) ? 0 : 1) - (required.has(b.name) ? 0 : 1) || a.i - b.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 parts = entries.map(([name, prop]) => {
const opt = markOptional && !required.has(name) ? '?' : ''; const isOpt = markOptional ? !required.has(name) : (hasExplicitRequired && !required.has(name));
const type = summarizeSchema(prop, components, depth + 1, markOptional, aliases) || 'any'; 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 `${name}${opt}: ${type}`;
}); });
return `{ ${parts.join(', ')} }`; return `{ ${parts.join(', ')} }`;
} }
if (resolved.type) { if (resolved.type) {
let t = resolved.type;
if (aliases?.compactTypes) { if (aliases?.compactTypes) {
return { t = {
string: 'str', string: 'str',
integer: 'int', integer: 'int',
number: 'num', number: 'num',
boolean: 'bool', boolean: 'bool',
object: 'obj', object: '{}',
}[resolved.type] || resolved.type; }[resolved.type] || resolved.type;
} }
return resolved.type; if (resolved.nullable && !t.includes('|null')) {
t = `${t}|null`;
}
return t;
} }
return schemaRefName(schema) || ''; return schemaRefName(schema) || '';
} }
@@ -738,7 +756,7 @@ function renderRoutesOverview(operations, title = 'Dash API Routes Overview') {
lines.push(''); lines.push('');
lines.push('### Notation'); lines.push('### Notation');
lines.push(''); 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.'); 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) { if (definitions.length) {
lines.push(''); lines.push('');

View File

@@ -50,6 +50,13 @@
* type: number * type: number
* reducedtaxrate: * reducedtaxrate:
* type: number * type: number
* SuccessResponse:
* type: object
* required: [ok]
* properties:
* ok:
* type: boolean
* example: true
* CompanySetting: * CompanySetting:
* type: object * type: object
* required: [key, value] * required: [key, value]
@@ -73,14 +80,18 @@
* type: string * type: string
* Unit: * Unit:
* type: object * type: object
* description: Measurement unit with conversion factor to base SI unit (e.g. g -> kg, ml -> l)
* required: [si, basesi, basesifactor] * required: [si, basesi, basesifactor]
* properties: * properties:
* si: * si:
* type: string * type: string
* description: Unit code identifier (e.g. "g", "kg", "ml", "l", "stk")
* basesi: * basesi:
* type: string * type: string
* description: Base SI reference unit (e.g. "kg" for mass, "l" or "m3" for volume, "stk" for counts)
* basesifactor: * basesifactor:
* type: number * type: number
* description: Conversion factor to base SI (1 si = basesifactor * basesi; e.g. 0.001 for g -> kg)
* symbol: * symbol:
* type: string * type: string
* nullable: true * nullable: true
@@ -130,13 +141,15 @@
* unitamount: * unitamount:
* type: number * type: number
* nullable: true * nullable: true
* description: Net package content quantity measured in article unit (e.g. 500 for 500g, 0.75 for 0.75l)
* weight: * weight:
* type: number * type: number
* nullable: true * nullable: true
* description: Shipping weight in kg (must be >= 0)
* unit: * unit:
* type: object * $ref: '#/components/schemas/Unit'
* brand: * brand:
* type: object * $ref: '#/components/schemas/ArticleBrand'
* nullable: true * nullable: true
* categoryIds: * categoryIds:
* type: array * type: array
@@ -354,6 +367,157 @@
* description: Brand pictures sorted by sort * description: Brand pictures sorted by sort
* items: * items:
* $ref: '#/components/schemas/BrandPic' * $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: * NumberSequence:
* type: object * type: object
* required: [id, type, prefix, padding, next] * required: [id, type, prefix, padding, next]
@@ -1012,6 +1176,60 @@
* type: array * type: array
* items: * items:
* type: integer * 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: * LabelTemplate:
* type: object * type: object
* required: [id, name, width, height, dpi, type, content] * required: [id, name, width, height, dpi, type, content]
@@ -1029,7 +1247,7 @@
* type: * type:
* type: string * type: string
* content: * content:
* type: object * $ref: '#/components/schemas/LabelContent'
* thumb: * thumb:
* type: string * type: string
* nullable: true * nullable: true
@@ -1048,7 +1266,7 @@
* type: * type:
* type: string * type: string
* content: * content:
* type: object * $ref: '#/components/schemas/LabelContent'
* LabelTemplateUpdateInput: * LabelTemplateUpdateInput:
* type: object * type: object
* properties: * properties:
@@ -1063,7 +1281,7 @@
* type: * type:
* type: string * type: string
* content: * content:
* type: object * $ref: '#/components/schemas/LabelContent'
* Warehouse: * Warehouse:
* type: object * type: object
* required: [id, name, sort] * required: [id, name, sort]
@@ -1151,6 +1369,20 @@
* type: array * type: array
* items: * items:
* type: object * 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: * ledger:
* type: array * type: array
* items: * items:
@@ -1271,6 +1503,36 @@
* type: string * type: string
* sort: * sort:
* type: integer * 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 {}; export {};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,13 +74,11 @@ export function register(app, basePath = '') {
* tree: * tree:
* type: array * type: array
* items: * items:
* type: object * $ref: '#/components/schemas/CategoryTreeNode'
* additionalProperties: true
* articles: * articles:
* type: array * type: array
* items: * items:
* type: object * $ref: '#/components/schemas/CategoryArticle'
* additionalProperties: true
* 400: * 400:
* description: No company selected * description: No company selected
* content: * content:
@@ -326,48 +324,7 @@ export function register(app, basePath = '') {
* articles: * articles:
* type: array * type: array
* items: * items:
* type: object * $ref: '#/components/schemas/CategoryArticle'
* 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
* 400: * 400:
* description: Invalid id * description: Invalid id
* content: * content:

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,8 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import multer from 'multer'; import multer from 'multer';
import { emit } from '../sse.js'; import { emit } from '../sse.js';
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath } from '../../tools/vfs.js'; import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js';
import * as db from '../../db/modelvfs.js';
import sharp from 'sharp'; 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 // 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. // stretched, so a 2000x100 image becomes 250x13 and not 250x250.
@@ -379,61 +377,32 @@ export function register(app, basePath = '') {
/** /**
* @openapi * @openapi
* /vfs/{*path}: * /vfs:
* get: * get:
* tags: [Vfs] * tags: [Vfs]
* summary: Get a virtual filesystem file * summary: List virtual filesystem files
* description: | * description: |
* Returns a file the model produced in the `javascript_execute` sandbox * Lists all virtual filesystem files for the authenticated user's selected company.
* (virtual filesystem), 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.
* 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.
* security: * security:
* - cookieAuth: [] * - cookieAuth: []
* parameters: * x-markdown: vfs/list.md
* - 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.
* responses: * responses:
* 200: * 200:
* description: File bytes * description: List of virtual filesystem files
* headers:
* Content-Disposition:
* schema:
* type: string
* Cache-Control:
* schema:
* type: string
* content: * content:
* application/octet-stream: * application/json:
* schema: * schema:
* type: string * type: object
* format: binary * required: [files]
* properties:
* files:
* type: array
* items:
* $ref: '#/components/schemas/VfsFile'
* 400: * 400:
* description: Invalid path, or no company selected * description: No company selected
* content: * content:
* application/json: * application/json:
* schema: * schema:
@@ -450,14 +419,7 @@ export function register(app, basePath = '') {
* application/json: * application/json:
* schema: * schema:
* $ref: '#/components/schemas/Error' * $ref: '#/components/schemas/Error'
* 404:
* description: File not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.get(`${basePath}/vfs`, async (req, res) => { app.get(`${basePath}/vfs`, async (req, res) => {
const selected = requireSelectedCompany(req); const selected = requireSelectedCompany(req);
if (selected.error) { 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) => { app.post(`${basePath}/vfs/upload`, vfsUpload.array('files'), async (req, res) => {
const selected = requireSelectedCompany(req); const selected = requireSelectedCompany(req);
if (selected.error) { if (selected.error) {
@@ -531,6 +572,7 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsed.name)) continue; if (isReadOnlyDatabasePath(parsed.name)) continue;
const absPath = path.resolve(vfsRoot, parsed.name); const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) continue;
fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, f.buffer); fs.writeFileSync(absPath, f.buffer);
@@ -538,12 +580,6 @@ export function register(app, basePath = '') {
? f.mimetype ? f.mimetype
: getMimeType(parsed.name); : getMimeType(parsed.name);
await db.write(req.user.id, {
name: parsed.name,
mime: effectiveMime,
content: f.buffer,
});
uploaded.push({ uploaded.push({
name: parsed.name, name: parsed.name,
size: f.buffer.length, 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) => { app.put(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req); const selected = requireSelectedCompany(req);
if (selected.error) { if (selected.error) {
@@ -593,15 +928,12 @@ export function register(app, basePath = '') {
try { try {
const vfsRoot = await resolveVfsRoot(req.user.id); const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name); 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.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, buffer); fs.writeFileSync(absPath, buffer);
await db.write(req.user.id, {
name: parsed.name,
mime,
content: buffer,
});
emit('vfs', { companyId: selected.companyId }); emit('vfs', { companyId: selected.companyId });
return res.json({ return res.json({
success: true, 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) => { app.delete(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req); const selected = requireSelectedCompany(req);
if (selected.error) { if (selected.error) {
@@ -636,10 +1021,18 @@ export function register(app, basePath = '') {
try { try {
const vfsRoot = await resolveVfsRoot(req.user.id); const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name); const absPath = path.resolve(vfsRoot, parsed.name);
if (fs.existsSync(absPath)) { if (!absPath.startsWith(vfsRoot)) {
try { fs.unlinkSync(absPath); } catch {} 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 }); emit('vfs', { companyId: selected.companyId });
return res.json({ success: true, deleted: parsed.name }); 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) => { app.get(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req); const selected = requireSelectedCompany(req);
if (selected.error) { if (selected.error) {
@@ -663,15 +1181,20 @@ export function register(app, basePath = '') {
} }
try { try {
const file = await db.read(req.user.id, parsed.name); const vfsRoot = await resolveVfsRoot(req.user.id);
if (!file?.content?.length) { const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot) || !fs.existsSync(absPath)) {
return res.status(404).json({ error: 'File not found' }); 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 fallbackName = parsed.name.split('/').pop() || 'download';
const effectiveMime = (file.mime && file.mime !== 'application/octet-stream') const effectiveMime = getMimeType(parsed.name);
? file.mime
: getMimeType(parsed.name);
const wantsDownload = req.query.download === '1' || req.query.download === 'true'; const wantsDownload = req.query.download === '1' || req.query.download === 'true';
const wantsRaw = req.query.raw === '1' || req.query.raw === '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). // unless the caller explicitly asks for the raw file (?raw=1) or a download (?download=1).
const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw; const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw;
if (shouldThumbnail) { if (shouldThumbnail) {
const rendered = await renderThumbnail(file.content, { const rendered = await renderThumbnail(content, {
mime: effectiveMime, mime: effectiveMime,
filename: fallbackName, filename: fallbackName,
}).catch((err) => { }).catch((err) => {
@@ -696,7 +1219,7 @@ export function register(app, basePath = '') {
res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`); res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`);
// Previews are derived data — never cache them immutably, the source file can change. // 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('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']; const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) { if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
return res.status(304).end(); return res.status(304).end();
@@ -707,11 +1230,11 @@ export function register(app, basePath = '') {
} }
res.setHeader('Content-Type', effectiveMime); 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'); res.setHeader('X-Content-Type-Options', 'nosniff');
// Mutable VFS artifacts are cached with revalidation // Mutable VFS artifacts are cached with revalidation
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); 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( res.setHeader(
'Content-Disposition', 'Content-Disposition',
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"` `${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"`
@@ -722,7 +1245,7 @@ export function register(app, basePath = '') {
return res.status(304).end(); return res.status(304).end();
} }
return res.status(200).send(file.content); return res.status(200).send(content);
} catch (error) { } catch (error) {
const status = vfsFileErrorStatus(error); const status = vfsFileErrorStatus(error);
if (status === 500) console.error('Get vfs file failed:', 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 { createUrlGetTool } from '../tools/url-get.js';
import { createPdfReadTool } from '../tools/pdf-read.js'; import { createPdfReadTool } from '../tools/pdf-read.js';
import { createSearchArticlesTool } from '../tools/search-articles.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'; import { resolveOpenRouterKey, resolveDefaultModel, resolveModelReasoning, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js';
const KEEP_ALIVE_MS = 20_000; 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).', '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.', '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.', '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:', 'VIRTUAL FILESYSTEM:',
'- Persistent per-company VFS. Paths are root-relative forward-slash (e.g. "labels/sheet1.png").', '- 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/…").', '- `image/` is READ-ONLY database storage. Write generated images elsewhere (e.g. "images/…").',
'', '',
'OPERATING GUIDELINES:', '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.', '- 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.', '- LABEL TEMPLATES (/labeltemplate): Must use Dashback JSON schema `{ "objects": [ ... ] }`. Do NOT use ZPL.',
'- Always Format output with markdown and emoji.', '- Always Format output with markdown and emoji.',
@@ -531,16 +535,6 @@ export class ConversationManager {
} catch (err) { } catch (err) {
console.error(`[ConversationManager ${this.conversationId}] failed to write attachment disk file:`, 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); const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val);
@@ -878,6 +872,8 @@ export class ConversationManager {
createResetVfsTool({ googleAuthId }), createResetVfsTool({ googleAuthId }),
createApiSuggestionsOrIssuesTool({ googleAuthId, companyId: this.companyId }), createApiSuggestionsOrIssuesTool({ googleAuthId, companyId: this.companyId }),
createSearchArticlesTool({ 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 jwt from 'jsonwebtoken';
import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter'; import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter';
import env from '../config/env.js'; 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'; 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. * Fetches a database image by its content hash using store or authenticated local endpoint.
* @param {string} hash 64-char SHA-256 hex * @param {string} hash 8-char SHA-256 Base62
* @param {number} googleAuthId * @param {number} googleAuthId
* @returns {Promise<Buffer>} * @returns {Promise<Buffer>}
*/ */
async function fetchDatabaseImage(hash, googleAuthId) { async function fetchDatabaseImage(hash, googleAuthId) {
const url = `http://127.0.0.1:${env.port}/image/${hash}`; try {
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' }); const image = await getImageByHash(googleAuthId, hash);
const res = await fetch(url, { if (image?.buffer) {
headers: { Cookie: `token=${token}` }, return image.buffer;
signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS), }
}); } catch (dbErr) {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); try {
return Buffer.from(await res.arrayBuffer()); 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, additionalProperties: false,
required: ['paths'], required: ['paths'],
properties: { 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: { paths: {
type: 'array', type: 'array',
description: '1–9 VFS paths or DB paths ("image/<hash>").', description: '1–9 VFS paths or DB paths ("image/<hash>").',
@@ -277,17 +296,17 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
maxItems: MAX_IMAGES, maxItems: MAX_IMAGES,
}, },
prompt: { prompt: {
type: 'string', type: ['string', 'null'],
description: 'Inspection criteria or question.', description: 'Inspection criteria or question, or null.',
}, },
grid: { grid: {
type: 'string', type: ['string', 'null'],
enum: Object.keys(GRID_CONFIGS), enum: [...Object.keys(GRID_CONFIGS), null],
description: 'Grid layout. Omit for auto.', description: 'Grid layout or null for auto.',
}, },
}, },
}, },
invoke: async ({ paths, prompt, grid } = {}) => { invoke: async ({ paths, prompt, grid, note } = {}) => {
const inputs = extractImageInputs({ paths }); const inputs = extractImageInputs({ paths });
if (inputs.length === 0) { if (inputs.length === 0) {
throw new ToolError( throw new ToolError(
@@ -304,7 +323,7 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
// ── Load the bytes ──────────────────────────────────────────── // ── Load the bytes ────────────────────────────────────────────
const items = []; const items = [];
for (const item of inputs) { 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 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. // a DB lookup or `images/candidates/x.avif` would be misread as a hash path.
const hash = extractFullHash(item.path); const hash = extractFullHash(item.path);
@@ -325,6 +344,10 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
buf = fs.readFileSync(cached); buf = fs.readFileSync(cached);
} else { } else {
buf = await fetchDatabaseImage(hash, googleAuthId); 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( throw new ToolError(
`Image not found or empty: "${item.path}". ` + `Image not found or empty: "${item.path}". ` +
(hash (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.') : '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.', description: 'Issue or request details.',
}, },
type: { type: {
type: 'string', type: ['string', 'null'],
enum: ['missing_api', 'bug', 'suggestion', 'issue', 'other'], enum: ['missing_api', 'bug', 'suggestion', 'issue', 'other', null],
description: 'Report category.', description: 'Report category or null.',
}, },
title: { title: {
type: 'string', type: ['string', 'null'],
description: 'Short summary.', description: 'Short summary or null.',
}, },
}, },
}, },

View File

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

View File

@@ -21,13 +21,17 @@ export function createDashbackRequestTool({ googleAuthId }) {
additionalProperties: false, additionalProperties: false,
required: ['method', 'path'], required: ['method', 'path'],
properties: { 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.' }, method: { type: 'string', enum: METHODS, description: 'HTTP method.' },
path: { type: 'string', description: 'Endpoint path (e.g. "/article/2").' }, path: { type: 'string', description: 'Endpoint path (e.g. "/article/2").' },
query: { type: 'object', description: 'Query parameters object.' }, query: { type: ['object', 'null'], description: 'Query parameters object or null.' },
body: { type: 'string', description: 'JSON request payload string.' }, 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 (!METHODS.includes(method)) throw new Error(`Unsupported method: ${method}`);
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) { if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) {
throw new Error('path must start with a single "/"'); 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 jwt from 'jsonwebtoken';
import { defineTool, ToolError } from 'libllmorbiter'; import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js'; import env from '../config/env.js';
import { getImageByHash } from '../db/pic.js';
import { prepareVfs, syncVfsToDb, extractFullHash, isReadOnlyDatabasePath } from './vfs.js'; import { prepareVfs, syncVfsToDb, extractFullHash, isReadOnlyDatabasePath } from './vfs.js';
import { resolveOpenRouterKey, resolveImageModel } from './llm-key.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. * - attachments: array of { vfsfilename, high: bool } reference images.
* - size: resolution tier — 3 choices ('1K', '2K', '4K'). * - size: resolution tier — 3 choices ('1K', '2K', '4K').
* - ratio: aspect ratio — 3 choices ('1:1', '4:3', '3:4'). * - 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_SIZE = '1K';
const DEFAULT_RATIO = '1:1'; const DEFAULT_RATIO = '1:1';
const AVIF_QUALITY = 80;
const IMAGE_FETCH_TIMEOUT_MS = 10_000; const IMAGE_FETCH_TIMEOUT_MS = 10_000;
const GENERATE_TIMEOUT_MS = 120_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 {string} hash
* @param {number} googleAuthId * @param {number} googleAuthId
* @returns {Promise<Buffer>} * @returns {Promise<Buffer>}
*/ */
async function fetchDatabaseImage(hash, googleAuthId) { async function fetchDatabaseImage(hash, googleAuthId) {
const url = `http://127.0.0.1:${env.port}/image/${hash}`; try {
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' }); const image = await getImageByHash(googleAuthId, hash);
const res = await fetch(url, { if (image?.buffer) {
headers: { Cookie: `token=${token}` }, return image.buffer;
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS), }
}); } catch (dbErr) {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); try {
return Buffer.from(await res.arrayBuffer()); 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)) { if (fs.existsSync(cached)) {
return fs.readFileSync(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(/^\.\/+/, ''); const clean = targetPath.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
@@ -233,20 +254,24 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
description: description:
'Generate or edit images using the configured company image model (modelslugimage). ' + 'Generate or edit images using the configured company image model (modelslugimage). ' +
'Supports reference image attachments fed in low or high quality, output size tier, ' + '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, strict: false,
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['prompt'], required: ['prompt'],
properties: { 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: { prompt: {
type: 'string', type: 'string',
description: 'Description of the image to generate, or edit/transformation instructions when attachments are provided.', description: 'Description of the image to generate, or edit/transformation instructions when attachments are provided.',
}, },
attachments: { attachments: {
type: 'array', type: ['array', 'null'],
description: 'Optional reference image(s) for editing/image-to-image. Omit for text-to-image generation.', description: 'Optional reference image(s) for editing/image-to-image or null. Omit for text-to-image generation.',
items: { items: {
type: 'object', type: 'object',
additionalProperties: false, 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>").', description: 'VFS path (e.g. "images/sample.png") or database path ("image/<hash>").',
}, },
high: { high: {
type: 'boolean', type: ['boolean', 'null'],
description: 'Feed image in high quality/resolution if true; low quality/resolution if false (default).', description: 'Feed image in high quality/resolution if true; low quality/resolution if false or null (default).',
}, },
}, },
}, },
}, },
size: { size: {
type: 'string', type: ['string', 'null'],
enum: VALID_SIZES, enum: [...VALID_SIZES, null],
description: 'Resolution tier (3 choices): "1K" (standard, default), "2K" (high quality), "4K" (ultra high).', description: 'Resolution tier (3 choices): "1K" (standard, default), "2K" (high quality), "4K" (ultra high), or null.',
}, },
ratio: { ratio: {
type: 'string', type: ['string', 'null'],
enum: VALID_RATIOS, enum: [...VALID_RATIOS, null],
description: 'Aspect ratio (3 choices): "1:1" (square, default), "4:3" (landscape), "3:4" (portrait).', description: 'Aspect ratio (3 choices): "1:1" (square, default), "4:3" (landscape), "3:4" (portrait), or null.',
}, },
vfsoutputfilename: { vfsoutputfilename: {
type: 'string', type: ['string', 'null'],
description: 'VFS path where the output image should be saved (e.g. "images/edited.png"). Defaults to "images/generated-<timestamp>.png".', 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() : ''; const cleanPrompt = typeof prompt === 'string' ? prompt.trim() : '';
if (!cleanPrompt) { if (!cleanPrompt) {
throw new ToolError('edit_image requires a non-empty "prompt" describing the image or edit instructions.'); 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.'); 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() let targetRel = typeof vfsoutputfilename === 'string' && vfsoutputfilename.trim()
? vfsoutputfilename.trim() ? vfsoutputfilename.trim()
: ''; : '';
if (!targetRel) { if (!targetRel) {
const prefix = inputReferences.length > 0 ? 'edited' : 'generated'; 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)) { if (isReadOnlyDatabasePath(cleanRel)) {
throw new ToolError( throw new ToolError(
`Access denied: "${targetRel}" matches read-only database image storage ("image/<hash>"). ` + `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.`); throw new ToolError(`Access denied: path "${targetRel}" escapes virtual filesystem boundary.`);
} }
// ── Format conversion if requested by output extension ──────── // ── Convert output to AVIF format ─────────────────────────────
let ext = path.extname(cleanRel).toLowerCase(); let finalBuffer;
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}`;
}
try { try {
if (ext === '.avif') { finalBuffer = await sharp(resultBuffer)
finalBuffer = await sharp(resultBuffer).avif({ quality: 80 }).toBuffer(); .rotate()
mimeType = 'image/avif'; .avif({ quality: AVIF_QUALITY })
} else if (ext === '.webp') { .toBuffer();
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';
}
} catch (convErr) { } catch (convErr) {
console.warn('[edit_image] sharp format conversion warning:', convErr.message); throw new ToolError(`edit_image: failed to encode output image to AVIF: ${convErr.message}`);
finalBuffer = resultBuffer;
} }
const mimeType = 'image/avif';
// ── Write to VFS disk and sync to database ──────────────────── // ── Write to VFS disk and sync to database ────────────────────
fs.mkdirSync(path.dirname(absTarget), { recursive: true }); fs.mkdirSync(path.dirname(absTarget), { recursive: true });

View File

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

View File

@@ -133,21 +133,25 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
additionalProperties: false, additionalProperties: false,
required: ['code'], required: ['code'],
properties: { 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: { code: {
type: 'string', type: 'string',
description: 'JavaScript code. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.', description: 'JavaScript code. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.',
}, },
timeout: { timeout: {
type: 'number', type: ['number', 'null'],
description: `Execution timeout in ms. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`, description: `Execution timeout in ms or null. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
}, },
max_output: { max_output: {
type: 'number', type: ['number', 'null'],
description: `Max stdout chars. Default ${DEFAULT_MAX_OUTPUT}.`, 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()) { if (typeof code !== 'string' || !code.trim()) {
throw new ToolError('"code" must be a non-empty string.'); 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.', description: 'Replacement text.',
}, },
title: { title: {
type: 'string', type: ['string', 'null'],
description: 'Optional line-1 title for SKILL files.', description: 'Optional line-1 title for SKILL files or null.',
}, },
skiplines: { skiplines: {
type: 'integer', type: ['integer', 'null'],
description: 'Lines to skip before searching.', 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.', description: 'VFS path or HTTP(S) URL.',
}, },
format: { format: {
type: 'string', type: ['string', 'null'],
enum: ['txt', 'png', 'both'], enum: ['txt', 'png', 'both', null],
description: 'Output format: "txt" (layout text), "png" (page images), "both". Default "txt".', description: 'Output format: "txt" (layout text), "png" (page images), "both", or null. Default "txt".',
}, },
page: { page: {
type: 'number', type: ['number', 'null'],
description: '1-based page number. Omit for all pages.', description: '1-based page number or null for all pages.',
}, },
dpi: { dpi: {
type: 'number', type: ['number', 'null'],
description: 'PNG render DPI. Default 150.', description: 'PNG render DPI or null. Default 150.',
}, },
}, },
}, },
@@ -76,8 +76,8 @@ export function createPdfReadTool({ googleAuthId }) {
} }
const targetFormat = normalizeFormat(format || type); const targetFormat = normalizeFormat(format || type);
const targetPage = Number.isInteger(Number(page)) && Number(page) >= 1 ? Number(page) : null; const targetPage = page != null && 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 targetDpi = dpi != null && Number.isInteger(Number(dpi)) && Number(dpi) >= 50 && Number(dpi) <= 600 ? Number(dpi) : DEFAULT_DPI;
const vfsRoot = await prepareVfs({ googleAuthId }); const vfsRoot = await prepareVfs({ googleAuthId });
let buffer = null; let buffer = null;

View File

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

View File

@@ -1,7 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { defineTool } from 'libllmorbiter'; import { defineTool } from 'libllmorbiter';
import { getStore } from '../db/store.js';
import { isSkillPath } from './skill.js'; import { isSkillPath } from './skill.js';
import { resolveVfsRoot } from './vfs.js'; import { resolveVfsRoot } from './vfs.js';
@@ -34,31 +33,14 @@ export function createResetVfsTool({ googleAuthId }) {
additionalProperties: false, additionalProperties: false,
properties: { properties: {
include_skills: { include_skills: {
type: 'boolean', type: ['boolean', 'null'],
description: 'Delete SKILL files too. Default false.', description: 'Delete SKILL files too (or null). Default false.',
}, },
}, },
}, },
invoke: async ({ include_skills = false } = {}) => { invoke: async ({ include_skills = false } = {}) => {
const store = await getStore();
const keepSkills = include_skills !== true; const keepSkills = include_skills !== true;
const keptSkills = []; 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); const vfsRoot = await resolveVfsRoot(googleAuthId);
let deletedCount = 0; let deletedCount = 0;
if (fs.existsSync(vfsRoot)) { if (fs.existsSync(vfsRoot)) {

View File

@@ -1,7 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter'; import { defineTool, ToolError } from 'libllmorbiter';
import { getStore } from '../db/store.js';
import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js'; import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js';
import { isSkillPath } from './skill.js'; import { isSkillPath } from './skill.js';
@@ -97,18 +96,12 @@ export function createRmTool({ googleAuthId }) {
const vfsRoot = await prepareVfs({ googleAuthId }); const vfsRoot = await prepareVfs({ googleAuthId });
const { abs, size } = deleteVfsFile(vfsRoot, clean); 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); fs.unlinkSync(abs);
const wasSkill = isSkillPath(clean); const wasSkill = isSkillPath(clean);
const result = { const result = {
success: true, success: true,
deleted: res?.deleted !== false, deleted: true,
filename: clean, filename: clean,
size, size,
message: `Deleted "${clean}" (${size} bytes). This cannot be undone.`, message: `Deleted "${clean}" (${size} bytes). This cannot be undone.`,

View File

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

View File

@@ -144,33 +144,40 @@ export function createSearchImagesTool({ googleAuthId }) {
additionalProperties: false, additionalProperties: false,
required: ['query'], required: ['query'],
properties: { 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: { query: {
type: 'string', type: 'string',
description: 'Search query.', description: 'Search query.',
}, },
count: { count: {
type: 'number', type: ['number', 'null'],
description: `Candidate count. Default ${DEFAULT_COUNT}, max ${MAX_COUNT}.`, description: `Candidate count or null. Default ${DEFAULT_COUNT}, max ${MAX_COUNT}.`,
}, },
gl: { gl: {
type: 'string', type: ['string', 'null'],
description: 'Country code. Default "de".', description: 'Country code or null. Default "de".',
}, },
hl: { hl: {
type: 'string', type: ['string', 'null'],
description: 'Language code. Default "de".', 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() : ''; const term = typeof query === 'string' ? query.trim() : '';
if (!term) { if (!term) {
throw new ToolError('search_images requires a non-empty "query" (e.g. " Mineralwasser Flasche Glas ").'); 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}).`); 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; let apiKey = null;
try { 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) { if (raw.length === 0) {
return { return {
query: term, query: term,

View File

@@ -4,7 +4,7 @@ import { getStore } from '../db/store.js';
import env from '../config/env.js'; import env from '../config/env.js';
import { getDefaultSkills } from './skill.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']); const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
export function getMimeType(filePath) { export function getMimeType(filePath) {
@@ -54,19 +54,27 @@ export function getMimeType(filePath) {
export function extractFullHash(p) { export function extractFullHash(p) {
if (typeof p !== 'string' || !p.trim()) return null; if (typeof p !== 'string' || !p.trim()) return null;
const clean = p.trim().replace(/^([a-zA-Z]:|\/)+/, ''); const clean = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '');
const parts = clean.split('/'); const parts = clean.split('/').filter(Boolean);
if (parts.length === 0) return null;
const first = parts[0].toLowerCase();
const last = parts[parts.length - 1]; const last = parts[parts.length - 1];
const ext = path.extname(last).toLowerCase();
const withoutExt = last.replace(/\.[a-zA-Z0-9]+$/, ''); const withoutExt = last.replace(/\.[a-zA-Z0-9]+$/, '');
if (FULL_HASH_REGEX.test(withoutExt)) {
return withoutExt.toLowerCase(); // Under virtual image folder (e.g. image/aB1cD2eF, images/aB1cD2eF.avif)
} if (VIRTUAL_IMAGE_FOLDERS.has(first)) {
for (const part of parts) { if (parts.length === 2 && FULL_HASH_REGEX.test(withoutExt)) {
const seg = part.replace(/\.[a-zA-Z0-9]+$/, ''); return withoutExt;
if (FULL_HASH_REGEX.test(seg)) {
return seg.toLowerCase();
} }
} }
// Direct hash (e.g. "aB1cD2eF" or "aB1cD2eF.avif")
if (parts.length === 1 && FULL_HASH_REGEX.test(withoutExt) && (!ext || ext === '.avif')) {
return withoutExt;
}
return null; 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 * @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
* @returns {Promise<Array<string>>} List of loaded skill file names * @returns {Promise<Array<string>>} List of loaded skill file names
@@ -140,19 +148,12 @@ export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsR
const loaded = []; const loaded = [];
for (const skill of defaultSkills) { 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 { try {
const diskPath = path.resolve(vfsRoot, skill.name); 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); loaded.push(skill.name);
} catch (err) { } catch (err) {
console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message); 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 * @param {{ googleAuthId: number }} ctx
* @returns {Promise<string>} vfsRoot directory path * @returns {Promise<string>} vfsRoot directory path
@@ -189,86 +190,21 @@ export async function prepareVfs({ googleAuthId }) {
fs.mkdirSync(vfsRoot, { recursive: true }); fs.mkdirSync(vfsRoot, { recursive: true });
try { try {
const files = await store.modelvfs.list(googleAuthId); await loadDefaultSkills({ googleAuthId, store, vfsRoot });
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);
}
}
}
} catch (err) { } catch (err) {
console.warn('[vfs] hydration warning:', err.message); console.warn('[vfs] prepare warning:', err.message);
} }
return vfsRoot; 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 * @param {{ googleAuthId?: number, vfsRoot?: string }} [ctx]
* correct while `vfsRoot` was produced by `prepareVfs`/`resolveVfsRoot` in the same turn.
*
* @param {{ googleAuthId: number, vfsRoot: string }} ctx
*/ */
export async function syncVfsToDb({ googleAuthId, vfsRoot }) { export async function syncVfsToDb({ googleAuthId, vfsRoot } = {}) {
if (!fs.existsSync(vfsRoot)) return; // Purely disk-based VFS; no database syncing.
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);
}
}
} }
/** /**
@@ -342,13 +278,14 @@ export function createVfs(vfsRoot, options = {}) {
readFile: (p, opt, cb) => { readFile: (p, opt, cb) => {
const callback = typeof opt === 'function' ? opt : cb; const callback = typeof opt === 'function' ? opt : cb;
const options = typeof opt === 'function' ? undefined : opt; const options = typeof opt === 'function' ? undefined : opt;
try { vfs.promises.readFile(p, options)
const data = vfs.readFileSync(p, options); .then((data) => {
if (typeof callback === 'function') process.nextTick(callback, null, data); if (typeof callback === 'function') process.nextTick(callback, null, data);
} catch (err) { })
if (typeof callback === 'function') process.nextTick(callback, err); .catch((err) => {
else throw err; if (typeof callback === 'function') process.nextTick(callback, err);
} else throw err;
});
}, },
createReadStream: (p, opt) => { createReadStream: (p, opt) => {
@@ -476,7 +413,34 @@ export function createVfs(vfsRoot, options = {}) {
}, },
promises: { 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), writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt),
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt), appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
stat: async (p, opt) => vfs.statSync(p, opt), stat: async (p, opt) => vfs.statSync(p, opt),

View File

@@ -96,36 +96,40 @@ export function createWebSearchTool({ googleAuthId }) {
additionalProperties: false, additionalProperties: false,
required: ['query'], required: ['query'],
properties: { 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: { query: {
type: 'string', type: 'string',
description: 'Search query.', description: 'Search query.',
}, },
timePeriod: { timePeriod: {
type: 'string', type: ['string', 'null'],
enum: ['day', 'week', 'month', 'year'], enum: ['day', 'week', 'month', 'year', null],
description: 'Time filter: "day", "week", "month", or "year".', description: 'Time filter: "day", "week", "month", or "year", or null.',
}, },
gl: { gl: {
type: 'string', type: ['string', 'null'],
description: 'Country code. Default "de".', description: 'Country code or null. Default "de".',
}, },
hl: { hl: {
type: 'string', type: ['string', 'null'],
description: 'Language code. Default "de".', description: 'Language code or null. Default "de".',
}, },
page: { page: {
type: 'number', type: ['number', 'null'],
description: 'Result page number (10/page). Default 1.', 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() : ''; const term = typeof query === 'string' ? query.trim() : '';
if (!term) { if (!term) {
throw new ToolError('web_search requires a non-empty "query".'); 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) { if (!Number.isInteger(parsedPage) || parsedPage < 1) {
throw new ToolError(`"page" must be a positive integer (received ${page}).`); throw new ToolError(`"page" must be a positive integer (received ${page}).`);
} }