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, +};