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 ({
|
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 }) => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user