802 lines
28 KiB
JavaScript
802 lines
28 KiB
JavaScript
import { renderLabel, validateObjects } from 'react-canvas-label-editor';
|
||
import * as db from '../../db/labeltemplate.js';
|
||
import * as agentprinterDb from '../../db/agentprinter.js';
|
||
import * as printerjobDb from '../../db/printerjob.js';
|
||
import * as articleDb from '../../db/article.js';
|
||
import { renderThumb } from '../../services/labelThumb.js';
|
||
import { pngToZpl } from '../../services/zplService.js';
|
||
import { notifyAgentJob } from '../agent/index.js';
|
||
import { emit } from '../sse.js';
|
||
import { applyZplCopies } from './zpljob.js';
|
||
import { interpolateString, interpolateObjects, withMandatedOptionsPrice } from './article/shared.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);
|
||
|
||
let article = null;
|
||
if (req.query?.sku) {
|
||
const skuStr = String(req.query.sku).trim();
|
||
const result = await articleDb.getBySku(req.user.id, skuStr);
|
||
if (result?.article) {
|
||
article = await withMandatedOptionsPrice(req.user.id, result.article);
|
||
}
|
||
}
|
||
|
||
const labeltemplates = await Promise.all(
|
||
templates.map(async (t) => {
|
||
const { content, ...rest } = serializeTemplate(t);
|
||
if (article) {
|
||
try {
|
||
const full = await db.getById(req.user.id, t.id);
|
||
const objects = Array.isArray(full?.content?.objects)
|
||
? full.content.objects
|
||
: Array.isArray(full?.content)
|
||
? full.content
|
||
: null;
|
||
if (objects && objects.length > 0) {
|
||
const interpolated = interpolateObjects(objects, article);
|
||
const thumbBuffer = await renderThumb({
|
||
width: t.width,
|
||
height: t.height,
|
||
objects: interpolated,
|
||
});
|
||
rest.thumb = thumbBuffer.toString('base64');
|
||
}
|
||
} catch (err) {
|
||
console.error(`Failed to render preview thumb for template ${t.id} with sku ${req.query.sku}:`, err);
|
||
}
|
||
}
|
||
return rest;
|
||
})
|
||
);
|
||
|
||
res.status(200).json({ labeltemplates });
|
||
} 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' });
|
||
}
|
||
const serialized = serializeTemplate(template);
|
||
if (req.query?.sku) {
|
||
const skuStr = String(req.query.sku).trim();
|
||
const result = await articleDb.getBySku(req.user.id, skuStr);
|
||
if (result?.article) {
|
||
const objects = Array.isArray(serialized?.content?.objects)
|
||
? serialized.content.objects
|
||
: Array.isArray(serialized?.content)
|
||
? serialized.content
|
||
: null;
|
||
if (objects && objects.length > 0) {
|
||
try {
|
||
const interpolated = interpolateObjects(objects, await withMandatedOptionsPrice(req.user.id, result.article));
|
||
const thumbBuffer = await renderThumb({
|
||
width: template.width,
|
||
height: template.height,
|
||
objects: interpolated,
|
||
});
|
||
serialized.thumb = thumbBuffer.toString('base64');
|
||
} catch (err) {
|
||
console.error(`Failed to render preview thumb for template ${template.id} with sku ${req.query.sku}:`, err);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
res.status(200).json({ labeltemplate: serialized });
|
||
} 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
|
||
|
||
/**
|
||
* @openapi
|
||
* /labeltemplate/testprint:
|
||
* post:
|
||
* tags: [LabelTemplate]
|
||
* summary: Test print label template
|
||
* description: |
|
||
* Dispatches a test print job for a label template or unsaved canvas objects to a specified ZPL printer.
|
||
* Placeholders in text and barcodes are interpolated using a sample article or the specified article SKU.
|
||
* security:
|
||
* - cookieAuth: []
|
||
* requestBody:
|
||
* required: true
|
||
* content:
|
||
* application/json:
|
||
* schema:
|
||
* type: object
|
||
* required: [agentprinterId]
|
||
* properties:
|
||
* agentprinterId:
|
||
* type: integer
|
||
* description: Agent printer ID
|
||
* templateId:
|
||
* type: integer
|
||
* objects:
|
||
* type: array
|
||
* width:
|
||
* type: integer
|
||
* height:
|
||
* type: integer
|
||
* dpi:
|
||
* type: integer
|
||
* sku:
|
||
* type: string
|
||
* quantity:
|
||
* type: integer
|
||
* responses:
|
||
* 200:
|
||
* description: Print job created
|
||
* content:
|
||
* application/json:
|
||
* schema:
|
||
* type: object
|
||
* properties:
|
||
* success:
|
||
* type: boolean
|
||
* 400:
|
||
* description: Invalid parameters
|
||
* 404:
|
||
* description: Printer or template not found
|
||
* 500:
|
||
* description: Server error
|
||
*/
|
||
// #region POST /labeltemplate/testprint
|
||
|
||
async function handleTestPrint(req, res) {
|
||
const selected = requireSelectedCompany(req);
|
||
if (selected.error) {
|
||
return res.status(400).json({ error: selected.error });
|
||
}
|
||
const companyId = selected.companyId;
|
||
|
||
const parsedPrinterId = parsePositiveInt(req.body?.agentprinterId, 'agentprinterId');
|
||
if (parsedPrinterId.error) {
|
||
return res.status(400).json({ error: parsedPrinterId.error });
|
||
}
|
||
|
||
try {
|
||
const printer = await agentprinterDb.getById(req.user.id, parsedPrinterId.id);
|
||
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 rawTemplateId = req.params?.id ?? req.body?.templateId;
|
||
let template = null;
|
||
if (rawTemplateId != null) {
|
||
const parsedTmplId = parsePositiveInt(rawTemplateId, 'templateId');
|
||
if (!parsedTmplId.error) {
|
||
template = await db.getById(req.user.id, parsedTmplId.id);
|
||
if (!template || template.company_id !== companyId) {
|
||
return res.status(404).json({ error: 'Label template not found' });
|
||
}
|
||
}
|
||
}
|
||
|
||
const rawQuantity = req.body?.quantity ?? 1;
|
||
const quantity = Number(rawQuantity) > 0 ? Number(rawQuantity) : 1;
|
||
|
||
let objects = req.body?.objects;
|
||
let width = req.body?.width != null ? Number(req.body.width) : template?.width;
|
||
let height = req.body?.height != null ? Number(req.body.height) : template?.height;
|
||
let content = req.body?.content ?? template?.content;
|
||
|
||
// Resolve sample article for placeholder interpolation
|
||
let article = null;
|
||
if (req.body?.sku) {
|
||
const skuStr = String(req.body.sku).trim();
|
||
const result = await articleDb.getBySku(req.user.id, skuStr);
|
||
if (!result?.article) {
|
||
return res.status(404).json({ error: 'Article not found' });
|
||
}
|
||
article = await withMandatedOptionsPrice(req.user.id, result.article);
|
||
} else {
|
||
article = {
|
||
name: template?.name || 'Musterartikel',
|
||
sku: 'TEST-12345',
|
||
prices: [{ net: 19.99, taxRate: 19 }],
|
||
eans: ['4012345678901'],
|
||
};
|
||
}
|
||
|
||
let zplData;
|
||
if (typeof content === 'string' && content.includes('^XA')) {
|
||
let interpolated = interpolateString(content, article);
|
||
if (quantity > 1) {
|
||
interpolated = applyZplCopies(interpolated, quantity);
|
||
}
|
||
zplData = interpolated;
|
||
} else {
|
||
if (!objects && content) {
|
||
objects = Array.isArray(content?.objects)
|
||
? content.objects
|
||
: Array.isArray(content)
|
||
? content
|
||
: [];
|
||
}
|
||
if (!Array.isArray(objects)) {
|
||
objects = [];
|
||
}
|
||
|
||
const interpolatedObjects = interpolateObjects(objects, article);
|
||
|
||
const printPayload = await renderLabel({
|
||
width: width || 448,
|
||
height: height || 200,
|
||
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('Testprint label template failed:', error);
|
||
return res.status(500).json({ error: error.message });
|
||
}
|
||
}
|
||
|
||
app.post(`${basePath}/labeltemplate/testprint`, handleTestPrint);
|
||
app.post(`${basePath}/labeltemplate/:id/testprint`, handleTestPrint);
|
||
|
||
// #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<Buffer|null>}
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
|