911 lines
32 KiB
JavaScript
911 lines
32 KiB
JavaScript
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 (omits child articles of a parent article), sorted alphabetically by name. 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
|
||
* 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 });
|
||
}
|
||
|
||
try {
|
||
const allArticles = await db.getCategoryArticles(req.user.id, parsed.id);
|
||
const articles = allArticles
|
||
.filter((a) => a.parentArticleId == null)
|
||
.map((a) => ({
|
||
...a,
|
||
childCount: Number(a.childCount ?? 0),
|
||
isParent: Boolean(a.isParent ?? a.is_parent),
|
||
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 });
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|