Files
dashback/src/api/routes/labeltemplate.js
2026-11-01 04:58:46 +01:00

752 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } 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);
res.status(200).json({ labeltemplates: templates.map((t) => {
const { content, ...rest } = serializeTemplate(t);
return rest;
}) });
} 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' });
}
res.status(200).json({ labeltemplate: serializeTemplate(template) });
} 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) {
try {
const result = await articleDb.getBySku(req.user.id, req.body.sku);
article = result?.article;
} catch {
// ignore
}
}
if (!article) {
article = {
name: req.body?.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 = 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: 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;
}
}