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:
parent
2dc2a9d296
commit
91c88b5cbb
@ -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 }) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user