This commit is contained in:
seb
2026-10-10 02:08:10 +02:00
parent d4e2b05cb0
commit 7f8661be3e
43 changed files with 3312 additions and 2339 deletions

View File

@@ -0,0 +1,465 @@
import fs from 'fs/promises';
import path from 'path';
import express from 'express';
import { renderLabel } from 'react-canvas-label-editor';
import * as db from '../../../db/article.js';
import * as printerDb from '../../../db/printer.js';
import * as labeltemplateDb from '../../../db/labeltemplate.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, printerId, quantity]
* properties:
* templateId:
* type: integer
* printerId:
* 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, printerId, 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 (printerId === undefined || printerId === null) {
return res.status(400).json({ error: 'printerId is required' });
}
if (typeof printerId !== 'number' || !Number.isInteger(printerId) || printerId <= 0) {
return res.status(400).json({ error: 'printerId 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' });
}
const parsedPrinterId = { id: printerId };
const parsedTemplateId = { id: templateId };
const parsedQuantity = { id: quantity };
try {
const printer = await printerDb.getById(req.user.id, parsedPrinterId.id);
if (!printer || printer.company_id !== companyId) {
return res.status(404).json({ error: 'Printer not found' });
}
if (printer.type !== 'zpl') {
return res.status(400).json({ error: 'Printer is not a ZPL printer' });
}
const template = await labeltemplateDb.getById(req.user.id, parsedTemplateId.id);
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
const content = template.content;
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,
});
// Mock network dispatch
const printDir = path.join(process.cwd(), 'print_spool', `${printer.address}_${printer.type}`);
await fs.mkdir(printDir, { recursive: true });
for (let i = 0; i < parsedQuantity.id; i++) {
const timestamp = Date.now();
const filename = parsedQuantity.id > 1 ? `${timestamp}_${i + 1}.png` : `${timestamp}.png`;
const filePath = path.join(printDir, filename);
await fs.writeFile(filePath, printPayload);
}
res.status(200).json({ success: true });
} catch (error) {
console.error('Print article label failed:', error);
res.status(500).json({ error: error.message });
// #endregion
}
});
}