Files
dashback/src/api/routes/brand.js
2026-10-11 03:30:30 +02:00

699 lines
24 KiB
JavaScript

import express from 'express';
import * as db from '../../db/brand.js';
import { emit } from '../sse.js';
// #region Helpers
const AVIF_BODY_LIMIT = 10 * 1024 * 1024;
function requireSelectedCompany(req) {
const companyId = req.user.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
function brandErrorStatus(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 === 'Brand not found') return 404;
if (error.message === 'Invalid brand') return 400;
if (error.message === 'Invalid offset') return 400;
if (error.message === 'Invalid limit') return 400;
if (error.message === 'Brand already exists') return 409;
if (error.message === 'Brand in use') return 409;
if (error.message === 'Image not found') return 404;
return 500;
}
/** Notify SSE subscribers after brand change; payload is `{ sender, brandId }` so receivers know which brand changed and the sender can ignore its own event. */
function emitBrandSignal(req, brandId = null) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) return;
const clientId = req.headers?.['x-client-id'];
emit('brand', { companyId, data: { sender: typeof clientId === 'string' ? clientId : '', brandId } });
}
// #endregion
/**
* Registers brand routes for the authenticated user's selected company.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /brand:
* get:
* tags: [Brand]
* summary: List brands
* description: Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count. Each brand includes `articleCount` (child articles inherit the brand from their father article; father articles are not counted). Small-thumbnail fields are omitted by default. Set `includeSmallThumbs=true` to include each picture's `smallThumbW`, `smallThumbH`, and `smallThumbBinary` (base64-encoded AVIF bytes, resized with a 32px maximum edge).
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: offset
* schema:
* type: integer
* default: 0
* minimum: 0
* description: Number of brands to skip
* - in: query
* name: limit
* schema:
* type: integer
* default: 10
* minimum: 1
* maximum: 100
* description: Number of brands to return
* - in: query
* name: includeSmallThumbs
* schema:
* type: boolean
* default: false
* description: Include each picture's small thumbnail dimensions and base64 AVIF bytes.
* x-markdown: brand/list.md
* responses:
* 200:
* description: Brand list
* content:
* application/json:
* schema:
* type: object
* required: [brands, total, offset, limit]
* properties:
* brands:
* type: array
* items:
* $ref: '#/components/schemas/BrandList'
* total:
* type: integer
* description: Total number of unpaged brands
* offset:
* type: integer
* description: Number of brands skipped
* limit:
* type: integer
* description: Number of brands returned
* 400:
* description: Invalid offset, invalid limit, invalid includeSmallThumbs, or 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 /brand
app.get(`${basePath}/brand`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const rawOffset = req.query.offset;
const rawLimit = req.query.limit;
const rawIncludeSmallThumbs = req.query.includeSmallThumbs;
if (
rawIncludeSmallThumbs !== undefined
&& rawIncludeSmallThumbs !== 'true'
&& rawIncludeSmallThumbs !== 'false'
) {
return res.status(400).json({ error: 'includeSmallThumbs must be true or false' });
}
let offset = 0;
let limit = 10;
if (rawOffset !== undefined) {
offset = parseInt(rawOffset, 10);
if (!Number.isInteger(offset) || offset < 0) {
return res.status(400).json({ error: 'offset must be an integer >= 0' });
}
}
if (rawLimit !== undefined) {
limit = parseInt(rawLimit, 10);
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
return res.status(400).json({ error: 'limit must be an integer between 1 and 100' });
}
}
try {
const result = await db.list(req.user.id, {
offset,
limit,
includeSmallThumbs: rawIncludeSmallThumbs === 'true',
});
res.status(200).json(result);
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('List brands failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /brand:
* post:
* tags: [Brand]
* summary: Create brand
* description: |
* Creates a brand for the selected company. Fails with `Brand already exists`
* if the name is taken. Emits SSE signal `brand` with payload
* `{ sender, brandId }` (`sender` echoes the caller's `x-client-id`).
* security:
* - cookieAuth: []
* x-signals: [brand]
* x-markdown: brand/create.md
* parameters:
* - in: header
* name: x-client-id
* required: false
* schema:
* type: string
* description: Sender id echoed as SSE payload so the sender can ignore its own event.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name:
* type: string
* gsprpinfo:
* type: string
* example:
* name: Acme
* gsprpinfo: 'Acme GmbH'
* responses:
* 201:
* description: Created brand
* content:
* application/json:
* schema:
* type: object
* properties:
* brand:
* $ref: '#/components/schemas/Brand'
* 400:
* description: Invalid brand or 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'
* 409:
* description: Brand already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /brand
app.post(`${basePath}/brand`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const { name, gsprpinfo } = req.body ?? {};
try {
const brand = await db.create(req.user.id, {
name,
gsprpinfo,
});
emitBrandSignal(req, brand.id);
res.status(201).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Create brand failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /brand/{id}:
* patch:
* tags: [Brand]
* summary: Rename brand
* description: |
* Renames/updates a brand by id for the selected company. Fails with
* `Brand already exists` if the name is taken by another brand.
* When the name changes, linked articles (`brand_id`) get their
* `updated_at` bumped; the response reports `touchedArticleCount`.
* Emits SSE signal `brand` with payload `{ sender, brandId }`
* (`sender` echoes the caller's `x-client-id`).
* security:
* - cookieAuth: []
* x-signals: [brand]
* x-markdown: brand/update.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: header
* name: x-client-id
* required: false
* schema:
* type: string
* description: Sender id echoed as SSE payload so the sender can ignore its own event.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* gsprpinfo:
* type: string
* example:
* name: Acme GmbH
* responses:
* 200:
* description: Updated brand
* content:
* application/json:
* schema:
* type: object
* properties:
* brand:
* type: object
* required: [id, name, touchedArticleCount]
* properties:
* id:
* type: integer
* name:
* type: string
* gsprpinfo:
* type: string
* nullable: true
* touchedArticleCount:
* type: integer
* description: Number of linked articles whose updated_at was bumped.
* 400:
* description: Invalid brand, invalid id, or 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: Brand or company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Brand already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region PATCH /brand/:id
app.patch(`${basePath}/brand/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
const { name, gsprpinfo } = req.body ?? {};
try {
const brand = await db.update(req.user.id, id, {
name,
gsprpinfo,
});
emitBrandSignal(req, id);
res.status(200).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Update brand failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /brand/{id}:
* delete:
* tags: [Brand]
* summary: Delete brand
* description: |
* Deletes the brand for the selected company. Fails with `Brand in use`
* if any article still references it. Emits SSE signal `brand`
* with payload `{ sender, brandId }`.
* security:
* - cookieAuth: []
* x-signals: [brand]
* x-markdown: brand/delete.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Deleted
* 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: Brand not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Brand in use
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region DELETE /brand/:id
app.delete(`${basePath}/brand/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
try {
await db.remove(req.user.id, id);
emitBrandSignal(req, id);
res.sendStatus(204);
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Delete brand failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /brand/{id}/image:
* post:
* tags: [Brand]
* summary: Add or replace brand image
* description: |
* Uploads an image for the brand (AVIF, WebP, or octet-stream). Converted to AVIF full and thumbnail.
* Replaces any existing image for this brand. Emits SSE signal `brand`
* with payload `{ sender, brandId }`.
* security:
* - cookieAuth: []
* x-signals: [brand]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Brand id
* requestBody:
* required: true
* content:
* image/avif:
* schema:
* type: string
* format: binary
* image/webp:
* schema:
* type: string
* format: binary
* application/octet-stream:
* schema:
* type: string
* format: binary
* responses:
* 201:
* description: Image stored and linked
* content:
* application/json:
* schema:
* type: object
* properties:
* brand:
* type: object
* properties:
* id:
* type: integer
* fullHash:
* type: string
* thumbW:
* type: integer
* thumbH:
* type: integer
* thumbHash:
* type: string
* pics:
* type: array
* items:
* $ref: '#/components/schemas/BrandPic'
* 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: Brand not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* delete:
* tags: [Brand]
* summary: Delete brand image
* description: Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null. Emits SSE signal `brand` with payload `{ sender, brandId }`.
* security:
* - cookieAuth: []
* x-signals: [brand]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Brand id
* responses:
* 200:
* description: Image removed
* content:
* application/json:
* schema:
* type: object
* properties:
* brand:
* type: object
* properties:
* id:
* type: integer
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* pics:
* type: array
* items:
* $ref: '#/components/schemas/BrandPic'
* 400:
* description: No company selected or 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'
* 404:
* description: Brand or image not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /brand/:id/image
app.post(
`${basePath}/brand/:id/image`,
express.raw({ type: ['image/avif', 'image/webp', 'application/octet-stream'], limit: AVIF_BODY_LIMIT }),
async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
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 brand = await db.addImage(req.user.id, id, imageBuffer);
emitBrandSignal(req, id);
res.status(201).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Add brand image failed:', error);
res.status(status).json({ error: error.message });
}
}
);
// #endregion
// #region DELETE /brand/:id/image
app.delete(`${basePath}/brand/:id/image`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
try {
const brand = await db.deleteImage(req.user.id, id);
emitBrandSignal(req, id);
res.status(200).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Delete brand image failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}