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 false) * 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) : false; 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 }); } }); }