import express from 'express'; import JSZip from 'jszip'; import * as db from '../../db/category.js'; 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. */ function emitCategorySignal(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) return; emit('category', { companyId }); } /** Notify SSE subscribers of the actor's selected company after an article change. */ function emitArticlesSignal(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) return; emit('articles', { companyId }); } function parsePositiveInt(value, field) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { return { error: `${field} is required` }; } return { id }; } function categoryErrorStatus(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 === 'Company not found') return 404; if (error.message === 'Parent not found') return 404; if (error.message === 'Cannot delete root category') return 400; if (error.message === 'Name required') return 400; if (error.message === 'Category not found') return 404; if (error.message === 'Image not found') return 404; if (error.message === 'Invalid category order') return 400; if (error.message === 'Invalid parent') return 400; if ( error.message === 'Category has articles' || error.message === 'Category has subcategories' || error.message === 'Category is assigned to POS terminals' || error.message?.includes('FOREIGN KEY constraint failed') || error.message?.includes('foreign key constraint') || error.message?.includes('REFERENCE constraint') ) return 409; return 500; } // #endregion /** * Registers category 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 * /category/tree: * get: * tags: [Category] * summary: Category tree * description: Returns the category tree for the authenticated user's selected company. * security: * - cookieAuth: [] * x-markdown: category/tree.md * responses: * 200: * description: Tree and article count for the selected company * content: * application/json: * schema: * type: object * properties: * languageId: * type: integer * tree: * type: array * items: * $ref: '#/components/schemas/CategoryTreeNode' * articleCount: * type: integer * description: Total number of parent articles across the category tree * 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 /category/tree app.get(`${basePath}/category/tree`, async (req, res) => { const companyId = req.user.selectedCompanyId; if (companyId == null) { return res.status(400).json({ error: 'No company selected' }); } try { const result = await db.getTreeForCompany(req.user.id); res.status(200).json(result); } catch (error) { const status = categoryErrorStatus(error); if (status === 500) console.error('Get category tree failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /category/export.zip: * get: * tags: [Category] * summary: Export categories as ZIP * description: | * Builds a `kategorien.zip` for the authenticated user's selected company. * The ZIP contains `kategorien.csv` (semicolon-separated, Windows-1252, * depth-first in sort order with an `Ebene N` name column per tree depth) * plus one AVIF entry per category image, named `.avif` * (content-addressed, so shared hashes are deduplicated). * security: * - cookieAuth: [] * 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 /category/export.zip app.get(`${basePath}/category/export.zip`, async (req, res) => { const companyId = req.user.selectedCompanyId; if (companyId == null) { return res.status(400).json({ error: 'No company selected' }); } try { const { tree } = await db.getTreeForCompany(req.user.id); const csv = buildKategorienCsv(tree, tree.find((n) => n.parentId == null)?.id ?? null); const zip = new JSZip(); zip.file('kategorien.csv', toWindows1252(csv)); const seen = new Set(); for (const node of tree) { const fullHash = node.fullHash; 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="kategorien.zip"'); res.status(200).send(content); } catch (error) { const status = categoryErrorStatus(error); if (status === 500) console.error('Export categories failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /category: * post: * tags: [Category] * summary: Create category * description: Creates a category under an authorized parent. * security: * - cookieAuth: [] * x-signals: [category] * x-markdown: category/create.md * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [parentId, name] * properties: * parentId: * type: integer * name: * type: string * example: * parentId: 1 * name: New category * responses: * 201: * description: Created * content: * application/json: * schema: * type: object * properties: * category: * $ref: '#/components/schemas/Category' * 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: Parent not found * content: * application/json: * 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) { return res.status(400).json({ error: 'parentId is required' }); } if (typeof rawParent !== 'number' || !Number.isInteger(rawParent) || rawParent <= 0) { return res.status(400).json({ error: 'parentId 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' }); } try { const category = await db.createUnderParent({ googleAuthId: req.user.id, parentId, name, }); emitCategorySignal(req); res.status(201).json({ category }); } catch (error) { const status = categoryErrorStatus(error); if (status === 500) console.error('Create category failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /category/{id}/articles: * get: * tags: [Category] * summary: Articles in category * description: Returns articles in a category the user is authorized for, sorted alphabetically by name. By default includes child articles (variants); pass `includeChildren=false` to omit them. Each article includes `childCount` (the number of its child articles) and small thumbnail fields (`smallThumbW`, `smallThumbH`, `smallThumbBinary`). * security: * - cookieAuth: [] * x-markdown: category/articles.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Category id * - in: query * name: includeChildren * required: false * schema: * type: boolean * default: true * description: When true (default), includes child articles (variants) alongside parent articles. Set to false to omit child articles. * responses: * 200: * description: Articles list * content: * application/json: * schema: * type: object * properties: * articles: * type: array * items: * $ref: '#/components/schemas/CategoryArticle' * 400: * description: Invalid id * 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' */ // #region GET /category/:id/articles app.get(`${basePath}/category/:id/articles`, async (req, res) => { const parsed = parsePositiveInt(req.params.id, 'id'); if (parsed.error) { return res.status(400).json({ error: parsed.error }); } let includeChildren = true; if (req.query.includeChildren !== undefined) { includeChildren = req.query.includeChildren !== 'false' && req.query.includeChildren !== '0'; } try { const allArticles = await db.getCategoryArticles(req.user.id, parsed.id); const articles = allArticles .filter((a) => includeChildren || a.parentArticleId == null) .map((a) => ({ ...a, childCount: Number(a.childCount ?? 0), isParent: Boolean(a.isParent), smallThumbW: a.smallThumbW ?? null, smallThumbH: a.smallThumbH ?? null, smallThumbBinary: a.smallThumbBinary ?? null, })) .sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '') || a.id - b.id); res.status(200).json({ articles }); } catch (error) { const status = categoryErrorStatus(error); if (status === 500) console.error('Get category articles failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /category/{id}: * patch: * tags: [Category] * summary: Update category (rename or move) * description: | * Updates a category the user is authorized for. * `name` renames the category (re-slugifies in the company default language). * `parentId` moves it under a new parent (cycle/self → `Invalid parent`). * Either `name` or `parentId` (or both) is required. * security: * - cookieAuth: [] * x-signals: [category] * x-markdown: category/update.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Category id * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * parentId: * type: integer * description: New parent category id * example: * parentId: 3 * responses: * 200: * description: Updated category (renamed and/or moved) * content: * application/json: * schema: * type: object * properties: * category: * type: object * required: [id, name, slug, parentId, sort] * properties: * id: * type: integer * name: * type: string * slug: * type: string * parentId: * type: integer * sort: * type: integer * 400: * description: Validation error (name or parentId required, or invalid parent) * 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: Category not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * delete: * tags: [Category] * summary: Delete category * description: Deletes a category the user is authorized for (not the company root). When recursive=true, recursively deletes all child categories, unassigns POS terminals, and deletes articles that only exist in the deleted subtree (including their children). * security: * - cookieAuth: [] * x-signals: [category, articles] * x-markdown: category/delete.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Category id * - in: query * name: recursive * required: false * schema: * type: boolean * description: When true, recursively deletes the category subtree and articles that only exist within it. * responses: * 204: * description: Deleted * 400: * description: Invalid id or cannot delete root * 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: Category not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Category has articles or subcategories * content: * application/json: * 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) { return res.status(400).json({ error: parsed.error }); } const recursive = req.query.recursive === 'true'; try { await db.deleteCategory({ googleAuthId: req.user.id, id: parsed.id, recursive, }); emitCategorySignal(req); if (recursive) { emitArticlesSignal(req); } res.sendStatus(204); } catch (error) { const status = categoryErrorStatus(error); if (status === 500) console.error('Delete category failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region PATCH /category/:id app.patch(`${basePath}/category/:id`, async (req, res) => { const parsed = parsePositiveInt(req.params.id, 'id'); if (parsed.error) { return res.status(400).json({ error: parsed.error }); } const body = req.body ?? {}; const hasName = Object.prototype.hasOwnProperty.call(body, 'name'); const hasParentId = Object.prototype.hasOwnProperty.call(body, 'parentId'); if (!hasName && !hasParentId) { return res.status(400).json({ error: 'name or parentId is required' }); } try { let category; if (hasParentId) { const parentId = Number(body.parentId); if (!Number.isInteger(parentId) || parentId <= 0) { return res.status(400).json({ error: 'parentId is required' }); } category = await db.move({ googleAuthId: req.user.id, id: parsed.id, parentId }); } if (hasName) { const name = typeof body.name === 'string' ? body.name.trim() : ''; if (!name) { return res.status(400).json({ error: 'name is required' }); } if (category) { // Merge: preserve the parentId/sort from the move, apply the new name/slug. const renamed = await db.rename({ googleAuthId: req.user.id, id: parsed.id, name, }); category = { ...category, ...renamed }; } else { category = await db.rename({ googleAuthId: req.user.id, id: parsed.id, name }); } } if (hasParentId) { emitArticlesSignal(req); } emitCategorySignal(req); res.status(200).json({ category }); } catch (error) { const status = categoryErrorStatus(error); if (status === 500) console.error('Update category failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /category/order: * put: * tags: [Category] * summary: Reorder sibling categories * description: | * Sets the sibling sort order of `categoryIds` under `parentId`. * `categoryIds` must be exactly the current child set of `parentId` * (same count, no foreign/duplicate ids). Each child's `sort` is set * to its index `0..n-1` in one transaction. * security: * - cookieAuth: [] * x-signals: [category] * x-markdown: category/order.md * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [parentId, categoryIds] * properties: * parentId: * type: integer * description: Parent category under which sibling order applies * categoryIds: * type: array * items: * type: integer * example: * parentId: 1 * categoryIds: [2, 4, 3, 5] * responses: * 200: * description: New sibling order * content: * application/json: * schema: * type: object * properties: * categoryIds: * type: array * items: * type: integer * 400: * description: Invalid category 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: Parent category not found * content: * application/json: * 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) { return res.status(400).json({ error: 'parentId is required' }); } const rawIds = req.body?.categoryIds; if (!Array.isArray(rawIds)) { return res.status(400).json({ error: 'categoryIds is required' }); } const categoryIds = []; for (const value of rawIds) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'categoryIds must be positive integers' }); } categoryIds.push(id); } try { const result = await db.order({ googleAuthId: req.user.id, parentId, categoryIds }); emitCategorySignal(req); res.status(200).json(result); } catch (error) { const status = categoryErrorStatus(error); if (status === 500) console.error('Order categories failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /category/{id}/image: * post: * tags: [Category] * summary: Add category 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 `categorypic`. * Auth like GET /category/tree — user must be authorized for the category's company. * security: * - cookieAuth: [] * x-signals: [category] * x-markdown: category/add-image.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Category id * 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: * category: * type: object * properties: * id: * type: integer * thumbW: * type: integer * thumbH: * type: integer * thumbHash: * type: string * 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: Category not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * delete: * tags: [Category] * summary: Delete category image * description: | * Removes the category's image. Sets thumbW, thumbH, thumbHash back to null. * Auth like GET /category/tree — user must be authorized for the category's company. * security: * - cookieAuth: [] * x-signals: [category] * x-markdown: category/delete-image.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Category id * responses: * 200: * description: Image removed * content: * application/json: * schema: * type: object * properties: * category: * type: object * properties: * id: * type: integer * thumbW: * type: integer * nullable: true * thumbH: * type: integer * nullable: true * thumbHash: * type: string * nullable: true * 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: Category or image not found * content: * application/json: * 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 }), async (req, res) => { const parsed = parsePositiveInt(req.params.id, 'id'); if (parsed.error) { return res.status(400).json({ error: parsed.error }); } 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 category = await db.addImage(req.user.id, parsed.id, imageBuffer); emitCategorySignal(req); res.status(201).json({ category }); } catch (error) { const status = categoryErrorStatus(error); if (status === 500) console.error('Add category image failed:', error); res.status(status).json({ error: error.message }); } }, ); // #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) { return res.status(400).json({ error: parsed.error }); } try { const category = await db.deleteImage(req.user.id, parsed.id); emitCategorySignal(req); res.status(200).json({ category }); } catch (error) { const status = categoryErrorStatus(error); if (status === 500) console.error('Delete category image failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /category/{id}/image/generate: * post: * tags: [Category] * summary: Generate category mosaic image from contents * description: | * Generates a 2x2, 3x3, or 4x4 mosaic image from the category's and its subcategories' * articles (grouped by subcategory) and sets it as the new main picture for the category. * If size is not specified, defaults to 3 (3x3), falling back to 2 (2x2) if fewer than 7 images are available. * security: * - cookieAuth: [] * x-signals: [category] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Category id * requestBody: * required: false * content: * application/json: * schema: * type: object * properties: * size: * type: integer * enum: [2, 3, 4] * description: Mosaic grid size (2 for 2x2, 3 for 3x3, 4 for 4x4). Defaults to 3x3 (or 2x2 if < 7 images). * responses: * 200: * description: Mosaic image generated and linked as category main picture * content: * application/json: * schema: * type: object * properties: * category: * type: object * properties: * id: * type: integer * fullHash: * type: string * thumbW: * type: integer * thumbH: * type: integer * thumbHash: * type: string * size: * type: integer * grid: * type: string * articleCount: * type: integer * 400: * description: Invalid category ID or size * 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: Category not found or no article images found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /category/:id/image/generate app.post( `${basePath}/category/:id/image/generate`, express.json(), async (req, res) => { const parsed = parsePositiveInt(req.params.id, 'id'); if (parsed.error) { return res.status(400).json({ error: parsed.error }); } const rawSize = req.body?.size; let size = undefined; if (rawSize !== undefined && rawSize !== null && rawSize !== '') { size = Number(rawSize); if (![2, 3, 4].includes(size)) { return res.status(400).json({ error: 'Invalid size, must be 2, 3, or 4' }); } } try { const category = await db.generateImage(req.user.id, parsed.id, { size }); emitCategorySignal(req); res.status(200).json({ category }); } catch (error) { const status = categoryErrorStatus(error); if (status === 500) console.error('Generate category image failed:', error); res.status(status).json({ error: error.message }); } }, ); // #endregion }