This commit is contained in:
seb
2026-10-29 03:28:02 +01:00
parent 907ee998c0
commit bcdfba1742
14 changed files with 184 additions and 60 deletions

View File

@@ -9,16 +9,16 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T18[], externalNumber?: str|null, createdAt?: str, shipping?: T20|null, shippings?: T20[], payment?: T29|null, payments?: T29[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T9[] }`
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T18[], shippings: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T9[], state?: str }`
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T18[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T9[] }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T21[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T36 }`
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T18[], externalNumber?: str|null, createdAt?: str, shipping?: T20|null, shippings?: T20[], payment?: T29|null, payments?: T29[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T10[] }`
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T18[], shippings: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T10[], state?: str }`
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T18[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T10[] }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T21[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T36 }`
- `T5` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T6|null }`
- `T6` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T11, totalSizeBytes: int, durationMs: int }`
- `T7` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T38|null, incoming?: { date: str, amount: num }[] }`
- `T8` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T22[] }, thumb?: str|null }`
- `T9` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T10` = `{ 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[] }`
- `T7` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, clientversion?: str|null, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T38|null, incoming?: { date: str, amount: num }[] }`
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T22[] }, thumb?: str|null }`
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T11` = `{ vfs: T37, storage: T37, database: T31 }`
- `T12` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T13` = `{ id: int, company_id: int, prompt: str, max_itr: int, cron: str, created_at: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, lastrun_at?: str|null }`
@@ -88,7 +88,7 @@ _none_
- Returns: `204 void`
#### Get article by SKU — `GET /article/{sku}`
- Params: `sku: str`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T26, brand: T25|null, categoryIds: int[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], prices: { minmultiplier: num, net: num, taxRate: num, customergroupId: int }[], suppliers: T7[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T26, brand: T25|null, categoryIds: int[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], prices: { minmultiplier: num, net: num, taxRate: num, customergroupId: int }[], suppliers: T8[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }`
#### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Returns: `204 void`
@@ -163,10 +163,10 @@ _none_
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T7[] }`
- Returns: `200 { suppliers: T8[] }`
#### 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: T7 }`
- Returns: `201 { supplier: T8 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -175,7 +175,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: T7 }`
- Returns: `200 { supplier: T8 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `204 void`
@@ -424,7 +424,7 @@ _none_
- Returns: `200 str`
#### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T9, fileUrl: str }`
- Returns: `201 { attachment: T10, fileUrl: str }`
### Section: /entity
@@ -518,19 +518,19 @@ _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: { objects: T22[] } }`
- Returns: `201 { labeltemplate: T8 }`
- Returns: `201 { labeltemplate: T9 }`
#### 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 { labeltemplate: T8 }`
- Returns: `200 { labeltemplate: T9 }`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T22[] } }`
- Returns: `200 { labeltemplate: T8 }`
- Returns: `200 { labeltemplate: T9 }`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T22[] } }`
- Returns: `200 { labeltemplate: T8 }`
- Returns: `200 { labeltemplate: T9 }`
### Section: /ledger
@@ -650,7 +650,7 @@ _none_
- Returns: `201 { payment: T29 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T9, fileUrl: str }`
- Returns: `201 { attachment: T10, fileUrl: str }`
#### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int`
- Returns: `200 { shipments: T2[] }`
@@ -680,16 +680,16 @@ _none_
#### List POS terminals — `GET /posterminal`
_none_
- Returns: `200 { posterminals: T10[] }`
- Returns: `200 { posterminals: T7[] }`
#### Create POS terminal — `PUT /posterminal`
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
- Returns: `200 { posterminal: T10 }`
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool }`
- Returns: `200 { posterminal: T7 }`
#### 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: T10 }`
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, clientversion?: str|null, pairingpin?: str|null }`
- Returns: `200 { posterminal: T7 }`
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 { id: int, categoryIds: int[] }`
@@ -788,7 +788,7 @@ _none_
- Returns: `201 { order: T1 }`
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T9, fileUrl: str }`
- Returns: `201 { attachment: T10, fileUrl: str }`
### Section: /retoure
@@ -875,7 +875,7 @@ _none_
- Returns: `200 str`
#### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T9, fileUrl: str }`
- Returns: `201 { attachment: T10, fileUrl: str }`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T20[] }`
@@ -982,7 +982,7 @@ _none_
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
- Returns: `200 jpeg`
#### POS discovery and pairing — `GET /v1/client`
- Params: `query: authCode: str, name?: str`
- Params: `query: authCode: str, name?: str, clientversion?: str`
- Returns: `200 { authCode: str|null, authToken: str|null, certificateFingerprint: str, certificateSerialNumber: str, mandantId: str, mandantName: str|null, mandantDatabase: str|null, serverFingerprint: str|null, name: str|null, serverTimestamp: str }`
#### JTL-POS customer catalog synchronization — `GET /v1/customer`
- Params: `query: lastChangedCustomer?: str, limit?: int`

View File

@@ -15,6 +15,7 @@ Special API for POS bind and sync.
|-------|----------|------|-------------|
| `authCode` | yes | string | Discovery code (1-4 digits) or pairing PIN (6 digits) |
| `name` | no | string | Client device name or serial identifier (default 'JTL-POS') |
| `clientversion` | no | string | Optional client app version (for future POS clients during pairing) |
## Response `200`

View File

@@ -22,6 +22,9 @@ Posterminal list
"clientserial": "…",
"rootCategoryId": 1,
"syncRootArticles": true,
"sort": 1,
"enabled": true,
"clientversion": "…",
"lastseenAt": "…",
"updatedAt": "…",
"categoryIds": [

View File

@@ -19,6 +19,9 @@ Updates a POS terminal configuration (name, root category, sync settings, or pai
| `name` | no | string | |
| `rootCategoryId` | no | integer | |
| `syncRootArticles` | no | boolean | |
| `sort` | no | integer | |
| `enabled` | no | boolean | |
| `clientversion` | no | string | |
| `pairingpin` | no | string | |
```json
@@ -44,6 +47,9 @@ Updated posterminal
"clientserial": "…",
"rootCategoryId": 1,
"syncRootArticles": true,
"sort": 1,
"enabled": true,
"clientversion": "…",
"lastseenAt": "…",
"updatedAt": "…",
"categoryIds": [

View File

@@ -15,6 +15,8 @@ The pairingpin is randomly generated server-side and returned in the response.
| `name` | yes | string | |
| `rootCategoryId` | no | integer | |
| `syncRootArticles` | no | boolean | |
| `sort` | no | integer | |
| `enabled` | no | boolean | |
```json
{
@@ -37,6 +39,9 @@ Created posterminal with generated pairingpin
"clientserial": "…",
"rootCategoryId": 1,
"syncRootArticles": true,
"sort": 1,
"enabled": true,
"clientversion": "…",
"lastseenAt": "…",
"updatedAt": "…",
"categoryIds": [

View File

@@ -11,6 +11,7 @@
"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",
"downgrade": "node scripts/downgradeSchema.js",
"docs:api": "node scripts/generateApiDocs.js",
"api-graph": "node generate-api-graph.js",
"review:functions": "node scripts/extractFunctions.js",

View File

@@ -0,0 +1,30 @@
/**
* Downgrades the configured database (from `.env`) to a target schema version.
*
* npm run downgrade -- <targetVersion>
*
* Uses libdashstore's `src/providers/<provider>/downgrade/<from>_to_<to>.js`.
* Stop the API first; starting it again upgrades back to the latest version.
*/
import { createProvider } from '../src/db/provider.js';
import env from '../src/config/env.js';
const [rawTarget] = process.argv.slice(2);
const target = Number(rawTarget);
if (!rawTarget || !Number.isInteger(target) || target < 1) {
console.error('Usage: npm run downgrade -- <targetVersion>');
process.exit(1);
}
const provider = createProvider();
try {
const before = await provider.getSchemaVersion();
const steps = await provider.downgradeSchema(target);
console.log(`Schema ${env.dbProvider}: ${before} -> ${target} (${steps} step(s))`);
} catch (err) {
console.error(`Downgrade failed: ${err.message}`);
process.exitCode = 1;
} finally {
await provider.close();
}

View File

@@ -1693,6 +1693,18 @@
* nullable: true
* syncRootArticles:
* type: boolean
* sort:
* type: integer
* description: Display order (ascending)
* default: 0
* enabled:
* type: boolean
* description: Whether terminal is active/allowed to sync
* default: true
* clientversion:
* type: string
* nullable: true
* description: Client software version reported by POS terminal
* lastseenAt:
* type: string
* nullable: true

View File

@@ -132,6 +132,12 @@ export function register(app, basePath = '') {
* syncRootArticles:
* type: boolean
* default: true
* sort:
* type: integer
* default: 0
* enabled:
* type: boolean
* default: true
* example:
* name: "Kassa 1"
* responses:
@@ -190,6 +196,11 @@ export function register(app, basePath = '') {
const syncRootArticles = req.body.syncRootArticles !== undefined
? req.body.syncRootArticles
: req.body.sync_root_articles;
const sort = req.body.sort !== undefined ? Number(req.body.sort) : undefined;
if (sort !== undefined && !Number.isInteger(sort)) {
return res.status(400).json({ error: 'sort must be an integer' });
}
const enabled = req.body.enabled !== undefined ? Boolean(req.body.enabled) : undefined;
try {
const posterminal = await db.upsert(req.user.id, {
@@ -197,6 +208,8 @@ export function register(app, basePath = '') {
pairingpin,
rootCategoryId,
syncRootArticles,
sort,
enabled,
});
res.status(200).json({ posterminal });
} catch (error) {
@@ -239,6 +252,13 @@ export function register(app, basePath = '') {
* nullable: true
* syncRootArticles:
* type: boolean
* sort:
* type: integer
* enabled:
* type: boolean
* clientversion:
* type: string
* nullable: true
* pairingpin:
* type: string
* nullable: true
@@ -312,6 +332,22 @@ export function register(app, basePath = '') {
payload.syncRootArticles = Boolean(rawSync);
}
if (req.body.sort !== undefined) {
const rawSort = Number(req.body.sort);
if (!Number.isInteger(rawSort)) {
return res.status(400).json({ error: 'sort must be an integer' });
}
payload.sort = rawSort;
}
if (req.body.enabled !== undefined) {
payload.enabled = Boolean(req.body.enabled);
}
if (req.body.clientversion !== undefined) {
payload.clientversion = req.body.clientversion ? String(req.body.clientversion).trim() : null;
}
if (req.body.pairingpin !== undefined) {
payload.pairingpin = req.body.pairingpin ? String(req.body.pairingpin).trim() : null;
}

View File

@@ -50,6 +50,11 @@ export async function touchLastseen(id) {
return store.posterminal.touchLastseen(id);
}
export async function setClientversion(id, clientversion) {
const store = await getStore();
return store.posterminal.setClientversion(id, clientversion);
}
export async function pair(id, data) {
const store = await getStore();
return store.posterminal.pair(id, data);

41
src/db/provider.js Normal file
View File

@@ -0,0 +1,41 @@
import { MssqlProvider } from 'libdashstore/providers/mssql';
import { BetterSqlite3Provider } from 'libdashstore/providers/better-sqlite3';
import env from '../config/env.js';
function getMssqlConfig() {
const { server, database, user, password, port, encrypt, trustServerCertificate } = env.mssql;
if (!server || !database || !user || !password) {
throw new Error('MSSQL is not configured (MSSQL_SERVER, MSSQL_DATABASE, MSSQL_USER, MSSQL_PASSWORD required)');
}
return {
server,
database,
user,
password,
port,
storageDir: env.storageDir,
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30_000,
},
options: {
encrypt,
trustServerCertificate,
},
};
}
/**
* Creates the configured (uninitialized) libdashstore provider from `.env`.
* @returns {MssqlProvider | BetterSqlite3Provider}
*/
export function createProvider() {
if (env.dbProvider === 'sqlite' || env.dbProvider === 'better-sqlite3') {
return new BetterSqlite3Provider(env.sqlite);
}
return new MssqlProvider(getMssqlConfig(), { storageDir: env.storageDir });
}

View File

@@ -1,7 +1,5 @@
import LibDashStore from 'libdashstore';
import { MssqlProvider } from 'libdashstore/providers/mssql';
import { BetterSqlite3Provider } from 'libdashstore/providers/better-sqlite3';
import env from '../config/env.js';
import { createProvider } from './provider.js';
import { createLogger, chalk } from '../util/logger.js';
import { clearCachedCompanyUsers, clearCachedUser } from '../api/middleware/auth.js';
import { clearDeletedCompany, setUserCompany } from '../api/sse.js';
@@ -134,34 +132,10 @@ function wrapStoreWithTiming(rawStore) {
return rawStore;
}
function getMssqlConfig() {
const { server, database, user, password, port, encrypt, trustServerCertificate } = env.mssql;
if (!server || !database || !user || !password) {
throw new Error('MSSQL is not configured (MSSQL_SERVER, MSSQL_DATABASE, MSSQL_USER, MSSQL_PASSWORD required)');
}
return {
server,
database,
user,
password,
port,
storageDir: env.storageDir,
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30_000,
},
options: {
encrypt,
trustServerCertificate,
},
};
}
/**
* Returns the initialized LibDashStore singleton.
* `provider.initialize()` applies pending schema upgrades (libdashstore
* `src/providers/<provider>/upgrade/`) before the store is used.
* @returns {Promise<InstanceType<typeof LibDashStore>>}
*/
export async function getStore() {
@@ -171,11 +145,7 @@ export async function getStore() {
if (!connecting) {
connecting = (async () => {
if (env.dbProvider === 'sqlite' || env.dbProvider === 'better-sqlite3') {
provider = new BetterSqlite3Provider(env.sqlite);
} else {
provider = new MssqlProvider(getMssqlConfig(), { storageDir: env.storageDir });
}
provider = createProvider();
await provider.initialize();
store = wrapStoreWithTiming(await new LibDashStore(provider));
return store;

View File

@@ -57,6 +57,11 @@ export async function posAuthMiddleware(req, res, next) {
return res.status(401).json({ Message: 'Authorization has been denied for this request.' });
}
if (terminal.enabled === 0 || terminal.enabled === false) {
console.log(chalk.yellow(` [posAuth] Terminal "${terminal.id}" is disabled`));
return res.status(403).json({ Message: 'Authorization has been denied for this request.' });
}
req.posterminal = terminal;
db.touchLastseen(terminal.id).catch((err) => {
console.error('Failed to touch lastseen for posterminal:', err);

View File

@@ -83,6 +83,13 @@ export function buildClientStep2(authCode, config) {
* type: string
* description: Client device name or serial identifier (default 'JTL-POS')
* example: "Kasse 1"
* - in: query
* name: clientversion
* required: false
* schema:
* type: string
* description: Optional client app version (for future POS clients during pairing)
* example: "1.10.12.0"
* responses:
* 200:
* description: Discovery or pairing successful
@@ -160,11 +167,13 @@ export async function handleClient(req, res) {
const token = terminal.token;
const strToken = String(token || crypto.randomBytes(16).toString('hex')).trim();
const pairedAt = serverTimestamp();
const clientversion = req.query.clientversion || req.query.version || req.query.clientVersion || null;
await db.pair(terminal.id, {
token: strToken,
clientserial: name,
pairedAt,
clientversion: clientversion ? String(clientversion).trim() : null,
});
if (terminal.companyId) {