From af0934b163a1e61458b42edd9f86935bba7ab225 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 14 Jun 2026 23:51:51 +0100 Subject: [PATCH] Added product category support, including new routes, database schemas, and service updates. Enhanced existing product functionalities to incorporate product categories in filtering and population. --- package.json | 1 + pnpm-lock.yaml | 9 + .../schemas/inventory/filamentstock.schema.js | 54 +- .../schemas/inventory/partstock.schema.js | 36 +- .../schemas/inventory/productstock.schema.js | 36 +- .../schemas/inventory/stocktransfer.schema.js | 5 +- .../schemas/management/product.schema.js | 1 + .../management/productcategory.schema.js | 18 + src/database/schemas/models.js | 16 + src/index.js | 2 + src/routes/index.js | 2 + src/routes/inventory/filamentstocks.js | 8 +- src/routes/inventory/purchaseorders.js | 26 +- src/routes/management/productcategories.js | 55 ++ src/routes/management/products.js | 2 +- src/routes/production/jobs.js | 2 +- src/services/finance/payments.js | 14 +- src/services/inventory/filamentstocks.js | 2 +- src/services/inventory/stocktransfers.js | 228 +++++--- src/services/management/auditlogs.js | 7 +- src/services/management/productcategories.js | 177 ++++++ src/services/management/products.js | 8 +- src/services/management/users.js | 1 + src/services/misc/export.js | 14 +- src/services/misc/spotlight.js | 2 + src/utils.js | 504 ++++++++++++++---- 26 files changed, 1035 insertions(+), 195 deletions(-) create mode 100644 src/database/schemas/management/productcategory.schema.js create mode 100644 src/routes/management/productcategories.js create mode 100644 src/services/management/productcategories.js diff --git a/package.json b/package.json index 3d3f510..51324ac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7768e19..aa47af5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/database/schemas/inventory/filamentstock.schema.js b/src/database/schemas/inventory/filamentstock.schema.js index e9743fa..7a3abca 100644 --- a/src/database/schemas/inventory/filamentstock.schema.js +++ b/src/database/schemas/inventory/filamentstock.schema.js @@ -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; diff --git a/src/database/schemas/inventory/partstock.schema.js b/src/database/schemas/inventory/partstock.schema.js index f8d9347..a57a3f9 100644 --- a/src/database/schemas/inventory/partstock.schema.js +++ b/src/database/schemas/inventory/partstock.schema.js @@ -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; diff --git a/src/database/schemas/inventory/productstock.schema.js b/src/database/schemas/inventory/productstock.schema.js index b6038c0..d0722d1 100644 --- a/src/database/schemas/inventory/productstock.schema.js +++ b/src/database/schemas/inventory/productstock.schema.js @@ -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; diff --git a/src/database/schemas/inventory/stocktransfer.schema.js b/src/database/schemas/inventory/stocktransfer.schema.js index 284bcd4..9d0e706 100644 --- a/src/database/schemas/inventory/stocktransfer.schema.js +++ b/src/database/schemas/inventory/stocktransfer.schema.js @@ -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 }, diff --git a/src/database/schemas/management/product.schema.js b/src/database/schemas/management/product.schema.js index fcc9392..c3640b4 100644 --- a/src/database/schemas/management/product.schema.js +++ b/src/database/schemas/management/product.schema.js @@ -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 }, diff --git a/src/database/schemas/management/productcategory.schema.js b/src/database/schemas/management/productcategory.schema.js new file mode 100644 index 0000000..d01ce07 --- /dev/null +++ b/src/database/schemas/management/productcategory.schema.js @@ -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); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index f771452..8069e09 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -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', + }, }; diff --git a/src/index.js b/src/index.js index b13c3fa..f0db454 100644 --- a/src/index.js +++ b/src/index.js @@ -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); diff --git a/src/routes/index.js b/src/routes/index.js index 65e2378..edce877 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -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, diff --git a/src/routes/inventory/filamentstocks.js b/src/routes/inventory/filamentstocks.js index 6a59519..8e6fe6b 100644 --- a/src/routes/inventory/filamentstocks.js +++ b/src/routes/inventory/filamentstocks.js @@ -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) { diff --git a/src/routes/inventory/purchaseorders.js b/src/routes/inventory/purchaseorders.js index 0977cc8..1207096 100644 --- a/src/routes/inventory/purchaseorders.js +++ b/src/routes/inventory/purchaseorders.js @@ -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) { diff --git a/src/routes/management/productcategories.js b/src/routes/management/productcategories.js new file mode 100644 index 0000000..e3dde6b --- /dev/null +++ b/src/routes/management/productcategories.js @@ -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; diff --git a/src/routes/management/products.js b/src/routes/management/products.js index 4276d82..9a3cb95 100644 --- a/src/routes/management/products.js +++ b/src/routes/management/products.js @@ -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); }); diff --git a/src/routes/production/jobs.js b/src/routes/production/jobs.js index 412bdd8..2a3c2b1 100644 --- a/src/routes/production/jobs.js +++ b/src/routes/production/jobs.js @@ -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); }); diff --git a/src/services/finance/payments.js b/src/services/finance/payments.js index 09ac5aa..e946536 100644 --- a/src/services/finance/payments.js +++ b/src/services/finance/payments.js @@ -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); }; - diff --git a/src/services/inventory/filamentstocks.js b/src/services/inventory/filamentstocks.js index 4b26d3b..b81a466 100644 --- a/src/services/inventory/filamentstocks.js +++ b/src/services/inventory/filamentstocks.js @@ -64,7 +64,7 @@ export const listFilamentStocksByPropertiesRouteHandler = async ( model: filamentStockModel, properties, filter, - populate: ['filament', { path: 'filamentSku', populate: 'filament' }, 'stockLocation'], + populate: ['filament', 'filamentSku', 'stockLocation'], masterFilter, }); diff --git a/src/services/inventory/stocktransfers.js b/src/services/inventory/stocktransfers.js index 898f49a..9d6f139 100644 --- a/src/services/inventory/stocktransfers.js +++ b/src/services/inventory/stocktransfers.js @@ -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); diff --git a/src/services/management/auditlogs.js b/src/services/management/auditlogs.js index a94a90a..029546d 100644 --- a/src/services/management/auditlogs.js +++ b/src/services/management/auditlogs.js @@ -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); diff --git a/src/services/management/productcategories.js b/src/services/management/productcategories.js new file mode 100644 index 0000000..b89b9ea --- /dev/null +++ b/src/services/management/productcategories.js @@ -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); +}; diff --git a/src/services/management/products.js b/src/services/management/products.js index 0bc6d4c..94ff2d6 100644 --- a/src/services/management/products.js +++ b/src/services/management/products.js @@ -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, diff --git a/src/services/management/users.js b/src/services/management/users.js index 656db0d..6486fb9 100644 --- a/src/services/management/users.js +++ b/src/services/management/users.js @@ -107,6 +107,7 @@ export const editUserRouteHandler = async (req, res) => { id, updateData, user: req.user, + populate: ['profileImage'], }); if (result.error) { diff --git a/src/services/misc/export.js b/src/services/misc/export.js index bb1c504..f633b54 100644 --- a/src/services/misc/export.js +++ b/src/services/misc/export.js @@ -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'], diff --git a/src/services/misc/spotlight.js b/src/services/misc/spotlight.js index 04cae79..dead5e9 100644 --- a/src/services/misc/spotlight.js +++ b/src/services/misc/spotlight.js @@ -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, }; }; diff --git a/src/utils.js b/src/utils.js index d2f9875..028835e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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 }); + } } });