diff --git a/src/api/routes/account.js b/src/api/routes/account.js index 98db1d5..2c22494 100644 --- a/src/api/routes/account.js +++ b/src/api/routes/account.js @@ -1,5 +1,7 @@ import * as db from '../../db/account.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -24,6 +26,8 @@ function accountErrorStatus(error) { return 500; } +// #endregion + /** * Registers account routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -64,6 +68,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /account + app.get(`${basePath}/account`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -80,6 +86,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /account: @@ -130,6 +138,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /account + app.post(`${basePath}/account`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -146,6 +156,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /account/{id}: @@ -190,6 +202,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /account/:id + app.delete(`${basePath}/account/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -210,4 +224,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/article.js b/src/api/routes/article.js deleted file mode 100644 index 9f546e9..0000000 --- a/src/api/routes/article.js +++ /dev/null @@ -1,2339 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import express from 'express'; -import JSZip from 'jszip'; -import { renderLabel } from 'react-canvas-label-editor'; -import * as db from '../../db/article.js'; -import * as picDb from '../../db/pic.js'; -import * as printerDb from '../../db/printer.js'; -import * as labeltemplateDb from '../../db/labeltemplate.js'; -import { emit } from '../sse.js'; -import { buildArtikelCsv, toWindows1252 } from '../../util/artikelCsv.js'; - -const AVIF_BODY_LIMIT = '20mb'; -const FULL_HASH_RE = /^[a-zA-Z0-9]{8}$/; - -/** Notify SSE subscribers after create / rename-ish list-level article changes. */ -function emitArticlesSignal(req) { - const companyId = req.user?.selectedCompanyId; - if (companyId == null) return; - emit('articles', { companyId }); -} - -/** Notify SSE subscribers of a single-article detail change. */ -function emitArticleSignal(req, sku) { - const companyId = req.user?.selectedCompanyId; - if (companyId == null) return; - emit(`article-${sku}`, { companyId }); -} - -function parsePositiveInt(value, field) { - const id = Number(value); - if (!Number.isInteger(id) || id <= 0) { - return { error: `${field} is required` }; - } - return { id }; -} - -function parseSku(value) { - const sku = typeof value === 'string' ? value.trim() : ''; - if (!sku) return { error: 'sku is required' }; - return { sku: decodeURIComponent(sku) }; -} - -function parseFullHash(value) { - const fullHash = typeof value === 'string' ? value.trim() : ''; - if (!FULL_HASH_RE.test(fullHash)) { - return { error: 'fullHash must be an 8-char alphanumeric string' }; - } - return { fullHash }; -} - -function articleErrorStatus(error) { - if (error.message === 'No company selected') return 400; - if (error.message === 'Unsupported image format' || error.message?.includes('unsupported image format')) return 400; - if (error.message === 'Not authorized') return 403; - if (error.message === 'Parent not found') return 404; - if ( - error.message === 'Article not found' || - error.message?.startsWith('Article not found') || - error.message?.startsWith('Component article not found') || - error.message?.startsWith('Option article not found') - ) return 404; - if (error.message === 'Image not found') return 404; - if (error.message === 'Name required') return 400; - if (error.message === 'Invalid image order') return 400; - if (error.message === 'Article has children') return 400; - if (error.message === 'Invalid prices') return 400; - if (error.message === 'POS customer group required') return 400; - if (error.message === 'Invalid unitamount') return 400; - if (error.message === 'Unit not found') return 404; - if (error.message === 'Invalid eans') return 400; - if (error.message === 'EAN already in use' || error.message === 'EAN already assigned') return 409; - if (error.message?.includes('UNIQUE constraint') || error.message?.includes('duplicate key') || error.message?.includes('Unique constraint') || error.message?.includes('already exists')) return 409; - if (error.message === 'Company not found') return 404; - if (error.message === 'Brand not found') return 404; - if (error.message === 'Invalid brandId') return 400; - if (error.message === 'Invalid attributeValueId') return 400; - if (error.message === 'Invalid weight') return 400; - if (error.message === 'Invalid track_stock') return 400; - if (error.message === 'Invalid categoryIds' || error.message === 'Article must have at least one category') return 400; - if (error.message === 'Attribute name not found') return 404; - if (error.message === 'attributeNameId is required to become a parent article') return 400; - if (error.message?.includes('already used by an ancestor parent')) return 400; - // Variant-parent preconditions: a parent article may not carry stock, - // suppliers, a Stückliste or options. These are user-correctable 409s, - // not server faults — without these the route returns a bare 500. - if (error.message === 'An article with existing stock cannot become a parent article') return 409; - if (error.message === 'An article with assigned suppliers cannot become a parent article') return 409; - if (error.message === 'A Stückliste article cannot become a parent') return 409; - if (error.message === 'An article with options cannot become a parent') return 409; - if (error.message?.includes('cannot have attribute values for its variant attribute')) return 400; - if (error.message?.includes('A parent article cannot have') || error.message?.includes('parent article cannot have')) return 400; - if (error.message === 'Cannot disable parent mode on an article that still has children') return 400; - if (error.message === 'Article is not a parent article') return 400; - if (error.message === 'Attribute value is required for child article' || error.message === 'Attribute value is required') return 400; - if (error.message === 'Attribute value not found or does not match parent attribute') return 400; - if (error.message === 'Child article does not belong to this parent') return 400; - if (error.message === 'Article already belongs to another parent') return 409; - if (error.message?.includes('sku is not allowed when creating a child article')) return 400; - if (error.message === 'sku is required' || error.message?.includes('sku is required')) return 400; - if (error.message === 'Article is already connected to a parent article') return 400; - if (error.message === 'An article cannot be a child of itself') return 400; - if (error.message === 'Variant with this attribute value already exists') return 400; - if (error.message?.startsWith('Child article') && error.message?.includes('already exists')) return 400; - if (error.message?.toLowerCase().includes('duplicate variant')) return 400; - if (error.message === 'Parent article not found') return 404; - if ( - error.message?.includes('Stückliste') || - error.message?.toLowerCase().includes('option') || - error.message?.includes('Item at index') || - error.message?.includes('Option at index') || - error.message?.includes('Invalid items array') || - error.message?.includes('Invalid options array') || - error.message?.includes('Invalid count') || - error.message?.includes('Invalid sort') || - error.message?.includes('Invalid mandated') || - error.message?.includes('circular') || - error.message?.includes('Circular') || - error.message?.includes('Duplicate article') - ) return 400; - return 500; -} - -function interpolateString(str, article) { - if (typeof str !== 'string') return str; - const net = article.net_price ?? article.prices?.[0]?.net; - const taxRate = article.prices?.[0]?.taxRate ?? 0; - const gross = net != null ? +(net * (1 + taxRate / 100)).toFixed(2) : null; - const ean = (article.eans && article.eans.length > 0) ? article.eans[0] : ''; - - return str - .replace(/\{\{(?:ARTICLE_NAME|article_name|name)\}\}/gi, article.name || '') - .replace(/\{\{SKU\}\}/gi, article.sku || '') - .replace(/\{\{(?:NET_PRICE|net_price)\}\}/gi, net != null ? net.toString() : '') - .replace(/\{\{(?:GROSS_PRICE|gross_price)\}\}/gi, gross != null ? gross.toString() : '') - .replace(/\{\{(?:PRICE|price)\}\}/gi, gross != null ? gross.toString() : (net != null ? net.toString() : '')) - .replace(/\{\{(?:EAN|ean)\}\}/gi, ean); -} - -/** - * Registers article routes (requires auth middleware). - * @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 - * /article: - * post: - * tags: [Article] - * summary: Create article in category - * description: Creates an article under an authorized category (`parentId`). - * security: - * - cookieAuth: [] - * x-signals: [articles] - * x-markdown: article/create.md - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [categoryId, name] - * properties: - * categoryId: - * type: integer - * description: Category ID under which to create the article - * name: - * type: string - * desc: - * type: string - * description: Article description text/HTML - * seo: - * type: string - * description: SEO text - * track_stock: - * type: boolean - * description: Whether to track inventory stock (defaults to true) - * example: - * categoryId: 10 - * name: New article - * responses: - * 201: - * description: Created - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/ArticleSummary' - * 400: - * description: Validation error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 401: - * description: Unauthorized - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 403: - * description: Not authorized for parent category - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 404: - * description: Parent not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 409: - * description: SKU already exists - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.post(`${basePath}/article`, async (req, res) => { - const rawParent = req.body?.categoryId; - if (rawParent === undefined || rawParent === null) { - return res.status(400).json({ error: 'categoryId is required' }); - } - if (typeof rawParent !== 'number' || !Number.isInteger(rawParent) || rawParent <= 0) { - return res.status(400).json({ error: 'categoryId must be an integer' }); - } - const parentId = rawParent; - - const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; - if (!name) { - return res.status(400).json({ error: 'name is required' }); - } - - const rawTrack = req.body?.track_stock; - if (rawTrack !== undefined && typeof rawTrack !== 'boolean' && rawTrack !== 0 && rawTrack !== 1) { - return res.status(400).json({ error: 'Invalid track_stock' }); - } - const track_stock = rawTrack !== undefined ? Boolean(rawTrack) : true; - const customSku = typeof req.body?.sku === 'string' && req.body.sku.trim() ? req.body.sku.trim() : undefined; - - try { - const desc = req.body?.desc; - const created = await db.createUnderParent({ - googleAuthId: req.user.id, - parentId, - name, - track_stock, - sku: customSku, - desc: desc !== undefined ? desc : undefined, - seo: req.body?.seo !== undefined ? req.body.seo : undefined, - }); - emitArticlesSignal(req); - const article = { - ...created, - categoryId: created.parentId, - }; - res.status(201).json({ article }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Create article failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/export.zip: - * get: - * tags: [Article] - * summary: Export articles as ZIP - * description: | - * Builds an `artikel.zip` for the authenticated user's selected company. - * `categoryId` (optional) restricts the export to that category and below; - * it must be reachable via the company's root category. Defaults to the - * whole company root tree. - * The ZIP contains `artikel.csv` (semicolon-separated, Windows-1252) - * plus one AVIF entry per article image, named `.avif` - * (content-addressed, so shared hashes are deduplicated). - * security: - * - cookieAuth: [] - * parameters: - * - in: query - * name: categoryId - * required: false - * schema: - * type: integer - * description: Restrict export to this category subtree - * responses: - * 200: - * description: ZIP archive - * content: - * application/zip: - * schema: - * type: string - * format: binary - * 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}/article/export.zip`, async (req, res) => { - const companyId = req.user.selectedCompanyId; - if (companyId == null) { - return res.status(400).json({ error: 'No company selected' }); - } - - let categoryId = null; - if (req.query.categoryId != null && req.query.categoryId !== '') { - const parsed = parsePositiveInt(req.query.categoryId, 'categoryId'); - if (parsed.error) { - return res.status(400).json({ error: parsed.error }); - } - categoryId = parsed.id; - } - - try { - const rows = await db.listExport(req.user.id, categoryId); - - const csv = buildArtikelCsv(rows); - - const zip = new JSZip(); - zip.file('artikel.csv', toWindows1252(csv)); - - const seen = new Set(); - for (const row of rows) { - for (const fullHash of row.fullHashes ?? []) { - if (!fullHash || seen.has(fullHash)) continue; - seen.add(fullHash); - try { - const { buffer } = await picDb.getImageByHash(req.user.id, fullHash); - zip.file(`${fullHash}.avif`, buffer); - } catch (error) { - // Skip images this user cannot access; CSV already carries the hash. - } - } - } - - const content = await zip.generateAsync({ type: 'nodebuffer' }); - res.setHeader('Content-Type', 'application/zip'); - res.setHeader('Content-Disposition', 'attachment; filename="artikel.zip"'); - res.status(200).send(content); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Export articles failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}: - * get: - * tags: [Article] - * summary: Get article by SKU - * description: Returns article detail by SKU if the user may access its category. - * security: - * - cookieAuth: [] - * x-markdown: article/get.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * responses: - * 200: - * description: Article detail - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/ArticleDetail' - * 400: - * description: Missing sku - * 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: Article not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * patch: - * tags: [Article] - * summary: Update article by SKU - * description: | - * Patches article fields. At least one of `name`, `unitamount`, `unitSi` required. - * `unitSi` must already exist for the company. Returns the same detail shape as GET. - * security: - * - cookieAuth: [] - * x-signals: [articles] - * x-markdown: article/update.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * name: - * type: string - * desc: - * type: string - * description: Article description text/HTML - * seo: - * type: string - * description: SEO text - * unitamount: - * type: number - * description: Must be finite and `> 0` - * unitSi: - * type: string - * description: Existing company unit `si` - * track_stock: - * type: boolean - * description: Whether to track inventory stock - * example: - * unitamount: 0.24 - * unitSi: l - * responses: - * 200: - * description: Updated article detail - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/ArticleDetail' - * 400: - * description: Validation error (Name required / Invalid unitamount) - * 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: Article or unit not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * delete: - * tags: [Article] - * summary: Delete article by SKU - * description: | - * Deletes the article when it has no child articles. Cascades related rows; - * nulls `manifestarticle.article_id` and appends a delete comment. - * Auth like GET by SKU. - * security: - * - cookieAuth: [] - * x-signals: [articles] - * x-markdown: article/delete.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * - in: query - * name: cascade - * schema: - * type: boolean - * description: If true, recursively deletes all descendant child articles in the subtree - * responses: - * 204: - * description: Deleted - * 400: - * description: Article has children - * 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: Article not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.get(`${basePath}/article/:sku`, async (req, res) => { - const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; - if (!sku) { - return res.status(400).json({ error: 'sku is required' }); - } - - try { - const { article } = await db.getBySku(req.user.id, decodeURIComponent(sku)); - res.status(200).json({ article }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Get article by sku failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/history: - * get: - * tags: [Article] - * summary: Get article history by SKU - * description: | - * Returns `articlehistory` rows for the article, newest first (`date` DESC, `id` DESC). - * Auth like GET by SKU. - * security: - * - cookieAuth: [] - * x-markdown: article/history.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * maximum: 1000 - * description: Maximum number of entries to return (newest first). Omit for all entries. - * responses: - * 200: - * description: Article history (newest first) - * content: - * application/json: - * schema: - * type: object - * required: [history, truncated] - * properties: - * history: - * type: array - * items: - * $ref: '#/components/schemas/ArticleHistoryEntry' - * truncated: - * type: boolean - * description: True when a limit was applied and the result set was exactly that size (more entries may exist). - * example: - * history: - * - id: 42 - * date: '2026-08-05T07:15:00.000Z' - * text: "Name geändert von \"Tee\" auf \"Grüner Tee\"\nEinheit geändert von \"500g\" auf \"1kg\"" - * - id: 41 - * date: '2026-08-01T09:00:00.000Z' - * text: 'Name geändert von "" auf "Tee"' - * 400: - * description: Missing sku - * 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: Article not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.get(`${basePath}/article/:sku/history`, async (req, res) => { - const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; - if (!sku) { - return res.status(400).json({ error: 'sku is required' }); - } - - const rawLimit = req.query.limit; - let limit = null; - if (rawLimit !== undefined) { - limit = parseInt(rawLimit, 10); - if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { - return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' }); - } - } - - try { - const history = await db.getHistoryBySku(req.user.id, decodeURIComponent(sku), limit != null ? { limit } : undefined); - const truncated = limit != null && history.length === limit; - res.status(200).json({ history, truncated }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Get article history by sku failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - app.patch(`${basePath}/article/:sku`, async (req, res) => { - const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; - if (!sku) { - return res.status(400).json({ error: 'sku is required' }); - } - - const body = req.body ?? {}; - const patch = {}; - if (Object.prototype.hasOwnProperty.call(body, 'name')) { - patch.name = body.name; - } - if (Object.prototype.hasOwnProperty.call(body, 'unitamount')) { - patch.unitamount = body.unitamount; - } - if (Object.prototype.hasOwnProperty.call(body, 'unitSi')) { - patch.unitSi = body.unitSi; - } - if (Object.prototype.hasOwnProperty.call(body, 'track_stock')) { - patch.track_stock = body.track_stock; - } - if (Object.prototype.hasOwnProperty.call(body, 'desc')) { - patch.desc = body.desc; - } - if (Object.prototype.hasOwnProperty.call(body, 'seo')) { - patch.seo = body.seo; - } - - if ( - !('name' in patch) && - !('unitamount' in patch) && - !('unitSi' in patch) && - !('track_stock' in patch) && - !('desc' in patch) && - !('seo' in patch) - ) { - return res.status(400).json({ error: 'name, unitamount, unitSi, track_stock, desc, or seo is required' }); - } - - try { - const decodedSku = decodeURIComponent(sku); - const article = await db.updateBySku(req.user.id, decodedSku, patch); - emitArticlesSignal(req); - emitArticleSignal(req, decodedSku); - res.status(200).json({ article }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Update article by sku failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - app.delete(`${basePath}/article/:sku`, async (req, res) => { - const parsed = parseSku(req.params.sku); - if (parsed.error) { - return res.status(400).json({ error: parsed.error }); - } - - const cascade = req.query.cascade === 'true'; - - try { - await db.remove(req.user.id, parsed.sku, { cascade }); - emitArticlesSignal(req); - res.sendStatus(204); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Delete article failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/prices: - * put: - * tags: [Article] - * summary: Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) - * description: | - * Replace-all prices for the company POS customer group. - * `net` takes precedence over `price` (legacy alias for net) and `gross`. - * `tax` and `taxRate` both specify the tax rate percentage (e.g. 19 for 19%), not monetary tax. - * When `gross` is provided, net price is computed as `gross / (1 + taxRate / 100)`. - * Prices are strictly scoped to the company's designated POS customer group (customergroupId returned on GET). - * Empty `prices` clears all POS prices. Returns the same shape as GET by SKU. - * security: - * - cookieAuth: [] - * x-signals: [article-] - * x-markdown: article/set-prices.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [prices] - * properties: - * prices: - * type: array - * items: - * type: object - * properties: - * net: - * type: number - * description: Net price (excl. VAT). Either `net`, `gross`, or legacy `price` is required. - * gross: - * type: number - * description: Gross price (incl. VAT). Net is computed as `gross / (1 + taxRate / 100)`. - * price: - * type: number - * description: Legacy alias for `net`. - * taxRate: - * type: number - * description: Tax rate percentage (e.g. 19 for 19%, 7 for 7%, 0 for 0%). - * tax: - * type: number - * description: Alias for `taxRate` percentage. - * minmultiplier: - * type: number - * description: Defaults to 1; must be unique per request. - * example: - * prices: - * - net: 10.5 - * taxRate: 19 - * - gross: 11.9 - * taxRate: 19 - * minmultiplier: 10 - * responses: - * 200: - * description: Updated article detail - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/ArticleDetail' - * 400: - * description: Invalid prices, POS customer group required, or parent article cannot have prices - * 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: Article not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.put(`${basePath}/article/:sku/prices`, async (req, res) => { - const skuParsed = parseSku(req.params.sku); - if (skuParsed.error) { - return res.status(400).json({ error: skuParsed.error }); - } - - if (!Array.isArray(req.body?.prices)) { - return res.status(400).json({ error: 'prices is required' }); - } - - try { - const article = await db.setPricesBySku(req.user.id, skuParsed.sku, req.body.prices); - emitArticleSignal(req, skuParsed.sku); - res.status(200).json({ article }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Set article prices failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/image: - * post: - * tags: [Article] - * summary: Add article image - * description: | - * Accepts a full-size AVIF body (`Content-Type: image/avif`) or WebP body (`Content-Type: image/webp`). - * WebP images are automatically converted to AVIF on the server. - * Full: stored as-is when already AVIF and ≤1000×1000; otherwise scaled to max 1000×1000. - * Thumb always scaled to max 200×200. Stores hash/thumbhash, links `articlepic`. - * Auth like GET by SKU. - * security: - * - cookieAuth: [] - * x-signals: [article-] - * x-markdown: article/add-image.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * requestBody: - * required: true - * content: - * image/avif: - * schema: - * type: string - * format: binary - * image/webp: - * schema: - * type: string - * format: binary - * responses: - * 201: - * description: Image stored and linked - * content: - * application/json: - * schema: - * type: object - * properties: - * image: - * $ref: '#/components/schemas/ArticleImage' - * 400: - * description: Missing body or invalid image - * 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: Article not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.post( - `${basePath}/article/:sku/image`, - express.raw({ type: ['image/avif', 'image/webp', 'application/octet-stream'], limit: AVIF_BODY_LIMIT }), - async (req, res) => { - const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; - if (!sku) { - return res.status(400).json({ error: 'sku is required' }); - } - - const imageBuffer = Buffer.isBuffer(req.body) ? req.body : null; - if (!imageBuffer?.length) { - return res.status(400).json({ error: 'AVIF or WebP image body is required' }); - } - - try { - const decodedSku = decodeURIComponent(sku); - const image = await db.addImage(req.user.id, decodedSku, imageBuffer); - emitArticleSignal(req, decodedSku); - res.status(201).json({ image }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Add article image failed:', error); - res.status(status).json({ error: error.message }); - } - }, - ); - - /** - * @openapi - * /article/{sku}/image/{fullHash}: - * delete: - * tags: [Article] - * summary: Delete article image - * description: Removes `articlepic` and the `pic` row for this article (`fullHash`). Auth like GET by SKU. - * security: - * - cookieAuth: [] - * x-signals: [article-] - * x-markdown: article/delete-image.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * - in: path - * name: fullHash - * required: true - * schema: - * type: string - * pattern: '^[a-zA-Z0-9]{8}$' - * responses: - * 204: - * description: Deleted - * 400: - * description: Invalid hash - * 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: Article or image not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.delete(`${basePath}/article/:sku/image/:fullHash`, async (req, res) => { - const skuParsed = parseSku(req.params.sku); - if (skuParsed.error) { - return res.status(400).json({ error: skuParsed.error }); - } - const hashParsed = parseFullHash(req.params.fullHash); - if (hashParsed.error) { - return res.status(400).json({ error: hashParsed.error }); - } - - try { - await db.deleteImage(req.user.id, skuParsed.sku, hashParsed.fullHash); - emitArticleSignal(req, skuParsed.sku); - res.sendStatus(204); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Delete article image failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/images/order: - * put: - * tags: [Article] - * summary: Reorder article images - * description: | - * Sets `articlepic.sort` to `0..n-1` in the given order. - * `fullHashes` must list every image of the article exactly once. - * security: - * - cookieAuth: [] - * x-signals: [article-] - * x-markdown: article/order-images.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [fullHashes] - * properties: - * fullHashes: - * type: array - * items: - * type: string - * pattern: '^[a-zA-Z0-9]{8}$' - * example: - * fullHashes: - * - "aB1cD2eF" - * - "gH3iJ4kL" - * responses: - * 200: - * description: New order - * content: - * application/json: - * schema: - * type: object - * properties: - * fullHashes: - * type: array - * items: - * type: string - * 400: - * description: Invalid image order - * 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: Article not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.put(`${basePath}/article/:sku/images/order`, async (req, res) => { - const skuParsed = parseSku(req.params.sku); - if (skuParsed.error) { - return res.status(400).json({ error: skuParsed.error }); - } - - const rawHashes = req.body?.fullHashes; - if (!Array.isArray(rawHashes)) { - return res.status(400).json({ error: 'fullHashes is required' }); - } - - const fullHashes = []; - for (const value of rawHashes) { - const parsed = parseFullHash(value); - if (parsed.error) { - return res.status(400).json({ error: parsed.error }); - } - fullHashes.push(parsed.fullHash); - } - - try { - const result = await db.orderImages(req.user.id, skuParsed.sku, { fullHashes }); - emitArticleSignal(req, skuParsed.sku); - res.status(200).json(result); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Order article images failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/eans: - * put: - * tags: [Article] - * summary: Replace article EANs by SKU - * description: | - * Replace-all EANs for the article. EANs must be non-empty unique strings; - * empty array clears all EANs. Returns the same shape as GET by SKU. - * security: - * - cookieAuth: [] - * x-signals: [article-] - * x-markdown: article/set-eans.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [eans] - * properties: - * eans: - * type: array - * items: - * type: string - * example: - * eans: ["4001234567890"] - * responses: - * 200: - * description: Updated article detail - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/ArticleDetail' - * 400: - * description: Invalid eans - * 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: Article not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 409: - * description: EAN already in use - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.put(`${basePath}/article/:sku/eans`, async (req, res) => { - const skuParsed = parseSku(req.params.sku); - if (skuParsed.error) { - return res.status(400).json({ error: skuParsed.error }); - } - - if (!Array.isArray(req.body?.eans)) { - return res.status(400).json({ error: 'eans is required' }); - } - - try { - const article = await db.setEansBySku(req.user.id, skuParsed.sku, req.body.eans); - emitArticleSignal(req, skuParsed.sku); - res.status(200).json({ article }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Set article eans failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/brand: - * put: - * tags: [Article] - * summary: Set or clear article brand by SKU (null to clear) - * description: | - * Sets the article's `brandId` (must belong to the article's company). - * Pass `null` to clear the brand. Returns the same shape as GET by SKU. - * security: - * - cookieAuth: [] - * x-signals: [article-] - * x-markdown: article/set-brand.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [brandId] - * properties: - * brandId: - * type: integer - * nullable: true - * description: Brand ID to assign, or null to remove the brand - * example: - * brandId: 3 - * responses: - * 200: - * description: Updated article detail - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/ArticleDetail' - * 400: - * description: Validation error - * 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: Article or brand not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.put(`${basePath}/article/:sku/brand`, async (req, res) => { - const skuParsed = parseSku(req.params.sku); - if (skuParsed.error) { - return res.status(400).json({ error: skuParsed.error }); - } - - const brandId = req.body?.brandId ?? null; - if (brandId !== null && (typeof brandId !== 'number' || !Number.isInteger(brandId) || brandId <= 0)) { - return res.status(400).json({ error: 'Invalid brandId' }); - } - - try { - const article = await db.setBrandIdBySku(req.user.id, skuParsed.sku, brandId); - emitArticleSignal(req, skuParsed.sku); - res.status(200).json({ article }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Set article brand failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/weight: - * put: - * tags: [Article] - * summary: Set article weight by SKU - * description: | - * Sets the article's `weight`. Must be finite and `>= 0`. - * Returns the same shape as GET by SKU. - * security: - * - cookieAuth: [] - * x-signals: [article-] - * x-markdown: article/set-weight.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [weight] - * properties: - * weight: - * type: number - * example: - * weight: 1.5 - * responses: - * 200: - * description: Updated article detail - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/ArticleDetail' - * 400: - * description: Invalid weight - * 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: Article not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.put(`${basePath}/article/:sku/weight`, async (req, res) => { - const skuParsed = parseSku(req.params.sku); - if (skuParsed.error) { - return res.status(400).json({ error: skuParsed.error }); - } - - const weight = req.body?.weight; - - try { - const article = await db.setWeightBySku(req.user.id, skuParsed.sku, weight); - emitArticleSignal(req, skuParsed.sku); - res.status(200).json({ article }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Set article weight failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/track_stock: - * put: - * tags: [Article] - * summary: Set article track_stock flag by SKU - * description: | - * Enables or disables inventory stock tracking for this article. - * security: - * - cookieAuth: [] - * x-signals: [article-] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [track_stock] - * properties: - * track_stock: - * type: boolean - * description: Whether to track inventory stock for this article - * example: - * track_stock: true - * responses: - * 200: - * description: Updated article detail - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/ArticleDetail' - * 400: - * description: Invalid track_stock - * 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: Article not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - async function handleSetTrackStock(req, res) { - const skuParsed = parseSku(req.params.sku); - if (skuParsed.error) { - return res.status(400).json({ error: skuParsed.error }); - } - - const raw = req.body?.track_stock; - if (raw === undefined || (typeof raw !== 'boolean' && raw !== 0 && raw !== 1)) { - return res.status(400).json({ error: 'Invalid track_stock' }); - } - - try { - const article = await db.setTrackStockBySku(req.user.id, skuParsed.sku, Boolean(raw)); - emitArticleSignal(req, skuParsed.sku); - res.status(200).json({ article }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Set article track_stock failed:', error); - res.status(status).json({ error: error.message }); - } - } - - app.put(`${basePath}/article/:sku/track_stock`, handleSetTrackStock); - app.put(`${basePath}/article/:sku/track-stock`, handleSetTrackStock); - - /** - * @openapi - * /article/{sku}/categories: - * put: - * tags: [Article] - * summary: Replace article categories by SKU - * description: | - * Replace-all the article's category list. Each category must belong to - * the article's company; ids must be unique. The article must belong to - * at least one category (empty array is rejected). Returns the same shape as GET by SKU. - * security: - * - cookieAuth: [] - * x-signals: [article-] - * x-markdown: article/set-categories.md - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [categoryIds] - * properties: - * categoryIds: - * type: array - * minItems: 1 - * items: - * type: integer - * example: - * categoryIds: [4, 7] - * responses: - * 200: - * description: Updated article detail - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/ArticleDetail' - * 400: - * description: Invalid categoryIds or empty array - * 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: Article not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.put(`${basePath}/article/:sku/categories`, async (req, res) => { - const skuParsed = parseSku(req.params.sku); - if (skuParsed.error) { - return res.status(400).json({ error: skuParsed.error }); - } - - if (!Array.isArray(req.body?.categoryIds)) { - return res.status(400).json({ error: 'categoryIds is required' }); - } - - try { - const article = await db.setCategoryIdsBySku( - req.user.id, - skuParsed.sku, - req.body.categoryIds, - ); - emitArticleSignal(req, skuParsed.sku); - res.status(200).json({ article }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Set article categories failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/print: - * post: - * tags: [Article] - * summary: Print article label - * description: Prints a label for the article using a label template. - * security: - * - cookieAuth: [] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [templateId, printerId, quantity] - * properties: - * templateId: - * type: integer - * printerId: - * type: integer - * quantity: - * type: integer - * responses: - * 200: - * description: Print job dispatched - * content: - * application/json: - * schema: - * type: object - * required: [success] - * properties: - * success: - * type: boolean - * example: true - * 400: - * description: Invalid parameters - * 404: - * description: Printer, Template or Article not found - * 500: - * description: Server error - */ - app.post(`${basePath}/article/:sku/print`, express.json(), async (req, res) => { - const companyId = req.user?.selectedCompanyId; - if (companyId == null) { - return res.status(400).json({ error: 'No company selected' }); - } - - const { sku: parsedSku, error: skuError } = parseSku(req.params.sku); - if (skuError) return res.status(400).json({ error: skuError }); - const sku = parsedSku; - - const { templateId, printerId, quantity } = req.body ?? {}; - - if (templateId === undefined || templateId === null) { - return res.status(400).json({ error: 'templateId is required' }); - } - if (typeof templateId !== 'number' || !Number.isInteger(templateId) || templateId <= 0) { - return res.status(400).json({ error: 'templateId must be an integer' }); - } - - if (printerId === undefined || printerId === null) { - return res.status(400).json({ error: 'printerId is required' }); - } - if (typeof printerId !== 'number' || !Number.isInteger(printerId) || printerId <= 0) { - return res.status(400).json({ error: 'printerId must be an integer' }); - } - - if (quantity === undefined || quantity === null) { - return res.status(400).json({ error: 'quantity is required' }); - } - if (typeof quantity !== 'number' || !Number.isInteger(quantity) || quantity <= 0) { - return res.status(400).json({ error: 'quantity must be an integer' }); - } - - const parsedPrinterId = { id: printerId }; - const parsedTemplateId = { id: templateId }; - const parsedQuantity = { id: quantity }; - - try { - const printer = await printerDb.getById(req.user.id, parsedPrinterId.id); - if (!printer || printer.company_id !== companyId) { - return res.status(404).json({ error: 'Printer not found' }); - } - if (printer.type !== 'zpl') { - return res.status(400).json({ error: 'Printer is not a ZPL printer' }); - } - - const template = await labeltemplateDb.getById(req.user.id, parsedTemplateId.id); - if (!template || template.company_id !== companyId) { - return res.status(404).json({ error: 'Label template not found' }); - } - - let article; - try { - const result = await db.getBySku(req.user.id, sku); - article = result?.article; - } catch (err) { - const status = articleErrorStatus(err); - if (status === 500) console.error('Get article by sku failed for print:', err); - return res.status(status).json({ error: err.message }); - } - if (!article) { - return res.status(404).json({ error: 'Article not found' }); - } - - // Extract and interpolate canvas objects - const content = template.content; - const objects = Array.isArray(content?.objects) - ? content.objects - : Array.isArray(content) - ? content - : []; - - const interpolatedObjects = objects.map(obj => { - const cloned = { ...obj }; - if (typeof cloned.text === 'string') { - cloned.text = interpolateString(cloned.text, article); - } - if (typeof cloned.code === 'string') { - cloned.code = interpolateString(cloned.code, article); - } - return cloned; - }); - - const printPayload = await renderLabel({ - width: template.width, - height: template.height, - objects: interpolatedObjects, - }); - - // Mock network dispatch - const printDir = path.join(process.cwd(), 'print_spool', `${printer.address}_${printer.type}`); - await fs.mkdir(printDir, { recursive: true }); - - for (let i = 0; i < parsedQuantity.id; i++) { - const timestamp = Date.now(); - const filename = parsedQuantity.id > 1 ? `${timestamp}_${i + 1}.png` : `${timestamp}.png`; - const filePath = path.join(printDir, filename); - await fs.writeFile(filePath, printPayload); - } - - res.status(200).json({ success: true }); - } catch (error) { - console.error('Print article label failed:', error); - res.status(500).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/parent: - * post: - * summary: Toggle parent mode for an article - * description: | - * Enables or disables parent article status. Enabling requires specifying an - * attributeNameId; disabling requires the article to have zero children. - * - * `attributeNameId` is **conditionally required**: it is optional in the schema - * because it is only required when `isParent` is `true`. Omitting it (or passing - * `0`/a negative value) together with `isParent: true` returns `400` - * `attributeNameId is required to become a parent article`. It must not be sent - * when `isParent` is `false`. - * tags: - * - Article - * security: - * - cookieAuth: [] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [isParent] - * properties: - * isParent: - * type: boolean - * description: True to make article a parent, false to revert to regular article - * attributeNameId: - * type: integer - * description: | - * **Conditionally required when `isParent` is `true`** (required iff isParent=true). - * The attributename id used for the article's variants. Omitting it while - * enabling parent mode returns 400. Must be omitted when `isParent` is `false`. - * responses: - * 200: - * description: Updated article - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/Article' - * 400: - * description: | - * Invalid input — `attributeNameId` missing while `isParent` is true, article still - * has children (disabling), or the attribute is already used by an ancestor parent. - * 409: - * description: | - * Article state conflicts with becoming a variant parent — it still has stock, - * assigned suppliers, a Stückliste, or options. Clear these first. - * 404: - * description: Article or attribute name not found - */ - app.post(`${basePath}/article/:sku/parent`, async (req, res) => { - const parsed = parseSku(req.params.sku); - if (parsed.error) return res.status(400).json({ error: parsed.error }); - - const isParent = req.body?.isParent; - if (typeof isParent !== 'boolean') { - return res.status(400).json({ error: 'isParent boolean is required' }); - } - const attributeNameId = req.body?.attributeNameId; - - try { - const result = await db.toggleParent(req.user.id, parsed.sku, { - isParent, - attributeNameId, - }); - emitArticleSignal(req, parsed.sku); - emitArticlesSignal(req); - res.status(200).json(result); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Toggle parent failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/children: - * get: - * summary: List child articles of a parent article - * description: Returns variants/children linked to this parent article. - * tags: - * - Article - * security: - * - cookieAuth: [] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Parent article SKU - * responses: - * 200: - * description: List of children - * content: - * application/json: - * schema: - * type: object - * properties: - * children: - * type: array - * items: - * $ref: '#/components/schemas/ArticleChild' - * 404: - * description: Article not found - */ - app.get(`${basePath}/article/:sku/children`, async (req, res) => { - const parsed = parseSku(req.params.sku); - if (parsed.error) return res.status(400).json({ error: parsed.error }); - - try { - const children = await db.listChildren(req.user.id, parsed.sku); - res.status(200).json({ children }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('List children failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/children: - * post: - * summary: Create and attach a new child article to a parent article - * description: Automatically generates SKU as parentSku-nextFreeIndex and name from parent name + value. Rejects a caller-supplied sku. - * tags: - * - Article - * security: - * - cookieAuth: [] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Parent article SKU - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * attributeValueId: - * type: integer - * description: ID of existing attribute value for the parent's attribute - * value: - * type: string - * description: String value to find or create under the parent's attribute - * name: - * type: string - * description: Optional custom variant name - * responses: - * 201: - * description: Created child article - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/Article' - * 400: - * description: Parent not configured as parent, missing value, duplicate variant, or sku was provided in body - * put: - * summary: Attach an existing article as a child to a parent article - * description: Attaches an existing article to this parent article. Idempotent if already a child. Returns 409 if the article already belongs to another parent. - * tags: - * - Article - * security: - * - cookieAuth: [] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Parent article SKU - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - sku - * properties: - * sku: - * type: string - * description: SKU of the existing article to attach as child - * attributeValueId: - * type: integer - * description: ID of existing attribute value for the parent's attribute - * value: - * type: string - * description: String value to find or create under the parent's attribute - * reparent: - * type: boolean - * description: Whether to allow re-parenting if child already belongs to another parent - * responses: - * 200: - * description: Attached child article and list of children - * content: - * application/json: - * schema: - * type: object - * properties: - * article: - * $ref: '#/components/schemas/Article' - * children: - * type: array - * items: - * $ref: '#/components/schemas/ArticleChild' - * 400: - * description: Missing sku or attribute value, or invalid input - * 404: - * description: Parent or child article not found - * 409: - * description: Article already belongs to another parent, or variant value already exists - */ - app.post(`${basePath}/article/:sku/children`, async (req, res) => { - const parsed = parseSku(req.params.sku); - if (parsed.error) return res.status(400).json({ error: parsed.error }); - - if (req.body?.sku || req.body?.childSku) { - return res.status(400).json({ - error: 'sku is not allowed when creating a child article; omit sku to auto-generate or use PUT to attach an existing article', - }); - } - - try { - const result = await db.addChild(req.user.id, parsed.sku, req.body || {}); - emitArticleSignal(req, parsed.sku); - if (result?.article?.sku) { - emitArticleSignal(req, result.article.sku); - } - emitArticlesSignal(req); - res.status(201).json(result); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Add child failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - app.put(`${basePath}/article/:sku/children`, async (req, res) => { - const parsed = parseSku(req.params.sku); - if (parsed.error) return res.status(400).json({ error: parsed.error }); - - const childSku = typeof req.body?.sku === 'string' && req.body.sku.trim() - ? req.body.sku.trim() - : (typeof req.body?.childSku === 'string' && req.body.childSku.trim() ? req.body.childSku.trim() : ''); - if (!childSku) { - return res.status(400).json({ error: 'sku is required' }); - } - - try { - const result = await db.attachChild(req.user.id, parsed.sku, req.body || {}); - emitArticleSignal(req, parsed.sku); - emitArticleSignal(req, childSku); - emitArticlesSignal(req); - res.status(200).json(result); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Attach child failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/children/{childSku}: - * delete: - * summary: Remove a child article from a parent article - * description: Unlinks the child from the parent (or deletes if deleteArticle query/body is true). - * tags: - * - Article - * security: - * - cookieAuth: [] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Parent article SKU - * - in: path - * name: childSku - * required: true - * schema: - * type: string - * description: Child article SKU - * - in: query - * name: deleteArticle - * schema: - * type: boolean - * description: If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a parent) instead of just unlinking - * responses: - * 200: - * description: Child removed - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * deleted: - * type: boolean - */ - app.delete(`${basePath}/article/:sku/children/:childSku`, async (req, res) => { - const parsed = parseSku(req.params.sku); - if (parsed.error) return res.status(400).json({ error: parsed.error }); - - const parsedChild = parseSku(req.params.childSku); - if (parsedChild.error) return res.status(400).json({ error: 'childSku is required' }); - - const deleteArticle = req.query.deleteArticle === 'true' || req.body?.deleteArticle === true; - - try { - const result = await db.removeChild(req.user.id, parsed.sku, parsedChild.sku, { - deleteArticle, - }); - emitArticleSignal(req, parsed.sku); - emitArticleSignal(req, parsedChild.sku); - emitArticlesSignal(req); - res.status(200).json(result); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Remove child failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/articlelist: - * get: - * summary: Get Stückliste (bundle items) for an article - * description: Returns the list of component items for a Stückliste article. - * tags: - * - Article - * security: - * - cookieAuth: [] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * responses: - * 200: - * description: List of bundle items - * content: - * application/json: - * schema: - * type: object - * properties: - * items: - * type: array - * items: - * $ref: '#/components/schemas/StuecklisteItem' - * 404: - * description: Article not found - */ - app.get(`${basePath}/article/:sku/articlelist`, async (req, res) => { - const parsed = parseSku(req.params.sku); - if (parsed.error) return res.status(400).json({ error: parsed.error }); - - try { - const items = await db.getArticlelist(req.user.id, parsed.sku); - res.status(200).json({ items }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Get articlelist failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /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. A parent article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste. - * tags: - * - Article - * security: - * - cookieAuth: [] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [items] - * properties: - * items: - * type: array - * items: - * type: object - * properties: - * articleId: - * type: integer - * sku: - * type: string - * count: - * type: integer - * default: 1 - * sort: - * type: integer - * responses: - * 200: - * description: Updated Stückliste items - * content: - * application/json: - * schema: - * type: object - * properties: - * items: - * type: array - * items: - * $ref: '#/components/schemas/StuecklisteItem' - * 400: - * description: Article is a parent, has options, or contains itself - * 404: - * description: Article or component article not found - */ - app.put(`${basePath}/article/:sku/articlelist`, async (req, res) => { - const parsed = parseSku(req.params.sku); - if (parsed.error) return res.status(400).json({ error: parsed.error }); - - const items = req.body?.items; - if (!Array.isArray(items)) { - return res.status(400).json({ error: 'items array is required' }); - } - - try { - const updated = await db.setArticlelist(req.user.id, parsed.sku, items); - emitArticleSignal(req, parsed.sku); - emitArticlesSignal(req); - res.status(200).json({ items: updated }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Set articlelist failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /article/{sku}/optionslist: - * get: - * summary: Get options list for an article - * description: Returns the available options for an article. - * tags: - * - Article - * security: - * - cookieAuth: [] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * responses: - * 200: - * description: List of options - * content: - * application/json: - * schema: - * type: object - * properties: - * options: - * type: array - * items: - * $ref: '#/components/schemas/ArticleOptionItem' - * 404: - * description: Article not found - */ - app.get(`${basePath}/article/:sku/optionslist`, async (req, res) => { - const parsed = parseSku(req.params.sku); - if (parsed.error) return res.status(400).json({ error: parsed.error }); - - try { - const options = await db.getOptionslist(req.user.id, parsed.sku); - res.status(200).json({ options }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Get optionslist failed:', error); - res.status(status).json({ error: error.message }); - } - }); - - /** - * @openapi - * /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). A parent article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options. - * tags: - * - Article - * security: - * - cookieAuth: [] - * parameters: - * - in: path - * name: sku - * required: true - * schema: - * type: string - * description: Article SKU - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [options] - * properties: - * options: - * type: array - * items: - * type: object - * properties: - * articleId: - * type: integer - * sku: - * type: string - * count: - * type: integer - * default: 1 - * sort: - * type: integer - * mandated: - * type: boolean - * default: false - * responses: - * 200: - * description: Updated options list - * content: - * application/json: - * schema: - * type: object - * properties: - * options: - * type: array - * items: - * $ref: '#/components/schemas/ArticleOptionItem' - * 400: - * description: Article is a parent, a Stückliste, or contains itself - * 404: - * description: Article or option article not found - */ - app.put(`${basePath}/article/:sku/optionslist`, async (req, res) => { - const parsed = parseSku(req.params.sku); - if (parsed.error) return res.status(400).json({ error: parsed.error }); - - const options = req.body?.options; - if (!Array.isArray(options)) { - return res.status(400).json({ error: 'options array is required' }); - } - - try { - const updated = await db.setOptionslist(req.user.id, parsed.sku, options); - emitArticleSignal(req, parsed.sku); - emitArticlesSignal(req); - res.status(200).json({ options: updated }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('Set optionslist failed:', error); - res.status(status).json({ error: error.message }); - } - }); -} - diff --git a/src/api/routes/article/attributes.js b/src/api/routes/article/attributes.js new file mode 100644 index 0000000..15fdb23 --- /dev/null +++ b/src/api/routes/article/attributes.js @@ -0,0 +1,613 @@ +import * as db from '../../../db/article.js'; +import { + emitArticleSignal, + parseSku, + articleErrorStatus, +} from './shared.js'; + +/** + * Registers article attribute routes — prices, EANs, brand, weight, stock + * tracking and category membership (requires auth middleware). + * @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 + * /article/{sku}/prices: + * put: + * tags: [Article] + * summary: Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) + * description: | + * Replace-all prices for the company POS customer group. + * `net` takes precedence over `price` (legacy alias for net) and `gross`. + * `tax` and `taxRate` both specify the tax rate percentage (e.g. 19 for 19%), not monetary tax. + * When `gross` is provided, net price is computed as `gross / (1 + taxRate / 100)`. + * Prices are strictly scoped to the company's designated POS customer group (customergroupId returned on GET). + * Empty `prices` clears all POS prices. Returns the same shape as GET by SKU. + * security: + * - cookieAuth: [] + * x-signals: [article-] + * x-markdown: article/set-prices.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [prices] + * properties: + * prices: + * type: array + * items: + * type: object + * properties: + * net: + * type: number + * description: Net price (excl. VAT). Either `net`, `gross`, or legacy `price` is required. + * gross: + * type: number + * description: Gross price (incl. VAT). Net is computed as `gross / (1 + taxRate / 100)`. + * price: + * type: number + * description: Legacy alias for `net`. + * taxRate: + * type: number + * description: Tax rate percentage (e.g. 19 for 19%, 7 for 7%, 0 for 0%). + * tax: + * type: number + * description: Alias for `taxRate` percentage. + * minmultiplier: + * type: number + * description: Defaults to 1; must be unique per request. + * example: + * prices: + * - net: 10.5 + * taxRate: 19 + * - gross: 11.9 + * taxRate: 19 + * minmultiplier: 10 + * responses: + * 200: + * description: Updated article detail + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/ArticleDetail' + * 400: + * description: Invalid prices, POS customer group required, or parent article cannot have prices + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region PUT /article/:sku/prices + + app.put(`${basePath}/article/:sku/prices`, async (req, res) => { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + + if (!Array.isArray(req.body?.prices)) { + return res.status(400).json({ error: 'prices is required' }); + } + + try { + const article = await db.setPricesBySku(req.user.id, skuParsed.sku, req.body.prices); + emitArticleSignal(req, skuParsed.sku); + res.status(200).json({ article }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Set article prices failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/eans: + * put: + * tags: [Article] + * summary: Replace article EANs by SKU + * description: | + * Replace-all EANs for the article. EANs must be non-empty unique strings; + * empty array clears all EANs. Returns the same shape as GET by SKU. + * security: + * - cookieAuth: [] + * x-signals: [article-] + * x-markdown: article/set-eans.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [eans] + * properties: + * eans: + * type: array + * items: + * type: string + * example: + * eans: ["4001234567890"] + * responses: + * 200: + * description: Updated article detail + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/ArticleDetail' + * 400: + * description: Invalid eans + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: EAN already in use + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region PUT /article/:sku/eans + + app.put(`${basePath}/article/:sku/eans`, async (req, res) => { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + + if (!Array.isArray(req.body?.eans)) { + return res.status(400).json({ error: 'eans is required' }); + } + + try { + const article = await db.setEansBySku(req.user.id, skuParsed.sku, req.body.eans); + emitArticleSignal(req, skuParsed.sku); + res.status(200).json({ article }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Set article eans failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/brand: + * put: + * tags: [Article] + * summary: Set or clear article brand by SKU (null to clear) + * description: | + * Sets the article's `brandId` (must belong to the article's company). + * Pass `null` to clear the brand. Returns the same shape as GET by SKU. + * security: + * - cookieAuth: [] + * x-signals: [article-] + * x-markdown: article/set-brand.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [brandId] + * properties: + * brandId: + * type: integer + * nullable: true + * description: Brand ID to assign, or null to remove the brand + * example: + * brandId: 3 + * responses: + * 200: + * description: Updated article detail + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/ArticleDetail' + * 400: + * description: Validation error + * 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: Article or brand not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region PUT /article/:sku/brand + + app.put(`${basePath}/article/:sku/brand`, async (req, res) => { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + + const brandId = req.body?.brandId ?? null; + if (brandId !== null && (typeof brandId !== 'number' || !Number.isInteger(brandId) || brandId <= 0)) { + return res.status(400).json({ error: 'Invalid brandId' }); + } + + try { + const article = await db.setBrandIdBySku(req.user.id, skuParsed.sku, brandId); + emitArticleSignal(req, skuParsed.sku); + res.status(200).json({ article }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Set article brand failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/weight: + * put: + * tags: [Article] + * summary: Set article weight by SKU + * description: | + * Sets the article's `weight`. Must be finite and `>= 0`. + * Returns the same shape as GET by SKU. + * security: + * - cookieAuth: [] + * x-signals: [article-] + * x-markdown: article/set-weight.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [weight] + * properties: + * weight: + * type: number + * example: + * weight: 1.5 + * responses: + * 200: + * description: Updated article detail + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/ArticleDetail' + * 400: + * description: Invalid weight + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region PUT /article/:sku/weight + + app.put(`${basePath}/article/:sku/weight`, async (req, res) => { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + + const weight = req.body?.weight; + + try { + const article = await db.setWeightBySku(req.user.id, skuParsed.sku, weight); + emitArticleSignal(req, skuParsed.sku); + res.status(200).json({ article }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Set article weight failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/track_stock: + * @openapi + * /article/{sku}/track_stock: + * put: + * tags: [Article] + * summary: Set article track_stock flag by SKU + * description: | + * Enables or disables inventory stock tracking for this article. + * security: + * - cookieAuth: [] + * x-signals: [article-] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [track_stock] + * properties: + * track_stock: + * type: boolean + * description: Whether to track inventory stock for this article + * example: + * track_stock: true + * responses: + * 200: + * description: Updated article detail + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/ArticleDetail' + * 400: + * description: Invalid track_stock + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region PUT /article/:sku/track_stock + + async function handleSetTrackStock(req, res) { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + + const raw = req.body?.track_stock; + if (raw === undefined || (typeof raw !== 'boolean' && raw !== 0 && raw !== 1)) { + return res.status(400).json({ error: 'Invalid track_stock' }); + } + + try { + const article = await db.setTrackStockBySku(req.user.id, skuParsed.sku, Boolean(raw)); + emitArticleSignal(req, skuParsed.sku); + res.status(200).json({ article }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Set article track_stock failed:', error); + res.status(status).json({ error: error.message }); + } + } + + app.put(`${basePath}/article/:sku/track_stock`, handleSetTrackStock); + app.put(`${basePath}/article/:sku/track-stock`, handleSetTrackStock); + + // #endregion + + /** + * @openapi + * /article/{sku}/categories: + * put: + * tags: [Article] + * summary: Replace article categories by SKU + * description: | + * Replace-all the article's category list. Each category must belong to + * the article's company; ids must be unique. The article must belong to + * at least one category (empty array is rejected). Returns the same shape as GET by SKU. + * security: + * - cookieAuth: [] + * x-signals: [article-] + * x-markdown: article/set-categories.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [categoryIds] + * properties: + * categoryIds: + * type: array + * minItems: 1 + * items: + * type: integer + * example: + * categoryIds: [4, 7] + * responses: + * 200: + * description: Updated article detail + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/ArticleDetail' + * 400: + * description: Invalid categoryIds or empty array + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region PUT /article/:sku/categories + + app.put(`${basePath}/article/:sku/categories`, async (req, res) => { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + + if (!Array.isArray(req.body?.categoryIds)) { + return res.status(400).json({ error: 'categoryIds is required' }); + } + + try { + const article = await db.setCategoryIdsBySku( + req.user.id, + skuParsed.sku, + req.body.categoryIds, + ); + emitArticleSignal(req, skuParsed.sku); + res.status(200).json({ article }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Set article categories failed:', error); + res.status(status).json({ error: error.message }); + + // #endregion + } + }); +} diff --git a/src/api/routes/article/crud.js b/src/api/routes/article/crud.js new file mode 100644 index 0000000..d414ef4 --- /dev/null +++ b/src/api/routes/article/crud.js @@ -0,0 +1,643 @@ +import JSZip from 'jszip'; +import * as db from '../../../db/article.js'; +import * as picDb from '../../../db/pic.js'; +import { buildArtikelCsv, toWindows1252 } from '../../../util/artikelCsv.js'; +import { + emitArticlesSignal, + emitArticleSignal, + parsePositiveInt, + parseSku, + articleErrorStatus, +} from './shared.js'; + +/** + * Registers article CRUD routes (requires auth middleware). + * + * `GET /article/export.zip` is registered before `GET /article/:sku` so the + * literal path is not swallowed by the `:sku` parameter route. + * + * @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 + * /article: + * post: + * tags: [Article] + * summary: Create article in category + * description: Creates an article under an authorized category (`parentId`). + * security: + * - cookieAuth: [] + * x-signals: [articles] + * x-markdown: article/create.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [categoryId, name] + * properties: + * categoryId: + * type: integer + * description: Category ID under which to create the article + * name: + * type: string + * desc: + * type: string + * description: Article description text/HTML + * seo: + * type: string + * description: SEO text + * track_stock: + * type: boolean + * description: Whether to track inventory stock (defaults to true) + * example: + * categoryId: 10 + * name: New article + * responses: + * 201: + * description: Created + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/ArticleSummary' + * 400: + * description: Validation error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized for parent category + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Parent not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: SKU already exists + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region POST /article + + app.post(`${basePath}/article`, async (req, res) => { + const rawParent = req.body?.categoryId; + if (rawParent === undefined || rawParent === null) { + return res.status(400).json({ error: 'categoryId is required' }); + } + if (typeof rawParent !== 'number' || !Number.isInteger(rawParent) || rawParent <= 0) { + return res.status(400).json({ error: 'categoryId must be an integer' }); + } + const parentId = rawParent; + + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + const rawTrack = req.body?.track_stock; + if (rawTrack !== undefined && typeof rawTrack !== 'boolean' && rawTrack !== 0 && rawTrack !== 1) { + return res.status(400).json({ error: 'Invalid track_stock' }); + } + const track_stock = rawTrack !== undefined ? Boolean(rawTrack) : true; + const customSku = typeof req.body?.sku === 'string' && req.body.sku.trim() ? req.body.sku.trim() : undefined; + + try { + const desc = req.body?.desc; + const created = await db.createUnderParent({ + googleAuthId: req.user.id, + parentId, + name, + track_stock, + sku: customSku, + desc: desc !== undefined ? desc : undefined, + seo: req.body?.seo !== undefined ? req.body.seo : undefined, + }); + emitArticlesSignal(req); + const article = { + ...created, + categoryId: created.parentId, + }; + res.status(201).json({ article }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Create article failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/export.zip: + * get: + * tags: [Article] + * summary: Export articles as ZIP + * description: | + * Builds an `artikel.zip` for the authenticated user's selected company. + * `categoryId` (optional) restricts the export to that category and below; + * it must be reachable via the company's root category. Defaults to the + * whole company root tree. + * The ZIP contains `artikel.csv` (semicolon-separated, Windows-1252) + * plus one AVIF entry per article image, named `.avif` + * (content-addressed, so shared hashes are deduplicated). + * security: + * - cookieAuth: [] + * parameters: + * - in: query + * name: categoryId + * required: false + * schema: + * type: integer + * description: Restrict export to this category subtree + * responses: + * 200: + * description: ZIP archive + * content: + * application/zip: + * schema: + * type: string + * format: binary + * 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 /article/export.zip + + app.get(`${basePath}/article/export.zip`, async (req, res) => { + const companyId = req.user.selectedCompanyId; + if (companyId == null) { + return res.status(400).json({ error: 'No company selected' }); + } + + let categoryId = null; + if (req.query.categoryId != null && req.query.categoryId !== '') { + const parsed = parsePositiveInt(req.query.categoryId, 'categoryId'); + if (parsed.error) { + return res.status(400).json({ error: parsed.error }); + } + categoryId = parsed.id; + } + + try { + const rows = await db.listExport(req.user.id, categoryId); + + const csv = buildArtikelCsv(rows); + + const zip = new JSZip(); + zip.file('artikel.csv', toWindows1252(csv)); + + const seen = new Set(); + for (const row of rows) { + for (const fullHash of row.fullHashes ?? []) { + if (!fullHash || seen.has(fullHash)) continue; + seen.add(fullHash); + try { + const { buffer } = await picDb.getImageByHash(req.user.id, fullHash); + zip.file(`${fullHash}.avif`, buffer); + } catch (error) { + // Skip images this user cannot access; CSV already carries the hash. + } + } + } + + const content = await zip.generateAsync({ type: 'nodebuffer' }); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename="artikel.zip"'); + res.status(200).send(content); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Export articles failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}: + * @openapi + * /article/{sku}: + * get: + * tags: [Article] + * summary: Get article by SKU + * description: Returns article detail by SKU if the user may access its category. + * security: + * - cookieAuth: [] + * x-markdown: article/get.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * responses: + * 200: + * description: Article detail + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/ArticleDetail' + * 400: + * description: Missing sku + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * patch: + * tags: [Article] + * summary: Update article by SKU + * description: | + * Patches article fields. At least one of `name`, `unitamount`, `unitSi` required. + * `unitSi` must already exist for the company. Returns the same detail shape as GET. + * security: + * - cookieAuth: [] + * x-signals: [articles] + * x-markdown: article/update.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * desc: + * type: string + * description: Article description text/HTML + * seo: + * type: string + * description: SEO text + * unitamount: + * type: number + * description: Must be finite and `> 0` + * unitSi: + * type: string + * description: Existing company unit `si` + * track_stock: + * type: boolean + * description: Whether to track inventory stock + * example: + * unitamount: 0.24 + * unitSi: l + * responses: + * 200: + * description: Updated article detail + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/ArticleDetail' + * 400: + * description: Validation error (Name required / Invalid unitamount) + * 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: Article or unit not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * delete: + * tags: [Article] + * summary: Delete article by SKU + * description: | + * Deletes the article when it has no child articles. Cascades related rows; + * nulls `manifestarticle.article_id` and appends a delete comment. + * Auth like GET by SKU. + * security: + * - cookieAuth: [] + * x-signals: [articles] + * x-markdown: article/delete.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * - in: query + * name: cascade + * schema: + * type: boolean + * description: If true, recursively deletes all descendant child articles in the subtree + * responses: + * 204: + * description: Deleted + * 400: + * description: Article has children + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region GET /article/:sku + + app.get(`${basePath}/article/:sku`, async (req, res) => { + const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; + if (!sku) { + return res.status(400).json({ error: 'sku is required' }); + } + + try { + const { article } = await db.getBySku(req.user.id, decodeURIComponent(sku)); + res.status(200).json({ article }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Get article by sku failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/history: + * get: + * tags: [Article] + * summary: Get article history by SKU + * description: | + * Returns `articlehistory` rows for the article, newest first (`date` DESC, `id` DESC). + * Auth like GET by SKU. + * security: + * - cookieAuth: [] + * x-markdown: article/history.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * minimum: 1 + * maximum: 1000 + * description: Maximum number of entries to return (newest first). Omit for all entries. + * responses: + * 200: + * description: Article history (newest first) + * content: + * application/json: + * schema: + * type: object + * required: [history, truncated] + * properties: + * history: + * type: array + * items: + * $ref: '#/components/schemas/ArticleHistoryEntry' + * truncated: + * type: boolean + * description: True when a limit was applied and the result set was exactly that size (more entries may exist). + * example: + * history: + * - id: 42 + * date: '2026-08-05T07:15:00.000Z' + * text: "Name geändert von \"Tee\" auf \"Grüner Tee\"\nEinheit geändert von \"500g\" auf \"1kg\"" + * - id: 41 + * date: '2026-08-01T09:00:00.000Z' + * text: 'Name geändert von "" auf "Tee"' + * 400: + * description: Missing sku + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region GET /article/:sku/history + + app.get(`${basePath}/article/:sku/history`, async (req, res) => { + const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; + if (!sku) { + return res.status(400).json({ error: 'sku is required' }); + } + + const rawLimit = req.query.limit; + let limit = null; + if (rawLimit !== undefined) { + limit = parseInt(rawLimit, 10); + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { + return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' }); + } + } + + try { + const history = await db.getHistoryBySku(req.user.id, decodeURIComponent(sku), limit != null ? { limit } : undefined); + const truncated = limit != null && history.length === limit; + res.status(200).json({ history, truncated }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Get article history by sku failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + // #region PATCH /article/:sku + + app.patch(`${basePath}/article/:sku`, async (req, res) => { + const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; + if (!sku) { + return res.status(400).json({ error: 'sku is required' }); + } + + const body = req.body ?? {}; + const patch = {}; + if (Object.prototype.hasOwnProperty.call(body, 'name')) { + patch.name = body.name; + } + if (Object.prototype.hasOwnProperty.call(body, 'unitamount')) { + patch.unitamount = body.unitamount; + } + if (Object.prototype.hasOwnProperty.call(body, 'unitSi')) { + patch.unitSi = body.unitSi; + } + if (Object.prototype.hasOwnProperty.call(body, 'track_stock')) { + patch.track_stock = body.track_stock; + } + if (Object.prototype.hasOwnProperty.call(body, 'desc')) { + patch.desc = body.desc; + } + if (Object.prototype.hasOwnProperty.call(body, 'seo')) { + patch.seo = body.seo; + } + + if ( + !('name' in patch) && + !('unitamount' in patch) && + !('unitSi' in patch) && + !('track_stock' in patch) && + !('desc' in patch) && + !('seo' in patch) + ) { + return res.status(400).json({ error: 'name, unitamount, unitSi, track_stock, desc, or seo is required' }); + } + + try { + const decodedSku = decodeURIComponent(sku); + const article = await db.updateBySku(req.user.id, decodedSku, patch); + emitArticlesSignal(req); + emitArticleSignal(req, decodedSku); + res.status(200).json({ article }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Update article by sku failed:', error); + // #endregion + + // #region DELETE /article/:sku + + res.status(status).json({ error: error.message }); + } + }); + + app.delete(`${basePath}/article/:sku`, async (req, res) => { + const parsed = parseSku(req.params.sku); + if (parsed.error) { + return res.status(400).json({ error: parsed.error }); + } + + const cascade = req.query.cascade === 'true'; + + try { + await db.remove(req.user.id, parsed.sku, { cascade }); + emitArticlesSignal(req); + res.sendStatus(204); + } catch (error) { + const status = articleErrorStatus(error); + + // #endregion + if (status === 500) console.error('Delete article failed:', error); + res.status(status).json({ error: error.message }); + } + }); +} diff --git a/src/api/routes/article/media.js b/src/api/routes/article/media.js new file mode 100644 index 0000000..7afb0a7 --- /dev/null +++ b/src/api/routes/article/media.js @@ -0,0 +1,465 @@ +import fs from 'fs/promises'; +import path from 'path'; +import express from 'express'; +import { renderLabel } from 'react-canvas-label-editor'; +import * as db from '../../../db/article.js'; +import * as printerDb from '../../../db/printer.js'; +import * as labeltemplateDb from '../../../db/labeltemplate.js'; +import { + AVIF_BODY_LIMIT, + emitArticleSignal, + parseSku, + parseFullHash, + articleErrorStatus, + interpolateString, +} from './shared.js'; + +/** + * Registers article image and label-printing routes (requires auth middleware). + * @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 + * /article/{sku}/image: + * post: + * tags: [Article] + * summary: Add article image + * description: | + * Accepts a full-size AVIF body (`Content-Type: image/avif`) or WebP body (`Content-Type: image/webp`). + * WebP images are automatically converted to AVIF on the server. + * Full: stored as-is when already AVIF and ≤1000×1000; otherwise scaled to max 1000×1000. + * Thumb always scaled to max 200×200. Stores hash/thumbhash, links `articlepic`. + * Auth like GET by SKU. + * security: + * - cookieAuth: [] + * x-signals: [article-] + * x-markdown: article/add-image.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * requestBody: + * required: true + * content: + * image/avif: + * schema: + * type: string + * format: binary + * image/webp: + * schema: + * type: string + * format: binary + * responses: + * 201: + * description: Image stored and linked + * content: + * application/json: + * schema: + * type: object + * properties: + * image: + * $ref: '#/components/schemas/ArticleImage' + * 400: + * description: Missing body or invalid image + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region POST /article/:sku/image + + app.post( + `${basePath}/article/:sku/image`, + express.raw({ type: ['image/avif', 'image/webp', 'application/octet-stream'], limit: AVIF_BODY_LIMIT }), + async (req, res) => { + const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; + if (!sku) { + return res.status(400).json({ error: 'sku is required' }); + } + + const imageBuffer = Buffer.isBuffer(req.body) ? req.body : null; + if (!imageBuffer?.length) { + return res.status(400).json({ error: 'AVIF or WebP image body is required' }); + } + + try { + const decodedSku = decodeURIComponent(sku); + const image = await db.addImage(req.user.id, decodedSku, imageBuffer); + emitArticleSignal(req, decodedSku); + res.status(201).json({ image }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Add article image failed:', error); + res.status(status).json({ error: error.message }); + } + }, + ); + + // #endregion + + /** + * @openapi + * /article/{sku}/image/{fullHash}: + * delete: + * tags: [Article] + * summary: Delete article image + * description: Removes `articlepic` and the `pic` row for this article (`fullHash`). Auth like GET by SKU. + * security: + * - cookieAuth: [] + * x-signals: [article-] + * x-markdown: article/delete-image.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * - in: path + * name: fullHash + * required: true + * schema: + * type: string + * pattern: '^[a-zA-Z0-9]{8}$' + * responses: + * 204: + * description: Deleted + * 400: + * description: Invalid hash + * 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: Article or image not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region DELETE /article/:sku/image/:fullHash + + app.delete(`${basePath}/article/:sku/image/:fullHash`, async (req, res) => { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + const hashParsed = parseFullHash(req.params.fullHash); + if (hashParsed.error) { + return res.status(400).json({ error: hashParsed.error }); + } + + try { + await db.deleteImage(req.user.id, skuParsed.sku, hashParsed.fullHash); + emitArticleSignal(req, skuParsed.sku); + res.sendStatus(204); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Delete article image failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/images/order: + * put: + * tags: [Article] + * summary: Reorder article images + * description: | + * Sets `articlepic.sort` to `0..n-1` in the given order. + * `fullHashes` must list every image of the article exactly once. + * security: + * - cookieAuth: [] + * x-signals: [article-] + * x-markdown: article/order-images.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [fullHashes] + * properties: + * fullHashes: + * type: array + * items: + * type: string + * pattern: '^[a-zA-Z0-9]{8}$' + * example: + * fullHashes: + * - "aB1cD2eF" + * - "gH3iJ4kL" + * responses: + * 200: + * description: New order + * content: + * application/json: + * schema: + * type: object + * properties: + * fullHashes: + * type: array + * items: + * type: string + * 400: + * description: Invalid image order + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region PUT /article/:sku/images/order + + app.put(`${basePath}/article/:sku/images/order`, async (req, res) => { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + + const rawHashes = req.body?.fullHashes; + if (!Array.isArray(rawHashes)) { + return res.status(400).json({ error: 'fullHashes is required' }); + } + + const fullHashes = []; + for (const value of rawHashes) { + const parsed = parseFullHash(value); + if (parsed.error) { + return res.status(400).json({ error: parsed.error }); + } + fullHashes.push(parsed.fullHash); + } + + try { + const result = await db.orderImages(req.user.id, skuParsed.sku, { fullHashes }); + emitArticleSignal(req, skuParsed.sku); + res.status(200).json(result); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Order article images failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/print: + * post: + * tags: [Article] + * summary: Print article label + * description: Prints a label for the article using a label template. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [templateId, printerId, quantity] + * properties: + * templateId: + * type: integer + * printerId: + * type: integer + * quantity: + * type: integer + * responses: + * 200: + * description: Print job dispatched + * content: + * application/json: + * schema: + * type: object + * required: [success] + * properties: + * success: + * type: boolean + * example: true + * 400: + * description: Invalid parameters + * 404: + * description: Printer, Template or Article not found + * 500: + * description: Server error + */ + // #region POST /article/:sku/print + + app.post(`${basePath}/article/:sku/print`, express.json(), async (req, res) => { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) { + return res.status(400).json({ error: 'No company selected' }); + } + + const { sku: parsedSku, error: skuError } = parseSku(req.params.sku); + if (skuError) return res.status(400).json({ error: skuError }); + const sku = parsedSku; + + const { templateId, printerId, quantity } = req.body ?? {}; + + if (templateId === undefined || templateId === null) { + return res.status(400).json({ error: 'templateId is required' }); + } + if (typeof templateId !== 'number' || !Number.isInteger(templateId) || templateId <= 0) { + return res.status(400).json({ error: 'templateId must be an integer' }); + } + + if (printerId === undefined || printerId === null) { + return res.status(400).json({ error: 'printerId is required' }); + } + if (typeof printerId !== 'number' || !Number.isInteger(printerId) || printerId <= 0) { + return res.status(400).json({ error: 'printerId must be an integer' }); + } + + if (quantity === undefined || quantity === null) { + return res.status(400).json({ error: 'quantity is required' }); + } + if (typeof quantity !== 'number' || !Number.isInteger(quantity) || quantity <= 0) { + return res.status(400).json({ error: 'quantity must be an integer' }); + } + + const parsedPrinterId = { id: printerId }; + const parsedTemplateId = { id: templateId }; + const parsedQuantity = { id: quantity }; + + try { + const printer = await printerDb.getById(req.user.id, parsedPrinterId.id); + if (!printer || printer.company_id !== companyId) { + return res.status(404).json({ error: 'Printer not found' }); + } + if (printer.type !== 'zpl') { + return res.status(400).json({ error: 'Printer is not a ZPL printer' }); + } + + const template = await labeltemplateDb.getById(req.user.id, parsedTemplateId.id); + if (!template || template.company_id !== companyId) { + return res.status(404).json({ error: 'Label template not found' }); + } + + let article; + try { + const result = await db.getBySku(req.user.id, sku); + article = result?.article; + } catch (err) { + const status = articleErrorStatus(err); + if (status === 500) console.error('Get article by sku failed for print:', err); + return res.status(status).json({ error: err.message }); + } + if (!article) { + return res.status(404).json({ error: 'Article not found' }); + } + + // Extract and interpolate canvas objects + const content = template.content; + const objects = Array.isArray(content?.objects) + ? content.objects + : Array.isArray(content) + ? content + : []; + + const interpolatedObjects = objects.map(obj => { + const cloned = { ...obj }; + if (typeof cloned.text === 'string') { + cloned.text = interpolateString(cloned.text, article); + } + if (typeof cloned.code === 'string') { + cloned.code = interpolateString(cloned.code, article); + } + return cloned; + }); + + const printPayload = await renderLabel({ + width: template.width, + height: template.height, + objects: interpolatedObjects, + }); + + // Mock network dispatch + const printDir = path.join(process.cwd(), 'print_spool', `${printer.address}_${printer.type}`); + await fs.mkdir(printDir, { recursive: true }); + + for (let i = 0; i < parsedQuantity.id; i++) { + const timestamp = Date.now(); + const filename = parsedQuantity.id > 1 ? `${timestamp}_${i + 1}.png` : `${timestamp}.png`; + const filePath = path.join(printDir, filename); + await fs.writeFile(filePath, printPayload); + } + + res.status(200).json({ success: true }); + } catch (error) { + console.error('Print article label failed:', error); + res.status(500).json({ error: error.message }); + + // #endregion + } + }); +} diff --git a/src/api/routes/article/shared.js b/src/api/routes/article/shared.js new file mode 100644 index 0000000..5f98580 --- /dev/null +++ b/src/api/routes/article/shared.js @@ -0,0 +1,132 @@ +import { emit } from '../../sse.js'; + +// #region Helpers + +export const AVIF_BODY_LIMIT = '20mb'; +export const FULL_HASH_RE = /^[a-zA-Z0-9]{8}$/; + +/** Notify SSE subscribers after create / rename-ish list-level article changes. */ +export function emitArticlesSignal(req) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) return; + emit('articles', { companyId }); +} + +/** Notify SSE subscribers of a single-article detail change. */ +export function emitArticleSignal(req, sku) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) return; + emit(`article-${sku}`, { companyId }); +} + +export function parsePositiveInt(value, field) { + const id = Number(value); + if (!Number.isInteger(id) || id <= 0) { + return { error: `${field} is required` }; + } + return { id }; +} + +export function parseSku(value) { + const sku = typeof value === 'string' ? value.trim() : ''; + if (!sku) return { error: 'sku is required' }; + return { sku: decodeURIComponent(sku) }; +} + +export function parseFullHash(value) { + const fullHash = typeof value === 'string' ? value.trim() : ''; + if (!FULL_HASH_RE.test(fullHash)) { + return { error: 'fullHash must be an 8-char alphanumeric string' }; + } + return { fullHash }; +} + +export function articleErrorStatus(error) { + if (error.message === 'No company selected') return 400; + if (error.message === 'Unsupported image format' || error.message?.includes('unsupported image format')) return 400; + if (error.message === 'Not authorized') return 403; + if (error.message === 'Parent not found') return 404; + if ( + error.message === 'Article not found' || + error.message?.startsWith('Article not found') || + error.message?.startsWith('Component article not found') || + error.message?.startsWith('Option article not found') + ) return 404; + if (error.message === 'Image not found') return 404; + if (error.message === 'Name required') return 400; + if (error.message === 'Invalid image order') return 400; + if (error.message === 'Article has children') return 400; + if (error.message === 'Invalid prices') return 400; + if (error.message === 'POS customer group required') return 400; + if (error.message === 'Invalid unitamount') return 400; + if (error.message === 'Unit not found') return 404; + if (error.message === 'Invalid eans') return 400; + if (error.message === 'EAN already in use' || error.message === 'EAN already assigned') return 409; + if (error.message?.includes('UNIQUE constraint') || error.message?.includes('duplicate key') || error.message?.includes('Unique constraint') || error.message?.includes('already exists')) return 409; + if (error.message === 'Company not found') return 404; + if (error.message === 'Brand not found') return 404; + if (error.message === 'Invalid brandId') return 400; + if (error.message === 'Invalid attributeValueId') return 400; + if (error.message === 'Invalid weight') return 400; + if (error.message === 'Invalid track_stock') return 400; + if (error.message === 'Invalid categoryIds' || error.message === 'Article must have at least one category') return 400; + if (error.message === 'Attribute name not found') return 404; + if (error.message === 'attributeNameId is required to become a parent article') return 400; + if (error.message?.includes('already used by an ancestor parent')) return 400; + // Variant-parent preconditions: a parent article may not carry stock, + // suppliers, a Stückliste or options. These are user-correctable 409s, + // not server faults — without these the route returns a bare 500. + if (error.message === 'An article with existing stock cannot become a parent article') return 409; + if (error.message === 'An article with assigned suppliers cannot become a parent article') return 409; + if (error.message === 'A Stückliste article cannot become a parent') return 409; + if (error.message === 'An article with options cannot become a parent') return 409; + if (error.message?.includes('cannot have attribute values for its variant attribute')) return 400; + if (error.message?.includes('A parent article cannot have') || error.message?.includes('parent article cannot have')) return 400; + if (error.message === 'Cannot disable parent mode on an article that still has children') return 400; + if (error.message === 'Article is not a parent article') return 400; + if (error.message === 'Attribute value is required for child article' || error.message === 'Attribute value is required') return 400; + if (error.message === 'Attribute value not found or does not match parent attribute') return 400; + if (error.message === 'Child article does not belong to this parent') return 400; + if (error.message === 'Article already belongs to another parent') return 409; + if (error.message?.includes('sku is not allowed when creating a child article')) return 400; + if (error.message === 'sku is required' || error.message?.includes('sku is required')) return 400; + if (error.message === 'Article is already connected to a parent article') return 400; + if (error.message === 'An article cannot be a child of itself') return 400; + if (error.message === 'Variant with this attribute value already exists') return 400; + if (error.message?.startsWith('Child article') && error.message?.includes('already exists')) return 400; + if (error.message?.toLowerCase().includes('duplicate variant')) return 400; + if (error.message === 'Parent article not found') return 404; + if ( + error.message?.includes('Stückliste') || + error.message?.toLowerCase().includes('option') || + error.message?.includes('Item at index') || + error.message?.includes('Option at index') || + error.message?.includes('Invalid items array') || + error.message?.includes('Invalid options array') || + error.message?.includes('Invalid count') || + error.message?.includes('Invalid sort') || + error.message?.includes('Invalid mandated') || + error.message?.includes('circular') || + error.message?.includes('Circular') || + error.message?.includes('Duplicate article') + ) return 400; + return 500; +} + +export function interpolateString(str, article) { + if (typeof str !== 'string') return str; + const net = article.net_price ?? article.prices?.[0]?.net; + const taxRate = article.prices?.[0]?.taxRate ?? 0; + const gross = net != null ? +(net * (1 + taxRate / 100)).toFixed(2) : null; + const ean = (article.eans && article.eans.length > 0) ? article.eans[0] : ''; + + return str + .replace(/\{\{(?:ARTICLE_NAME|article_name|name)\}\}/gi, article.name || '') + .replace(/\{\{SKU\}\}/gi, article.sku || '') + .replace(/\{\{(?:NET_PRICE|net_price)\}\}/gi, net != null ? net.toString() : '') + .replace(/\{\{(?:GROSS_PRICE|gross_price)\}\}/gi, gross != null ? gross.toString() : '') + .replace(/\{\{(?:PRICE|price)\}\}/gi, gross != null ? gross.toString() : (net != null ? net.toString() : '')) + .replace(/\{\{(?:EAN|ean)\}\}/gi, ean); +} + +// #endregion diff --git a/src/api/routes/article/variants.js b/src/api/routes/article/variants.js new file mode 100644 index 0000000..ca00305 --- /dev/null +++ b/src/api/routes/article/variants.js @@ -0,0 +1,651 @@ +import * as db from '../../../db/article.js'; +import { + emitArticlesSignal, + emitArticleSignal, + parseSku, + articleErrorStatus, +} from './shared.js'; + +/** + * Registers article variant routes — parent mode, children, Stückliste + * (articlelist) and options (requires auth middleware). + * @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 + * /article/{sku}/parent: + * post: + * summary: Toggle parent mode for an article + * description: | + * Enables or disables parent article status. Enabling requires specifying an + * attributeNameId; disabling requires the article to have zero children. + * + * `attributeNameId` is **conditionally required**: it is optional in the schema + * because it is only required when `isParent` is `true`. Omitting it (or passing + * `0`/a negative value) together with `isParent: true` returns `400` + * `attributeNameId is required to become a parent article`. It must not be sent + * when `isParent` is `false`. + * tags: + * - Article + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [isParent] + * properties: + * isParent: + * type: boolean + * description: True to make article a parent, false to revert to regular article + * attributeNameId: + * type: integer + * description: | + * **Conditionally required when `isParent` is `true`** (required iff isParent=true). + * The attributename id used for the article's variants. Omitting it while + * enabling parent mode returns 400. Must be omitted when `isParent` is `false`. + * responses: + * 200: + * description: Updated article + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/Article' + * 400: + * description: | + * Invalid input — `attributeNameId` missing while `isParent` is true, article still + * has children (disabling), or the attribute is already used by an ancestor parent. + * 409: + * description: | + * Article state conflicts with becoming a variant parent — it still has stock, + * assigned suppliers, a Stückliste, or options. Clear these first. + * 404: + * description: Article or attribute name not found + */ + // #region POST /article/:sku/parent + + app.post(`${basePath}/article/:sku/parent`, async (req, res) => { + const parsed = parseSku(req.params.sku); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + const isParent = req.body?.isParent; + if (typeof isParent !== 'boolean') { + return res.status(400).json({ error: 'isParent boolean is required' }); + } + const attributeNameId = req.body?.attributeNameId; + + try { + const result = await db.toggleParent(req.user.id, parsed.sku, { + isParent, + attributeNameId, + }); + emitArticleSignal(req, parsed.sku); + emitArticlesSignal(req); + res.status(200).json(result); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Toggle parent failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/children: + * get: + * summary: List child articles of a parent article + * description: Returns variants/children linked to this parent article. + * tags: + * - Article + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Parent article SKU + * responses: + * 200: + * description: List of children + * content: + * application/json: + * schema: + * type: object + * properties: + * children: + * type: array + * items: + * $ref: '#/components/schemas/ArticleChild' + * 404: + * description: Article not found + */ + // #region GET /article/:sku/children + + app.get(`${basePath}/article/:sku/children`, async (req, res) => { + const parsed = parseSku(req.params.sku); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + try { + const children = await db.listChildren(req.user.id, parsed.sku); + res.status(200).json({ children }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('List children failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/children: + * post: + * summary: Create and attach a new child article to a parent article + * description: Automatically generates SKU as parentSku-nextFreeIndex and name from parent name + value. Rejects a caller-supplied sku. + * tags: + * - Article + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Parent article SKU + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * attributeValueId: + * type: integer + * description: ID of existing attribute value for the parent's attribute + * value: + * type: string + * description: String value to find or create under the parent's attribute + * name: + * type: string + * description: Optional custom variant name + * responses: + * 201: + * description: Created child article + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/Article' + * 400: + * description: Parent not configured as parent, missing value, duplicate variant, or sku was provided in body + * put: + * summary: Attach an existing article as a child to a parent article + * description: Attaches an existing article to this parent article. Idempotent if already a child. Returns 409 if the article already belongs to another parent. + * tags: + * - Article + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Parent article SKU + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - sku + * properties: + * sku: + * type: string + * description: SKU of the existing article to attach as child + * attributeValueId: + * type: integer + * description: ID of existing attribute value for the parent's attribute + * value: + * type: string + * description: String value to find or create under the parent's attribute + * reparent: + * type: boolean + * description: Whether to allow re-parenting if child already belongs to another parent + * responses: + * 200: + * description: Attached child article and list of children + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/Article' + * children: + * type: array + * items: + * $ref: '#/components/schemas/ArticleChild' + * 400: + * description: Missing sku or attribute value, or invalid input + * 404: + * description: Parent or child article not found + * 409: + * description: Article already belongs to another parent, or variant value already exists + */ + // #region POST /article/:sku/children + + app.post(`${basePath}/article/:sku/children`, async (req, res) => { + const parsed = parseSku(req.params.sku); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + if (req.body?.sku || req.body?.childSku) { + return res.status(400).json({ + error: 'sku is not allowed when creating a child article; omit sku to auto-generate or use PUT to attach an existing article', + }); + } + + try { + const result = await db.addChild(req.user.id, parsed.sku, req.body || {}); + emitArticleSignal(req, parsed.sku); + if (result?.article?.sku) { + emitArticleSignal(req, result.article.sku); + } + emitArticlesSignal(req); + res.status(201).json(result); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Add child failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + // #region PUT /article/:sku/children + + app.put(`${basePath}/article/:sku/children`, async (req, res) => { + const parsed = parseSku(req.params.sku); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + const childSku = typeof req.body?.sku === 'string' && req.body.sku.trim() + ? req.body.sku.trim() + : (typeof req.body?.childSku === 'string' && req.body.childSku.trim() ? req.body.childSku.trim() : ''); + if (!childSku) { + return res.status(400).json({ error: 'sku is required' }); + } + + try { + const result = await db.attachChild(req.user.id, parsed.sku, req.body || {}); + emitArticleSignal(req, parsed.sku); + emitArticleSignal(req, childSku); + emitArticlesSignal(req); + res.status(200).json(result); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Attach child failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/children/{childSku}: + * delete: + * summary: Remove a child article from a parent article + * description: Unlinks the child from the parent (or deletes if deleteArticle query/body is true). + * tags: + * - Article + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Parent article SKU + * - in: path + * name: childSku + * required: true + * schema: + * type: string + * description: Child article SKU + * - in: query + * name: deleteArticle + * schema: + * type: boolean + * description: If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a parent) instead of just unlinking + * responses: + * 200: + * description: Child removed + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * deleted: + * type: boolean + */ + // #region DELETE /article/:sku/children/:childSku + + app.delete(`${basePath}/article/:sku/children/:childSku`, async (req, res) => { + const parsed = parseSku(req.params.sku); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + const parsedChild = parseSku(req.params.childSku); + if (parsedChild.error) return res.status(400).json({ error: 'childSku is required' }); + + const deleteArticle = req.query.deleteArticle === 'true' || req.body?.deleteArticle === true; + + try { + const result = await db.removeChild(req.user.id, parsed.sku, parsedChild.sku, { + deleteArticle, + }); + emitArticleSignal(req, parsed.sku); + emitArticleSignal(req, parsedChild.sku); + emitArticlesSignal(req); + res.status(200).json(result); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Remove child failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/articlelist: + * @openapi + * /article/{sku}/articlelist: + * get: + * summary: Get Stückliste (bundle items) for an article + * description: Returns the list of component items for a Stückliste article. + * tags: + * - Article + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * responses: + * 200: + * description: List of bundle items + * content: + * application/json: + * schema: + * type: object + * properties: + * items: + * type: array + * items: + * $ref: '#/components/schemas/StuecklisteItem' + * 404: + * description: Article not found + */ + // #region GET /article/:sku/articlelist + + app.get(`${basePath}/article/:sku/articlelist`, async (req, res) => { + const parsed = parseSku(req.params.sku); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + try { + const items = await db.getArticlelist(req.user.id, parsed.sku); + res.status(200).json({ items }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Get articlelist failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /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. A parent article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste. + * tags: + * - Article + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [items] + * properties: + * items: + * type: array + * items: + * type: object + * properties: + * articleId: + * type: integer + * sku: + * type: string + * count: + * type: integer + * default: 1 + * sort: + * type: integer + * responses: + * 200: + * description: Updated Stückliste items + * content: + * application/json: + * schema: + * type: object + * properties: + * items: + * type: array + * items: + * $ref: '#/components/schemas/StuecklisteItem' + * 400: + * description: Article is a parent, has options, or contains itself + * 404: + * description: Article or component article not found + */ + // #region PUT /article/:sku/articlelist + + app.put(`${basePath}/article/:sku/articlelist`, async (req, res) => { + const parsed = parseSku(req.params.sku); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + const items = req.body?.items; + if (!Array.isArray(items)) { + return res.status(400).json({ error: 'items array is required' }); + } + + try { + const updated = await db.setArticlelist(req.user.id, parsed.sku, items); + emitArticleSignal(req, parsed.sku); + emitArticlesSignal(req); + res.status(200).json({ items: updated }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Set articlelist failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /article/{sku}/optionslist: + * get: + * summary: Get options list for an article + * description: Returns the available options for an article. + * tags: + * - Article + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * responses: + * 200: + * description: List of options + * content: + * application/json: + * schema: + * type: object + * properties: + * options: + * type: array + * items: + * $ref: '#/components/schemas/ArticleOptionItem' + * 404: + * description: Article not found + */ + // #region GET /article/:sku/optionslist + + app.get(`${basePath}/article/:sku/optionslist`, async (req, res) => { + const parsed = parseSku(req.params.sku); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + try { + const options = await db.getOptionslist(req.user.id, parsed.sku); + res.status(200).json({ options }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Get optionslist failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion + + /** + * @openapi + * /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). A parent article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options. + * tags: + * - Article + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [options] + * properties: + * options: + * type: array + * items: + * type: object + * properties: + * articleId: + * type: integer + * sku: + * type: string + * count: + * type: integer + * default: 1 + * sort: + * type: integer + * mandated: + * type: boolean + * default: false + * responses: + * 200: + * description: Updated options list + * content: + * application/json: + * schema: + * type: object + * properties: + * options: + * type: array + * items: + * $ref: '#/components/schemas/ArticleOptionItem' + * 400: + * description: Article is a parent, a Stückliste, or contains itself + * 404: + * description: Article or option article not found + */ + // #region PUT /article/:sku/optionslist + + app.put(`${basePath}/article/:sku/optionslist`, async (req, res) => { + const parsed = parseSku(req.params.sku); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + const options = req.body?.options; + if (!Array.isArray(options)) { + return res.status(400).json({ error: 'options array is required' }); + } + + try { + const updated = await db.setOptionslist(req.user.id, parsed.sku, options); + emitArticleSignal(req, parsed.sku); + emitArticlesSignal(req); + res.status(200).json({ options: updated }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Set optionslist failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + // #endregion +} diff --git a/src/api/routes/brand.js b/src/api/routes/brand.js index 2745a15..305a007 100644 --- a/src/api/routes/brand.js +++ b/src/api/routes/brand.js @@ -2,6 +2,8 @@ import express from 'express'; import * as db from '../../db/brand.js'; import { emit } from '../sse.js'; +// #region Helpers + const AVIF_BODY_LIMIT = 10 * 1024 * 1024; function requireSelectedCompany(req) { @@ -35,6 +37,8 @@ function emitBrandSignal(req, brandId = null) { emit('brand', { companyId, data: { sender: typeof clientId === 'string' ? clientId : '', brandId } }); } +// #endregion + /** * Registers brand routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -120,6 +124,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /brand + app.get(`${basePath}/brand`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -169,6 +175,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /brand: @@ -246,6 +254,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /brand + app.post(`${basePath}/brand`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -268,6 +278,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /brand/{id}: @@ -363,6 +375,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /brand/:id + app.patch(`${basePath}/brand/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -390,6 +404,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /brand/{id}: @@ -444,6 +460,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /brand/:id + app.delete(`${basePath}/brand/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -466,6 +484,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /brand/{id}/image: @@ -615,6 +635,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /brand/:id/image + app.post( `${basePath}/brand/:id/image`, express.raw({ type: ['image/avif', 'image/webp', 'application/octet-stream'], limit: AVIF_BODY_LIMIT }), @@ -646,6 +668,10 @@ export function register(app, basePath = '') { } ); + // #endregion + + // #region DELETE /brand/:id/image + app.delete(`${basePath}/brand/:id/image`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -667,4 +693,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/category.js b/src/api/routes/category.js index 149b72e..d552e90 100644 --- a/src/api/routes/category.js +++ b/src/api/routes/category.js @@ -5,6 +5,8 @@ import * as picDb from '../../db/pic.js'; import { emit } from '../sse.js'; import { buildKategorienCsv, toWindows1252 } from '../../util/kategorienCsv.js'; +// #region Helpers + const AVIF_BODY_LIMIT = '20mb'; /** Notify SSE subscribers of the actor's selected company after a real category change. */ @@ -52,6 +54,8 @@ function categoryErrorStatus(error) { return 500; } +// #endregion + /** * Registers category routes (requires auth middleware). * @param {import('express').Application} app - Express app @@ -111,6 +115,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /category/tree + app.get(`${basePath}/category/tree`, async (req, res) => { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -127,6 +133,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /category/export.zip: @@ -174,6 +182,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /category/export.zip + app.get(`${basePath}/category/export.zip`, async (req, res) => { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -212,6 +222,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /category: @@ -273,6 +285,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /category + app.post(`${basePath}/category`, async (req, res) => { const rawParent = req.body?.parentId; if (rawParent === undefined || rawParent === null) { @@ -303,6 +317,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /category/{id}/articles: @@ -351,6 +367,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /category/:id/articles + app.get(`${basePath}/category/:id/articles`, async (req, res) => { const parsed = parsePositiveInt(req.params.id, 'id'); if (parsed.error) { @@ -375,6 +393,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /category/{id}: @@ -512,6 +532,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /category/:id + app.delete(`${basePath}/category/:id`, async (req, res) => { const parsed = parsePositiveInt(req.params.id, 'id'); if (parsed.error) { @@ -537,6 +559,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region PATCH /category/:id + app.patch(`${basePath}/category/:id`, async (req, res) => { const parsed = parsePositiveInt(req.params.id, 'id'); if (parsed.error) { @@ -588,6 +614,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /category/order: @@ -658,6 +686,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PUT /category/order + app.put(`${basePath}/category/order`, async (req, res) => { const parentId = Number(req.body?.parentId); if (!Number.isInteger(parentId) || parentId <= 0) { @@ -688,6 +718,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /category/{id}/image: @@ -823,6 +855,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /category/:id/image + app.post( `${basePath}/category/:id/image`, express.raw({ type: ['image/avif', 'image/webp', 'application/octet-stream'], limit: AVIF_BODY_LIMIT }), @@ -849,6 +883,10 @@ export function register(app, basePath = '') { }, ); + // #endregion + + // #region DELETE /category/:id/image + app.delete(`${basePath}/category/:id/image`, async (req, res) => { const parsed = parsePositiveInt(req.params.id, 'id'); if (parsed.error) { @@ -865,4 +903,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/categoryattributevalue.js b/src/api/routes/categoryattributevalue.js index 9f306e6..0fb793a 100644 --- a/src/api/routes/categoryattributevalue.js +++ b/src/api/routes/categoryattributevalue.js @@ -1,6 +1,8 @@ import * as db from '../../db/categoryattributevalue.js'; import { emit } from '../sse.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -33,6 +35,8 @@ function categoryAttributeValueErrorStatus(error) { return 500; } +// #endregion + /** * Registers categoryattributevalue routes under `/category/:id/attributevalue`. * @param {import('express').Application} app @@ -103,6 +107,8 @@ export function register(app, basePath = '') { * items: * $ref: '#/components/schemas/CategoryAttributeValue' */ + // #region GET /category/:id/attributevalue + app.get(`${basePath}/category/:id/attributevalue`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -122,6 +128,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region PUT /category/:id/attributevalue + app.put(`${basePath}/category/:id/attributevalue`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -143,4 +153,6 @@ export function register(app, basePath = '') { res.status(categoryAttributeValueErrorStatus(error)).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/categoryproperty.js b/src/api/routes/categoryproperty.js index d43fdbc..c090808 100644 --- a/src/api/routes/categoryproperty.js +++ b/src/api/routes/categoryproperty.js @@ -1,6 +1,8 @@ import * as db from '../../db/categoryproperty.js'; import { emit } from '../sse.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -33,6 +35,8 @@ function categoryPropertyErrorStatus(error) { return 500; } +// #endregion + /** * Registers categoryproperty routes under `/category/:id/property`. * @param {import('express').Application} app @@ -113,6 +117,8 @@ export function register(app, basePath = '') { * items: * $ref: '#/components/schemas/CategoryProperty' */ + // #region GET /category/:id/property + app.get(`${basePath}/category/:id/property`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -132,6 +138,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region PUT /category/:id/property + app.put(`${basePath}/category/:id/property`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -157,6 +167,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /category/{id}/property/{propertyId}: @@ -185,6 +197,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/SuccessResponse' */ + // #region DELETE /category/:id/property/:propertyId + app.delete(`${basePath}/category/:id/property/:propertyId`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -205,4 +219,6 @@ export function register(app, basePath = '') { res.status(categoryPropertyErrorStatus(error)).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/company.js b/src/api/routes/company.js index 8fed2ee..82e71ed 100644 --- a/src/api/routes/company.js +++ b/src/api/routes/company.js @@ -8,6 +8,8 @@ import { clearCachedUser } from '../middleware/auth.js'; import { initCompanyVfs } from '../../tools/vfs.js'; import { executeCompanyImport } from '../../services/companyImport.js'; +// #region Helpers + const uploadZip = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB @@ -85,6 +87,8 @@ function companyErrorStatus(error) { return 500; } +// #endregion + /** * Registers company CRUD routes (requires auth middleware). * @param {import('express').Application} app - Express app @@ -184,6 +188,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /company + app.get(`${basePath}/company`, async (req, res) => { try { const companies = await db.listCompanies(req.user.id); @@ -194,6 +200,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /company + app.post(`${basePath}/company`, async (req, res) => { const body = parseCompanyBody(req.body); if (body.error) { @@ -218,6 +228,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /company/history: @@ -333,6 +345,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /company/history + app.get(`${basePath}/company/history`, async (req, res) => { try { requireSelectedCompany(req); @@ -360,6 +374,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /company/history + app.post(`${basePath}/company/history`, async (req, res) => { try { requireSelectedCompany(req); @@ -382,6 +400,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /company/import: @@ -446,6 +466,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /company/import + app.post( `${basePath}/company/import`, uploadCompanyImportFile, @@ -578,6 +600,8 @@ export function register(app, basePath = '') { } ); + // #endregion + /** * @openapi * /company/{id}: @@ -679,6 +703,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PUT /company/:id + app.put(`${basePath}/company/:id`, async (req, res) => { const parsedId = parseCompanyId(req.params.id); if (parsedId.error) { @@ -700,6 +726,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /company/:id + app.delete(`${basePath}/company/:id`, async (req, res) => { const parsedId = parseCompanyId(req.params.id); if (parsedId.error) { @@ -714,4 +744,6 @@ export function register(app, basePath = '') { res.status(500).json({ error: 'Failed to delete company' }); } }); + + // #endregion } diff --git a/src/api/routes/companysetting.js b/src/api/routes/companysetting.js index c5a59e0..7107da4 100644 --- a/src/api/routes/companysetting.js +++ b/src/api/routes/companysetting.js @@ -1,5 +1,7 @@ import * as db from '../../db/companysetting.js'; +// #region Helpers + const ALLOWED_KEYS = [ 'ui.theme', 'keys.tavily', @@ -52,6 +54,8 @@ function shapeSetting(s) { return { key: s.key, value: s.value ?? null }; } +// #endregion + /** * Registers company-settings routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -109,6 +113,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /company/setting + app.get(`${basePath}/company/setting`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -140,6 +146,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /company/setting/{key}: @@ -193,6 +201,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /company/setting/:key + app.get(`${basePath}/company/setting/:key`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -225,6 +235,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /company/setting/{key}: @@ -296,6 +308,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PUT /company/setting/:key + app.put(`${basePath}/company/setting/:key`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -347,4 +361,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } \ No newline at end of file diff --git a/src/api/routes/customergroup.js b/src/api/routes/customergroup.js index af1fe6a..65c5e32 100644 --- a/src/api/routes/customergroup.js +++ b/src/api/routes/customergroup.js @@ -1,5 +1,7 @@ import * as db from '../../db/customergroup.js'; +// #region Helpers + /** Implicit groups created by the system — name locked. */ const LOCKED_NAMES = new Set(['POS', 'Lieferanten']); @@ -31,6 +33,8 @@ function withEditable(row) { }; } +// #endregion + /** * Registers customergroup routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -143,6 +147,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /customergroup + app.get(`${basePath}/customergroup`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -159,6 +165,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /customergroup + app.post(`${basePath}/customergroup`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -177,6 +187,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /customergroup/order: @@ -241,6 +253,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PUT /customergroup/order + app.put(`${basePath}/customergroup/order`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -270,6 +284,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /customergroup/{id}: @@ -383,6 +399,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /customergroup/:id + app.patch(`${basePath}/customergroup/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -406,6 +424,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /customergroup/:id + app.delete(`${basePath}/customergroup/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -426,4 +448,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/delivery.js b/src/api/routes/delivery.js index 4111ac1..6c4feb0 100644 --- a/src/api/routes/delivery.js +++ b/src/api/routes/delivery.js @@ -2,6 +2,8 @@ import multer from 'multer'; import * as db from '../../db/delivery.js'; import { writeErrorLog } from '../../util/errorLog.js'; +// #region Helpers + // Shared memory storage for delivery attachments (max 50 MB) const multerUpload = multer({ storage: multer.memoryStorage(), @@ -184,6 +186,8 @@ function deliveryErrorStatus(error) { return 500; } +// #endregion + /** * Registers delivery (goods receipt) routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -344,6 +348,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /delivery + app.get(`${basePath}/delivery`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -361,6 +367,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /delivery + app.post(`${basePath}/delivery`, uploadDeliveryFiles, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -412,6 +422,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /delivery/{id}: @@ -613,6 +625,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /delivery/:id + app.get(`${basePath}/delivery/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -635,6 +649,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region PATCH /delivery/:id + app.patch(`${basePath}/delivery/:id`, uploadDeliveryFiles, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -695,6 +713,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /delivery/:id + app.delete(`${basePath}/delivery/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -723,6 +745,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /delivery/{id}/file/{fileId}: @@ -867,6 +891,15 @@ export function register(app, basePath = '') { } }; + // #region GET /delivery/:id/file/:fileId + app.get(`${basePath}/delivery/:id/file/:fileId`, handleGetDeliveryFile); + + // #endregion + + // #region GET /delivery/:id/files/:fileId + app.get(`${basePath}/delivery/:id/files/:fileId`, handleGetDeliveryFile); + + // #endregion } diff --git a/src/api/routes/deliveryParse.js b/src/api/routes/deliveryParse.js index 5fb62e3..cc396ed 100644 --- a/src/api/routes/deliveryParse.js +++ b/src/api/routes/deliveryParse.js @@ -8,6 +8,8 @@ import * as dbSupplier from '../../db/supplier.js'; import env from '../../config/env.js'; import { resolveDefaultModel } from '../../tools/llm-key.js'; +// #region Helpers + // ─── Multer — memory storage (no temp files); accepts PDF or text ─────────────── const upload = multer({ storage: multer.memoryStorage(), @@ -432,6 +434,8 @@ ${pageContent}`; res.end(); } +// #endregion + // ─── Route ─────────────────────────────────────────────────────────────────────── /** * @param {import('express').Application} app @@ -551,6 +555,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /delivery/parse + app.post( `${basePath}/delivery/parse`, upload.single('file'), @@ -582,4 +588,6 @@ export function register(app, basePath = '') { }); }, ); + + // #endregion } diff --git a/src/api/routes/docs.js b/src/api/routes/docs.js index 3fdd464..6c110f1 100644 --- a/src/api/routes/docs.js +++ b/src/api/routes/docs.js @@ -4,6 +4,8 @@ import { getOpenApiSpec } from '../openapiSpec.js'; const swaggerDistPath = swaggerUiDist.getAbsoluteFSPath(); +// #region Helpers + function renderDocsHtml() { return ` @@ -56,6 +58,8 @@ function renderDocsHtml() { `; } +// #endregion + /** * Registers documentation and interactive API tester under /docs/. * @param {import('express').Application} app - Express app @@ -64,6 +68,8 @@ function renderDocsHtml() { export function register(app, basePath = '') { const docsRouter = express.Router(); + // #region USE /docs (redirect) + // Redirect /docs (or /api/docs) to /docs/ so relative assets resolve properly docsRouter.use((req, res, next) => { const rawPath = req.originalUrl.split('?')[0]; @@ -74,6 +80,10 @@ export function register(app, basePath = '') { next(); }); + // #endregion + + // #region GET /openapi.json + // Serve OpenAPI 3.0 specification JSON docsRouter.get('/openapi.json', (_req, res) => { try { @@ -86,26 +96,48 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region GET / + // Serve HTML UI for /docs/ and /docs/index.html docsRouter.get('/', (_req, res) => { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(renderDocsHtml()); }); + // #endregion + + // #region GET /index.html + docsRouter.get('/index.html', (_req, res) => { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(renderDocsHtml()); }); + // #endregion + + // #region USE /docs (static assets) + // Serve static assets from swagger-ui-dist (CSS, JS bundles, icons) docsRouter.use(express.static(swaggerDistPath, { index: false })); + // #endregion + + // #region USE /docs + // Mount on /docs app.use('/docs', docsRouter); + // #endregion + + // #region USE /docs + // Also mount on /api/docs and alias /api/openapi.json if basePath is set if (basePath) { app.use(basePath + '/docs', docsRouter); app.get(basePath + '/openapi.json', (_req, res) => res.redirect(301, '/docs/openapi.json')); } + + // #endregion } diff --git a/src/api/routes/entity.js b/src/api/routes/entity.js index 754e01e..8411213 100644 --- a/src/api/routes/entity.js +++ b/src/api/routes/entity.js @@ -1,5 +1,7 @@ import * as db from '../../db/entity.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -22,6 +24,8 @@ function entityErrorStatus(error) { return 500; } +// #endregion + /** * Registers entity routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -162,6 +166,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /entity + app.get(`${basePath}/entity`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -190,6 +196,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /entity + app.post(`${basePath}/entity`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -219,6 +229,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /entity/next-number: @@ -269,6 +281,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /entity/next-number + app.get(`${basePath}/entity/next-number`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -285,6 +299,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /entity/{id}: @@ -451,6 +467,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /entity/:id + app.get(`${basePath}/entity/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -472,6 +490,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region PATCH /entity/:id + app.patch(`${basePath}/entity/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -505,6 +527,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /entity/:id + app.delete(`${basePath}/entity/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -525,4 +551,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/entityaddress.js b/src/api/routes/entityaddress.js index 926a211..73948e9 100644 --- a/src/api/routes/entityaddress.js +++ b/src/api/routes/entityaddress.js @@ -1,5 +1,7 @@ import * as db from '../../db/entityaddress.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -29,6 +31,8 @@ const ADDRESS_FIELDS = [ 'county', ]; +// #endregion + /** * Registers entity address routes nested under `/entity/:entityId/address`. * @param {import('express').Application} app @@ -172,6 +176,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /entity/:entityId/address + app.get(`${basePath}/entity/:entityId/address`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -193,6 +199,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /entity/:entityId/address + app.post(`${basePath}/entity/:entityId/address`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -222,6 +232,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /entity/{entityId}/address/{id}: @@ -356,6 +368,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /entity/:entityId/address/:id + app.patch(`${basePath}/entity/:entityId/address/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -389,6 +403,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /entity/:entityId/address/:id + app.delete(`${basePath}/entity/:entityId/address/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -413,4 +431,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/entitycontact.js b/src/api/routes/entitycontact.js index 441de54..db5a94d 100644 --- a/src/api/routes/entitycontact.js +++ b/src/api/routes/entitycontact.js @@ -1,5 +1,7 @@ import * as db from '../../db/entitycontact.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -18,6 +20,8 @@ function contactErrorStatus(error) { return 500; } +// #endregion + /** * Registers entity contact routes nested under `/entity/:entityId/contact`. * @param {import('express').Application} app @@ -140,6 +144,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /entity/:entityId/contact + app.get(`${basePath}/entity/:entityId/contact`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -161,6 +167,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /entity/:entityId/contact + app.post(`${basePath}/entity/:entityId/contact`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -187,6 +197,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /entity/{entityId}/contact/{id}: @@ -301,6 +313,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /entity/:entityId/contact/:id + app.patch(`${basePath}/entity/:entityId/contact/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -335,6 +349,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /entity/:entityId/contact/:id + app.delete(`${basePath}/entity/:entityId/contact/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -359,4 +377,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/events.js b/src/api/routes/events.js index 6504afc..fd50057 100644 --- a/src/api/routes/events.js +++ b/src/api/routes/events.js @@ -130,7 +130,11 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /events + app.get(`${basePath}/events`, handleSse); + + // #endregion } export function handleSse(req, res) { diff --git a/src/api/routes/health.js b/src/api/routes/health.js index 09caf8a..784baf1 100644 --- a/src/api/routes/health.js +++ b/src/api/routes/health.js @@ -39,6 +39,8 @@ export function register(app, basePath = '') { * error: * type: string */ + // #region GET /health + app.get(`${basePath}/health`, async (_request, response) => { try { await ensureConnection(); @@ -47,4 +49,6 @@ export function register(app, basePath = '') { response.status(503).json({ ok: false, error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/labeltemplate.js b/src/api/routes/labeltemplate.js index 4a98fa4..321f0dc 100644 --- a/src/api/routes/labeltemplate.js +++ b/src/api/routes/labeltemplate.js @@ -3,6 +3,8 @@ import * as db from '../../db/labeltemplate.js'; import { renderThumb } from '../../services/labelThumb.js'; import { emit } from '../sse.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -34,6 +36,8 @@ function labeltemplateErrorStatus(error) { return 500; } +// #endregion + /** * Registers label template routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -75,6 +79,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /labeltemplate + app.get(`${basePath}/labeltemplate`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -94,6 +100,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /labeltemplate/{id}: @@ -140,6 +148,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /labeltemplate/:id + app.get(`${basePath}/labeltemplate/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -164,6 +174,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /labeltemplate: @@ -207,6 +219,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /labeltemplate + app.post(`${basePath}/labeltemplate`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -240,6 +254,8 @@ export function register(app, basePath = '') { } }); + // #endregion + async function handleUpdate(req, res) { const selected = requireSelectedCompany(req); if (selected.error) { @@ -389,9 +405,18 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /labeltemplate/:id + app.patch(`${basePath}/labeltemplate/:id`, handleUpdate); + + // #endregion + + // #region PUT /labeltemplate/:id + app.put(`${basePath}/labeltemplate/:id`, handleUpdate); + // #endregion + /** * @openapi * /labeltemplate/{id}: @@ -432,6 +457,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /labeltemplate/:id + app.delete(`${basePath}/labeltemplate/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -453,6 +480,8 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } /** diff --git a/src/api/routes/ledger.js b/src/api/routes/ledger.js index 34c2dfd..96e9e2c 100644 --- a/src/api/routes/ledger.js +++ b/src/api/routes/ledger.js @@ -1,5 +1,7 @@ import * as db from '../../db/ledger.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -20,6 +22,8 @@ function ledgerErrorStatus(error) { return 500; } +// #endregion + /** * Registers ledger routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -86,6 +90,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /ledger + app.post(`${basePath}/ledger`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -102,6 +108,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /ledger/manifestarticle/{manifestarticleId}: @@ -160,6 +168,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /ledger/manifestarticle/:manifestarticleId + app.get(`${basePath}/ledger/manifestarticle/:manifestarticleId`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -183,6 +193,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /ledger/sku/{sku}: @@ -241,6 +253,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /ledger/sku/:sku + app.get(`${basePath}/ledger/sku/:sku`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -260,6 +274,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /ledger: @@ -306,6 +322,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /ledger + app.get(`${basePath}/ledger`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -340,4 +358,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/modelconversation.js b/src/api/routes/modelconversation.js index c77002d..489a337 100644 --- a/src/api/routes/modelconversation.js +++ b/src/api/routes/modelconversation.js @@ -4,6 +4,8 @@ import { RETIRED_MODELS, resolveDefaultModel } from '../../tools/llm-key.js'; import { emit } from '../sse.js'; import { getOrCreateManager, removeManager } from '../../services/conversationManager.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -32,6 +34,8 @@ function modelConversationErrorStatus(error) { return 500; } +// #endregion + /** * Registers model conversation routes. Conversations are private to the * authenticated user within their selected company. @@ -79,6 +83,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /modelconversation + app.get(`${basePath}/modelconversation`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -95,6 +101,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /modelconversation: @@ -140,6 +148,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /modelconversation + app.post(`${basePath}/modelconversation`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -168,6 +178,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /modelconversation/{id}: @@ -219,6 +231,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /modelconversation/:id + app.get(`${basePath}/modelconversation/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -240,6 +254,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /modelconversation/{id}: @@ -285,6 +301,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /modelconversation/:id + app.delete(`${basePath}/modelconversation/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -308,6 +326,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /modelconversation/{id}/stream: @@ -384,6 +404,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /modelconversation/:id/stream + app.get(`${basePath}/modelconversation/:id/stream`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -410,6 +432,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /modelconversation/{id}/message: @@ -463,6 +487,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /modelconversation/:id/message + app.get(`${basePath}/modelconversation/:id/message`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -484,6 +510,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /modelconversation/{id}/message: @@ -550,6 +578,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /modelconversation/:id/message + app.post(`${basePath}/modelconversation/:id/message`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -596,4 +626,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/number.js b/src/api/routes/number.js index 1ac164d..f35bb42 100644 --- a/src/api/routes/number.js +++ b/src/api/routes/number.js @@ -1,5 +1,7 @@ import * as db from '../../db/numbers.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -17,6 +19,8 @@ function numberErrorStatus(error) { return 500; } +// #endregion + /** * Registers number-sequence routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -70,6 +74,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /number + app.get(`${basePath}/number`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -86,6 +92,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /number/{id}: @@ -161,6 +169,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /number/:id + app.patch(`${basePath}/number/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -193,4 +203,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/order.js b/src/api/routes/order.js index e4b5fea..ddc2eae 100644 --- a/src/api/routes/order.js +++ b/src/api/routes/order.js @@ -1,5 +1,7 @@ import * as db from '../../db/order.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -24,6 +26,8 @@ function orderErrorStatus(error) { return 500; } +// #endregion + /** * Registers sales-order routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -184,6 +188,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /order + app.get(`${basePath}/order`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -220,6 +226,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /order + app.post(`${basePath}/order`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -247,6 +257,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /order/{id}: @@ -411,6 +423,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /order/:id + app.get(`${basePath}/order/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -432,6 +446,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region PATCH /order/:id + app.patch(`${basePath}/order/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -474,6 +492,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /order/:id + app.delete(`${basePath}/order/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -494,4 +516,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/package.js b/src/api/routes/package.js index 885c09c..d54a990 100644 --- a/src/api/routes/package.js +++ b/src/api/routes/package.js @@ -1,5 +1,7 @@ import * as db from '../../db/package.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -24,6 +26,8 @@ function packageErrorStatus(error) { return 500; } +// #endregion + /** * Registers package routes (packages belong to a shipping and target an entity address). * @param {import('express').Application} app @@ -77,6 +81,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /shipping/:id/package + app.get(`${basePath}/shipping/:id/package`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -98,6 +104,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /shipping/{id}/package: @@ -167,6 +175,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /shipping/:id/package + app.post(`${basePath}/shipping/:id/package`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -191,6 +201,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /package/{id}: @@ -229,6 +241,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /package/:id + app.delete(`${basePath}/package/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -249,4 +263,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/payment.js b/src/api/routes/payment.js index 829de72..4c9d190 100644 --- a/src/api/routes/payment.js +++ b/src/api/routes/payment.js @@ -1,5 +1,7 @@ import * as db from '../../db/payment.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -25,6 +27,8 @@ function paymentErrorStatus(error) { return 500; } +// #endregion + /** * Registers payment routes (payments belong to an order / manifest). * @param {import('express').Application} app @@ -78,6 +82,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /order/:id/payment + app.get(`${basePath}/order/:id/payment`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -99,6 +105,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /order/{id}/payment: @@ -164,6 +172,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /order/:id/payment + app.post(`${basePath}/order/:id/payment`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -188,6 +198,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /payment/{id}: @@ -226,6 +238,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /payment/:id + app.delete(`${basePath}/payment/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -246,4 +260,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/pic.js b/src/api/routes/pic.js index 137387d..7cde5b5 100644 --- a/src/api/routes/pic.js +++ b/src/api/routes/pic.js @@ -1,5 +1,7 @@ import * as db from '../../db/pic.js'; +// #region Helpers + const FULL_HASH_RE = /^[a-zA-Z0-9]{8}$/; /** Content-addressed by SHA-256 (Base62, 8 chars) — safe for long-lived browser cache (auth still required on first fetch). */ const CACHE_CONTROL = 'private, max-age=31536000, immutable'; @@ -34,6 +36,8 @@ function sendImmutableAvif(req, res, { buffer, contentType, fullHash }) { return res.status(200).send(buffer); } +// #endregion + /** * Registers content-addressed image routes (requires auth). * @param {import('express').Application} app @@ -103,6 +107,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /image/:fullHash + const imagePaths = [`${basePath}/image/:fullHash`]; if (basePath) imagePaths.push('/image/:fullHash'); app.get(imagePaths, async (req, res) => { @@ -125,6 +131,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /thumb/{fullHash}: @@ -188,6 +196,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /thumb/:fullHash + const thumbPaths = [`${basePath}/thumb/:fullHash`]; if (basePath) thumbPaths.push('/thumb/:fullHash'); app.get(thumbPaths, async (req, res) => { @@ -210,6 +220,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /smallthumb/{fullHash}: @@ -273,6 +285,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /smallthumb/:fullHash + const smallThumbPaths = [`${basePath}/smallthumb/:fullHash`]; if (basePath) smallThumbPaths.push('/smallthumb/:fullHash'); app.get(smallThumbPaths, async (req, res) => { @@ -294,4 +308,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/posterminal.js b/src/api/routes/posterminal.js index 75f9fb3..a1bac8c 100644 --- a/src/api/routes/posterminal.js +++ b/src/api/routes/posterminal.js @@ -1,6 +1,8 @@ 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) { @@ -27,6 +29,8 @@ function posterminalErrorStatus(error) { return 500; } +// #endregion + /** * Registers posterminal routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -80,6 +84,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /posterminal + app.get(`${basePath}/posterminal`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -96,6 +102,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /posterminal: @@ -161,6 +169,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PUT /posterminal + app.put(`${basePath}/posterminal`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -196,6 +206,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /posterminal/{id}: @@ -269,6 +281,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /posterminal/:id + app.patch(`${basePath}/posterminal/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -312,6 +326,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /posterminal/{id}: @@ -356,6 +372,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /posterminal/:id + app.delete(`${basePath}/posterminal/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -377,6 +395,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /posterminal/{id}/category/{categoryId}: @@ -493,6 +513,8 @@ export function register(app, basePath = '') { * 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) { @@ -519,6 +541,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /posterminal/:id/category/:categoryId + app.delete(`${basePath}/posterminal/:id/category/:categoryId`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -545,6 +571,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /posterminal/{id}/all: @@ -653,6 +681,8 @@ export function register(app, basePath = '') { * 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) { @@ -674,6 +704,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /posterminal/:id/all + app.delete(`${basePath}/posterminal/:id/all`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -694,4 +728,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/printer.js b/src/api/routes/printer.js index 129da4d..ce5d8e3 100644 --- a/src/api/routes/printer.js +++ b/src/api/routes/printer.js @@ -1,6 +1,8 @@ import * as db from '../../db/printer.js'; import { emit } from '../sse.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -29,6 +31,8 @@ function printerErrorStatus(error) { return 500; } +// #endregion + /** * Registers printer routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -76,6 +80,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /printer + app.get(`${basePath}/printer`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -92,6 +98,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /printer/order: @@ -148,6 +156,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PUT /printer/order + app.put(`${basePath}/printer/order`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -167,6 +177,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /printer/{id}: @@ -219,6 +231,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /printer/:id + app.get(`${basePath}/printer/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -243,6 +257,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /printer: @@ -291,6 +307,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /printer + app.post(`${basePath}/printer`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -317,6 +335,8 @@ export function register(app, basePath = '') { } }); + // #endregion + async function handleUpdate(req, res) { const selected = requireSelectedCompany(req); if (selected.error) { @@ -461,9 +481,18 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /printer/:id + app.patch(`${basePath}/printer/:id`, handleUpdate); + + // #endregion + + // #region PUT /printer/:id + app.put(`${basePath}/printer/:id`, handleUpdate); + // #endregion + /** * @openapi * /printer/{id}: @@ -510,6 +539,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /printer/:id + app.delete(`${basePath}/printer/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -531,4 +562,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/propertyname.js b/src/api/routes/propertyname.js index 3c33c16..b064c00 100644 --- a/src/api/routes/propertyname.js +++ b/src/api/routes/propertyname.js @@ -1,6 +1,8 @@ import * as db from '../../db/propertyname.js'; import { emit } from '../sse.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -22,6 +24,8 @@ function propertyErrorStatus(error) { return 500; } +// #endregion + /** * Registers propertyname routes. * @param {import('express').Application} app @@ -93,6 +97,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /propertyname + app.get(`${basePath}/propertyname`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -107,6 +113,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /propertyname + app.post(`${basePath}/propertyname`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -122,6 +132,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /propertyname/order: @@ -154,6 +166,8 @@ export function register(app, basePath = '') { * 400: * description: Bad request */ + // #region PUT /propertyname/order + app.put(`${basePath}/propertyname/order`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -169,6 +183,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /propertyname/{id}: @@ -265,6 +281,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /propertyname/:id + app.patch(`${basePath}/propertyname/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -283,6 +301,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /propertyname/:id + app.delete(`${basePath}/propertyname/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -303,4 +325,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: message }); } }); + + // #endregion } diff --git a/src/api/routes/publicCatalog.js b/src/api/routes/publicCatalog.js index b3c516d..94d0fa8 100644 --- a/src/api/routes/publicCatalog.js +++ b/src/api/routes/publicCatalog.js @@ -1,5 +1,7 @@ import * as db from '../../db/publicCatalog.js'; +// #region Helpers + function parseCompanyId(id) { const companyId = Number(id); if (!Number.isInteger(companyId) || companyId <= 0) { @@ -22,6 +24,8 @@ function publicErrorStatus(error) { return 500; } +// #endregion + /** * Registers public catalog routes (no session/cookie auth required). * @param {import('express').Application} app @@ -92,6 +96,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /public/:companyId/categories + app.get(`${basePath}/public/:companyId/categories`, async (req, res) => { const parsed = parseCompanyId(req.params.companyId); if (parsed.error) { @@ -109,6 +115,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /public/{companyId}/articles: @@ -212,6 +220,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /public/:companyId/articles + app.get(`${basePath}/public/:companyId/articles`, async (req, res) => { const parsed = parseCompanyId(req.params.companyId); if (parsed.error) { @@ -243,6 +253,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /public/{companyId}/articles/{sku}: @@ -340,6 +352,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /public/:companyId/articles/:sku + app.get(`${basePath}/public/:companyId/articles/:sku`, async (req, res) => { const parsedCompany = parseCompanyId(req.params.companyId); if (parsedCompany.error) { @@ -361,4 +375,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/quote.js b/src/api/routes/quote.js index 17560ae..e53a121 100644 --- a/src/api/routes/quote.js +++ b/src/api/routes/quote.js @@ -1,5 +1,7 @@ import * as db from '../../db/quote.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -22,6 +24,8 @@ function quoteErrorStatus(error) { return 500; } +// #endregion + /** * Registers quote routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -155,6 +159,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /quote + app.get(`${basePath}/quote`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -171,6 +177,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /quote + app.post(`${basePath}/quote`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -198,6 +208,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /quote/{id}: @@ -362,6 +374,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /quote/:id + app.get(`${basePath}/quote/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -383,6 +397,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region PATCH /quote/:id + app.patch(`${basePath}/quote/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -425,6 +443,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /quote/:id + app.delete(`${basePath}/quote/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -445,4 +467,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/serper.js b/src/api/routes/serper.js index 933fa80..a4de4b2 100644 --- a/src/api/routes/serper.js +++ b/src/api/routes/serper.js @@ -2,6 +2,8 @@ import sharp from 'sharp'; import * as dbCompanySetting from '../../db/companysetting.js'; import { crawlAndProcessImage } from '../../services/imageCrop.js'; +// #region Helpers + const CONCURRENCY = 6; const FETCH_TIMEOUT_MS = 6000; @@ -83,6 +85,8 @@ async function generateWebpThumbnail(img, { signal, preferSource = false } = {}) throw lastError || new Error('Failed to load thumbnail'); } +// #endregion + /** * Registers Serper image search routes. * @param {import('express').Application} app @@ -186,6 +190,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /serper/images + const handleImagesSearch = async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -341,6 +347,8 @@ export function register(app, basePath = '') { } }; + // #endregion + /** * @openapi * /serper/image: @@ -411,6 +419,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /serper/image + const handleImageCrop = async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -438,6 +448,17 @@ export function register(app, basePath = '') { } }; + // #endregion + + // #region POST /serper/images (registration) + app.post(`${basePath}/serper/images`, handleImagesSearch); + + // #endregion + + // #region POST /serper/image (registration) + app.post(`${basePath}/serper/image`, handleImageCrop); + + // #endregion } diff --git a/src/api/routes/shipping.js b/src/api/routes/shipping.js index 42b6120..3e59dae 100644 --- a/src/api/routes/shipping.js +++ b/src/api/routes/shipping.js @@ -1,5 +1,7 @@ import * as db from '../../db/shipping.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -24,6 +26,8 @@ function shippingErrorStatus(error) { return 500; } +// #endregion + /** * Registers shipping routes (shippings belong to an order / manifest). * @param {import('express').Application} app @@ -77,6 +81,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /order/:id/shipping + app.get(`${basePath}/order/:id/shipping`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -98,6 +104,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /order/{id}/shipping: @@ -163,6 +171,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /order/:id/shipping + app.post(`${basePath}/order/:id/shipping`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -187,6 +197,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /shipping/{id}: @@ -225,6 +237,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /shipping/:id + app.delete(`${basePath}/shipping/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -245,4 +259,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/stock.js b/src/api/routes/stock.js index 37ea118..6d76f37 100644 --- a/src/api/routes/stock.js +++ b/src/api/routes/stock.js @@ -1,6 +1,8 @@ import * as db from '../../db/stock.js'; import { emit } from '../sse.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -49,6 +51,8 @@ function emitStockEvents(companyId, body, result) { } } +// #endregion + /** * Registers stock adjustment routes for the authenticated user's selected company. * All operations append to the append-only ledger under an implicit "adjustment" manifest. @@ -111,6 +115,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /stock/adjust + app.post(`${basePath}/stock/adjust`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -127,4 +133,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/supplier.js b/src/api/routes/supplier.js index 069055a..b7d802a 100644 --- a/src/api/routes/supplier.js +++ b/src/api/routes/supplier.js @@ -1,5 +1,7 @@ import * as db from '../../db/supplier.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -48,6 +50,8 @@ function parseSkus(source) { return undefined; } +// #endregion + /** * Registers supplier routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -157,6 +161,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /supplier + app.get(`${basePath}/supplier`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -173,6 +179,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region POST /supplier + app.post(`${basePath}/supplier`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -191,6 +201,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /supplier/{id}: @@ -296,6 +308,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /supplier/:id + app.patch(`${basePath}/supplier/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -319,6 +333,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /supplier/:id + app.delete(`${basePath}/supplier/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -340,6 +358,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /supplier/{id}/articles: @@ -474,6 +494,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /supplier/:id/articles + async function handleGetSupplierArticles(req, res) { const selected = requireSelectedCompany(req); if (selected.error) { @@ -520,9 +542,30 @@ export function register(app, basePath = '') { } } + // #endregion + + // #region GET /supplier/:id/articles (registration) + app.get(`${basePath}/supplier/:id/articles`, handleGetSupplierArticles); + + // #endregion + + // #region GET /supplier/:id/article (registration) + app.get(`${basePath}/supplier/:id/article`, handleGetSupplierArticles); + + // #endregion + + // #region POST /supplier/:id/articles (registration) + app.post(`${basePath}/supplier/:id/articles`, handlePostSupplierArticles); + + // #endregion + + // #region POST /supplier/:id/article (registration) + app.post(`${basePath}/supplier/:id/article`, handlePostSupplierArticles); + + // #endregion } diff --git a/src/api/routes/telemetry.js b/src/api/routes/telemetry.js index 23b5287..09c477c 100644 --- a/src/api/routes/telemetry.js +++ b/src/api/routes/telemetry.js @@ -2,6 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +// #region Helpers + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const CRASH_DUMP_DIR = process.env.CRASH_DUMP_DIR || path.resolve(__dirname, '../../../logs/crash-dumps'); @@ -11,6 +13,8 @@ function telemetryErrorStatus(error) { return 500; } +// #endregion + /** * Registers public telemetry endpoints (no session/cookie auth required). * @param {import('express').Application} app @@ -73,6 +77,8 @@ export function register(app, basePath = '') { * error: * type: string */ + // #region POST /public/telemetry/crash + const routePaths = [`${basePath}/public/telemetry/crash`]; if (basePath && !routePaths.includes('/public/telemetry/crash')) { routePaths.push('/public/telemetry/crash'); @@ -106,4 +112,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message || 'Internal server error' }); } }); + + // #endregion } diff --git a/src/api/routes/unit.js b/src/api/routes/unit.js index 14b23e6..4a15c2f 100644 --- a/src/api/routes/unit.js +++ b/src/api/routes/unit.js @@ -1,5 +1,7 @@ import * as db from '../../db/unit.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -18,6 +20,8 @@ function unitErrorStatus(error) { return 500; } +// #endregion + /** * Registers unit routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -184,6 +188,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /unit + app.get(`${basePath}/unit`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -200,6 +206,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region PUT /unit + app.put(`${basePath}/unit`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -224,6 +234,10 @@ export function register(app, basePath = '') { } }); + // #endregion + + // #region DELETE /unit + app.delete(`${basePath}/unit`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -244,4 +258,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } diff --git a/src/api/routes/vfs.js b/src/api/routes/vfs.js index 0408902..0ec7018 100644 --- a/src/api/routes/vfs.js +++ b/src/api/routes/vfs.js @@ -5,6 +5,8 @@ import { emit } from '../sse.js'; import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js'; import sharp from 'sharp'; +// #region Helpers + // Thumbnail box. The image is fitted inside this box preserving aspect ratio; it is never // stretched, so a 2000x100 image becomes 250x13 and not 250x250. const THUMB_BOX = 250; @@ -373,6 +375,8 @@ const vfsUpload = multer({ limits: { fileSize: 100 * 1024 * 1024 }, }); +// #endregion + export function register(app, basePath = '') { /** @@ -420,6 +424,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /vfs + app.get(`${basePath}/vfs`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -468,6 +474,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /vfs/upload: @@ -547,6 +555,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /vfs/upload + app.post(`${basePath}/vfs/upload`, vfsUpload.array('files'), async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -596,6 +606,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /vfs/mkdir: @@ -653,6 +665,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /vfs/mkdir + app.post(`${basePath}/vfs/mkdir`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -684,6 +698,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /vfs/rename: @@ -751,6 +767,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /vfs/rename + app.post(`${basePath}/vfs/rename`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -800,6 +818,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /vfs/{*path}: @@ -895,6 +915,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PUT /vfs/*path + app.put(`${basePath}/vfs/*path`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -951,6 +973,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /vfs/{*path}: @@ -1004,6 +1028,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /vfs/*path + app.delete(`${basePath}/vfs/*path`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -1043,6 +1069,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /vfs/{*path}: @@ -1168,6 +1196,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /vfs/*path + app.get(`${basePath}/vfs/*path`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -1252,4 +1282,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion } \ No newline at end of file diff --git a/src/api/routes/warehouse.js b/src/api/routes/warehouse.js index 81f7ead..7214594 100644 --- a/src/api/routes/warehouse.js +++ b/src/api/routes/warehouse.js @@ -1,6 +1,8 @@ import * as db from '../../db/warehouse.js'; import { emit } from '../sse.js'; +// #region Helpers + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -30,6 +32,8 @@ function warehouseErrorStatus(error) { return 500; } +// #endregion + /** * Registers warehouse routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -77,6 +81,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /warehouse + app.get(`${basePath}/warehouse`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -93,6 +99,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /warehouse/order: @@ -149,6 +157,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PUT /warehouse/order + app.put(`${basePath}/warehouse/order`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -168,6 +178,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /warehouse/{id}: @@ -220,6 +232,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region GET /warehouse/:id + app.get(`${basePath}/warehouse/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -244,6 +258,8 @@ export function register(app, basePath = '') { } }); + // #endregion + /** * @openapi * /warehouse: @@ -298,6 +314,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region POST /warehouse + app.post(`${basePath}/warehouse`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -317,6 +335,8 @@ export function register(app, basePath = '') { } }); + // #endregion + async function handleUpdate(req, res) { const selected = requireSelectedCompany(req); if (selected.error) { @@ -465,9 +485,18 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region PATCH /warehouse/:id + app.patch(`${basePath}/warehouse/:id`, handleUpdate); + + // #endregion + + // #region PUT /warehouse/:id + app.put(`${basePath}/warehouse/:id`, handleUpdate); + // #endregion + /** * @openapi * /warehouse/{id}: @@ -520,6 +549,8 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ + // #region DELETE /warehouse/:id + app.delete(`${basePath}/warehouse/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -541,4 +572,6 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + // #endregion }