Compare commits

...

7 Commits

Author SHA1 Message Date
seb
1991c0b529 u 2026-10-14 01:38:37 +02:00
seb
8ccdad18f4 u 2026-10-14 01:14:02 +02:00
seb
f9ed4fe75d u 2026-10-14 00:21:31 +02:00
seb
74b24686bc u 2026-10-14 00:08:11 +02:00
seb
11f6a8fc0f u 2026-10-13 22:23:48 +02:00
seb
b7b5d554d8 u 2026-10-13 17:49:36 +02:00
seb
c3d4fa5cd9 u 2026-10-13 17:19:16 +02:00
35 changed files with 6376 additions and 272 deletions

View File

@@ -2,6 +2,8 @@
PORT=4208
# HTTPS server port (defaults to 8443, set to 0 or false to disable)
HTTPS_PORT=8443
# Backend Monitor Interface port (served when backend is started with npm run dev)
MONITOR_PORT=4210
# Optional custom TLS cert/key paths (defaults to auto-generated certs/key.pem and certs/cert.pem)
# HTTPS_KEY_PATH=./certs/key.pem
# HTTPS_CERT_PATH=./certs/cert.pem
@@ -42,4 +44,11 @@ OPENROUTER_FREE_KEY=sk-or-v1-3d3c4e
OPENROUTER_FREE_MODEL=openrouter/free
OPENROUTER_PAID_MODEL=openai/gpt-6-luna:floor
# Backup Service
# BACKUP_ENABLED=true
# BACKUP_DIR=./data/backups
# BACKUP_INTERVAL_MS=3600000
# BACKUP_HOURLY_RETENTION=50
# BACKUP_DAILY_RETENTION=50

View File

@@ -88,6 +88,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` |
| [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` |
| [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` |
| [`routes/backup.md`](routes/backup.md) | `GET /backup` |
| [`routes/backup/run.md`](routes/backup/run.md) | `POST /backup/run` |
| [`routes/brand/list.md`](routes/brand/list.md) | `GET /brand` |
| [`routes/brand/create.md`](routes/brand/create.md) | `POST /brand` |
| [`routes/brand/delete.md`](routes/brand/delete.md) | `DELETE /brand/{id}` |
@@ -247,6 +249,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` |
| [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` |
| [`routes/vfs/list.md`](routes/vfs/list.md) | `GET /vfs` |
| [`routes/vfs/drafts/response.md`](routes/vfs/drafts/response.md) | `POST /vfs/drafts/response` |
| [`routes/vfs/mkdir.md`](routes/vfs/mkdir.md) | `POST /vfs/mkdir` |
| [`routes/vfs/rename.md`](routes/vfs/rename.md) | `POST /vfs/rename` |
| [`routes/vfs/upload.md`](routes/vfs/upload.md) | `POST /vfs/upload` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 229 API endpoints across all modules.
Overview of all 232 API endpoints across all modules.
### Notation
@@ -10,55 +10,60 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ order: T2 }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T25[], createdAt?: str, shipping?: T33|null, shippings?: T33[], payment?: T39|null, payments?: T39[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T7[] }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T28[], createdAt?: str, shipping?: T36|null, shippings?: T36[], payment?: T42|null, payments?: T42[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T7[] }`
- `T3` = `{ article: T4 }`
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T36, brand: T34|null, categoryIds: int[], photos: T31[], eans: str[], prices: T42[], suppliers: T14[] }`
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T39, brand: T37|null, categoryIds: int[], photos: T34[], eans: str[], prices: T46[], suppliers: T16[] }`
- `T5` = `{ delivery: T6 }`
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T25[], files: T11[], externalNumber?: str|null, createdAt?: str, ledger?: T46 }`
- `T7` = `{ id: int, number: str, type: str, date: str, lines: T25[], shippings: T33[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
- `T8` = `{ labeltemplate: T9 }`
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T21, thumb?: str|null }`
- `T10` = `{ quote: T13 }`
- `T11` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T12` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T13` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T25[], createdAt?: str }`
- `T14` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }`
- `T15` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T16` = `{ ledger: T17[] }`
- `T17` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T18` = `{ id: int, name: str, sort: int, exclusive: bool, values: T37[], articleCount?: int }`
- `T19` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }`
- `T20` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T21` = `{ objects: T26[] }`
- `T22` = `{ objects: T27[] }`
- `T23` = `{ printer: T29 }`
- `T24` = `{ entity: T32 }`
- `T25` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T26` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T27` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T28` = `{ shipping: T33 }`
- `T29` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T30` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T31` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T32` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T33` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T34` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T35` = `{ article: T40 }`
- `T36` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T37` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T38` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T39` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T40` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T41` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T42` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T43` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T44` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T45` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T46` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T47` = `{ warehouse: T49 }`
- `T48` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T49` = `{ id: int, name: str, sort: int }`
- `T50` = `{ id: int, categoryIds: int[] }`
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T28[], files: T13[], externalNumber?: str|null, createdAt?: str, ledger?: T50 }`
- `T7` = `{ id: int, number: str, type: str, date: str, lines: T28[], shippings: T36[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
- `T8` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T9|null }`
- `T9` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, manual: bool, files: T17, totalSizeBytes: int, durationMs: int }`
- `T10` = `{ labeltemplate: T11 }`
- `T11` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T24, thumb?: str|null }`
- `T12` = `{ quote: T15 }`
- `T13` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T14` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T15` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T28[], createdAt?: str }`
- `T16` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }`
- `T17` = `{ vfs: T51, storage: T51, database: T44 }`
- `T18` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T19` = `{ ledger: T20[] }`
- `T20` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T21` = `{ id: int, name: str, sort: int, exclusive: bool, values: T40[], articleCount?: int }`
- `T22` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }`
- `T23` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T24` = `{ objects: T29[] }`
- `T25` = `{ objects: T30[] }`
- `T26` = `{ printer: T32 }`
- `T27` = `{ entity: T35 }`
- `T28` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T29` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T30` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T31` = `{ shipping: T36 }`
- `T32` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T33` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T34` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T35` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T36` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T37` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T38` = `{ article: T43 }`
- `T39` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T40` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T41` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T42` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T43` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T44` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T45` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T46` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T47` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T48` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T49` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T50` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T51` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T52` = `{ warehouse: T54 }`
- `T53` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T54` = `{ id: int, name: str, sort: int }`
- `T55` = `{ id: int, categoryIds: int[] }`
### Routes
@@ -88,7 +93,7 @@ _none_
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T35`
- Returns: `201 T38`
#### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int`
- Returns: `200 zip`
@@ -124,10 +129,10 @@ _none_
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
- Returns: `201 T35`
- Returns: `201 T38`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
- Returns: `200 { article: T40, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
- Returns: `200 { article: T43, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
- Returns: `200 { success: bool, deleted: bool }`
@@ -154,7 +159,7 @@ _none_
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `200 T35`
- Returns: `200 T38`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
- Returns: `200 T3`
@@ -172,10 +177,10 @@ _none_
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T14[] }`
- Returns: `200 { suppliers: T16[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `201 { supplier: T14 }`
- Returns: `201 { supplier: T16 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -184,7 +189,7 @@ _none_
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `200 { supplier: T14 }`
- Returns: `200 { supplier: T16 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T3`
@@ -196,19 +201,19 @@ _none_
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T18[] }`
- Returns: `200 { attributenames: T21[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T18 }`
- Returns: `201 { attributename: T21 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T37[] }`
- Returns: `200 { values: T40[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T37 }`
- Returns: `201 { attributevalue: T40 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -217,7 +222,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T18 }`
- Returns: `200 { attributename: T21 }`
### Section: /attributevalue
@@ -226,28 +231,37 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T37 }`
- Returns: `200 { attributevalue: T40 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T43[] } }`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T47[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T43[] } }`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T47[] } }`
### Section: /auth
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T41 }`
- Returns: `200 { message: str, user: T45 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: T41, googleClientId: str }`
- Returns: `200 { user: T45, googleClientId: str }`
#### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }`
- Returns: `200 { user: T41 }`
- Returns: `200 { user: T45 }`
### Section: /backup
#### List backups and service status — `GET /backup`
_none_
- Returns: `200 { status: { enabled: bool, isRunning: bool, dbProvider: str, mssqlWarning: bool, backupDir: str, retention: { hourlyMax: int, dailyMax: int, monthly: str }, counts: { hourly: int, daily: int, monthly: int }, latestHourly?: T8|null, latestDaily?: T8|null, latestMonthly?: T8|null }, backups: { counts: { hourly: int, daily: int, monthly: int }, hourly: T8[], daily: T8[], monthly: T8[] } }`
#### Trigger manual backup — `POST /backup/run`
_none_
- Returns: `200 { success: bool, id: str, timestamp: str, promotedDaily: bool, promotedMonthly: bool, totalSizeBytes: int, pruneResult: { hourlyPruned: int, dailyPruned: int }, manifest: T9 }`
### Section: /brand
@@ -256,7 +270,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
#### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T43[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T47[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -265,10 +279,10 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T43[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T47[] } }`
#### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T43[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T47[] } }`
### Section: /category
@@ -292,7 +306,7 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T34|null }[] }`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T37|null }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -319,10 +333,10 @@ _none_
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: T20[] }`
- Returns: `200 { companies: T23[] }`
#### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T20, user: T41 }`
- Returns: `201 { company: T23, user: T45 }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
@@ -346,7 +360,7 @@ _none_
- Returns: `204 void`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T20 }`
- Returns: `200 { company: T23 }`
### Section: /customergroup
@@ -355,7 +369,7 @@ _none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup`
- Params: `body: { name: str }`
- Returns: `201 { customergroup: T49 }`
- Returns: `201 { customergroup: T54 }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }`
@@ -364,15 +378,15 @@ _none_
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T49 }`
- Returns: `200 { customergroup: T54 }`
### Section: /delivery
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T46 }[] }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T50 }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T30[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Params: `body: { externalNumber: str, date: str, lines: T33[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T5`
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
@@ -393,7 +407,7 @@ _none_
- Params: `id: int`
- Returns: `200 T5`
#### Update delivery — `PATCH /delivery/{id}`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T30[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T33[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T5`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
@@ -403,46 +417,46 @@ _none_
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T32[] }`
- Returns: `200 { entities: T35[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T24`
- Returns: `201 T27`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: int`
- Returns: `200 { addresses: T15[] }`
- Returns: `200 { addresses: T18[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `201 { address: T15 }`
- Returns: `201 { address: T18 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `200 { address: T15 }`
- Returns: `200 { address: T18 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T44[] }`
- Returns: `200 { contacts: T48[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T44 }`
- Returns: `201 { contact: T48 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T44 }`
- Returns: `200 { contact: T48 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T24`
- Returns: `200 T27`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T24`
- Returns: `200 T27`
### Section: /events
@@ -465,6 +479,8 @@ _none_
- `modelconversation`: `{}`
- `modelconversation-<id>`: `{}`
- `brand`: `{ sender, brandId }`
- `draft`: `{ sender, action, name, content }`
- `draft:request`: `{ requestId, action, name, target, sender }`
### Section: /health
@@ -484,35 +500,35 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T22 }`
- Returns: `201 T8`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T25 }`
- Returns: `201 T10`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int`
- Returns: `200 T8`
- Returns: `200 T10`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T22 }`
- Returns: `200 T8`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }`
- Returns: `200 T10`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T22 }`
- Returns: `200 T8`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }`
- Returns: `200 T10`
### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T16`
- Returns: `200 T19`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
- Returns: `201 { ledger: T17 }`
- Returns: `201 { ledger: T20 }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T16`
- Returns: `200 T19`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T16`
- Returns: `200 T19`
### Section: /mcp
@@ -542,16 +558,16 @@ _none_
#### List conversations — `GET /modelconversation`
_none_
- Returns: `200 { conversations: T19[] }`
- Returns: `200 { conversations: T22[] }`
#### Create conversation — `POST /modelconversation`
- Params: `body: { provider: str, model: str }`
- Returns: `201 { conversation: T19 }`
- Returns: `201 { conversation: T22 }`
#### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}`
- Params: `id: int`
- Returns: `200 { conversation: T19 }`
- Returns: `200 { conversation: T22 }`
#### List conversation messages — `GET /modelconversation/{id}/message`
- Params: `id: int`
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
@@ -590,9 +606,9 @@ _none_
#### List orders — `GET /order`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T2[], total: int, offset: int, limit: int }`
- Returns: `200 { orders: T2[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T30[], entityId?: int|null }`
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null }`
- Returns: `201 T1`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
@@ -601,14 +617,14 @@ _none_
- Params: `id: int`
- Returns: `200 T1`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T30[] }`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T33[] }`
- Returns: `200 T1`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T39[] }`
- Returns: `200 { payments: T42[] }`
#### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T39 }`
- Returns: `201 { payment: T42 }`
#### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int`
- Returns: `200 { shipments: T7[] }`
@@ -617,10 +633,10 @@ _none_
- Returns: `201 { shipment: T7 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T33[] }`
- Returns: `200 { shippings: T36[] }`
#### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
- Returns: `201 T28`
- Returns: `201 T31`
### Section: /package
@@ -638,37 +654,37 @@ _none_
#### List POS terminals — `GET /posterminal`
_none_
- Returns: `200 { posterminals: T12[] }`
- Returns: `200 { posterminals: T14[] }`
#### Create POS terminal — `PUT /posterminal`
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
- Returns: `200 { posterminal: T12 }`
- Returns: `200 { posterminal: T14 }`
#### Delete POS terminal — `DELETE /posterminal/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update POS terminal — `PATCH /posterminal/{id}`
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, pairingpin?: str|null }`
- Returns: `200 { posterminal: T12 }`
- Returns: `200 { posterminal: T14 }`
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T50`
- Returns: `200 T55`
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T50`
- Returns: `200 T55`
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T50`
- Returns: `200 T55`
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T50`
- Returns: `200 T55`
### Section: /printer
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T29[] }`
- Returns: `200 { printers: T32[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T23`
- Returns: `201 T26`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -677,22 +693,22 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T23`
- Returns: `200 T26`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T23`
- Returns: `200 T26`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T23`
- Returns: `200 T26`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T38[] }`
- Returns: `200 { propertynames: T41[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T38 }`
- Returns: `201 { propertyname: T41 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -701,7 +717,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T38 }`
- Returns: `200 { propertyname: T41 }`
### Section: /public
@@ -724,17 +740,17 @@ _none_
_none_
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T30[], entityId?: int|null }`
- Returns: `201 T10`
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null }`
- Returns: `201 T12`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get quote — `GET /quote/{id}`
- Params: `id: int`
- Returns: `200 T10`
- Returns: `200 T12`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T30[] }`
- Returns: `200 T10`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T33[] }`
- Returns: `200 T12`
### Section: /serper
@@ -761,10 +777,10 @@ _none_
- Returns: `200 { shipment: T7 }`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T33[] }`
- Returns: `200 { shippings: T36[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `201 T28`
- Returns: `201 T31`
### Section: /shipping
@@ -773,7 +789,7 @@ _none_
- Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `200 T28`
- Returns: `200 T31`
#### List packages of a shipping — `GET /shipping/{id}/package`
- Params: `id: int`
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
@@ -791,28 +807,28 @@ _none_
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T17[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T20[] }`
### Section: /supplier
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T45[] }`
- Returns: `200 { suppliers: T49[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T45 }`
- Returns: `201 { supplier: T49 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T45 }`
- Returns: `200 { supplier: T49 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }[] }`
### Section: /thumb
@@ -827,10 +843,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T36[] }`
- Returns: `200 { units: T39[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T36 }`
- Returns: `200 { unit: T39 }`
### Section: /v1
@@ -855,6 +871,9 @@ _none_
#### List virtual filesystem files — `GET /vfs`
_none_
- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }`
#### Respond to a frontend draft request — `POST /vfs/drafts/response`
- Params: `body: { requestId: str, content?: any, error?: str, files?: any[] }`
- Returns: `200 { success: bool }`
#### Create directory in virtual filesystem — `POST /vfs/mkdir`
- Params: `body: { path: str }`
- Returns: `200 { success: bool, dir: str }`
@@ -881,10 +900,10 @@ _none_
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T49[] }`
- Returns: `200 { warehouses: T54[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }`
- Returns: `201 T47`
- Returns: `201 T52`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }`
@@ -893,10 +912,10 @@ _none_
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int`
- Returns: `200 T47`
- Returns: `200 T52`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T47`
- Returns: `200 T52`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T47`
- Returns: `200 T52`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 182 API endpoints across all modules.
Overview of all 164 API endpoints across all modules.
### Notation
@@ -58,27 +58,6 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Routes
### Section: /.well-known
#### OAuth 2.0 Authorization Server Metadata (RFC 8414) — `GET /.well-known/oauth-authorization-server`
_none_
- Returns: `200`
#### OAuth 2.0 Protected Resource Metadata (RFC 9728) — `GET /.well-known/oauth-protected-resource`
_none_
- Returns: `200`
### Section: /account
#### List accounts — `GET /account`
_none_
- Returns: `200 { accounts: { id: int, companyId: int, type: str, name: str }[] }`
#### Create account — `POST /account`
- Params: `body: { type: str, name: str }`
- Returns: `201 { account: { id: int, companyId: int, type: str, name: str } }`
#### Delete account — `DELETE /account/{id}`
- Params: `id: int`
- Returns: `204 void`
### Section: /article
#### Create article in category — `POST /article`
@@ -223,15 +202,6 @@ _none_
- Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T39[] } }`
### Section: /auth
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: { id: int, email: str, selectedCompanyId: int|null, picture?: str } }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
### Section: /brand
#### List brands — `GET /brand`
@@ -439,30 +409,6 @@ _none_
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T15`
### Section: /mcp
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
_none_
- Returns: `200`
#### List active MCP client authorizations — `GET /mcp/authorizations`
_none_
- Returns: `200`
#### Revoke MCP client authorizations — `DELETE /mcp/authorizations/{clientId}`
- Params: `clientId: str`
- Returns: `200`
#### Get MCP authorization prompt details — `GET /mcp/authorize`
- Params: `query: client_id: str, redirect_uri: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str`
- Returns: `200`
#### Grant or deny MCP client authorization — `POST /mcp/authorize`
- Params: `body: { client_id: str, redirect_uri: str, action: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str }`
- Returns: `200`
#### List registered MCP clients — `GET /mcp/clients`
_none_
- Returns: `200`
#### Delete registered MCP client — `DELETE /mcp/clients/{clientId}`
- Params: `clientId: str`
- Returns: `200`
### Section: /number
#### List number sequences — `GET /number`
@@ -472,26 +418,11 @@ _none_
- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }`
- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
### Section: /oauth
#### OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) — `GET /oauth/authorize`
- Params: `query: client_id?: str, redirect_uri?: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str`
_no response body_
#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect`
_none_
- Returns: `200`
#### Dynamic Client Registration (RFC 7591) — `POST /oauth/register`
- Params: `body: { redirect_uris: str[], client_name?: str }`
- Returns: `201`
#### OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636) — `POST /oauth/token`
- Params: `application/x-www-form-urlencoded`
- Returns: `200`
### Section: /order
#### List orders — `GET /order`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T2[], total: int, offset: int, limit: int }`
- Returns: `200 { orders: T2[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T27[], entityId?: int|null }`
- Returns: `201 T1`

260
docs/api/routes/backup.md Normal file
View File

@@ -0,0 +1,260 @@
# `GET /backup`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List backups and service status
Retrieves the current status of the internal backup service and lists available hourly, daily, and monthly backups.
## Response `200`
Backup service status and listing
```json
{
"status": {
"enabled": true,
"isRunning": true,
"dbProvider": "…",
"mssqlWarning": true,
"backupDir": "…",
"retention": {
"hourlyMax": 1,
"dailyMax": 1,
"monthly": "…"
},
"counts": {
"hourly": 1,
"daily": 1,
"monthly": 1
},
"latestHourly": {
"name": "…",
"path": "…",
"totalSizeBytes": 1,
"timestamp": "2026-08-10T00:00:00.000Z",
"manifest": {
"id": "…",
"timestamp": "2026-08-10T00:00:00.000Z",
"dateKey": "…",
"monthKey": "…",
"dbProvider": "…",
"manual": true,
"files": {
"vfs": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"storage": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"database": {
"name": "…",
"sizeBytes": 1,
"backedUp": true,
"reason": "…"
}
},
"totalSizeBytes": 1,
"durationMs": 1
}
},
"latestDaily": {
"name": "…",
"path": "…",
"totalSizeBytes": 1,
"timestamp": "2026-08-10T00:00:00.000Z",
"manifest": {
"id": "…",
"timestamp": "2026-08-10T00:00:00.000Z",
"dateKey": "…",
"monthKey": "…",
"dbProvider": "…",
"manual": true,
"files": {
"vfs": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"storage": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"database": {
"name": "…",
"sizeBytes": 1,
"backedUp": true,
"reason": "…"
}
},
"totalSizeBytes": 1,
"durationMs": 1
}
},
"latestMonthly": {
"name": "…",
"path": "…",
"totalSizeBytes": 1,
"timestamp": "2026-08-10T00:00:00.000Z",
"manifest": {
"id": "…",
"timestamp": "2026-08-10T00:00:00.000Z",
"dateKey": "…",
"monthKey": "…",
"dbProvider": "…",
"manual": true,
"files": {
"vfs": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"storage": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"database": {
"name": "…",
"sizeBytes": 1,
"backedUp": true,
"reason": "…"
}
},
"totalSizeBytes": 1,
"durationMs": 1
}
}
},
"backups": {
"counts": {
"hourly": 1,
"daily": 1,
"monthly": 1
},
"hourly": [
{
"name": "…",
"path": "…",
"totalSizeBytes": 1,
"timestamp": "2026-08-10T00:00:00.000Z",
"manifest": {
"id": "…",
"timestamp": "2026-08-10T00:00:00.000Z",
"dateKey": "…",
"monthKey": "…",
"dbProvider": "…",
"manual": true,
"files": {
"vfs": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"storage": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"database": {
"name": "…",
"sizeBytes": 1,
"backedUp": true,
"reason": "…"
}
},
"totalSizeBytes": 1,
"durationMs": 1
}
}
],
"daily": [
{
"name": "…",
"path": "…",
"totalSizeBytes": 1,
"timestamp": "2026-08-10T00:00:00.000Z",
"manifest": {
"id": "…",
"timestamp": "2026-08-10T00:00:00.000Z",
"dateKey": "…",
"monthKey": "…",
"dbProvider": "…",
"manual": true,
"files": {
"vfs": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"storage": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"database": {
"name": "…",
"sizeBytes": 1,
"backedUp": true,
"reason": "…"
}
},
"totalSizeBytes": 1,
"durationMs": 1
}
}
],
"monthly": [
{
"name": "…",
"path": "…",
"totalSizeBytes": 1,
"timestamp": "2026-08-10T00:00:00.000Z",
"manifest": {
"id": "…",
"timestamp": "2026-08-10T00:00:00.000Z",
"dateKey": "…",
"monthKey": "…",
"dbProvider": "…",
"manual": true,
"files": {
"vfs": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"storage": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"database": {
"name": "…",
"sizeBytes": 1,
"backedUp": true,
"reason": "…"
}
},
"totalSizeBytes": 1,
"durationMs": 1
}
}
]
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 500 | Internal error — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,64 @@
# `POST /backup/run`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Trigger manual backup
Immediately triggers an on-demand backup snapshot of VFS, storage, and SQLite database.
## Response `200`
Backup triggered successfully
```json
{
"success": true,
"id": "…",
"timestamp": "2026-08-10T00:00:00.000Z",
"promotedDaily": true,
"promotedMonthly": true,
"totalSizeBytes": 1,
"pruneResult": {
"hourlyPruned": 1,
"dailyPruned": 1
},
"manifest": {
"id": "…",
"timestamp": "2026-08-10T00:00:00.000Z",
"dateKey": "…",
"monthKey": "…",
"dbProvider": "…",
"manual": true,
"files": {
"vfs": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"storage": {
"name": "…",
"sizeBytes": 1,
"sourceExists": true
},
"database": {
"name": "…",
"sizeBytes": 1,
"backedUp": true,
"reason": "…"
}
},
"totalSizeBytes": 1,
"durationMs": 1
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 500 | Backup execution failure — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -44,6 +44,8 @@ richer payload.
| `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
| `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
| `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
| `draft` | `{ sender, action, name, content }` | Draft update on `drafts/quote`, `drafts/order`, `drafts/delivery` (`sender` echoes the caller's `x-client-id`) |
| `draft:request` | `{ requestId, action, name, target, sender }` | Server request to frontend to read a draft or list drafts |
The `brand` payload is `{ sender, brandId }`: `sender` echoes the
caller's `x-client-id` header (empty string if absent) so the sender

View File

@@ -6,7 +6,7 @@ List orders
Lists sales orders (`manifest` rows with `type = 'order'`)
for the authenticated user's selected company.
Results are paged, include line items, shipping, payment, and provide total unpaged count.
Results are paged, include line items, shipping, payment, and provide total unpaged count as well as unfiltered count.
## Query parameters
@@ -130,6 +130,7 @@ Order list
}
],
"total": 1,
"unfilteredTotal": 1,
"offset": 1,
"limit": 1
}

View File

@@ -0,0 +1,48 @@
# `POST /vfs/drafts/response`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Respond to a frontend draft request
Used by the frontend to answer a server-initiated `draft:request` SSE event
when reading or listing virtual drafts (`drafts/quote`, `drafts/order`, `drafts/delivery`).
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `requestId` | yes | string | Unique request ID from the draft:request SSE event |
| `content` | no | | File content when action was read |
| `error` | no | string | Error message if draft retrieval failed on frontend |
| `files` | no | any[] | File listing when action was list |
```json
{
"requestId": "…",
"content": "…",
"error": "…",
"files": []
}
```
## Response `200`
Response received and resolved
```json
{
"success": true
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Missing requestId or no company selected — `{"error":"…"}` |
| 404 | Request ID not found or already timed out — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

53
mcp.md
View File

@@ -6,12 +6,12 @@ This document explains the architecture, database schema, OAuth 2.1 authenticati
## 1. Overview & Purpose
The Dashback backend provides an MCP server running over **Streamable HTTP** at `/mcp`. It exposes the internal **`dashback_request`** tool (the same tool provided to the local agent via `src/tools/dashback-request.js`) to external MCP clients such as:
The Dashback backend provides an MCP server running over **Streamable HTTP** at `/mcp`. It exposes internal tools (`dashback_request`, `dash-subagent`, and `search-articles`) to external MCP clients such as:
- Visual Studio Code / GitHub Copilot
- Claude Desktop
- Cursor / Windsurf
All operations executed via `dashback_request` are cryptographically authenticated and scoped to the authorizing Dashback user (`google_auth_id`) and their selected company.
All operations executed via these tools are cryptographically authenticated and scoped to the authorizing Dashback user (`google_auth_id`) and their selected company.
---
@@ -176,9 +176,12 @@ The client connects to `/mcp` with `Authorization: Bearer <access_token>`.
---
## 5. The Exposed Tool: `dashback_request`
## 5. The Exposed Tools
The MCP server registers the exact same tool exposed to the internal local agent (`src/tools/dashback-request.js`):
The MCP server exposes three tools to external clients:
### 1. `dashback_request`
Performs HTTP requests against the Dashback API:
```javascript
server.registerTool('dashback_request', {
@@ -191,14 +194,44 @@ server.registerTool('dashback_request', {
body: z.union([z.string(), z.record(z.string(), z.any())]).nullable().optional(),
note: z.string().nullable().optional(),
},
}, async ({ method = 'GET', path, query = null, body = null, note }) => {
// Generates a 5-minute JWT signed with env.jwtSecret scoped to the authenticated user's ID
// Performs fetch against http://127.0.0.1:<port>/api<path>
// Returns status and truncated body (up to 20,000 characters)
});
}, async ({ method = 'GET', path, query = null, body = null, note }) => { ... });
```
Because the internal fetch uses a JWT containing the user's `google_auth_id`, all Dashback route-level permissions, company isolation (`req.user.selectedCompanyId`), audit logging, and data scoping apply automatically.
### 2. `dash-subagent`
Delegates a self-contained task to an isolated subagent with access to Dashback tools and VFS:
```javascript
server.registerTool('dash-subagent', {
title: 'Dashback Subagent',
description: 'Delegate a task to an isolated subagent with access to Dashback tools and VFS.',
inputSchema: {
task: z.string().describe('Specific instructions and goal for the subagent.'),
skills: z.array(z.string()).nullable().optional().describe('Array of SKILL names or slugs to inject.'),
conversation_id: z.number().int().positive().nullable().optional().describe('Existing subagent conversation ID to follow up on, or null to create a new one.'),
max_iterations: z.number().int().min(1).max(30).nullable().optional().describe('Safety bound on tool loops (default 15, max 30).'),
model: z.string().nullable().optional().describe('Optional OpenRouter model override.'),
note: z.string().nullable().optional(),
},
}, async ({ task, skills, conversation_id, max_iterations, model, note }, extra) => { ... });
```
### 3. `search-articles`
Searches articles across name, brand, EAN, SKU, and supplier SKU (AND logic, ordered by name):
```javascript
server.registerTool('search-articles', {
title: 'Search Articles',
description: 'Search articles by name, brand, EAN, SKU, or supplier SKU (AND logic, ordered by name).',
inputSchema: {
searchquery: z.string().nullable().optional().describe('Keywords matching name, brand, EAN, SKU, or supplier SKU (AND logic).'),
excludewords: z.string().nullable().optional().describe('Keywords to exclude (AND logic).'),
offset: z.number().int().min(0).nullable().optional().describe('Pagination offset (default 0).'),
limit: z.number().int().min(1).max(20).nullable().optional().describe('Window size 1–20 (default 3).'),
},
}, async ({ searchquery, excludewords, offset, limit }) => { ... });
```
Because all tool executions use credentials scoped to the authenticated user's `google_auth_id`, all Dashback route-level permissions, company isolation (`req.user.selectedCompanyId`), audit logging, and data scoping apply automatically.
---

View File

@@ -8,7 +8,7 @@
"main": "index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch-path=./src --watch-path=./.env --watch-path=../dashdbschema/src --watch-path=../llmorbiter/src src/index.js",
"dev": "cross-env DEV_BACKEND_MONITOR=true node --watch-path=./src --watch-path=./.env --watch-path=../dashdbschema/src --watch-path=../llmorbiter/src src/index.js",
"manage-users": "node scripts/manageUsers.js",
"wipe-company": "node scripts/wipeCompany.js",
"docs:api": "node scripts/generateApiDocs.js",

View File

@@ -53,6 +53,24 @@ function isRouteOmitted(method, apiPath) {
if (apiPath === '/vfs' || apiPath.startsWith('/vfs/')) {
return true;
}
if (apiPath === '/backup' || apiPath.startsWith('/backup/')) {
return true;
}
if (apiPath === '/mcp' || apiPath.startsWith('/mcp/')) {
return true;
}
if (apiPath === '/oauth' || apiPath.startsWith('/oauth/')) {
return true;
}
if (apiPath === '/.well-known' || apiPath.startsWith('/.well-known/')) {
return true;
}
if (apiPath === '/account' || apiPath.startsWith('/account/')) {
return true;
}
if (apiPath === '/auth' || apiPath.startsWith('/auth/')) {
return true;
}
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
}

View File

@@ -0,0 +1,153 @@
import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert';
import env from '../src/config/env.js';
import {
createHourlyBackup,
listBackups,
pruneBackups,
getBackupServiceStatus,
startBackupService,
stopBackupService,
resolveBackupPaths,
} from '../src/services/backupService.js';
import { closeStore } from '../src/db/store.js';
async function runTests() {
console.log('--- Starting Backup Service Verification ---');
// 1. Verify path resolution and directories
const paths = resolveBackupPaths();
console.log(`Backup root dir: ${paths.rootDir}`);
assert(paths.rootDir.endsWith('data/backups'), 'Backup root should be data/backups');
// 2. Execute a backup
console.log('\n[Test 1] Running createHourlyBackup()...');
const backupResult = await createHourlyBackup({ manual: true });
assert(backupResult.success === true, 'Backup should report success');
console.log(`Backup created: ${backupResult.id}, size: ${(backupResult.totalSizeBytes / 1024 / 1024).toFixed(2)} MB`);
console.log(`Promoted to daily: ${backupResult.promotedDaily}, Promoted to monthly: ${backupResult.promotedMonthly}`);
// Verify files in the created hourly backup
const hourlyBackupFolder = path.join(paths.hourlyDir, backupResult.id);
assert(fs.existsSync(hourlyBackupFolder), 'Hourly backup folder should exist');
assert(fs.existsSync(path.join(hourlyBackupFolder, 'vfs.tar.gz')), 'vfs.tar.gz should exist');
assert(fs.existsSync(path.join(hourlyBackupFolder, 'storage.tar')), 'storage.tar should exist');
assert(fs.existsSync(path.join(hourlyBackupFolder, 'manifest.json')), 'manifest.json should exist');
if (env.dbProvider === 'sqlite') {
const dbName = path.basename(env.sqlite.filename);
assert(fs.existsSync(path.join(hourlyBackupFolder, dbName)), `${dbName} should exist`);
console.log(`Verified SQLite database backup file: ${dbName}`);
}
// Verify daily backup directory
const { dateKey, monthKey } = backupResult.manifest;
const dailyFolder = path.join(paths.dailyDir, `backup-${dateKey}`);
assert(fs.existsSync(dailyFolder), 'Daily backup folder should exist');
assert(fs.existsSync(path.join(dailyFolder, 'manifest.json')), 'Daily manifest should exist');
// Verify monthly backup directory
const monthlyFolder = path.join(paths.monthlyDir, `backup-${monthKey}`);
assert(fs.existsSync(monthlyFolder), 'Monthly backup folder should exist');
assert(fs.existsSync(path.join(monthlyFolder, 'manifest.json')), 'Monthly manifest should exist');
// 3. Test listBackups and getBackupServiceStatus
console.log('\n[Test 2] Testing listBackups() and getBackupServiceStatus()...');
const status = getBackupServiceStatus();
assert(typeof status.enabled === 'boolean', 'status.enabled should be boolean');
assert(status.retention.hourlyMax === 50, 'hourly retention should be 50');
assert(status.retention.dailyMax === 50, 'daily retention should be 50');
const listing = listBackups();
assert(listing.hourly.length >= 1, 'Should have at least 1 hourly backup');
assert(listing.daily.length >= 1, 'Should have at least 1 daily backup');
assert(listing.monthly.length >= 1, 'Should have at least 1 monthly backup');
console.log(`Counts -> hourly: ${listing.counts.hourly}, daily: ${listing.counts.daily}, monthly: ${listing.counts.monthly}`);
// 4. Test Retention Pruning (exceeding 50 entries)
console.log('\n[Test 3] Testing retention pruning for > 50 entries...');
// Create dummy hourly backup folders
for (let i = 1; i <= 55; i++) {
const dummyName = `backup-2020-01-01_${String(i).padStart(2, '0')}-00-00`;
const dummyPath = path.join(paths.hourlyDir, dummyName);
fs.mkdirSync(dummyPath, { recursive: true });
fs.writeFileSync(path.join(dummyPath, 'manifest.json'), '{}');
}
// Create dummy daily backup folders
for (let i = 1; i <= 55; i++) {
const dummyName = `backup-2020-01-${String(i).padStart(2, '0')}`;
const dummyPath = path.join(paths.dailyDir, dummyName);
fs.mkdirSync(dummyPath, { recursive: true });
fs.writeFileSync(path.join(dummyPath, 'manifest.json'), '{}');
}
// Create dummy monthly backup folders (should never be pruned!)
for (let i = 1; i <= 55; i++) {
const dummyName = `backup-2010-${String(i).padStart(2, '0')}`;
const dummyPath = path.join(paths.monthlyDir, dummyName);
fs.mkdirSync(dummyPath, { recursive: true });
fs.writeFileSync(path.join(dummyPath, 'manifest.json'), '{}');
}
const pruneResult = pruneBackups();
console.log(`Pruned ${pruneResult.hourlyPruned} hourly, ${pruneResult.dailyPruned} daily`);
assert(pruneResult.hourlyPruned >= 5, 'Should have pruned at least 5 hourly backups');
assert(pruneResult.dailyPruned >= 5, 'Should have pruned at least 5 daily backups');
const listingAfterPrune = listBackups();
assert(listingAfterPrune.counts.hourly <= 50, `Hourly count should be <= 50, got ${listingAfterPrune.counts.hourly}`);
assert(listingAfterPrune.counts.daily <= 50, `Daily count should be <= 50, got ${listingAfterPrune.counts.daily}`);
assert(listingAfterPrune.counts.monthly >= 55, `Monthly count should NOT be pruned (got ${listingAfterPrune.counts.monthly})`);
console.log('Retention pruning verified: 50 hourly max, 50 daily max, monthly preserved forever.');
// Clean up dummy test backups
for (let i = 1; i <= 55; i++) {
const hPath = path.join(paths.hourlyDir, `backup-2020-01-01_${String(i).padStart(2, '0')}-00-00`);
if (fs.existsSync(hPath)) fs.rmSync(hPath, { recursive: true, force: true });
const dPath = path.join(paths.dailyDir, `backup-2020-01-${String(i).padStart(2, '0')}`);
if (fs.existsSync(dPath)) fs.rmSync(dPath, { recursive: true, force: true });
const mPath = path.join(paths.monthlyDir, `backup-2010-${String(i).padStart(2, '0')}`);
if (fs.existsSync(mPath)) fs.rmSync(mPath, { recursive: true, force: true });
}
// 5. Test MSSQL warning behavior
console.log('\n[Test 4] Testing MSSQL console warning behavior...');
const originalProvider = env.dbProvider;
let warningCaptured = false;
const origWarn = console.warn;
console.warn = (...args) => {
const text = args.join(' ');
if (text.includes('MSSQL database backup is not implemented')) {
warningCaptured = true;
}
origWarn(...args);
};
try {
env.dbProvider = 'mssql';
const mssqlBackup = await createHourlyBackup({ manual: true });
assert(warningCaptured, 'Console warning must be printed when running on MSSQL');
assert(mssqlBackup.manifest.files.database.backedUp === false, 'Database backup must be skipped on MSSQL');
console.log('MSSQL warning and database skip verified.');
} finally {
console.warn = origWarn;
env.dbProvider = originalProvider;
}
// 6. Test Lifecycle methods
console.log('\n[Test 5] Testing startBackupService() and stopBackupService()...');
startBackupService();
assert(getBackupServiceStatus().isRunning === true, 'Service should be running');
await stopBackupService();
assert(getBackupServiceStatus().isRunning === false, 'Service should be stopped');
console.log('Service lifecycle start/stop verified.');
await closeStore();
console.log('\n--- ALL BACKUP SERVICE TESTS PASSED ---');
}
runTests().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -17,6 +17,8 @@ import { shutdownJavascriptSandboxWorkers } from '../services/javascriptSandbox.
import { getOrCreateCert } from '../util/cert.js';
import { createLogger } from '../util/logger.js';
import v1Router from '../v1/index.js';
import { startBackendMonitor, stopBackendMonitor, isDevMode } from '../backendmonitor/server.js';
import { startBackupService, stopBackupService } from '../services/backupService.js';
const apiLog = createLogger('api');
@@ -236,6 +238,20 @@ export async function startApi() {
}
}
if (isDevMode()) {
try {
await startBackendMonitor();
} catch (err) {
apiLog.error('Failed to start Backend Monitor Interface:', err.message);
}
}
try {
startBackupService();
} catch (err) {
apiLog.error('Failed to start Backup Service:', err.message);
}
return server;
})();
@@ -247,6 +263,7 @@ export async function startApi() {
}
export async function stopApi() {
await stopBackendMonitor();
if (!server && !httpsServer) return;
// Long-lived SSE (and keep-alive) sockets otherwise block server.close()
// forever — node --watch hangs on "Waiting for graceful termination...".
@@ -257,6 +274,7 @@ export async function stopApi() {
await shutdownImageCropWorkers();
// Same for the pooled sandbox threads that run `javascript_execute` / applets.
await shutdownJavascriptSandboxWorkers();
await stopBackupService();
const closers = [];
if (server) {
const s = server;

View File

@@ -1780,6 +1780,178 @@
* isPdf:
* type: boolean
* description: True if file is a PDF document
* BackupFileDetail:
* type: object
* required: [name, sizeBytes, sourceExists]
* properties:
* name:
* type: string
* sizeBytes:
* type: integer
* sourceExists:
* type: boolean
* BackupDatabaseDetail:
* type: object
* required: [name, sizeBytes, backedUp]
* properties:
* name:
* type: string
* nullable: true
* sizeBytes:
* type: integer
* backedUp:
* type: boolean
* reason:
* type: string
* nullable: true
* BackupManifest:
* type: object
* required: [id, timestamp, dateKey, monthKey, dbProvider, manual, files, totalSizeBytes, durationMs]
* properties:
* id:
* type: string
* timestamp:
* type: string
* format: date-time
* dateKey:
* type: string
* monthKey:
* type: string
* dbProvider:
* type: string
* manual:
* type: boolean
* files:
* type: object
* required: [vfs, storage, database]
* properties:
* vfs:
* $ref: '#/components/schemas/BackupFileDetail'
* storage:
* $ref: '#/components/schemas/BackupFileDetail'
* database:
* $ref: '#/components/schemas/BackupDatabaseDetail'
* totalSizeBytes:
* type: integer
* durationMs:
* type: integer
* BackupEntry:
* type: object
* required: [name, path, totalSizeBytes, timestamp]
* properties:
* name:
* type: string
* path:
* type: string
* totalSizeBytes:
* type: integer
* timestamp:
* type: string
* format: date-time
* nullable: true
* manifest:
* $ref: '#/components/schemas/BackupManifest'
* nullable: true
* BackupTierCounts:
* type: object
* required: [hourly, daily, monthly]
* properties:
* hourly:
* type: integer
* daily:
* type: integer
* monthly:
* type: integer
* BackupRetentionConfig:
* type: object
* required: [hourlyMax, dailyMax, monthly]
* properties:
* hourlyMax:
* type: integer
* dailyMax:
* type: integer
* monthly:
* type: string
* BackupStatus:
* type: object
* required: [enabled, isRunning, dbProvider, mssqlWarning, backupDir, retention, counts]
* properties:
* enabled:
* type: boolean
* isRunning:
* type: boolean
* dbProvider:
* type: string
* mssqlWarning:
* type: boolean
* backupDir:
* type: string
* retention:
* $ref: '#/components/schemas/BackupRetentionConfig'
* counts:
* $ref: '#/components/schemas/BackupTierCounts'
* latestHourly:
* $ref: '#/components/schemas/BackupEntry'
* nullable: true
* latestDaily:
* $ref: '#/components/schemas/BackupEntry'
* nullable: true
* latestMonthly:
* $ref: '#/components/schemas/BackupEntry'
* nullable: true
* BackupListing:
* type: object
* required: [counts, hourly, daily, monthly]
* properties:
* counts:
* $ref: '#/components/schemas/BackupTierCounts'
* hourly:
* type: array
* items:
* $ref: '#/components/schemas/BackupEntry'
* daily:
* type: array
* items:
* $ref: '#/components/schemas/BackupEntry'
* monthly:
* type: array
* items:
* $ref: '#/components/schemas/BackupEntry'
* BackupOverviewResponse:
* type: object
* required: [status, backups]
* properties:
* status:
* $ref: '#/components/schemas/BackupStatus'
* backups:
* $ref: '#/components/schemas/BackupListing'
* BackupRunResult:
* type: object
* required: [success, id, timestamp, promotedDaily, promotedMonthly, totalSizeBytes, pruneResult, manifest]
* properties:
* success:
* type: boolean
* id:
* type: string
* timestamp:
* type: string
* format: date-time
* promotedDaily:
* type: boolean
* promotedMonthly:
* type: boolean
* totalSizeBytes:
* type: integer
* pruneResult:
* type: object
* required: [hourlyPruned, dailyPruned]
* properties:
* hourlyPruned:
* type: integer
* dailyPruned:
* type: integer
* manifest:
* $ref: '#/components/schemas/BackupManifest'
*/
export {};

75
src/api/routes/backup.js Normal file
View File

@@ -0,0 +1,75 @@
import { listBackups, getBackupServiceStatus, createHourlyBackup } from '../../services/backupService.js';
/**
* Registers internal backup service status and execution endpoints.
* @param {import('express').Application} app - Express app
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
*/
export function register(app, basePath = '') {
/**
* @openapi
* /backup:
* get:
* tags: [Backup]
* summary: List backups and service status
* description: Retrieves the current status of the internal backup service and lists available hourly, daily, and monthly backups.
* responses:
* 200:
* description: Backup service status and listing
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/BackupOverviewResponse'
* 500:
* description: Internal error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /backup
// MARK: handles GET /backup to list backups and service status
app.get(`${basePath}/backup`, async (_req, res) => {
try {
const status = getBackupServiceStatus();
const backups = listBackups();
res.status(200).json({ status, backups });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /backup/run:
* post:
* tags: [Backup]
* summary: Trigger manual backup
* description: Immediately triggers an on-demand backup snapshot of VFS, storage, and SQLite database.
* responses:
* 200:
* description: Backup triggered successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/BackupRunResult'
* 500:
* description: Backup execution failure
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /backup/run
// MARK: handles POST /backup/run to trigger manual backup
app.post(`${basePath}/backup/run`, async (_req, res) => {
try {
const result = await createHourlyBackup({ manual: true });
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -53,6 +53,8 @@ export function register(app, basePath = '') {
* | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
* | `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
* | `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
* | `draft` | `{ sender, action, name, content }` | Draft update on `drafts/quote`, `drafts/order`, `drafts/delivery` (`sender` echoes the caller's `x-client-id`) |
* | `draft:request` | `{ requestId, action, name, target, sender }` | Server request to frontend to read a draft or list drafts |
*
* The `brand` payload is `{ sender, brandId }`: `sender` echoes the
* caller's `x-client-id` header (empty string if absent) so the sender
@@ -95,6 +97,10 @@ export function register(app, basePath = '') {
* payload: "{}"
* - name: brand
* payload: "{ sender, brandId }"
* - name: draft
* payload: "{ sender, action, name, content }"
* - name: draft:request
* payload: "{ requestId, action, name, target, sender }"
* responses:
* 200:
* description: |
@@ -157,5 +163,6 @@ export function handleSse(req, res) {
// Send an initial ping to establish frame
res.write(': ping\n\n');
subscribe(res, { companyId, userId: req.user?.id }, req);
const clientId = req.query?.cid || req.headers?.['x-client-id'] || null;
subscribe(res, { companyId, userId: req.user?.id, clientId }, req);
}

View File

@@ -9,6 +9,9 @@ import env from '../../config/env.js';
import * as mcpDb from '../../db/mcp.js';
import * as googleAuthDb from '../../db/googleAuth.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from '../../tools/prompts.js';
import { createSearchArticlesTool } from '../../tools/search-articles.js';
import { createSubagentTool } from '../../tools/subagent.js';
import { resolveDefaultModel } from '../../tools/llm-key.js';
const MAX_RESPONSE_CHARS = 20_000;
const REQUEST_TIMEOUT_MS = 30_000;
@@ -177,6 +180,89 @@ function createDashbackMcpServer({ user }) {
}
);
server.registerTool(
'dash-subagent',
{
title: 'Dashback Subagent',
description: TOOL_DESCRIPTIONS.subagent || 'Delegate a task to an isolated subagent with access to Dashback tools and VFS.',
inputSchema: {
task: z.string().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.task || 'Specific instructions and goal for the subagent.'),
skills: z.array(z.string()).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.skills || 'Array of SKILL names or slugs to inject into the subagent prompt.'),
conversation_id: z.number().int().positive().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.conversation_id || 'Existing subagent conversation ID to follow up on, or null to create a new one.'),
max_iterations: z.number().int().min(1).max(30).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.max_iterations || 'Safety bound on tool loops (default 15, max 30).'),
model: z.string().nullable().optional().describe('Optional OpenRouter model override. Defaults to company default model.'),
note: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.note || 'Status message shown during execution.'),
},
},
async ({ task, skills = null, conversation_id = null, max_iterations = null, model = null, note = null }, extra = {}) => {
const freshUser = await googleAuthDb.getById(user.id);
const companyId = freshUser?.selectedCompanyId || user.selectedCompanyId;
if (!companyId) throw new Error('No company selected');
const parentModel = model || (await resolveDefaultModel(user.id));
const tool = createSubagentTool({
googleAuthId: user.id,
companyId,
conversationId: null,
parentModel,
depth: 0,
});
console.log(`[mcp] Tool invoke dash-subagent: task="${task?.slice(0, 100)}"`);
const signals = [AbortSignal.timeout(300_000)];
if (extra?.signal) signals.push(extra.signal);
const signal = AbortSignal.any(signals);
const result = await tool.invoke(
{ task, skills, conversation_id, max_iterations, note },
{ signal }
);
let text = JSON.stringify(result, null, 2);
if (text.length > MAX_RESPONSE_CHARS) {
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
}
return {
content: [{ type: 'text', text }],
};
}
);
server.registerTool(
'search-articles',
{
title: 'Search Articles',
description: TOOL_DESCRIPTIONS.search_articles || 'Search articles by name, brand, EAN, SKU, or supplier SKU (AND logic, ordered by name).',
inputSchema: {
searchquery: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.searchquery || 'Keywords matching name, brand, EAN, SKU, or supplier SKU (AND logic).'),
excludewords: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.excludewords || 'Keywords to exclude (AND logic).'),
offset: z.number().int().min(0).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.offset || 'Pagination offset (default 0).'),
limit: z.number().int().min(1).max(20).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.limit || 'Window size 1–20 (default 3).'),
},
},
async ({ searchquery = '', excludewords = '', offset = 0, limit = 3 } = {}) => {
const freshUser = await googleAuthDb.getById(user.id);
const companyId = freshUser?.selectedCompanyId || user.selectedCompanyId;
if (!companyId) throw new Error('No company selected');
console.log(`[mcp] Tool invoke search-articles: query="${searchquery ?? ''}" exclude="${excludewords ?? ''}" offset=${offset} limit=${limit}`);
const tool = createSearchArticlesTool({ googleAuthId: user.id, companyId });
const result = await tool.invoke({
searchquery: searchquery ?? '',
excludewords: excludewords ?? '',
offset: offset ?? 0,
limit: limit ?? 3,
});
let text = JSON.stringify(result, null, 2);
if (text.length > MAX_RESPONSE_CHARS) {
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
}
return {
content: [{ type: 'text', text }],
};
}
);
return server;
}

View File

@@ -44,7 +44,7 @@ export function register(app, basePath = '') {
* description: |
* Lists sales orders (`manifest` rows with `type = 'order'`)
* for the authenticated user's selected company.
* Results are paged, include line items, shipping, payment, and provide total unpaged count.
* Results are paged, include line items, shipping, payment, and provide total unpaged count as well as unfiltered count.
* security:
* - cookieAuth: []
* parameters:
@@ -93,7 +93,7 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* type: object
* required: [orders, total, offset, limit]
* required: [orders, total, unfilteredTotal, offset, limit]
* properties:
* orders:
* type: array
@@ -102,6 +102,9 @@ export function register(app, basePath = '') {
* total:
* type: integer
* description: Total number of filtered orders
* unfilteredTotal:
* type: integer
* description: Total number of orders without any filters applied
* offset:
* type: integer
* description: Number of orders skipped

View File

@@ -4,6 +4,13 @@ import multer from 'multer';
import { emit } from '../sse.js';
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js';
import sharp from 'sharp';
import {
parseDraftPath,
requestDraftRead,
requestDraftList,
handleDraftResponse,
notifyDraftUpdate,
} from '../../services/draftManager.js';
// #region Helpers
@@ -465,7 +472,18 @@ export function register(app, basePath = '') {
}
return list;
}
const files = walk(vfsRoot).sort((a, b) => a.name.localeCompare(b.name));
const diskFiles = walk(vfsRoot);
let drafts = [];
try {
drafts = await requestDraftList({
companyId: selected.companyId,
userId: req.user.id,
clientId: req.headers?.['x-client-id'],
});
} catch (err) {
console.error('Failed to request drafts from frontend:', err);
}
const files = [...diskFiles, ...drafts].sort((a, b) => a.name.localeCompare(b.name));
return res.json({ files });
} catch (error) {
const status = vfsFileErrorStatus(error);
@@ -580,6 +598,7 @@ export function register(app, basePath = '') {
const parsed = parseVfsRef(relPath);
if (parsed.error) continue;
if (isReadOnlyDatabasePath(parsed.name)) continue;
if (parseDraftPath(parsed.name).isFolder || parseDraftPath(parsed.name).isDraft) continue;
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) continue;
@@ -680,6 +699,9 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsed.name)) {
return res.status(403).json({ error: 'Cannot create directories in read-only database paths' });
}
if (parseDraftPath(parsed.name).isFolder || parseDraftPath(parsed.name).isDraft) {
return res.status(403).json({ error: 'Cannot create directories in virtual drafts folder' });
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
@@ -788,6 +810,14 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) {
return res.status(403).json({ error: 'Cannot rename read-only database paths' });
}
if (
parseDraftPath(parsedOld.name).isFolder ||
parseDraftPath(parsedOld.name).isDraft ||
parseDraftPath(parsedNew.name).isFolder ||
parseDraftPath(parsedNew.name).isDraft
) {
return res.status(403).json({ error: 'Cannot rename virtual draft files or folders' });
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
@@ -931,6 +961,50 @@ export function register(app, basePath = '') {
return res.status(403).json({ error: 'Cannot modify read-only database files' });
}
const draftInfo = parseDraftPath(parsed.name);
if (draftInfo.isFolder) {
return res.status(400).json({ error: 'Cannot write directly to directory' });
}
if (draftInfo.isDraft) {
let strContent;
if (typeof req.body?.content === 'string') {
strContent = req.body.content;
} else if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
strContent = req.body.content !== undefined
? (typeof req.body.content === 'string' ? req.body.content : JSON.stringify(req.body.content))
: JSON.stringify(req.body);
} else if (Buffer.isBuffer(req.body)) {
strContent = req.body.toString('utf8');
} else if (typeof req.body === 'string') {
strContent = req.body;
} else {
return res.status(400).json({ error: 'No content provided' });
}
let parsedContent = strContent;
try {
parsedContent = JSON.parse(strContent);
} catch {}
const updated = notifyDraftUpdate({
companyId: selected.companyId,
userId: req.user.id,
clientId: req.headers?.['x-client-id'],
name: draftInfo.name,
content: parsedContent,
});
return res.json({
success: true,
file: {
name: `drafts/${draftInfo.name}`,
size: updated.size,
mime: 'application/json; charset=utf-8',
updated_at: updated.updatedAt,
},
});
}
let buffer;
let mime = parsed.mime;
if (typeof req.body?.content === 'string') {
@@ -1043,6 +1117,10 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsed.name)) {
return res.status(403).json({ error: 'Cannot delete read-only database files' });
}
const draftDeleteInfo = parseDraftPath(parsed.name);
if (draftDeleteInfo.isFolder || draftDeleteInfo.isDraft) {
return res.status(403).json({ error: 'Cannot delete virtual draft files' });
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
@@ -1210,6 +1288,35 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: parsed.error });
}
const draftGetInfo = parseDraftPath(parsed.name);
if (draftGetInfo.isFolder) {
return res.status(400).json({ error: 'Cannot download a directory directly' });
}
if (draftGetInfo.isDraft) {
try {
const content = await requestDraftRead({
companyId: selected.companyId,
userId: req.user.id,
clientId: req.headers?.['x-client-id'],
name: draftGetInfo.name,
});
const strContent = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
const buffer = Buffer.from(strContent, 'utf8');
const effectiveMime = 'application/json; charset=utf-8';
const fallbackName = `${draftGetInfo.name}.json`;
res.setHeader('Content-Type', effectiveMime);
res.setHeader('Content-Length', String(buffer.length));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.setHeader('Content-Disposition', `inline; filename="${fallbackName}"`);
return res.status(200).send(buffer);
} catch (err) {
const status = err.message?.includes('timed out') ? 504 : 404;
return res.status(status).json({ error: err.message });
}
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
@@ -1284,4 +1391,77 @@ export function register(app, basePath = '') {
});
// #endregion
/**
* @openapi
* /vfs/drafts/response:
* post:
* tags: [Vfs]
* summary: Respond to a frontend draft request
* description: |
* Used by the frontend to answer a server-initiated `draft:request` SSE event
* when reading or listing virtual drafts (`drafts/quote`, `drafts/order`, `drafts/delivery`).
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [requestId]
* properties:
* requestId:
* type: string
* description: Unique request ID from the draft:request SSE event
* content:
* description: File content when action was read
* error:
* type: string
* description: Error message if draft retrieval failed on frontend
* files:
* type: array
* description: File listing when action was list
* responses:
* 200:
* description: Response received and resolved
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* 400:
* description: Missing requestId or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Request ID not found or already timed out
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /vfs/drafts/response
app.post(`${basePath}/vfs/drafts/response`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const { requestId, content, error, files } = req.body || {};
if (!requestId) {
return res.status(400).json({ error: 'requestId is required' });
}
const result = handleDraftResponse({ requestId, content, error, files });
if (!result.handled) {
return res.status(404).json({ error: result.error || 'Request expired or not found' });
}
return res.json({ success: true });
});
// #endregion
}

View File

@@ -3,7 +3,7 @@
* after a successful mutation so other tabs/users can refetch.
*/
/** @typedef {{ res: import('express').Response, companyId: number, userId: number, keepAlive: NodeJS.Timeout }} SseClient */
/** @typedef {{ res: import('express').Response, companyId: number, userId: number, clientId: string|null, keepAlive: NodeJS.Timeout }} SseClient */
/** @type {Set<SseClient>} */
const clients = new Set();
@@ -13,16 +13,18 @@ const KEEP_ALIVE_MS = 20_000;
/**
* Register an open SSE response. Caller must write the initial headers.
* @param {import('express').Response} res
* @param {{ companyId: number, userId: number }} meta
* @param {{ companyId: number, userId: number, clientId?: string|null }} meta
* @param {import('express').Request} [req]
* @returns {() => void} unsubscribe
*/
export function subscribe(res, { companyId, userId }, req) {
export function subscribe(res, { companyId, userId, clientId }, req) {
const resolvedClientId = clientId || req?.headers?.['x-client-id'] || req?.query?.cid || null;
/** @type {SseClient} */
const client = {
res,
companyId,
userId,
clientId: resolvedClientId ? String(resolvedClientId) : null,
keepAlive: setInterval(() => {
if (res.writableEnded) return;
res.write(': heartbeat\n\n');
@@ -55,15 +57,59 @@ export function closeAll() {
}
/**
* Broadcast a named signal to subscribers of the given company.
* @param {string} signal - SSE event name (e.g. `category`)
* @param {{ companyId: number, data?: string|object }} options
* Checks whether an active SSE client connection exists matching the given criteria.
* @param {{ companyId?: number, userId?: number, clientId?: string }} [options]
* @returns {boolean}
*/
export function emit(signal, { companyId, data }) {
export function hasActiveClient({ companyId, userId, clientId } = {}) {
for (const client of clients) {
if (companyId != null && client.companyId !== companyId) continue;
if (userId != null && client.userId !== userId) continue;
if (clientId != null && client.clientId !== clientId) continue;
if (!client.res.writableEnded) return true;
}
return false;
}
/**
* Broadcast or target a named signal to subscribers.
* If `targetClientId` or `targetUserId` is specified, sends only to matching clients of that user.
*
* @param {string} signal - SSE event name (e.g. `category`, `draft`, `draft:request`)
* @param {{ companyId: number, data?: string|object, targetClientId?: string, targetUserId?: number }} options
*/
export function emit(signal, { companyId, data, targetClientId, targetUserId }) {
if (companyId == null) return;
const payload = data == null ? '{}' : (typeof data === 'string' ? data : JSON.stringify(data));
const frame = `event: ${signal}\ndata: ${payload}\n\n`;
if (targetClientId || targetUserId != null) {
let sent = false;
if (targetClientId) {
for (const client of clients) {
if (client.companyId !== companyId) continue;
if (targetUserId != null && client.userId !== targetUserId) continue;
if (client.clientId === targetClientId && !client.res.writableEnded) {
client.res.write(frame);
sent = true;
}
}
}
if (!sent && targetUserId != null) {
for (const client of clients) {
if (client.companyId !== companyId) continue;
if (client.userId === targetUserId && !client.res.writableEnded) {
client.res.write(frame);
sent = true;
}
}
}
if (sent) return;
// Targeted to a specific user but no active client was found: do not blast to other users
if (targetUserId != null) return;
}
for (const client of clients) {
if (client.companyId !== companyId) continue;
if (client.res.writableEnded) continue;

View File

@@ -0,0 +1,357 @@
import env from '../config/env.js';
import { getProvider } from '../db/store.js';
/**
* Returns dialect ('sqlite' or 'mssql')
*/
export function getDialect(provider) {
if (provider?.dialect === 'sqlite3') return 'sqlite';
if (provider?.dialect === 'mssql') return 'mssql';
return env.dbProvider === 'sqlite' || env.dbProvider === 'better-sqlite3' ? 'sqlite' : 'mssql';
}
function sanitizeValue(val) {
if (val === null || val === undefined) return null;
if (Buffer.isBuffer(val) || val instanceof Uint8Array) {
return `<Buffer ${val.length} bytes>`;
}
if (typeof val === 'bigint') {
return val.toString();
}
return val;
}
export function sanitizeRow(row) {
if (!row || typeof row !== 'object') return row;
const sanitized = {};
for (const [key, val] of Object.entries(row)) {
sanitized[key] = sanitizeValue(val);
}
return sanitized;
}
export async function getDatabaseInfo() {
const provider = await getProvider();
const dialect = getDialect(provider);
return {
dialect,
database: dialect === 'sqlite' ? env.sqlite.filename : env.mssql.database,
server: dialect === 'sqlite' ? 'local' : (env.mssql.server || 'localhost'),
port: env.monitorPort,
};
}
/**
* Returns list of tables in the database with their row counts.
*/
export async function getTables() {
const provider = await getProvider();
const dialect = getDialect(provider);
let tablesQuery = '';
if (dialect === 'sqlite') {
tablesQuery = `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name ASC;`;
} else {
tablesQuery = `SELECT TABLE_NAME as name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME NOT LIKE 'sys%' AND TABLE_NAME != 'sysdiagrams' ORDER BY TABLE_NAME ASC;`;
}
const result = await provider.query(tablesQuery);
const tableList = (result.rows || []).map((r) => ({
name: r.name,
rowCount: 0,
}));
// Fetch row counts concurrently
await Promise.all(
tableList.map(async (t) => {
try {
const countRes = await provider.query(`SELECT COUNT(*) as cnt FROM [${t.name}]`);
t.rowCount = Number(countRes.rows[0]?.cnt ?? 0);
} catch {
t.rowCount = 0;
}
})
);
return tableList;
}
/**
* Returns column schema for a given table.
*/
export async function getTableSchema(tableName) {
const provider = await getProvider();
const dialect = getDialect(provider);
if (dialect === 'sqlite') {
const res = await provider.query(`PRAGMA table_info("${tableName}")`);
return (res.rows || []).map((row) => ({
name: row.name,
type: (row.type || 'TEXT').toUpperCase(),
isNullable: row.notnull === 0,
defaultValue: row.dflt_value,
isPrimaryKey: Boolean(row.pk),
pkOrder: row.pk || 0,
}));
} else {
const sql = `
SELECT
c.COLUMN_NAME as name,
c.DATA_TYPE as type,
CASE WHEN c.IS_NULLABLE = 'YES' THEN 1 ELSE 0 END as isNullable,
c.COLUMN_DEFAULT as defaultValue,
CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END as isPrimaryKey
FROM INFORMATION_SCHEMA.COLUMNS c
LEFT JOIN (
SELECT ku.TABLE_NAME, ku.COLUMN_NAME
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku
ON tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME
) pk ON c.TABLE_NAME = pk.TABLE_NAME AND c.COLUMN_NAME = pk.COLUMN_NAME
WHERE c.TABLE_NAME = @tableName
ORDER BY c.ORDINAL_POSITION;
`;
const res = await provider.query(sql, { tableName });
return (res.rows || []).map((row) => ({
name: row.name,
type: (row.type || 'VARCHAR').toUpperCase(),
isNullable: Boolean(row.isNullable),
defaultValue: row.defaultValue,
isPrimaryKey: Boolean(row.isPrimaryKey),
pkOrder: row.isPrimaryKey ? 1 : 0,
}));
}
}
/**
* Browse table data with pagination, sorting, and search.
*/
export async function getTableData(tableName, { page = 1, pageSize = 50, sortBy, sortDir = 'ASC', search = '' } = {}) {
const provider = await getProvider();
const dialect = getDialect(provider);
// Verify table exists
const tables = await getTables();
const tableExists = tables.some((t) => t.name.toLowerCase() === tableName.toLowerCase());
if (!tableExists) {
throw new Error(`Table "${tableName}" not found`);
}
const schema = await getTableSchema(tableName);
if (!schema.length) {
throw new Error(`No columns found for table "${tableName}"`);
}
const columnNames = schema.map((c) => c.name);
const primaryKeys = schema.filter((c) => c.isPrimaryKey).map((c) => c.name);
const safePage = Math.max(1, parseInt(page, 10) || 1);
const safePageSize = Math.min(500, Math.max(1, parseInt(pageSize, 10) || 50));
const offset = (safePage - 1) * safePageSize;
// Validate sorting column
let safeSortCol = sortBy && columnNames.includes(sortBy) ? sortBy : null;
if (!safeSortCol) {
safeSortCol = primaryKeys.length > 0 ? primaryKeys[0] : columnNames[0];
}
const safeSortDir = String(sortDir).toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
// Search filter
const trimmedSearch = (search || '').trim();
let whereClause = '';
const queryParams = {};
if (trimmedSearch) {
// Build search across text/varchar/char columns
const searchableCols = schema.filter((c) => {
const t = c.type.toLowerCase();
return t.includes('char') || t.includes('text') || t.includes('string');
});
const targetCols = searchableCols.length > 0 ? searchableCols : schema;
const searchConditions = targetCols.map((c, i) => {
const paramKey = `search${i}`;
queryParams[paramKey] = `%${trimmedSearch}%`;
return `CAST([${c.name}] AS NVARCHAR(MAX)) LIKE @${paramKey}`;
});
whereClause = `WHERE (${searchConditions.join(' OR ')})`;
}
// Count query
const countSql = `SELECT COUNT(*) as total FROM [${tableName}] ${whereClause}`;
const countRes = await provider.query(countSql, queryParams);
const total = Number(countRes.rows[0]?.total ?? 0);
// Data query
let dataSql = '';
if (dialect === 'sqlite') {
dataSql = `SELECT * FROM [${tableName}] ${whereClause} ORDER BY [${safeSortCol}] ${safeSortDir} LIMIT ${safePageSize} OFFSET ${offset};`;
} else {
dataSql = `SELECT * FROM [${tableName}] ${whereClause} ORDER BY [${safeSortCol}] ${safeSortDir} OFFSET ${offset} ROWS FETCH NEXT ${safePageSize} ROWS ONLY;`;
}
const dataRes = await provider.query(dataSql, queryParams);
const rows = (dataRes.rows || []).map((row) => sanitizeRow(row));
return {
tableName,
columns: schema,
rows,
total,
page: safePage,
pageSize: safePageSize,
totalPages: Math.ceil(total / safePageSize) || 1,
primaryKeys,
sortBy: safeSortCol,
sortDir: safeSortDir,
};
}
/**
* Insert a new row into the table.
*/
export async function insertRow(tableName, rowData) {
const provider = await getProvider();
const schema = await getTableSchema(tableName);
const validColNames = new Set(schema.map((c) => c.name));
// Filter to valid columns only and strip undefined / empty auto-increment PKs
const cleanData = {};
for (const [col, val] of Object.entries(rowData || {})) {
if (!validColNames.has(col)) continue;
const colDef = schema.find((c) => c.name === col);
if (colDef?.isPrimaryKey && (val === '' || val === null || val === undefined)) {
// omit auto-increment primary key so provider assigns it
continue;
}
cleanData[col] = val === '' ? (colDef?.isNullable ? null : '') : val;
}
const cols = Object.keys(cleanData);
if (!cols.length) {
throw new Error('No valid column data provided for insert');
}
const placeholders = cols.map((_, i) => `@p${i}`).join(', ');
const params = {};
cols.forEach((col, i) => {
params[`p${i}`] = cleanData[col];
});
const sql = `INSERT INTO [${tableName}] (${cols.map((c) => `[${c}]`).join(', ')}) VALUES (${placeholders});`;
const result = await provider.query(sql, params);
return {
success: true,
insertId: result.insertId,
rowsAffected: result.rowsAffected,
};
}
/**
* Update an existing row.
*/
export async function updateRow(tableName, { primaryKey, changes } = {}) {
const provider = await getProvider();
const schema = await getTableSchema(tableName);
const validColNames = new Set(schema.map((c) => c.name));
if (!primaryKey || typeof primaryKey !== 'object' || !Object.keys(primaryKey).length) {
throw new Error('Primary key specification required for updating a row');
}
if (!changes || typeof changes !== 'object' || !Object.keys(changes).length) {
throw new Error('No changes specified');
}
// Build SET clause
const setCols = Object.keys(changes).filter((c) => validColNames.has(c));
if (!setCols.length) {
throw new Error('No valid columns in changes');
}
const params = {};
const setParts = setCols.map((c, i) => {
params[`s${i}`] = changes[c];
return `[${c}] = @s${i}`;
});
// Build WHERE clause
const pkCols = Object.keys(primaryKey).filter((c) => validColNames.has(c));
const whereParts = pkCols.map((c, i) => {
params[`pk${i}`] = primaryKey[c];
return `[${c}] = @pk${i}`;
});
const sql = `UPDATE [${tableName}] SET ${setParts.join(', ')} WHERE ${whereParts.join(' AND ')};`;
const result = await provider.query(sql, params);
return {
success: true,
rowsAffected: result.rowsAffected,
};
}
/**
* Delete a row by primary key.
*/
export async function deleteRow(tableName, { primaryKey } = {}) {
const provider = await getProvider();
const schema = await getTableSchema(tableName);
const validColNames = new Set(schema.map((c) => c.name));
if (!primaryKey || typeof primaryKey !== 'object' || !Object.keys(primaryKey).length) {
throw new Error('Primary key specification required for deleting a row');
}
const pkCols = Object.keys(primaryKey).filter((c) => validColNames.has(c));
if (!pkCols.length) {
throw new Error('No valid primary key columns provided');
}
const params = {};
const whereParts = pkCols.map((c, i) => {
params[`pk${i}`] = primaryKey[c];
return `[${c}] = @pk${i}`;
});
const sql = `DELETE FROM [${tableName}] WHERE ${whereParts.join(' AND ')};`;
const result = await provider.query(sql, params);
return {
success: true,
rowsAffected: result.rowsAffected,
};
}
/**
* Execute raw arbitrary SQL query.
*/
export async function executeRawQuery(sql) {
const provider = await getProvider();
const trimmed = (sql || '').trim();
if (!trimmed) {
throw new Error('Query cannot be empty');
}
const start = performance.now();
try {
const result = await provider.query(trimmed, {});
const executionTimeMs = Math.round((performance.now() - start) * 100) / 100;
const rows = (result.rows || []).map((row) => sanitizeRow(row));
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
return {
success: true,
rows,
columns,
rowsAffected: result.rowsAffected ?? 0,
insertId: result.insertId ?? null,
executionTimeMs,
};
} catch (err) {
const executionTimeMs = Math.round((performance.now() - start) * 100) / 100;
return {
success: false,
error: err.message,
executionTimeMs,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backend Monitor Interface</title>
<!-- Google Fonts: Inter & JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app" class="app-container">
<!-- Top Header -->
<header class="app-header">
<div class="header-left">
<div class="brand">
<div class="brand-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
</div>
<span class="brand-title">Backend Monitor Interface</span>
</div>
<div class="dev-badge">DEV MODE • NO AUTH</div>
</div>
<div class="header-center">
<div id="db-status-pill" class="db-status-pill">
<span class="status-dot"></span>
<span id="db-engine-name" class="engine-badge">Connecting...</span>
<span id="db-target-name" class="db-target">...</span>
</div>
</div>
<div class="header-right">
<button id="btn-run-query-header" class="btn btn-primary" title="Execute Query (Ctrl+Enter or F5)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
<span>Execute</span>
<kbd>Ctrl+↵</kbd>
</button>
<button id="btn-new-query" class="btn btn-secondary" title="Open new query tab">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>New Query</span>
</button>
<div class="dropdown">
<button id="btn-templates-dropdown" class="btn btn-secondary dropdown-toggle">
<span>Snippets</span>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div id="templates-menu" class="dropdown-menu">
<button class="dropdown-item" data-template="select">SELECT * FROM [table] LIMIT 50</button>
<button class="dropdown-item" data-template="count">SELECT COUNT(*) FROM [table]</button>
<button class="dropdown-item" data-template="insert">INSERT INTO [table] (...) VALUES (...)</button>
<button class="dropdown-item" data-template="update">UPDATE [table] SET col = val WHERE id = ...</button>
<button class="dropdown-item" data-template="delete">DELETE FROM [table] WHERE id = ...</button>
</div>
</div>
<button id="btn-history" class="btn btn-icon" title="Query History">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
</div>
</header>
<!-- Main Workspace -->
<div class="main-layout">
<!-- Left Sidebar: Object Explorer -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"></path>
</svg>
<span>OBJECT EXPLORER</span>
<span id="table-count-badge" class="badge">0</span>
</div>
<button id="btn-refresh-tables" class="btn-icon-subtle" title="Refresh Table List">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"></path>
</svg>
</button>
</div>
<div class="sidebar-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" id="table-search-input" placeholder="Filter tables..." autocomplete="off">
<button id="btn-clear-search" class="btn-clear-search hidden">&times;</button>
</div>
<div id="tables-tree" class="tables-tree">
<div class="tree-loading">Loading tables...</div>
</div>
<div class="sidebar-footer">
<div class="sidebar-info-row">
<span class="info-label">Port:</span>
<span id="footer-port" class="info-val">...</span>
</div>
<div class="sidebar-info-row">
<span class="info-label">Dialect:</span>
<span id="footer-dialect" class="info-val">...</span>
</div>
</div>
</aside>
<!-- Center / Right Work Area -->
<main class="workspace">
<!-- Tabs Header Bar -->
<div class="tab-strip-container">
<div id="tab-strip" class="tab-strip"></div>
<button id="btn-add-tab" class="btn-add-tab" title="New Query Tab">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<!-- Tab Views Container -->
<div id="tab-views" class="tab-views"></div>
</main>
</div>
<!-- Modals -->
<!-- Add Row Modal -->
<div id="modal-add-row" class="modal-overlay hidden">
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title">Insert Record &bull; <span id="add-modal-table-name"></span></h3>
<button class="modal-close" data-close-modal>&times;</button>
</div>
<form id="form-add-row" class="modal-body">
<div id="add-row-fields" class="form-grid"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary">Insert Record</button>
</div>
</form>
</div>
</div>
<!-- Edit Row Modal -->
<div id="modal-edit-row" class="modal-overlay hidden">
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title">Edit Record &bull; <span id="edit-modal-table-name"></span></h3>
<button class="modal-close" data-close-modal>&times;</button>
</div>
<form id="form-edit-row" class="modal-body">
<div id="edit-row-fields" class="form-grid"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Delete Row Modal -->
<div id="modal-delete-row" class="modal-overlay hidden">
<div class="modal-dialog modal-dialog-sm">
<div class="modal-header">
<h3 class="modal-title text-danger">Confirm Delete</h3>
<button class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to permanently delete this row from <strong id="delete-modal-table-name"></strong>?</p>
<div id="delete-modal-summary" class="delete-summary-box"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Cancel</button>
<button id="btn-confirm-delete" type="button" class="btn btn-danger">Delete Record</button>
</div>
</div>
</div>
</div>
<!-- Query History Modal -->
<div id="modal-history" class="modal-overlay hidden">
<div class="modal-dialog modal-dialog-lg">
<div class="modal-header">
<h3 class="modal-title">Query History</h3>
<div class="modal-header-actions">
<button id="btn-clear-history" class="btn btn-secondary btn-sm">Clear History</button>
<button class="modal-close" data-close-modal>&times;</button>
</div>
</div>
<div class="modal-body">
<div id="history-list" class="history-list"></div>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast-container" class="toast-container"></div>
</div>
<script src="app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
import express from 'express';
import http from 'http';
import path from 'path';
import { fileURLToPath } from 'url';
import env from '../config/env.js';
import { createLogger } from '../util/logger.js';
import * as dbHelper from './dbHelper.js';
const monitorLog = createLogger('backendmonitor');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let monitorServer;
let monitorApp;
export function isDevMode() {
return (
process.env.npm_lifecycle_event === 'dev' ||
process.env.DEV_BACKEND_MONITOR === 'true' ||
process.env.DEV_SQL_STUDIO === 'true' ||
process.env.DEV_MODE === 'true'
);
}
export function buildStudioApp() {
const app = express();
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Unrestricted local CORS
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
// 1. Status
app.get('/api/status', async (req, res) => {
try {
const info = await dbHelper.getDatabaseInfo();
res.json(info);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 2. Tables list
app.get('/api/tables', async (req, res) => {
try {
const tables = await dbHelper.getTables();
res.json({ tables });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 3. Table schema
app.get('/api/tables/:table/schema', async (req, res) => {
try {
const schema = await dbHelper.getTableSchema(req.params.table);
res.json({ tableName: req.params.table, columns: schema });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 4. Table data (browsing)
app.get('/api/tables/:table/data', async (req, res) => {
try {
const data = await dbHelper.getTableData(req.params.table, {
page: req.query.page,
pageSize: req.query.pageSize,
sortBy: req.query.sortBy,
sortDir: req.query.sortDir,
search: req.query.search,
});
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 5. Insert row
app.post('/api/tables/:table/row', async (req, res) => {
try {
const result = await dbHelper.insertRow(req.params.table, req.body.row || req.body);
res.json(result);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// 6. Update row
app.put('/api/tables/:table/row', async (req, res) => {
try {
const { primaryKey, changes } = req.body;
const result = await dbHelper.updateRow(req.params.table, { primaryKey, changes });
res.json(result);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// 7. Delete row
app.delete('/api/tables/:table/row', async (req, res) => {
try {
const { primaryKey } = req.body;
const result = await dbHelper.deleteRow(req.params.table, { primaryKey });
res.json(result);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// 8. Raw SQL Query execution
app.post('/api/query', async (req, res) => {
try {
const { sql } = req.body;
const result = await dbHelper.executeRawQuery(sql);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Static assets
const publicDir = path.join(__dirname, 'public');
app.use(express.static(publicDir));
// Fallback to index.html for SPA routing
app.use((req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
return app;
}
export async function startBackendMonitor() {
if (monitorServer) {
return monitorServer;
}
const app = buildStudioApp();
monitorApp = app;
const serverInstance = http.createServer(app);
await new Promise((resolve, reject) => {
serverInstance.once('listening', () => resolve());
serverInstance.once('error', (err) => {
monitorLog.error(`Failed to listen on port ${env.monitorPort}:`, err.message);
reject(err);
});
serverInstance.listen(env.monitorPort);
});
monitorServer = serverInstance;
monitorLog.info(`Backend Monitor Interface running on http://localhost:${env.monitorPort} (dev mode, no auth)`);
return monitorServer;
}
export async function stopBackendMonitor() {
if (!monitorServer) return;
const s = monitorServer;
monitorServer = undefined;
monitorApp = undefined;
await new Promise((resolve, reject) => {
s.close((err) => (err ? reject(err) : resolve()));
s.closeAllConnections();
});
monitorLog.info('Backend Monitor Interface stopped');
}
// Aliases for compatibility
export const startSqlStudio = startBackendMonitor;
export const stopSqlStudio = stopBackendMonitor;

View File

@@ -15,6 +15,7 @@ function parseHttpsPort(val) {
export default {
port: Number(process.env.PORT) || 4208,
httpsPort: parseHttpsPort(process.env.HTTPS_PORT),
monitorPort: Number(process.env.MONITOR_PORT || process.env.SQL_STUDIO_PORT) || 4210,
httpsKeyPath: process.env.HTTPS_KEY_PATH || resolve(__dirname, '../../certs/key.pem'),
httpsCertPath: process.env.HTTPS_CERT_PATH || resolve(__dirname, '../../certs/cert.pem'),
logLevel: process.env.LOG_LEVEL || 'debug',
@@ -44,4 +45,11 @@ export default {
encrypt: process.env.MSSQL_ENCRYPT === 'true',
trustServerCertificate: process.env.MSSQL_TRUST_SERVER_CERTIFICATE === 'true',
},
backup: {
enabled: process.env.BACKUP_ENABLED !== 'false',
dir: process.env.BACKUP_DIR ? resolve(process.env.BACKUP_DIR) : resolve(__dirname, '../../data/backups'),
intervalMs: Number(process.env.BACKUP_INTERVAL_MS) || 60 * 60 * 1000,
hourlyRetentionCount: Number(process.env.BACKUP_HOURLY_RETENTION) || 50,
dailyRetentionCount: Number(process.env.BACKUP_DAILY_RETENTION) || 50,
},
};

View File

@@ -0,0 +1,537 @@
VFS Drafts: Collaborative Quote, Order, and Delivery Editing
# Overview
The virtual directory `drafts/` in VFS allows the LLM agent and the frontend user to collaborate in real time on in-progress commercial documents.
Drafts are stateful forms maintained in the frontend application and synchronized with the backend.
There are three virtual draft files:
1. `drafts/quote` : Sales Quote / Angebot (Customer sales proposal)
2. `drafts/order` : Sales Order / Auftrag & Bestellung (Customer order confirmation)
3. `drafts/delivery` : Goods Inbound / Wareneingang & Lieferschein (Supplier delivery receipt)
---
# Draft JSON Schemas
## 1. Common Manifest Envelope Schema
Every draft file contains a root object with a `manifest` property conforming to this JSON Schema:
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DraftManifestEnvelope",
"type": "object",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "object",
"required": ["kind", "mode", "lines"],
"properties": {
"kind": {
"type": "string",
"enum": ["quote", "order", "delivery"],
"description": "Document type"
},
"mode": {
"type": "string",
"enum": ["create", "edit"],
"description": "Whether creating a new document or editing an existing saved one"
},
"id": {
"type": ["integer", "null"],
"description": "Database document ID (null for new drafts, integer when editing)"
},
"date": {
"type": ["string", "null"],
"format": "date",
"description": "ISO date string (YYYY-MM-DD) or null"
},
"partyId": {
"type": ["integer", "null"],
"description": "Customer entity.id for quotes/orders; supplier.id for deliveries"
},
"number": {
"type": ["string", "null"],
"description": "Document identifier. Null for automatic numbering upon commit"
},
"externalNumber": {
"type": ["string", "null"],
"description": "External reference (e.g. customer PO number, supplier delivery slip number)"
},
"comment": {
"type": ["string", "null"],
"description": "Document-level remarks or notes"
},
"lines": {
"type": "array",
"description": "Ordered line items of the document"
}
}
}
}
}
```
---
## 2. Sales Quote Draft (`drafts/quote`)
Used for preparing customer sales proposals (Angebote).
### Complete Quote JSON Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "QuoteDraft",
"type": "object",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "object",
"required": ["kind", "mode", "lines"],
"properties": {
"kind": { "type": "string", "const": "quote" },
"mode": { "type": "string", "enum": ["create", "edit"] },
"id": { "type": ["integer", "null"] },
"date": {
"type": ["string", "null"],
"format": "date",
"description": "Offer validity expiration date (Gültig bis, YYYY-MM-DD)"
},
"partyId": {
"type": ["integer", "null"],
"description": "Customer entity ID (entity.id)"
},
"number": {
"type": ["string", "null"],
"description": "Quote number (null for auto-generation)"
},
"externalNumber": {
"type": ["string", "null"],
"description": "Customer project or RFQ reference"
},
"comment": {
"type": ["string", "null"],
"description": "Optional quotation remark"
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "amount", "net", "taxrate"],
"properties": {
"pos": {
"type": "integer",
"minimum": 1,
"description": "Position line number (1, 2, 3...)"
},
"sku": {
"type": ["string", "null"],
"description": "Catalogue article SKU, or null for custom service/text position"
},
"name": {
"type": "string",
"description": "Title or designation of the position"
},
"amount": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Quoted quantity"
},
"pu": {
"type": "number",
"minimum": 1,
"default": 1,
"description": "Packing unit / price unit (Preiseinheit, default 1)"
},
"net": {
"type": "number",
"minimum": 0,
"description": "Net unit price in EUR"
},
"gross": {
"type": ["number", "null"],
"description": "Gross unit price in EUR (net * (1 + taxrate/100))"
},
"taxrate": {
"type": "number",
"minimum": 0,
"description": "VAT rate percentage (e.g. 19 or 7)"
},
"rebate": {
"type": "number",
"minimum": 0,
"maximum": 100,
"default": 0,
"description": "Line discount percentage (0 to 100)"
},
"unit": {
"type": ["string", "null"],
"description": "Unit of measurement (e.g. 'Stück', 'm', 'kg', 'Pauschal')"
},
"comment": {
"type": ["string", "null"],
"description": "Optional line-specific comment or specification"
}
}
}
}
}
}
}
}
```
### Complete Quote Example
```json
{
"manifest": {
"kind": "quote",
"mode": "create",
"id": null,
"date": "2026-11-30",
"partyId": 104,
"number": null,
"externalNumber": "ANFRAGE-2026-441",
"comment": "Angebot freibleibend, Lieferung frei Haus ab 250 EUR.",
"lines": [
{
"pos": 1,
"sku": "42001257",
"name": "1 Pot 8L System inkl. 47L Wasserfass",
"amount": 2,
"pu": 1,
"net": 58.74,
"gross": 69.90,
"taxrate": 19,
"rebate": 0,
"unit": "Stück",
"comment": null
},
{
"pos": 2,
"sku": null,
"name": "Aufbau und Fachberatung vor Ort",
"amount": 1.5,
"pu": 1,
"net": 80.00,
"gross": 95.20,
"taxrate": 19,
"rebate": 10,
"unit": "Std",
"comment": "Inklusive Anfahrt Zone 1"
}
]
}
}
```
---
## 3. Sales Order Draft (`drafts/order`)
Used for customer order confirmations and sales orders (Aufträge & Bestellungen).
### Complete Order JSON Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "OrderDraft",
"type": "object",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "object",
"required": ["kind", "mode", "lines"],
"properties": {
"kind": { "type": "string", "const": "order" },
"mode": { "type": "string", "enum": ["create", "edit"] },
"id": { "type": ["integer", "null"] },
"date": {
"type": ["string", "null"],
"format": "date",
"description": "Expected delivery date (Liefertermin, YYYY-MM-DD)"
},
"partyId": {
"type": ["integer", "null"],
"description": "Customer entity ID (entity.id)"
},
"number": {
"type": ["string", "null"],
"description": "Order number (null for auto-generation)"
},
"externalNumber": {
"type": ["string", "null"],
"description": "Customer purchase order / PO number (Kundenbestellnummer)"
},
"comment": {
"type": ["string", "null"],
"description": "Optional order remark or delivery instruction"
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "amount", "net", "taxrate"],
"properties": {
"pos": {
"type": "integer",
"minimum": 1,
"description": "Position line number (1, 2, 3...)"
},
"sku": {
"type": ["string", "null"],
"description": "Article SKU"
},
"name": {
"type": "string",
"description": "Line item description"
},
"amount": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Ordered quantity"
},
"pu": {
"type": "number",
"minimum": 1,
"default": 1,
"description": "Packaging unit / price unit (default 1)"
},
"net": {
"type": "number",
"minimum": 0,
"description": "Net unit price in EUR"
},
"gross": {
"type": ["number", "null"],
"description": "Gross unit price in EUR"
},
"taxrate": {
"type": "number",
"minimum": 0,
"description": "VAT percentage (19 or 7)"
},
"rebate": {
"type": "number",
"minimum": 0,
"maximum": 100,
"default": 0,
"description": "Line discount percentage"
},
"unit": {
"type": ["string", "null"],
"description": "Unit of measurement"
},
"comment": {
"type": ["string", "null"],
"description": "Line comment or customer note"
}
}
}
}
}
}
}
}
```
### Complete Order Example
```json
{
"manifest": {
"kind": "order",
"mode": "create",
"id": null,
"date": "2026-10-28",
"partyId": 104,
"number": null,
"externalNumber": "PO-2026-8841",
"comment": "Bitte Lieferung avisieren unter +49 171 1234567.",
"lines": [
{
"pos": 1,
"sku": "42001257",
"name": "1 Pot 8L System inkl. 47L Wasserfass",
"amount": 5,
"pu": 1,
"net": 58.74,
"gross": 69.90,
"taxrate": 19,
"rebate": 5,
"unit": "Stück",
"comment": null
},
{
"pos": 2,
"sku": "42000009",
"name": "Autopot Easy2Grow Erweiterungsset 2Pot",
"amount": 2,
"pu": 1,
"net": 39.50,
"gross": 47.01,
"taxrate": 19,
"rebate": 0,
"unit": "Stück",
"comment": "Farbe: Schwarz"
}
]
}
}
```
---
## 4. Goods Inbound Delivery Draft (`drafts/delivery`)
Used for inbound delivery receipts from suppliers (Wareneingang & Lieferscheine).
### Complete Delivery JSON Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DeliveryDraft",
"type": "object",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "object",
"required": ["kind", "mode", "lines"],
"properties": {
"kind": { "type": "string", "const": "delivery" },
"mode": { "type": "string", "enum": ["create", "edit"] },
"id": { "type": ["integer", "null"] },
"date": {
"type": ["string", "null"],
"format": "date",
"description": "Delivery arrival / receipt date (Lieferdatum, YYYY-MM-DD)"
},
"partyId": {
"type": ["integer", "null"],
"description": "Supplier ID (supplier.id / entity_id)"
},
"number": {
"type": ["string", "null"],
"description": "Delivery manifest number (allocated by system sequence upon creation)"
},
"externalNumber": {
"type": ["string", "null"],
"description": "Supplier delivery slip number (Lieferschein-Nr. des Lieferanten)"
},
"comment": {
"type": ["string", "null"],
"description": "Warehouse receipt remarks or gate notes"
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "amount", "net", "taxrate"],
"properties": {
"pos": {
"type": "integer",
"minimum": 1,
"description": "Position line number (1, 2, 3...)"
},
"sku": {
"type": ["string", "null"],
"description": "Article SKU (internal SKU or matched supplier SKU)"
},
"name": {
"type": "string",
"description": "Delivered article name"
},
"amount": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Delivered quantity received into warehouse"
},
"pu": {
"type": "number",
"minimum": 1,
"default": 1,
"description": "Packaging unit / VPE (default 1)"
},
"net": {
"type": "number",
"minimum": 0,
"description": "Purchase cost net per unit in EUR (EK netto)"
},
"gross": {
"type": ["number", "null"],
"description": "Purchase cost gross per unit in EUR (EK brutto)"
},
"taxrate": {
"type": "number",
"minimum": 0,
"description": "Input tax percentage (e.g. 19 or 7)"
},
"rebate": {
"type": "number",
"minimum": 0,
"maximum": 100,
"default": 0,
"description": "Supplier discount percentage"
},
"unit": {
"type": ["string", "null"],
"description": "Unit of measurement"
},
"comment": {
"type": ["string", "null"],
"description": "Batch number, lot, condition, or storage bin note"
}
}
}
}
}
}
}
}
```
### Complete Delivery Example
```json
{
"manifest": {
"kind": "delivery",
"mode": "create",
"id": null,
"date": "2026-10-14",
"partyId": 12,
"number": null,
"externalNumber": "LS-SUP-99412",
"comment": "Ware unbeschädigt auf Palette 3 angeliefert.",
"lines": [
{
"pos": 1,
"sku": "42001257",
"name": "1 Pot 8L System inkl. 47L Wasserfass",
"amount": 20,
"pu": 1,
"net": 38.50,
"gross": 45.82,
"taxrate": 19,
"rebate": 0,
"unit": "Stück",
"comment": "Palette A-04"
},
{
"pos": 2,
"sku": "42000009",
"name": "Autopot Easy2Grow Erweiterungsset 2Pot",
"amount": 50,
"pu": 1,
"net": 24.00,
"gross": 28.56,
"taxrate": 19,
"rebate": 3,
"unit": "Stück",
"comment": "Karton 1 bis 5"
}
]
}
}
```

View File

@@ -0,0 +1,510 @@
// #region Imports & Configuration
import fs from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import env from '../config/env.js';
import { getProvider } from '../db/store.js';
import { createLogger } from '../util/logger.js';
const execFileAsync = promisify(execFile);
const backupLog = createLogger('backup');
let scheduledTimer = null;
let scheduledInterval = null;
let isServiceRunning = false;
let activeBackupPromise = null;
// #endregion
// #region Path Helpers
// MARK: resolves standard directories for backups and source assets
export function resolveBackupPaths() {
const rootDir = env.backup?.dir || path.resolve(process.cwd(), 'data/backups');
const vfsDir = path.resolve(process.cwd(), 'data/vfs');
const storageDir = path.resolve(process.cwd(), 'data/storage');
const sqlitePath = env.sqlite?.filename || path.resolve(process.cwd(), 'data/dash.db');
return {
rootDir,
hourlyDir: path.join(rootDir, 'hourly'),
dailyDir: path.join(rootDir, 'daily'),
monthlyDir: path.join(rootDir, 'monthly'),
tempDir: path.join(rootDir, '.tmp'),
vfsDir,
storageDir,
sqlitePath,
};
}
// MARK: ensures all backup tier directories exist
export function ensureBackupDirectories() {
const paths = resolveBackupPaths();
for (const dir of [paths.rootDir, paths.hourlyDir, paths.dailyDir, paths.monthlyDir, paths.tempDir]) {
fs.mkdirSync(dir, { recursive: true });
}
return paths;
}
// #endregion
// #region Format & Timestamp Helpers
// MARK: formats current Date into zero-padded parts
function formatDateTimeComponents(date = new Date()) {
const pad = (val) => String(val).padStart(2, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
return {
timestamp: `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`,
dateKey: `${year}-${month}-${day}`,
monthKey: `${year}-${month}`,
isoString: date.toISOString(),
};
}
// #endregion
// #region Archiving & Database Snapshotting
// MARK: archives a directory into tar (uncompressed) or tar.gz (compressed)
async function archiveDirectory(sourcePath, destinationArchive, { gzip = false } = {}) {
fs.mkdirSync(path.dirname(destinationArchive), { recursive: true });
if (fs.existsSync(destinationArchive)) {
fs.unlinkSync(destinationArchive);
}
const sourceExists = fs.existsSync(sourcePath);
if (!sourceExists) {
// Create an empty archive if source directory does not exist
const flags = gzip ? ['-czf', destinationArchive, '-T', '/dev/null'] : ['-cf', destinationArchive, '-T', '/dev/null'];
await execFileAsync('tar', flags);
return { exists: false, sizeBytes: fs.statSync(destinationArchive).size };
}
const parentDir = path.dirname(sourcePath);
const folderName = path.basename(sourcePath);
const flags = gzip ? ['-czf', destinationArchive, '-C', parentDir, folderName] : ['-cf', destinationArchive, '-C', parentDir, folderName];
try {
await execFileAsync('tar', flags);
} catch (err) {
// GNU tar returns exit code 1 if files changed while being read; treat as non-fatal
if (err.code === 1 && /file changed as we read it/i.test(err.stderr || '')) {
backupLog.warn(`tar warning: ${err.stderr.trim()}`);
} else {
throw err;
}
}
const stats = fs.statSync(destinationArchive);
return { exists: true, sizeBytes: stats.size };
}
// MARK: creates an atomic SQLite database snapshot file
async function snapshotSqliteDatabase(targetFile) {
if (env.dbProvider === 'mssql') {
const warningMessage = 'MSSQL database backup is not implemented; skipping database backup.';
console.warn(`[backup] WARNING: ${warningMessage}`);
backupLog.warn(warningMessage);
return { success: false, reason: 'mssql_unsupported', sizeBytes: 0 };
}
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
if (fs.existsSync(targetFile)) {
fs.unlinkSync(targetFile);
}
try {
const provider = await getProvider();
if (provider?.db && typeof provider.db.backup === 'function') {
await provider.db.backup(targetFile);
const stats = fs.statSync(targetFile);
return { success: true, sizeBytes: stats.size };
}
if (provider?.query) {
const escapedPath = targetFile.replace(/'/g, "''");
await provider.query(`VACUUM INTO '${escapedPath}'`);
const stats = fs.statSync(targetFile);
return { success: true, sizeBytes: stats.size };
}
} catch (err) {
backupLog.warn(`Online SQLite backup method failed (${err.message}), falling back to direct copy`);
}
// Direct file fallback
const { sqlitePath } = resolveBackupPaths();
if (fs.existsSync(sqlitePath)) {
fs.copyFileSync(sqlitePath, targetFile);
const stats = fs.statSync(targetFile);
return { success: true, sizeBytes: stats.size };
}
throw new Error(`SQLite source file not found at ${sqlitePath}`);
}
// #endregion
// #region Promotion & Retention
// MARK: links or copies all files from one backup folder into another
function mirrorBackupDirectory(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
for (const fileName of fs.readdirSync(sourceDir)) {
const srcFile = path.join(sourceDir, fileName);
const destFile = path.join(targetDir, fileName);
if (fs.existsSync(destFile)) {
fs.unlinkSync(destFile);
}
try {
fs.linkSync(srcFile, destFile);
} catch {
fs.copyFileSync(srcFile, destFile);
}
}
}
// MARK: enforces retention limits for hourly (50) and daily (50) tiers
export function pruneBackups() {
const { hourlyDir, dailyDir } = resolveBackupPaths();
const hourlyLimit = env.backup?.hourlyRetentionCount || 50;
const dailyLimit = env.backup?.dailyRetentionCount || 50;
let hourlyPruned = 0;
let dailyPruned = 0;
// Prune hourly backups: keep last 50
if (fs.existsSync(hourlyDir)) {
const entries = fs.readdirSync(hourlyDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.startsWith('backup-'))
.map((e) => e.name)
.sort();
if (entries.length > hourlyLimit) {
const toRemove = entries.slice(0, entries.length - hourlyLimit);
for (const name of toRemove) {
const target = path.join(hourlyDir, name);
fs.rmSync(target, { recursive: true, force: true });
hourlyPruned++;
}
}
}
// Prune daily backups: keep last 50
if (fs.existsSync(dailyDir)) {
const entries = fs.readdirSync(dailyDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.startsWith('backup-'))
.map((e) => e.name)
.sort();
if (entries.length > dailyLimit) {
const toRemove = entries.slice(0, entries.length - dailyLimit);
for (const name of toRemove) {
const target = path.join(dailyDir, name);
fs.rmSync(target, { recursive: true, force: true });
dailyPruned++;
}
}
}
// Monthly backups are kept forever — no pruning is performed on monthlyDir.
if (hourlyPruned > 0 || dailyPruned > 0) {
backupLog.info(`Pruned backups: ${hourlyPruned} hourly, ${dailyPruned} daily.`);
}
return { hourlyPruned, dailyPruned };
}
// #endregion
// #region Core Backup Execution
// MARK: executes a full backup and promotes to daily/monthly tiers as appropriate
export async function createHourlyBackup({ manual = false } = {}) {
if (activeBackupPromise) {
backupLog.warn('A backup is already in progress, waiting for it to finish...');
return activeBackupPromise;
}
activeBackupPromise = (async () => {
const start = Date.now();
const paths = ensureBackupDirectories();
const { timestamp, dateKey, monthKey, isoString } = formatDateTimeComponents(new Date());
const backupId = `backup-${timestamp}`;
const stagingDir = path.join(paths.tempDir, `${backupId}-${Date.now()}`);
const finalHourlyDir = path.join(paths.hourlyDir, backupId);
backupLog.info(`Starting backup (${backupId})...`);
fs.mkdirSync(stagingDir, { recursive: true });
try {
// 1. VFS -> gzipped tar (vfs.tar.gz)
const vfsResult = await archiveDirectory(paths.vfsDir, path.join(stagingDir, 'vfs.tar.gz'), { gzip: true });
// 2. Storage images -> uncompressed tar (storage.tar)
const storageResult = await archiveDirectory(paths.storageDir, path.join(stagingDir, 'storage.tar'), { gzip: false });
// 3. SQLite DB -> file (dash.db)
const dbFileName = env.sqlite?.filename ? path.basename(env.sqlite.filename) : 'dash.db';
const dbResult = await snapshotSqliteDatabase(path.join(stagingDir, dbFileName));
// 4. Manifest
const totalSize = (vfsResult.sizeBytes || 0) + (storageResult.sizeBytes || 0) + (dbResult.sizeBytes || 0);
const manifest = {
id: backupId,
timestamp: isoString,
dateKey,
monthKey,
dbProvider: env.dbProvider,
manual,
files: {
vfs: {
name: 'vfs.tar.gz',
sizeBytes: vfsResult.sizeBytes,
sourceExists: vfsResult.exists,
},
storage: {
name: 'storage.tar',
sizeBytes: storageResult.sizeBytes,
sourceExists: storageResult.exists,
},
database: {
name: dbResult.success ? dbFileName : null,
sizeBytes: dbResult.sizeBytes,
backedUp: dbResult.success,
reason: dbResult.reason || null,
},
},
totalSizeBytes: totalSize,
durationMs: Date.now() - start,
};
fs.writeFileSync(path.join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
// 5. Atomically move staging to hourly directory
if (fs.existsSync(finalHourlyDir)) {
fs.rmSync(finalHourlyDir, { recursive: true, force: true });
}
fs.renameSync(stagingDir, finalHourlyDir);
// 6. Promote to daily backup (keep first backup of the day)
const dailyBackupName = `backup-${dateKey}`;
const targetDailyDir = path.join(paths.dailyDir, dailyBackupName);
let promotedDaily = false;
if (!fs.existsSync(targetDailyDir)) {
mirrorBackupDirectory(finalHourlyDir, targetDailyDir);
promotedDaily = true;
backupLog.info(`Promoted backup to daily tier (${dailyBackupName})`);
}
// 7. Promote to monthly backup (keep first backup of the month forever)
const monthlyBackupName = `backup-${monthKey}`;
const targetMonthlyDir = path.join(paths.monthlyDir, monthlyBackupName);
let promotedMonthly = false;
if (!fs.existsSync(targetMonthlyDir)) {
mirrorBackupDirectory(finalHourlyDir, targetMonthlyDir);
promotedMonthly = true;
backupLog.info(`Promoted backup to monthly tier (${monthlyBackupName})`);
}
// 8. Enforce retention limits
const pruneResult = pruneBackups();
const elapsed = Date.now() - start;
backupLog.info(
`Backup completed successfully in ${elapsed}ms (${(totalSize / 1024 / 1024).toFixed(2)} MB total)`
);
return {
success: true,
id: backupId,
timestamp: isoString,
promotedDaily,
promotedMonthly,
totalSizeBytes: totalSize,
pruneResult,
manifest,
};
} catch (err) {
backupLog.error(`Backup failed: ${err.message}`);
if (fs.existsSync(stagingDir)) {
fs.rmSync(stagingDir, { recursive: true, force: true });
}
throw err;
}
})();
try {
return await activeBackupPromise;
} finally {
activeBackupPromise = null;
}
}
// #endregion
// #region Backup Inspection & Status
// MARK: returns list of all backups across all tiers with sizes and timestamps
export function listBackups() {
const paths = ensureBackupDirectories();
function readTier(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.startsWith('backup-'))
.map((e) => {
const folderPath = path.join(dir, e.name);
const manifestPath = path.join(folderPath, 'manifest.json');
let manifest = null;
if (fs.existsSync(manifestPath)) {
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch {
// ignore corrupt manifest
}
}
let totalSize = 0;
for (const file of fs.readdirSync(folderPath)) {
try {
totalSize += fs.statSync(path.join(folderPath, file)).size;
} catch {
// ignore stat errors
}
}
return {
name: e.name,
path: folderPath,
totalSizeBytes: totalSize,
timestamp: manifest?.timestamp || null,
manifest,
};
})
.sort((a, b) => b.name.localeCompare(a.name));
}
const hourly = readTier(paths.hourlyDir);
const daily = readTier(paths.dailyDir);
const monthly = readTier(paths.monthlyDir);
return {
counts: {
hourly: hourly.length,
daily: daily.length,
monthly: monthly.length,
},
hourly,
daily,
monthly,
};
}
// MARK: returns service runtime status and config
export function getBackupServiceStatus() {
const paths = resolveBackupPaths();
const listing = listBackups();
return {
enabled: env.backup?.enabled ?? true,
isRunning: isServiceRunning,
dbProvider: env.dbProvider,
mssqlWarning: env.dbProvider === 'mssql',
backupDir: paths.rootDir,
retention: {
hourlyMax: env.backup?.hourlyRetentionCount || 50,
dailyMax: env.backup?.dailyRetentionCount || 50,
monthly: 'unlimited (forever)',
},
counts: listing.counts,
latestHourly: listing.hourly[0] || null,
latestDaily: listing.daily[0] || null,
latestMonthly: listing.monthly[0] || null,
};
}
// #endregion
// #region Service Lifecycle
// MARK: starts the periodic backup service timer and initial check
export function startBackupService() {
if (isServiceRunning) return;
if (env.backup?.enabled === false) {
backupLog.info('Backup service is disabled by configuration.');
return;
}
isServiceRunning = true;
ensureBackupDirectories();
if (env.dbProvider === 'mssql') {
const warning = 'MSSQL database backup is not implemented; skipping database backup.';
console.warn(`[backup] WARNING: ${warning}`);
backupLog.warn(warning);
}
// Determine when the last hourly backup was taken
const listing = listBackups();
const lastHourly = listing.hourly[0];
let shouldRunInitial = false;
if (!lastHourly || !lastHourly.timestamp) {
shouldRunInitial = true;
} else {
const lastTime = new Date(lastHourly.timestamp).getTime();
const diffMinutes = (Date.now() - lastTime) / (1000 * 60);
if (diffMinutes >= 55) {
shouldRunInitial = true;
}
}
if (shouldRunInitial) {
// Run after 5s initial warmup delay
scheduledTimer = setTimeout(() => {
scheduledTimer = null;
createHourlyBackup().catch((err) => {
backupLog.error(`Initial backup error: ${err.message}`);
});
}, 5000);
}
// Schedule next run at the start of the next hour
const now = new Date();
const nextHour = new Date(now);
nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0);
const msUntilNextHour = Math.max(1000, nextHour.getTime() - now.getTime());
scheduledTimer = setTimeout(() => {
scheduledTimer = null;
createHourlyBackup().catch((err) => {
backupLog.error(`Scheduled hourly backup error: ${err.message}`);
});
// Set recurring 1-hour interval
const intervalMs = env.backup?.intervalMs || 60 * 60 * 1000;
scheduledInterval = setInterval(() => {
createHourlyBackup().catch((err) => {
backupLog.error(`Recurring hourly backup error: ${err.message}`);
});
}, intervalMs);
}, msUntilNextHour);
backupLog.info(`Backup service started. Next run scheduled in ${Math.round(msUntilNextHour / 1000)}s.`);
}
// MARK: stops the backup service and clears all active timers
export async function stopBackupService() {
isServiceRunning = false;
if (scheduledTimer) {
clearTimeout(scheduledTimer);
scheduledTimer = null;
}
if (scheduledInterval) {
clearInterval(scheduledInterval);
scheduledInterval = null;
}
if (activeBackupPromise) {
backupLog.info('Waiting for active backup to complete...');
await activeBackupPromise;
}
backupLog.info('Backup service stopped.');
}
// #endregion

View File

@@ -0,0 +1,349 @@
import { emit, hasActiveClient } from '../api/sse.js';
import { getStore } from '../db/store.js';
export const DRAFT_NAMES = new Set(['quote', 'order', 'delivery']);
/**
* Checks and parses a VFS path to determine if it points to the drafts directory or a draft file.
* Accepts paths like:
* - 'drafts', '/drafts', 'drafts/'
* - 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'vfs://drafts/delivery'
*
* @param {string} filePath
* @returns {{ isFolder: boolean, isDraft: boolean, name?: 'quote'|'order'|'delivery', filename?: string }}
*/
export function parseDraftPath(filePath) {
if (typeof filePath !== 'string' || !filePath.trim()) {
return { isFolder: false, isDraft: false };
}
let clean = filePath.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
clean = clean.replace(/^api\//i, '').replace(/^vfs\//i, '');
const segments = clean.split('/').filter(Boolean);
if (segments.length === 0) return { isFolder: false, isDraft: false };
if (segments[0].toLowerCase() === 'drafts') {
if (segments.length === 1) {
return { isFolder: true, isDraft: false };
}
if (segments.length === 2) {
const rawName = segments[1];
const lower = rawName.toLowerCase();
const withoutExt = lower.endsWith('.json') ? lower.slice(0, -5) : lower;
if (DRAFT_NAMES.has(withoutExt)) {
return { isFolder: false, isDraft: true, name: withoutExt, filename: rawName };
}
}
}
return { isFolder: false, isDraft: false };
}
/**
* In-memory cache for drafts: key: `${companyId}:${userId}:${name}` -> { content, updatedAt, size }
*/
const draftCache = new Map();
function cacheKey(companyId, userId, name) {
return `${companyId}:${userId}:${name}`;
}
export function getCachedDraft({ companyId, userId, name }) {
return draftCache.get(cacheKey(companyId, userId, name)) || null;
}
/**
* Normalizes draft content by deduplicating redundant `items` array when `lines` exists.
* @param {any} content
* @returns {any}
*/
export function normalizeDraftContent(content) {
if (!content) return content;
let parsed = content;
let wasString = false;
if (typeof content === 'string') {
try {
parsed = JSON.parse(content);
wasString = true;
} catch {
return content;
}
}
if (parsed && typeof parsed === 'object') {
const target = parsed.manifest && typeof parsed.manifest === 'object' ? parsed.manifest : parsed;
if (target && typeof target === 'object') {
if (Array.isArray(target.lines) && Array.isArray(target.items)) {
delete target.items;
} else if (!Array.isArray(target.lines) && Array.isArray(target.items)) {
target.lines = target.items;
delete target.items;
}
}
}
return wasString ? JSON.stringify(parsed, null, 2) : parsed;
}
export function setCachedDraft({ companyId, userId, name, content }) {
const normalized = normalizeDraftContent(content);
const updatedAt = new Date().toISOString();
const str = typeof normalized === 'string' ? normalized : JSON.stringify(normalized ?? {}, null, 2);
const entry = {
content: normalized,
updatedAt,
size: Buffer.byteLength(str, 'utf8'),
};
draftCache.set(cacheKey(companyId, userId, name), entry);
return entry;
}
/**
* Resolves the selected company ID for a given googleAuth ID if needed.
* @param {number} googleAuthId
* @returns {Promise<number|null>}
*/
export async function resolveCompanyForUser(googleAuthId) {
if (!googleAuthId) return null;
try {
const store = await getStore();
const row = await store.google_auth.getById(googleAuthId);
return row?.selectedCompanyId ?? null;
} catch {
return null;
}
}
/**
* Pending requests map: requestId -> { resolve, reject, timer, companyId, userId, action, name }
*/
const pendingRequests = new Map();
/**
* Request a draft content from the frontend via SSE and wait for its response.
*
* @param {{ companyId?: number, userId: number, clientId?: string, name: string, timeoutMs?: number }} options
* @returns {Promise<any>}
*/
export async function requestDraftRead({ companyId, userId, clientId, name, timeoutMs = 15000 }) {
const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId);
if (effectiveCompanyId == null) {
throw new Error('No company selected');
}
// Check if client is active; if not and we have cache, return cache immediately
const hasClient = hasActiveClient({ companyId: effectiveCompanyId, userId, clientId });
if (!hasClient) {
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
if (cached) {
return cached.content;
}
}
const requestId = `draft_req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(requestId);
// Fallback to cache if available
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
if (cached) {
return resolve(cached.content);
}
reject(new Error(`Frontend draft response timed out for "${name}"`));
}, timeoutMs);
pendingRequests.set(requestId, {
resolve,
reject,
timer,
companyId: effectiveCompanyId,
userId,
action: 'read',
name,
});
// Emit request over SSE
emit('draft:request', {
companyId: effectiveCompanyId,
targetUserId: userId,
targetClientId: clientId,
data: {
requestId,
action: 'read',
name,
target: name,
sender: clientId || '',
},
});
});
}
/**
* Request draft listing from the frontend via SSE.
* If frontend doesn't respond or times out, returns default metadata for all 3 drafts.
*
* @param {{ companyId?: number, userId: number, clientId?: string, timeoutMs?: number }} options
* @returns {Promise<Array<object>>}
*/
export async function requestDraftList({ companyId, userId, clientId, timeoutMs = 2500 }) {
const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId);
if (effectiveCompanyId == null) {
return [];
}
const defaultDrafts = () => {
const now = new Date().toISOString();
return Array.from(DRAFT_NAMES).map((name) => {
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
return {
name: `drafts/${name}`,
size: cached ? cached.size : 0,
mime: 'application/json; charset=utf-8',
updated_at: cached ? cached.updatedAt : now,
created_at: cached ? cached.updatedAt : now,
isText: true,
isImage: false,
isPdf: false,
isDraft: true,
};
});
};
// If no client is connected, return defaults immediately without delaying listing
const hasClient = hasActiveClient({ companyId: effectiveCompanyId, userId, clientId });
if (!hasClient) {
return defaultDrafts();
}
const requestId = `draft_list_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
return new Promise((resolve) => {
const timer = setTimeout(() => {
pendingRequests.delete(requestId);
resolve(defaultDrafts());
}, timeoutMs);
pendingRequests.set(requestId, {
resolve: (filesOrList) => {
if (Array.isArray(filesOrList)) {
const now = new Date().toISOString();
const normalized = filesOrList.map((item) => {
const rawName = typeof item === 'string' ? item : (item.name || item.filename || '');
const parsed = parseDraftPath(rawName.startsWith('drafts/') ? rawName : `drafts/${rawName}`);
const nameKey = parsed.name || rawName;
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name: nameKey });
return {
name: `drafts/${nameKey}`,
size: typeof item === 'object' && item.size != null ? item.size : (cached ? cached.size : 0),
mime: 'application/json; charset=utf-8',
updated_at: typeof item === 'object' && item.updated_at ? item.updated_at : (cached ? cached.updatedAt : now),
created_at: typeof item === 'object' && item.created_at ? item.created_at : (cached ? cached.updatedAt : now),
isText: true,
isImage: false,
isPdf: false,
isDraft: true,
};
});
resolve(normalized);
} else {
resolve(defaultDrafts());
}
},
reject: () => {
resolve(defaultDrafts());
},
timer,
companyId: effectiveCompanyId,
userId,
action: 'list',
});
emit('draft:request', {
companyId: effectiveCompanyId,
targetUserId: userId,
targetClientId: clientId,
data: {
requestId,
action: 'list',
sender: clientId || '',
},
});
});
}
/**
* Handle frontend response to a previous draft:request.
*
* @param {{ requestId: string, content?: any, error?: string, files?: any[] }} payload
* @returns {{ handled: boolean, error?: string }}
*/
export function handleDraftResponse({ requestId, content, error, files }) {
if (!requestId || !pendingRequests.has(requestId)) {
return { handled: false, error: 'Unknown or expired requestId' };
}
const pending = pendingRequests.get(requestId);
pendingRequests.delete(requestId);
clearTimeout(pending.timer);
if (error) {
pending.reject(new Error(error));
return { handled: true };
}
if (pending.action === 'list') {
pending.resolve(files !== undefined ? files : content);
return { handled: true };
}
// action === 'read'
const normalized = normalizeDraftContent(content);
setCachedDraft({
companyId: pending.companyId,
userId: pending.userId,
name: pending.name,
content: normalized,
});
pending.resolve(normalized);
return { handled: true };
}
/**
* Notify frontend about a draft update via the normal /events SSE endpoint.
*
* @param {{ companyId: number, userId: number, clientId?: string, name: string, content: any }} options
* @returns {{ name: string, size: number, updatedAt: string }}
*/
export function notifyDraftUpdate({ companyId, userId, clientId, name, content }) {
const entry = setCachedDraft({ companyId, userId, name, content });
emit('draft', {
companyId,
targetUserId: userId,
targetClientId: clientId,
data: {
sender: clientId || '',
action: 'update',
name,
content,
},
});
return {
name: `drafts/${name}`,
size: entry.size,
updatedAt: entry.updatedAt,
};
}
/**
* Synchronous/fire-and-forget version of notifyDraftUpdate for sync writes.
*
* @param {{ googleAuthId: number, clientId?: string, name: string, content: any }} options
*/
export function notifyDraftUpdateSync({ googleAuthId, clientId, name, content }) {
resolveCompanyForUser(googleAuthId).then((companyId) => {
if (companyId != null) {
notifyDraftUpdate({ companyId, userId: googleAuthId, clientId, name, content });
}
}).catch((err) => {
console.error('[draftManager] notifyDraftUpdateSync error:', err);
});
}

View File

@@ -4,6 +4,7 @@ import { defineTool, ToolError } from 'libllmorbiter';
import { isSkillPath, listSkillFiles, SKILL_EXT } from './skill.js';
import { prepareVfs } from './vfs.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
import { DRAFT_NAMES } from '../services/draftManager.js';
/**
* find_files — lists the contents of the virtual filesystem (VFS).
@@ -173,6 +174,31 @@ export function createFindFilesTool({ googleAuthId }) {
);
}
if (cleanDir.replace(/\/+$/, '').toLowerCase() === 'drafts') {
const draftEntries = Array.from(DRAFT_NAMES).map((name) => ({
path: `drafts/${name}`,
name,
type: 'file',
size: null,
modified: null,
}));
const page = draftEntries.slice(seek, seek + vfsLimit);
const truncated = Math.max(0, draftEntries.length - (seek + page.length));
const result = {
kind: 'files',
dirname: 'drafts',
recursive: recursive === true,
total: draftEntries.length,
returned: page.length,
seek,
entries: page,
};
if (truncated > 0) {
result.truncated = truncated;
}
return result;
}
const absDir = path.resolve(vfsRoot, cleanDir);
const rel = path.relative(vfsRoot, absDir);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
@@ -187,6 +213,16 @@ export function createFindFilesTool({ googleAuthId }) {
}
const all = walkVfs(absDir, vfsRoot, recursive === true, 0, maxDepth);
if (cleanDir === '.') {
if (!all.some((e) => e.path === 'drafts/')) {
all.push({ path: 'drafts/', name: 'drafts', type: 'dir', size: null, modified: null });
}
if (recursive === true) {
for (const name of DRAFT_NAMES) {
all.push({ path: `drafts/${name}`, name, type: 'file', size: null, modified: null });
}
}
}
// Directories first, then alphabetical — makes a truncated listing predictable
// instead of arbitrary.
all.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === 'dir' ? -1 : 1));

View File

@@ -5,6 +5,8 @@ import { isReadOnlyDatabasePath } from './vfs.js';
import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js';
import { prepareVfs, syncVfsToDb } from './vfs.js';
import { TOOL_DESCRIPTIONS, PATCH_PARAMETER_DESCRIPTIONS } from './prompts.js';
import { getStore } from '../db/store.js';
import { parseDraftPath, requestDraftRead, notifyDraftUpdate, getCachedDraft } from '../services/draftManager.js';
/**
* patch — applies edit operations to files in the virtual filesystem (VFS).
@@ -175,7 +177,12 @@ export function createPatchTool({ googleAuthId }) {
);
}
validated.push({ index: i, op, clean, abs, isSkill: looksSkillByExt, skillBody });
const draftInfo = parseDraftPath(clean);
if (draftInfo.isFolder) {
throw new ToolError(`Operation at index ${i}: "${clean}" is a directory and cannot be patched.`);
}
validated.push({ index: i, op, clean, abs, isSkill: looksSkillByExt, skillBody, draftInfo });
}
// ── Apply ──────────────────────────────────────────────────────
@@ -183,7 +190,57 @@ export function createPatchTool({ googleAuthId }) {
const touchedSkills = new Set();
for (const v of validated) {
const { op, clean, abs, isSkill } = v;
const { op, clean, abs, isSkill, draftInfo } = v;
if (draftInfo?.isDraft) {
const isCreate = op.search === null || op.search === undefined || op.search === '';
let original = '';
if (!isCreate) {
try {
const readVal = await requestDraftRead({ userId: googleAuthId, name: draftInfo.name });
original = typeof readVal === 'string' ? readVal : JSON.stringify(readVal ?? {}, null, 2);
} catch (err) {
const cached = getCachedDraft({ userId: googleAuthId, name: draftInfo.name });
if (cached) {
original = typeof cached.content === 'string' ? cached.content : JSON.stringify(cached.content ?? {}, null, 2);
} else {
throw new ToolError(`Cannot read draft "${clean}" before patching: ${err.message}`);
}
}
}
let draftUpdated;
if (isCreate) {
draftUpdated = typeof op.replace === 'string' ? op.replace : JSON.stringify(op.replace, null, 2);
} else {
const haystack = original.includes('\r\n') ? original.replace(/\r\n/g, '\n') : original;
const needle = typeof op.search === 'string' ? op.search.replace(/\r\n/g, '\n') : '';
if (!needle) {
throw new ToolError(`Operation at index ${v.index} ("${clean}") has an empty "search". Use "search": null to overwrite.`);
}
const at = haystack.indexOf(needle);
if (at === -1) {
throw new ToolError(`SEARCH/REPLACE failed in "${clean}": the search text was not found. Read the draft again with read_file.`);
}
const replacement = typeof op.replace === 'string' ? op.replace : JSON.stringify(op.replace, null, 2);
draftUpdated = haystack.slice(0, at) + replacement + haystack.slice(at + needle.length);
}
let parsed = draftUpdated;
try { parsed = JSON.parse(draftUpdated); } catch {}
const store = await getStore();
const row = await store.google_auth.getById(googleAuthId);
const companyId = row?.selectedCompanyId;
notifyDraftUpdate({
companyId,
userId: googleAuthId,
name: draftInfo.name,
content: parsed,
});
applied.push(`PATCH ${clean}`);
continue;
}
const isCreate = op.search === null || op.search === undefined || op.search === '';
const exists = fs.existsSync(abs);
@@ -260,16 +317,19 @@ export function createPatchTool({ googleAuthId }) {
if (isSkill) touchedSkills.add(clean);
}
// Persist immediately — unlike javascript_execute, there is no trailing sync, so a
// Persist immediately for disk files — unlike javascript_execute, there is no trailing sync, so a
// text edit would otherwise live only in the on-disk cache until some other tool ran.
try {
await syncVfsToDb({ googleAuthId, vfsRoot });
} catch (err) {
console.error('[patch] failed to sync VFS to db:', err.message);
throw new ToolError(
`Patch applied to disk but could not be persisted: ${err.message}. ` +
`Files changed: ${applied.join(', ')}.`
);
const hasDiskWrites = validated.some((v) => !v.draftInfo?.isDraft);
if (hasDiskWrites) {
try {
await syncVfsToDb({ googleAuthId, vfsRoot });
} catch (err) {
console.error('[patch] failed to sync VFS to db:', err.message);
throw new ToolError(
`Patch applied to disk but could not be persisted: ${err.message}. ` +
`Files changed: ${applied.join(', ')}.`
);
}
}
const result = {

View File

@@ -5,6 +5,7 @@ import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js';
import { getApiRouteSkill } from './routes-skills.js';
import { prepareVfs } from './vfs.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
import { parseDraftPath, requestDraftRead } from '../services/draftManager.js';
const DEFAULT_LIMIT = 400;
const MAX_LIMIT = 5000;
@@ -108,18 +109,37 @@ export function createReadFileTool({ googleAuthId }) {
throw new ToolError(`Access denied: path "${filename}" escapes virtual filesystem boundary.`);
}
const virtualSkill = !fs.existsSync(absPath) && isSkillPath(clean) ? getApiRouteSkill(clean) : null;
if (!virtualSkill && !fs.existsSync(absPath)) {
throw new ToolError(
`File not found in virtual filesystem: "${clean}". ` +
'Call find_files to see what is stored, or find_files({ skills: true }) for SKILL files.'
);
}
if (!virtualSkill && !fs.statSync(absPath).isFile()) {
throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`);
const draftInfo = parseDraftPath(clean);
if (draftInfo.isFolder) {
throw new ToolError('"drafts" is a directory. Use find_files to list its contents.');
}
const raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath);
let raw;
let virtualSkill = null;
if (draftInfo.isDraft) {
try {
const content = await requestDraftRead({
userId: googleAuthId,
name: draftInfo.name,
});
const str = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
raw = Buffer.from(str, 'utf8');
} catch (err) {
throw new ToolError(`Failed to read draft "${draftInfo.name}": ${err.message}`);
}
} else {
virtualSkill = !fs.existsSync(absPath) && isSkillPath(clean) ? getApiRouteSkill(clean) : null;
if (!virtualSkill && !fs.existsSync(absPath)) {
throw new ToolError(
`File not found in virtual filesystem: "${clean}". ` +
'Call find_files to see what is stored, or find_files({ skills: true }) for SKILL files.'
);
}
if (!virtualSkill && !fs.statSync(absPath).isFile()) {
throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`);
}
raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath);
}
if (looksBinary(raw)) {
const ext = path.extname(clean).toLowerCase();
throw new ToolError(

View File

@@ -4,6 +4,14 @@ import { getStore } from '../db/store.js';
import env from '../config/env.js';
import { getDefaultSkills } from './skill.js';
import { getDefaultApplets, DEFAULT_APPLETS_VERSION, APPLET_EXT } from './applet.js';
import {
parseDraftPath,
requestDraftRead,
notifyDraftUpdate,
notifyDraftUpdateSync,
getCachedDraft,
DRAFT_NAMES,
} from '../services/draftManager.js';
const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/;
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
@@ -300,6 +308,16 @@ export function createVfs(vfsRoot, options = {}) {
const vfs = {
readFileSync: (p, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isDraft) {
const cached = getCachedDraft({ userId: googleAuthId, name: draftInfo.name });
if (cached) {
const str = typeof cached.content === 'string' ? cached.content : JSON.stringify(cached.content ?? {}, null, 2);
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? str : Buffer.from(str, 'utf8');
}
throw new Error(`Virtual draft "${draftInfo.name}" is not cached in memory. Use vfs.promises.readFile() to fetch it asynchronously.`);
}
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
@@ -342,6 +360,19 @@ export function createVfs(vfsRoot, options = {}) {
},
writeFileSync: (p, data, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isDraft) {
let str = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : JSON.stringify(data);
let parsed = str;
try { parsed = JSON.parse(str); } catch {}
notifyDraftUpdateSync({
googleAuthId,
clientId: options.clientId,
name: draftInfo.name,
content: parsed,
});
return;
}
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
@@ -382,6 +413,8 @@ export function createVfs(vfsRoot, options = {}) {
}
},
existsSync: (p) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder || draftInfo.isDraft) return true;
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
@@ -396,6 +429,31 @@ export function createVfs(vfsRoot, options = {}) {
},
statSync: (p, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder) {
const now = new Date();
return {
isFile: () => false,
isDirectory: () => true,
size: 0,
mtime: now,
ctime: now,
atime: now,
};
}
if (draftInfo.isDraft) {
const cached = getCachedDraft({ userId: googleAuthId, name: draftInfo.name });
const now = cached?.updatedAt ? new Date(cached.updatedAt) : new Date();
const size = cached?.size ?? 0;
return {
isFile: () => true,
isDirectory: () => false,
size,
mtime: now,
ctime: now,
atime: now,
};
}
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
@@ -418,36 +476,62 @@ export function createVfs(vfsRoot, options = {}) {
lstatSync: (p, opt) => vfs.statSync(p, opt),
readdirSync: (p = '.', opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder) {
return Array.from(DRAFT_NAMES);
}
const clean = typeof p === 'string' ? p.trim().replace(/^([a-zA-Z]:|\/)+/, '') : '';
if (VIRTUAL_IMAGE_FOLDERS.has(clean.toLowerCase())) {
return [];
}
const diskPath = safeResolve(p);
return fs.readdirSync(diskPath, opt);
const entries = fs.readdirSync(diskPath, opt);
if ((clean === '' || clean === '.') && Array.isArray(entries)) {
if (!entries.some((e) => (typeof e === 'string' ? e : e.name) === 'drafts')) {
entries.push('drafts');
}
}
return entries;
},
mkdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
try {
return fs.mkdirSync(diskPath, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder || draftInfo.isDraft) {
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be created on disk.`);
}
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
try {
return fs.mkdirSync(diskPath, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
unlinkSync: (p) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder || draftInfo.isDraft) {
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be unlinked on disk.`);
}
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.unlinkSync(diskPath);
},
rmdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder || draftInfo.isDraft) {
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be removed on disk.`);
}
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmdirSync(diskPath, opt);
},
rmSync: (p, opt) => {
rmSync: (p, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder || draftInfo.isDraft) {
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be removed on disk.`);
}
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmSync(diskPath, opt);
@@ -455,6 +539,17 @@ export function createVfs(vfsRoot, options = {}) {
promises: {
readFile: async (p, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isDraft) {
const content = await requestDraftRead({
userId: googleAuthId,
clientId: options.clientId,
name: draftInfo.name,
});
const str = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? str : Buffer.from(str, 'utf8');
}
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
@@ -482,7 +577,29 @@ export function createVfs(vfsRoot, options = {}) {
}
return vfs.readFileSync(p, opt);
},
writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt),
writeFile: async (p, data, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isDraft) {
let str = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : JSON.stringify(data);
let parsed = str;
try { parsed = JSON.parse(str); } catch {}
let companyId = options.companyId;
if (companyId == null && googleAuthId) {
const store = await getStore();
const row = await store.google_auth.getById(googleAuthId);
companyId = row?.selectedCompanyId;
}
notifyDraftUpdate({
companyId,
userId: googleAuthId,
clientId: options.clientId,
name: draftInfo.name,
content: parsed,
});
return;
}
return vfs.writeFileSync(p, data, opt);
},
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
stat: async (p, opt) => vfs.statSync(p, opt),
readdir: async (p, opt) => vfs.readdirSync(p, opt),

View File

@@ -66,6 +66,7 @@ const DIVISION_COLORS = {
cron: chalk.hex('#eab308'), // Yellow
vfs: chalk.hex('#14b8a6'), // Teal
event: chalk.hex('#a855f7'), // Violet
backup: chalk.hex('#06b6d4'), // Cyan
};
/**