Compare commits

...

10 Commits

Author SHA1 Message Date
seb
15664223ee u 2026-09-20 16:33:38 +02:00
seb
dff254e923 errorlog 2026-09-20 13:14:55 +02:00
seb
dbabce2a2d u 2026-09-20 08:03:00 +02:00
seb
043facff48 llm 2026-09-20 07:22:14 +02:00
seb
fb2ab77ab8 u 2026-09-20 06:13:24 +02:00
seb
2130a1e408 abord and log 2026-09-19 08:13:35 +02:00
seb
bc0e277b81 sse abort 2026-09-19 07:01:55 +02:00
seb
37a676bb50 file api 2026-09-19 06:34:11 +02:00
seb
96d35bcf6d u 2026-09-19 05:14:41 +02:00
seb
c2147de284 multipart 2026-09-18 07:19:20 +02:00
20 changed files with 955 additions and 106 deletions

View File

@@ -1,5 +1,6 @@
# Server
PORT=4208
BODY_LIMIT=100mb
# CORS — comma-separated allowed origins
CORS_ORIGINS=http://localhost:5173

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
node_modules/
.env
data/
data/logs/

View File

@@ -84,6 +84,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/delivery/delete.md`](routes/delivery/delete.md) | `DELETE /delivery/{id}` |
| [`routes/delivery/get.md`](routes/delivery/get.md) | `GET /delivery/{id}` |
| [`routes/delivery/update.md`](routes/delivery/update.md) | `PATCH /delivery/{id}` |
| [`routes/delivery/file.md`](routes/delivery/file.md) | `GET /delivery/{id}/file/{fileId}` |
| [`routes/entity/list.md`](routes/entity/list.md) | `GET /entity` |
| [`routes/entity/create.md`](routes/entity/create.md) | `POST /entity` |
| [`routes/entity/next-number.md`](routes/entity/next-number.md) | `GET /entity/next-number` |

View File

@@ -4330,7 +4330,7 @@
},
"responses": {
"200": {
"description": "`text/event-stream` — emits `status`, `llm_call`, `tool_call`, `usage`,\n`progress`, `result` (and `error` on failure). See SSE Events table below.\n\n**ManifestResult shape** (used by `progress` and `result`):\n\n```json\n{\n \"basedata\": {\n \"date\": \"2026-01-15\",\n \"kLieferant\": 42,\n \"lieferantName\": \"Muster GmbH\",\n \"manifestNr\": \"LS-2026-001\",\n \"manifestType\": \"lieferschein\"\n },\n \"notes\": [\"Pos. 3: VPE-Angabe fehlte, Standardwert 1 angenommen.\"],\n \"items\": {\n \"pagesFrom\": 1,\n \"pagesTo\": 2,\n \"items\": [\n {\n \"position\": 1,\n \"count\": 10,\n \"vpe\": 1,\n \"unit\": \"Stk.\",\n \"articleName\": \"Einhängesieb Stahl\",\n \"liefArtNr\": \"ES-100\",\n \"mwst\": \"19\",\n \"eknetto\": 450,\n \"page\": 1\n }\n ]\n }\n}\n```\n",
"description": "`text/event-stream` — emits `status`, `llm_call`, `tool_call`, `usage`,\n`progress`, `result` (and `error` on failure). See SSE Events table below.\n\n**ManifestResult shape** (used by `progress` and `result`):\n\n```json\n{\n \"basedata\": {\n \"date\": \"2026-01-15\",\n \"lieferant_id\": 42,\n \"lieferantName\": \"Muster GmbH\",\n \"manifestNr\": \"LS-2026-001\",\n \"manifestType\": \"lieferschein\"\n },\n \"notes\": [\"Pos. 3: VPE-Angabe fehlte, Standardwert 1 angenommen.\"],\n \"items\": {\n \"pagesFrom\": 1,\n \"pagesTo\": 2,\n \"items\": [\n {\n \"position\": 1,\n \"count\": 10,\n \"vpe\": 1,\n \"unit\": \"Stk.\",\n \"articleName\": \"Einhängesieb Stahl\",\n \"liefArtNr\": \"ES-100\",\n \"mwst\": \"19\",\n \"eknetto\": 450,\n \"page\": 1\n }\n ]\n }\n}\n```\n",
"content": {
"text/event-stream": {
"schema": {
@@ -4480,7 +4480,7 @@
},
"files": {
"type": "array",
"description": "Attached documents (pdf or txt) stored with the delivery\nand returned in `files` on every delivery response.\n",
"description": "Attached documents (pdf or txt) stored with the delivery\nand returned in `files` on every delivery response.\nThumbnails (AVIF) are generated server-side.\n",
"items": {
"$ref": "#/components/schemas/DeliveryFileInput"
}
@@ -4509,6 +4509,28 @@
]
}
}
},
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "string",
"description": "JSON string containing manifest header and line items ({ externalNumber, date, supplierId, lines, filesMeta? }).\n"
},
"files": {
"type": "array",
"items": {
"type": "string",
"format": "binary"
},
"description": "Raw binary PDF or text files attached natively via standard HTTP multipart upload."
}
}
}
}
}
},
@@ -4702,7 +4724,7 @@
},
"files": {
"type": "array",
"description": "Appends attachments (pdf or txt) to the delivery.\nOmit to leave existing files unchanged.\n",
"description": "Appends attachments (pdf or txt) to the delivery.\nOmit to leave existing files unchanged.\nThumbnails (AVIF) are generated server-side.\n",
"items": {
"$ref": "#/components/schemas/DeliveryFileInput"
}
@@ -4713,6 +4735,25 @@
"lines": []
}
}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"description": "JSON string containing patch fields ({ externalNumber, date, supplierId, lines, filesMeta? }).\n"
},
"files": {
"type": "array",
"items": {
"type": "string",
"format": "binary"
},
"description": "Raw binary PDF or text files to append to the delivery."
}
}
}
}
}
},
@@ -4843,6 +4884,143 @@
}
}
},
"/delivery/{id}/file/{fileId}": {
"get": {
"tags": [
"Delivery"
],
"summary": "Get delivery attachment file content",
"description": "Returns the full binary content of an attached file (e.g. PDF or text document)\nfor the specified delivery. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.\n",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "delivery/file.md",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
},
"description": "Delivery (manifest) ID"
},
{
"in": "path",
"name": "fileId",
"required": true,
"schema": {
"type": "integer"
},
"description": "File ID or attachment ID"
},
{
"in": "query",
"name": "download",
"required": false,
"schema": {
"type": "boolean"
},
"description": "When true, sets Content-Disposition to attachment instead of inline"
}
],
"responses": {
"200": {
"description": "Full binary file content",
"headers": {
"Content-Type": {
"schema": {
"type": "string",
"example": "application/pdf"
}
},
"Content-Disposition": {
"schema": {
"type": "string",
"example": "inline; filename=\"document.pdf\""
}
},
"ETag": {
"schema": {
"type": "string"
}
},
"Cache-Control": {
"schema": {
"type": "string",
"example": "private, max-age=86400"
}
}
},
"content": {
"application/pdf": {
"schema": {
"type": "string",
"format": "binary"
}
},
"text/plain": {
"schema": {
"type": "string",
"format": "binary"
}
},
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"304": {
"description": "Not modified (If-None-Match matched ETag)"
},
"400": {
"description": "Invalid id, invalid fileId, 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": "Delivery or file not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/customergroup": {
"get": {
"tags": [
@@ -10047,7 +10225,7 @@
},
"DeliveryFile": {
"type": "object",
"description": "Attachment metadata (binary content is served via the attachment content endpoints)",
"description": "Attachment metadata (full binary content is served via `GET /delivery/{id}/file/{fileId}`). Thumbnails (AVIF) are generated server-side.",
"required": [
"id",
"manifestId",
@@ -10097,7 +10275,8 @@
"thumbavif": {
"type": "string",
"format": "byte",
"nullable": true
"nullable": true,
"description": "Base64-encoded AVIF thumbnail generated server-side (null for text files)"
},
"thumbx": {
"type": "integer",
@@ -10124,7 +10303,7 @@
"name",
"content"
],
"description": "Attachment input. Only application/pdf and text/plain are accepted.",
"description": "Attachment input. Only application/pdf and text/plain are accepted. Thumbnails are generated server-side and must not be supplied by the client.",
"properties": {
"mimetype": {
"type": "string",

View File

@@ -38,6 +38,14 @@ The manifest `number` is allocated automatically via
}
```
## Body (`multipart/form-data`)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `data` | yes | string | JSON string containing manifest header and line items ({ externalNumber, date, supplierId, lines, filesMeta? }).
|
| `files` | no | file | Raw binary PDF or text files attached natively via standard HTTP multipart upload. |
## Response `201`
Created delivery

View File

@@ -0,0 +1,40 @@
# `GET /delivery/{id}/file/{fileId}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get delivery attachment file content
Returns the full binary content of an attached file (e.g. PDF or text document)
for the specified delivery. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Delivery (manifest) ID |
| `fileId` | yes | integer | File ID or attachment ID |
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `download` | no | boolean | When true, sets Content-Disposition to attachment instead of inline |
## Response `200`
Full binary file content
## Errors
| Status | Description |
|--------|-------------|
| 304 | Not modified (If-None-Match matched ETag) |
| 400 | Invalid id, invalid fileId, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Delivery or file not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -51,7 +51,7 @@ The response is a `text/event-stream`. Events are emitted in order until the str
{
"basedata": {
"date": "2026-01-15",
"kLieferant": 42,
"lieferant_id": 42,
"lieferantName": "Muster GmbH",
"manifestNr": "LS-2026-001",
"manifestType": "lieferschein"

View File

@@ -25,6 +25,14 @@ replaces all lines; if omitted, leaves lines unchanged.
}
```
## Body (`multipart/form-data`)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `data` | no | string | JSON string containing patch fields ({ externalNumber, date, supplierId, lines, filesMeta? }).
|
| `files` | no | file | Raw binary PDF or text files to append to the delivery. |
## Response `200`
Updated delivery

62
package-lock.json generated
View File

@@ -13,9 +13,9 @@
"cookie-parser": "^1.4.7",
"cross-env": "^10.1.0",
"crypto": "^1.0.1",
"dotenv": "^17.4.2",
"dotenv": "^18.0.1",
"express": "^5.2.1",
"google-auth-library": "^11.0.2",
"google-auth-library": "^11.1.0",
"ink": "^7.1.1",
"ink-text-input": "^6.0.0",
"jsonwebtoken": "^9.0.3",
@@ -39,7 +39,8 @@
"@clack/prompts": "^1.8.1",
"@hpcc-js/wasm-graphviz": "^1.29.1",
"chalk": "^6.0.0",
"dotenv": "^17.4.2",
"dotenv": "^18.0.1",
"pdfconvert-wasm": "^0.3.0",
"sharp": "^0.35.4",
"thumbhash": "^0.1.1"
},
@@ -47,7 +48,7 @@
"mssql": "^12.7.2"
},
"peerDependencies": {
"mssql": "^12.6.0"
"mssql": "^12.7.2"
}
},
"../llmorbiter": {
@@ -969,12 +970,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.20.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz",
"integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==",
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~8.9.0"
}
},
"node_modules/@types/readable-stream": {
@@ -1634,10 +1635,13 @@
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"version": "18.0.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.1.tgz",
"integrity": "sha512-0eR4m4D/jH5eaI3evo2ZqqMii5mrTCR12v12VChdie6O1gPHs7XAjzTep0CWc1Bd+oNx3vVfbi734dK4zghigw==",
"license": "BSD-2-Clause",
"bin": {
"dotenv": "dist/index.cjs"
},
"engines": {
"node": ">=12"
},
@@ -1999,9 +2003,9 @@
}
},
"node_modules/gcp-metadata": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.0.3.tgz",
"integrity": "sha512-2YYnIlHaKBGT2IPg3G2M57hia9Galz15zsEOvw9T3oRf0lSn6KN6VcHQLqby7x8ksYKnjXvp3rp2KJyLCN6zfQ==",
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.0.4.tgz",
"integrity": "sha512-p6WvbXOhfwnVtv/MyPq5kBiVhfV/qglMIxwwTVNEI1Ha1HmOgz5uHYHtYO6SfGckFaXe8blA11IJJ3wqXmvmBA==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.1.3",
@@ -2013,9 +2017,9 @@
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -2644,9 +2648,9 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
@@ -3472,9 +3476,9 @@
"license": "ISC"
},
"node_modules/slice-ansi": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz",
"integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.1.tgz",
"integrity": "sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.3",
@@ -3669,9 +3673,9 @@
"license": "0BSD"
},
"node_modules/type-fest": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
"integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz",
"integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
@@ -3715,9 +3719,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"license": "MIT"
},
"node_modules/unpipe": {

View File

@@ -18,9 +18,9 @@
"cookie-parser": "^1.4.7",
"cross-env": "^10.1.0",
"crypto": "^1.0.1",
"dotenv": "^17.4.2",
"dotenv": "^18.0.1",
"express": "^5.2.1",
"google-auth-library": "^11.0.2",
"google-auth-library": "^11.1.0",
"ink": "^7.1.1",
"ink-text-input": "^6.0.0",
"jsonwebtoken": "^9.0.3",

View File

@@ -153,7 +153,6 @@ function renderRequestBody(requestBody) {
lines.push('');
lines.push(formatSchema(jsonSchema, spec.components));
lines.push('');
return lines;
}
// multipart/form-data — render fields as a table when properties are present
@@ -166,29 +165,30 @@ function renderRequestBody(requestBody) {
lines.push('| Field | Required | Type | Description |');
lines.push('|-------|----------|------|-------------|');
for (const [name, prop] of Object.entries(schema.properties)) {
const type = prop.format === 'binary' ? 'file' : (prop.type || '');
const type = prop.format === 'binary' || prop.items?.format === 'binary' ? 'file' : (prop.type || '');
lines.push(`| \`${name}\` | ${required.has(name) ? 'yes' : 'no'} | ${type} | ${prop.description || ''} |`);
}
lines.push('');
return lines;
}
lines.push('## Body');
lines.push('');
for (const type of types) {
const schema = requestBody.content[type]?.schema;
if (schema?.format === 'binary') {
lines.push(`Raw binary (\`${type}\`).`);
} else {
lines.push(`\`${type}\``);
if (schema) {
lines.push('');
lines.push(formatSchema(schema, spec.components));
}
}
if (!jsonSchema && !multipart?.schema?.properties) {
lines.push('## Body');
lines.push('');
for (const type of types) {
const schema = requestBody.content[type]?.schema;
if (schema?.format === 'binary') {
lines.push(`Raw binary (\`${type}\`).`);
} else {
lines.push(`\`${type}\``);
if (schema) {
lines.push('');
lines.push(formatSchema(schema, spec.components));
}
}
lines.push('');
}
}
return lines;
return lines.length ? lines : null;
}
function docsFileFor(apiPath, method) {

View File

@@ -65,7 +65,7 @@ async function createApi() {
next();
});
app.use(corsMiddleware);
app.use(express.json());
app.use(express.json({ limit: env.bodyLimit }));
app.use(cookieParser());
app.use(loggerMiddleware);
app.use(authMiddleware);
@@ -79,6 +79,16 @@ async function createApi() {
await loadRoutes(app, '/api');
app.use((err, _req, res, next) => {
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'Payload too large' });
}
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
return res.status(400).json({ error: 'Invalid JSON body' });
}
next(err);
});
return app;
}

View File

@@ -1,5 +1,6 @@
import chalk from 'chalk';
import { isEmpty, prettyJson } from '../../util/prettyJson.js';
import { writeErrorLog } from '../../util/errorLog.js';
function logField(label, value, { skipEmpty = false } = {}) {
if (skipEmpty && isEmpty(value)) return;
@@ -56,6 +57,60 @@ export const loggerMiddleware = (req, res, next) => {
}
logField('body', req.body ?? {}, { skipEmpty: true });
logField('response', getBody());
// Dump full request/response/error details to a log file on 5xx.
const statusCode = res.statusCode;
if (statusCode >= 500) {
const body = getBody();
writeErrorLog(
`${req.method} ${req.originalUrl} ${statusCode}`,
{
request: {
method: req.method,
url: req.originalUrl,
headers: req.headers,
params: req.params,
query: req.query,
},
body: req.body,
files: Array.isArray(req.files)
? req.files.map((f) => ({
fieldname: f.fieldname,
originalname: f.originalname,
mimetype: f.mimetype,
size: f.size,
encoding: f.encoding,
bufferHeadHex: f.buffer
? [...f.buffer.subarray(0, 32)]
.map((b) => b.toString(16).padStart(2, '0'))
.join(' ')
: null,
}))
: undefined,
response: {
status: statusCode,
body,
},
error: res.locals?.error
? {
message: res.locals.error.message,
code: res.locals.error.code,
number: res.locals.error.number,
state: res.locals.error.state,
class: res.locals.error.class,
lineNumber: res.locals.error.lineNumber,
procName: res.locals.error.procName,
stack: res.locals.error.stack,
precedingErrors: res.locals.error.precedingErrors?.map((e) => ({
message: e.message,
number: e.number,
state: e.state,
})),
}
: undefined,
},
);
}
});
next();

View File

@@ -435,7 +435,7 @@
* $ref: '#/components/schemas/DeliveryFile'
* DeliveryFile:
* type: object
* description: Attachment metadata (binary content is served via the attachment content endpoints)
* description: Attachment metadata (full binary content is served via `GET /delivery/{id}/file/{fileId}`). Thumbnails (AVIF) are generated server-side.
* required: [id, manifestId, fileId, sort, hash, mimetype, name, size]
* properties:
* id:
@@ -467,6 +467,7 @@
* type: string
* format: byte
* nullable: true
* description: Base64-encoded AVIF thumbnail generated server-side (null for text files)
* thumbx:
* type: integer
* nullable: true
@@ -482,7 +483,7 @@
* DeliveryFileInput:
* type: object
* required: [mimetype, name, content]
* description: Attachment input. Only application/pdf and text/plain are accepted.
* description: Attachment input. Only application/pdf and text/plain are accepted. Thumbnails are generated server-side and must not be supplied by the client.
* properties:
* mimetype:
* type: string

View File

@@ -1,4 +1,102 @@
import multer from 'multer';
import * as db from '../../db/delivery.js';
import { writeErrorLog } from '../../util/errorLog.js';
// Shared memory storage for delivery attachments (max 50 MB)
const multerUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const name = file.originalname?.toLowerCase() || '';
const isPdf = file.mimetype === 'application/pdf' || name.endsWith('.pdf');
const isText = file.mimetype.startsWith('text/') || /\.(txt|text|md|csv)$/.test(name);
if (isPdf || isText) {
cb(null, true);
} else {
cb(new Error('Only PDF or text files are accepted as attachments'));
}
},
}).array('files');
function uploadDeliveryFiles(req, res, next) {
multerUpload(req, res, (err) => {
if (err) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large (max 50 MB)' });
}
return res.status(400).json({ error: err.message });
}
next();
});
}
/**
* Helper to normalize payload from either multipart/form-data or application/json
*/
function parseDeliveryPayload(req) {
let payload = req.body ?? {};
// If sent as multipart/form-data with a JSON `data` field:
if (typeof payload.data === 'string') {
try {
payload = JSON.parse(payload.data);
} catch {
throw new Error('Invalid JSON in "data" field');
}
}
if (typeof payload !== 'object' || payload === null) {
throw new Error('Invalid delivery');
}
const { externalNumber, date, supplierId, lines } = payload;
let files = payload.files;
// If binary files were uploaded via multer:
if (Array.isArray(req.files) && req.files.length > 0) {
const filesMeta = Array.isArray(payload.filesMeta)
? payload.filesMeta
: (Array.isArray(payload.files) ? payload.files : []);
const metaByName = new Map(
filesMeta.filter((m) => m?.name).map((m) => [m.name, m])
);
files = req.files.map((f, idx) => {
const metaObj = metaByName.get(f.originalname) || filesMeta[idx] || {};
const name = f.originalname || '';
const isPdf = f.mimetype === 'application/pdf' || name.toLowerCase().endsWith('.pdf');
const mimetype = isPdf ? 'application/pdf' : 'text/plain';
console.log(`[delivery upload] Received multipart file "${name}": ${f.size} bytes, mimetype=${mimetype}`);
return {
mimetype,
name: f.originalname,
content: f.buffer, // Raw Buffer directly accepted by libdashstore/attachments.js
size: f.size,
meta: metaObj.meta ?? null,
};
});
} else if (Array.isArray(files) && files.length > 0) {
files = files.map((f) => {
let content = f.content;
if (typeof content === 'string') {
const trimmed = content.trim();
if (f.mimetype === 'application/pdf' || trimmed.startsWith('JVBER') || /^[A-Za-z0-9+/=]+$/.test(trimmed.slice(0, 100))) {
try {
content = Buffer.from(trimmed, 'base64');
} catch {
content = Buffer.from(content, 'utf8');
}
} else {
content = Buffer.from(content, 'utf8');
}
}
const size = Buffer.isBuffer(content) ? content.length : (f.size ?? 0);
console.log(`[delivery upload] Received JSON file "${f.name}": ${size} bytes, mimetype=${f.mimetype}`);
return {
...f,
content,
size,
};
});
}
return { payload, externalNumber, date, supplierId, lines, files };
}
function requireSelectedCompany(req) {
const companyId = req.user.selectedCompanyId;
@@ -8,15 +106,66 @@ function requireSelectedCompany(req) {
return { companyId };
}
/**
* Debug logging: dump per-file details before handing them to the store.
* Needed to diagnose MSSQL TDS errors on @content ("Data type 0xA5 has an
* invalid data length or metadata length").
*/
function logDeliveryFiles(files, label) {
if (!Array.isArray(files) || files.length === 0) {
console.log(`[delivery diag] ${label}: no files`);
return;
}
files.forEach((f, idx) => {
const content = f?.content;
const isBuf = Buffer.isBuffer(content);
const hex = isBuf
? [...content.subarray(0, 8)].map((b) => b.toString(16).padStart(2, '0')).join(' ')
: null;
console.log(
`[delivery diag] ${label} file[${idx}]:` +
` name=${JSON.stringify(f?.name)}` +
` mimetype=${JSON.stringify(f?.mimetype)}` +
` size=${JSON.stringify(f?.size)}` +
` contentType=${isBuf ? 'Buffer' : typeof content}` +
` contentLength=${isBuf ? content.length : typeof content === 'string' ? content.length : 'n/a'}` +
(hex !== null ? ` headHex=${hex}` : '') +
(typeof content === 'string' ? ` head=${JSON.stringify(content.slice(0, 16))}` : '')
);
});
}
/**
* Debug logging: dump everything tedious/mssql puts on a SQL error.
*/
function logStoreError(label, error) {
console.error(`[delivery diag] ${label} store error:`);
console.error(` message: ${error.message}`);
console.error(` code: ${error.code} number: ${error.number} state: ${error.state} class: ${error.class}`);
console.error(` lineNumber: ${error.lineNumber} serverName: ${error.serverName} procName: ${error.procName}`);
if (error.precedingErrors?.length) {
error.precedingErrors.forEach((e, i) => {
console.error(` precedingError[${i}]: ${e.message} (number=${e.number} state=${e.state})`);
});
}
console.error(error.stack);
}
function deliveryErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Delivery not found') return 404;
if (error.message === 'Manifest not found') return 404;
if (error.message === 'Attachment not found') return 404;
if (error.message === 'File not found') return 404;
if (error.message === 'Supplier not found') return 404;
if (error.message === 'Article not found') return 404;
if (error.message === 'Invalid delivery') return 400;
if (error.message === 'Invalid lines') return 400;
if (error.message === 'Invalid file') return 400;
if (error.message === 'Invalid JSON in "data" field') return 400;
if (error.message === 'Only PDF or text files are accepted as attachments') return 400;
return 500;
}
@@ -112,6 +261,7 @@ export function register(app, basePath = '') {
* description: |
* Attached documents (pdf or txt) stored with the delivery
* and returned in `files` on every delivery response.
* Thumbnails (AVIF) are generated server-side.
* items:
* $ref: '#/components/schemas/DeliveryFileInput'
* example:
@@ -129,6 +279,21 @@ export function register(app, basePath = '') {
* - mimetype: application/pdf
* name: delivery-note.pdf
* content: '<base64 encoded content>'
* multipart/form-data:
* schema:
* type: object
* required: [data]
* properties:
* data:
* type: string
* description: |
* JSON string containing manifest header and line items ({ externalNumber, date, supplierId, lines, filesMeta? }).
* files:
* type: array
* items:
* type: string
* format: binary
* description: Raw binary PDF or text files attached natively via standard HTTP multipart upload.
* responses:
* 201:
* description: Created delivery
@@ -174,21 +339,22 @@ export function register(app, basePath = '') {
const deliveries = await db.list(req.user.id);
res.status(200).json({ deliveries });
} catch (error) {
res.locals.error = error;
const status = deliveryErrorStatus(error);
if (status === 500) console.error('List deliveries failed:', error);
res.status(status).json({ error: error.message });
}
});
app.post(`${basePath}/delivery`, async (req, res) => {
app.post(`${basePath}/delivery`, uploadDeliveryFiles, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const { externalNumber, date, supplierId, lines, files } = req.body ?? {};
try {
const { externalNumber, date, supplierId, lines, files } = parseDeliveryPayload(req);
logDeliveryFiles(files, 'create');
const delivery = await db.create(req.user.id, {
externalNumber,
date,
@@ -198,8 +364,34 @@ export function register(app, basePath = '') {
});
res.status(201).json({ delivery });
} catch (error) {
res.locals.error = error;
const status = deliveryErrorStatus(error);
if (status === 500) console.error('Create delivery failed:', error);
if (status === 500) {
logStoreError('Create delivery failed', error);
// Dump the full parsed payload (including file metadata) to a log file.
try {
const { externalNumber, date, supplierId, lines, files } = parseDeliveryPayload(req);
writeErrorLog(`POST ${basePath}/delivery 500 (create delivery)`, {
payload: { externalNumber, date, supplierId, lines },
files: files?.map((f) => ({
name: f.name,
mimetype: f.mimetype,
size: f.size,
meta: f.meta,
})),
error: {
message: error.message,
code: error.code,
number: error.number,
state: error.state,
class: error.class,
stack: error.stack,
},
});
} catch (logErr) {
console.error('[delivery diag] Failed to dump payload:', logErr.message);
}
}
res.status(status).json({ error: error.message });
}
});
@@ -297,11 +489,26 @@ export function register(app, basePath = '') {
* description: |
* Appends attachments (pdf or txt) to the delivery.
* Omit to leave existing files unchanged.
* Thumbnails (AVIF) are generated server-side.
* items:
* $ref: '#/components/schemas/DeliveryFileInput'
* example:
* externalNumber: DHL-124
* lines: []
* multipart/form-data:
* schema:
* type: object
* properties:
* data:
* type: string
* description: |
* JSON string containing patch fields ({ externalNumber, date, supplierId, lines, filesMeta? }).
* files:
* type: array
* items:
* type: string
* format: binary
* description: Raw binary PDF or text files to append to the delivery.
* responses:
* 200:
* description: Updated delivery
@@ -393,13 +600,14 @@ export function register(app, basePath = '') {
const delivery = await db.getById(req.user.id, id);
res.status(200).json({ delivery });
} catch (error) {
res.locals.error = error;
const status = deliveryErrorStatus(error);
if (status === 500) console.error('Get delivery failed:', error);
res.status(status).json({ error: error.message });
}
});
app.patch(`${basePath}/delivery/:id`, async (req, res) => {
app.patch(`${basePath}/delivery/:id`, uploadDeliveryFiles, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
@@ -410,30 +618,34 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: 'id is required' });
}
const body = req.body ?? {};
const patch = {};
if (Object.prototype.hasOwnProperty.call(body, 'externalNumber')) {
patch.externalNumber = body.externalNumber;
}
if (Object.prototype.hasOwnProperty.call(body, 'date')) {
patch.date = body.date;
}
if (Object.prototype.hasOwnProperty.call(body, 'supplierId')) {
patch.supplierId = body.supplierId;
}
if (Object.prototype.hasOwnProperty.call(body, 'lines')) {
patch.lines = body.lines;
}
if (Object.prototype.hasOwnProperty.call(body, 'files')) {
patch.files = body.files;
}
try {
const parsed = parseDeliveryPayload(req);
const patch = {};
if (Object.prototype.hasOwnProperty.call(parsed.payload, 'externalNumber')) {
patch.externalNumber = parsed.externalNumber;
}
if (Object.prototype.hasOwnProperty.call(parsed.payload, 'date')) {
patch.date = parsed.date;
}
if (Object.prototype.hasOwnProperty.call(parsed.payload, 'supplierId')) {
patch.supplierId = parsed.supplierId;
}
if (Object.prototype.hasOwnProperty.call(parsed.payload, 'lines')) {
patch.lines = parsed.lines;
}
if (parsed.files !== undefined) {
patch.files = parsed.files;
}
logDeliveryFiles(patch.files, `update ${id}`);
const delivery = await db.update(req.user.id, id, patch);
res.status(200).json({ delivery });
} catch (error) {
res.locals.error = error;
const status = deliveryErrorStatus(error);
if (status === 500) console.error('Update delivery failed:', error);
if (status === 500) {
logStoreError('Update delivery failed', error);
}
res.status(status).json({ error: error.message });
}
});
@@ -453,9 +665,157 @@ export function register(app, basePath = '') {
await db.remove(req.user.id, id);
res.sendStatus(204);
} catch (error) {
res.locals.error = error;
const status = deliveryErrorStatus(error);
if (status === 500) console.error('Delete delivery failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /delivery/{id}/file/{fileId}:
* get:
* tags: [Delivery]
* summary: Get delivery attachment file content
* description: |
* Returns the full binary content of an attached file (e.g. PDF or text document)
* for the specified delivery. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.
* security:
* - cookieAuth: []
* x-markdown: delivery/file.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Delivery (manifest) ID
* - in: path
* name: fileId
* required: true
* schema:
* type: integer
* description: File ID or attachment ID
* - in: query
* name: download
* required: false
* schema:
* type: boolean
* description: When true, sets Content-Disposition to attachment instead of inline
* responses:
* 200:
* description: Full binary file content
* headers:
* Content-Type:
* schema:
* type: string
* example: application/pdf
* Content-Disposition:
* schema:
* type: string
* example: inline; filename="document.pdf"
* ETag:
* schema:
* type: string
* Cache-Control:
* schema:
* type: string
* example: private, max-age=86400
* content:
* application/pdf:
* schema:
* type: string
* format: binary
* text/plain:
* schema:
* type: string
* format: binary
* application/octet-stream:
* schema:
* type: string
* format: binary
* 304:
* description: Not modified (If-None-Match matched ETag)
* 400:
* description: Invalid id, invalid fileId, 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: Delivery or file not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
const handleGetDeliveryFile = async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
const fileId = Number(req.params.fileId);
if (!Number.isInteger(fileId) || fileId <= 0) {
return res.status(400).json({ error: 'fileId is required' });
}
try {
const attachment = await db.getFile(req.user.id, id, fileId);
if (attachment.hash) {
const etag = `"${attachment.hash}"`;
res.setHeader('ETag', etag);
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === etag || ifNoneMatch === attachment.hash) {
return res.status(304).end();
}
}
const isDownload = req.query.download === '1' || req.query.download === 'true';
const dispositionType = isDownload ? 'attachment' : 'inline';
const filename = attachment.name || `file-${attachment.fileId}`;
const asciiName = filename.replace(/["\r\n\\]/g, '_');
const encodedName = encodeURIComponent(filename).replace(/['()]/g, escape);
res.setHeader('Content-Type', attachment.mimetype || 'application/octet-stream');
res.setHeader('Content-Disposition', `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`);
res.setHeader('Cache-Control', 'private, max-age=86400');
if (attachment.content && Buffer.isBuffer(attachment.content)) {
res.setHeader('Content-Length', String(attachment.content.length));
return res.status(200).send(attachment.content);
} else {
const buffer = Buffer.from(attachment.content || '');
res.setHeader('Content-Length', String(buffer.length));
return res.status(200).send(buffer);
}
} catch (error) {
res.locals.error = error;
const status = deliveryErrorStatus(error);
if (status === 500) console.error('Get delivery file failed:', error);
res.status(status).json({ error: error.message });
}
};
app.get(`${basePath}/delivery/:id/file/:fileId`, handleGetDeliveryFile);
app.get(`${basePath}/delivery/:id/files/:fileId`, handleGetDeliveryFile);
}

View File

@@ -4,6 +4,7 @@ import { execFileSync } from 'child_process';
import multer from 'multer';
import { LLMOrbiter, OpenRouterProvider, defineTool, DirectReturnError } from 'libllmorbiter';
import * as dbCompanySetting from '../../db/companysetting.js';
import * as dbSupplier from '../../db/supplier.js';
// ─── Multer — memory storage (no temp files); accepts PDF or text ───────────────
const upload = multer({
@@ -31,7 +32,7 @@ function isPdfUpload(file) {
// ─── SSE helpers ────────────────────────────────────────────────────────────────
function sseWrite(res, event, data) {
if (res.writableEnded) return;
if (res.writableEnded || res.destroyed) return;
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
@@ -67,7 +68,7 @@ Rules:
- manifestType: "rechnung" or "lieferschein". For "lieferschein", prices and taxes must be null.
- Exclude non-product lines (shipping, handling, etc.).
- Line items: Extract count, vpe (default: 1), liefArtNr, mwst (7 or 19), and eknetto per piece(euro cents).
- Match supplier against the provided list for kLieferant and lieferantName. For unknown suppliers, extract lieferantName and set kLieferant to null.
- Match supplier against the provided list for lieferant_id and lieferantName. For unknown suppliers, extract lieferantName and set lieferant_id to null.
- Write notes strictly in German.
- The output_manifest tool has three strictly optional components: basedata, notes, items. Only include a component when it carries genuinely new data for this batch; omit the rest.
- Include basedata ONLY when at least one of its fields is currently null/empty and you can fill it in, or when a value actually changes. If all basedata fields are already set and unchanged, omit basedata.
@@ -89,6 +90,17 @@ Unit (ME) rules:
- A packaging size like "VE100" means the bulk pack contains 100 individual items: set vpe = 100 and unit = the individual item (e.g. Stk.).
- Example: "Einhängesiebe Stahl, VE100" -> vpe = 100, unit = "Stk.", count = 1 (one bulk pack of 100 sieves).`;
function buildSystemPrompt(suppliers = []) {
const supplierList = suppliers.length > 0
? suppliers.map((s) => `${s.id} | ${s.name}`).join('\n')
: '(no suppliers registered)';
return `${BASE_PROMPT}
Suppliers (lieferant_id | lieferant_name):
${supplierList}`;
}
// ─── Shared SSE manifest extraction (POST /delivery/parse) ─────────────────────
/**
* Fetches the OpenRouter API key, opens the SSE stream, and runs the LLM
@@ -113,6 +125,14 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
return res.status(400).json({ error: 'OpenRouter API key not configured (companysetting: keys.openrouter)' });
}
// ── Fetch suppliers from Lieferanten group ────────────────────────
let suppliers = [];
try {
suppliers = await dbSupplier.list(req.user.id);
} catch (err) {
console.warn('delivery parse: failed to list suppliers:', err?.message || err);
}
// ── Open SSE stream ──────────────────────────────────────────────
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
@@ -124,7 +144,7 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
// ── Accumulated state ────────────────────────────────────────────
const manifestBasedata = {
date: null,
kLieferant: null,
lieferant_id: null,
lieferantName: null,
manifestNr: null,
manifestType: null,
@@ -151,7 +171,7 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
additionalProperties: false,
properties: {
date: { type: ['string', 'null'], description: 'Document date (ISO 8601)' },
kLieferant: { type: ['number', 'null'], description: 'Lieferant ID' },
lieferant_id: { type: ['number', 'null'], description: 'Lieferant ID' },
lieferantName: { type: 'string', description: 'Lieferant name' },
manifestNr: { type: ['string', 'null'], description: 'Lieferscheinnummer or Rechnungsnummer' },
manifestType: {
@@ -212,11 +232,22 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
},
},
invoke: async (args) => {
const basedataKeys = args.basedata && typeof args.basedata === 'object' ? Object.keys(args.basedata) : [];
const notesCount = Array.isArray(args.notes) ? args.notes.length : 0;
const itemsCount = args.items?.items && Array.isArray(args.items.items) ? args.items.items.length : 0;
console.log(`[llmorbiter] Tool invoke output_manifest: basedata=[${basedataKeys.join(', ')}], notes=${notesCount}, items=${itemsCount}`);
// Merge basedata
if (args.basedata && typeof args.basedata === 'object') {
for (const key of ['date', 'kLieferant', 'lieferantName', 'manifestNr', 'manifestType']) {
for (const key of ['date', 'lieferant_id', 'lieferantName', 'manifestNr', 'manifestType']) {
if (args.basedata[key] != null) manifestBasedata[key] = args.basedata[key];
}
if (args.basedata.kLieferant != null && manifestBasedata.lieferant_id == null) {
manifestBasedata.lieferant_id = args.basedata.kLieferant;
}
if (args.basedata.lieferant_name != null && manifestBasedata.lieferantName == null) {
manifestBasedata.lieferantName = args.basedata.lieferant_name;
}
}
// Merge notes
if (Array.isArray(args.notes)) {
@@ -259,32 +290,88 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
const model = '~openai/gpt-luna-latest:floor';
const provider = new OpenRouterProvider({ apiKey });
const orbiter = new LLMOrbiter(provider, {
onStatus: (status) => sseWrite(res, 'status', { message: status }),
onLLMCall: (call) =>
onStatus: (status) => {
console.log(`[llmorbiter] Status: ${status}`);
sseWrite(res, 'status', { message: status });
},
onLLMCall: (call) => {
console.log(
`[llmorbiter] LLM call (iteration ${call.iteration}): model=${call.model}, promptSize=${call.promptSize} chars, messages=${call.messageCount || call.messages}`
);
sseWrite(res, 'llm_call', {
model: call.model,
promptSize: call.promptSize,
messages: call.messages,
}),
onToolCall: (call) => sseWrite(res, 'tool_call', { name: call.name }),
onUsage: (usage) =>
});
},
onToolCall: (call) => {
const details = [];
if (call.args?.basedata) details.push(`basedata: [${Object.keys(call.args.basedata).join(', ')}]`);
if (call.args?.items?.items) details.push(`items: ${call.args.items.items.length}`);
if (call.args?.notes) details.push(`notes: ${call.args.notes.length}`);
const suffix = details.length ? ` (${details.join(', ')})` : '';
console.log(`[llmorbiter] Tool call: ${call.name}${suffix}`);
sseWrite(res, 'tool_call', { name: call.name });
},
onToolResult: (call) => {
console.log(`[llmorbiter] Tool result: ${call.name}`);
},
onToolError: (call) => {
console.error(`[llmorbiter] Tool error in ${call.name}:`, call.error);
},
onUsage: (usage) => {
const costStr = usage.cost != null ? `, cost=$${usage.cost.toFixed(4)}` : '';
console.log(
`[llmorbiter] Usage: input=${usage.input}, cached=${usage.cached}, output=${usage.output}${costStr}`
);
sseWrite(res, 'usage', {
input: usage.input,
cached: usage.cached,
output: usage.output,
cost: usage.cost,
}),
});
},
});
const abortController = new AbortController();
abortController.signal.addEventListener('abort', () => {
const reason = abortController.signal.reason;
const msg = reason instanceof Error ? reason.message : String(reason || 'aborted');
console.log(`[llmorbiter] Abort signal triggered: ${msg}`);
});
const originalAbort = orbiter.abort.bind(orbiter);
orbiter.abort = (reason) => {
const reasonMsg = reason instanceof Error ? reason.message : String(reason || 'aborted');
console.log(`[llmorbiter] orbiter.abort() called (reason: ${reasonMsg})`);
return originalAbort(reason);
};
const onDisconnect = () => {
if (!res.writableEnded && !abortController.signal.aborted) {
console.log('[llmorbiter] Client connection closed, aborting request');
abortController.abort('Client connection closed');
orbiter.abort('Client connection closed');
}
};
res.on('close', onDisconnect);
// ── Process all pages in one batch ───────────────────────────────
try {
if (res.destroyed || res.writableEnded) {
console.log('[llmorbiter] Response already closed or destroyed before run, aborting');
abortController.abort('Response closed before run');
orbiter.abort('Response closed before run');
return;
}
const pageContent = pages
.map((p, idx) => `--- Page ${idx + 1} of ${pages.length} ---\n${p}`)
.join('\n\n');
const prompt = `${BASE_PROMPT}
const systemPrompt = buildSystemPrompt(suppliers);
You are processing all ${pages.length} page(s) of this document.
const prompt = `You are processing all ${pages.length} page(s) of this document.
Current manifest_basedata (fill in what is still missing):
${JSON.stringify(manifestBasedata, null, 2)}
@@ -298,15 +385,36 @@ ${JSON.stringify(manifestItems, null, 2)}
Page content:
${pageContent}`;
console.log(`[llmorbiter] Starting run: model=${model}, pages=${pages.length} (${sourceLabel}), suppliers=${suppliers.length}`);
currentPage = 1;
await orbiter.run({ prompt, model, tools: [outputManifestTool] });
await orbiter.run({
prompt,
systemPrompt,
model,
tools: [outputManifestTool],
signal: abortController.signal,
});
console.log(`[llmorbiter] Run completed successfully. Extracted ${manifestItems.items.length} item(s), ${manifestNotes.length} note(s)`);
} catch (err) {
if (!res.writableEnded) {
if (err.name === 'AbortError' || abortController.signal.aborted) {
console.log(`[llmorbiter] Run aborted: ${err.message || abortController.signal.reason?.message || 'AbortError'}`);
} else {
console.error('[llmorbiter] Run failed with error:', err);
}
if (!res.writableEnded && !res.destroyed) {
sseWrite(res, 'error', { message: 'LLM processing failed: ' + err.message });
}
} finally {
res.off('close', onDisconnect);
}
if (res.writableEnded || res.destroyed) {
console.log('[llmorbiter] Response ended or destroyed, skipping final result');
return;
}
// ── Final result ─────────────────────────────────────────────────
console.log(`[llmorbiter] Emitting final result event and closing stream`);
sseWrite(res, 'result', {
basedata: manifestBasedata,
notes: manifestNotes,
@@ -392,7 +500,7 @@ export function register(app, basePath = '') {
* {
* "basedata": {
* "date": "2026-01-15",
* "kLieferant": 42,
* "lieferant_id": 42,
* "lieferantName": "Muster GmbH",
* "manifestNr": "LS-2026-001",
* "manifestType": "lieferschein"
@@ -442,13 +550,18 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: 'A document file is required (field name: file)' });
}
const sourceLabel = isPdfUpload(req.file) ? 'PDF' : 'Text';
console.log(`[deliveryParse] Received file "${req.file.originalname}" (${req.file.size} bytes, type=${sourceLabel})`);
let pages;
try {
pages = isPdfUpload(req.file) ? readPdfPages(req.file.buffer) : readTxtPages(req.file.buffer);
pages = sourceLabel === 'PDF' ? readPdfPages(req.file.buffer) : readTxtPages(req.file.buffer);
} catch (err) {
console.error(`[deliveryParse] Failed to parse document: ${err.message}`);
return res.status(400).json({ error: 'Failed to parse document: ' + err.message });
}
if (pages.length === 0) {
console.warn('[deliveryParse] Document content is empty');
return res.status(400).json({ error: 'Document content is empty' });
}
@@ -456,7 +569,7 @@ export function register(app, basePath = '') {
req,
res,
pages,
sourceLabel: isPdfUpload(req.file) ? 'PDF' : 'Text',
sourceLabel,
});
},
);

View File

@@ -7,6 +7,7 @@ dotenv.config({ path: resolve(__dirname, '../../.env'), quiet: true });
export default {
port: Number(process.env.PORT) || 4208,
bodyLimit: process.env.BODY_LIMIT || '100mb',
corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173')
.split(',')
.map((s) => s.trim())

View File

@@ -24,3 +24,8 @@ export async function remove(googleAuthId, id) {
const store = await getStore();
return store.delivery.delete(googleAuthId, id);
}
export async function getFile(googleAuthId, deliveryId, fileId) {
const store = await getStore();
return store.manifestattachment.getById(googleAuthId, deliveryId, fileId);
}

View File

@@ -1,6 +1,7 @@
import chalk from 'chalk';
import sql from 'mssql';
import LibDashStore from 'libdashstore';
import { MssqlProvider } from 'libdashstore/providers/mssql';
import env from '../config/env.js';
let pool;
@@ -48,12 +49,17 @@ function wrapStoreWithTiming(rawStore) {
api[method] = async (...args) => {
const start = Date.now();
try {
return await fn(...args);
} finally {
const duration = Date.now() - start;
const result = await fn(...args);
console.log(
`${chalk.magenta('libstore')} ${namespace}.${method} ${chalk.gray(`(${duration}ms)`)}`
`${chalk.magenta('libstore')} ${namespace}.${method} ${chalk.gray(`(${Date.now() - start}ms)`)}`
);
return result;
} catch (err) {
console.error(
`${chalk.magenta('libstore')} ${namespace}.${method} ${chalk.red('FAILED')} ` +
`${chalk.gray(`(${Date.now() - start}ms)`)} ${chalk.red(err.message)}`
);
throw err;
}
};
}
@@ -114,7 +120,9 @@ export async function getStore() {
if (!connecting) {
connecting = (async () => {
pool = await new sql.ConnectionPool(getMssqlConfig()).connect();
store = wrapStoreWithTiming(await new LibDashStore(pool));
store = wrapStoreWithTiming(
await new LibDashStore(new MssqlProvider(pool))
);
return store;
})().finally(() => {
connecting = undefined;

55
src/util/errorLog.js Normal file
View File

@@ -0,0 +1,55 @@
import { appendFileSync, mkdirSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { prettyJson } from '../util/prettyJson.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const LOG_DIR = process.env.ERROR_LOG_DIR || resolve(__dirname, '../../logs/errors');
const LOG_PREFIX = 'error-';
let dirEnsured = false;
function ensureLogDir() {
if (dirEnsured) return;
mkdirSync(LOG_DIR, { recursive: true });
dirEnsured = true;
}
/**
* Writes a plain-text error log file (no ANSI colors) to the error log
* directory and returns the file path. Never throws.
* @param {string} label Short description, e.g. "POST /api/delivery 500"
* @param {Record<string, unknown>} sections Named sections (request, body, response, error…)
* @returns {string | null} Path of the log file, or null on failure
*/
export function writeErrorLog(label, sections) {
try {
ensureLogDir();
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const safeLabel = label
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
const filePath = resolve(LOG_DIR, `${LOG_PREFIX}${stamp}-${safeLabel}.log`);
const divider = '='.repeat(80);
const parts = [
`Timestamp: ${new Date().toISOString()}`,
`Label: ${label}`,
divider,
];
for (const [name, value] of Object.entries(sections)) {
parts.push(`--- ${name} ${'-'.repeat(Math.max(0, 74 - name.length))}`);
parts.push(prettyJson(value, { colors: false }));
}
appendFileSync(filePath, `${parts.join('\n')}\n`);
console.error(`[error-log] Wrote ${filePath}`);
return filePath;
} catch (err) {
console.error('[error-log] Failed to write error log:', err.message);
return null;
}
}