Implement EventManager for handling object events and enhance document template management
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit

This commit introduces the `EventManager` class, which facilitates tracking and broadcasting object events using NATS and websockets. The `documenttemplate.schema.js` file is updated to change the `testObject` field type to `ObjectId` with a reference path, improving data integrity. Additionally, the document template service is enhanced with new functionalities for asynchronous rendering and progress reporting, utilizing Redis for storing render requests. Corresponding tests are added to ensure the correct functionality of the new event handling and document management features.
This commit is contained in:
Tom Butcher 2026-08-22 12:56:05 +01:00
parent 30d1469204
commit 97c44565a8
9 changed files with 861 additions and 68 deletions

View File

@ -45,7 +45,8 @@ const documentTemplateSchema = new Schema(
default: '<Container></Container>',
},
testObject: {
type: Schema.Types.Mixed,
type: Schema.Types.ObjectId,
refPath: 'objectType',
required: false,
},
},

View File

@ -0,0 +1,134 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../database/nats.js', () => ({
natsServer: {
publish: jest.fn().mockResolvedValue({ success: true }),
subscribe: jest.fn().mockResolvedValue({ success: true }),
removeSubscription: jest.fn().mockResolvedValue({ success: true })
}
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
info: jest.fn()
})
}
}));
jest.unstable_mockModule('../../config.js', () => ({
default: {
server: {
logLevel: 'info'
}
}
}));
const { EventManager } = await import('../eventmanager.js');
const { natsServer } = await import('../../database/nats.js');
describe('EventManager', () => {
let mockSocketClient;
let eventManager;
beforeEach(() => {
jest.clearAllMocks();
mockSocketClient = {
socketId: 'test-socket-id',
socket: {
emit: jest.fn()
}
};
eventManager = new EventManager(mockSocketClient);
});
describe('subscribeToObjectEvent', () => {
it('should subscribe to object events and emit when received', async () => {
const id = 'obj-123';
const objectType = 'printer';
const eventType = 'status';
await eventManager.subscribeToObjectEvent(id, objectType, eventType);
expect(natsServer.subscribe).toHaveBeenCalledWith(
'printers.obj-123.events.status',
'test-socket-id',
expect.any(Function)
);
// Simulate NATS message
const natsCallback = natsServer.subscribe.mock.calls[0][2];
const eventData = { status: 'online' };
natsCallback('printers.obj-123.events.status', eventData);
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith('objectEvent', {
_id: id,
objectType: objectType,
event: eventData
});
});
});
describe('removeObjectEventsListener', () => {
it('should remove specific subscription', async () => {
const id = 'obj-123';
const objectType = 'printer';
const eventType = 'status';
await eventManager.subscribeToObjectEvent(id, objectType, eventType);
await eventManager.removeObjectEventsListener(id, objectType, eventType);
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
'printers.obj-123.events.status',
'test-socket-id'
);
expect(eventManager.subscriptions.size).toBe(0);
});
});
describe('sendObjectEvent', () => {
it('should publish event to NATS', async () => {
const id = 'obj-123';
const objectType = 'printer';
const event = { type: 'alert', message: 'low filament' };
const result = await eventManager.sendObjectEvent(id, objectType, event);
expect(result).toEqual({ success: true });
expect(natsServer.publish).toHaveBeenCalledWith(
'printers.obj-123.events.alert',
event
);
});
it('should handle errors when publishing fails', async () => {
natsServer.publish.mockRejectedValueOnce(new Error('NATS error'));
const result = await eventManager.sendObjectEvent('id', 'type', {
type: 't'
});
expect(result).toHaveProperty('error', 'NATS error');
});
});
describe('removeAllListeners', () => {
it('should remove all subscriptions', async () => {
await eventManager.subscribeToObjectEvent('1', 'printer', 'e1');
await eventManager.subscribeToObjectEvent('2', 'printer', 'e2');
await eventManager.removeAllListeners();
expect(natsServer.removeSubscription).toHaveBeenCalledTimes(2);
expect(eventManager.subscriptions.size).toBe(0);
});
});
});

View File

@ -0,0 +1,94 @@
import log4js from 'log4js';
import config from '../config.js';
import { natsServer } from '../database/nats.js';
// Setup logger
const logger = log4js.getLogger('Event Manager');
logger.level = config.server.logLevel;
/**
* EventManager handles tracking object events using NATS and broadcasts event events via websockets.
*/
export class EventManager {
constructor(socketClient) {
this.socketClient = socketClient;
this.subscriptions = new Set();
}
async subscribeToObjectEvent(id, objectType, eventType) {
logger.debug('Subscribing to object event:', eventType, id, objectType);
const subject = `${objectType}s.${id}.events.${eventType}`;
const subscriptionKey = `${subject}:${this.socketClient.socketId}`;
await natsServer.subscribe(
subject,
this.socketClient.socketId,
(key, value) => {
if (!value?.result) {
logger.trace('Object event detected:', id);
this.socketClient.socket.emit('objectEvent', {
_id: id,
objectType: objectType,
event: { ...value }
});
}
}
);
this.subscriptions.add(subscriptionKey);
return { success: true };
}
async removeObjectEventsListener(id, objectType, eventType) {
// Remove specific event subscription for this object
const subject = `${objectType}s.${id}.events.${eventType}`;
const subscriptionKey = `${subject}:${this.socketClient.socketId}`;
await natsServer.removeSubscription(subject, this.socketClient.socketId);
this.subscriptions.delete(subscriptionKey);
return { success: true };
}
async sendObjectEvent(id, objectType, event) {
const eventType = event?.type || 'unknown';
try {
logger.trace(
`Calling event: ${eventType}, object id: ${id}, object type: ${objectType} Event:`,
event
);
await natsServer.publish(
`${objectType}s.${id}.events.${eventType}`,
event
);
return { success: true };
} catch (error) {
logger.error(
`Failed to publish event for ${objectType}s.${id}.events.${eventType}:`,
error
);
return {
error:
error?.message ||
`Failed to publish event for ${objectType}s.${id}.events.${eventType}.`
};
}
}
async removeAllListeners() {
logger.debug('Removing all event listeners...');
const removePromises = Array.from(this.subscriptions).map(
subscriptionKey => {
const [subject, socketId] = subscriptionKey.split(':');
return natsServer.removeSubscription(subject, socketId);
}
);
await Promise.all(removePromises);
this.subscriptions.clear();
logger.debug(`Removed ${removePromises.length} event listener(s)`);
return { success: true };
}
}
export const eventManager = new EventManager();

View File

@ -23,6 +23,15 @@ jest.unstable_mockModule('../../../templates/templatemanager.js', () => ({
renderTemplate: jest.fn(),
renderDownload: jest.fn(),
},
DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX: 'documenttemplaterenders:',
DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS: 15,
}));
jest.unstable_mockModule('../../../database/redis.js', () => ({
redisServer: {
getKey: jest.fn(),
setKey: jest.fn(),
},
}));
jest.unstable_mockModule('log4js', () => ({
@ -53,6 +62,7 @@ const { documentTemplateModel } = await import(
'../../../database/schemas/management/documenttemplate.schema.js'
);
const { templateManager } = await import('../../../templates/templatemanager.js');
const { redisServer } = await import('../../../database/redis.js');
describe('Document Template Service Route Handlers', () => {
let req, res;
@ -205,6 +215,54 @@ describe('Document Template Service Route Handlers', () => {
images: [pageOne.toString('base64'), pageTwo.toString('base64')],
});
});
it('returns a renderRequestId when async is true', async () => {
req.params.id = 'template-1';
req.query = { type: 'pdf', async: 'true' };
req.body = { object: { name: 'Test' } };
templateManager.renderDownload.mockResolvedValue({
type: 'pdf',
mime: 'application/pdf',
extension: 'pdf',
buffers: [Buffer.from('pdf-data')],
});
await downloadDocumentTemplateRouteHandler(req, res);
await new Promise((resolve) => setImmediate(resolve));
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({ renderRequestId: expect.any(String) })
);
expect(templateManager.renderDownload).toHaveBeenCalledWith(
'template-1',
undefined,
{ name: 'Test' },
'pdf',
expect.objectContaining({
padding: true,
renderRequestId: expect.any(String),
})
);
});
it('returns a stored render when renderRequestId is provided', async () => {
req.params.id = 'template-1';
req.body = { renderRequestId: 'render-123' };
const pdf = Buffer.from('stored-pdf');
redisServer.getKey.mockResolvedValue({
type: 'pdf',
mime: 'application/pdf',
extension: 'pdf',
buffers: [pdf.toString('base64')],
});
await downloadDocumentTemplateRouteHandler(req, res);
expect(redisServer.getKey).toHaveBeenCalledWith('documenttemplaterenders:render-123');
expect(templateManager.renderDownload).not.toHaveBeenCalled();
expect(res.set).toHaveBeenCalledWith('Content-Type', 'application/pdf');
expect(res.send).toHaveBeenCalledWith(pdf);
});
});
});

View File

@ -15,7 +15,13 @@ import {
getPropertyValues,
getObjectNeighbors,
} from '../../database/database.js';
import { templateManager } from '../../templates/templatemanager.js';
import { randomUUID } from 'crypto';
import { redisServer } from '../../database/redis.js';
import {
templateManager,
DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX,
DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS,
} from '../../templates/templatemanager.js';
import { formatTemplateContent } from '../../templates/templateformatter.js';
const logger = log4js.getLogger('Document Templates');
logger.level = config.server.logLevel;
@ -130,6 +136,7 @@ export const getDocumentTemplateRouteHandler = async (req, res) => {
{ path: 'documentSize' },
{ path: 'parent', strictPopulate: false },
{ path: 'documentPrinters', strictPopulate: false },
{ path: 'testObject', strictPopulate: false },
],
});
if (result?.error) {
@ -167,7 +174,7 @@ export const editDocumentTemplateRouteHandler = async (req, res) => {
documentSize: req.body.documentSize,
documentPrinters: req.body.documentPrinters,
content: formattedContent,
testObject: req.body.testObject,
testObject: req.body.testObject?._id || req.body.testObject,
};
// Create audit log before updating
const result = await editObject({
@ -179,6 +186,7 @@ export const editDocumentTemplateRouteHandler = async (req, res) => {
{ path: 'documentSize' },
{ path: 'parent', strictPopulate: false },
{ path: 'documentPrinters', strictPopulate: false },
{ path: 'testObject', strictPopulate: false },
],
});
@ -335,29 +343,7 @@ export const previewDocumentTemplateRouteHandler = async (req, res) => {
res.send(result);
};
export const downloadDocumentTemplateRouteHandler = async (req, res) => {
const id = req.params.id;
const type = req.query.type || req.body?.type || 'pdf';
const content = req.body?.content;
const data = req.body?.object || req.body?.testObject || {};
const width = req.body?.width || req.query.width;
const filename = req.body?.filename || req.query.filename || 'document';
const options = {
padding: getPaddingOption(req, true),
};
if (width) {
options.width = Number(width);
}
logger.debug(`Downloading document template ${id} as ${type}`);
const result = await templateManager.renderDownload(id, content, data, type, options);
if (result?.error) {
logger.warn('Error downloading document template:', result.error);
return res.status(result.code || 400).send(result);
}
function sendRenderedDownload(res, result, filename) {
const buffers = result.buffers || [];
if (buffers.length === 0) {
return res.status(500).send({ error: 'Failed to render document template.' });
@ -374,11 +360,87 @@ export const downloadDocumentTemplateRouteHandler = async (req, res) => {
return res.send(buffer);
}
res.send({
return res.send({
type: result.type,
mime: result.mime,
extension: result.extension,
images: buffers.map((buffer) => Buffer.from(buffer).toString('base64')),
});
}
function decodeStoredRenderBuffers(stored) {
return {
...stored,
buffers: (stored.buffers || []).map((buffer) => Buffer.from(buffer, 'base64')),
};
}
export const downloadDocumentTemplateRouteHandler = async (req, res) => {
const id = req.params.id;
const type = req.query.type || req.body?.type || 'pdf';
const content = req.body?.content;
const data = req.body?.object || req.body?.testObject || {};
const width = req.body?.width || req.query.width;
const filename = req.body?.filename || req.query.filename || 'document';
const asyncRequested = parseOptionalBoolean(req.body?.async ?? req.query?.async) === true;
const renderRequestId = req.body?.renderRequestId || req.query?.renderRequestId;
const options = {
padding: getPaddingOption(req, true),
};
if (width) {
options.width = Number(width);
}
if (renderRequestId) {
logger.debug(`Fetching stored document template render ${renderRequestId}`);
const stored = await redisServer.getKey(
`${DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX}${renderRequestId}`
);
if (stored == null) {
return res.status(404).send({ error: 'Render not found or expired.', code: 404 });
}
if (stored.error) {
return res.status(stored.code || 400).send(stored);
}
return sendRenderedDownload(res, decodeStoredRenderBuffers(stored), filename);
}
if (asyncRequested) {
const newRenderRequestId = randomUUID();
logger.debug(
`Starting async document template download ${id} as ${type} (${newRenderRequestId})`
);
res.send({ renderRequestId: newRenderRequestId });
setImmediate(() => {
templateManager
.renderDownload(id, content, data, type, {
...options,
renderRequestId: newRenderRequestId,
})
.catch(async (error) => {
logger.error('Error rendering document template asynchronously:', error);
await redisServer.setKey(
`${DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX}${newRenderRequestId}`,
{
error: error?.message || 'Failed to render document template.',
code: 500,
},
DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS
);
});
});
return;
}
logger.debug(`Downloading document template ${id} as ${type}`);
const result = await templateManager.renderDownload(id, content, data, type, options);
if (result?.error) {
logger.warn('Error downloading document template:', result.error);
return res.status(result.code || 400).send(result);
}
return sendRenderedDownload(res, result, filename);
};

View File

@ -42,6 +42,13 @@ jest.unstable_mockModule('pdf-lib', () => ({
PDFDocument,
}));
jest.unstable_mockModule('../../events/eventmanager.js', () => ({
eventManager: {
sendObjectEvent: jest.fn().mockResolvedValue({ success: true }),
},
EventManager: jest.fn(),
}));
jest.unstable_mockModule('../../config.js', () => ({
default: {
server: {
@ -63,6 +70,7 @@ jest.unstable_mockModule('log4js', () => ({
}));
const { generatePDF, startPDFBrowser, closePDFBrowser } = await import('../pdffactory.js');
const { eventManager } = await import('../../events/eventmanager.js');
function mockEvaluate({ pageCount = 1, height = 120 } = {}) {
page.evaluate.mockImplementation(async (fn) => {
@ -160,6 +168,50 @@ describe('PDF Factory', () => {
expect(result).toEqual(Buffer.from([9, 8, 7]));
});
it('reports progress while rendering each page', async () => {
mockEvaluate({ pageCount: 2, height: 120 });
const onProgress = jest.fn().mockResolvedValue(undefined);
await generatePDF('<html></html>', { width: 100, height: 50, onProgress });
expect(onProgress).toHaveBeenCalledWith(0.05, 'Loading document');
expect(onProgress).toHaveBeenCalledWith(0.15, 'Paginating document');
expect(onProgress).toHaveBeenCalledWith(0.525, 'Rendering page 1 of 2');
expect(onProgress).toHaveBeenCalledWith(0.85, 'Rendering page 2 of 2');
expect(onProgress).toHaveBeenCalledWith(0.92, 'Combining pages');
expect(onProgress).toHaveBeenCalledWith(1, 'PDF generated');
});
it('sends object events through the event manager when a renderRequestId is provided', async () => {
mockEvaluate({ pageCount: 1, height: 120 });
await generatePDF('<html></html>', {
width: 100,
height: 50,
renderRequestId: 'render-123',
progressStart: 0.35,
progressEnd: 0.9,
});
expect(eventManager.sendObjectEvent).toHaveBeenCalledWith(
'render-123',
'render',
expect.objectContaining({
type: 'render:render-123',
message: 'Loading document',
})
);
expect(eventManager.sendObjectEvent).toHaveBeenCalledWith(
'render-123',
'render',
expect.objectContaining({
type: 'render:render-123',
progress: 0.9,
message: 'PDF generated',
})
);
});
it('can start the browser before the first render', async () => {
await startPDFBrowser();
expect(puppeteer.launch).toHaveBeenCalledTimes(1);

View File

@ -59,6 +59,20 @@ jest.unstable_mockModule('../pdffactory.js', () => ({
generatePDF: jest.fn().mockResolvedValue(Buffer.from('pdf-data')),
}));
jest.unstable_mockModule('../../events/eventmanager.js', () => ({
eventManager: {
sendObjectEvent: jest.fn().mockResolvedValue({ success: true }),
},
EventManager: jest.fn(),
}));
jest.unstable_mockModule('../../database/redis.js', () => ({
redisServer: {
setKey: jest.fn().mockResolvedValue(undefined),
getKey: jest.fn().mockResolvedValue(null),
},
}));
jest.unstable_mockModule('../pdfUtils.js', () => ({
convertPDFToImage: jest.fn().mockResolvedValue([Buffer.from('image-data')]),
}));
@ -100,6 +114,8 @@ const { getObject, listObjects } = await import('../../database/database.js');
const { getFilter } = await import('../../utils.js');
const ejs = (await import('ejs')).default;
const { generatePDF } = await import('../pdffactory.js');
const { eventManager } = await import('../../events/eventmanager.js');
const { redisServer } = await import('../../database/redis.js');
describe('stripDocumentTemplateReference', () => {
it('strips a DTP: prefix', () => {
@ -184,6 +200,93 @@ describe('TemplateManager', () => {
expect(result.height).toBe(100);
});
it('hydrates a saved testObject id when previewing', async () => {
const mockTemplate = {
documentSize: { width: 100, height: 100, infiniteHeight: false },
global: false,
objectType: 'printer',
testObject: 'printer-id',
};
const mockPrinter = { _id: 'printer-id', name: 'Hydrated Printer', status: 'online' };
getObject.mockImplementation(async ({ id }) => {
if (id === 'temp-id') {
return mockTemplate;
}
if (id === 'printer-id') {
return mockPrinter;
}
return null;
});
await templateManager.renderTemplate('temp-id', 'some content', {}, 1, {}, true);
expect(getObject).toHaveBeenCalledWith(
expect.objectContaining({ id: 'printer-id', model: expect.anything() })
);
expect(ejs.render).toHaveBeenCalledWith(
'some content',
expect.objectContaining({
name: 'Hydrated Printer',
status: 'online',
}),
expect.anything()
);
});
it('hydrates a test object id passed as preview data', async () => {
getObject.mockImplementation(async ({ id }) => {
if (id === 'temp-id') {
return {
documentSize: { width: 100, height: 100, infiniteHeight: false },
global: false,
objectType: 'printer',
testObject: 'saved-printer-id',
};
}
if (id === 'preview-printer-id') {
return { _id: 'preview-printer-id', name: 'Preview Printer' };
}
return { _id: id, name: 'Unexpected' };
});
await templateManager.renderTemplate(
'temp-id',
'some content',
'preview-printer-id',
1,
{},
true
);
expect(getObject).toHaveBeenCalledWith(
expect.objectContaining({ id: 'preview-printer-id' })
);
expect(ejs.render).toHaveBeenCalledWith(
'some content',
expect.objectContaining({ name: 'Preview Printer' }),
expect.anything()
);
});
it('does not hydrate a saved testObject when not previewing', async () => {
getObject.mockResolvedValue({
documentSize: { width: 100, height: 100, infiniteHeight: false },
global: false,
objectType: 'printer',
testObject: 'printer-id',
});
await templateManager.renderTemplate('temp-id', 'some content', {}, 1, {}, false);
expect(getObject).toHaveBeenCalledTimes(1);
expect(ejs.render).toHaveBeenCalledWith(
'some content',
expect.objectContaining({ name: null, status: null }),
expect.anything()
);
});
it('should return error if template not found', async () => {
getObject.mockResolvedValue(null);
@ -550,6 +653,43 @@ describe('TemplateManager', () => {
expect(result.type).toBe('pdf');
expect(result.buffers).toHaveLength(1);
});
it('stores the render in redis and publishes progress when a renderRequestId is provided', async () => {
getObject.mockResolvedValue({
documentSize: { width: 100, height: 100, infiniteHeight: false },
global: false,
objectType: 'printer',
});
const result = await templateManager.renderDownload(
'temp-id',
'content',
{},
'pdf',
{ renderRequestId: 'render-123' }
);
expect(result.type).toBe('pdf');
expect(redisServer.setKey).toHaveBeenCalledWith(
'documenttemplaterenders:render-123',
expect.objectContaining({
type: 'pdf',
mime: 'application/pdf',
extension: 'pdf',
buffers: [Buffer.from('pdf-data').toString('base64')],
}),
15
);
expect(eventManager.sendObjectEvent).toHaveBeenCalledWith(
'render-123',
'render',
expect.objectContaining({
type: 'render:render-123',
progress: 1,
message: 'Render complete',
})
);
});
});
describe('formatDate', () => {

View File

@ -3,6 +3,7 @@ import puppeteer from 'puppeteer';
import { PDFDocument } from 'pdf-lib';
import config from '../config.js';
import { getPuppeteerLaunchOptions } from '../puppeteerLaunchOptions.js';
import { eventManager } from '../events/eventmanager.js';
const logger = log4js.getLogger('PDF Factory');
logger.level = config.server.logLevel;
@ -137,11 +138,7 @@ function buildPdfOptions(options, height, isAutoHeight, isolatePage) {
printBackground: true,
preferCSSPageSize: isolatePage !== true && isAutoHeight !== true,
width: options.width ? `${options.width}mm` : undefined,
height: height
? isAutoHeight
? `${height + 2}px`
: `${height}mm`
: undefined,
height: height ? (isAutoHeight ? `${height + 2}px` : `${height}mm`) : undefined,
margin: {
top: '0mm',
right: '0mm',
@ -170,30 +167,60 @@ export async function mergePdfBuffers(buffers = []) {
return Buffer.from(await merged.save());
}
function roundProgress(progress) {
const clamped = Math.min(1, Math.max(0, Number(progress) || 0));
return Math.round(clamped * 1000) / 1000;
}
async function reportPdfProgress(options = {}, localProgress, message) {
const { onProgress, renderRequestId, progressStart = 0, progressEnd = 1 } = options;
if (typeof onProgress === 'function') {
await onProgress(localProgress, message);
}
if (!renderRequestId) {
return;
}
const mapped = roundProgress(progressStart + localProgress * (progressEnd - progressStart));
const eventType = `render:${renderRequestId}`;
const result = await eventManager.sendObjectEvent(renderRequestId, 'render', {
type: eventType,
progress: mapped,
message: message || '',
});
if (result?.error) {
logger.warn('Failed to publish render progress:', result.error);
}
}
/**
* Generates a PDF from HTML content using a persistent Puppeteer browser
* @param {string} html - The HTML content to convert to PDF
* @param {Object} options - PDF generation options
* @param {number} options.width - Document width in mm
* @param {number|string} options.height - Document height in mm, or 'auto'
* @param {Function} [options.onProgress] - Optional progress callback (0-1, message)
* @returns {Promise<Buffer>} The PDF buffer
*/
export async function generatePDF(html, options = {}) {
const { onProgress, renderRequestId, progressStart, progressEnd, ...pdfOptions } = options;
const progressOptions = { onProgress, renderRequestId, progressStart, progressEnd };
try {
return await withPage(async (page) => {
await reportPdfProgress(progressOptions, 0.05, 'Loading document...');
await page.setContent(html, {
waitUntil: 'domcontentloaded',
});
await reportPdfProgress(progressOptions, 0.15, 'Paginating document...');
await page
.waitForFunction(
() => document.documentElement.getAttribute('data-paginated') === 'true',
{ timeout: 15000 }
)
.waitForFunction(() => document.documentElement.getAttribute('data-paginated') === 'true', {
timeout: 15000,
})
.catch(() => {});
const pageCount = await getPaginationPageCount(page);
const isAutoHeight = options.height == 'auto' || options.infiniteHeight == true;
const isAutoHeight = pdfOptions.height == 'auto' || pdfOptions.infiniteHeight == true;
const isolatePages = pageCount > 1;
const pdfBuffers = [];
@ -202,17 +229,27 @@ export async function generatePDF(html, options = {}) {
await showPaginationPage(page, index);
}
var height = options?.height || '50';
var height = pdfOptions?.height || '50';
if (isAutoHeight) {
height = await measureVisiblePageHeight(page);
}
const pageProgress = 0.2 + ((index + 1) / pageCount) * 0.65;
await reportPdfProgress(
progressOptions,
Math.round(pageProgress * 1000) / 1000,
`Rendering page ${index + 1} of ${pageCount}...`
);
pdfBuffers.push(
await page.pdf(buildPdfOptions(options, height, isAutoHeight, isolatePages))
await page.pdf(buildPdfOptions(pdfOptions, height, isAutoHeight, isolatePages))
);
}
return mergePdfBuffers(pdfBuffers);
await reportPdfProgress(progressOptions, 0.92, 'Combining pages...');
const merged = await mergePdfBuffers(pdfBuffers);
await reportPdfProgress(progressOptions, 1, 'PDF generated!');
return merged;
});
} catch (error) {
logger.error('Error generating PDF:', error.message);

View File

@ -17,6 +17,8 @@ import { getModelByName } from '../services/misc/model.js';
import { getFilter } from '../utils.js';
import { generatePDF } from './pdffactory.js';
import { convertPDFToImage } from './pdfUtils.js';
import { eventManager } from '../events/eventmanager.js';
import { redisServer } from '../database/redis.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -28,6 +30,8 @@ const logger = log4js.getLogger('Template Manager');
logger.level = config.server.logLevel;
const DOWNLOAD_TYPES = ['pdf', 'png', 'svg', 'jpeg'];
const DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX = 'documenttemplaterenders:';
const DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS = 15;
let baseTemplate;
let baseCSS;
@ -394,6 +398,52 @@ export function stripDocumentTemplateReference(reference) {
return trimmed;
}
function roundProgress(progress) {
const clamped = Math.min(1, Math.max(0, Number(progress) || 0));
return Math.round(clamped * 1000) / 1000;
}
function splitRenderOptions(options = {}) {
const { padding, renderRequestId, onProgress, width, ...ejsOptions } = options;
return { padding, renderRequestId, onProgress, width, ejsOptions };
}
async function publishRenderProgress(renderRequestId, progress, message) {
if (!renderRequestId) {
return;
}
const eventType = `render:${renderRequestId}`;
const result = await eventManager.sendObjectEvent(renderRequestId, 'render', {
type: eventType,
progress: roundProgress(progress),
message: message || '',
});
if (result?.error) {
logger.warn('Failed to publish render progress:', result.error);
}
}
function createRenderProgressReporter(renderRequestId, onProgress) {
return async (progress, message) => {
const rounded = roundProgress(progress);
if (typeof onProgress === 'function') {
await onProgress(rounded, message);
}
await publishRenderProgress(renderRequestId, rounded, message);
};
}
async function storeDocumentTemplateRender(renderRequestId, payload) {
if (!renderRequestId) {
return;
}
await redisServer.setKey(
`${DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX}${renderRequestId}`,
payload,
DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS
);
}
function omitTemplateFc(data) {
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
return data;
@ -403,6 +453,38 @@ function omitTemplateFc(data) {
return rest;
}
function extractObjectId(value) {
if (value == null || value === '') {
return null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
}
if (typeof value === 'object') {
if (typeof value.toHexString === 'function') {
return String(value);
}
if (value._id != null) {
return extractObjectId(value._id);
}
}
return null;
}
function isLegacyPreviewSnapshot(data) {
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
return false;
}
if (typeof data.toHexString === 'function') {
return false;
}
if (extractObjectId(data) != null) {
return false;
}
return Object.keys(data).length > 0;
}
function resolveNestedTemplateObject(object) {
if (typeof object === 'string') {
const trimmed = object.trim();
@ -563,15 +645,57 @@ export class TemplateManager {
return nestedContent;
}
async hydratePreviewTestObject(documentTemplate, data = {}, preview = true) {
if (documentTemplate?.global == true) {
return data;
}
if (isLegacyPreviewSnapshot(data)) {
return data;
}
const objectId =
extractObjectId(data) || (preview ? extractObjectId(documentTemplate?.testObject) : null);
if (objectId == null) {
return data;
}
const objectType = documentTemplate?.objectType;
if (objectType == null || objectType === '') {
return data;
}
const modelEntry = getModelByName(objectType);
const model = modelEntry?.model || modelEntry;
if (model == null || model.schema == null) {
return { error: `Unknown object type: ${objectType}`, code: 400 };
}
const object = await getObject({ model, id: objectId });
if (object == null || object.error) {
return {
error: object?.error || 'Test object not found.',
code: object?.code || 404,
};
}
return object;
}
async renderTemplate(id, content, data = {}, scale = 1, options = {}, preview = true) {
try {
const { padding: paddingRequested = false, ...ejsOptions } = options;
const {
padding: paddingRequested = false,
ejsOptions,
onProgress,
} = splitRenderOptions(options);
const defaultOptions = {
async: true,
...ejsOptions,
};
const report = preview === true ? async () => {} : onProgress || (async () => {});
logger.debug('Rendering template:', id);
await report(0.05, 'Loading template...');
const documentTemplate = await getObject({
model: documentTemplateModel,
id,
@ -599,6 +723,12 @@ export class TemplateManager {
visited.add(String(documentTemplate._reference));
}
await report(0.1, 'Loading document data...');
const resolvedData = await this.hydratePreviewTestObject(documentTemplate, data, preview);
if (resolvedData?.error) {
return resolvedData;
}
var templateData = {};
if (documentTemplate.global == true) {
templateData = { content: contentPlaceholder };
@ -614,10 +744,11 @@ export class TemplateManager {
for (const key of defaultKeys) {
defaultValues[key] = null;
}
templateData = { ...defaultValues, ...data };
templateData = { ...defaultValues, ...resolvedData };
}
templateData.fc = this.createTemplateFc(() => templateData, defaultOptions, visited);
await report(0.16, 'Rendering template content...');
const templateContent = await ejs.render(templateContentSource, templateData, defaultOptions);
var templateWithParentContent;
@ -652,6 +783,7 @@ export class TemplateManager {
}
const parentData = { content: templateContent };
parentData.fc = this.createTemplateFc(() => parentData, defaultOptions, visited);
await report(0.22, 'Rendering parent template...');
templateWithParentContent = await ejs.render(
parentTemplate.content,
parentData,
@ -665,6 +797,7 @@ export class TemplateManager {
return { error: 'Failed to render template content.', code: 500 };
}
await report(0.28, 'Transforming template elements...');
const templateHtml = await transformCustomElements(templateWithParentContent);
if (templateHtml == null || typeof templateHtml !== 'string') {
@ -690,6 +823,7 @@ export class TemplateManager {
? null
: (documentSize.height || 0) + padding.paddingTop + padding.paddingBottom;
await report(0.32, 'Building document...');
const baseHtml = await ejs.render(
baseTemplate,
{
@ -744,18 +878,32 @@ export class TemplateManager {
async renderPDF(id, content, data = {}, options = {}) {
try {
logger.debug('Rendering PDF for template:', id);
const { onProgress, padding, ejsOptions, renderRequestId } = splitRenderOptions(options);
const report = typeof onProgress === 'function' ? onProgress : async () => {};
const templateOptions = { padding, ...ejsOptions, onProgress: report };
const renderedTemplate = await this.renderTemplate(id, content, data, 1, options, false);
const renderedTemplate = await this.renderTemplate(
id,
content,
data,
1,
templateOptions,
false
);
if (renderedTemplate.error != undefined) {
return { error: renderedTemplate.error, code: renderedTemplate.code };
}
const baseHtml = renderedTemplate.html;
await report(0.35, 'Generating PDF...');
const pdfBuffer = await generatePDF(baseHtml, {
width: renderedTemplate.width,
height: renderedTemplate.height,
infiniteHeight: renderedTemplate.infiniteHeight,
renderRequestId,
progressStart: 0.35,
progressEnd: 0.9,
});
return {
@ -773,16 +921,30 @@ export class TemplateManager {
async renderImages(id, content, data = {}, options = {}, format = 'jpeg') {
try {
logger.debug(`Rendering ${format} for template:`, id);
const { onProgress, padding, width, ejsOptions, renderRequestId } =
splitRenderOptions(options);
const report = typeof onProgress === 'function' ? onProgress : async () => {};
const { padding: _padding, ...imageOptions } = options;
const pdfResult = await this.renderPDF(id, content, data, options);
const pdfResult = await this.renderPDF(id, content, data, {
padding,
onProgress: report,
renderRequestId,
...ejsOptions,
});
if (pdfResult.error != undefined) {
return { error: pdfResult.error, code: pdfResult.code };
}
await report(0.92, `Converting PDF to ${format}...`);
const imageOptions = width ? { width } : {};
const images = await convertPDFToImage(pdfResult.pdf, imageOptions);
const converted = [];
for (const image of images) {
for (let index = 0; index < images.length; index += 1) {
const image = images[index];
await report(
0.92 + ((index + 1) / images.length) * 0.06,
`Encoding ${format} page ${index + 1} of ${images.length}...`
);
if (format === 'png') {
converted.push(await sharp(image).png().toBuffer());
} else {
@ -799,17 +961,30 @@ export class TemplateManager {
async renderSVG(id, content, data = {}, options = {}) {
try {
logger.debug('Rendering SVG for template:', id);
const { onProgress, padding, width, ejsOptions, renderRequestId } =
splitRenderOptions(options);
const report = typeof onProgress === 'function' ? onProgress : async () => {};
const { padding: _padding, ...imageOptions } = options;
const pdfResult = await this.renderPDF(id, content, data, options);
const pdfResult = await this.renderPDF(id, content, data, {
padding,
onProgress: report,
renderRequestId,
...ejsOptions,
});
if (pdfResult.error != undefined) {
return { error: pdfResult.error, code: pdfResult.code };
}
await report(0.92, 'Converting PDF to SVG...');
const imageOptions = width ? { width } : {};
const images = await convertPDFToImage(pdfResult.pdf, imageOptions);
const svgs = [];
for (const image of images) {
const png = await sharp(image).png().toBuffer();
for (let index = 0; index < images.length; index += 1) {
await report(
0.92 + ((index + 1) / images.length) * 0.06,
`Encoding SVG page ${index + 1} of ${images.length}...`
);
const png = await sharp(images[index]).png().toBuffer();
const meta = await sharp(png).metadata();
svgs.push(imageToSvg(png, meta.width, meta.height, pdfResult.width, pdfResult.height));
}
@ -822,49 +997,84 @@ export class TemplateManager {
async renderDownload(id, content, data = {}, type = 'pdf', options = {}) {
const normalizedType = normalizeDownloadType(type);
const { renderRequestId, onProgress } = splitRenderOptions(options);
const report = createRenderProgressReporter(renderRequestId, onProgress);
const renderOptions = { ...options, onProgress: report };
if (!DOWNLOAD_TYPES.includes(normalizedType)) {
return {
const result = {
error: `Unsupported download type: ${type}. Use pdf, png, svg or jpeg.`,
code: 400,
};
if (renderRequestId) {
await storeDocumentTemplateRender(renderRequestId, result);
await report(1, result.error);
}
return result;
}
await report(0, 'Starting render...');
let result;
if (normalizedType === 'pdf') {
const result = await this.renderPDF(id, content, data, options);
result = await this.renderPDF(id, content, data, renderOptions);
if (result.error) {
if (renderRequestId) {
await storeDocumentTemplateRender(renderRequestId, result);
await report(1, result.error);
}
return result;
}
return {
result = {
type: 'pdf',
mime: 'application/pdf',
extension: 'pdf',
buffers: [result.pdf],
};
}
if (normalizedType === 'svg') {
const result = await this.renderSVG(id, content, data, options);
} else if (normalizedType === 'svg') {
result = await this.renderSVG(id, content, data, renderOptions);
if (result.error) {
if (renderRequestId) {
await storeDocumentTemplateRender(renderRequestId, result);
await report(1, result.error);
}
return result;
}
return {
result = {
type: 'svg',
mime: 'image/svg+xml',
extension: 'svg',
buffers: result.svgs.map((svg) => Buffer.from(svg, 'utf8')),
};
} else {
result = await this.renderImages(id, content, data, renderOptions, normalizedType);
if (result.error) {
if (renderRequestId) {
await storeDocumentTemplateRender(renderRequestId, result);
await report(1, result.error);
}
return result;
}
result = {
type: normalizedType,
mime: normalizedType === 'png' ? 'image/png' : 'image/jpeg',
extension: normalizedType === 'png' ? 'png' : 'jpeg',
buffers: result.images,
};
}
const result = await this.renderImages(id, content, data, options, normalizedType);
if (result.error) {
return result;
if (renderRequestId) {
await report(0.99, 'Saving render...');
await storeDocumentTemplateRender(renderRequestId, {
type: result.type,
mime: result.mime,
extension: result.extension,
buffers: result.buffers.map((buffer) => Buffer.from(buffer).toString('base64')),
});
await report(1, 'Render complete!');
}
return {
type: normalizedType,
mime: normalizedType === 'png' ? 'image/png' : 'image/jpeg',
extension: normalizedType === 'png' ? 'png' : 'jpeg',
buffers: result.images,
};
return result;
}
async listObjects(objectType, filter = {}, populate = []) {
@ -911,4 +1121,9 @@ export class TemplateManager {
}
export const templateManager = new TemplateManager();
export { DOWNLOAD_TYPES, normalizeDownloadType };
export {
DOWNLOAD_TYPES,
normalizeDownloadType,
DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX,
DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS,
};