diff --git a/src/database/schemas/management/documenttemplate.schema.js b/src/database/schemas/management/documenttemplate.schema.js
index b3fabe0..4b0c6b9 100644
--- a/src/database/schemas/management/documenttemplate.schema.js
+++ b/src/database/schemas/management/documenttemplate.schema.js
@@ -45,7 +45,8 @@ const documentTemplateSchema = new Schema(
default: '',
},
testObject: {
- type: Schema.Types.Mixed,
+ type: Schema.Types.ObjectId,
+ refPath: 'objectType',
required: false,
},
},
diff --git a/src/events/__tests__/eventmanager.test.js b/src/events/__tests__/eventmanager.test.js
new file mode 100644
index 0000000..d25aa7d
--- /dev/null
+++ b/src/events/__tests__/eventmanager.test.js
@@ -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);
+ });
+ });
+});
diff --git a/src/events/eventmanager.js b/src/events/eventmanager.js
new file mode 100644
index 0000000..73af5b4
--- /dev/null
+++ b/src/events/eventmanager.js
@@ -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();
diff --git a/src/services/management/__tests__/documenttemplates.test.js b/src/services/management/__tests__/documenttemplates.test.js
index 1e7b567..1d5b793 100644
--- a/src/services/management/__tests__/documenttemplates.test.js
+++ b/src/services/management/__tests__/documenttemplates.test.js
@@ -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);
+ });
});
});
diff --git a/src/services/management/documenttemplates.js b/src/services/management/documenttemplates.js
index fde625a..2e37971 100644
--- a/src/services/management/documenttemplates.js
+++ b/src/services/management/documenttemplates.js
@@ -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);
};
diff --git a/src/templates/__tests__/pdffactory.test.js b/src/templates/__tests__/pdffactory.test.js
index 86ff120..07168c6 100644
--- a/src/templates/__tests__/pdffactory.test.js
+++ b/src/templates/__tests__/pdffactory.test.js
@@ -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('', { 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('', {
+ 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);
diff --git a/src/templates/__tests__/templatemanager.test.js b/src/templates/__tests__/templatemanager.test.js
index 76b6207..69d2a05 100644
--- a/src/templates/__tests__/templatemanager.test.js
+++ b/src/templates/__tests__/templatemanager.test.js
@@ -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', () => {
diff --git a/src/templates/pdffactory.js b/src/templates/pdffactory.js
index cc27ba1..fb00c94 100644
--- a/src/templates/pdffactory.js
+++ b/src/templates/pdffactory.js
@@ -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} 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);
diff --git a/src/templates/templatemanager.js b/src/templates/templatemanager.js
index d369096..49a146f 100644
--- a/src/templates/templatemanager.js
+++ b/src/templates/templatemanager.js
@@ -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,
+};