import express from 'express'; import * as db from '../../db/attributevalue.js'; import { emit } from '../sse.js'; 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 attributeValueErrorStatus(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' || error.message === 'Attribute name not found' || error.message === 'Attribute value not found') return 404; if (error.message === 'Image not found') return 404; if (error.message === 'Value required') return 400; if (error.message === 'Invalid attribute value order') return 400; if (error.message === 'Invalid id' || error.message === 'Invalid attributeId') return 400; if (error.message === 'Attribute value already exists') return 409; if (error.message === 'Attribute value in use' || error.message?.includes('FOREIGN KEY constraint')) return 409; return 500; } /** * Registers attributevalue routes under `/attributename/:attributeId/value` and `/attributevalue/:id`. * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /attributename/{attributeId}/value: * get: * tags: [Article] * summary: List values for an attribute * description: Lists all values for a specific attribute. Each value includes `articleCount` (the number of articles having this attribute value). * security: * - cookieAuth: [] * parameters: * - in: path * name: attributeId * required: true * schema: * type: integer * responses: * 200: * description: List of attribute values * content: * application/json: * schema: * type: object * properties: * values: * type: array * items: * $ref: '#/components/schemas/AttributeValue' * post: * tags: [Article] * summary: Create value for an attribute * description: Adds a new predefined value to an attribute. * security: * - cookieAuth: [] * parameters: * - in: path * name: attributeId * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [value] * properties: * value: * type: string * sort: * type: integer * responses: * 201: * description: Created attribute value * content: * application/json: * schema: * type: object * properties: * attributevalue: * $ref: '#/components/schemas/AttributeValue' */ app.get(`${basePath}/attributename/:attributeId/value`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const attributeId = Number(req.params.attributeId); const values = await db.listByAttributeId(req.user.id, attributeId); res.json({ values }); } catch (error) { res.status(attributeValueErrorStatus(error)).json({ error: error.message }); } }); app.post(`${basePath}/attributename/:attributeId/value`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const attributeId = Number(req.params.attributeId); const attributevalue = await db.create(req.user.id, attributeId, req.body ?? {}); emit('attributename', { companyId: selected.companyId }); res.status(201).json({ attributevalue }); } catch (error) { res.status(attributeValueErrorStatus(error)).json({ error: error.message }); } }); /** * @openapi * /attributename/{attributeId}/value/order: * put: * tags: [Article] * summary: Reorder values of an attribute * description: Updates the display sort order of values belonging to an attribute. * security: * - cookieAuth: [] * parameters: * - in: path * name: attributeId * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [attributevalueIds] * properties: * attributevalueIds: * type: array * items: * type: integer * responses: * 200: * description: Reordered successfully * content: * application/json: * schema: * $ref: '#/components/schemas/SuccessResponse' */ app.put(`${basePath}/attributename/:attributeId/value/order`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const attributeId = Number(req.params.attributeId); await db.order(req.user.id, attributeId, req.body ?? {}); emit('attributename', { companyId: selected.companyId }); res.json({ ok: true }); } catch (error) { res.status(attributeValueErrorStatus(error)).json({ error: error.message }); } }); /** * @openapi * /attributevalue/{id}: * patch: * tags: [Article] * summary: Update attribute value * description: Updates value text and/or sort for an attribute value. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * value: * type: string * sort: * type: integer * responses: * 200: * description: Updated attribute value * content: * application/json: * schema: * type: object * properties: * attributevalue: * $ref: '#/components/schemas/AttributeValue' * delete: * tags: [Article] * summary: Delete attribute value * description: Deletes an attribute value and cascades to article assignments. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Deleted successfully * content: * application/json: * schema: * $ref: '#/components/schemas/SuccessResponse' * 400: * description: Bad request 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: Forbidden * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Attribute value not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Attribute value in use * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ app.patch(`${basePath}/attributevalue/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const id = Number(req.params.id); const attributevalue = await db.update(req.user.id, id, req.body ?? {}); emit('attributename', { companyId: selected.companyId }); res.json({ attributevalue }); } catch (error) { const status = attributeValueErrorStatus(error); if (status === 500) console.error('Update attribute value failed:', error); res.status(status).json({ error: error.message }); } }); app.delete(`${basePath}/attributevalue/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const id = Number(req.params.id); await db.remove(req.user.id, id); emit('attributename', { companyId: selected.companyId }); res.json({ ok: true }); } catch (error) { const status = attributeValueErrorStatus(error); const message = (error.message === 'Attribute value in use' || error.message?.includes('FOREIGN KEY constraint')) ? 'Attribute value in use' : error.message; if (status === 500) console.error('Delete attribute value failed:', error); res.status(status).json({ error: message }); } }); /** * @openapi * /attributevalue/{id}/image: * post: * tags: [Article] * summary: Add or replace attribute value image * description: | * Uploads an image for the attribute value (AVIF, WebP, or octet-stream). Converted to AVIF full and thumbnail. * Replaces any existing image for this attribute value. Emits SSE signal `attributename`. * security: * - cookieAuth: [] * x-signals: [attributename] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Attribute value 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: * attributevalue: * 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/AttributeValuePic' * 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: Attribute value not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * delete: * tags: [Article] * summary: Delete attribute value image * description: Removes the attribute value's image. Sets thumbW, thumbH, thumbHash back to null. Emits SSE signal `attributename`. * security: * - cookieAuth: [] * x-signals: [attributename] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Attribute value id * responses: * 200: * description: Image removed * content: * application/json: * schema: * type: object * properties: * attributevalue: * 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/AttributeValuePic' * 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: Attribute value or image not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /attributevalue/:id/image app.post( `${basePath}/attributevalue/: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 attributevalue = await db.addImage(req.user.id, id, imageBuffer); emit('attributename', { companyId: selected.companyId }); res.status(201).json({ attributevalue }); } catch (error) { const status = attributeValueErrorStatus(error); if (status === 500) console.error('Add attribute value image failed:', error); res.status(status).json({ error: error.message }); } } ); // #endregion // #region DELETE /attributevalue/:id/image app.delete(`${basePath}/attributevalue/: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 attributevalue = await db.deleteImage(req.user.id, id); emit('attributename', { companyId: selected.companyId }); res.status(200).json({ attributevalue }); } catch (error) { const status = attributeValueErrorStatus(error); if (status === 500) console.error('Delete attribute value image failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion }