Compare commits
6 Commits
1743cef11c
...
446f465a48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
446f465a48 | ||
|
|
1a236b568a | ||
|
|
85635cc0a5 | ||
|
|
7d537aaa6b | ||
|
|
a29ad50b74 | ||
|
|
857963b1c3 |
264
SANDBOX.md
Normal file
264
SANDBOX.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -42,7 +42,14 @@ Updated Stückliste items
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
{}
|
{
|
||||||
|
"id": 1,
|
||||||
|
"articleId": 1,
|
||||||
|
"sku": "…",
|
||||||
|
"name": "…",
|
||||||
|
"count": 1,
|
||||||
|
"sort": 1
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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": "…"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -44,7 +44,15 @@ Updated options list
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"options": [
|
"options": [
|
||||||
{}
|
{
|
||||||
|
"id": 1,
|
||||||
|
"articleId": 1,
|
||||||
|
"sku": "…",
|
||||||
|
"name": "…",
|
||||||
|
"count": 1,
|
||||||
|
"sort": 1,
|
||||||
|
"mandated": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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`._
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ Sets `articlepic.sort` to `0..n-1` in the given order.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"fullHashes": [
|
"fullHashes": [
|
||||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
"aB1cD2eF",
|
||||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
"gH3iJ4kL"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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":"…"}` |
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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`._
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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`._
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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`._
|
||||||
|
|||||||
@@ -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": "…"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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": "…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ List of label templates
|
|||||||
"height": 1,
|
"height": 1,
|
||||||
"dpi": 1,
|
"dpi": 1,
|
||||||
"type": "…",
|
"type": "…",
|
||||||
"content": {},
|
|
||||||
"thumb": "…"
|
"thumb": "…"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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": "…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
``.
|
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":"…"}` |
|
||||||
|
|||||||
42
docs/api/routes/vfs/delete.md
Normal file
42
docs/api/routes/vfs/delete.md
Normal 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`._
|
||||||
43
docs/api/routes/vfs/list.md
Normal file
43
docs/api/routes/vfs/list.md
Normal 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`._
|
||||||
48
docs/api/routes/vfs/mkdir.md
Normal file
48
docs/api/routes/vfs/mkdir.md
Normal 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`._
|
||||||
52
docs/api/routes/vfs/rename.md
Normal file
52
docs/api/routes/vfs/rename.md
Normal 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`._
|
||||||
53
docs/api/routes/vfs/upload.md
Normal file
53
docs/api/routes/vfs/upload.md
Normal 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`._
|
||||||
66
docs/api/routes/vfs/write.md
Normal file
66
docs/api/routes/vfs/write.md
Normal 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`._
|
||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
|
||||||
* ``.
|
|
||||||
*
|
|
||||||
* 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);
|
||||||
|
|||||||
@@ -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: ``. Other files (PDF, JSON, CSV) do not render inline; provide download links.',
|
'- DISPLAYING GENERATED IMAGES: Inline markdown images supported. Embed written VFS image paths: ``. 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(),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,21 +235,35 @@ 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 image = await getImageByHash(googleAuthId, hash);
|
||||||
|
if (image?.buffer) {
|
||||||
|
return image.buffer;
|
||||||
|
}
|
||||||
|
} catch (dbErr) {
|
||||||
|
try {
|
||||||
|
const url = `http://127.0.0.1:${env.port}/api/image/${hash}`;
|
||||||
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { Cookie: `token=${token}` },
|
headers: { Cookie: `token=${token}` },
|
||||||
signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS),
|
signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (res.ok) {
|
||||||
return Buffer.from(await res.arrayBuffer());
|
return Buffer.from(await res.arrayBuffer());
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback error ignored
|
||||||
|
}
|
||||||
|
throw dbErr;
|
||||||
|
}
|
||||||
|
throw new Error('Image not found');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the analyze_image tool.
|
* Creates the analyze_image tool.
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
165
src/tools/ean-checksum.js
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,21 +134,35 @@ 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 image = await getImageByHash(googleAuthId, hash);
|
||||||
|
if (image?.buffer) {
|
||||||
|
return image.buffer;
|
||||||
|
}
|
||||||
|
} catch (dbErr) {
|
||||||
|
try {
|
||||||
|
const url = `http://127.0.0.1:${env.port}/api/image/${hash}`;
|
||||||
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { Cookie: `token=${token}` },
|
headers: { Cookie: `token=${token}` },
|
||||||
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
|
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (res.ok) {
|
||||||
return Buffer.from(await res.arrayBuffer());
|
return Buffer.from(await res.arrayBuffer());
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback error ignored
|
||||||
|
}
|
||||||
|
throw dbErr;
|
||||||
|
}
|
||||||
|
throw new Error('Image not found');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads image bytes for an attachment path (VFS relative path, DB hash, or HTTP URL).
|
* Loads image bytes for an attachment path (VFS relative path, DB hash, or HTTP URL).
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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}).`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
190
src/tools/net-gross-calc.js
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}).`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
160
src/tools/vfs.js
160
src/tools/vfs.js
@@ -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);
|
||||||
|
if (!fs.existsSync(diskPath)) {
|
||||||
|
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||||
fs.writeFileSync(diskPath, skill.content, 'utf8');
|
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) {
|
} catch (err) {
|
||||||
console.warn(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message);
|
console.warn('[vfs] prepare warning:', 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) {
|
|
||||||
console.warn('[vfs] hydration 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) {
|
})
|
||||||
|
.catch((err) => {
|
||||||
if (typeof callback === 'function') process.nextTick(callback, err);
|
if (typeof callback === 'function') process.nextTick(callback, err);
|
||||||
else throw 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),
|
||||||
|
|||||||
@@ -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}).`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user