Compare commits

..

No commits in common. "4109523f59679e6eb6cee880143d7a3055238104" and "2dc2a9d296bbf2cbb9596652ae327fa3b474f8cd" have entirely different histories.

14 changed files with 97 additions and 1099 deletions

View File

@ -340,8 +340,7 @@ export const aggregateRollups = async ({
}, {}); }, {});
}; };
// Snapshot absolute rollup values at each point in time by reconstructing // Reusable function to aggregate rollups over history using audit logs
// object state from the current documents plus audit logs.
export const aggregateRollupsHistory = async ({ export const aggregateRollupsHistory = async ({
model, model,
baseFilter = {}, baseFilter = {},
@ -350,146 +349,74 @@ export const aggregateRollupsHistory = async ({
endDate endDate
}) => { }) => {
if (!rollupConfigs.length) { if (!rollupConfigs.length) {
return []; return {};
} }
const end = endDate ? new Date(endDate) : new Date(); // Helper to map filter keys to audit log structure
const start = startDate const mapFilterToAudit = filter => {
? new Date(startDate) if (Array.isArray(filter)) {
: new Date(end.getTime() - 24 * 60 * 60 * 1000); return filter.map(mapFilterToAudit);
const parentType = model.modelName ? model.modelName : 'unknown'; }
const matchesFilter = (obj, filter) => { if (_.isPlainObject(filter)) {
if (!filter || Object.keys(filter).length === 0) return true; const newFilter = {};
for (const key in filter) {
for (const [key, expectedValue] of Object.entries(filter)) { if (['$or', '$and', '$nor', '$in', '$nin'].includes(key)) {
const actualValue = _.get(obj, key); newFilter[key] = mapFilterToAudit(filter[key]);
if (actualValue != expectedValue) { } else if (key.startsWith('$')) {
return false; 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]);
} }
} }
return true; return newFilter;
}
return filter;
}; };
const existedAt = (obj, bucketDate) => { const matchQuery = {
if (!obj?.createdAt) return true; parentType: model.modelName,
return new Date(obj.createdAt) <= bucketDate; ...mapFilterToAudit(baseFilter)
}; };
const snapshotRollups = (objects, bucketDate) => { if (startDate || endDate) {
const bucketResult = { matchQuery.createdAt = {};
date: bucketDate.toISOString() if (startDate) matchQuery.createdAt.$gte = new Date(startDate);
}; if (endDate) matchQuery.createdAt.$lte = new Date(endDate);
if (Object.keys(matchQuery.createdAt).length === 0)
rollupConfigs.forEach(config => { delete matchQuery.createdAt;
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 }; const mappedRollupConfigs = rollupConfigs.map(config => ({
...config,
filter: config.filter ? mapFilterToAudit(config.filter) : undefined,
rollups: (config.rollups || []).map(rollup => ({
...rollup,
property: `changes.new.${rollup.property}`
}))
}));
return await aggregateRollups({
model: auditLogModel,
baseFilter: matchQuery,
rollupConfigs: mappedRollupConfigs
}); });
});
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));
});
}
if (objectMap.size === 0 && auditLogs.length === 0) {
return [];
}
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 }) => { export const getModelStats = async ({ model }) => {

View File

@ -1,412 +0,0 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import mongoose from 'mongoose';
jest.unstable_mockModule('../../database.js', () => ({
searchObjects: jest.fn(),
getPropertyValues: jest.fn(),
listObjects: jest.fn(),
getObject: jest.fn(),
editObject: jest.fn(),
editObjects: jest.fn(),
newObject: jest.fn(),
deleteObject: jest.fn(),
listObjectsByProperties: jest.fn(),
getModelStats: jest.fn(),
getModelHistory: jest.fn(),
aggregateRollups: jest.fn(),
aggregateRollupsHistory: jest.fn(),
checkStates: jest.fn(),
getObjectNeighbors: jest.fn(),
}));
jest.unstable_mockModule('../../utils.js', () => ({
generateId: jest.fn(() => () => 'test-id'),
}));
const { aggregateRollups, editObject, newObject, deleteObject } = await import('../../database.js');
const { listingModel } = await import('../sales/listing.schema.js');
const { listingVarientModel } = await import('../sales/listingvarient.schema.js');
const { productSkuModel } = await import('../management/productsku.schema.js');
const { productStockModel } = await import('../inventory/productstock.schema.js');
const listingId = new mongoose.Types.ObjectId();
const productId = new mongoose.Types.ObjectId();
const productSkuId = new mongoose.Types.ObjectId();
const stockLocationId = new mongoose.Types.ObjectId();
const varientId = new mongoose.Types.ObjectId();
const mockFind = (docs) => ({
sort: () => ({ lean: async () => docs }),
});
describe('listing.recalculate', () => {
beforeEach(() => {
editObject.mockReset();
newObject.mockReset();
deleteObject.mockReset();
jest.restoreAllMocks();
});
it('calls recalculate on each listing varient', async () => {
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
jest.spyOn(listingVarientModel, 'find').mockReturnValue(
mockFind([{ _id: varientId, listing: listingId }])
);
const listing = { _id: listingId, stockLocation: stockLocationId };
await listingModel.recalculate(listing, 'user-1');
expect(listingVarientModel.find).toHaveBeenCalledWith({ listing: listingId });
expect(recalculate).toHaveBeenCalledWith({ _id: varientId, listing: listingId }, 'user-1');
expect(newObject).not.toHaveBeenCalled();
expect(deleteObject).not.toHaveBeenCalled();
});
it('creates listing varients from product skus when none exist', async () => {
const skuBId = new mongoose.Types.ObjectId();
const createdVarientId = new mongoose.Types.ObjectId();
const productSkus = [{ _id: productSkuId }, { _id: skuBId }];
const syncedVarients = [
{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId },
{ _id: createdVarientId, listing: listingId, product: productId, productSku: skuBId },
];
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
jest
.spyOn(listingVarientModel, 'find')
.mockReturnValueOnce(mockFind([]))
.mockReturnValueOnce(mockFind(syncedVarients));
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind(productSkus));
newObject.mockResolvedValue({ _id: createdVarientId });
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
expect(productSkuModel.find).toHaveBeenCalledWith({ product: productId });
expect(editObject).not.toHaveBeenCalled();
expect(deleteObject).not.toHaveBeenCalled();
expect(newObject).toHaveBeenCalledTimes(2);
expect(newObject).toHaveBeenCalledWith({
model: listingVarientModel,
newData: expect.objectContaining({
listing: listingId,
product: productId,
productSku: productSkuId,
state: { type: 'draft' },
}),
user: 'user-1',
recalculate: false,
});
expect(recalculate).toHaveBeenCalledTimes(2);
});
it('creates missing listing varients when one already matches a sku', async () => {
const skuBId = new mongoose.Types.ObjectId();
const skuCId = new mongoose.Types.ObjectId();
const existingVarient = {
_id: varientId,
listing: listingId,
product: productId,
productSku: productSkuId,
};
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
jest
.spyOn(listingVarientModel, 'find')
.mockReturnValueOnce(mockFind([existingVarient]))
.mockReturnValueOnce(
mockFind([
existingVarient,
{ _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuBId },
{ _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuCId },
])
);
jest
.spyOn(productSkuModel, 'find')
.mockReturnValue(mockFind([{ _id: productSkuId }, { _id: skuBId }, { _id: skuCId }]));
newObject.mockResolvedValue({ _id: new mongoose.Types.ObjectId() });
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
expect(editObject).not.toHaveBeenCalled();
expect(newObject).toHaveBeenCalledTimes(2);
expect(newObject).toHaveBeenCalledWith({
model: listingVarientModel,
newData: expect.objectContaining({
listing: listingId,
product: productId,
productSku: skuBId,
state: { type: 'draft' },
}),
user: 'user-1',
recalculate: false,
});
expect(recalculate).toHaveBeenCalledTimes(3);
});
it('rebuilds listing varients when an existing varient is missing a product sku', async () => {
const skuBId = new mongoose.Types.ObjectId();
jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
jest
.spyOn(listingVarientModel, 'find')
.mockReturnValueOnce(
mockFind([{ _id: varientId, listing: listingId, product: productId }])
)
.mockReturnValueOnce(
mockFind([
{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId },
{
_id: new mongoose.Types.ObjectId(),
listing: listingId,
product: productId,
productSku: skuBId,
},
])
);
jest
.spyOn(productSkuModel, 'find')
.mockReturnValue(mockFind([{ _id: productSkuId }, { _id: skuBId }]));
editObject.mockResolvedValue({});
newObject.mockResolvedValue({ _id: new mongoose.Types.ObjectId() });
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
expect(editObject).toHaveBeenCalledWith({
model: listingVarientModel,
id: varientId,
updateData: expect.objectContaining({
product: productId,
productSku: productSkuId,
}),
user: 'user-1',
recalculate: false,
});
expect(newObject).toHaveBeenCalledTimes(1);
});
it('does not rebuild varients when they already match the product skus', async () => {
const existingVarient = {
_id: varientId,
listing: listingId,
product: productId,
productSku: productSkuId,
};
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
jest.spyOn(listingVarientModel, 'find').mockReturnValue(mockFind([existingVarient]));
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind([{ _id: productSkuId }]));
await listingModel.recalculate(
{ _id: listingId, product: productId, stockLocation: stockLocationId },
'user-1'
);
expect(newObject).not.toHaveBeenCalled();
expect(deleteObject).not.toHaveBeenCalled();
expect(editObject).not.toHaveBeenCalled();
expect(recalculate).toHaveBeenCalledTimes(1);
});
it('creates and updates listing varients to match product skus when a product differs', async () => {
const otherProductId = new mongoose.Types.ObjectId();
const skuBId = new mongoose.Types.ObjectId();
const skuCId = new mongoose.Types.ObjectId();
const createdVarientId = new mongoose.Types.ObjectId();
const existingVarients = [{ _id: varientId, listing: listingId, product: otherProductId }];
const productSkus = [{ _id: productSkuId }, { _id: skuBId }, { _id: skuCId }];
const syncedVarients = [
{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId },
{ _id: createdVarientId, listing: listingId, product: productId, productSku: skuBId },
{ _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuCId },
];
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
jest
.spyOn(listingVarientModel, 'find')
.mockReturnValueOnce(mockFind(existingVarients))
.mockReturnValueOnce(mockFind(syncedVarients));
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind(productSkus));
editObject.mockResolvedValue({});
newObject.mockResolvedValue({ _id: createdVarientId });
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
expect(editObject).toHaveBeenCalledWith({
model: listingVarientModel,
id: varientId,
updateData: expect.objectContaining({
product: productId,
productSku: productSkuId,
}),
user: 'user-1',
recalculate: false,
});
expect(newObject).toHaveBeenCalledTimes(2);
expect(newObject).toHaveBeenCalledWith({
model: listingVarientModel,
newData: expect.objectContaining({
listing: listingId,
product: productId,
productSku: skuBId,
state: { type: 'draft' },
}),
user: 'user-1',
recalculate: false,
});
expect(deleteObject).not.toHaveBeenCalled();
expect(recalculate).toHaveBeenCalledTimes(3);
});
it('deletes extra listing varients when the product has fewer skus', async () => {
const otherProductId = new mongoose.Types.ObjectId();
const extraVarientId = new mongoose.Types.ObjectId();
const existingVarients = [
{ _id: varientId, listing: listingId, product: otherProductId },
{ _id: extraVarientId, listing: listingId, product: otherProductId },
];
jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
jest
.spyOn(listingVarientModel, 'find')
.mockReturnValueOnce(mockFind(existingVarients))
.mockReturnValueOnce(
mockFind([{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId }])
);
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind([{ _id: productSkuId }]));
editObject.mockResolvedValue({});
deleteObject.mockResolvedValue({});
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
expect(editObject).toHaveBeenCalledTimes(1);
expect(newObject).not.toHaveBeenCalled();
expect(deleteObject).toHaveBeenCalledWith({
model: listingVarientModel,
id: extraVarientId,
user: 'user-1',
});
});
});
describe('listingVarient.recalculate', () => {
beforeEach(() => {
aggregateRollups.mockReset();
editObject.mockReset();
jest.restoreAllMocks();
});
it('sums sibling listing varient stock quantities onto the listing', async () => {
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 12 } });
editObject.mockResolvedValue({});
await listingVarientModel.recalculate({ listing: listingId, stockQuantity: 4 }, 'user-1');
expect(aggregateRollups).toHaveBeenCalledWith(
expect.objectContaining({
model: listingVarientModel,
baseFilter: { listing: listingId },
})
);
expect(editObject).toHaveBeenCalledWith({
model: listingModel,
id: listingId,
updateData: { stockQuantity: 12 },
user: 'user-1',
recalculate: false,
});
});
it('writes the product sku stock total onto the listing varient before rolling up', async () => {
jest.spyOn(listingVarientModel, 'exists').mockResolvedValue({ _id: varientId });
aggregateRollups.mockImplementation(async ({ model }) => {
if (model === productStockModel) {
return { stockQuantity: { sum: 9 } };
}
return { stockQuantity: { sum: 12 } };
});
editObject.mockResolvedValue({});
await listingVarientModel.recalculate(
{
_id: varientId,
listing: { _id: listingId, stockLocation: stockLocationId, product: productId },
product: productId,
productSku: productSkuId,
stockQuantity: 0,
},
'user-1'
);
expect(aggregateRollups).toHaveBeenCalledWith(
expect.objectContaining({
model: productStockModel,
baseFilter: {
productSku: productSkuId,
stockLocation: stockLocationId,
},
})
);
expect(editObject).toHaveBeenCalledWith({
model: listingVarientModel,
id: varientId,
updateData: { stockQuantity: 9 },
user: 'user-1',
recalculate: false,
});
expect(editObject).toHaveBeenCalledWith({
model: listingModel,
id: listingId,
updateData: { stockQuantity: 12 },
user: 'user-1',
recalculate: false,
});
});
});
describe('productStock.recalculate', () => {
beforeEach(() => {
aggregateRollups.mockReset();
editObject.mockReset();
jest.restoreAllMocks();
});
it('writes the sku/location total onto matching listing varients', async () => {
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 9 } });
editObject.mockResolvedValue({});
jest.spyOn(productSkuModel, 'findById').mockReturnValue({
select: () => ({ lean: async () => ({ product: productId }) }),
});
jest.spyOn(listingVarientModel, 'find').mockReturnValue({
populate: () => ({
lean: async () => [
{
_id: varientId,
product: productId,
productSku: productSkuId,
stockQuantity: 0,
listing: { product: productId, stockLocation: stockLocationId },
},
],
}),
});
await productStockModel.recalculate(
{ productSku: productSkuId, stockLocation: stockLocationId, currentQuantity: 9 },
'user-1'
);
expect(aggregateRollups).toHaveBeenCalledWith(
expect.objectContaining({
model: productStockModel,
baseFilter: {
productSku: productSkuId,
stockLocation: stockLocationId,
},
})
);
expect(editObject).toHaveBeenCalledWith({
model: listingVarientModel,
id: varientId,
updateData: { stockQuantity: 9 },
user: 'user-1',
});
});
});

View File

@ -58,21 +58,6 @@ const rollupConfigs = [
filter: {}, filter: {},
rollups: [{ name: 'totalCurrentWeight', property: 'currentWeight.net', operation: 'sum' }], rollups: [{ name: 'totalCurrentWeight', property: 'currentWeight.net', operation: 'sum' }],
}, },
{
name: 'unconsumed',
filter: { 'state.type': 'unconsumed' },
rollups: [{ name: 'unconsumed', property: 'state.type', operation: 'count' }],
},
{
name: 'used',
filter: { 'state.type': 'used' },
rollups: [{ name: 'used', property: 'state.type', operation: 'count' }],
},
{
name: 'consumed',
filter: { 'state.type': 'consumed' },
rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }],
},
]; ];
filamentStockSchema.statics.stats = async function () { filamentStockSchema.statics.stats = async function () {

View File

@ -1,7 +1,7 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import { generateId } from '../../utils.js'; import { generateId } from '../../utils.js';
const { Schema } = mongoose; const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
// Define the main partStock schema // Define the main partStock schema
const partStockSchema = new Schema( const partStockSchema = new Schema(
@ -11,7 +11,6 @@ const partStockSchema = new Schema(
type: { type: String, required: true }, type: { type: String, required: true },
progress: { type: Number, required: false }, progress: { type: Number, required: false },
}, },
part: { type: mongoose.Schema.Types.ObjectId, ref: 'part', required: true },
partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true }, partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true },
stockLocation: { stockLocation: {
type: mongoose.Schema.Types.ObjectId, type: mongoose.Schema.Types.ObjectId,
@ -33,34 +32,12 @@ const partStockSchema = new Schema(
partStockSchema.index({ sourceType: 'text', 'state.type': 'text' }); partStockSchema.index({ sourceType: 'text', 'state.type': 'text' });
partStockSchema.pre('validate', async function () {
if (!this.part && this.partSku) {
const sku = await mongoose.model('partSku').findById(this.partSku).select('part').lean();
if (sku?.part) this.part = sku.part;
}
});
const rollupConfigs = [ const rollupConfigs = [
{ {
name: 'totalCurrentQuantity', name: 'totalCurrentQuantity',
filter: {}, filter: {},
rollups: [{ name: 'totalCurrentQuantity', property: 'currentQuantity', operation: 'sum' }], rollups: [{ name: 'totalCurrentQuantity', property: 'currentQuantity', operation: 'sum' }],
}, },
{
name: 'new',
filter: { 'state.type': 'new' },
rollups: [{ name: 'new', property: 'state.type', operation: 'count' }],
},
{
name: 'used',
filter: { 'state.type': 'used' },
rollups: [{ name: 'used', property: 'state.type', operation: 'count' }],
},
{
name: 'consumed',
filter: { 'state.type': 'consumed' },
rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }],
},
]; ];
partStockSchema.statics.stats = async function () { partStockSchema.statics.stats = async function () {
@ -84,22 +61,6 @@ partStockSchema.statics.history = async function (from, to) {
return results; return results;
}; };
partStockSchema.statics.recalculate = async function (partStock, user) {
if (!partStock?._id) return;
if (partStock.state?.type === 'draft' || partStock.state?.type === 'consumed') return;
if ((Number(partStock.currentQuantity) || 0) > 0) return;
await editObject({
model: this,
id: partStock._id,
updateData: {
state: { ...(partStock.state || {}), type: 'consumed', progress: 0 },
},
user,
recalculate: false,
});
};
// Add virtual id getter // Add virtual id getter
partStockSchema.virtual('id').get(function () { partStockSchema.virtual('id').get(function () {
return this._id; return this._id;

View File

@ -3,25 +3,12 @@ import { generateId } from '../../utils.js';
const { Schema } = mongoose; const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
const partStockListItemSchema = new Schema({ const partStockUsageSchema = new Schema({
part: { type: Schema.Types.ObjectId, ref: 'part', required: true }, partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false },
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true }, partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
partStocks: [{ type: Schema.Types.ObjectId, ref: 'partStock', required: false }], quantity: { type: Number, required: true },
requiredQuantity: { type: Number, required: true },
}); });
partStockListItemSchema.virtual('remainingQuantity').get(function () {
const required = this.requiredQuantity || 0;
const stocks = Array.isArray(this.partStocks) ? this.partStocks : [];
const available = stocks.reduce(
(sum, stock) => sum + (Number(stock?.currentQuantity) || 0),
0
);
return required - available;
});
partStockListItemSchema.set('toJSON', { virtuals: true });
const toId = (value) => { const toId = (value) => {
if (value == null) return null; if (value == null) return null;
if (typeof value === 'object' && value._id) return String(value._id); if (typeof value === 'object' && value._id) return String(value._id);
@ -37,7 +24,6 @@ const productStockSchema = new Schema(
progress: { type: Number, required: false }, progress: { type: Number, required: false },
}, },
postedAt: { type: Date, required: false }, postedAt: { type: Date, required: false },
product: { type: mongoose.Schema.Types.ObjectId, ref: 'product', required: true },
productSku: { type: mongoose.Schema.Types.ObjectId, ref: 'productSku', required: true }, productSku: { type: mongoose.Schema.Types.ObjectId, ref: 'productSku', required: true },
stockLocation: { stockLocation: {
type: mongoose.Schema.Types.ObjectId, type: mongoose.Schema.Types.ObjectId,
@ -51,28 +37,13 @@ const productStockSchema = new Schema(
timestamp: { type: Date, default: Date.now }, timestamp: { type: Date, default: Date.now },
}, },
], ],
partStockList: [partStockListItemSchema], partStocks: [partStockUsageSchema],
}, },
{ timestamps: true } { timestamps: true }
); );
productStockSchema.index({ 'state.type': 'text' }); productStockSchema.index({ 'state.type': 'text' });
productStockSchema.pre('validate', async function () {
if (!this.product && this.productSku) {
const sku = await mongoose.model('productSku').findById(this.productSku).select('product').lean();
if (sku?.product) this.product = sku.product;
}
if (this.partStockList?.length) {
for (const item of this.partStockList) {
if (!item.part && item.partSku) {
const sku = await mongoose.model('partSku').findById(item.partSku).select('part').lean();
if (sku?.part) item.part = sku.part;
}
}
}
});
const rollupConfigs = [ const rollupConfigs = [
{ {
name: 'totalCurrentQuantity', name: 'totalCurrentQuantity',
@ -85,19 +56,9 @@ const rollupConfigs = [
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }], rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
}, },
{ {
name: 'new', name: 'posted',
filter: { 'state.type': 'new' }, filter: { 'state.type': 'posted' },
rollups: [{ name: 'new', property: 'state.type', operation: 'count' }], rollups: [{ name: 'posted', property: 'state.type', operation: 'count' }],
},
{
name: 'used',
filter: { 'state.type': 'used' },
rollups: [{ name: 'used', property: 'state.type', operation: 'count' }],
},
{
name: 'consumed',
filter: { 'state.type': 'consumed' },
rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }],
}, },
]; ];
@ -122,30 +83,13 @@ productStockSchema.statics.history = async function (from, to) {
}; };
productStockSchema.statics.recalculate = async function (productStock, user) { productStockSchema.statics.recalculate = async function (productStock, user) {
if (
productStock?._id &&
productStock.state?.type !== 'draft' &&
productStock.state?.type !== 'consumed' &&
(Number(productStock.currentQuantity) || 0) <= 0
) {
await editObject({
model: this,
id: productStock._id,
updateData: {
state: { ...(productStock.state || {}), type: 'consumed', progress: 0 },
},
user,
recalculate: false,
});
}
const productSkuId = toId(productStock?.productSku); const productSkuId = toId(productStock?.productSku);
const stockLocationId = toId(productStock?.stockLocation); const stockLocationId = toId(productStock?.stockLocation);
if (!productSkuId || !stockLocationId) { if (!productSkuId || !stockLocationId) {
return; return;
} }
let productId = toId(productStock?.product) || toId(productStock?.productSku?.product); let productId = toId(productStock?.productSku?.product);
if (!productId) { if (!productId) {
const productSku = await mongoose.model('productSku').findById(productSkuId).select('product').lean(); const productSku = await mongoose.model('productSku').findById(productSkuId).select('product').lean();
productId = toId(productSku?.product); productId = toId(productSku?.product);

View File

@ -1,6 +1,6 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import { generateId } from '../../utils.js'; import { generateId } from '../../utils.js';
import { getObject, editObject, aggregateRollups, aggregateRollupsHistory } from '../../database.js'; import { getObject, editObject } from '../../database.js';
const { Schema } = mongoose; const { Schema } = mongoose;
const parentStockModelNames = { const parentStockModelNames = {
@ -12,7 +12,7 @@ const parentStockModelNames = {
const initialStockStates = { const initialStockStates = {
filamentStock: 'unconsumed', filamentStock: 'unconsumed',
partStock: 'new', partStock: 'new',
productStock: 'new', productStock: 'posted',
}; };
const getStartingAmount = (parentType, parentStock) => { const getStartingAmount = (parentType, parentStock) => {
@ -75,7 +75,7 @@ const getStockEventTotal = async (parentId, parentType) => {
}; };
}; };
const buildParentUpdateData = (parentType, parentStock, events, stockEvent) => { const buildParentUpdateData = (parentType, parentStock, events) => {
const updateData = {}; const updateData = {};
let currentAmount; let currentAmount;
@ -86,17 +86,9 @@ const buildParentUpdateData = (parentType, parentStock, events, stockEvent) => {
const gross = startingNet > 0 ? (startingGross * net) / startingNet : net; const gross = startingNet > 0 ? (startingGross * net) / startingNet : net;
updateData.currentWeight = { net, gross }; updateData.currentWeight = { net, gross };
currentAmount = net; currentAmount = net;
} else {
const eventValue = Number(stockEvent?.value);
if (Number.isFinite(eventValue) && eventValue < 0) {
updateData.currentQuantity = Math.max(
0,
(Number(parentStock.currentQuantity) || 0) + eventValue
);
} else { } else {
updateData.currentQuantity = events.total; updateData.currentQuantity = events.total;
} currentAmount = events.total;
currentAmount = updateData.currentQuantity;
} }
const state = buildParentState( const state = buildParentState(
@ -172,7 +164,7 @@ const appendParentHistoryIfChanged = (
}; };
}; };
const recalculateParentStock = async (parentType, parentId, user, stockEvent) => { const recalculateParentStock = async (parentType, parentId, user) => {
if (!parentType || !parentId) return; if (!parentType || !parentId) return;
const modelName = parentStockModelNames[parentType]; const modelName = parentStockModelNames[parentType];
@ -182,6 +174,7 @@ const recalculateParentStock = async (parentType, parentId, user, stockEvent) =>
const parentStock = await getObject({ const parentStock = await getObject({
model: parentModel, model: parentModel,
id: parentId, id: parentId,
cached: true,
}); });
if (!parentStock || parentStock.error) return; if (!parentStock || parentStock.error) return;
@ -194,10 +187,10 @@ const recalculateParentStock = async (parentType, parentId, user, stockEvent) =>
updateData: appendParentHistoryIfChanged( updateData: appendParentHistoryIfChanged(
parentType, parentType,
parentStock, parentStock,
buildParentUpdateData(parentType, parentStock, events, stockEvent) buildParentUpdateData(parentType, parentStock, events)
), ),
user, user,
recalculate: parentType === 'productStock' || parentType === 'partStock', recalculate: parentType === 'productStock',
}); });
}; };
@ -224,7 +217,7 @@ const stockEventSchema = new Schema(
ownerType: { ownerType: {
type: String, type: String,
required: true, required: true,
enum: ['user', 'subJob', 'stockAudit', 'stockTransfer', 'productStock'], enum: ['user', 'subJob', 'stockAudit', 'stockTransfer'],
}, },
history: [ history: [
{ {
@ -239,44 +232,6 @@ const stockEventSchema = new Schema(
stockEventSchema.index({ parentType: 'text', ownerType: 'text', unit: 'text' }); stockEventSchema.index({ parentType: 'text', ownerType: 'text', unit: 'text' });
const rollupConfigs = [
{
name: 'partStock',
filter: { parentType: 'partStock' },
rollups: [{ name: 'partStock', property: 'parentType', operation: 'count' }],
},
{
name: 'filamentStock',
filter: { parentType: 'filamentStock' },
rollups: [{ name: 'filamentStock', property: 'parentType', operation: 'count' }],
},
{
name: 'productStock',
filter: { parentType: 'productStock' },
rollups: [{ name: 'productStock', property: 'parentType', operation: 'count' }],
},
];
stockEventSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,
rollupConfigs: rollupConfigs,
});
return results;
};
stockEventSchema.statics.history = async function (from, to) {
const results = await aggregateRollupsHistory({
model: this,
startDate: from,
endDate: to,
rollupConfigs: rollupConfigs,
});
return results;
};
stockEventSchema.statics.recalculate = async function (stockEvent, user) { stockEventSchema.statics.recalculate = async function (stockEvent, user) {
const history = stockEvent.history || []; const history = stockEvent.history || [];
const lastEntry = history.at(-1); const lastEntry = history.at(-1);
@ -298,7 +253,7 @@ stockEventSchema.statics.recalculate = async function (stockEvent, user) {
const parentType = stockEvent.parentType; const parentType = stockEvent.parentType;
const parentId = stockEvent.parent?._id || stockEvent.parent; const parentId = stockEvent.parent?._id || stockEvent.parent;
await recalculateParentStock(parentType, parentId, user, stockEvent); await recalculateParentStock(parentType, parentId, user);
}; };
// Add virtual id getter // Add virtual id getter

View File

@ -23,7 +23,7 @@ const productSkuSchema = new Schema(
overridePrice: { type: Boolean, default: false }, overridePrice: { type: Boolean, default: false },
margin: { type: Number, required: false }, margin: { type: Number, required: false },
amount: { type: Number, required: false }, amount: { type: Number, required: false },
parts: { type: [partSkuUsageSchema], default: [] }, parts: [partSkuUsageSchema],
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false }, priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false }, costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
priceWithTax: { type: Number, required: false }, priceWithTax: { type: Number, required: false },

View File

@ -23,7 +23,6 @@ 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,5 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import { generateId } from '../../utils.js'; import { generateId } from '../../utils.js';
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
const addressSchema = new mongoose.Schema({ const addressSchema = new mongoose.Schema({
building: { required: false, type: String }, building: { required: false, type: String },
@ -37,37 +36,4 @@ clientSchema.virtual('id').get(function () {
clientSchema.set('toJSON', { virtuals: true }); clientSchema.set('toJSON', { virtuals: true });
const rollupConfigs = [
{
name: 'active',
filter: { active: true },
rollups: [{ name: 'active', property: 'active', operation: 'count' }],
},
{
name: 'inactive',
filter: { active: false },
rollups: [{ name: 'inactive', property: 'active', operation: 'count' }],
},
];
clientSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,
rollupConfigs: rollupConfigs,
});
return results;
};
clientSchema.statics.history = async function (from, to) {
const results = await aggregateRollupsHistory({
model: this,
startDate: from,
endDate: to,
rollupConfigs: rollupConfigs,
});
return results;
};
export const clientModel = mongoose.model('client', clientSchema); export const clientModel = mongoose.model('client', clientSchema);

View File

@ -1,6 +1,5 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import { generateId } from '../../utils.js'; import { generateId } from '../../utils.js';
import { editObject, newObject, deleteObject, aggregateRollups, aggregateRollupsHistory } from '../../database.js';
const { Schema } = mongoose; const { Schema } = mongoose;
const listingSchema = new Schema( const listingSchema = new Schema(
@ -73,155 +72,4 @@ listingSchema.set('toJSON', {
}, },
}); });
const refId = (value) => value?._id ?? value;
listingSchema.statics.recalculate = async function (listing, user) {
const listingId = refId(listing);
if (!listingId) {
return;
}
const listingVarientModel = mongoose.model('listingVarient');
const productSkuModel = mongoose.model('productSku');
const listingProductId = refId(listing.product);
const findVarients = () =>
listingVarientModel.find({ listing: listingId }).sort({ createdAt: 1 }).lean();
let varients = await findVarients();
if (listingProductId) {
const productSkus = await productSkuModel
.find({ product: listingProductId })
.sort({ createdAt: 1 })
.lean();
const varientsBySkuId = new Map();
const unmatchedVarients = [];
for (const varient of varients) {
const skuId = refId(varient.productSku);
if (skuId && !varientsBySkuId.has(String(skuId))) {
varientsBySkuId.set(String(skuId), varient);
} else {
unmatchedVarients.push(varient);
}
}
for (const sku of productSkus) {
const skuId = sku._id;
const varientUpdateData = {
product: listingProductId,
productSku: skuId,
};
const existingVarient = varientsBySkuId.get(String(skuId)) || unmatchedVarients.shift();
if (existingVarient) {
varientsBySkuId.delete(String(skuId));
const existingProductId = refId(existingVarient.product);
const existingSkuId = refId(existingVarient.productSku);
if (
String(existingProductId) === String(listingProductId) &&
String(existingSkuId) === String(skuId)
) {
continue;
}
const varientResult = await editObject({
model: listingVarientModel,
id: existingVarient._id,
updateData: varientUpdateData,
user,
recalculate: false,
});
if (varientResult.error) {
throw varientResult;
}
} else {
const varientResult = await newObject({
model: listingVarientModel,
newData: {
...varientUpdateData,
listing: listingId,
state: { type: listing.state?.type || 'draft' },
},
user,
recalculate: false,
});
if (varientResult.error) {
throw varientResult;
}
}
}
for (const extra of [...varientsBySkuId.values(), ...unmatchedVarients]) {
const deleteResult = await deleteObject({
model: listingVarientModel,
id: extra._id,
user,
});
if (deleteResult.error) {
throw deleteResult;
}
}
varients = await findVarients();
}
for (const varient of varients) {
await listingVarientModel.recalculate(varient, user);
}
};
const rollupConfigs = [
{
name: 'draft',
filter: { 'state.type': 'draft' },
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
},
{
name: 'active',
filter: { 'state.type': 'active' },
rollups: [{ name: 'active', property: 'state.type', operation: 'count' }],
},
{
name: 'inactive',
filter: { 'state.type': 'inactive' },
rollups: [{ name: 'inactive', property: 'state.type', operation: 'count' }],
},
{
name: 'syncing',
filter: { 'state.type': 'syncing' },
rollups: [{ name: 'syncing', property: 'state.type', operation: 'count' }],
},
{
name: 'suspended',
filter: { 'state.type': 'suspended' },
rollups: [{ name: 'suspended', property: 'state.type', operation: 'count' }],
},
{
name: 'deleted',
filter: { 'state.type': 'deleted' },
rollups: [{ name: 'deleted', property: 'state.type', operation: 'count' }],
},
];
listingSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,
rollupConfigs: rollupConfigs,
});
return results;
};
listingSchema.statics.history = async function (from, to) {
const results = await aggregateRollupsHistory({
model: this,
startDate: from,
endDate: to,
rollupConfigs: rollupConfigs,
});
return results;
};
export const listingModel = mongoose.model('listing', listingSchema); export const listingModel = mongoose.model('listing', listingSchema);

View File

@ -3,12 +3,6 @@ import { generateId } from '../../utils.js';
import { aggregateRollups, editObject } from '../../database.js'; import { aggregateRollups, editObject } from '../../database.js';
const { Schema } = mongoose; const { Schema } = mongoose;
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id) return String(value._id);
return String(value);
};
const listingVarientSchema = new Schema( const listingVarientSchema = new Schema(
{ {
_reference: { type: String, default: () => generateId()() }, _reference: { type: String, default: () => generateId()() },
@ -35,14 +29,7 @@ const listingVarientSchema = new Schema(
); );
listingVarientSchema.index({ currency: 'text', 'state.type': 'text' }); listingVarientSchema.index({ currency: 'text', 'state.type': 'text' });
listingVarientSchema.index( listingVarientSchema.index({ listing: 1, externalReference: 1 }, { unique: true, sparse: true });
{ listing: 1, externalReference: 1 },
{
unique: true,
name: 'listing_1_externalReference_1',
partialFilterExpression: { externalReference: { $type: 'string', $gt: '' } },
}
);
listingVarientSchema.virtual('id').get(function () { listingVarientSchema.virtual('id').get(function () {
return this._id; return this._id;
@ -61,46 +48,6 @@ listingVarientSchema.set('toJSON', {
listingVarientSchema.statics.recalculate = async function (listingVarient, user) { listingVarientSchema.statics.recalculate = async function (listingVarient, user) {
const listingId = listingVarient?.listing?._id || listingVarient?.listing; const listingId = listingVarient?.listing?._id || listingVarient?.listing;
const varientId = listingVarient?._id;
const productSkuId = toId(listingVarient?.productSku);
if (varientId && productSkuId && (await this.exists({ _id: varientId }))) {
let listing = listingVarient.listing;
if (!listing?.stockLocation) {
listing = await mongoose
.model('listing')
.findById(listingId)
.select('stockLocation product')
.lean();
}
const stockLocationId = toId(listing?.stockLocation);
if (stockLocationId) {
const stockRollup = await aggregateRollups({
model: mongoose.model('productStock'),
baseFilter: {
productSku: new mongoose.Types.ObjectId(productSkuId),
stockLocation: new mongoose.Types.ObjectId(stockLocationId),
},
rollupConfigs: [
{
name: 'stockQuantity',
rollups: [{ name: 'stockQuantity', property: 'currentQuantity', operation: 'sum' }],
},
],
});
const stockQuantity = stockRollup.stockQuantity?.sum || 0;
if (listingVarient.stockQuantity !== stockQuantity) {
await editObject({
model: this,
id: varientId,
updateData: { stockQuantity },
user,
recalculate: false,
});
}
}
}
if (!listingId) { if (!listingId) {
return; return;
} }
@ -128,22 +75,3 @@ listingVarientSchema.statics.recalculate = async function (listingVarient, user)
}; };
export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema); export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema);
async function replaceSparseExternalReferenceIndex() {
try {
const indexes = await listingVarientModel.collection.indexes();
const current = indexes.find((idx) => idx.name === 'listing_1_externalReference_1');
if (current && (current.sparse || !current.partialFilterExpression)) {
await listingVarientModel.collection.dropIndex('listing_1_externalReference_1');
}
await listingVarientModel.createIndexes();
} catch {
// Collection/index may not exist until Mongo is connected.
}
}
if (mongoose.connection.readyState === 1) {
replaceSparseExternalReferenceIndex();
} else {
mongoose.connection.once('open', replaceSparseExternalReferenceIndex);
}

View File

@ -1,5 +1,5 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import { editObject, aggregateRollups, aggregateRollupsHistory } from '../../database.js'; import { editObject } from '../../database.js';
import { generateId } from '../../utils.js'; import { generateId } from '../../utils.js';
const marketplaceSchema = new mongoose.Schema( const marketplaceSchema = new mongoose.Schema(
@ -56,54 +56,6 @@ marketplaceSchema.statics.recalculate = async function (marketplace, user) {
}); });
}; };
const rollupConfigs = [
{
name: 'ready',
filter: { 'state.type': 'ready' },
rollups: [{ name: 'ready', property: 'state.type', operation: 'count' }],
},
{
name: 'syncing',
filter: { 'state.type': 'syncing' },
rollups: [{ name: 'syncing', property: 'state.type', operation: 'count' }],
},
{
name: 'disconnected',
filter: { 'state.type': 'disconnected' },
rollups: [{ name: 'disconnected', property: 'state.type', operation: 'count' }],
},
{
name: 'inactive',
filter: { 'state.type': 'inactive' },
rollups: [{ name: 'inactive', property: 'state.type', operation: 'count' }],
},
{
name: 'offline',
filter: { 'state.type': 'offline' },
rollups: [{ name: 'offline', property: 'state.type', operation: 'count' }],
},
];
marketplaceSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,
rollupConfigs: rollupConfigs,
});
return results;
};
marketplaceSchema.statics.history = async function (from, to) {
const results = await aggregateRollupsHistory({
model: this,
startDate: from,
endDate: to,
rollupConfigs: rollupConfigs,
});
return results;
};
marketplaceSchema.set('toJSON', { virtuals: true }); marketplaceSchema.set('toJSON', { virtuals: true });
export const marketplaceModel = mongoose.model('marketplace', marketplaceSchema); export const marketplaceModel = mongoose.model('marketplace', marketplaceSchema);

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 DEFAULTS_CATEGORIES = [ const SETTINGS_CATEGORIES = [
'viewMode', 'viewMode',
'filterSidebarVisibility', 'filterSidebarVisibility',
'sortSidebarVisibility', 'sortSidebarVisibility',
@ -8,17 +8,12 @@ const DEFAULTS_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 =>
@ -26,19 +21,13 @@ const normalizeCategory = category =>
? category ? category
: {}; : {};
const normalizeSettings = (userSettings = {}) => { const normalizeSettings = (defaults = {}) => ({
const defaults = userSettings?.defaults ?? {};
return {
viewMode: normalizeCategory(defaults?.viewMode), viewMode: normalizeCategory(defaults?.viewMode),
filterSidebarVisibility: normalizeCategory( filterSidebarVisibility: normalizeCategory(defaults?.filterSidebarVisibility),
defaults?.filterSidebarVisibility
),
sortSidebarVisibility: normalizeCategory(defaults?.sortSidebarVisibility), sortSidebarVisibility: normalizeCategory(defaults?.sortSidebarVisibility),
columnVisibility: normalizeCategory(defaults?.columnVisibility), columnVisibility: normalizeCategory(defaults?.columnVisibility),
collapseState: normalizeCategory(defaults?.collapseState), collapseState: normalizeCategory(defaults?.collapseState)
pageLayout: normalizeCategory(userSettings?.pageLayout) });
};
};
const isSafeSettingsKey = key => const isSafeSettingsKey = key =>
typeof key === 'string' && typeof key === 'string' &&
@ -48,11 +37,6 @@ 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;
@ -70,10 +54,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 pageLayout') .select('defaults')
.lean(); .lean();
return normalizeSettings(userSettings); return normalizeSettings(userSettings?.defaults);
} }
async updateUserSettings({ category, key, value } = {}) { async updateUserSettings({ category, key, value } = {}) {
@ -91,7 +75,7 @@ export class UserSettingsManager {
const update = { const update = {
$set: { $set: {
[getCategoryPath(category, key)]: value, [`defaults.${category}.${key}`]: value,
updatedAt: new Date() updatedAt: new Date()
}, },
$setOnInsert: { $setOnInsert: {
@ -108,7 +92,7 @@ export class UserSettingsManager {
upsert: true, upsert: true,
setDefaultsOnInsert: true setDefaultsOnInsert: true
}) })
.select('defaults pageLayout') .select('defaults')
.lean(); .lean();
} catch (error) { } catch (error) {
if (error?.code !== 11000) { if (error?.code !== 11000) {
@ -121,11 +105,11 @@ export class UserSettingsManager {
{ $set: update.$set }, { $set: update.$set },
{ new: true } { new: true }
) )
.select('defaults pageLayout') .select('defaults')
.lean(); .lean();
} }
return normalizeSettings(userSettings); return normalizeSettings(userSettings?.defaults);
} }
} }

View File

@ -73,45 +73,6 @@ 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({