diff --git a/src/database/schemas/management/documentjob.schema.js b/src/database/schemas/management/documentjob.schema.js index 8fbaf7c..eed28c5 100644 --- a/src/database/schemas/management/documentjob.schema.js +++ b/src/database/schemas/management/documentjob.schema.js @@ -14,8 +14,15 @@ const documentJobSchema = new Schema( object: { type: Schema.Types.ObjectId, refPath: 'objectType', - required: true, + required: false, }, + objects: [ + { + type: Schema.Types.ObjectId, + refPath: 'objectType', + required: false, + }, + ], state: { type: { type: String, required: true, default: 'queued' }, progress: { type: Number, required: false }, diff --git a/src/database/schemas/management/emailmessage.schema.js b/src/database/schemas/management/emailmessage.schema.js index ee0f7a3..5c4f066 100644 --- a/src/database/schemas/management/emailmessage.schema.js +++ b/src/database/schemas/management/emailmessage.schema.js @@ -13,7 +13,8 @@ const emailMessageSchema = new Schema( required: true, }, objectType: { type: String, required: true }, - object: { type: Schema.Types.ObjectId, refPath: 'objectType', required: true }, + object: { type: Schema.Types.ObjectId, refPath: 'objectType', required: false }, + objects: [{ type: Schema.Types.ObjectId, refPath: 'objectType', required: false }], emailAccount: { type: Schema.Types.ObjectId, ref: 'emailAccount', diff --git a/src/routes/management/emailmessages.js b/src/routes/management/emailmessages.js index f9c2b1a..46f4ad4 100644 --- a/src/routes/management/emailmessages.js +++ b/src/routes/management/emailmessages.js @@ -21,7 +21,7 @@ const filters = [ 'emailTemplate', 'emailAccount', 'objectType', - 'object', + 'objects', 'recipientEmail', 'recipientType', 'recipient', diff --git a/src/services/management/__tests__/emailmessages.test.js b/src/services/management/__tests__/emailmessages.test.js index 4df08f7..5fad49b 100644 --- a/src/services/management/__tests__/emailmessages.test.js +++ b/src/services/management/__tests__/emailmessages.test.js @@ -175,7 +175,7 @@ describe('email message creation', () => { expect.objectContaining({ properties: ['state', 'emailAccount'], filter: { read: false }, - populate: ['emailTemplate', 'emailAccount', 'object', 'recipient', 'attachments'], + populate: ['emailTemplate', 'emailAccount', 'objects', 'object', 'recipient', 'attachments'], }) ); }); @@ -267,7 +267,7 @@ describe('email message creation', () => { expect(templateManager.renderDownload).toHaveBeenCalledWith( 'doc-1', undefined, - invoice, + { ...invoice, objects: [invoice] }, 'pdf' ); expect(newObject).toHaveBeenCalledWith( diff --git a/src/services/management/documentjobs.js b/src/services/management/documentjobs.js index 0f99a95..a4fabf3 100644 --- a/src/services/management/documentjobs.js +++ b/src/services/management/documentjobs.js @@ -16,6 +16,7 @@ import { getObjectNeighbors, } from '../../database/database.js'; import { createDocumentJobUserNotifier } from '../../utils.js'; +import { relatedObjectIdsFromBody, withLegacyObjects } from '../../utils/relatedObjects.js'; const logger = log4js.getLogger('Document Jobs'); logger.level = config.server.logLevel; @@ -48,7 +49,7 @@ export const listDocumentJobsRouteHandler = async ( } logger.debug(`List of document jobs (Page ${page}, Limit ${limit}). Count: ${result.length}`); - res.send(result); + res.send(withLegacyObjects(result)); }; export const listDocumentJobsByPropertiesRouteHandler = async ( @@ -72,7 +73,7 @@ export const listDocumentJobsByPropertiesRouteHandler = async ( } logger.debug(`List of document jobs. Count: ${result.length}`); - res.send(result); + res.send(withLegacyObjects(result)); }; export const getDocumentJobPropertyValuesRouteHandler = async ( @@ -95,7 +96,7 @@ export const searchDocumentJobsRouteHandler = async (req, res, search) => { model: documentJobModel, search, }); - res.send(result); + res.send(withLegacyObjects(result)); }; export const getDocumentJobRouteHandler = async (req, res) => { @@ -103,14 +104,14 @@ export const getDocumentJobRouteHandler = async (req, res) => { const result = await getObject({ model: documentJobModel, id, - populate: ['documentTemplate', 'documentPrinter', 'object'], + populate: ['documentTemplate', 'documentPrinter', 'objects', 'object'], }); if (result?.error) { logger.warn(`Document Job not found with supplied id.`); return res.status(result.code).send(result); } logger.debug(`Retreived document job with ID: ${id}`); - res.send(result); + res.send(withLegacyObjects(result)); }; export const editDocumentJobRouteHandler = async (req, res) => { @@ -129,7 +130,7 @@ export const editDocumentJobRouteHandler = async (req, res) => { id, updateData, user: req.user, - populate: ['documentTemplate', 'documentPrinter', 'object'], + populate: ['documentTemplate', 'documentPrinter', 'objects', 'object'], }); if (result.error) { @@ -140,7 +141,7 @@ export const editDocumentJobRouteHandler = async (req, res) => { logger.debug(`Edited document job with ID: ${id}`); - res.send(result); + res.send(withLegacyObjects(result)); }; export const newDocumentJobRouteHandler = async (req, res) => { @@ -150,7 +151,7 @@ export const newDocumentJobRouteHandler = async (req, res) => { documentPrinter: req.body.documentPrinter, documentTemplate: req.body.documentTemplate, objectType: req.body.objectType, - object: req.body.object, + objects: relatedObjectIdsFromBody(req.body), content: req.body.content, quantity: req.body.quantity ?? 1, state: { type: 'draft' }, @@ -161,6 +162,7 @@ export const newDocumentJobRouteHandler = async (req, res) => { model: documentJobModel, newData, user: req.user, + populate: ['documentTemplate', 'documentPrinter', 'objects', 'object'], }); if (result.error) { logger.error('No document job created:', result.error); @@ -171,7 +173,7 @@ export const newDocumentJobRouteHandler = async (req, res) => { logger.debug(`New document job with ID: ${result._id}`); - res.send(result); + res.send(withLegacyObjects(result)); }; export const deleteDocumentJobRouteHandler = async (req, res) => { diff --git a/src/services/management/emailmessages.js b/src/services/management/emailmessages.js index 9f9c449..effaa65 100644 --- a/src/services/management/emailmessages.js +++ b/src/services/management/emailmessages.js @@ -27,11 +27,17 @@ import { import { BUCKETS, downloadFile, uploadFile } from '../../database/ceph.js'; import { distributeNew, getFileMeta, notfiyObjectUserNotifiers } from '../../utils.js'; import { templateManager } from '../../templates/templatemanager.js'; +import { + relatedObjectIdsFromBody, + relatedObjectRefs, + templateDataFromObjects, + withLegacyObjects, +} from '../../utils/relatedObjects.js'; const logger = log4js.getLogger('Email Messages'); logger.level = config.server.logLevel; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const populate = ['emailTemplate', 'emailAccount', 'object', 'recipient', 'attachments']; +const populate = ['emailTemplate', 'emailAccount', 'objects', 'object', 'recipient', 'attachments']; let worker; const pending = new Map(); @@ -167,14 +173,18 @@ async function resolveEmailAttachments(emailMessage, user) { if (!modelEntry?.model) { throw new Error(`Unknown object type for email attachment: ${emailMessage.objectType}`); } - const object = await getObject({ - model: modelEntry.model, - id: emailMessage.object?._id || emailMessage.object, - }); + const objects = []; + for (const ref of relatedObjectRefs(emailMessage)) { + const object = await getObject({ + model: modelEntry.model, + id: ref?._id || ref, + }); + objects.push(object); + } const rendered = await templateManager.renderDownload( templateId, undefined, - object, + templateDataFromObjects(objects), attachment.fileType || 'pdf' ); if (rendered?.error) throw new Error(rendered.error); @@ -265,7 +275,7 @@ export const processEmailMessage = async (id, account, user) => { emailMessage.emailTemplate, undefined, undefined, - emailMessage.object, + templateDataFromObjects(relatedObjectRefs(emailMessage)), {}, false, String(id) @@ -350,7 +360,7 @@ export const listEmailMessagesRouteHandler = async ( } logger.debug(`List of email messages (Page ${page}, Limit ${limit}). Count: ${result.length}`); - res.send(result); + res.send(withLegacyObjects(result)); }; export const listEmailMessagesByPropertiesRouteHandler = async ( @@ -375,7 +385,7 @@ export const listEmailMessagesByPropertiesRouteHandler = async ( } logger.debug(`List of email messages. Count: ${result.length}`); - res.send(result); + res.send(withLegacyObjects(result)); }; export const getEmailMessagePropertyValuesRouteHandler = async ( @@ -398,7 +408,7 @@ export const searchEmailMessagesRouteHandler = async (req, res, search) => { model: emailMessageModel, search, }); - res.send(result); + res.send(withLegacyObjects(result)); }; export const getEmailMessageRouteHandler = async (req, res) => { @@ -413,7 +423,7 @@ export const getEmailMessageRouteHandler = async (req, res) => { return res.status(result.code).send(result); } logger.debug(`Retreived email message with ID: ${id}`); - res.send(result); + res.send(withLegacyObjects(result)); }; export const newEmailMessageRouteHandler = async (req, res) => { @@ -441,7 +451,7 @@ export const newEmailMessageRouteHandler = async (req, res) => { name: req.body.name, emailTemplate: req.body.emailTemplate?._id ?? req.body.emailTemplate, objectType: req.body.objectType, - object: req.body.object?._id ?? req.body.object, + objects: relatedObjectIdsFromBody(req.body), emailAccount: account._id, recipientEmail: req.body.recipientEmail, recipientType: req.body.recipientType, @@ -472,7 +482,7 @@ export const newEmailMessageRouteHandler = async (req, res) => { logger.debug(`New email message with ID: ${result._id}`); - res.send(result); + res.send(withLegacyObjects(result)); setImmediate(() => processEmailMessage(result._id, account, req.user)); }; @@ -557,5 +567,5 @@ export const markEmailMessageAsReadRouteHandler = async (req, res) => { logger.debug(`Marked email message as read with ID: ${id}`); - res.send(result); + res.send(withLegacyObjects(result)); }; diff --git a/src/services/misc/export.js b/src/services/misc/export.js index e0b8b45..a8ef556 100644 --- a/src/services/misc/export.js +++ b/src/services/misc/export.js @@ -39,7 +39,7 @@ export const EXPORT_FILTER_BY_TYPE = { stockTransfer: ['state.type', 'postedAt'], stockAudit: ['auditLevel._id', 'stockLocation._id', 'state.type', 'postedAt'], stockAuditLevel: ['name', 'tags'], - documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'], + documentJob: ['documentTemplate', 'documentPrinter', 'objects._id', 'objectType'], documentTemplate: ['parent._id', 'documentSize._id'], salesOrder: ['client'], invoice: ['to._id', 'from._id', 'order._id', 'orderType'], diff --git a/src/templates/__tests__/templatemanager.test.js b/src/templates/__tests__/templatemanager.test.js index 63d32c3..367f20b 100644 --- a/src/templates/__tests__/templatemanager.test.js +++ b/src/templates/__tests__/templatemanager.test.js @@ -433,6 +433,50 @@ describe('TemplateManager', () => { ); }); + it('renders each object and joins them with a document page break', async () => { + const mockTemplate = { + documentSize: { width: 100, height: 100, infiniteHeight: false }, + global: false, + objectType: 'printer', + }; + + getObject.mockImplementation(async ({ id }) => { + if (id === 'temp-id') return mockTemplate; + if (id === 'printer-a') return { _id: 'printer-a', name: 'Printer A', status: 'online' }; + if (id === 'printer-b') return { _id: 'printer-b', name: 'Printer B', status: 'offline' }; + return null; + }); + + await templateManager.renderTemplate( + 'temp-id', + 'some content', + { objects: [{ _id: 'printer-a' }, { _id: 'printer-b' }] }, + 1, + {}, + true + ); + + const objectRenders = ejs.render.mock.calls.filter((call) => call[0] === 'some content'); + expect(objectRenders).toHaveLength(2); + expect(objectRenders[0][1]).toEqual( + expect.objectContaining({ + name: 'Printer A', + status: 'online', + }) + ); + expect(objectRenders[1][1]).toEqual( + expect.objectContaining({ + name: 'Printer B', + status: 'offline', + }) + ); + expect( + ejs.render.mock.calls.some((call) => + String(call[1]?.content || '').includes('class="documentPageBreak"') + ) + ).toBe(true); + }); + it('does not hydrate a saved testObject when not previewing', async () => { getObject.mockResolvedValue({ documentSize: { width: 100, height: 100, infiniteHeight: false }, diff --git a/src/templates/templatemanager.js b/src/templates/templatemanager.js index 84d29a0..5fac0eb 100644 --- a/src/templates/templatemanager.js +++ b/src/templates/templatemanager.js @@ -500,6 +500,9 @@ function isLegacyPreviewSnapshot(data) { if (typeof data.toHexString === 'function') { return false; } + if (Array.isArray(data.objects)) { + return false; + } if (extractObjectId(data) != null) { return false; } @@ -529,6 +532,28 @@ function resolveNestedTemplateObject(object) { return omitTemplateFc(object); } +function isObjectSnapshot(value) { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + if (typeof value.toHexString === 'function') { + return false; + } + return Object.keys(value).some((key) => key !== '_id' && key !== 'id' && key !== 'objects'); +} + +function getRenderObjects(resolvedData) { + if (Array.isArray(resolvedData?.objects) && resolvedData.objects.length > 0) { + return resolvedData.objects.filter(Boolean); + } + if (resolvedData != null && typeof resolvedData === 'object' && !Array.isArray(resolvedData)) { + return [resolvedData]; + } + return [{}]; +} + +const OBJECT_PAGE_BREAK = '
'; + function buildTemplateData(documentTemplate, data = {}, fc) { const objectData = resolveNestedTemplateObject(data) ?? {}; if (documentTemplate?.global == true) { @@ -860,35 +885,69 @@ export class TemplateManager { 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 === '') { + const modelEntry = objectType != null && objectType !== '' ? getModelByName(objectType) : null; + const model = modelEntry?.model || modelEntry; + + const objectIds = []; + const snapshots = []; + if (Array.isArray(data?.objects)) { + for (const item of data.objects) { + snapshots.push(item); + const id = extractObjectId(item); + if (id != null && !objectIds.includes(id)) { + objectIds.push(id); + } + } + } + + const rootId = + extractObjectId(data) || (preview ? extractObjectId(documentTemplate?.testObject) : null); + if (rootId != null && objectIds.length === 0) { + objectIds.push(rootId); + } + + if (objectIds.length === 0) { + if (snapshots.length) { + return { objects: snapshots }; + } + if (isLegacyPreviewSnapshot(data)) { + return data; + } 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, - }; + const objects = []; + for (let index = 0; index < Math.max(objectIds.length, snapshots.length); index += 1) { + const snapshot = snapshots[index]; + const objectId = extractObjectId(snapshot) || objectIds[index]; + if (isObjectSnapshot(snapshot) && objectId == null) { + objects.push(snapshot); + continue; + } + if (objectId == null) { + if (snapshot) objects.push(snapshot); + continue; + } + const object = await getObject({ model, id: objectId }); + if (object == null || object.error) { + if (isObjectSnapshot(snapshot)) { + objects.push(snapshot); + continue; + } + return { + error: object?.error || 'Test object not found.', + code: object?.code || 404, + }; + } + objects.push(isObjectSnapshot(snapshot) ? { ...object, ...snapshot } : object); } - return object; + + return { objects }; } async renderTemplate(id, content, data = {}, _scale = 1, options = {}, preview = true) { @@ -945,9 +1004,12 @@ export class TemplateManager { return resolvedData; } - var templateData = {}; + await report(0.16, 'Rendering template content...'); + let templateContent; if (documentTemplate.global == true) { - templateData = { content: contentPlaceholder }; + let templateData = { content: contentPlaceholder }; + templateData.fc = this.createTemplateFc(() => templateData, defaultOptions, visited); + templateContent = await ejs.render(templateContentSource, templateData, defaultOptions); } else { const objectType = documentTemplate?.objectType; const modelEntry = getModelByName(objectType); @@ -955,17 +1017,19 @@ export class TemplateManager { if (model == null || model.schema == null) { return { error: `Unknown object type: ${objectType}`, code: 400 }; } - const defaultKeys = Object.keys(model.schema.obj); - const defaultValues = {}; - for (const key of defaultKeys) { - defaultValues[key] = null; + const objects = getRenderObjects(resolvedData); + const renderedParts = []; + for (const object of objects) { + const objectVisited = new Set(visited); + let objectData; + const fc = this.createTemplateFc(() => objectData, defaultOptions, objectVisited); + objectData = buildTemplateData(documentTemplate, object, fc); + renderedParts.push( + await ejs.render(templateContentSource, objectData, defaultOptions) + ); } - templateData = { ...defaultValues, ...resolvedData }; + templateContent = renderedParts.join(OBJECT_PAGE_BREAK); } - templateData.fc = this.createTemplateFc(() => templateData, defaultOptions, visited); - - await report(0.16, 'Rendering template content...'); - const templateContent = await ejs.render(templateContentSource, templateData, defaultOptions); var templateWithParentContent; var parentTemplate = documentTemplate.parent; diff --git a/src/utils/relatedObjects.js b/src/utils/relatedObjects.js new file mode 100644 index 0000000..e7dfbac --- /dev/null +++ b/src/utils/relatedObjects.js @@ -0,0 +1,43 @@ +export function relatedObjectRefs(record) { + if (Array.isArray(record?.objects) && record.objects.length) { + return record.objects; + } + if (record?.object != null) { + return [record.object]; + } + return []; +} + +export function relatedObjectIdsFromBody(body) { + return relatedObjectRefs(body) + .map((item) => item?._id ?? item) + .filter((id) => id != null && id !== ''); +} + +export function templateDataFromObjects(objects) { + if (!Array.isArray(objects) || !objects.length) { + return {}; + } + const first = objects[0]; + if ( + first && + typeof first === 'object' && + !Array.isArray(first) && + typeof first.toHexString !== 'function' + ) { + return { ...first, objects }; + } + return { objects }; +} + +export function withLegacyObjects(result) { + if (result == null || result.error) return result; + const apply = (item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + if ((!Array.isArray(item.objects) || item.objects.length === 0) && item.object != null) { + return { ...item, objects: [item.object] }; + } + return item; + }; + return Array.isArray(result) ? result.map(apply) : apply(result); +}