475 lines
17 KiB
JavaScript
475 lines
17 KiB
JavaScript
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-<sku>]
|
||
* 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-<sku>]
|
||
* 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-<sku>]
|
||
* 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
|
||
}
|
||
});
|
||
}
|