Refactor document and email message schemas to support multiple object references
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
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:
parent
9fe854e37b
commit
dc0a120231
@ -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 },
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -21,7 +21,7 @@ const filters = [
|
||||
'emailTemplate',
|
||||
'emailAccount',
|
||||
'objectType',
|
||||
'object',
|
||||
'objects',
|
||||
'recipientEmail',
|
||||
'recipientType',
|
||||
'recipient',
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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));
|
||||
};
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 = '<hr class="documentPageBreak">';
|
||||
|
||||
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;
|
||||
|
||||
43
src/utils/relatedObjects.js
Normal file
43
src/utils/relatedObjects.js
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user