import { randomInt } from 'node:crypto'; import * as db from '../../db/posterminal.js'; // #region Helpers function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { return { error: 'No company selected' }; } return { companyId }; } function parsePositiveInt(value, field) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { return { error: `${field} is required` }; } return { id }; } function posterminalErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; if (error.message === 'Company not found') return 404; if (error.message === 'Posterminal not found') return 404; if (error.message === 'Invalid posterminal') return 400; if (error.message === 'Invalid posterminal or category') return 400; return 500; } // #endregion /** * Registers posterminal routes for the authenticated user's selected company. * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /posterminal: * get: * tags: [Posterminal] * summary: List POS terminals * description: Lists POS terminals for the authenticated user's selected company. * security: * - cookieAuth: [] * x-markdown: posterminal/list.md * responses: * 200: * description: Posterminal list * content: * application/json: * schema: * type: object * properties: * posterminals: * type: array * items: * $ref: '#/components/schemas/Posterminal' * 400: * description: No company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /posterminal app.get(`${basePath}/posterminal`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const posterminals = await db.list(req.user.id); res.status(200).json({ posterminals }); } catch (error) { const status = posterminalErrorStatus(error); if (status === 500) console.error('List posterminals failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /posterminal: * put: * tags: [Posterminal] * summary: Create POS terminal * description: | * Creates a new POS terminal for the selected company. * The pairingpin is randomly generated server-side and returned in the response. * security: * - cookieAuth: [] * x-markdown: posterminal/upsert.md * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [name] * properties: * name: * type: string * rootCategoryId: * type: integer * nullable: true * syncRootArticles: * type: boolean * default: true * sort: * type: integer * default: 0 * enabled: * type: boolean * default: true * example: * name: "Kassa 1" * responses: * 200: * description: Created posterminal with generated pairingpin * content: * application/json: * schema: * type: object * properties: * posterminal: * $ref: '#/components/schemas/Posterminal' * 400: * description: Invalid posterminal or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region PUT /posterminal app.put(`${basePath}/posterminal`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const { name } = req.body ?? {}; if (typeof name !== 'string' || !name.trim()) { return res.status(400).json({ error: 'name is required' }); } const pairingpin = String(randomInt(100000, 999999)); const rootCategoryId = req.body.rootCategoryId; const syncRootArticles = req.body.syncRootArticles; 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, { name: name.trim(), pairingpin, rootCategoryId, syncRootArticles, sort, enabled, }); res.status(200).json({ posterminal }); } catch (error) { const status = posterminalErrorStatus(error); if (status === 500) console.error('Upsert posterminal failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /posterminal/{id}: * patch: * tags: [Posterminal] * summary: Update POS terminal * description: Updates a POS terminal configuration (name, root category, sync settings, or pairing pin). * security: * - cookieAuth: [] * x-markdown: posterminal/update.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: POS terminal ID * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * rootCategoryId: * type: integer * nullable: true * syncRootArticles: * type: boolean * sort: * type: integer * enabled: * type: boolean * pairingpin: * type: string * nullable: true * example: * name: "Kassa 1 Neu" * rootCategoryId: 10 * syncRootArticles: false * responses: * 200: * description: Updated posterminal * content: * application/json: * schema: * type: object * properties: * posterminal: * $ref: '#/components/schemas/Posterminal' * 400: * description: Invalid posterminal or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Posterminal not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region PATCH /posterminal/:id app.patch(`${basePath}/posterminal/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsed = parsePositiveInt(req.params.id, 'id'); if (parsed.error) { return res.status(400).json({ error: parsed.error }); } const payload = { id: parsed.id }; if (req.body.name !== undefined) { if (typeof req.body.name !== 'string' || !req.body.name.trim()) { return res.status(400).json({ error: 'name must be a non-empty string' }); } payload.name = req.body.name.trim(); } if (req.body.rootCategoryId !== undefined) { payload.rootCategoryId = req.body.rootCategoryId != null ? Number(req.body.rootCategoryId) : null; } if (req.body.syncRootArticles !== undefined) { payload.syncRootArticles = Boolean(req.body.syncRootArticles); } 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.pairingpin !== undefined) { payload.pairingpin = req.body.pairingpin ? String(req.body.pairingpin).trim() : null; } try { const posterminal = await db.upsert(req.user.id, payload); res.status(200).json({ posterminal }); } catch (error) { const status = posterminalErrorStatus(error); if (status === 500) console.error('Update posterminal failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /posterminal/{id}: * delete: * tags: [Posterminal] * summary: Delete POS terminal * description: Deletes a POS terminal for the selected company. * security: * - cookieAuth: [] * x-markdown: posterminal/delete.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 204: * description: Deleted * 400: * description: No company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Posterminal not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region DELETE /posterminal/:id app.delete(`${basePath}/posterminal/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsed = parsePositiveInt(req.params.id, 'id'); if (parsed.error) { return res.status(400).json({ error: parsed.error }); } try { await db.remove(req.user.id, parsed.id); res.sendStatus(204); } catch (error) { const status = posterminalErrorStatus(error); if (status === 500) console.error('Delete posterminal failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /posterminal/{id}/category/{categoryId}: * put: * tags: [Posterminal] * summary: Enable category and subcategories on terminal * description: Enables a category and all its descendant subcategories on a terminal (idempotent). * security: * - cookieAuth: [] * x-markdown: posterminal/setOn.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * - in: path * name: categoryId * required: true * schema: * type: integer * responses: * 200: * description: Updated category IDs for this terminal * content: * application/json: * schema: * type: object * properties: * id: * type: integer * categoryIds: * type: array * items: * type: integer * 400: * description: No company selected or invalid terminal/category * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Posterminal not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * delete: * tags: [Posterminal] * summary: Disable category and subcategories on terminal * description: Removes a category and all its descendant subcategories from a terminal (idempotent). * security: * - cookieAuth: [] * x-markdown: posterminal/setOff.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * - in: path * name: categoryId * required: true * schema: * type: integer * responses: * 200: * description: Updated category IDs for this terminal * content: * application/json: * schema: * type: object * properties: * id: * type: integer * categoryIds: * type: array * items: * type: integer * 400: * description: No company selected or invalid terminal/category * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Posterminal not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region PUT /posterminal/:id/category/:categoryId app.put(`${basePath}/posterminal/:id/category/:categoryId`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const terminal = parsePositiveInt(req.params.id, 'id'); if (terminal.error) { return res.status(400).json({ error: terminal.error }); } const category = parsePositiveInt(req.params.categoryId, 'categoryId'); if (category.error) { return res.status(400).json({ error: category.error }); } try { const result = await db.setOn(req.user.id, terminal.id, category.id); res.status(200).json(result); } catch (error) { const status = posterminalErrorStatus(error); if (status === 500) console.error('SetOn posterminal failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region DELETE /posterminal/:id/category/:categoryId app.delete(`${basePath}/posterminal/:id/category/:categoryId`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const terminal = parsePositiveInt(req.params.id, 'id'); if (terminal.error) { return res.status(400).json({ error: terminal.error }); } const category = parsePositiveInt(req.params.categoryId, 'categoryId'); if (category.error) { return res.status(400).json({ error: category.error }); } try { const result = await db.setOff(req.user.id, terminal.id, category.id); res.status(200).json(result); } catch (error) { const status = posterminalErrorStatus(error); if (status === 500) console.error('SetOff posterminal failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /posterminal/{id}/all: * put: * tags: [Posterminal] * summary: Enable all categories on terminal * description: | * Walks the company's category tree and inserts any missing links * for this terminal. Returns the updated category IDs. * security: * - cookieAuth: [] * x-markdown: posterminal/setOnAll.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Updated category IDs for this terminal * content: * application/json: * schema: * type: object * properties: * id: * type: integer * categoryIds: * type: array * items: * type: integer * 400: * description: No company selected or invalid terminal * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Posterminal not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * delete: * tags: [Posterminal] * summary: Disable all categories on terminal * description: Removes all category links for this terminal. * security: * - cookieAuth: [] * x-markdown: posterminal/setOffAll.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Updated category IDs for this terminal * content: * application/json: * schema: * type: object * properties: * id: * type: integer * categoryIds: * type: array * items: * type: integer * 400: * description: No company selected or invalid terminal * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Posterminal not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region PUT /posterminal/:id/all app.put(`${basePath}/posterminal/:id/all`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const terminal = parsePositiveInt(req.params.id, 'id'); if (terminal.error) { return res.status(400).json({ error: terminal.error }); } try { const result = await db.setOnAll(req.user.id, terminal.id); res.status(200).json(result); } catch (error) { const status = posterminalErrorStatus(error); if (status === 500) console.error('SetOnAll posterminal failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region DELETE /posterminal/:id/all app.delete(`${basePath}/posterminal/:id/all`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const terminal = parsePositiveInt(req.params.id, 'id'); if (terminal.error) { return res.status(400).json({ error: terminal.error }); } try { const result = await db.setOffAll(req.user.id, terminal.id); res.status(200).json(result); } catch (error) { const status = posterminalErrorStatus(error); if (status === 500) console.error('SetOffAll posterminal failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion }