Compare commits
10 Commits
8c19388901
...
15664223ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15664223ee | ||
|
|
dff254e923 | ||
|
|
dbabce2a2d | ||
|
|
043facff48 | ||
|
|
fb2ab77ab8 | ||
|
|
2130a1e408 | ||
|
|
bc0e277b81 | ||
|
|
37a676bb50 | ||
|
|
96d35bcf6d | ||
|
|
c2147de284 |
@@ -1,5 +1,6 @@
|
||||
# Server
|
||||
PORT=4208
|
||||
BODY_LIMIT=100mb
|
||||
|
||||
# CORS — comma-separated allowed origins
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/
|
||||
data/logs/
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
40
docs/api/routes/delivery/file.md
Normal file
40
docs/api/routes/delivery/file.md
Normal 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`._
|
||||
@@ -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"
|
||||
|
||||
@@ -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
62
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
55
src/util/errorLog.js
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user