Enhance user settings management and schema

- Introduced a new `pageLayout` field in the user settings schema to allow for top-level layout configurations.
- Refactored the user settings manager to handle `pageLayout` separately from default settings, ensuring proper data structure during updates.
- Updated normalization functions to accommodate the new `pageLayout` field, enhancing the overall user settings management.
- Added tests to verify the correct handling of `pageLayout` updates, ensuring that it is written at the top level and not under defaults.
This commit is contained in:
Tom Butcher 2026-08-25 01:59:14 +01:00
parent 2dc2a9d296
commit 91c88b5cbb
4 changed files with 201 additions and 72 deletions

View File

@ -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 ({ export const aggregateRollupsHistory = async ({
model, model,
baseFilter = {}, baseFilter = {},
@ -349,74 +350,146 @@ export const aggregateRollupsHistory = async ({
endDate endDate
}) => { }) => {
if (!rollupConfigs.length) { if (!rollupConfigs.length) {
return {}; return [];
} }
// Helper to map filter keys to audit log structure const end = endDate ? new Date(endDate) : new Date();
const mapFilterToAudit = filter => { const start = startDate
if (Array.isArray(filter)) { ? new Date(startDate)
return filter.map(mapFilterToAudit); : new Date(end.getTime() - 24 * 60 * 60 * 1000);
} const parentType = model.modelName ? model.modelName : 'unknown';
if (_.isPlainObject(filter)) { const matchesFilter = (obj, filter) => {
const newFilter = {}; if (!filter || Object.keys(filter).length === 0) return true;
for (const key in filter) {
if (['$or', '$and', '$nor', '$in', '$nin'].includes(key)) { for (const [key, expectedValue] of Object.entries(filter)) {
newFilter[key] = mapFilterToAudit(filter[key]); const actualValue = _.get(obj, key);
} else if (key.startsWith('$')) { if (actualValue != expectedValue) {
newFilter[key] = mapFilterToAudit(filter[key]); return false;
} 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]);
}
} }
return newFilter;
} }
return true;
return filter;
}; };
const matchQuery = { const existedAt = (obj, bucketDate) => {
parentType: model.modelName, if (!obj?.createdAt) return true;
...mapFilterToAudit(baseFilter) return new Date(obj.createdAt) <= bucketDate;
}; };
if (startDate || endDate) { const snapshotRollups = (objects, bucketDate) => {
matchQuery.createdAt = {}; const bucketResult = {
if (startDate) matchQuery.createdAt.$gte = new Date(startDate); date: bucketDate.toISOString()
if (endDate) matchQuery.createdAt.$lte = new Date(endDate); };
if (Object.keys(matchQuery.createdAt).length === 0)
delete matchQuery.createdAt; 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 => ({ if (objectMap.size === 0 && auditLogs.length === 0) {
...config, return [];
filter: config.filter ? mapFilterToAudit(config.filter) : undefined, }
rollups: (config.rollups || []).map(rollup => ({
...rollup,
property: `changes.new.${rollup.property}`
}))
}));
return await aggregateRollups({ const buckets = [];
model: auditLogModel, let currentTime = new Date(end);
baseFilter: matchQuery, currentTime.setSeconds(0, 0);
rollupConfigs: mappedRollupConfigs
}); 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 }) => { export const getModelStats = async ({ model }) => {

View File

@ -23,6 +23,7 @@ const userSettingsSchema = new mongoose.Schema({
columnVisibility: { type: Schema.Types.Mixed, default: () => ({}) }, columnVisibility: { type: Schema.Types.Mixed, default: () => ({}) },
collapseState: { type: Schema.Types.Mixed, default: () => ({}) }, collapseState: { type: Schema.Types.Mixed, default: () => ({}) },
}, },
pageLayout: { type: Schema.Types.Mixed, default: () => ({}) },
createdAt: { createdAt: {
type: Date, type: Date,

View File

@ -1,6 +1,6 @@
import { userSettingsModel } from '../database/schemas/misc/usersettings.schema.js'; import { userSettingsModel } from '../database/schemas/misc/usersettings.schema.js';
const SETTINGS_CATEGORIES = [ const DEFAULTS_CATEGORIES = [
'viewMode', 'viewMode',
'filterSidebarVisibility', 'filterSidebarVisibility',
'sortSidebarVisibility', 'sortSidebarVisibility',
@ -8,12 +8,17 @@ const SETTINGS_CATEGORIES = [
'collapseState' 'collapseState'
]; ];
const TOP_LEVEL_CATEGORIES = ['pageLayout'];
const SETTINGS_CATEGORIES = [...DEFAULTS_CATEGORIES, ...TOP_LEVEL_CATEGORIES];
const createEmptySettings = () => ({ const createEmptySettings = () => ({
viewMode: {}, viewMode: {},
filterSidebarVisibility: {}, filterSidebarVisibility: {},
sortSidebarVisibility: {}, sortSidebarVisibility: {},
columnVisibility: {}, columnVisibility: {},
collapseState: {} collapseState: {},
pageLayout: {}
}); });
const normalizeCategory = category => const normalizeCategory = category =>
@ -21,13 +26,19 @@ const normalizeCategory = category =>
? category ? category
: {}; : {};
const normalizeSettings = (defaults = {}) => ({ const normalizeSettings = (userSettings = {}) => {
viewMode: normalizeCategory(defaults?.viewMode), const defaults = userSettings?.defaults ?? {};
filterSidebarVisibility: normalizeCategory(defaults?.filterSidebarVisibility), return {
sortSidebarVisibility: normalizeCategory(defaults?.sortSidebarVisibility), viewMode: normalizeCategory(defaults?.viewMode),
columnVisibility: normalizeCategory(defaults?.columnVisibility), filterSidebarVisibility: normalizeCategory(
collapseState: normalizeCategory(defaults?.collapseState) defaults?.filterSidebarVisibility
}); ),
sortSidebarVisibility: normalizeCategory(defaults?.sortSidebarVisibility),
columnVisibility: normalizeCategory(defaults?.columnVisibility),
collapseState: normalizeCategory(defaults?.collapseState),
pageLayout: normalizeCategory(userSettings?.pageLayout)
};
};
const isSafeSettingsKey = key => const isSafeSettingsKey = key =>
typeof key === 'string' && typeof key === 'string' &&
@ -37,6 +48,11 @@ const isSafeSettingsKey = key =>
!key.includes('$') && !key.includes('$') &&
!key.includes('\0'); !key.includes('\0');
const getCategoryPath = (category, key) =>
TOP_LEVEL_CATEGORIES.includes(category)
? `${category}.${key}`
: `defaults.${category}.${key}`;
export class UserSettingsManager { export class UserSettingsManager {
constructor(socketClient) { constructor(socketClient) {
this.socketClient = socketClient; this.socketClient = socketClient;
@ -54,10 +70,10 @@ export class UserSettingsManager {
const userId = this.getUserId(); const userId = this.getUserId();
const userSettings = await userSettingsModel const userSettings = await userSettingsModel
.findOne({ user: userId }) .findOne({ user: userId })
.select('defaults') .select('defaults pageLayout')
.lean(); .lean();
return normalizeSettings(userSettings?.defaults); return normalizeSettings(userSettings);
} }
async updateUserSettings({ category, key, value } = {}) { async updateUserSettings({ category, key, value } = {}) {
@ -75,7 +91,7 @@ export class UserSettingsManager {
const update = { const update = {
$set: { $set: {
[`defaults.${category}.${key}`]: value, [getCategoryPath(category, key)]: value,
updatedAt: new Date() updatedAt: new Date()
}, },
$setOnInsert: { $setOnInsert: {
@ -92,7 +108,7 @@ export class UserSettingsManager {
upsert: true, upsert: true,
setDefaultsOnInsert: true setDefaultsOnInsert: true
}) })
.select('defaults') .select('defaults pageLayout')
.lean(); .lean();
} catch (error) { } catch (error) {
if (error?.code !== 11000) { if (error?.code !== 11000) {
@ -105,11 +121,11 @@ export class UserSettingsManager {
{ $set: update.$set }, { $set: update.$set },
{ new: true } { new: true }
) )
.select('defaults') .select('defaults pageLayout')
.lean(); .lean();
} }
return normalizeSettings(userSettings?.defaults); return normalizeSettings(userSettings);
} }
} }

View File

@ -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 () => { it('rejects invalid categories and unsafe keys', async () => {
await expect( await expect(
manager.updateUserSettings({ manager.updateUserSettings({