import express from 'express'; import JSZip from 'jszip'; import * as db from '../../db/article.js'; import * as picDb from '../../db/pic.js'; import { emit } from '../sse.js'; import { buildArtikelCsv, toWindows1252 } from '../../util/artikelCsv.js'; const AVIF_BODY_LIMIT = '20mb'; const FULL_HASH_RE = /^[a-fA-F0-9]{64}$/; /** 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 a 64-char hex SHA-256' }; } return { fullHash: fullHash.toLowerCase() }; } function articleErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; if (error.message === 'Parent not found') return 404; if (error.message === '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 === 'Company not found') return 404; if (error.message === 'Brand not found') return 404; if (error.message === 'Invalid weight') return 400; if (error.message === 'Invalid track_stock') return 400; if (error.message === 'Invalid categoryIds') return 400; if (error.message?.includes('already exists') || error.code === 'SQLITE_CONSTRAINT_UNIQUE') return 409; return 500; } /** * 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 * 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: [parentId, name] * properties: * parentId: * type: integer * description: Category id * name: * type: string * track_stock: * type: boolean * description: Whether to track inventory stock (defaults to true) * example: * parentId: 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 parent = parsePositiveInt(req.body?.parentId, 'parentId'); if (parent.error) { return res.status(400).json({ error: parent.error }); } 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 ?? req.body?.trackStock; 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; try { const article = await db.createUnderParent({ googleAuthId: req.user.id, parentId: parent.id, name, track_stock, }); emitArticlesSignal(req); 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 * 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 * 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 * responses: * 200: * description: Article history (newest first) * content: * application/json: * schema: * type: object * required: [history] * properties: * history: * type: array * items: * $ref: '#/components/schemas/ArticleHistoryEntry' * 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' }); } try { const history = await db.getHistoryBySku(req.user.id, decodeURIComponent(sku)); res.status(200).json({ history }); } 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; } else if (Object.prototype.hasOwnProperty.call(body, 'trackStock')) { patch.track_stock = body.trackStock; } if (!('name' in patch) && !('unitamount' in patch) && !('unitSi' in patch) && !('track_stock' in patch)) { return res.status(400).json({ error: 'name, unitamount, unitSi, or track_stock 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 }); } try { await db.remove(req.user.id, parsed.sku); 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 * description: | * Replace-all prices for the company POS customer group. * Each entry: `price` (net), `tax` (rate, ensured via tax.ensure), optional `minmultiplier` (default 1). * 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 * required: [price, tax] * properties: * price: * type: number * description: Net price * tax: * type: number * description: Tax rate (e.g. 19) * minmultiplier: * type: number * description: Defaults to 1; must be unique per request * example: * prices: * - price: 10.5 * tax: 19 * - price: 9.9 * tax: 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 or POS customer group required * 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`). * 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 * 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', '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 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-fA-F0-9]{64}$' * 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-fA-F0-9]{64}$' * example: * fullHashes: * - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" * - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" * 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' */ 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 * 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 * 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; 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 ?? req.body?.trackStock; 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. Empty array removes the * article from all categories. 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 * 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 * 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 * /articleIndex: * get: * tags: [Article] * summary: List article search index * description: | * Returns every article reachable via the company's category tree. * Designed for client-side search / autocomplete preload. * Auth via company access. * security: * - cookieAuth: [] * responses: * 200: * description: Search index * content: * application/json: * schema: * type: object * properties: * articles: * type: array * items: * type: object * properties: * id: * type: integer * sku: * type: string * name: * type: string * nullable: true * eans: * type: array * items: * type: string * categoryIds: * type: array * items: * type: integer * 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}/articleIndex`, async (req, res) => { const companyId = req.user.selectedCompanyId; if (companyId == null) { return res.status(400).json({ error: 'No company selected' }); } try { const articles = await db.listSearchIndex(req.user.id); res.status(200).json({ articles }); } catch (error) { const status = articleErrorStatus(error); if (status === 500) console.error('List article search index failed:', error); res.status(status).json({ error: error.message }); } }); }