Refactor document and email message schemas to support multiple object references
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good

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.
This commit is contained in:
Tom Butcher 2026-09-14 23:32:59 +01:00
parent 9fe854e37b
commit dc0a120231
10 changed files with 231 additions and 60 deletions

View File

@ -14,8 +14,15 @@ const documentJobSchema = new Schema(
object: { object: {
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
refPath: 'objectType', refPath: 'objectType',
required: true, required: false,
}, },
objects: [
{
type: Schema.Types.ObjectId,
refPath: 'objectType',
required: false,
},
],
state: { state: {
type: { type: String, required: true, default: 'queued' }, type: { type: String, required: true, default: 'queued' },
progress: { type: Number, required: false }, progress: { type: Number, required: false },

View File

@ -13,7 +13,8 @@ const emailMessageSchema = new Schema(
required: true, required: true,
}, },
objectType: { type: String, 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: { emailAccount: {
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'emailAccount', ref: 'emailAccount',

View File

@ -21,7 +21,7 @@ const filters = [
'emailTemplate', 'emailTemplate',
'emailAccount', 'emailAccount',
'objectType', 'objectType',
'object', 'objects',
'recipientEmail', 'recipientEmail',
'recipientType', 'recipientType',
'recipient', 'recipient',

View File

@ -175,7 +175,7 @@ describe('email message creation', () => {
expect.objectContaining({ expect.objectContaining({
properties: ['state', 'emailAccount'], properties: ['state', 'emailAccount'],
filter: { read: false }, 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( expect(templateManager.renderDownload).toHaveBeenCalledWith(
'doc-1', 'doc-1',
undefined, undefined,
invoice, { ...invoice, objects: [invoice] },
'pdf' 'pdf'
); );
expect(newObject).toHaveBeenCalledWith( expect(newObject).toHaveBeenCalledWith(

View File

@ -16,6 +16,7 @@ import {
getObjectNeighbors, getObjectNeighbors,
} from '../../database/database.js'; } from '../../database/database.js';
import { createDocumentJobUserNotifier } from '../../utils.js'; import { createDocumentJobUserNotifier } from '../../utils.js';
import { relatedObjectIdsFromBody, withLegacyObjects } from '../../utils/relatedObjects.js';
const logger = log4js.getLogger('Document Jobs'); const logger = log4js.getLogger('Document Jobs');
logger.level = config.server.logLevel; 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}`); logger.debug(`List of document jobs (Page ${page}, Limit ${limit}). Count: ${result.length}`);
res.send(result); res.send(withLegacyObjects(result));
}; };
export const listDocumentJobsByPropertiesRouteHandler = async ( export const listDocumentJobsByPropertiesRouteHandler = async (
@ -72,7 +73,7 @@ export const listDocumentJobsByPropertiesRouteHandler = async (
} }
logger.debug(`List of document jobs. Count: ${result.length}`); logger.debug(`List of document jobs. Count: ${result.length}`);
res.send(result); res.send(withLegacyObjects(result));
}; };
export const getDocumentJobPropertyValuesRouteHandler = async ( export const getDocumentJobPropertyValuesRouteHandler = async (
@ -95,7 +96,7 @@ export const searchDocumentJobsRouteHandler = async (req, res, search) => {
model: documentJobModel, model: documentJobModel,
search, search,
}); });
res.send(result); res.send(withLegacyObjects(result));
}; };
export const getDocumentJobRouteHandler = async (req, res) => { export const getDocumentJobRouteHandler = async (req, res) => {
@ -103,14 +104,14 @@ export const getDocumentJobRouteHandler = async (req, res) => {
const result = await getObject({ const result = await getObject({
model: documentJobModel, model: documentJobModel,
id, id,
populate: ['documentTemplate', 'documentPrinter', 'object'], populate: ['documentTemplate', 'documentPrinter', 'objects', 'object'],
}); });
if (result?.error) { if (result?.error) {
logger.warn(`Document Job not found with supplied id.`); logger.warn(`Document Job not found with supplied id.`);
return res.status(result.code).send(result); return res.status(result.code).send(result);
} }
logger.debug(`Retreived document job with ID: ${id}`); logger.debug(`Retreived document job with ID: ${id}`);
res.send(result); res.send(withLegacyObjects(result));
}; };
export const editDocumentJobRouteHandler = async (req, res) => { export const editDocumentJobRouteHandler = async (req, res) => {
@ -129,7 +130,7 @@ export const editDocumentJobRouteHandler = async (req, res) => {
id, id,
updateData, updateData,
user: req.user, user: req.user,
populate: ['documentTemplate', 'documentPrinter', 'object'], populate: ['documentTemplate', 'documentPrinter', 'objects', 'object'],
}); });
if (result.error) { if (result.error) {
@ -140,7 +141,7 @@ export const editDocumentJobRouteHandler = async (req, res) => {
logger.debug(`Edited document job with ID: ${id}`); logger.debug(`Edited document job with ID: ${id}`);
res.send(result); res.send(withLegacyObjects(result));
}; };
export const newDocumentJobRouteHandler = async (req, res) => { export const newDocumentJobRouteHandler = async (req, res) => {
@ -150,7 +151,7 @@ export const newDocumentJobRouteHandler = async (req, res) => {
documentPrinter: req.body.documentPrinter, documentPrinter: req.body.documentPrinter,
documentTemplate: req.body.documentTemplate, documentTemplate: req.body.documentTemplate,
objectType: req.body.objectType, objectType: req.body.objectType,
object: req.body.object, objects: relatedObjectIdsFromBody(req.body),
content: req.body.content, content: req.body.content,
quantity: req.body.quantity ?? 1, quantity: req.body.quantity ?? 1,
state: { type: 'draft' }, state: { type: 'draft' },
@ -161,6 +162,7 @@ export const newDocumentJobRouteHandler = async (req, res) => {
model: documentJobModel, model: documentJobModel,
newData, newData,
user: req.user, user: req.user,
populate: ['documentTemplate', 'documentPrinter', 'objects', 'object'],
}); });
if (result.error) { if (result.error) {
logger.error('No document job created:', 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}`); logger.debug(`New document job with ID: ${result._id}`);
res.send(result); res.send(withLegacyObjects(result));
}; };
export const deleteDocumentJobRouteHandler = async (req, res) => { export const deleteDocumentJobRouteHandler = async (req, res) => {

View File

@ -27,11 +27,17 @@ import {
import { BUCKETS, downloadFile, uploadFile } from '../../database/ceph.js'; import { BUCKETS, downloadFile, uploadFile } from '../../database/ceph.js';
import { distributeNew, getFileMeta, notfiyObjectUserNotifiers } from '../../utils.js'; import { distributeNew, getFileMeta, notfiyObjectUserNotifiers } from '../../utils.js';
import { templateManager } from '../../templates/templatemanager.js'; import { templateManager } from '../../templates/templatemanager.js';
import {
relatedObjectIdsFromBody,
relatedObjectRefs,
templateDataFromObjects,
withLegacyObjects,
} from '../../utils/relatedObjects.js';
const logger = log4js.getLogger('Email Messages'); const logger = log4js.getLogger('Email Messages');
logger.level = config.server.logLevel; logger.level = config.server.logLevel;
const __dirname = path.dirname(fileURLToPath(import.meta.url)); 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; let worker;
const pending = new Map(); const pending = new Map();
@ -167,14 +173,18 @@ async function resolveEmailAttachments(emailMessage, user) {
if (!modelEntry?.model) { if (!modelEntry?.model) {
throw new Error(`Unknown object type for email attachment: ${emailMessage.objectType}`); throw new Error(`Unknown object type for email attachment: ${emailMessage.objectType}`);
} }
const objects = [];
for (const ref of relatedObjectRefs(emailMessage)) {
const object = await getObject({ const object = await getObject({
model: modelEntry.model, model: modelEntry.model,
id: emailMessage.object?._id || emailMessage.object, id: ref?._id || ref,
}); });
objects.push(object);
}
const rendered = await templateManager.renderDownload( const rendered = await templateManager.renderDownload(
templateId, templateId,
undefined, undefined,
object, templateDataFromObjects(objects),
attachment.fileType || 'pdf' attachment.fileType || 'pdf'
); );
if (rendered?.error) throw new Error(rendered.error); if (rendered?.error) throw new Error(rendered.error);
@ -265,7 +275,7 @@ export const processEmailMessage = async (id, account, user) => {
emailMessage.emailTemplate, emailMessage.emailTemplate,
undefined, undefined,
undefined, undefined,
emailMessage.object, templateDataFromObjects(relatedObjectRefs(emailMessage)),
{}, {},
false, false,
String(id) String(id)
@ -350,7 +360,7 @@ export const listEmailMessagesRouteHandler = async (
} }
logger.debug(`List of email messages (Page ${page}, Limit ${limit}). Count: ${result.length}`); logger.debug(`List of email messages (Page ${page}, Limit ${limit}). Count: ${result.length}`);
res.send(result); res.send(withLegacyObjects(result));
}; };
export const listEmailMessagesByPropertiesRouteHandler = async ( export const listEmailMessagesByPropertiesRouteHandler = async (
@ -375,7 +385,7 @@ export const listEmailMessagesByPropertiesRouteHandler = async (
} }
logger.debug(`List of email messages. Count: ${result.length}`); logger.debug(`List of email messages. Count: ${result.length}`);
res.send(result); res.send(withLegacyObjects(result));
}; };
export const getEmailMessagePropertyValuesRouteHandler = async ( export const getEmailMessagePropertyValuesRouteHandler = async (
@ -398,7 +408,7 @@ export const searchEmailMessagesRouteHandler = async (req, res, search) => {
model: emailMessageModel, model: emailMessageModel,
search, search,
}); });
res.send(result); res.send(withLegacyObjects(result));
}; };
export const getEmailMessageRouteHandler = async (req, res) => { export const getEmailMessageRouteHandler = async (req, res) => {
@ -413,7 +423,7 @@ export const getEmailMessageRouteHandler = async (req, res) => {
return res.status(result.code).send(result); return res.status(result.code).send(result);
} }
logger.debug(`Retreived email message with ID: ${id}`); logger.debug(`Retreived email message with ID: ${id}`);
res.send(result); res.send(withLegacyObjects(result));
}; };
export const newEmailMessageRouteHandler = async (req, res) => { export const newEmailMessageRouteHandler = async (req, res) => {
@ -441,7 +451,7 @@ export const newEmailMessageRouteHandler = async (req, res) => {
name: req.body.name, name: req.body.name,
emailTemplate: req.body.emailTemplate?._id ?? req.body.emailTemplate, emailTemplate: req.body.emailTemplate?._id ?? req.body.emailTemplate,
objectType: req.body.objectType, objectType: req.body.objectType,
object: req.body.object?._id ?? req.body.object, objects: relatedObjectIdsFromBody(req.body),
emailAccount: account._id, emailAccount: account._id,
recipientEmail: req.body.recipientEmail, recipientEmail: req.body.recipientEmail,
recipientType: req.body.recipientType, recipientType: req.body.recipientType,
@ -472,7 +482,7 @@ export const newEmailMessageRouteHandler = async (req, res) => {
logger.debug(`New email message with ID: ${result._id}`); logger.debug(`New email message with ID: ${result._id}`);
res.send(result); res.send(withLegacyObjects(result));
setImmediate(() => processEmailMessage(result._id, account, req.user)); 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}`); logger.debug(`Marked email message as read with ID: ${id}`);
res.send(result); res.send(withLegacyObjects(result));
}; };

View File

@ -39,7 +39,7 @@ export const EXPORT_FILTER_BY_TYPE = {
stockTransfer: ['state.type', 'postedAt'], stockTransfer: ['state.type', 'postedAt'],
stockAudit: ['auditLevel._id', 'stockLocation._id', 'state.type', 'postedAt'], stockAudit: ['auditLevel._id', 'stockLocation._id', 'state.type', 'postedAt'],
stockAuditLevel: ['name', 'tags'], stockAuditLevel: ['name', 'tags'],
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'], documentJob: ['documentTemplate', 'documentPrinter', 'objects._id', 'objectType'],
documentTemplate: ['parent._id', 'documentSize._id'], documentTemplate: ['parent._id', 'documentSize._id'],
salesOrder: ['client'], salesOrder: ['client'],
invoice: ['to._id', 'from._id', 'order._id', 'orderType'], invoice: ['to._id', 'from._id', 'order._id', 'orderType'],

View File

@ -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 () => { it('does not hydrate a saved testObject when not previewing', async () => {
getObject.mockResolvedValue({ getObject.mockResolvedValue({
documentSize: { width: 100, height: 100, infiniteHeight: false }, documentSize: { width: 100, height: 100, infiniteHeight: false },

View File

@ -500,6 +500,9 @@ function isLegacyPreviewSnapshot(data) {
if (typeof data.toHexString === 'function') { if (typeof data.toHexString === 'function') {
return false; return false;
} }
if (Array.isArray(data.objects)) {
return false;
}
if (extractObjectId(data) != null) { if (extractObjectId(data) != null) {
return false; return false;
} }
@ -529,6 +532,28 @@ function resolveNestedTemplateObject(object) {
return omitTemplateFc(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 = '<hr class="documentPageBreak">';
function buildTemplateData(documentTemplate, data = {}, fc) { function buildTemplateData(documentTemplate, data = {}, fc) {
const objectData = resolveNestedTemplateObject(data) ?? {}; const objectData = resolveNestedTemplateObject(data) ?? {};
if (documentTemplate?.global == true) { if (documentTemplate?.global == true) {
@ -860,35 +885,69 @@ export class TemplateManager {
return data; return data;
} }
const objectType = documentTemplate?.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)) { if (isLegacyPreviewSnapshot(data)) {
return data; return data;
} }
const objectId =
extractObjectId(data) || (preview ? extractObjectId(documentTemplate?.testObject) : null);
if (objectId == null) {
return data; 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) { if (model == null || model.schema == null) {
return { error: `Unknown object type: ${objectType}`, code: 400 }; return { error: `Unknown object type: ${objectType}`, code: 400 };
} }
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 }); const object = await getObject({ model, id: objectId });
if (object == null || object.error) { if (object == null || object.error) {
if (isObjectSnapshot(snapshot)) {
objects.push(snapshot);
continue;
}
return { return {
error: object?.error || 'Test object not found.', error: object?.error || 'Test object not found.',
code: object?.code || 404, code: object?.code || 404,
}; };
} }
return object; objects.push(isObjectSnapshot(snapshot) ? { ...object, ...snapshot } : object);
}
return { objects };
} }
async renderTemplate(id, content, data = {}, _scale = 1, options = {}, preview = true) { async renderTemplate(id, content, data = {}, _scale = 1, options = {}, preview = true) {
@ -945,9 +1004,12 @@ export class TemplateManager {
return resolvedData; return resolvedData;
} }
var templateData = {}; await report(0.16, 'Rendering template content...');
let templateContent;
if (documentTemplate.global == true) { 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 { } else {
const objectType = documentTemplate?.objectType; const objectType = documentTemplate?.objectType;
const modelEntry = getModelByName(objectType); const modelEntry = getModelByName(objectType);
@ -955,17 +1017,19 @@ export class TemplateManager {
if (model == null || model.schema == null) { if (model == null || model.schema == null) {
return { error: `Unknown object type: ${objectType}`, code: 400 }; return { error: `Unknown object type: ${objectType}`, code: 400 };
} }
const defaultKeys = Object.keys(model.schema.obj); const objects = getRenderObjects(resolvedData);
const defaultValues = {}; const renderedParts = [];
for (const key of defaultKeys) { for (const object of objects) {
defaultValues[key] = null; 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 templateWithParentContent;
var parentTemplate = documentTemplate.parent; var parentTemplate = documentTemplate.parent;

View File

@ -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);
}