import { validateObjects } from 'react-canvas-label-editor'; import * as db from '../../db/labeltemplate.js'; import { renderThumb } from '../../services/labelThumb.js'; import { emit } from '../sse.js'; // #region Helpers function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { return { error: 'No company selected' }; } return { companyId }; } function parsePositiveInt(value, field) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { return { error: `${field} is required` }; } return { id }; } function labeltemplateErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; if (error.message === 'Company not found') return 404; if (error.message === 'Label template not found') return 404; if (error.message === 'Name required') return 400; if (error.message === 'Invalid width') return 400; if (error.message === 'Invalid height') return 400; if (error.message?.startsWith('Invalid dpi')) return 400; if (error.message?.startsWith('Invalid type')) return 400; if (error.message === 'Content required') return 400; if (error.message?.startsWith('Invalid label objects')) return 400; return 500; } // #endregion /** * Registers label template routes for the authenticated user's selected company. * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /labeltemplate: * get: * tags: [LabelTemplate] * summary: List label templates * description: Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present. The `content` field is omitted to reduce payload size (use GET /labeltemplate/{id} for it). * security: * - cookieAuth: [] * x-markdown: labeltemplate/list.md * responses: * 200: * description: List of label templates * content: * application/json: * schema: * type: object * properties: * labeltemplates: * type: array * items: * $ref: '#/components/schemas/LabelTemplateSummary' * 400: * description: No company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /labeltemplate app.get(`${basePath}/labeltemplate`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const templates = await db.list(req.user.id); res.status(200).json({ labeltemplates: templates.map((t) => { const { content, ...rest } = serializeTemplate(t); return rest; }) }); } catch (error) { const status = labeltemplateErrorStatus(error); if (status === 500) console.error('List label templates failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /labeltemplate/{id}: * get: * tags: [LabelTemplate] * summary: Get label template by ID * description: Retrieves a single label template for the authenticated user's selected company. * security: * - cookieAuth: [] * x-markdown: labeltemplate/get.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Label template ID * responses: * 200: * description: Label template details * content: * application/json: * schema: * type: object * properties: * labeltemplate: * $ref: '#/components/schemas/LabelTemplate' * 400: * description: 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' * 404: * description: Label template not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /labeltemplate/:id app.get(`${basePath}/labeltemplate/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { const template = await db.getById(req.user.id, parsedId.id); if (!template) { return res.status(404).json({ error: 'Label template not found' }); } res.status(200).json({ labeltemplate: serializeTemplate(template) }); } catch (error) { const status = labeltemplateErrorStatus(error); if (status === 500) console.error('Get label template failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /labeltemplate: * post: * tags: [LabelTemplate] * summary: Create label template * description: | * Creates a new ZPL label template for the selected company. * A 50×50 1-bit PNG thumbnail is automatically rendered from the content * and stored with the template. * security: * - cookieAuth: [] * x-signals: [labeltemplate] * x-markdown: labeltemplate/create.md * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/LabelTemplateCreateInput' * responses: * 201: * description: Created label template * content: * application/json: * schema: * type: object * properties: * labeltemplate: * $ref: '#/components/schemas/LabelTemplate' * 400: * description: Invalid input or missing fields * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /labeltemplate app.post(`${basePath}/labeltemplate`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const { name, width, height, dpi, type, content } = req.body ?? {}; const validation = validateLabelContent(content); if (!validation.valid) { return res.status(400).json({ error: validation.error, ...(validation.errors ? { errors: validation.errors } : {}) }); } try { const thumb = await buildThumb({ width, height, content, objects: validation.objects }); const template = await db.create(req.user.id, { name, width, height, dpi, type, content, thumb, }); emit('labeltemplate', { companyId: selected.companyId }); res.status(201).json({ labeltemplate: serializeTemplate(template) }); } catch (error) { const status = labeltemplateErrorStatus(error); if (status === 500) console.error('Create label template failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion async function handleUpdate(req, res) { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } const { name, width, height, dpi, type, content } = req.body ?? {}; let validatedObjects; if (content !== undefined) { const validation = validateLabelContent(content); if (!validation.valid) { return res.status(400).json({ error: validation.error, ...(validation.errors ? { errors: validation.errors } : {}) }); } validatedObjects = validation.objects; } try { // Re-render thumb whenever content/dimensions are provided const thumb = await buildThumb({ width, height, content, objects: validatedObjects }); const template = await db.update(req.user.id, parsedId.id, { name, width, height, dpi, type, content, ...(thumb !== null ? { thumb } : {}), }); emit('labeltemplate', { companyId: selected.companyId }); res.status(200).json({ labeltemplate: serializeTemplate(template) }); } catch (error) { const status = labeltemplateErrorStatus(error); if (status === 500) console.error('Update label template failed:', error); res.status(status).json({ error: error.message }); } } /** * @openapi * /labeltemplate/{id}: * patch: * tags: [LabelTemplate] * summary: Update label template (partial) * description: | * Updates specified fields of an existing label template. * When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered. * security: * - cookieAuth: [] * x-signals: [labeltemplate] * x-markdown: labeltemplate/update.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Label template ID * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/LabelTemplateUpdateInput' * responses: * 200: * description: Updated label template * content: * application/json: * schema: * type: object * properties: * labeltemplate: * $ref: '#/components/schemas/LabelTemplate' * 400: * description: Invalid input * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Label template not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * put: * tags: [LabelTemplate] * summary: Update label template * description: | * Replaces an existing label template. * When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered. * security: * - cookieAuth: [] * x-signals: [labeltemplate] * x-markdown: labeltemplate/update.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Label template ID * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/LabelTemplateUpdateInput' * responses: * 200: * description: Updated label template * content: * application/json: * schema: * type: object * properties: * labeltemplate: * $ref: '#/components/schemas/LabelTemplate' * 400: * description: Invalid input * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Label template not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region PATCH /labeltemplate/:id app.patch(`${basePath}/labeltemplate/:id`, handleUpdate); // #endregion // #region PUT /labeltemplate/:id app.put(`${basePath}/labeltemplate/:id`, handleUpdate); // #endregion /** * @openapi * /labeltemplate/{id}: * delete: * tags: [LabelTemplate] * summary: Delete label template * description: Deletes a label template for the authenticated user's selected company. * security: * - cookieAuth: [] * x-signals: [labeltemplate] * x-markdown: labeltemplate/delete.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Label template ID * responses: * 204: * description: Label template deleted * 400: * description: 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' * 404: * description: Label template not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region DELETE /labeltemplate/:id app.delete(`${basePath}/labeltemplate/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { await db.remove(req.user.id, parsedId.id); emit('labeltemplate', { companyId: selected.companyId }); res.sendStatus(204); } catch (error) { const status = labeltemplateErrorStatus(error); if (status === 500) console.error('Delete label template failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion } /** * Serializes a label template row for the API response. * Converts the thumb Buffer to a base64 string (or null). */ function serializeTemplate(template) { return { ...template, thumb: template.thumb ? template.thumb.toString('base64') : null, }; } /** * Validates the label template content and its objects. * Expects content to be an object with an `objects` array (or an array of objects). * * @param {any} content * @returns {{ valid: true, objects: Array } | { valid: false, error: string, errors?: string[] }} */ function validateLabelContent(content) { if (content == null) { return { valid: false, error: 'Content required' }; } let parsed = content; if (typeof content === 'string') { try { parsed = JSON.parse(content); } catch { return { valid: false, error: 'Invalid JSON in content' }; } } const objects = Array.isArray(parsed?.objects) ? parsed.objects : Array.isArray(parsed) ? parsed : null; if (!objects) { return { valid: false, error: 'content.objects must be an array' }; } const { valid, errors } = validateObjects(objects); if (!valid) { return { valid: false, error: `Invalid label objects: ${errors.join(', ')}`, errors }; } return { valid: true, objects }; } /** * Attempts to render a thumbnail from content + dimensions. * Returns null silently if content or dimensions are missing/invalid — * the store will reject the request with a proper error anyway. * * @returns {Promise} */ async function buildThumb({ width, height, content, objects: passedObjects }) { try { if (content == null && passedObjects == null) return null; const w = Number(width); const h = Number(height); if (!Number.isInteger(w) || w <= 0 || !Number.isInteger(h) || h <= 0) return null; const objects = passedObjects ?? ( Array.isArray(content?.objects) ? content.objects : Array.isArray(content) ? content : [] ); return await renderThumb({ width: w, height: h, objects }); } catch { // Thumbnail rendering failure is non-fatal return null; } }