Files
dashback/src/api/routes/category.js
2026-11-01 05:37:15 +01:00

1044 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 `<fullHash>.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
}