From 3e47cb131be8edb66e79b080034494030cea1c3d Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 1 Mar 2026 01:42:30 +0000 Subject: [PATCH] Implemented notifications. --- src/database/database.js | 31 ++- .../schemas/misc/notification.schema.js | 51 ++++ .../schemas/misc/usernotifier.schema.js | 44 ++++ src/database/schemas/models.js | 218 ++++++++++++++++-- src/index.js | 4 + src/routes/index.js | 4 + src/routes/misc/notifications.js | 37 +++ src/routes/misc/usernotifiers.js | 37 +++ src/services/misc/notifications.js | 105 +++++++++ src/services/misc/usernotifiers.js | 146 ++++++++++++ src/utils.js | 79 +++++++ 11 files changed, 727 insertions(+), 29 deletions(-) create mode 100644 src/database/schemas/misc/notification.schema.js create mode 100644 src/database/schemas/misc/usernotifier.schema.js create mode 100644 src/routes/misc/notifications.js create mode 100644 src/routes/misc/usernotifiers.js create mode 100644 src/services/misc/notifications.js create mode 100644 src/services/misc/usernotifiers.js diff --git a/src/database/database.js b/src/database/database.js index 1849aa9..0735f00 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -1,6 +1,7 @@ import config from '../config.js'; import { fileModel } from './schemas/management/file.schema.js'; import _ from 'lodash'; +import log4js from 'log4js'; import { deleteAuditLog, distributeDelete, @@ -8,9 +9,6 @@ import { modelHasRef, getFieldsByRef, getQueryToCacheKey, -} from '../utils.js'; -import log4js from 'log4js'; -import { editAuditLog, distributeUpdate, newAuditLog, @@ -19,6 +17,8 @@ import { distributeChildDelete, distributeChildNew, distributeStats, + editNotification, + deleteNotification, } from '../utils.js'; import { getAllModels } from '../services/misc/model.js'; import { redisServer } from './redis.js'; @@ -802,6 +802,20 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc parentType, user ); + + if ( + parentType !== 'notification' && + parentType !== 'auditLog' && + parentType !== 'userNotifier' + ) { + await editNotification( + previousExpandedObject, + { ...previousExpandedObject, ...updateData }, + id, + parentType, + user + ); + } // Distribute update await distributeUpdate(updateData, id, parentType); // Call childUpdate event for any child objects @@ -878,6 +892,7 @@ export const newObject = async ({ model, newData, user = null }, distributeChang const created = expandObjectIds(result.toObject()); await newAuditLog(newData, created._id, parentType, user); + if (distributeChanges == true) { await distributeNew(created, parentType); } @@ -922,7 +937,15 @@ export const deleteObject = async ({ model, id, user = null }, distributeChanges const deleted = expandObjectIds(result.toObject()); // Audit log the deletion - await deleteAuditLog(deleted, id, parentType, user, 'delete'); + await deleteAuditLog(deleted, id, parentType, user); + + if ( + parentType !== 'notification' && + parentType !== 'auditLog' && + parentType !== 'userNotifier' + ) { + await deleteNotification(deleted, id, parentType, user); + } if (distributeChanges == true) { await distributeDelete(deleted, parentType); diff --git a/src/database/schemas/misc/notification.schema.js b/src/database/schemas/misc/notification.schema.js new file mode 100644 index 0000000..aff1168 --- /dev/null +++ b/src/database/schemas/misc/notification.schema.js @@ -0,0 +1,51 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; + +const notificationSchema = new mongoose.Schema({ + _reference: { type: String, default: () => generateId()() }, + user: { + type: Schema.Types.ObjectId, + ref: 'user', + required: true, + }, + title: { + type: String, + required: true, + }, + message: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + default: 'info', + }, + read: { + type: Boolean, + default: false, + }, + metadata: { + type: Object, + required: false, + }, + createdAt: { + type: Date, + required: true, + default: Date.now, + }, + updatedAt: { + type: Date, + required: true, + default: Date.now, + }, +}); + +notificationSchema.virtual('id').get(function () { + return this._id; +}); + +notificationSchema.set('toJSON', { virtuals: true }); + +export const notificationModel = mongoose.model('notification', notificationSchema); diff --git a/src/database/schemas/misc/usernotifier.schema.js b/src/database/schemas/misc/usernotifier.schema.js new file mode 100644 index 0000000..eb9cd3a --- /dev/null +++ b/src/database/schemas/misc/usernotifier.schema.js @@ -0,0 +1,44 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; + +const userNotifierSchema = new mongoose.Schema({ + _reference: { type: String, default: () => generateId()() }, + user: { + type: Schema.Types.ObjectId, + ref: 'user', + required: true, + }, + email: { + type: Boolean, + required: true, + default: false, + }, + object: { + type: Schema.Types.ObjectId, + refPath: 'objectType', + required: true, + }, + objectType: { + type: String, + required: true, + }, + createdAt: { + type: Date, + required: true, + default: Date.now, + }, + updatedAt: { + type: Date, + required: true, + default: Date.now, + }, +}); + +userNotifierSchema.virtual('id').get(function () { + return this._id; +}); + +userNotifierSchema.set('toJSON', { virtuals: true }); + +export const userNotifierModel = mongoose.model('userNotifier', userNotifierSchema); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 21315df..e1cb4f5 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -16,6 +16,8 @@ import { auditLogModel } from './management/auditlog.schema.js'; import { userModel } from './management/user.schema.js'; import { noteTypeModel } from './management/notetype.schema.js'; import { noteModel } from './misc/note.schema.js'; +import { notificationModel } from './misc/notification.schema.js'; +import { userNotifierModel } from './misc/usernotifier.schema.js'; import { documentSizeModel } from './management/documentsize.schema.js'; import { documentTemplateModel } from './management/documenttemplate.schema.js'; import { hostModel } from './management/host.schema.js'; @@ -33,77 +35,243 @@ import { salesOrderModel } from './sales/salesorder.schema.js'; // Map prefixes to models and id fields export const models = { - PRN: { model: printerModel, idField: '_id', type: 'printer', referenceField: '_reference' }, - FIL: { model: filamentModel, idField: '_id', type: 'filament', referenceField: '_reference' }, - GCF: { model: gcodeFileModel, idField: '_id', type: 'gcodeFile', referenceField: '_reference' }, - JOB: { model: jobModel, idField: '_id', type: 'job', referenceField: '_reference' }, - PRT: { model: partModel, idField: '_id', type: 'part', referenceField: '_reference' }, - PRD: { model: productModel, idField: '_id', type: 'product', referenceField: '_reference' }, - VEN: { model: vendorModel, idField: '_id', type: 'vendor', referenceField: '_reference' }, - SJB: { model: subJobModel, idField: '_id', type: 'subJob', referenceField: '_reference' }, + PRN: { + model: printerModel, + idField: '_id', + type: 'printer', + referenceField: '_reference', + label: 'Printer', + }, + FIL: { + model: filamentModel, + idField: '_id', + type: 'filament', + referenceField: '_reference', + label: 'Filament', + }, + GCF: { + model: gcodeFileModel, + idField: '_id', + type: 'gcodeFile', + referenceField: '_reference', + label: 'G-Code File', + }, + JOB: { model: jobModel, idField: '_id', type: 'job', referenceField: '_reference', label: 'Job' }, + PRT: { + model: partModel, + idField: '_id', + type: 'part', + referenceField: '_reference', + label: 'Part', + }, + PRD: { + model: productModel, + idField: '_id', + type: 'product', + referenceField: '_reference', + label: 'Product', + }, + VEN: { + model: vendorModel, + idField: '_id', + type: 'vendor', + referenceField: '_reference', + label: 'Vendor', + }, + SJB: { + model: subJobModel, + idField: '_id', + type: 'subJob', + referenceField: '_reference', + label: 'Sub Job', + }, FLS: { model: filamentStockModel, idField: '_id', type: 'filamentStock', referenceField: '_reference', + label: 'Filament Stock', + }, + SEV: { + model: stockEventModel, + idField: '_id', + type: 'stockEvent', + referenceField: '_reference', + label: 'Stock Event', + }, + SAU: { + model: stockAuditModel, + idField: '_id', + type: 'stockAudit', + referenceField: '_reference', + label: 'Stock Audit', + }, + PTS: { + model: partStockModel, + idField: '_id', + type: 'partStock', + referenceField: '_reference', + label: 'Part Stock', + }, + PDS: { + model: null, + idField: '_id', + type: 'productStock', + referenceField: '_reference', + label: 'Product Stock', + }, // No productStockModel found + ADL: { + model: auditLogModel, + idField: '_id', + type: 'auditLog', + referenceField: '_reference', + label: 'Audit Log', + }, + USR: { + model: userModel, + idField: '_id', + type: 'user', + referenceField: '_reference', + label: 'User', + }, + NTY: { + model: noteTypeModel, + idField: '_id', + type: 'noteType', + referenceField: '_reference', + label: 'Note Type', + }, + NTE: { + model: noteModel, + idField: '_id', + type: 'note', + referenceField: '_reference', + label: 'Note', + }, + NTF: { + model: notificationModel, + idField: '_id', + type: 'notification', + label: 'Notification', + referenceField: '_reference', + }, + ONF: { + model: userNotifierModel, + idField: '_id', + type: 'userNotifier', + label: 'User Notifier', + referenceField: '_reference', }, - SEV: { model: stockEventModel, idField: '_id', type: 'stockEvent', referenceField: '_reference' }, - SAU: { model: stockAuditModel, idField: '_id', type: 'stockAudit', referenceField: '_reference' }, - PTS: { model: partStockModel, idField: '_id', type: 'partStock', referenceField: '_reference' }, - PDS: { model: null, idField: '_id', type: 'productStock', referenceField: '_reference' }, // No productStockModel found - ADL: { model: auditLogModel, idField: '_id', type: 'auditLog', referenceField: '_reference' }, - USR: { model: userModel, idField: '_id', type: 'user', referenceField: '_reference' }, - NTY: { model: noteTypeModel, idField: '_id', type: 'noteType', referenceField: '_reference' }, - NTE: { model: noteModel, idField: '_id', type: 'note', referenceField: '_reference' }, DSZ: { model: documentSizeModel, idField: '_id', type: 'documentSize', + label: 'Document Size', referenceField: '_reference', }, DTP: { model: documentTemplateModel, idField: '_id', type: 'documentTemplate', + label: 'Document Template', referenceField: '_reference', }, DPR: { model: documentPrinterModel, idField: '_id', type: 'documentPrinter', + label: 'Document Printer', referenceField: '_reference', }, DJB: { model: documentJobModel, idField: '_id', type: 'documentJob', + label: 'Document Job', referenceField: '_reference', }, - HST: { model: hostModel, idField: '_id', type: 'host', referenceField: '_reference' }, - FLE: { model: fileModel, idField: '_id', type: 'file', referenceField: '_reference' }, + HST: { + model: hostModel, + idField: '_id', + type: 'host', + referenceField: '_reference', + label: 'Host', + }, + FLE: { + model: fileModel, + idField: '_id', + type: 'file', + referenceField: '_reference', + label: 'File', + }, POR: { model: purchaseOrderModel, idField: '_id', type: 'purchaseOrder', + label: 'Purchase Order', referenceField: '_reference', }, ODI: { model: orderItemModel, idField: '_id', type: 'orderItem', + label: 'Order Item', referenceField: '_reference', }, COS: { model: courierServiceModel, idField: '_id', type: 'courierService', + label: 'Courier Service', + referenceField: '_reference', + }, + COR: { + model: courierModel, + idField: '_id', + type: 'courier', + label: 'Courier', + referenceField: '_reference', + }, + TXR: { + model: taxRateModel, + idField: '_id', + type: 'taxRate', + label: 'Tax Rate', + referenceField: '_reference', + }, + TXD: { + model: taxRecordModel, + idField: '_id', + type: 'taxRecord', + label: 'Tax Record', + referenceField: '_reference', + }, + SHP: { + model: shipmentModel, + idField: '_id', + type: 'shipment', + label: 'Shipment', + referenceField: '_reference', + }, + INV: { + model: invoiceModel, + idField: '_id', + type: 'invoice', + label: 'Invoice', + referenceField: '_reference', + }, + CLI: { + model: clientModel, + idField: '_id', + type: 'client', + label: 'Client', + referenceField: '_reference', + }, + SOR: { + model: salesOrderModel, + idField: '_id', + type: 'salesOrder', + label: 'Sales Order', referenceField: '_reference', }, - COR: { model: courierModel, idField: '_id', type: 'courier', referenceField: '_reference' }, - TXR: { model: taxRateModel, idField: '_id', type: 'taxRate', referenceField: '_reference' }, - TXD: { model: taxRecordModel, idField: '_id', type: 'taxRecord', referenceField: '_reference' }, - SHP: { model: shipmentModel, idField: '_id', type: 'shipment', referenceField: '_reference' }, - INV: { model: invoiceModel, idField: '_id', type: 'invoice', referenceField: '_reference' }, - CLI: { model: clientModel, idField: '_id', type: 'client', referenceField: '_reference' }, - SOR: { model: salesOrderModel, idField: '_id', type: 'salesOrder', referenceField: '_reference' }, }; diff --git a/src/index.js b/src/index.js index ba1b7ff..1cd4e58 100644 --- a/src/index.js +++ b/src/index.js @@ -41,6 +41,8 @@ import { paymentRoutes, clientRoutes, salesOrderRoutes, + userNotifierRoutes, + notificationRoutes, } from './routes/index.js'; import path from 'path'; import * as fs from 'fs'; @@ -148,6 +150,8 @@ app.use('/payments', paymentRoutes); app.use('/clients', clientRoutes); app.use('/salesorders', salesOrderRoutes); app.use('/notes', noteRoutes); +app.use('/usernotifiers', userNotifierRoutes); +app.use('/notifications', notificationRoutes); // Start the application if (process.env.NODE_ENV !== 'test') { diff --git a/src/routes/index.js b/src/routes/index.js index c7fb0f9..c48a3fd 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -34,6 +34,8 @@ import paymentRoutes from './finance/payments.js'; import clientRoutes from './sales/clients.js'; import salesOrderRoutes from './sales/salesorders.js'; import noteRoutes from './misc/notes.js'; +import userNotifierRoutes from './misc/usernotifiers.js'; +import notificationRoutes from './misc/notifications.js'; export { userRoutes, @@ -72,4 +74,6 @@ export { paymentRoutes, clientRoutes, salesOrderRoutes, + userNotifierRoutes, + notificationRoutes, }; diff --git a/src/routes/misc/notifications.js b/src/routes/misc/notifications.js new file mode 100644 index 0000000..3eb9ffb --- /dev/null +++ b/src/routes/misc/notifications.js @@ -0,0 +1,37 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { + listNotificationsRouteHandler, + markNotificationAsReadRouteHandler, + markAllNotificationsAsReadRouteHandler, + deleteNotificationRouteHandler, + deleteAllNotificationsRouteHandler, +} from '../../services/misc/notifications.js'; +import { getFilter } from '../../utils.js'; + +const router = express.Router(); + +router.get('/', isAuthenticated, (req, res) => { + const { page = 1, limit = 50, sort = 'createdAt', order = 'descend' } = req.query; + const allowedFilters = ['user']; + const filter = getFilter(req.query, allowedFilters); + listNotificationsRouteHandler(req, res, page, limit, filter, sort, order); +}); + +router.put('/read-all', isAuthenticated, (req, res) => { + markAllNotificationsAsReadRouteHandler(req, res); +}); + +router.put('/:id/read', isAuthenticated, (req, res) => { + markNotificationAsReadRouteHandler(req, res); +}); + +router.delete('/', isAuthenticated, (req, res) => { + deleteAllNotificationsRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, (req, res) => { + deleteNotificationRouteHandler(req, res); +}); + +export default router; diff --git a/src/routes/misc/usernotifiers.js b/src/routes/misc/usernotifiers.js new file mode 100644 index 0000000..961849d --- /dev/null +++ b/src/routes/misc/usernotifiers.js @@ -0,0 +1,37 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { + listUserNotifiersRouteHandler, + getUserNotifierRouteHandler, + newUserNotifierRouteHandler, + deleteUserNotifierRouteHandler, + editUserNotifierRouteHandler, +} from '../../services/misc/usernotifiers.js'; +import { getFilter } from '../../utils.js'; + +const router = express.Router(); + +router.get('/', isAuthenticated, (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const allowedFilters = ['user', 'object', 'objectType']; + const filter = getFilter(req.query, allowedFilters); + listUserNotifiersRouteHandler(req, res, page, limit, property, filter, search, sort, order); +}); + +router.post('/', isAuthenticated, (req, res) => { + newUserNotifierRouteHandler(req, res); +}); + +router.get('/:id', isAuthenticated, (req, res) => { + getUserNotifierRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, (req, res) => { + editUserNotifierRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, (req, res) => { + deleteUserNotifierRouteHandler(req, res); +}); + +export default router; diff --git a/src/services/misc/notifications.js b/src/services/misc/notifications.js new file mode 100644 index 0000000..67297c3 --- /dev/null +++ b/src/services/misc/notifications.js @@ -0,0 +1,105 @@ +import config from '../../config.js'; +import { notificationModel } from '../../database/schemas/misc/notification.schema.js'; +import log4js from 'log4js'; +import { listObjects, editObject } from '../../database/database.js'; + +const logger = log4js.getLogger('Notifications'); +logger.level = config.server.logLevel; + +export const listNotificationsRouteHandler = async ( + req, + res, + page = 1, + limit = 50, + filter = {}, + sort = 'createdAt', + order = 'descend' +) => { + const userFilter = { ...filter, user: req.user._id }; + const result = await listObjects({ + model: notificationModel, + page, + limit, + filter: userFilter, + sort, + order, + populate: [], + }); + + if (result?.error) { + logger.error('Error listing notifications.'); + res.status(result.code || 500).send(result); + return; + } + + logger.debug(`List of notifications (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const markNotificationAsReadRouteHandler = async (req, res) => { + const id = req.params.id; + + const existing = await notificationModel.findById(id).lean(); + if (!existing) { + return res.status(404).send({ error: 'Notification not found' }); + } + if (String(existing.user) !== String(req.user._id)) { + return res.status(403).send({ error: 'Forbidden: you can only update your own notifications' }); + } + + const result = await editObject({ + model: notificationModel, + id, + updateData: { read: true, updatedAt: new Date() }, + user: req.user, + }); + + if (result?.error) { + logger.error('Error marking notification as read:', result.error); + return res.status(result.code || 500).send(result); + } + + logger.debug(`Marked notification as read: ${id}`); + res.send(result); +}; + +export const markAllNotificationsAsReadRouteHandler = async (req, res) => { + try { + await notificationModel.updateMany( + { user: req.user._id, read: false }, + { $set: { read: true, updatedAt: new Date() } } + ); + logger.debug('Marked all notifications as read'); + res.send({ status: 'ok' }); + } catch (err) { + logger.error('Error marking all notifications as read:', err); + res.status(500).send({ error: 'Failed to mark all as read' }); + } +}; + +export const deleteNotificationRouteHandler = async (req, res) => { + const id = req.params.id; + + const existing = await notificationModel.findById(id).lean(); + if (!existing) { + return res.status(404).send({ error: 'Notification not found' }); + } + if (String(existing.user._id) !== String(req.user._id)) { + return res.status(403).send({ error: 'Forbidden: you can only delete your own notifications' }); + } + + await notificationModel.findByIdAndDelete(id); + logger.debug('Deleted notification:', id); + res.send({ status: 'ok' }); +}; + +export const deleteAllNotificationsRouteHandler = async (req, res) => { + try { + await notificationModel.deleteMany({ user: req.user._id }); + logger.debug('Deleted all notifications'); + res.send({ status: 'ok' }); + } catch (err) { + logger.error('Error deleting all notifications:', err); + res.status(500).send({ error: 'Failed to delete all notifications' }); + } +}; diff --git a/src/services/misc/usernotifiers.js b/src/services/misc/usernotifiers.js new file mode 100644 index 0000000..9e25d7f --- /dev/null +++ b/src/services/misc/usernotifiers.js @@ -0,0 +1,146 @@ +import config from '../../config.js'; +import { userNotifierModel } from '../../database/schemas/misc/usernotifier.schema.js'; +import log4js from 'log4js'; +import { + deleteObject, + editObject, + getObject, + listObjects, + newObject, +} from '../../database/database.js'; + +const logger = log4js.getLogger('UserNotifiers'); +logger.level = config.server.logLevel; + +export const listUserNotifiersRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: userNotifierModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: ['user', 'object'], + }); + + if (result?.error) { + logger.error('Error listing user notifiers.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of user notifiers (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const getUserNotifierRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: userNotifierModel, + id, + populate: ['user', 'object'], + }); + if (result?.error) { + logger.warn(`User notifier not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved user notifier with ID: ${id}`); + res.send(result); +}; + +export const newUserNotifierRouteHandler = async (req, res) => { + const newData = { + user: req.user._id, + object: req.body.object, + objectType: req.body.objectType, + }; + const result = await newObject({ + model: userNotifierModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No user notifier created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New user notifier with ID: ${result._id}`); + res.send(result); +}; + +export const editUserNotifierRouteHandler = async (req, res) => { + const id = req.params.id; + + const existing = await getObject({ + model: userNotifierModel, + id, + }); + if (existing?.error) { + return res.status(existing.code).send(existing); + } + if (String(existing.user._id) !== String(req.user._id)) { + return res.status(403).send({ error: 'Forbidden: you can only edit your own notifiers' }); + } + + const updateData = { + updatedAt: new Date(), + email: req.body.email, + }; + const result = await editObject({ + model: userNotifierModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing user notifier:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Edited user notifier with ID: ${id}`); + res.send(result); +}; + +export const deleteUserNotifierRouteHandler = async (req, res) => { + const id = req.params.id; + + const existing = await getObject({ + model: userNotifierModel, + id, + }); + if (existing?.error) { + return res.status(existing.code).send(existing); + } + if (String(existing.user._id) !== String(req.user._id)) { + return res.status(403).send({ error: 'Forbidden: you can only delete your own notifiers' }); + } + + const result = await deleteObject({ + model: userNotifierModel, + id, + user: req.user, + }); + + if (result?.error) { + logger.error('No user notifier deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.info(`Successfully deleted user notifier ${id}`); + res.send({ + status: 'ok', + }); +}; diff --git a/src/utils.js b/src/utils.js index ad3b759..eacb86f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,11 +1,14 @@ import { mongoose } from 'mongoose'; import { auditLogModel } from './database/schemas/management/auditlog.schema.js'; +import { notificationModel } from './database/schemas/misc/notification.schema.js'; +import { userNotifierModel } from './database/schemas/misc/usernotifier.schema.js'; import exifr from 'exifr'; import { natsServer } from './database/nats.js'; import log4js from 'log4js'; import config from './config.js'; import crypto from 'crypto'; import canonicalize from 'canonical-json'; +import { getModelByName } from './services/misc/model.js'; const logger = log4js.getLogger('Utils'); logger.level = config.server.logLevel; @@ -350,7 +353,11 @@ function getChangedValues(oldObj, newObj, old = false) { return changes; } +const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier']; + async function newAuditLog(newValue, parentId, parentType, user) { + if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return; + // Filter out createdAt and updatedAt from newValue const filteredNewValue = { ...newValue }; delete filteredNewValue.createdAt; @@ -372,6 +379,8 @@ async function newAuditLog(newValue, parentId, parentType, user) { } async function editAuditLog(oldValue, newValue, parentId, parentType, user) { + if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return; + // Get only the changed values const changedOldValues = getChangedValues(oldValue, newValue, true); const changedNewValues = getChangedValues(oldValue, newValue, false); @@ -398,7 +407,35 @@ async function editAuditLog(oldValue, newValue, parentId, parentType, user) { await distributeNew(auditLog._id, 'auditLog'); } +async function editNotification(oldValue, newValue, parentId, parentType, user) { + const model = getModelByName(parentType); + const objectName = oldValue?.name ?? newValue?.name ?? model?.label ?? parentType; + const changedOldValues = getChangedValues(oldValue, newValue, true); + const changedNewValues = getChangedValues(oldValue, newValue, false); + + if (Object.keys(changedOldValues).length === 0 || Object.keys(changedNewValues).length === 0) { + return; + } + + await notfiyObjectUserNotifiers( + parentId, + parentType, + `${objectName} edited by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`, + `The ${parentType} ${parentId} has been updated.`, + 'editObject', + { + old: changedOldValues, + new: changedNewValues, + objectType: parentType, + object: { _id: parentId }, + user: { _id: user._id, firstName: user.firstName, lastName: user.lastName }, + } + ); +} + async function deleteAuditLog(deleteValue, parentId, parentType, user) { + if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return; + const auditLog = new auditLogModel({ changes: { old: deleteValue, @@ -415,6 +452,24 @@ async function deleteAuditLog(deleteValue, parentId, parentType, user) { await distributeNew(auditLog._id, 'auditLog'); } +async function deleteNotification(object, parentId, parentType, user) { + const model = getModelByName(parentType); + const objectName = oldValue?.name ?? newValue?.name ?? model?.label ?? parentType; + await notfiyObjectUserNotifiers( + parentId, + parentType, + `${objectName} deleted by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`, + `The ${parentType} ${parentId} has been deleted.`, + 'deleteObject', + { + object: object, + objectType: parentType, + object: { _id: parentId }, + user: { _id: user._id, firstName: user.firstName, lastName: user.lastName }, + } + ); +} + async function getAuditLogs(idOrIds) { if (Array.isArray(idOrIds)) { return auditLogModel.find({ parent: { $in: idOrIds } }).populate('owner'); @@ -493,6 +548,27 @@ async function distributeChildNew(value, id, model) { } } +async function notfiyObjectUserNotifiers(id, objectType, title, message, type = 'info', metadata) { + const userNotifiers = await userNotifierModel.find({ object: id, objectType: objectType }); + for (const userNotifier of userNotifiers) { + await createNotification(userNotifier.user._id, title, message, type, metadata); + } +} + +async function createNotification(user, title, message, type = 'info', metadata) { + const notification = new notificationModel({ + user, + title, + message, + type, + metadata, + }); + await notification.save(); + const value = notification.toJSON ? notification.toJSON() : notification; + await natsServer.publish(`notifications.${user._id}`, value); + return notification; +} + function flatternObjectIds(object) { if (!object || typeof object !== 'object') { return object; @@ -707,6 +783,8 @@ export { parseFilter, convertToCamelCase, newAuditLog, + editNotification, + deleteNotification, editAuditLog, deleteAuditLog, getAuditLogs, @@ -719,6 +797,7 @@ export { distributeChildUpdate, distributeChildDelete, distributeChildNew, + notfiyObjectUserNotifiers, getFilter, // <-- add here convertPropertiesString, getFileMeta,