@dynamatix/cat-shared 0.0.138 → 0.0.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,426 +1,426 @@
1
- import AuditConfigModel from '../models/audit-config.model.js';
2
- import AuditLog from '../models/audit.model.js';
3
- import ValueReferenceMap from '../models/value-reference-map.model.js';
4
- import { getContext } from '../services/request-context.service.js';
5
- import mongoose from 'mongoose';
6
- import FormConfigurationModel from '../models/form-configuration.model.js';
7
-
8
- let onAuditLogCreated = null;
9
-
10
- // Simple utility to get nested property - much cleaner approach
11
- function getNestedProperty(obj, path) {
12
- if (!obj || !path) return undefined;
13
-
14
- // Handle flattened MongoDB keys first
15
- if (obj[path] !== undefined) {
16
- return obj[path];
17
- }
18
-
19
- // Handle nested path
20
- return path.split('.').reduce((current, key) => {
21
- if (current == null) return undefined;
22
-
23
- // Try Mongoose get method first
24
- if (current.get && typeof current.get === 'function') {
25
- try {
26
- return current.get(key);
27
- } catch (e) {
28
- // Fall through to direct access
29
- }
30
- }
31
-
32
- // Direct access or _doc access for Mongoose
33
- return current[key] ?? current._doc?.[key];
34
- }, obj);
35
- }
36
-
37
- // Simple utility to check if nested property exists
38
- function hasNestedProperty(obj, path) {
39
- if (!obj || !path) return false;
40
-
41
- // Handle flattened MongoDB keys first
42
- if (obj.hasOwnProperty(path)) {
43
- return true;
44
- }
45
-
46
- // Check nested path
47
- const keys = path.split('.');
48
- let current = obj;
49
-
50
- for (const key of keys) {
51
- if (current == null) return false;
52
-
53
- // Try Mongoose get method first
54
- if (current.get && typeof current.get === 'function') {
55
- try {
56
- const value = current.get(key);
57
- if (value !== undefined) {
58
- current = value;
59
- continue;
60
- }
61
- } catch (e) {
62
- // Fall through to direct access
63
- }
64
- }
65
-
66
- // Check direct access or _doc access
67
- if (current[key] !== undefined) {
68
- current = current[key];
69
- } else if (current._doc?.[key] !== undefined) {
70
- current = current._doc[key];
71
- } else {
72
- return false;
73
- }
74
- }
75
-
76
- return true;
77
- }
78
-
79
- // Optionally, define custom resolvers here
80
- const customResolvers = {
81
- // Example:
82
- // expenditureRationale: (doc) => `Rationale for ${doc.type}: ${doc.rationale}`
83
- };
84
-
85
- // Expression-based description resolver: resolves ${fieldName} in the expression using doc, and via Lookup if needed
86
- async function resolveExpressionDescription(doc, expression, resolverType) {
87
- console.log("expression-", expression);
88
- console.log("resolverType-", resolverType);
89
- const matches = [...expression.matchAll(/\$\{([^}]+)\}/g)];
90
- let result = expression;
91
- for (const match of matches) {
92
- const fieldName = match[1];
93
- let value = getNestedProperty(doc, fieldName);
94
- if (resolverType === 'lookup' && value && mongoose.models['Lookup']) {
95
- const lookupDoc = await mongoose.models['Lookup'].findById(value).lean();
96
- value = lookupDoc?.name || value;
97
- }
98
- result = result.replace(match[0], value ?? '');
99
- }
100
- return result;
101
- }
102
-
103
- // Update resolveDescription to support expression-based descriptionField
104
- async function resolveDescription(field, doc) {
105
- const modelName = doc.constructor.modelName;
106
- const config = await ValueReferenceMap.findOne({ field, model: modelName });
107
- console.log("config-", config);
108
- if (!config) return field;
109
-
110
- if (config.descriptionField && config.descriptionField.includes('${')) {
111
- console.log("config.descriptionField", config.descriptionField);
112
- return await resolveExpressionDescription(doc, config.descriptionField, config.descriptionResolverType);
113
- }
114
- console.log("config.descriptionResolverType", config.descriptionResolverType);
115
- switch (config.descriptionResolverType) {
116
- case 'lookup': {
117
- const lookupId = getNestedProperty(doc, config.descriptionField);
118
- if (!lookupId) return '';
119
- const lookupDoc = await mongoose.models['Lookup'].findById(lookupId).lean();
120
- return lookupDoc?.name || '';
121
- }
122
- case 'direct':
123
- return getNestedProperty(doc, config.descriptionField) || '';
124
- case 'displayFieldReturn': // For case we just want to show display field value directly
125
- return config.displayField || '';
126
- case 'composite':
127
- return (config.descriptionFields || [])
128
- .map(f => getNestedProperty(doc, f))
129
- .filter(Boolean)
130
- .join(' ');
131
- case 'custom':
132
- if (typeof customResolvers?.[config.descriptionFunction] === 'function') {
133
- return await customResolvers[config.descriptionFunction](doc);
134
- }
135
- return '';
136
- default:
137
- return '';
138
- }
139
- }
140
-
141
- // Update resolveEntityDescription to support expression-based descriptionField
142
- async function resolveEntityDescription(doc, auditConfig) {
143
- if (!auditConfig.descriptionResolutorForExternalData) return '';
144
- const field = auditConfig.descriptionResolutorForExternalData;
145
- const value = getNestedProperty(doc, field);
146
- // Try to resolve via ValueReferenceMap
147
- console.log("field-", field);
148
- const map = await ValueReferenceMap.findOne({ field });
149
- console.log("map-", map);
150
- if (map?.descriptionResolverType === 'displayFieldReturn') {
151
- return map.descriptionField;
152
- }
153
- if (map && map.descriptionField && map.descriptionField.includes('${')) {
154
- return await resolveExpressionDescription(doc, map.descriptionField, map.descriptionResolverType);
155
- }
156
- if (map && value) {
157
- const Model = mongoose.models[map.model];
158
- if (Model) {
159
- const refDoc = await Model.findById(value).lean();
160
- return refDoc?.[map.displayField] || value;
161
- }
162
- }
163
- // Fallback: If it's an ObjectId and Lookup model exists, resolve to name
164
- if (mongoose.Types.ObjectId.isValid(value) && mongoose.models['Lookup']) {
165
- const lookupDoc = await mongoose.models['Lookup'].findById(value).lean();
166
- return lookupDoc?.name || value;
167
- }
168
- return value;
169
- }
170
-
171
- // Add new function to resolve deletion description
172
- async function resolveDeletionDescription(doc, auditConfig) {
173
- if (!auditConfig.descriptionResolutorForDeletion) return '';
174
- const expression = auditConfig.descriptionResolutorForDeletion;
175
- return await resolveExpressionDescription(doc, expression, 'direct');
176
- }
177
-
178
- // Utility to build a fieldName -> dataType map from multiple formConfig.sections
179
- function buildFieldTypeMapFromConfigs(formConfigs) {
180
- const map = {};
181
- function extractFields(sections) {
182
- for (const section of sections || []) {
183
- if (section.fields) {
184
- for (const f of section.fields) map[f.fieldName] = f.dataType;
185
- }
186
- if (section.sections) extractFields(section.sections);
187
- }
188
- }
189
- for (const config of formConfigs) {
190
- extractFields(config.sections);
191
- }
192
- return map;
193
- }
194
-
195
- function applyAuditMiddleware(schema, collectionName) {
196
- // Handle creation audit
197
- schema.post('save', async function (doc) {
198
- const auditConfig = await AuditConfigModel.findOne({ collectionName });
199
- if (!auditConfig?.trackCreation) return;
200
-
201
- // Fetch all form configs and build fieldTypeMap once
202
- const formConfigs = await FormConfigurationModel.find({ collectionName });
203
- const fieldTypeMap = buildFieldTypeMapFromConfigs(formConfigs);
204
-
205
- const context = getContext();
206
- const userId = context?.userId || null;
207
- const contextId = context?.contextId;
208
-
209
- const entityDescription = await resolveEntityDescription(doc, auditConfig);
210
- let logs = [];
211
-
212
- // Per-field logs
213
- if (auditConfig.fields?.length) {
214
- for (const field of auditConfig.fields) {
215
- let lookupName;
216
- const newValue = getNestedProperty(doc, field);
217
- if (field.endsWith('Lid')) {
218
- const newlookupDoc = await mongoose.models['Lookup'].findById(newValue).lean();
219
- if (newlookupDoc) {
220
- lookupName = newlookupDoc.name;
221
- } else {
222
- lookupName = '';
223
- }
224
- }
225
- const fieldDescription = await resolveDescription(field, doc);
226
- // --- Add pound prefix if needed ---
227
- let displayNewValue = lookupName || newValue;
228
- const dataType = fieldTypeMap[field];
229
- if (dataType === 'pound' && displayNewValue != null && displayNewValue !== '') {
230
- displayNewValue = `£${displayNewValue}`;
231
- }
232
- logs.push({
233
- name: fieldDescription,
234
- entity: entityDescription,
235
- recordId: contextId || doc._id,
236
- oldValue: null,
237
- newValue: displayNewValue,
238
- createdBy: userId,
239
- externalData: {
240
- description: entityDescription,
241
- contextId
242
- }
243
- });
244
- }
245
- }
246
- logs = logs.filter(log => {
247
- // Convert null/undefined to empty string for comparison
248
- const oldVal = log.oldValue ?? '';
249
- const newVal = log.newValue ?? '';
250
- return oldVal !== newVal;
251
- });
252
- if (logs.length) {
253
- await AuditLog.insertMany(logs);
254
- await updateContextAuditCount(contextId, logs.length);
255
- if (onAuditLogCreated) {
256
- await onAuditLogCreated(logs, contextId);
257
- }
258
- }
259
- });
260
-
261
- // Capture the original doc before update
262
- schema.pre(['findOneAndUpdate', 'findByIdAndUpdate'], async function (next) {
263
- this._originalDoc = await this.model.findOne(this.getQuery()).lean();
264
- next();
265
- });
266
-
267
- // Handle update audits
268
- schema.post(['findOneAndUpdate', 'findByIdAndUpdate'], async function (result) {
269
- if (!result) return;
270
-
271
- const auditConfig = await AuditConfigModel.findOne({ collectionName });
272
- if (!auditConfig?.fields?.length) return;
273
-
274
- // Fetch all form configs and build fieldTypeMap once
275
- const formConfigs = await FormConfigurationModel.find({ collectionName });
276
- const fieldTypeMap = buildFieldTypeMapFromConfigs(formConfigs);
277
-
278
- const update = this.getUpdate();
279
- let logs = [];
280
- const context = getContext();
281
- const userId = context?.userId || null;
282
- const contextId = context?.contextId;
283
-
284
- const entityDescription = await resolveEntityDescription(result, auditConfig);
285
-
286
- for (const field of auditConfig.fields) {
287
- console.log("field", field);
288
- const hasChanged =
289
- (update?.$set && hasNestedProperty(update.$set, field)) ||
290
- (update && hasNestedProperty(update, field));
291
- console.log("hasChanged", hasChanged);
292
- if (hasChanged) {
293
- const newValue = getNestedProperty(update?.$set, field) ?? getNestedProperty(update, field);
294
- const oldValue = this._originalDoc ? getNestedProperty(this._originalDoc, field) : undefined;
295
- let lookupOldName;
296
- let lookupNewName;
297
- if (field.endsWith('Lid')) {
298
- const newlookupDoc = await mongoose.models['Lookup'].findById(newValue).lean();
299
- if (newlookupDoc) {
300
- lookupNewName = newlookupDoc.name;
301
- } else {
302
- lookupNewName = '';
303
- }
304
- const oldlookupDoc = await mongoose.models['Lookup'].findById(oldValue).lean();
305
- if (oldlookupDoc) {
306
- lookupOldName = oldlookupDoc.name;
307
- } else {
308
- lookupOldName = '';
309
- }
310
- }
311
- // Convert null/undefined to empty string for comparison
312
- let displayOldValue = lookupOldName ?? oldValue ?? '';
313
- let displayNewValue = lookupNewName ?? newValue ?? '';
314
- console.log("displayOldValue", displayOldValue);
315
- console.log("displayNewValue", displayNewValue);
316
- // --- Add pound prefix if needed ---
317
- const dataType = fieldTypeMap[field];
318
- if (dataType === 'pound') {
319
- if (displayOldValue != null && displayOldValue !== '') displayOldValue = `£${displayOldValue}`;
320
- if (displayNewValue != null && displayNewValue !== '') displayNewValue = `£${displayNewValue}`;
321
- }
322
- if (displayOldValue !== displayNewValue) {
323
- const fieldDescription = await resolveDescription(field, result);
324
- console.log("fieldDescription-", fieldDescription);
325
- logs.push({
326
- name: fieldDescription,
327
- entity: entityDescription,
328
- recordId: contextId || result._id,
329
- oldValue: displayOldValue,
330
- newValue: displayNewValue,
331
- createdBy: userId,
332
- externalData: {
333
- description: entityDescription,
334
- contextId: contextId || result._id
335
- }
336
- });
337
- }
338
- }
339
- }
340
- console.log("logs", logs);
341
- logs = logs.filter(log => {
342
- // Convert null/undefined to empty string for comparison
343
- const oldVal = log.oldValue ?? '';
344
- const newVal = log.newValue ?? '';
345
- return oldVal !== newVal;
346
- });
347
- console.log("logs.length", logs.length);
348
- if (logs.length) {
349
- console.log("logs.length", logs.length);
350
- await AuditLog.insertMany(logs);
351
- await updateContextAuditCount(contextId, logs.length);
352
- if (onAuditLogCreated) {
353
- await onAuditLogCreated(logs, contextId);
354
- }
355
- }
356
- });
357
-
358
- // Handle delete audits
359
- schema.pre(['findOneAndDelete', 'findByIdAndDelete', 'deleteOne', 'deleteMany'], async function (next) {
360
- try {
361
- // Fetch the document before deletion
362
- const docToDelete = await this.model.findOne(this.getQuery()).lean();
363
- if (docToDelete) {
364
- // Store the document for use in post middleware
365
- this._docToDelete = docToDelete;
366
- }
367
- next();
368
- } catch (error) {
369
- next(error);
370
- }
371
- });
372
-
373
- schema.post(['findOneAndDelete', 'findByIdAndDelete', 'deleteOne', 'deleteMany'], async function (result) {
374
- if (!result) return;
375
-
376
- const auditConfig = await AuditConfigModel.findOne({ collectionName });
377
- if (!auditConfig?.trackDeletion) return;
378
-
379
- const context = getContext();
380
- const userId = context?.userId || null;
381
- const contextId = context?.contextId;
382
-
383
- const entityDescription = await resolveEntityDescription(this._docToDelete, auditConfig);
384
- const deletionDescription = await resolveDeletionDescription(this._docToDelete, auditConfig);
385
-
386
- const log = {
387
- name: deletionDescription || 'Entity Deletion',
388
- entity: entityDescription,
389
- recordId: contextId || this._docToDelete._id,
390
- oldValue: '',
391
- newValue: 'Deleted',
392
- createdBy: userId,
393
- externalData: {
394
- description: entityDescription,
395
- contextId: contextId || this._docToDelete._id
396
- }
397
- };
398
- const oldVal = log.oldValue ?? '';
399
- const newVal = log.newValue ?? '';
400
- if (oldVal === newVal) {
401
- return;
402
- }
403
- await AuditLog.create(log);
404
- await updateContextAuditCount(contextId, 1);
405
- if (onAuditLogCreated) {
406
- await onAuditLogCreated([log], contextId);
407
- }
408
- });
409
- }
410
-
411
- async function updateContextAuditCount(contextId, count) {
412
- count = Number(count);
413
- if (!contextId || isNaN(count)) return;
414
- const model = mongoose.models['Application'];
415
- await model.findByIdAndUpdate(
416
- { _id: contextId },
417
- { $inc: { newAuditRecordsCount: count } },
418
- { new: true, upsert: true }
419
- );
420
- }
421
-
422
- export function registerAuditHook(callback) {
423
- onAuditLogCreated = callback;
424
- }
425
-
1
+ import AuditConfigModel from '../models/audit-config.model.js';
2
+ import AuditLog from '../models/audit.model.js';
3
+ import ValueReferenceMap from '../models/value-reference-map.model.js';
4
+ import { getContext } from '../services/request-context.service.js';
5
+ import mongoose from 'mongoose';
6
+ import FormConfigurationModel from '../models/form-configuration.model.js';
7
+
8
+ let onAuditLogCreated = null;
9
+
10
+ // Simple utility to get nested property - much cleaner approach
11
+ function getNestedProperty(obj, path) {
12
+ if (!obj || !path) return undefined;
13
+
14
+ // Handle flattened MongoDB keys first
15
+ if (obj[path] !== undefined) {
16
+ return obj[path];
17
+ }
18
+
19
+ // Handle nested path
20
+ return path.split('.').reduce((current, key) => {
21
+ if (current == null) return undefined;
22
+
23
+ // Try Mongoose get method first
24
+ if (current.get && typeof current.get === 'function') {
25
+ try {
26
+ return current.get(key);
27
+ } catch (e) {
28
+ // Fall through to direct access
29
+ }
30
+ }
31
+
32
+ // Direct access or _doc access for Mongoose
33
+ return current[key] ?? current._doc?.[key];
34
+ }, obj);
35
+ }
36
+
37
+ // Simple utility to check if nested property exists
38
+ function hasNestedProperty(obj, path) {
39
+ if (!obj || !path) return false;
40
+
41
+ // Handle flattened MongoDB keys first
42
+ if (obj.hasOwnProperty(path)) {
43
+ return true;
44
+ }
45
+
46
+ // Check nested path
47
+ const keys = path.split('.');
48
+ let current = obj;
49
+
50
+ for (const key of keys) {
51
+ if (current == null) return false;
52
+
53
+ // Try Mongoose get method first
54
+ if (current.get && typeof current.get === 'function') {
55
+ try {
56
+ const value = current.get(key);
57
+ if (value !== undefined) {
58
+ current = value;
59
+ continue;
60
+ }
61
+ } catch (e) {
62
+ // Fall through to direct access
63
+ }
64
+ }
65
+
66
+ // Check direct access or _doc access
67
+ if (current[key] !== undefined) {
68
+ current = current[key];
69
+ } else if (current._doc?.[key] !== undefined) {
70
+ current = current._doc[key];
71
+ } else {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ return true;
77
+ }
78
+
79
+ // Optionally, define custom resolvers here
80
+ const customResolvers = {
81
+ // Example:
82
+ // expenditureRationale: (doc) => `Rationale for ${doc.type}: ${doc.rationale}`
83
+ };
84
+
85
+ // Expression-based description resolver: resolves ${fieldName} in the expression using doc, and via Lookup if needed
86
+ async function resolveExpressionDescription(doc, expression, resolverType) {
87
+ console.log("expression-", expression);
88
+ console.log("resolverType-", resolverType);
89
+ const matches = [...expression.matchAll(/\$\{([^}]+)\}/g)];
90
+ let result = expression;
91
+ for (const match of matches) {
92
+ const fieldName = match[1];
93
+ let value = getNestedProperty(doc, fieldName);
94
+ if (resolverType === 'lookup' && value && mongoose.models['Lookup']) {
95
+ const lookupDoc = await mongoose.models['Lookup'].findById(value).lean();
96
+ value = lookupDoc?.name || value;
97
+ }
98
+ result = result.replace(match[0], value ?? '');
99
+ }
100
+ return result;
101
+ }
102
+
103
+ // Update resolveDescription to support expression-based descriptionField
104
+ async function resolveDescription(field, doc) {
105
+ const modelName = doc.constructor.modelName;
106
+ const config = await ValueReferenceMap.findOne({ field, model: modelName });
107
+ console.log("config-", config);
108
+ if (!config) return field;
109
+
110
+ if (config.descriptionField && config.descriptionField.includes('${')) {
111
+ console.log("config.descriptionField", config.descriptionField);
112
+ return await resolveExpressionDescription(doc, config.descriptionField, config.descriptionResolverType);
113
+ }
114
+ console.log("config.descriptionResolverType", config.descriptionResolverType);
115
+ switch (config.descriptionResolverType) {
116
+ case 'lookup': {
117
+ const lookupId = getNestedProperty(doc, config.descriptionField);
118
+ if (!lookupId) return '';
119
+ const lookupDoc = await mongoose.models['Lookup'].findById(lookupId).lean();
120
+ return lookupDoc?.name || '';
121
+ }
122
+ case 'direct':
123
+ return getNestedProperty(doc, config.descriptionField) || '';
124
+ case 'displayFieldReturn': // For case we just want to show display field value directly
125
+ return config.displayField || '';
126
+ case 'composite':
127
+ return (config.descriptionFields || [])
128
+ .map(f => getNestedProperty(doc, f))
129
+ .filter(Boolean)
130
+ .join(' ');
131
+ case 'custom':
132
+ if (typeof customResolvers?.[config.descriptionFunction] === 'function') {
133
+ return await customResolvers[config.descriptionFunction](doc);
134
+ }
135
+ return '';
136
+ default:
137
+ return '';
138
+ }
139
+ }
140
+
141
+ // Update resolveEntityDescription to support expression-based descriptionField
142
+ async function resolveEntityDescription(doc, auditConfig) {
143
+ if (!auditConfig.descriptionResolutorForExternalData) return '';
144
+ const field = auditConfig.descriptionResolutorForExternalData;
145
+ const value = getNestedProperty(doc, field);
146
+ // Try to resolve via ValueReferenceMap
147
+ console.log("field-", field);
148
+ const map = await ValueReferenceMap.findOne({ field });
149
+ console.log("map-", map);
150
+ if (map?.descriptionResolverType === 'displayFieldReturn') {
151
+ return map.descriptionField;
152
+ }
153
+ if (map && map.descriptionField && map.descriptionField.includes('${')) {
154
+ return await resolveExpressionDescription(doc, map.descriptionField, map.descriptionResolverType);
155
+ }
156
+ if (map && value) {
157
+ const Model = mongoose.models[map.model];
158
+ if (Model) {
159
+ const refDoc = await Model.findById(value).lean();
160
+ return refDoc?.[map.displayField] || value;
161
+ }
162
+ }
163
+ // Fallback: If it's an ObjectId and Lookup model exists, resolve to name
164
+ if (mongoose.Types.ObjectId.isValid(value) && mongoose.models['Lookup']) {
165
+ const lookupDoc = await mongoose.models['Lookup'].findById(value).lean();
166
+ return lookupDoc?.name || value;
167
+ }
168
+ return value;
169
+ }
170
+
171
+ // Add new function to resolve deletion description
172
+ async function resolveDeletionDescription(doc, auditConfig) {
173
+ if (!auditConfig.descriptionResolutorForDeletion) return '';
174
+ const expression = auditConfig.descriptionResolutorForDeletion;
175
+ return await resolveExpressionDescription(doc, expression, 'direct');
176
+ }
177
+
178
+ // Utility to build a fieldName -> dataType map from multiple formConfig.sections
179
+ function buildFieldTypeMapFromConfigs(formConfigs) {
180
+ const map = {};
181
+ function extractFields(sections) {
182
+ for (const section of sections || []) {
183
+ if (section.fields) {
184
+ for (const f of section.fields) map[f.fieldName] = f.dataType;
185
+ }
186
+ if (section.sections) extractFields(section.sections);
187
+ }
188
+ }
189
+ for (const config of formConfigs) {
190
+ extractFields(config.sections);
191
+ }
192
+ return map;
193
+ }
194
+
195
+ function applyAuditMiddleware(schema, collectionName) {
196
+ // Handle creation audit
197
+ schema.post('save', async function (doc) {
198
+ const auditConfig = await AuditConfigModel.findOne({ collectionName });
199
+ if (!auditConfig?.trackCreation) return;
200
+
201
+ // Fetch all form configs and build fieldTypeMap once
202
+ const formConfigs = await FormConfigurationModel.find({ collectionName });
203
+ const fieldTypeMap = buildFieldTypeMapFromConfigs(formConfigs);
204
+
205
+ const context = getContext();
206
+ const userId = context?.userId || null;
207
+ const contextId = context?.contextId;
208
+
209
+ const entityDescription = await resolveEntityDescription(doc, auditConfig);
210
+ let logs = [];
211
+
212
+ // Per-field logs
213
+ if (auditConfig.fields?.length) {
214
+ for (const field of auditConfig.fields) {
215
+ let lookupName;
216
+ const newValue = getNestedProperty(doc, field);
217
+ if (field.endsWith('Lid')) {
218
+ const newlookupDoc = await mongoose.models['Lookup'].findById(newValue).lean();
219
+ if (newlookupDoc) {
220
+ lookupName = newlookupDoc.name;
221
+ } else {
222
+ lookupName = '';
223
+ }
224
+ }
225
+ const fieldDescription = await resolveDescription(field, doc);
226
+ // --- Add pound prefix if needed ---
227
+ let displayNewValue = lookupName || newValue;
228
+ const dataType = fieldTypeMap[field];
229
+ if (dataType === 'pound' && displayNewValue != null && displayNewValue !== '') {
230
+ displayNewValue = `£${displayNewValue}`;
231
+ }
232
+ logs.push({
233
+ name: fieldDescription,
234
+ entity: entityDescription,
235
+ recordId: contextId || doc._id,
236
+ oldValue: null,
237
+ newValue: displayNewValue,
238
+ createdBy: userId,
239
+ externalData: {
240
+ description: entityDescription,
241
+ contextId
242
+ }
243
+ });
244
+ }
245
+ }
246
+ logs = logs.filter(log => {
247
+ // Convert null/undefined to empty string for comparison
248
+ const oldVal = log.oldValue ?? '';
249
+ const newVal = log.newValue ?? '';
250
+ return oldVal !== newVal;
251
+ });
252
+ if (logs.length) {
253
+ await AuditLog.insertMany(logs);
254
+ await updateContextAuditCount(contextId, logs.length);
255
+ if (onAuditLogCreated) {
256
+ await onAuditLogCreated(logs, contextId);
257
+ }
258
+ }
259
+ });
260
+
261
+ // Capture the original doc before update
262
+ schema.pre(['findOneAndUpdate', 'findByIdAndUpdate'], async function (next) {
263
+ this._originalDoc = await this.model.findOne(this.getQuery()).lean();
264
+ next();
265
+ });
266
+
267
+ // Handle update audits
268
+ schema.post(['findOneAndUpdate', 'findByIdAndUpdate'], async function (result) {
269
+ if (!result) return;
270
+
271
+ const auditConfig = await AuditConfigModel.findOne({ collectionName });
272
+ if (!auditConfig?.fields?.length) return;
273
+
274
+ // Fetch all form configs and build fieldTypeMap once
275
+ const formConfigs = await FormConfigurationModel.find({ collectionName });
276
+ const fieldTypeMap = buildFieldTypeMapFromConfigs(formConfigs);
277
+
278
+ const update = this.getUpdate();
279
+ let logs = [];
280
+ const context = getContext();
281
+ const userId = context?.userId || null;
282
+ const contextId = context?.contextId;
283
+
284
+ const entityDescription = await resolveEntityDescription(result, auditConfig);
285
+
286
+ for (const field of auditConfig.fields) {
287
+ console.log("field", field);
288
+ const hasChanged =
289
+ (update?.$set && hasNestedProperty(update.$set, field)) ||
290
+ (update && hasNestedProperty(update, field));
291
+ console.log("hasChanged", hasChanged);
292
+ if (hasChanged) {
293
+ const newValue = getNestedProperty(update?.$set, field) ?? getNestedProperty(update, field);
294
+ const oldValue = this._originalDoc ? getNestedProperty(this._originalDoc, field) : undefined;
295
+ let lookupOldName;
296
+ let lookupNewName;
297
+ if (field.endsWith('Lid')) {
298
+ const newlookupDoc = await mongoose.models['Lookup'].findById(newValue).lean();
299
+ if (newlookupDoc) {
300
+ lookupNewName = newlookupDoc.name;
301
+ } else {
302
+ lookupNewName = '';
303
+ }
304
+ const oldlookupDoc = await mongoose.models['Lookup'].findById(oldValue).lean();
305
+ if (oldlookupDoc) {
306
+ lookupOldName = oldlookupDoc.name;
307
+ } else {
308
+ lookupOldName = '';
309
+ }
310
+ }
311
+ // Convert null/undefined to empty string for comparison
312
+ let displayOldValue = lookupOldName ?? oldValue ?? '';
313
+ let displayNewValue = lookupNewName ?? newValue ?? '';
314
+ console.log("displayOldValue", displayOldValue);
315
+ console.log("displayNewValue", displayNewValue);
316
+ // --- Add pound prefix if needed ---
317
+ const dataType = fieldTypeMap[field];
318
+ if (dataType === 'pound') {
319
+ if (displayOldValue != null && displayOldValue !== '') displayOldValue = `£${displayOldValue}`;
320
+ if (displayNewValue != null && displayNewValue !== '') displayNewValue = `£${displayNewValue}`;
321
+ }
322
+ if (displayOldValue !== displayNewValue) {
323
+ const fieldDescription = await resolveDescription(field, result);
324
+ console.log("fieldDescription-", fieldDescription);
325
+ logs.push({
326
+ name: fieldDescription,
327
+ entity: entityDescription,
328
+ recordId: contextId || result._id,
329
+ oldValue: displayOldValue,
330
+ newValue: displayNewValue,
331
+ createdBy: userId,
332
+ externalData: {
333
+ description: entityDescription,
334
+ contextId: contextId || result._id
335
+ }
336
+ });
337
+ }
338
+ }
339
+ }
340
+ console.log("logs", logs);
341
+ logs = logs.filter(log => {
342
+ // Convert null/undefined to empty string for comparison
343
+ const oldVal = log.oldValue ?? '';
344
+ const newVal = log.newValue ?? '';
345
+ return oldVal !== newVal;
346
+ });
347
+ console.log("logs.length", logs.length);
348
+ if (logs.length) {
349
+ console.log("logs.length", logs.length);
350
+ await AuditLog.insertMany(logs);
351
+ await updateContextAuditCount(contextId, logs.length);
352
+ if (onAuditLogCreated) {
353
+ await onAuditLogCreated(logs, contextId);
354
+ }
355
+ }
356
+ });
357
+
358
+ // Handle delete audits
359
+ schema.pre(['findOneAndDelete', 'findByIdAndDelete', 'deleteOne', 'deleteMany'], async function (next) {
360
+ try {
361
+ // Fetch the document before deletion
362
+ const docToDelete = await this.model.findOne(this.getQuery()).lean();
363
+ if (docToDelete) {
364
+ // Store the document for use in post middleware
365
+ this._docToDelete = docToDelete;
366
+ }
367
+ next();
368
+ } catch (error) {
369
+ next(error);
370
+ }
371
+ });
372
+
373
+ schema.post(['findOneAndDelete', 'findByIdAndDelete', 'deleteOne', 'deleteMany'], async function (result) {
374
+ if (!result) return;
375
+
376
+ const auditConfig = await AuditConfigModel.findOne({ collectionName });
377
+ if (!auditConfig?.trackDeletion) return;
378
+
379
+ const context = getContext();
380
+ const userId = context?.userId || null;
381
+ const contextId = context?.contextId;
382
+
383
+ const entityDescription = await resolveEntityDescription(this._docToDelete, auditConfig);
384
+ const deletionDescription = await resolveDeletionDescription(this._docToDelete, auditConfig);
385
+
386
+ const log = {
387
+ name: deletionDescription || 'Entity Deletion',
388
+ entity: entityDescription,
389
+ recordId: contextId || this._docToDelete._id,
390
+ oldValue: '',
391
+ newValue: 'Deleted',
392
+ createdBy: userId,
393
+ externalData: {
394
+ description: entityDescription,
395
+ contextId: contextId || this._docToDelete._id
396
+ }
397
+ };
398
+ const oldVal = log.oldValue ?? '';
399
+ const newVal = log.newValue ?? '';
400
+ if (oldVal === newVal) {
401
+ return;
402
+ }
403
+ await AuditLog.create(log);
404
+ await updateContextAuditCount(contextId, 1);
405
+ if (onAuditLogCreated) {
406
+ await onAuditLogCreated([log], contextId);
407
+ }
408
+ });
409
+ }
410
+
411
+ async function updateContextAuditCount(contextId, count) {
412
+ count = Number(count);
413
+ if (!contextId || isNaN(count)) return;
414
+ const model = mongoose.models['Application'];
415
+ await model.findByIdAndUpdate(
416
+ { _id: contextId },
417
+ { $inc: { newAuditRecordsCount: count } },
418
+ { new: true, upsert: true }
419
+ );
420
+ }
421
+
422
+ export function registerAuditHook(callback) {
423
+ onAuditLogCreated = callback;
424
+ }
425
+
426
426
  export default applyAuditMiddleware;