import express from 'express'; import { renderLabel } from 'react-canvas-label-editor'; import * as db from '../../../db/article.js'; import * as agentprinterDb from '../../../db/agentprinter.js'; import * as labeltemplateDb from '../../../db/labeltemplate.js'; import * as printerjobDb from '../../../db/printerjob.js'; import { notifyAgentJob } from '../../agent/index.js'; import { pngToZpl } from '../../../services/zplService.js'; import { applyZplCopies } from '../zpljob.js'; import { AVIF_BODY_LIMIT, emitArticleSignal, parseSku, parseFullHash, articleErrorStatus, interpolateString, } from './shared.js'; /** * Registers article image and label-printing 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 * /article/{sku}/image: * post: * tags: [Article] * summary: Add article 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 `articlepic`. * Auth like GET by SKU. * security: * - cookieAuth: [] * x-signals: [article-] * x-markdown: article/add-image.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * description: Article SKU * 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: * image: * $ref: '#/components/schemas/ArticleImage' * 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: Article not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /article/:sku/image app.post( `${basePath}/article/:sku/image`, express.raw({ type: ['image/avif', 'image/webp', 'application/octet-stream'], limit: AVIF_BODY_LIMIT }), async (req, res) => { const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; if (!sku) { return res.status(400).json({ error: 'sku 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 decodedSku = decodeURIComponent(sku); const image = await db.addImage(req.user.id, decodedSku, imageBuffer); emitArticleSignal(req, decodedSku); res.status(201).json({ image }); } catch (error) { const status = articleErrorStatus(error); if (status === 500) console.error('Add article image failed:', error); res.status(status).json({ error: error.message }); } }, ); // #endregion /** * @openapi * /article/{sku}/image/{fullHash}: * delete: * tags: [Article] * summary: Delete article image * description: Removes `articlepic` and the `pic` row for this article (`fullHash`). Auth like GET by SKU. * security: * - cookieAuth: [] * x-signals: [article-] * x-markdown: article/delete-image.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * - in: path * name: fullHash * required: true * schema: * type: string * pattern: '^[a-zA-Z0-9]{8}$' * responses: * 204: * description: Deleted * 400: * description: Invalid hash * 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: Article or image not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region DELETE /article/:sku/image/:fullHash app.delete(`${basePath}/article/:sku/image/:fullHash`, async (req, res) => { const skuParsed = parseSku(req.params.sku); if (skuParsed.error) { return res.status(400).json({ error: skuParsed.error }); } const hashParsed = parseFullHash(req.params.fullHash); if (hashParsed.error) { return res.status(400).json({ error: hashParsed.error }); } try { await db.deleteImage(req.user.id, skuParsed.sku, hashParsed.fullHash); emitArticleSignal(req, skuParsed.sku); res.sendStatus(204); } catch (error) { const status = articleErrorStatus(error); if (status === 500) console.error('Delete article image failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /article/{sku}/images/order: * put: * tags: [Article] * summary: Reorder article images * description: | * Sets `articlepic.sort` to `0..n-1` in the given order. * `fullHashes` must list every image of the article exactly once. * security: * - cookieAuth: [] * x-signals: [article-] * x-markdown: article/order-images.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [fullHashes] * properties: * fullHashes: * type: array * items: * type: string * pattern: '^[a-zA-Z0-9]{8}$' * example: * fullHashes: * - "aB1cD2eF" * - "gH3iJ4kL" * responses: * 200: * description: New order * content: * application/json: * schema: * type: object * properties: * fullHashes: * type: array * items: * type: string * 400: * description: Invalid image 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: Article not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region PUT /article/:sku/images/order app.put(`${basePath}/article/:sku/images/order`, async (req, res) => { const skuParsed = parseSku(req.params.sku); if (skuParsed.error) { return res.status(400).json({ error: skuParsed.error }); } const rawHashes = req.body?.fullHashes; if (!Array.isArray(rawHashes)) { return res.status(400).json({ error: 'fullHashes is required' }); } const fullHashes = []; for (const value of rawHashes) { const parsed = parseFullHash(value); if (parsed.error) { return res.status(400).json({ error: parsed.error }); } fullHashes.push(parsed.fullHash); } try { const result = await db.orderImages(req.user.id, skuParsed.sku, { fullHashes }); emitArticleSignal(req, skuParsed.sku); res.status(200).json(result); } catch (error) { const status = articleErrorStatus(error); if (status === 500) console.error('Order article images failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /article/{sku}/print: * post: * tags: [Article] * summary: Print article label * description: Prints a label for the article using a label template. * security: * - cookieAuth: [] * parameters: * - in: path * name: sku * required: true * schema: * type: string * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [templateId, agentprinterId, quantity] * properties: * templateId: * type: integer * agentprinterId: * type: integer * quantity: * type: integer * responses: * 200: * description: Print job dispatched * content: * application/json: * schema: * type: object * required: [success] * properties: * success: * type: boolean * example: true * 400: * description: Invalid parameters * 404: * description: Printer, Template or Article not found * 500: * description: Server error */ // #region POST /article/:sku/print app.post(`${basePath}/article/:sku/print`, express.json(), async (req, res) => { const companyId = req.user?.selectedCompanyId; if (companyId == null) { return res.status(400).json({ error: 'No company selected' }); } const { sku: parsedSku, error: skuError } = parseSku(req.params.sku); if (skuError) return res.status(400).json({ error: skuError }); const sku = parsedSku; const { templateId, agentprinterId, quantity } = req.body ?? {}; if (templateId === undefined || templateId === null) { return res.status(400).json({ error: 'templateId is required' }); } if (typeof templateId !== 'number' || !Number.isInteger(templateId) || templateId <= 0) { return res.status(400).json({ error: 'templateId must be an integer' }); } if (agentprinterId === undefined || agentprinterId === null) { return res.status(400).json({ error: 'agentprinterId is required' }); } if (typeof agentprinterId !== 'number' || !Number.isInteger(agentprinterId) || agentprinterId <= 0) { return res.status(400).json({ error: 'agentprinterId must be an integer' }); } if (quantity === undefined || quantity === null) { return res.status(400).json({ error: 'quantity is required' }); } if (typeof quantity !== 'number' || !Number.isInteger(quantity) || quantity <= 0) { return res.status(400).json({ error: 'quantity must be an integer' }); } try { const printer = await agentprinterDb.getById(req.user.id, agentprinterId); if (!printer) { return res.status(404).json({ error: 'Printer not found' }); } if (printer.type && printer.type.toLowerCase() !== 'zpl') { return res.status(400).json({ error: 'Printer is not a ZPL printer' }); } const template = await labeltemplateDb.getById(req.user.id, templateId); if (!template || template.company_id !== companyId) { return res.status(404).json({ error: 'Label template not found' }); } let article; try { const result = await db.getBySku(req.user.id, sku); article = result?.article; } catch (err) { const status = articleErrorStatus(err); if (status === 500) console.error('Get article by sku failed for print:', err); return res.status(status).json({ error: err.message }); } if (!article) { return res.status(404).json({ error: 'Article not found' }); } // Extract and interpolate canvas objects or raw ZPL const content = template.content; let zplData; if (typeof content === 'string' && content.includes('^XA')) { let interpolated = interpolateString(content, article); if (quantity > 1) { interpolated = applyZplCopies(interpolated, quantity); } zplData = interpolated; } else { const objects = Array.isArray(content?.objects) ? content.objects : Array.isArray(content) ? content : []; const interpolatedObjects = objects.map(obj => { const cloned = { ...obj }; if (typeof cloned.text === 'string') { cloned.text = interpolateString(cloned.text, article); } if (typeof cloned.code === 'string') { cloned.code = interpolateString(cloned.code, article); } return cloned; }); const printPayload = await renderLabel({ width: template.width, height: template.height, objects: interpolatedObjects, }); zplData = await pngToZpl(printPayload, quantity); } const job = await printerjobDb.create(req.user.id, { agentprinterId: printer.id, type: 'zpl', data: zplData, }); if (job.printeragentId) { notifyAgentJob(job.printeragentId, 'created', job); } return res.status(200).json({ success: true, printerjob: job }); } catch (error) { console.error('Print article label failed:', error); res.status(500).json({ error: error.message }); // #endregion } }); }