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 }