From a4b78ad6d5bfdb21c81c2315e1588bfac46f30e2 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 3 Aug 2026 06:32:39 +0200 Subject: [PATCH] feat: add posterminal API endpoints - Add posterminal to STORE_NAMESPACES for timing wrappers - Create db/posterminal.js wrapper for store methods - Create routes/posterminal.js with all endpoints: - GET /api/posterminal - list terminals - PUT /api/posterminal - create terminal (name only, pin randomized) - DELETE /api/posterminal/:id - delete terminal - PUT/DELETE /api/posterminal/:id/category/:categoryId - toggle category - PUT/DELETE /api/posterminal/:id/categories - toggle all categories Closes # --- src/api/routes/posterminal.js | 566 ++++++++++++++++++++++++++++++++++ src/db/posterminal.js | 36 +++ src/db/store.js | 1 + 3 files changed, 603 insertions(+) create mode 100644 src/api/routes/posterminal.js create mode 100644 src/db/posterminal.js diff --git a/src/api/routes/posterminal.js b/src/api/routes/posterminal.js new file mode 100644 index 0000000..0de6bbd --- /dev/null +++ b/src/api/routes/posterminal.js @@ -0,0 +1,566 @@ +import { randomInt } from 'node:crypto'; +import * as db from '../../db/posterminal.js'; + +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 === '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; +} + +/** + * 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' + */ + 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, selected.companyId); + 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 }); + } + }); + + /** + * @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 + * 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' + */ + 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)); + + try { + const posterminal = await db.upsert(req.user.id, selected.companyId, { + name: name.trim(), + pairingpin, + }); + 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 }); + } + }); + + /** + * @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' + */ + 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, selected.companyId, 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 }); + } + }); + + /** + * @openapi + * /posterminal/{id}/category/{categoryId}: + * put: + * tags: [Posterminal] + * summary: Enable category on terminal + * description: Toggles a single category's visibility 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 on terminal + * description: Removes a single category's visibility on 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' + */ + 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, selected.companyId, 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 }); + } + }); + + 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, selected.companyId, 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 }); + } + }); + + /** + * @openapi + * /posterminal/{id}/categories: + * 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' + */ + app.put(`${basePath}/posterminal/:id/categories`, 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, selected.companyId, 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 }); + } + }); + + app.delete(`${basePath}/posterminal/:id/categories`, 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, selected.companyId, 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 }); + } + }); +} diff --git a/src/db/posterminal.js b/src/db/posterminal.js new file mode 100644 index 0000000..1d1f4ee --- /dev/null +++ b/src/db/posterminal.js @@ -0,0 +1,36 @@ +import { getStore } from './store.js'; + +export async function list(googleAuthId, companyId) { + const store = await getStore(); + return store.posterminal.list(googleAuthId, companyId); +} + +export async function upsert(googleAuthId, companyId, data) { + const store = await getStore(); + return store.posterminal.upsert(googleAuthId, companyId, data); +} + +export async function remove(googleAuthId, companyId, id) { + const store = await getStore(); + return store.posterminal.delete(googleAuthId, companyId, id); +} + +export async function setOn(googleAuthId, companyId, posterminalId, categoryId) { + const store = await getStore(); + return store.posterminal.setOn(googleAuthId, companyId, posterminalId, categoryId); +} + +export async function setOff(googleAuthId, companyId, posterminalId, categoryId) { + const store = await getStore(); + return store.posterminal.setOff(googleAuthId, companyId, posterminalId, categoryId); +} + +export async function setOnAll(googleAuthId, companyId, posterminalId) { + const store = await getStore(); + return store.posterminal.setOnAll(googleAuthId, companyId, posterminalId); +} + +export async function setOffAll(googleAuthId, companyId, posterminalId) { + const store = await getStore(); + return store.posterminal.setOffAll(googleAuthId, companyId, posterminalId); +} diff --git a/src/db/store.js b/src/db/store.js index 77cacb8..a7be2be 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -18,6 +18,7 @@ const STORE_NAMESPACES = [ 'lang', 'tax', 'google_auth', + 'posterminal', ]; /**