diff --git a/src/database/database.js b/src/database/database.js index 45b86b4..c8acb70 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -340,7 +340,8 @@ export const aggregateRollups = async ({ }, {}); }; -// Reusable function to aggregate rollups over history using audit logs +// Snapshot absolute rollup values at each point in time by reconstructing +// object state from the current documents plus audit logs. export const aggregateRollupsHistory = async ({ model, baseFilter = {}, @@ -349,74 +350,146 @@ export const aggregateRollupsHistory = async ({ endDate }) => { if (!rollupConfigs.length) { - return {}; + return []; } - // Helper to map filter keys to audit log structure - const mapFilterToAudit = filter => { - if (Array.isArray(filter)) { - return filter.map(mapFilterToAudit); - } + const end = endDate ? new Date(endDate) : new Date(); + const start = startDate + ? new Date(startDate) + : new Date(end.getTime() - 24 * 60 * 60 * 1000); + const parentType = model.modelName ? model.modelName : 'unknown'; - if (_.isPlainObject(filter)) { - const newFilter = {}; - for (const key in filter) { - if (['$or', '$and', '$nor', '$in', '$nin'].includes(key)) { - newFilter[key] = mapFilterToAudit(filter[key]); - } else if (key.startsWith('$')) { - newFilter[key] = mapFilterToAudit(filter[key]); - } else if ( - [ - 'operation', - 'parent', - 'parentType', - 'owner', - 'ownerType', - 'createdAt', - 'updatedAt', - '_id', - '_reference', - 'changes' - ].includes(key) - ) { - newFilter[key] = mapFilterToAudit(filter[key]); - } else { - newFilter[`changes.new.${key}`] = mapFilterToAudit(filter[key]); - } + const matchesFilter = (obj, filter) => { + if (!filter || Object.keys(filter).length === 0) return true; + + for (const [key, expectedValue] of Object.entries(filter)) { + const actualValue = _.get(obj, key); + if (actualValue != expectedValue) { + return false; } - return newFilter; } - - return filter; + return true; }; - const matchQuery = { - parentType: model.modelName, - ...mapFilterToAudit(baseFilter) + const existedAt = (obj, bucketDate) => { + if (!obj?.createdAt) return true; + return new Date(obj.createdAt) <= bucketDate; }; - if (startDate || endDate) { - matchQuery.createdAt = {}; - if (startDate) matchQuery.createdAt.$gte = new Date(startDate); - if (endDate) matchQuery.createdAt.$lte = new Date(endDate); - if (Object.keys(matchQuery.createdAt).length === 0) - delete matchQuery.createdAt; + const snapshotRollups = (objects, bucketDate) => { + const bucketResult = { + date: bucketDate.toISOString() + }; + + rollupConfigs.forEach(config => { + const matchingObjects = objects.filter( + obj => + existedAt(obj, bucketDate) && + matchesFilter(obj, baseFilter) && + matchesFilter(obj, config.filter) + ); + + (config.rollups || []).forEach(rollup => { + let value = 0; + if (rollup.operation === 'count') { + value = matchingObjects.length; + } else if (rollup.operation === 'sum') { + value = _.sumBy(matchingObjects, obj => _.get(obj, rollup.property) || 0); + } else if (rollup.operation === 'avg') { + const sum = _.sumBy(matchingObjects, obj => _.get(obj, rollup.property) || 0); + value = matchingObjects.length ? sum / matchingObjects.length : 0; + } + + bucketResult[rollup.name] = { [rollup.operation]: value }; + }); + }); + + return bucketResult; + }; + + const auditLogs = await auditLogModel + .find({ + parentType, + createdAt: { $gte: start } + }) + .sort({ createdAt: -1 }) + .lean(); + + const currentObjects = await model.find(baseFilter).lean(); + const objectMap = new Map(); + currentObjects.forEach(obj => { + objectMap.set(obj._id.toString(), expandObjectIds(obj)); + }); + + const extraIds = [ + ...new Set( + auditLogs + .map(log => log.parent?.toString()) + .filter(id => id && !objectMap.has(id)) + ) + ]; + if (extraIds.length) { + const extraObjects = await model.find({ _id: { $in: extraIds } }).lean(); + extraObjects.forEach(obj => { + objectMap.set(obj._id.toString(), expandObjectIds(obj)); + }); } - const mappedRollupConfigs = rollupConfigs.map(config => ({ - ...config, - filter: config.filter ? mapFilterToAudit(config.filter) : undefined, - rollups: (config.rollups || []).map(rollup => ({ - ...rollup, - property: `changes.new.${rollup.property}` - })) - })); + if (objectMap.size === 0 && auditLogs.length === 0) { + return []; + } - return await aggregateRollups({ - model: auditLogModel, - baseFilter: matchQuery, - rollupConfigs: mappedRollupConfigs - }); + const buckets = []; + let currentTime = new Date(end); + currentTime.setSeconds(0, 0); + + while (currentTime >= start) { + buckets.push(new Date(currentTime)); + currentTime = new Date(currentTime.getTime() - 60000); + } + + if (!buckets.length) { + return []; + } + + const workingObjects = new Map(); + objectMap.forEach((val, key) => workingObjects.set(key, _.cloneDeep(val))); + + const results = []; + let logIndex = 0; + + for (const bucketDate of buckets) { + while (logIndex < auditLogs.length) { + const log = auditLogs[logIndex]; + const logDate = new Date(log.createdAt); + + if (logDate <= bucketDate) { + break; + } + + const objectId = log.parent.toString(); + const object = workingObjects.get(objectId); + + if (log.operation === 'new') { + workingObjects.delete(objectId); + } else if (log.operation === 'delete' && log.changes?.old) { + if (!workingObjects.has(objectId)) { + workingObjects.set( + objectId, + expandObjectIds({ ...log.changes.old, _id: log.parent }) + ); + } + } else if (object && log.changes?.old) { + mergeObjectUpdates(object, log.changes.old); + } + + logIndex++; + } + + results.push(snapshotRollups(Array.from(workingObjects.values()), bucketDate)); + } + + return results.reverse(); }; export const getModelStats = async ({ model }) => { diff --git a/src/database/schemas/misc/usersettings.schema.js b/src/database/schemas/misc/usersettings.schema.js index 8a5faa5..17e8df9 100644 --- a/src/database/schemas/misc/usersettings.schema.js +++ b/src/database/schemas/misc/usersettings.schema.js @@ -23,6 +23,7 @@ const userSettingsSchema = new mongoose.Schema({ columnVisibility: { type: Schema.Types.Mixed, default: () => ({}) }, collapseState: { type: Schema.Types.Mixed, default: () => ({}) }, }, + pageLayout: { type: Schema.Types.Mixed, default: () => ({}) }, createdAt: { type: Date, diff --git a/src/usersettings/usersettingsmanager.js b/src/usersettings/usersettingsmanager.js index 7354155..6f8bf44 100644 --- a/src/usersettings/usersettingsmanager.js +++ b/src/usersettings/usersettingsmanager.js @@ -1,6 +1,6 @@ import { userSettingsModel } from '../database/schemas/misc/usersettings.schema.js'; -const SETTINGS_CATEGORIES = [ +const DEFAULTS_CATEGORIES = [ 'viewMode', 'filterSidebarVisibility', 'sortSidebarVisibility', @@ -8,12 +8,17 @@ const SETTINGS_CATEGORIES = [ 'collapseState' ]; +const TOP_LEVEL_CATEGORIES = ['pageLayout']; + +const SETTINGS_CATEGORIES = [...DEFAULTS_CATEGORIES, ...TOP_LEVEL_CATEGORIES]; + const createEmptySettings = () => ({ viewMode: {}, filterSidebarVisibility: {}, sortSidebarVisibility: {}, columnVisibility: {}, - collapseState: {} + collapseState: {}, + pageLayout: {} }); const normalizeCategory = category => @@ -21,13 +26,19 @@ const normalizeCategory = category => ? category : {}; -const normalizeSettings = (defaults = {}) => ({ - viewMode: normalizeCategory(defaults?.viewMode), - filterSidebarVisibility: normalizeCategory(defaults?.filterSidebarVisibility), - sortSidebarVisibility: normalizeCategory(defaults?.sortSidebarVisibility), - columnVisibility: normalizeCategory(defaults?.columnVisibility), - collapseState: normalizeCategory(defaults?.collapseState) -}); +const normalizeSettings = (userSettings = {}) => { + const defaults = userSettings?.defaults ?? {}; + return { + viewMode: normalizeCategory(defaults?.viewMode), + filterSidebarVisibility: normalizeCategory( + defaults?.filterSidebarVisibility + ), + sortSidebarVisibility: normalizeCategory(defaults?.sortSidebarVisibility), + columnVisibility: normalizeCategory(defaults?.columnVisibility), + collapseState: normalizeCategory(defaults?.collapseState), + pageLayout: normalizeCategory(userSettings?.pageLayout) + }; +}; const isSafeSettingsKey = key => typeof key === 'string' && @@ -37,6 +48,11 @@ const isSafeSettingsKey = key => !key.includes('$') && !key.includes('\0'); +const getCategoryPath = (category, key) => + TOP_LEVEL_CATEGORIES.includes(category) + ? `${category}.${key}` + : `defaults.${category}.${key}`; + export class UserSettingsManager { constructor(socketClient) { this.socketClient = socketClient; @@ -54,10 +70,10 @@ export class UserSettingsManager { const userId = this.getUserId(); const userSettings = await userSettingsModel .findOne({ user: userId }) - .select('defaults') + .select('defaults pageLayout') .lean(); - return normalizeSettings(userSettings?.defaults); + return normalizeSettings(userSettings); } async updateUserSettings({ category, key, value } = {}) { @@ -75,7 +91,7 @@ export class UserSettingsManager { const update = { $set: { - [`defaults.${category}.${key}`]: value, + [getCategoryPath(category, key)]: value, updatedAt: new Date() }, $setOnInsert: { @@ -92,7 +108,7 @@ export class UserSettingsManager { upsert: true, setDefaultsOnInsert: true }) - .select('defaults') + .select('defaults pageLayout') .lean(); } catch (error) { if (error?.code !== 11000) { @@ -105,11 +121,11 @@ export class UserSettingsManager { { $set: update.$set }, { new: true } ) - .select('defaults') + .select('defaults pageLayout') .lean(); } - return normalizeSettings(userSettings?.defaults); + return normalizeSettings(userSettings); } } diff --git a/src/usersettings/usersettingsmanager.test.js b/src/usersettings/usersettingsmanager.test.js index 7b77e50..d82c931 100644 --- a/src/usersettings/usersettingsmanager.test.js +++ b/src/usersettings/usersettingsmanager.test.js @@ -73,6 +73,45 @@ describe('UserSettingsManager', () => { ); }); + it('writes pageLayout at the top level, not under defaults', async () => { + const pageLayout = { + InventoryOverview: { + sectionOrder: ['partStockStats'], + statsOrder: {} + } + }; + findOneAndUpdate.mockReturnValue( + queryResult({ + defaults: createEmptySettings(), + pageLayout + }) + ); + + await expect( + manager.updateUserSettings({ + category: 'pageLayout', + key: 'InventoryOverview', + value: pageLayout.InventoryOverview + }) + ).resolves.toEqual({ + ...createEmptySettings(), + pageLayout + }); + + expect(findOneAndUpdate).toHaveBeenCalledWith( + { user: 'user-id' }, + expect.objectContaining({ + $set: expect.objectContaining({ + 'pageLayout.InventoryOverview': pageLayout.InventoryOverview + }) + }), + expect.anything() + ); + expect(findOneAndUpdate.mock.calls[0][1].$set).not.toHaveProperty( + 'defaults.pageLayout.InventoryOverview' + ); + }); + it('rejects invalid categories and unsafe keys', async () => { await expect( manager.updateUserSettings({