u
This commit is contained in:
465
src/api/routes/article/media.js
Normal file
465
src/api/routes/article/media.js
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user