Added product category support, including new routes, database schemas, and service updates. Enhanced existing product functionalities to incorporate product categories in filtering and population.

This commit is contained in:
Tom Butcher 2026-06-14 23:51:51 +01:00
parent 92e07c97d7
commit af0934b163
26 changed files with 1035 additions and 195 deletions

View File

@ -12,6 +12,7 @@
"body-parser": "^2.2.0",
"canonical-json": "^0.2.0",
"cors": "^2.8.5",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"exifr": "^7.1.3",

9
pnpm-lock.yaml generated
View File

@ -32,6 +32,9 @@ importers:
cors:
specifier: ^2.8.5
version: 2.8.6
diff:
specifier: ^9.0.0
version: 9.0.0
dotenv:
specifier: ^17.2.3
version: 17.2.3
@ -2265,6 +2268,10 @@ packages:
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
diff@9.0.0:
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
engines: {node: '>=0.3.1'}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@ -7723,6 +7730,8 @@ snapshots:
asap: 2.0.6
wrappy: 1.0.2
diff@9.0.0: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3

View File

@ -1,7 +1,26 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { stockEventModel } from './stockevent.schema.js';
const getStockEventTotal = async (stock, parentType) => {
const stockId = stock?._id;
if (!stockId) return null;
const parentId =
stockId instanceof mongoose.Types.ObjectId ? stockId : new mongoose.Types.ObjectId(stockId);
const [result] = await stockEventModel.aggregate([
{ $match: { parent: parentId, parentType } },
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
]);
return {
total: result?.total ?? 0,
count: result?.count ?? 0,
};
};
// Define the main filamentStock schema
const filamentStockSchema = new Schema(
@ -32,7 +51,11 @@ const filamentStockSchema = new Schema(
filamentStockSchema.pre('validate', async function () {
if (!this.filament && this.filamentSku) {
const sku = await mongoose.model('filamentSku').findById(this.filamentSku).select('filament').lean();
const sku = await mongoose
.model('filamentSku')
.findById(this.filamentSku)
.select('filament')
.lean();
if (sku?.filament) this.filament = sku.filament;
}
});
@ -66,6 +89,33 @@ filamentStockSchema.statics.history = async function (from, to) {
return results;
};
filamentStockSchema.statics.recalculate = async function (filamentStock, user) {
const events = await getStockEventTotal(filamentStock, this.modelName);
if (!events?.count) return;
const net = events.total;
const startingNet = filamentStock.startingWeight?.net ?? 0;
const startingGross = filamentStock.startingWeight?.gross ?? 0;
const gross = startingNet > 0 ? (startingGross * net) / startingNet : net;
console.log('Recalculating filament stock');
console.log('events', events);
console.log('filamentStock', filamentStock);
await editObject({
model: this,
id: filamentStock._id,
updateData: {
currentWeight: {
net,
gross,
},
},
user,
recalculate: false,
});
};
// Add virtual id getter
filamentStockSchema.virtual('id').get(function () {
return this._id;

View File

@ -1,7 +1,26 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { stockEventModel } from './stockevent.schema.js';
const getStockEventTotal = async (stock, parentType) => {
const stockId = stock?._id;
if (!stockId) return null;
const parentId =
stockId instanceof mongoose.Types.ObjectId ? stockId : new mongoose.Types.ObjectId(stockId);
const [result] = await stockEventModel.aggregate([
{ $match: { parent: parentId, parentType } },
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
]);
return {
total: result?.total ?? 0,
count: result?.count ?? 0,
};
};
// Define the main partStock schema
const partStockSchema = new Schema(
@ -53,6 +72,21 @@ partStockSchema.statics.history = async function (from, to) {
return results;
};
partStockSchema.statics.recalculate = async function (partStock, user) {
const events = await getStockEventTotal(partStock, this.modelName);
if (!events?.count) return;
await editObject({
model: this,
id: partStock._id,
updateData: {
currentQuantity: events.total,
},
user,
recalculate: false,
});
};
// Add virtual id getter
partStockSchema.virtual('id').get(function () {
return this._id;

View File

@ -1,7 +1,26 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { stockEventModel } from './stockevent.schema.js';
const getStockEventTotal = async (stock, parentType) => {
const stockId = stock?._id;
if (!stockId) return null;
const parentId =
stockId instanceof mongoose.Types.ObjectId ? stockId : new mongoose.Types.ObjectId(stockId);
const [result] = await stockEventModel.aggregate([
{ $match: { parent: parentId, parentType } },
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
]);
return {
total: result?.total ?? 0,
count: result?.count ?? 0,
};
};
const partStockUsageSchema = new Schema({
partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false },
@ -68,6 +87,21 @@ productStockSchema.statics.history = async function (from, to) {
return results;
};
productStockSchema.statics.recalculate = async function (productStock, user) {
const events = await getStockEventTotal(productStock, this.modelName);
if (!events?.count) return;
await editObject({
model: this,
id: productStock._id,
updateData: {
currentQuantity: events.total,
},
user,
recalculate: false,
});
};
// Add virtual id getter
productStockSchema.virtual('id').get(function () {
return this._id;

View File

@ -11,7 +11,7 @@ const stockTransferLineSchema = new Schema(
},
fromStock: {
type: Schema.Types.ObjectId,
refPath: 'fromStockType',
refPath: 'lines.fromStockType',
required: true,
},
quantity: { type: Number, required: true },
@ -27,7 +27,7 @@ const stockTransferLineSchema = new Schema(
},
toStock: {
type: Schema.Types.ObjectId,
refPath: 'toStockType',
refPath: 'lines.toStockType',
required: false,
},
},
@ -37,6 +37,7 @@ const stockTransferLineSchema = new Schema(
const stockTransferSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
state: {
type: { type: String, required: true, default: 'draft' },
progress: { type: Number, required: false },

View File

@ -7,6 +7,7 @@ const productSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
productCategory: { type: Schema.Types.ObjectId, ref: 'productCategory', required: true },
tags: [{ type: String }],
version: { type: String },
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },

View File

@ -0,0 +1,18 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const productCategorySchema = new mongoose.Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { required: true, type: String },
},
{ timestamps: true }
);
productCategorySchema.virtual('id').get(function () {
return this._id;
});
productCategorySchema.set('toJSON', { virtuals: true });
export const productCategoryModel = mongoose.model('productCategory', productCategorySchema);

View File

@ -7,6 +7,7 @@ import { gcodeFileModel } from './production/gcodefile.schema.js';
import { partModel } from './management/part.schema.js';
import { partSkuModel } from './management/partsku.schema.js';
import { productModel } from './management/product.schema.js';
import { productCategoryModel } from './management/productcategory.schema.js';
import { productSkuModel } from './management/productsku.schema.js';
import { vendorModel } from './management/vendor.schema.js';
import { materialModel } from './management/material.schema.js';
@ -43,6 +44,7 @@ import { salesOrderModel } from './sales/salesorder.schema.js';
import { marketplaceModel } from './sales/marketplace.schema.js';
import { listingModel } from './sales/listing.schema.js';
import { listingVarientModel } from './sales/listingvarient.schema.js';
import { paymentModel } from './finance/payment.schema.js';
// Map prefixes to models and id fields
export const models = {
@ -96,6 +98,13 @@ export const models = {
referenceField: '_reference',
label: 'Product',
},
PCG: {
model: productCategoryModel,
idField: '_id',
type: 'productCategory',
referenceField: '_reference',
label: 'Product Category',
},
SKU: {
model: productSkuModel,
idField: '_id',
@ -355,4 +364,11 @@ export const models = {
label: 'Listing Varient',
referenceField: '_reference',
},
PAY: {
model: paymentModel,
idField: '_id',
type: 'payment',
label: 'Payment',
referenceField: '_reference',
},
};

View File

@ -19,6 +19,7 @@ import {
partRoutes,
partSkuRoutes,
productRoutes,
productCategoryRoutes,
productSkuRoutes,
vendorRoutes,
materialRoutes,
@ -143,6 +144,7 @@ app.use('/filamentskus', filamentSkuRoutes);
app.use('/parts', partRoutes);
app.use('/partskus', partSkuRoutes);
app.use('/products', productRoutes);
app.use('/productcategories', productCategoryRoutes);
app.use('/productskus', productSkuRoutes);
app.use('/vendors', vendorRoutes);
app.use('/materials', materialRoutes);

View File

@ -13,6 +13,7 @@ import spotlightRoutes from './misc/spotlight.js';
import partRoutes from './management/parts.js';
import partSkuRoutes from './management/partskus.js';
import productRoutes from './management/products.js';
import productCategoryRoutes from './management/productcategories.js';
import productSkuRoutes from './management/productskus.js';
import vendorRoutes from './management/vendors.js';
import materialRoutes from './management/materials.js';
@ -66,6 +67,7 @@ export {
partRoutes,
partSkuRoutes,
productRoutes,
productCategoryRoutes,
productSkuRoutes,
vendorRoutes,
materialRoutes,

View File

@ -35,7 +35,13 @@ router.get('/', isAuthenticated, (req, res) => {
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['filament', 'filament._id', 'filamentSku', 'state.type'];
const allowedFilters = [
'filament',
'filament._id',
'filamentSku',
'state.type',
'filamentSku._id',
];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {

View File

@ -21,14 +21,36 @@ import {
// list of purchase orders
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['vendor', 'state', 'value', 'vendor._id'];
const allowedFilters = [
'vendor',
'state',
'value',
'vendor._id',
'totalAmount',
'totalAmountWithTax',
'totalTaxAmount',
'shippingAmount',
'shippingAmountWithTax',
'grandTotalAmount',
];
const filter = getFilter(req.query, allowedFilters);
listPurchaseOrdersRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['vendor', 'state.type', 'value', 'vendor._id'];
const allowedFilters = [
'vendor',
'state.type',
'value',
'vendor._id',
'totalAmount',
'totalAmountWithTax',
'totalTaxAmount',
'shippingAmount',
'shippingAmountWithTax',
'grandTotalAmount',
];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {

View File

@ -0,0 +1,55 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { convertPropertiesString, getFilter } from '../../utils.js';
import {
deleteProductCategoryRouteHandler,
editProductCategoryRouteHandler,
getProductCategoryHistoryRouteHandler,
getProductCategoryRouteHandler,
getProductCategoryStatsRouteHandler,
listProductCategoriesByPropertiesRouteHandler,
listProductCategoriesRouteHandler,
newProductCategoryRouteHandler,
} from '../../services/management/productcategories.js';
const router = express.Router();
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['_id', 'name'];
const filter = getFilter(req.query, allowedFilters);
listProductCategoriesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
const properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['name'];
const filter = getFilter(req.query, allowedFilters, false);
listProductCategoriesByPropertiesRouteHandler(req, res, properties, filter);
});
router.post('/', isAuthenticated, (req, res) => {
newProductCategoryRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getProductCategoryStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getProductCategoryHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getProductCategoryRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editProductCategoryRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteProductCategoryRouteHandler(req, res);
});
export default router;

View File

@ -17,7 +17,7 @@ import {
// list of products
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['_id', 'name', 'globalPrice'];
const allowedFilters = ['_id', 'name', 'globalPrice', 'productCategory', 'productCategory._id'];
const filter = getFilter(req.query, allowedFilters);
listProductsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});

View File

@ -16,7 +16,7 @@ import { convertPropertiesString, getFilter } from '../../utils.js';
// list of jobs
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['state'];
const allowedFilters = ['state', 'gcodeFile._id', 'quantity'];
const filter = getFilter(req.query, allowedFilters);
listJobsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});

View File

@ -81,11 +81,7 @@ export const listPaymentsByPropertiesRouteHandler = async (
export const getPaymentRouteHandler = async (req, res) => {
const id = req.params.id;
const populateFields = [
{ path: 'vendor' },
{ path: 'client' },
{ path: 'invoice' },
];
const populateFields = [{ path: 'vendor' }, { path: 'client' }, { path: 'invoice' }];
const result = await getObject({
model: paymentModel,
id,
@ -181,8 +177,11 @@ export const newPaymentRouteHandler = async (req, res) => {
// Get invoice to populate vendor/client
const invoice = await getObject({
model: invoiceModel,
id: req.body.invoice,
populate: [{ path: 'vendor' }, { path: 'client' }],
id: req.body.invoice?._id || req.body.invoice,
populate: [
{ path: 'vendor', strictPopulate: false },
{ path: 'client', strictPopulate: false },
],
});
if (invoice.error) {
@ -370,4 +369,3 @@ export const cancelPaymentRouteHandler = async (req, res) => {
logger.debug(`Cancelled payment with ID: ${id}`);
res.send(result);
};

View File

@ -64,7 +64,7 @@ export const listFilamentStocksByPropertiesRouteHandler = async (
model: filamentStockModel,
properties,
filter,
populate: ['filament', { path: 'filamentSku', populate: 'filament' }, 'stockLocation'],
populate: ['filament', 'filamentSku', 'stockLocation'],
masterFilter,
});

View File

@ -30,31 +30,78 @@ const normalizeLineInput = (l) => ({
toStockLocation: l.toStockLocation?._id ?? l.toStockLocation,
});
async function createStockEventsForLine({ transferId, fromId, fromType, toId, toType, qty, unit }) {
const stockModelByType = {
filamentStock: filamentStockModel,
partStock: partStockModel,
productStock: productStockModel,
};
async function createStockEvent(newData, user) {
const result = await newObject({
model: stockEventModel,
newData,
user,
});
if (result?.error) {
throw new Error(result.error);
}
return result;
}
async function recalculateStock(stockType, stock, user) {
const model = stockModelByType[stockType];
if (!model?.recalculate) return;
await model.recalculate(stock, user);
}
async function createStockEventsForLine({ transferId, fromId, fromType, toId, toType, qty, unit, user }) {
const ts = new Date();
await stockEventModel.insertMany([
{
value: -Math.abs(qty),
unit,
parent: fromId,
parentType: fromType,
owner: transferId,
ownerType: 'stockTransfer',
timestamp: ts,
},
{
value: Math.abs(qty),
unit,
parent: toId,
parentType: toType,
owner: transferId,
ownerType: 'stockTransfer',
timestamp: ts,
},
await Promise.all([
createStockEvent(
{
value: -Math.abs(qty),
unit,
parent: fromId,
parentType: fromType,
owner: transferId,
ownerType: 'stockTransfer',
timestamp: ts,
},
user
),
createStockEvent(
{
value: Math.abs(qty),
unit,
parent: toId,
parentType: toType,
owner: transferId,
ownerType: 'stockTransfer',
timestamp: ts,
},
user
),
]);
}
async function executePostedLine(transferId, line) {
async function createStock(model, newData, user) {
const result = await newObject({
model,
newData,
user,
});
if (result?.error) {
throw new Error(result.error);
}
return result;
}
async function executePostedLine(transferId, line, user) {
const toLocId = line.toStockLocation;
const loc = await stockLocationModel.findById(toLocId).lean();
if (!loc) {
@ -73,25 +120,23 @@ async function executePostedLine(transferId, line) {
throw new Error('Filament transfer quantity exceeds available net weight');
}
const tareBefore = Math.max(0, (src.currentWeight?.gross ?? 0) - (src.currentWeight?.net ?? 0));
const newNet = netAvail - line.quantity;
const ratio = netAvail > 0 ? newNet / netAvail : 0;
const newGross = (src.currentWeight?.gross ?? 0) * ratio;
await filamentStockModel.findByIdAndUpdate(src._id, {
$set: { 'currentWeight.net': newNet, 'currentWeight.gross': newGross },
});
const destWeight = {
net: line.quantity,
gross: line.quantity + tareBefore,
};
const dest = await filamentStockModel.create({
state: src.state,
startingWeight: destWeight,
currentWeight: destWeight,
filament: src.filament,
filamentSku: src.filamentSku,
stockLocation: toLocId,
});
const dest = await createStock(
filamentStockModel,
{
state: src.state,
startingWeight: destWeight,
currentWeight: destWeight,
filament: src.filament,
filamentSku: src.filamentSku,
stockLocation: toLocId,
},
user
);
await createStockEventsForLine({
transferId,
@ -101,31 +146,37 @@ async function executePostedLine(transferId, line) {
toType: 'filamentStock',
qty: line.quantity,
unit: 'g',
user,
});
await Promise.all([
recalculateStock('filamentStock', src, user),
recalculateStock('filamentStock', dest, user),
]);
return { toStockType: 'filamentStock', toStock: dest._id };
}
if (line.fromStockType === 'partStock') {
const src = await partStockModel.findById(line.fromStock);
console.log(src);
if (!src) throw new Error('From part stock not found');
const currentQuantity = src.state.type === 'new' ? src.startingQuantity : src.currentQuantity;
const currentQuantity = src.currentQuantity;
if (line.quantity > currentQuantity) {
throw new Error('Part transfer quantity exceeds current quantity');
}
await partStockModel.findByIdAndUpdate(src._id, {
$inc: { currentQuantity: -line.quantity },
});
const dest = await partStockModel.create({
partSku: src.partSku,
currentQuantity: line.quantity,
state: { type: 'new' },
sourceType: 'stockTransfer',
source: transferId,
stockLocation: toLocId,
});
const dest = await createStock(
partStockModel,
{
partSku: src.partSku,
currentQuantity: line.quantity,
state: { type: 'new' },
sourceType: 'stockTransfer',
source: transferId,
stockLocation: toLocId,
},
user
);
await createStockEventsForLine({
transferId,
@ -135,8 +186,14 @@ async function executePostedLine(transferId, line) {
toType: 'partStock',
qty: line.quantity,
unit: 'each',
user,
});
await Promise.all([
recalculateStock('partStock', src, user),
recalculateStock('partStock', dest, user),
]);
return { toStockType: 'partStock', toStock: dest._id };
}
@ -146,18 +203,19 @@ async function executePostedLine(transferId, line) {
if (line.quantity > src.currentQuantity) {
throw new Error('Product transfer quantity exceeds current quantity');
}
await productStockModel.findByIdAndUpdate(src._id, {
$inc: { currentQuantity: -line.quantity },
});
const dest = await productStockModel.create({
productSku: src.productSku,
currentQuantity: line.quantity,
state: { type: 'posted' },
postedAt: new Date(),
partStocks: [],
stockLocation: toLocId,
});
const dest = await createStock(
productStockModel,
{
productSku: src.productSku,
currentQuantity: line.quantity,
state: { type: 'posted' },
postedAt: new Date(),
partStocks: [],
stockLocation: toLocId,
},
user
);
await createStockEventsForLine({
transferId,
@ -167,8 +225,14 @@ async function executePostedLine(transferId, line) {
toType: 'productStock',
qty: line.quantity,
unit: 'each',
user,
});
await Promise.all([
recalculateStock('productStock', src, user),
recalculateStock('productStock', dest, user),
]);
return { toStockType: 'productStock', toStock: dest._id };
}
@ -280,6 +344,9 @@ export const editStockTransferRouteHandler = async (req, res) => {
const updateData = {
lines: (req.body.lines || []).map((l) => normalizeLineInput(l)),
};
if (req.body.name !== undefined) {
updateData.name = req.body.name;
}
const result = await editObject({
model: stockTransferModel,
@ -325,6 +392,7 @@ export const editMultipleStockTransfersRouteHandler = async (req, res) => {
export const newStockTransferRouteHandler = async (req, res) => {
const newData = {
name: req.body.name,
state: req.body.state ?? { type: 'draft' },
lines: (req.body.lines || []).map((l) => normalizeLineInput(l)),
};
@ -404,7 +472,7 @@ export const postStockTransferRouteHandler = async (req, res) => {
try {
for (const line of doc.lines) {
const plain = line.toObject();
const { toStockType, toStock } = await executePostedLine(doc._id, plain);
const { toStockType, toStock } = await executePostedLine(doc._id, plain, req.user);
updatedLines.push({
...plain,
toStockType,
@ -412,24 +480,34 @@ export const postStockTransferRouteHandler = async (req, res) => {
});
}
const posted = await stockTransferModel
.findByIdAndUpdate(
id,
{
$set: {
state: { type: 'posted' },
postedAt: new Date(),
lines: updatedLines,
},
},
{ new: true }
)
.populate([
const postedResult = await editObject({
model: stockTransferModel,
id,
updateData: {
state: { type: 'posted' },
postedAt: new Date(),
lines: updatedLines,
},
user: req.user,
});
if (postedResult?.error) {
throw new Error(postedResult.error);
}
const posted = await getObject({
model: stockTransferModel,
id,
populate: [
{ path: 'lines.fromStock' },
{ path: 'lines.toStockLocation' },
{ path: 'lines.toStock' },
])
.lean();
],
});
if (posted?.error) {
throw new Error(posted.error);
}
logger.debug(`Posted stock transfer with ID: ${id}`);
res.send(posted);

View File

@ -42,7 +42,10 @@ export const listAuditLogsRouteHandler = async (
.sort({ [sort]: sortOrder })
.skip(skip)
.limit(Number(limit))
.populate('owner', 'name _id');
.populate([
{ path: 'owner', select: 'name _id color' },
{ path: 'parent', select: '_id name' },
]);
const auditLogs = await query;
logger.trace(
@ -51,7 +54,7 @@ export const listAuditLogsRouteHandler = async (
);
const expandedIdAuditLogs = auditLogs.map((auditLog) => {
const expendedAuditLog = { ...auditLog._doc, parent: { _id: auditLog.parent } };
const expendedAuditLog = { ...auditLog._doc };
return expendedAuditLog;
});
res.send(expandedIdAuditLogs);

View File

@ -0,0 +1,177 @@
import config from '../../config.js';
import { productCategoryModel } from '../../database/schemas/management/productcategory.schema.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import {
deleteObject,
editObject,
getModelHistory,
getModelStats,
getObject,
listObjects,
listObjectsByProperties,
newObject,
} from '../../database/database.js';
const logger = log4js.getLogger('ProductCategories');
logger.level = config.server.logLevel;
export const listProductCategoriesRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
const result = await listObjects({
model: productCategoryModel,
page,
limit,
property,
filter,
search,
sort,
order,
populate: [],
});
if (result?.error) {
logger.error('Error listing product categories.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of product categories (Page ${page}, Limit ${limit}). Count: ${result.length}.`);
res.send(result);
};
export const listProductCategoriesByPropertiesRouteHandler = async (
req,
res,
properties = [],
filter = {}
) => {
const result = await listObjectsByProperties({
model: productCategoryModel,
properties,
filter,
populate: [],
});
if (result?.error) {
logger.error('Error listing product categories.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of product categories. Count: ${result.length}`);
res.send(result);
};
export const getProductCategoryRouteHandler = async (req, res) => {
const id = req.params.id;
const result = await getObject({
model: productCategoryModel,
id,
populate: [],
});
if (result?.error) {
logger.warn(`Product category not found with supplied id.`);
return res.status(result.code).send(result);
}
logger.debug(`Retrieved product category with ID: ${id}`);
res.send(result);
};
export const editProductCategoryRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
logger.trace(`Product category with ID: ${id}`);
const updateData = {
updatedAt: new Date(),
name: req.body.name,
};
const result = await editObject({
model: productCategoryModel,
id,
updateData,
user: req.user,
});
if (result.error) {
logger.error('Error editing product category:', result.error);
res.status(result.code).send(result);
return;
}
logger.debug(`Edited product category with ID: ${id}`);
res.send(result);
};
export const newProductCategoryRouteHandler = async (req, res) => {
const newData = {
createdAt: new Date(),
updatedAt: new Date(),
name: req.body.name,
};
const result = await newObject({
model: productCategoryModel,
newData,
user: req.user,
});
if (result.error) {
logger.error('No product category created:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`New product category with ID: ${result._id}`);
res.send(result);
};
export const deleteProductCategoryRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
logger.trace(`Product category with ID: ${id}`);
const result = await deleteObject({
model: productCategoryModel,
id,
user: req.user,
});
if (result.error) {
logger.error('No product category deleted:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Deleted product category with ID: ${result._id}`);
res.send(result);
};
export const getProductCategoryStatsRouteHandler = async (req, res) => {
const result = await getModelStats({ model: productCategoryModel });
if (result?.error) {
logger.error('Error fetching product category stats:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Product category stats:', result);
res.send(result);
};
export const getProductCategoryHistoryRouteHandler = async (req, res) => {
const from = req.query.from;
const to = req.query.to;
const result = await getModelHistory({ model: productCategoryModel, from, to });
if (result?.error) {
logger.error('Error fetching product category history:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Product category history:', result);
res.send(result);
};

View File

@ -35,7 +35,7 @@ export const listProductsRouteHandler = async (
search,
sort,
order,
populate: ['vendor', 'costTaxRate', 'priceTaxRate'],
populate: ['productCategory', 'vendor', 'costTaxRate', 'priceTaxRate'],
});
if (result?.error) {
@ -58,7 +58,7 @@ export const listProductsByPropertiesRouteHandler = async (
model: productModel,
properties,
filter,
populate: ['vendor'],
populate: ['productCategory', 'vendor'],
});
if (result?.error) {
@ -76,7 +76,7 @@ export const getProductRouteHandler = async (req, res) => {
const result = await getObject({
model: productModel,
id,
populate: ['vendor', 'costTaxRate', 'priceTaxRate'],
populate: ['productCategory', 'vendor', 'costTaxRate', 'priceTaxRate'],
});
if (result?.error) {
logger.warn(`Product not found with supplied id.`);
@ -95,6 +95,7 @@ export const editProductRouteHandler = async (req, res) => {
const updateData = {
updatedAt: new Date(),
name: req.body?.name,
productCategory: req.body?.productCategory,
tags: req.body?.tags,
version: req.body?.version,
vendor: req.body.vendor,
@ -130,6 +131,7 @@ export const newProductRouteHandler = async (req, res) => {
const newData = {
updatedAt: new Date(),
name: req.body?.name,
productCategory: req.body?.productCategory,
tags: req.body?.tags,
version: req.body?.version,
vendor: req.body.vendor,

View File

@ -107,6 +107,7 @@ export const editUserRouteHandler = async (req, res) => {
id,
updateData,
user: req.user,
populate: ['profileImage'],
});
if (result.error) {

View File

@ -18,14 +18,24 @@ export const EXPORT_FILTER_BY_TYPE = {
material: ['name', 'tags'],
partStock: ['partSku'],
partSku: ['part', 'vendor', 'priceTaxRate', 'costTaxRate'],
productCategory: ['name'],
product: ['productCategory', 'productCategory._id', 'vendor', 'priceTaxRate', 'costTaxRate'],
productStock: ['productSku'],
productSku: ['product', 'vendor', 'priceTaxRate', 'costTaxRate'],
purchaseOrder: ['vendor'],
purchaseOrder: [
'vendor',
'totalAmount',
'totalAmountWithTax',
'totalTaxAmount',
'shippingAmount',
'shippingAmountWithTax',
'grandTotalAmount',
],
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'],
shipment: ['order._id', 'orderType', 'courierService._id'],
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
stockLocation: ['name', 'address'],
stockTransfer: ['state.type', 'postedAt'],
stockTransfer: ['name', 'state.type', 'postedAt'],
stockAudit: ['filamentStock._id', 'partStock._id'],
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
documentTemplate: ['parent._id', 'documentSize._id'],

View File

@ -40,6 +40,7 @@ const buildSearchFilter = (params) => {
const trimSpotlightObject = (object, objectType) => {
return {
_id: object._id,
_reference: object._reference || undefined,
name: object.name || undefined,
state: object.state && object?.state.type ? { type: object.state.type } : undefined,
tags: object.tags || undefined,
@ -48,6 +49,7 @@ const trimSpotlightObject = (object, objectType) => {
updatedAt: object.updatedAt || undefined,
objectType: objectType || undefined,
online: object.online || undefined,
amount: object.amount || undefined,
};
};

View File

@ -14,21 +14,309 @@ import { createEmailRenderAuthCode } from './services/misc/emailRenderAuth.js';
import { Worker } from 'worker_threads';
import path from 'path';
import { fileURLToPath } from 'url';
import { diffJson } from 'diff';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const logger = log4js.getLogger('Utils');
logger.level = config.server.logLevel;
// ---------------------------------------------------------------------------
// Filter expression parsing (Microsoft Dynamics NAV / Business Central style)
//
// Supported syntax (all compiled down to MongoDB query operators):
// = equal to 377 -> { field: 377 }
// <> not equal to <>0 -> { field: { $ne: 0 } }
// > greater than >1200 -> { field: { $gt: 1200 } }
// >= greater than or equal >=1200 -> { field: { $gte: 1200 } }
// < less than <1200 -> { field: { $lt: 1200 } }
// <= less than or equal <=1200 -> { field: { $lte: 1200 } }
// .. interval 1100..2100 -> { field: { $gte: 1100, $lte: 2100 } }
// open-ended intervals ..2500 / 23..
// | either / or 1200|1300 -> { field: { $in: [1200, 1300] } }
// & and <2000&>1000 -> { field: { $gt: 1000, $lt: 2000 } }
// ( ) grouping / precedence 30|(>=10&<=20)
// * any number of characters Co* / *Co / *Co*
// ? a single character Hans?n
// @ ignore case @location (text matching is case-insensitive)
//
// Date-typed fields (detected by name, e.g. *At / *Date / *Time) interpret bare
// values as calendar days/datetimes, e.g. "22" => the whole of day 22 of the
// current month/year, "22..23" => start of 22 through end of 23.
//
// Note: BC's space-delimited date shorthand is locale dependent; here numeric
// date operands are read day-first ("D M Y H Min S") with missing components
// filled from the current date and the interval boundary.
// ---------------------------------------------------------------------------
function buildWildcardRegexPattern(input) {
// Escape all regex special chars except * (which we treat as a wildcard)
const escaped = input.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
// Convert * to "match anything"
const withWildcards = escaped.replace(/\*/g, '.*');
// Anchor so that, without *, this is an exact match
// Escape all regex special chars except * and ? (which we treat as wildcards)
const escaped = String(input).replace(/[.+^${}()|[\]\\]/g, '\\$&');
// * matches any run of characters, ? matches exactly one character
const withWildcards = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
// Anchor so that, without wildcards, this is an exact match
return `^${withWildcards}$`;
}
function looksLikeDateField(property) {
const last = String(property).split('.').pop();
return /[a-z](?:At|Date|Time)$/.test(last) || /^(?:date|time|datetime)$/i.test(last);
}
function isObjectIdString(value) {
return /^[a-f\d]{24}$/i.test(value);
}
function isNumeric(value) {
return value.trim() !== '' && !isNaN(value);
}
function startOfDay(date) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
function endOfDay(date) {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
}
function normalizeYear(year) {
if (year >= 100) return year;
return year < 70 ? 2000 + year : 1900 + year;
}
// Parses a single date operand to a Date, filling missing parts from the
// current date and the supplied interval boundary ('start' | 'end').
function parseDateOperand(value, boundary = 'start') {
const text = String(value).trim();
if (!text) return null;
const end = boundary === 'end';
// Values with explicit separators (ISO and similar) are parsed directly.
if (/[-/T]/.test(text) || /\d:\d/.test(text)) {
const parsed = new Date(text);
if (isNaN(parsed.getTime())) return null;
if (!/[T:]/.test(text)) {
return end ? endOfDay(parsed) : startOfDay(parsed);
}
return parsed;
}
// Numeric, day-first form: "D [M] [Y] [H] [Min] [S]".
const parts = text.split(/\s+/);
if (!parts.every((part) => /^\d+$/.test(part))) return null;
const nums = parts.map(Number);
const now = new Date();
const day = nums[0];
const month = nums.length >= 2 ? nums[1] : now.getMonth() + 1;
const year = nums.length >= 3 ? normalizeYear(nums[2]) : now.getFullYear();
const hour = nums.length >= 4 ? nums[3] : end ? 23 : 0;
const minute = nums.length >= 5 ? nums[4] : end ? 59 : 0;
const second = nums.length >= 6 ? nums[5] : end ? 59 : 0;
const ms = end ? 999 : 0;
const date = new Date(year, month - 1, day, hour, minute, second, ms);
return isNaN(date.getTime()) ? null : date;
}
// Coerces a literal to its strongest matching scalar type.
function coerceScalar(value) {
const lower = value.toLowerCase();
if (lower === 'true') return true;
if (lower === 'false') return false;
if (isObjectIdString(value)) return new mongoose.Types.ObjectId(value);
if (isNumeric(value)) return Number(value);
return value;
}
// Coerces an interval/comparison boundary, preferring a date for date fields.
function coerceBoundary(value, isDateField, boundary) {
if (isDateField) {
const date = parseDateOperand(value, boundary);
if (date) return date;
}
return coerceScalar(value);
}
// Splits on a separator while respecting parentheses depth.
function splitTopLevel(str, separator) {
const parts = [];
let depth = 0;
let current = '';
for (const ch of str) {
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
if (ch === separator && depth === 0) {
parts.push(current);
current = '';
} else {
current += ch;
}
}
parts.push(current);
return parts;
}
// True when a single outer pair of parentheses wraps the whole token.
function isWrappedInParens(str) {
if (!str.startsWith('(') || !str.endsWith(')')) return false;
let depth = 0;
for (let i = 0; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') {
depth--;
if (depth === 0 && i < str.length - 1) return false;
}
}
return depth === 0;
}
// Parses a filter expression into an AST of or / and / leaf nodes.
function parseExpression(str) {
const orParts = splitTopLevel(str, '|');
if (orParts.length > 1) {
return { type: 'or', items: orParts.map(parseExpression) };
}
const andParts = splitTopLevel(str, '&');
if (andParts.length > 1) {
return { type: 'and', items: andParts.map(parseExpression) };
}
const trimmed = str.trim();
if (isWrappedInParens(trimmed)) {
return parseExpression(trimmed.slice(1, -1));
}
return { type: 'leaf', token: trimmed };
}
// Strips a leading @ (ignore-case marker); text matching is already case-insensitive.
function stripIgnoreCase(str) {
return str.startsWith('@') ? str.slice(1) : str;
}
// Builds an equality condition (value, date range, or wildcard regex).
function buildEquality(value, isDateField) {
if (isDateField) {
const start = parseDateOperand(value, 'start');
const end = parseDateOperand(value, 'end');
if (start && end) return { op: { $gte: start, $lte: end } };
}
const lower = value.toLowerCase();
if (lower === 'true') return { value: true };
if (lower === 'false') return { value: false };
if (isObjectIdString(value)) return { value: new mongoose.Types.ObjectId(value) };
if (isNumeric(value)) return { value: Number(value) };
return { op: { $regex: buildWildcardRegexPattern(value), $options: 'i' } };
}
// Builds a comparison condition for a single operator.
function buildComparison(name, value, isDateField) {
if (name === 'eq') return buildEquality(value, isDateField);
if (name === 'ne') {
if (isDateField) {
const start = parseDateOperand(value, 'start');
const end = parseDateOperand(value, 'end');
if (start && end) return { op: { $not: { $gte: start, $lte: end } } };
}
if (/[*?]/.test(value)) {
return { op: { $not: { $regex: buildWildcardRegexPattern(value), $options: 'i' } } };
}
return { op: { $ne: coerceScalar(value) } };
}
// For dates, < and >= align to the start of the day, > and <= to the end.
const boundary = name === 'gt' || name === 'lte' ? 'end' : 'start';
return { op: { [`$${name}`]: coerceBoundary(value, isDateField, boundary) } };
}
// Parses a leaf token (range, comparison, or plain value) into a condition.
function parseLeafCondition(rawToken, isDateField) {
const token = rawToken.trim();
if (token === '') {
return { op: { $regex: '^$', $options: 'i' } };
}
// Interval: a..b, ..b, a..
const rangeIdx = token.indexOf('..');
if (rangeIdx !== -1) {
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim());
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim());
const op = {};
if (lo !== '') op.$gte = coerceBoundary(lo, isDateField, 'start');
if (hi !== '') op.$lte = coerceBoundary(hi, isDateField, 'end');
// A bare ".." places no constraint on the field.
if (Object.keys(op).length === 0) return { query: {} };
return { op };
}
// Comparison operators, longest symbols first.
const operators = [
['<>', 'ne'],
['>=', 'gte'],
['<=', 'lte'],
['>', 'gt'],
['<', 'lt'],
['=', 'eq'],
];
for (const [symbol, name] of operators) {
if (token.startsWith(symbol)) {
return buildComparison(name, stripIgnoreCase(token.slice(symbol.length).trim()), isDateField);
}
}
return buildEquality(stripIgnoreCase(token), isDateField);
}
// Converts a condition descriptor into a MongoDB query object for `property`.
function conditionToQuery(cond, property) {
if (cond.query) return cond.query;
if (cond.op) return { [property]: cond.op };
return { [property]: cond.value };
}
function isOnlyRegex(op) {
return Object.keys(op).every((key) => key === '$regex' || key === '$options');
}
// Combines AND-ed conditions, merging operator objects on the same field where possible.
function combineAnd(children, property) {
if (children.some((child) => child.query)) {
return { query: { $and: children.map((child) => conditionToQuery(child, property)) } };
}
const merged = {};
for (const child of children) {
if (child.value !== undefined) {
merged.$eq = child.value;
} else if (child.op) {
Object.assign(merged, child.op);
}
}
return { op: merged };
}
// Combines OR-ed conditions, collapsing to $in or a single regex when possible.
function combineOr(children, property) {
if (children.every((child) => child.value !== undefined)) {
return { op: { $in: children.map((child) => child.value) } };
}
if (children.every((child) => child.op && isOnlyRegex(child.op))) {
const pattern = children.map((child) => child.op.$regex).join('|');
return { op: { $regex: pattern, $options: 'i' } };
}
return { query: { $or: children.map((child) => conditionToQuery(child, property)) } };
}
function buildCondition(node, property, isDateField) {
if (node.type === 'leaf') {
return parseLeafCondition(node.token, isDateField);
}
const children = node.items.map((item) => buildCondition(item, property, isDateField));
return node.type === 'and' ? combineAnd(children, property) : combineOr(children, property);
}
function parseFilter(property, value) {
// Normalize state filter to state.type for schemas with state: { type }
if (property === 'state') {
@ -38,40 +326,22 @@ function parseFilter(property, value) {
if (value?._id !== undefined && value?._id !== null) {
return { [property]: { _id: new mongoose.Types.ObjectId(value._id) } };
}
if (typeof value === 'string') {
var trimmed = value.trim();
if (trimmed.charAt(3) == ':') {
trimmed = value.split(':')[1];
}
// Handle booleans
if (trimmed.toLowerCase() === 'true') return { [property]: true };
if (trimmed.toLowerCase() === 'false') return { [property]: false };
// Handle ObjectId (24-char hex)
if (/^[a-f\d]{24}$/i.test(trimmed) && trimmed.length >= 24) {
return { [property]: new mongoose.Types.ObjectId(trimmed) };
}
// Handle numbers
if (!isNaN(trimmed)) {
return { [property]: parseFloat(trimmed) };
}
// Default to case-insensitive regex for non-numeric strings.
// Supports * as a wildcard (e.g. "filament*" matches "filament stock").
const pattern = buildWildcardRegexPattern(trimmed);
return {
[property]: {
$regex: pattern,
$options: 'i',
},
};
// Non-string values (actual booleans, numbers, objects, etc.) pass through.
if (typeof value !== 'string') {
return { [property]: value };
}
// Handle actual booleans, numbers, objects, etc.
return { [property]: value };
let trimmed = value.trim();
if (trimmed.charAt(3) === ':') {
const afterColon = value.split(':')[1];
trimmed = afterColon != null ? afterColon.trim() : '';
}
const isDateField = looksLikeDateField(property);
const tree = parseExpression(trimmed);
const condition = buildCondition(tree, property, isDateField);
return conditionToQuery(condition, property);
}
function convertToCamelCase(obj) {
@ -303,6 +573,30 @@ function extractGCodeConfigBlock(fileContent, useCamelCase = true) {
return useCamelCase ? convertToCamelCase(configObject) : configObject;
}
const AUDIT_IGNORED_KEYS = ['createdAt', 'updatedAt', '_id'];
function isDiffableObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) && value !== null;
}
function isNumericValue(value) {
return (
typeof value === 'number' ||
(value !== null && value !== undefined && !isNaN(Number(value)) && value !== '')
);
}
function normalizeDiffValue(value) {
return isNumericValue(value) ? Number(value) : value;
}
function valuesDiffer(oldVal, newVal) {
return diffJson(
{ value: normalizeDiffValue(oldVal) },
{ value: normalizeDiffValue(newVal) }
).some((part) => part.added || part.removed);
}
function getChangedValues(oldObj, newObj, old = false) {
const changes = {};
@ -311,24 +605,15 @@ function getChangedValues(oldObj, newObj, old = false) {
// Check all keys in the new object
for (const key in combinedObj) {
// Skip if the key is _id or timestamps
if (key === 'createdAt' || key === 'updatedAt' || key === '_id') continue;
if (AUDIT_IGNORED_KEYS.includes(key)) continue;
const oldVal = oldObj ? oldObj[key] : undefined;
const newVal = newObj ? newObj[key] : undefined;
// If both values are objects (but not arrays or null), recurse
if (
oldVal &&
newVal &&
typeof oldVal === 'object' &&
typeof newVal === 'object' &&
!Array.isArray(oldVal) &&
!Array.isArray(newVal) &&
oldVal !== null &&
newVal !== null
) {
if (isDiffableObject(oldVal) && isDiffableObject(newVal)) {
if (oldVal?._id || newVal?._id) {
if (JSON.stringify(oldVal?._id) !== JSON.stringify(newVal?._id)) {
if (valuesDiffer(oldVal?._id, newVal?._id)) {
changes[key] = old ? oldVal : newVal;
}
} else {
@ -338,24 +623,7 @@ function getChangedValues(oldObj, newObj, old = false) {
}
}
} else {
// Check if both values are numbers (or can be converted to numbers)
const oldIsNumber =
typeof oldVal === 'number' ||
(oldVal !== null && oldVal !== undefined && !isNaN(Number(oldVal)) && oldVal !== '');
const newIsNumber =
typeof newVal === 'number' ||
(newVal !== null && newVal !== undefined && !isNaN(Number(newVal)) && newVal !== '');
let valuesDiffer;
if (oldIsNumber && newIsNumber) {
// Compare numbers directly (this normalizes 7.50 to 7.5)
valuesDiffer = Number(oldVal) !== Number(newVal);
} else {
// Use JSON.stringify for non-number comparisons
valuesDiffer = JSON.stringify(oldVal) !== JSON.stringify(newVal);
}
if (valuesDiffer) {
if (valuesDiffer(oldVal, newVal)) {
// If the old value is different from the new value, include it
changes[key] = old ? oldVal : newVal;
}
@ -426,6 +694,8 @@ async function editAuditLog(oldValue, newValue, parentId, parentType, user) {
operation: 'edit',
});
console.log('auditLog', oldValue);
await auditLog.save();
await distributeNew(auditLog._id, 'auditLog');
@ -544,29 +814,52 @@ async function distributeDelete(value, type) {
await natsServer.publish(`${type}s.delete`, value);
}
function getReferenceId(value) {
if (value instanceof mongoose.Types.ObjectId) {
return value;
}
if (value && typeof value === 'object' && value._id) {
return getReferenceId(value._id);
}
return value;
}
function getReferenceIdString(value) {
const id = getReferenceId(value);
return id == null ? null : id.toString();
}
async function distributeChildUpdate(oldValue, newValue, id, model) {
const oldPopulatedObjects = populateObjects(oldValue, model) || [];
const oldPopulatedObjectIds = oldPopulatedObjects.map((populate) => populate._id.toString());
const oldPopulatedObjectIds = oldPopulatedObjects.map((populate) =>
getReferenceIdString(populate._id)
);
const newPopulatedObjects = populateObjects(newValue, model) || [];
const newPopulatedObjectIds = newPopulatedObjects.map((populate) => populate._id.toString());
const newPopulatedObjectIds = newPopulatedObjects.map((populate) =>
getReferenceIdString(populate._id)
);
for (const populated of oldPopulatedObjects) {
if (!newPopulatedObjectIds.includes(populated._id.toString())) {
const populatedId = getReferenceIdString(populated._id);
if (!populatedId) continue;
if (!newPopulatedObjectIds.includes(populatedId)) {
logger.debug(
`Distributing child update for ${populated.ref}s.${populated._id}.events.childUpdate`
`Distributing child update for ${populated.ref}s.${populatedId}.events.childUpdate`
);
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childUpdate`, {
await natsServer.publish(`${populated.ref}s.${populatedId}.events.childUpdate`, {
type: 'childUpdate',
data: { parentId: id, parentType: model.modelName },
});
}
}
for (const populated of newPopulatedObjects) {
if (!oldPopulatedObjectIds.includes(populated._id.toString())) {
const populatedId = getReferenceIdString(populated._id);
if (!populatedId) continue;
if (!oldPopulatedObjectIds.includes(populatedId)) {
logger.debug(
`Distributing child update for ${populated.ref}s.${populated._id}.events.childUpdate`
`Distributing child update for ${populated.ref}s.${populatedId}.events.childUpdate`
);
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childUpdate`, {
await natsServer.publish(`${populated.ref}s.${populatedId}.events.childUpdate`, {
type: 'childUpdate',
data: { parentId: id, parentType: model.modelName },
});
@ -577,10 +870,12 @@ async function distributeChildUpdate(oldValue, newValue, id, model) {
async function distributeChildDelete(value, id, model) {
const populatedObjects = populateObjects(value, model) || [];
for (const populated of populatedObjects) {
const populatedId = getReferenceIdString(populated._id);
if (!populatedId) continue;
logger.debug(
`Distributing child delete for ${populated.ref}s.${populated._id}.events.childDelete`
`Distributing child delete for ${populated.ref}s.${populatedId}.events.childDelete`
);
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childDelete`, {
await natsServer.publish(`${populated.ref}s.${populatedId}.events.childDelete`, {
type: 'childDelete',
data: { parentId: id, parentType: model.modelName },
});
@ -590,8 +885,10 @@ async function distributeChildDelete(value, id, model) {
async function distributeChildNew(value, id, model) {
const populatedObjects = populateObjects(value, model) || [];
for (const populated of populatedObjects) {
logger.debug(`Distributing child new for ${populated.ref}s.${populated._id}.events.childNew`);
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childNew`, {
const populatedId = getReferenceIdString(populated._id);
if (!populatedId) continue;
logger.debug(`Distributing child new for ${populated.ref}s.${populatedId}.events.childNew`);
await natsServer.publish(`${populated.ref}s.${populatedId}.events.childNew`, {
type: 'childNew',
data: { parentId: id, parentType: model.modelName },
});
@ -760,16 +1057,36 @@ function expandObjectIds(input) {
return expand(input);
}
// Returns a filter object based on allowed filters and req.query
function getFilter(query, allowedFilters, parse = true) {
let filter = {};
for (const [key, value] of Object.entries(query)) {
if (allowedFilters.includes(key)) {
const parsedFilter = parse ? parseFilter(key, value) : { [key]: value };
filter = { ...filter, ...parsedFilter };
// Merges per-field filter clauses, falling back to $and when clauses use
// logical operators or target the same field (so nothing is silently lost).
function mergeFilterClauses(clauses) {
const valid = clauses.filter((clause) => clause && Object.keys(clause).length > 0);
if (valid.length === 0) return {};
if (valid.length === 1) return valid[0];
const seen = new Set();
let needsAnd = false;
for (const clause of valid) {
for (const key of Object.keys(clause)) {
if (key === '$or' || key === '$and' || seen.has(key)) {
needsAnd = true;
}
seen.add(key);
}
}
return filter;
return needsAnd ? { $and: valid } : Object.assign({}, ...valid);
}
// Returns a filter object based on allowed filters and req.query
function getFilter(query, allowedFilters, parse = true) {
const clauses = [];
for (const [key, value] of Object.entries(query)) {
if (allowedFilters.includes(key)) {
clauses.push(parse ? parseFilter(key, value) : { [key]: value });
}
}
return mergeFilterClauses(clauses);
}
// Converts a properties argument (string or array) to an array of strings
@ -869,14 +1186,15 @@ function buildDeepPopulateSpec(object, model, populated = new Set()) {
const refModel = model.db.model(refName);
const childPopulate = buildDeepPopulateSpec(object, refModel, populated);
const id = object[pathname]?._id || object[pathname];
const values = Array.isArray(object[pathname]) ? object[pathname] : [object[pathname]];
const ids = values.map(getReferenceId).filter(Boolean);
if (id == null || !id) return;
if (childPopulate.length > 0) {
populateSpec.push({ path: pathname, populate: childPopulate, ref: refName, _id: id });
} else {
populateSpec.push({ path: pathname, ref: refName, _id: id });
for (const id of ids) {
if (childPopulate.length > 0) {
populateSpec.push({ path: pathname, populate: childPopulate, ref: refName, _id: id });
} else {
populateSpec.push({ path: pathname, ref: refName, _id: id });
}
}
});