diff --git a/docs/api/README.md b/docs/api/README.md index c40eacb..06b50bf 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -184,6 +184,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/unit/list.md`](routes/unit/list.md) | `GET /unit` | | [`routes/unit/upsert.md`](routes/unit/upsert.md) | `PUT /unit` | | [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` | +| [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` | | [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` | | [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` | | [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 7326da5..73eb382 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -8804,7 +8804,7 @@ "Category" ], "summary": "Articles in category", - "description": "Returns articles in a category the user is authorized for.", + "description": "Returns articles in a category the user is authorized for (omits child articles of a father article). Each article includes `childCount` (the number of its child articles).", "security": [ { "cookieAuth": [] @@ -8834,6 +8834,62 @@ "type": "array", "items": { "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "sku": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "net": { + "type": "number", + "nullable": true + }, + "taxRate": { + "type": "number", + "nullable": true + }, + "track_stock": { + "type": "boolean" + }, + "optionsCount": { + "type": "integer" + }, + "articleListCount": { + "type": "integer" + }, + "childCount": { + "type": "integer", + "description": "Number of child articles belonging to this article" + }, + "fullHash": { + "type": "string", + "nullable": true + }, + "thumbW": { + "type": "integer", + "nullable": true + }, + "thumbH": { + "type": "integer", + "nullable": true + }, + "thumbHash": { + "type": "string", + "nullable": true + }, + "brand": { + "type": "object", + "nullable": true, + "additionalProperties": true + } + }, "additionalProperties": true } } @@ -13016,7 +13072,7 @@ }, "put": { "summary": "Set Stückliste (bundle items) for an article", - "description": "Configures the article as a Stückliste covering a list of count * article. An article that is a father or has options cannot be a Stückliste. Pass an empty array to remove the Stückliste.", + "description": "Configures the article as a Stückliste covering a list of count * article. A father article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste.", "tags": [ "Article" ], @@ -13172,7 +13228,7 @@ }, "put": { "summary": "Set options list for an article", - "description": "Configures options for an article. Some options can be mandated (come with the article in any case). An article that is a father or a Stückliste cannot have options. Pass an empty array to remove options.", + "description": "Configures options for an article. Some options can be mandated (come with the article in any case). A father article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options.", "tags": [ "Article" ], @@ -13520,6 +13576,164 @@ } } }, + "/v1/init": { + "get": { + "tags": [ + "POS" + ], + "summary": "POS sync initialization counts", + "description": "Returns entity change counts since the provided `lastChanged*` unix timestamp cursors\nfor the authenticated POS terminal (`Authorization: Bearer `).\n", + "x-markdown": "pos/init.md", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": true, + "schema": { + "type": "string" + }, + "description": "POS device authToken obtained during pairing (`Bearer ` or ``)", + "example": "Bearer c30099b66ec1fcb9ccd05795423c94e8" + }, + { + "in": "query", + "name": "lastChangedProduct", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Unix timestamp of last synced product update", + "example": 0 + }, + { + "in": "query", + "name": "lastChangedCategory", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Unix timestamp of last synced category update", + "example": 0 + }, + { + "in": "query", + "name": "lastChangedCustomer", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Unix timestamp of last synced customer update", + "example": 0 + }, + { + "in": "query", + "name": "lastChangedCustomerGroup", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Unix timestamp of last synced customer group update", + "example": 0 + }, + { + "in": "query", + "name": "lastChangedCompositeProduct", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Unix timestamp of last synced composite product update", + "example": 0 + }, + { + "in": "query", + "name": "lastChangedDeletedEntity", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Unix timestamp of last synced deleted entity", + "example": 0 + } + ], + "responses": { + "200": { + "description": "Sync initialization counts", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "string", + "example": "1.10.12.0" + }, + "product_count": { + "type": "string", + "example": "19" + }, + "category_count": { + "type": "string", + "example": "4" + }, + "customer_count": { + "type": "string", + "example": "0" + }, + "customerGroup_count": { + "type": "string", + "example": "1" + }, + "compositeProduct_count": { + "type": "string", + "example": "0" + }, + "configurationGroup_count": { + "type": "string", + "example": "0" + }, + "configurationItem_count": { + "type": "string", + "example": "0" + }, + "deletedEntity_count": { + "type": "string", + "example": "0" + }, + "max_orderId_count": { + "type": "string", + "example": "0" + } + } + } + } + } + }, + "401": { + "description": "Missing or invalid POS Authorization token", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Message": { + "type": "string", + "example": "Authorization has been denied for this request." + } + } + } + } + } + } + } + } + }, "/v1/client": { "get": { "tags": [ diff --git a/docs/api/routes/article/_sku/articlelist.put.md b/docs/api/routes/article/_sku/articlelist.put.md index 57a49c5..da64330 100644 --- a/docs/api/routes/article/_sku/articlelist.put.md +++ b/docs/api/routes/article/_sku/articlelist.put.md @@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Set Stückliste (bundle items) for an article -Configures the article as a Stückliste covering a list of count * article. An article that is a father or has options cannot be a Stückliste. Pass an empty array to remove the Stückliste. +Configures the article as a Stückliste covering a list of count * article. A father article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste. ## Path parameters diff --git a/docs/api/routes/article/_sku/optionslist.put.md b/docs/api/routes/article/_sku/optionslist.put.md index b90a82b..a341510 100644 --- a/docs/api/routes/article/_sku/optionslist.put.md +++ b/docs/api/routes/article/_sku/optionslist.put.md @@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Set options list for an article -Configures options for an article. Some options can be mandated (come with the article in any case). An article that is a father or a Stückliste cannot have options. Pass an empty array to remove options. +Configures options for an article. Some options can be mandated (come with the article in any case). A father article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options. ## Path parameters diff --git a/docs/api/routes/category/articles.md b/docs/api/routes/category/articles.md index f6b1cec..cf990b9 100644 --- a/docs/api/routes/category/articles.md +++ b/docs/api/routes/category/articles.md @@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Articles in category -Returns articles in a category the user is authorized for. +Returns articles in a category the user is authorized for (omits child articles of a father article). Each article includes `childCount` (the number of its child articles). ## Path parameters @@ -19,7 +19,23 @@ Articles list ```json { "articles": [ - {} + { + "id": 1, + "sku": "…", + "name": "…", + "slug": "…", + "net": 1, + "taxRate": 1, + "track_stock": true, + "optionsCount": 1, + "articleListCount": 1, + "childCount": 1, + "fullHash": "…", + "thumbW": 1, + "thumbH": 1, + "thumbHash": "…", + "brand": {} + } ] } ``` diff --git a/docs/api/routes/pos/init.md b/docs/api/routes/pos/init.md new file mode 100644 index 0000000..62ee4cc --- /dev/null +++ b/docs/api/routes/pos/init.md @@ -0,0 +1,49 @@ +# `GET /v1/init` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +POS sync initialization counts + +Returns entity change counts since the provided `lastChanged*` unix timestamp cursors +for the authenticated POS terminal (`Authorization: Bearer `). + + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `lastChangedProduct` | no | integer | Unix timestamp of last synced product update | +| `lastChangedCategory` | no | integer | Unix timestamp of last synced category update | +| `lastChangedCustomer` | no | integer | Unix timestamp of last synced customer update | +| `lastChangedCustomerGroup` | no | integer | Unix timestamp of last synced customer group update | +| `lastChangedCompositeProduct` | no | integer | Unix timestamp of last synced composite product update | +| `lastChangedDeletedEntity` | no | integer | Unix timestamp of last synced deleted entity | + +## Response `200` + +Sync initialization counts + +```json +{ + "version": "1.10.12.0", + "product_count": "19", + "category_count": "4", + "customer_count": "0", + "customerGroup_count": "1", + "compositeProduct_count": "0", + "configurationGroup_count": "0", + "configurationItem_count": "0", + "deletedEntity_count": "0", + "max_orderId_count": "0" +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 401 | Missing or invalid POS Authorization token | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/quixpos2jtl b/quixpos2jtl new file mode 160000 index 0000000..a0a2e8d --- /dev/null +++ b/quixpos2jtl @@ -0,0 +1 @@ +Subproject commit a0a2e8d87f68ced946b993130f5ef690915fcb44 diff --git a/src/api/middleware/logger.js b/src/api/middleware/logger.js index e36a865..3a5ea51 100644 --- a/src/api/middleware/logger.js +++ b/src/api/middleware/logger.js @@ -55,6 +55,9 @@ export const loggerMiddleware = (req, res, next) => { if (req.headers['content-type']) { console.log(chalk.gray(' content-type:'), chalk.cyan(req.headers['content-type'])); } + if (req.headers.authorization) { + console.log(chalk.gray(' authorization:'), chalk.cyan(req.headers.authorization)); + } logField('body', req.body ?? {}, { skipEmpty: true }); logField('response', getBody()); diff --git a/src/api/routes/article.js b/src/api/routes/article.js index a883634..67e4c9c 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -1997,7 +1997,7 @@ export function register(app, basePath = '') { * /article/{sku}/articlelist: * put: * summary: Set Stückliste (bundle items) for an article - * description: Configures the article as a Stückliste covering a list of count * article. An article that is a father or has options cannot be a Stückliste. Pass an empty array to remove the Stückliste. + * description: Configures the article as a Stückliste covering a list of count * article. A father article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste. * tags: * - Article * security: @@ -2135,7 +2135,7 @@ export function register(app, basePath = '') { * /article/{sku}/optionslist: * put: * summary: Set options list for an article - * description: Configures options for an article. Some options can be mandated (come with the article in any case). An article that is a father or a Stückliste cannot have options. Pass an empty array to remove options. + * description: Configures options for an article. Some options can be mandated (come with the article in any case). A father article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options. * tags: * - Article * security: diff --git a/src/api/routes/category.js b/src/api/routes/category.js index 5b6e07b..7ff8a57 100644 --- a/src/api/routes/category.js +++ b/src/api/routes/category.js @@ -292,7 +292,7 @@ export function register(app, basePath = '') { * get: * tags: [Category] * summary: Articles in category - * description: Returns articles in a category the user is authorized for. + * description: Returns articles in a category the user is authorized for (omits child articles of a father article). Each article includes `childCount` (the number of its child articles). * security: * - cookieAuth: [] * x-markdown: category/articles.md @@ -315,6 +315,46 @@ export function register(app, basePath = '') { * type: array * items: * type: object + * properties: + * id: + * type: integer + * sku: + * type: string + * name: + * type: string + * slug: + * type: string + * net: + * type: number + * nullable: true + * taxRate: + * type: number + * nullable: true + * track_stock: + * type: boolean + * optionsCount: + * type: integer + * articleListCount: + * type: integer + * childCount: + * type: integer + * description: Number of child articles belonging to this article + * fullHash: + * type: string + * nullable: true + * thumbW: + * type: integer + * nullable: true + * thumbH: + * type: integer + * nullable: true + * thumbHash: + * type: string + * nullable: true + * brand: + * type: object + * nullable: true + * additionalProperties: true * additionalProperties: true * 400: * description: Invalid id @@ -342,7 +382,13 @@ export function register(app, basePath = '') { } try { - const articles = await db.getCategoryArticles(req.user.id, parsed.id); + const allArticles = await db.getCategoryArticles(req.user.id, parsed.id); + const articles = allArticles + .filter((a) => a.fatherArticleId == null) + .map((a) => ({ + ...a, + childCount: Number(a.childCount ?? 0), + })); res.status(200).json({ articles }); } catch (error) { const status = categoryErrorStatus(error); diff --git a/src/db/posterminal.js b/src/db/posterminal.js index 3d592ac..66b84b0 100644 --- a/src/db/posterminal.js +++ b/src/db/posterminal.js @@ -40,7 +40,48 @@ export async function findByPairingpin(pairingpin) { return store.posterminal.findByPairingpin(pairingpin); } +export async function findByToken(token) { + const store = await getStore(); + return store.posterminal.findByToken(token); +} + export async function pair(id, data) { const store = await getStore(); return store.posterminal.pair(id, data); } + +export async function getProductCount(params) { + const store = await getStore(); + return store.posterminal.getProductCount(params); +} + +export async function getCategoryCount(params) { + const store = await getStore(); + return store.posterminal.getCategoryCount(params); +} + +export async function getCustomerCount(params) { + const store = await getStore(); + return store.posterminal.getCustomerCount(params); +} + +export async function getCustomerGroupCount(params) { + const store = await getStore(); + return store.posterminal.getCustomerGroupCount(params); +} + +export async function getCompositeProductCount(params) { + const store = await getStore(); + return store.posterminal.getCompositeProductCount(params); +} + +export async function getDeletedEntityCount(params) { + const store = await getStore(); + return store.posterminal.getDeletedEntityCount(params); +} + +export async function getMaxOrderIdCount(params) { + const store = await getStore(); + return store.posterminal.getMaxOrderIdCount(params); +} + diff --git a/src/v1/index.js b/src/v1/index.js index 3b6bb33..0c31687 100644 --- a/src/v1/index.js +++ b/src/v1/index.js @@ -1,9 +1,15 @@ import { Router } from 'express'; +import { posAuthMiddleware } from './middleware/posAuth.js'; import { handleClient } from './routes/client.js'; +import { handleInit } from './routes/init.js'; const router = Router(); // POS discovery and pairing router.get('/client', handleClient); +// POS sync initialization counts +router.get('/init', posAuthMiddleware, handleInit); + export default router; + diff --git a/src/v1/middleware/posAuth.js b/src/v1/middleware/posAuth.js new file mode 100644 index 0000000..465c3ac --- /dev/null +++ b/src/v1/middleware/posAuth.js @@ -0,0 +1,66 @@ +import chalk from 'chalk'; +import * as db from '../../db/posterminal.js'; + +export function extractPosToken(req) { + const authHeader = req.headers.authorization; + if (typeof authHeader === 'string' && authHeader.trim()) { + const trimmed = authHeader.trim(); + if (/^Bearer\s+/i.test(trimmed)) { + return trimmed.replace(/^Bearer\s+/i, '').trim(); + } + if (/^Basic\s+/i.test(trimmed)) { + const b64 = trimmed.replace(/^Basic\s+/i, '').trim(); + try { + const decoded = Buffer.from(b64, 'base64').toString('utf8'); + const parts = decoded.split(':'); + return (parts[1] || parts[0] || '').trim(); + } catch { + return b64; + } + } + return trimmed; + } + + for (const key of ['x-auth-token', 'authtoken', 'auth-token', 'token', 'x-token']) { + const val = req.headers[key]; + if (typeof val === 'string' && val.trim()) { + return val.trim(); + } + } + + const queryToken = req.query?.authToken || req.query?.token; + if (typeof queryToken === 'string' && queryToken.trim()) { + return queryToken.trim(); + } + + return ''; +} + +/** + * Verifies the POS terminal token from the Authorization header and attaches + * `req.posterminal`. + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +export async function posAuthMiddleware(req, res, next) { + const token = extractPosToken(req); + if (!token) { + console.log(chalk.yellow(' [posAuth] No token found in headers:'), req.headers); + return res.status(401).json({ Message: 'Authorization has been denied for this request.' }); + } + + try { + const terminal = await db.findByToken(token); + if (!terminal) { + console.log(chalk.yellow(` [posAuth] Unknown token "${token}", headers:`), req.headers); + return res.status(401).json({ Message: 'Authorization has been denied for this request.' }); + } + + req.posterminal = terminal; + next(); + } catch (err) { + console.error('POS auth error:', err); + return res.status(500).json({ Message: 'Internal server error' }); + } +} diff --git a/src/v1/routes/client.js b/src/v1/routes/client.js index d8ac038..1c95973 100644 --- a/src/v1/routes/client.js +++ b/src/v1/routes/client.js @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import * as db from '../../db/posterminal.js'; import { getCompanyById } from '../../db/company.js'; import { getCertMetadata } from '../../util/cert.js'; +import { emit } from '../../api/sse.js'; export function serverTimestamp() { const d = new Date(); @@ -166,6 +167,10 @@ export async function handleClient(req, res) { pairedAt, }); + if (terminal.companyId) { + emit('posterminal', { companyId: terminal.companyId }); + } + let mandantName = process.env.MANDANT_NAME || 'eB-Standard'; let mandantDatabase = process.env.MANDANT_DATABASE || 'eazybusiness'; let mandantId = String(terminal.companyId || process.env.MANDANT_ID || '1'); diff --git a/src/v1/routes/init.js b/src/v1/routes/init.js new file mode 100644 index 0000000..94b90c3 --- /dev/null +++ b/src/v1/routes/init.js @@ -0,0 +1,165 @@ +import * as db from '../../db/posterminal.js'; + +/** + * @openapi + * /v1/init: + * get: + * tags: [POS] + * summary: POS sync initialization counts + * description: | + * Returns entity change counts since the provided `lastChanged*` unix timestamp cursors + * for the authenticated POS terminal (`Authorization: Bearer `). + * x-markdown: pos/init.md + * parameters: + * - in: header + * name: Authorization + * required: true + * schema: + * type: string + * description: POS device authToken obtained during pairing (`Bearer ` or ``) + * example: "Bearer c30099b66ec1fcb9ccd05795423c94e8" + * - in: query + * name: lastChangedProduct + * required: false + * schema: + * type: integer + * default: 0 + * description: Unix timestamp of last synced product update + * example: 0 + * - in: query + * name: lastChangedCategory + * required: false + * schema: + * type: integer + * default: 0 + * description: Unix timestamp of last synced category update + * example: 0 + * - in: query + * name: lastChangedCustomer + * required: false + * schema: + * type: integer + * default: 0 + * description: Unix timestamp of last synced customer update + * example: 0 + * - in: query + * name: lastChangedCustomerGroup + * required: false + * schema: + * type: integer + * default: 0 + * description: Unix timestamp of last synced customer group update + * example: 0 + * - in: query + * name: lastChangedCompositeProduct + * required: false + * schema: + * type: integer + * default: 0 + * description: Unix timestamp of last synced composite product update + * example: 0 + * - in: query + * name: lastChangedDeletedEntity + * required: false + * schema: + * type: integer + * default: 0 + * description: Unix timestamp of last synced deleted entity + * example: 0 + * responses: + * 200: + * description: Sync initialization counts + * content: + * application/json: + * schema: + * type: object + * properties: + * version: + * type: string + * example: "1.10.12.0" + * product_count: + * type: string + * example: "19" + * category_count: + * type: string + * example: "4" + * customer_count: + * type: string + * example: "0" + * customerGroup_count: + * type: string + * example: "1" + * compositeProduct_count: + * type: string + * example: "0" + * configurationGroup_count: + * type: string + * example: "0" + * configurationItem_count: + * type: string + * example: "0" + * deletedEntity_count: + * type: string + * example: "0" + * max_orderId_count: + * type: string + * example: "0" + * 401: + * description: Missing or invalid POS Authorization token + * content: + * application/json: + * schema: + * type: object + * properties: + * Message: + * type: string + * example: "Authorization has been denied for this request." + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +export async function handleInit(req, res) { + const posterminalId = req.posterminal.id; + + const productCursor = Number(req.query.lastChangedProduct) || 0; + const categoryCursor = Number(req.query.lastChangedCategory) || 0; + const customerCursor = Number(req.query.lastChangedCustomer) || 0; + const customerGroupCursor = Number(req.query.lastChangedCustomerGroup) || 0; + const compositeProductCursor = Number(req.query.lastChangedCompositeProduct) || 0; + const deletedEntityCursor = Number(req.query.lastChangedDeletedEntity) || 0; + + try { + const [ + productCount, + categoryCount, + customerCount, + customerGroupCount, + compositeProductCount, + deletedEntityCount, + maxOrderIdCount, + ] = await Promise.all([ + db.getProductCount({ posterminalId, cursor: productCursor }), + db.getCategoryCount({ posterminalId, cursor: categoryCursor }), + db.getCustomerCount({ posterminalId, cursor: customerCursor }), + db.getCustomerGroupCount({ posterminalId, cursor: customerGroupCursor }), + db.getCompositeProductCount({ posterminalId, cursor: compositeProductCursor }), + db.getDeletedEntityCount({ posterminalId, cursor: deletedEntityCursor }), + db.getMaxOrderIdCount({ posterminalId }), + ]); + + return res.status(200).json({ + version: '1.10.12.0', + product_count: String(productCount), + category_count: String(categoryCount), + customer_count: String(customerCount), + customerGroup_count: String(customerGroupCount), + compositeProduct_count: String(compositeProductCount), + configurationGroup_count: '0', + configurationItem_count: '0', + deletedEntity_count: String(deletedEntityCount), + max_orderId_count: String(maxOrderIdCount), + }); + } catch (err) { + console.error('POS /v1/init error:', err); + return res.status(500).json({ Message: 'Internal server error' }); + } +}