From dc0a1202317b327e3a24435c67cfdbb450d8080a Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 14 Sep 2026 23:32:59 +0100 Subject: [PATCH] Refactor document and email message schemas to support multiple object references This commit updates the documentJob and emailMessage schemas to allow for multiple object references by introducing an 'objects' array and making the 'object' field optional. Additionally, it modifies various route handlers and service functions to accommodate these changes, ensuring consistent handling of object references throughout the application. The updates enhance the flexibility of document and email message management, allowing for more complex relationships between entities. --- .../schemas/management/documentjob.schema.js | 9 +- .../schemas/management/emailmessage.schema.js | 3 +- src/routes/management/emailmessages.js | 2 +- .../__tests__/emailmessages.test.js | 4 +- src/services/management/documentjobs.js | 20 +-- src/services/management/emailmessages.js | 38 ++++-- src/services/misc/export.js | 2 +- .../__tests__/templatemanager.test.js | 44 ++++++ src/templates/templatemanager.js | 126 +++++++++++++----- src/utils/relatedObjects.js | 43 ++++++ 10 files changed, 231 insertions(+), 60 deletions(-) create mode 100644 src/utils/relatedObjects.js 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); +}