From 9180f97e4e5ab2589a45c222c44d7ab6125b0204 Mon Sep 17 00:00:00 2001 From: Uha Kruthi Kommi Date: Sun, 11 Jan 2026 15:20:32 -0500 Subject: [PATCH 1/4] feat(messaging): add sms/email notifications --- package-lock.json | 11 --- .../lbdashboard/lbuserPrefController.js | 95 +++++++++++++------ src/models/lbdashboard/userPreferences.js | 5 + src/utilities/smsQueue.js | 58 +++++++++++ src/websockets/lbMessaging/messagingSocket.js | 52 +++++++++- 5 files changed, 178 insertions(+), 43 deletions(-) create mode 100644 src/utilities/smsQueue.js diff --git a/package-lock.json b/package-lock.json index c7f8c9ccc..b8971dd1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1277,7 +1277,6 @@ "node_modules/@babel/core": { "version": "7.28.5", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3980,7 +3979,6 @@ "node_modules/@redis/client": { "version": "1.6.1", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -4948,7 +4946,6 @@ "node_modules/@types/node-fetch": { "version": "2.6.13", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "form-data": "^4.0.4" @@ -5083,7 +5080,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5901,7 +5897,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -7134,7 +7129,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7280,7 +7274,6 @@ "version": "2.32.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7332,7 +7325,6 @@ "version": "6.10.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -7361,7 +7353,6 @@ "version": "7.37.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -7393,7 +7384,6 @@ "version": "4.6.2", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -8069,7 +8059,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/controllers/lbdashboard/lbuserPrefController.js b/src/controllers/lbdashboard/lbuserPrefController.js index 1a49fa0dd..642ae2881 100644 --- a/src/controllers/lbdashboard/lbuserPrefController.js +++ b/src/controllers/lbdashboard/lbuserPrefController.js @@ -1,4 +1,19 @@ const lbUserPrefController = function (UserPreferences, Notification) { + const normalizePhone = (phone) => { + if (!phone) return { normalized: '', last4: '' }; + const trimmed = String(phone).trim(); + const hasPlus = trimmed.startsWith('+'); + const digits = trimmed.replace(/\D/g, ''); + const normalized = hasPlus ? `+${digits}` : digits; + return { normalized, last4: digits.slice(-4) }; + }; + + const maskPhone = (phone) => { + if (!phone) return ''; + const digits = String(phone).replace(/\D/g, ''); + if (digits.length <= 4) return digits; + return `***-***-${digits.slice(-4)}`; + }; const getPreferences = async (req, res) => { try { const { userId, selectedUserId } = req.body; @@ -23,7 +38,9 @@ const lbUserPrefController = function (UserPreferences, Notification) { return res.status(200).json(selectedUserPref || { notifyInApp: false, notifyEmail: false }); } - res.status(200).json(preferences); + const response = preferences.toObject(); + response.smsPhoneMasked = maskPhone(preferences.smsPhone); + res.status(200).json(response); } catch (error) { console.error('Error fetching preferences:', error); res.status(500).json({ message: 'Error fetching preferences', error: error.message }); @@ -32,43 +49,61 @@ const lbUserPrefController = function (UserPreferences, Notification) { const updatePreferences = async (req, res) => { try { - const { userId, selectedUserId, notifyInApp, notifyEmail } = req.body; + const { userId, selectedUserId, notifyInApp, notifyEmail, notifySms, smsPhone } = req.body; - if (!userId || !selectedUserId) { - return res.status(400).json({ message: 'User ID and Selected User ID are required.' }); + if (!userId) { + return res.status(400).json({ message: 'User ID is required.' }); } - const preferences = await UserPreferences.findOne({ user: userId }); + let preferences = await UserPreferences.findOne({ user: userId }); if (!preferences) { - const newPreferences = new UserPreferences({ - user: userId, - users: [ - { - userNotifyingFor: selectedUserId, - notifyInApp: notifyInApp !== undefined ? notifyInApp : false, - notifyEmail: notifyEmail !== undefined ? notifyEmail : false, - }, - ], - }); - - await newPreferences.save(); - return res.status(200).json(newPreferences); + preferences = new UserPreferences({ user: userId, users: [] }); } - const userIndex = preferences.users.findIndex( - (user) => user.userNotifyingFor.toString() === selectedUserId, - ); + if (selectedUserId) { + const userIndex = preferences.users.findIndex( + (user) => user.userNotifyingFor.toString() === selectedUserId, + ); + + if (userIndex === -1) { + preferences.users.push({ + userNotifyingFor: selectedUserId, + notifyInApp: notifyInApp !== undefined ? notifyInApp : false, + notifyEmail: notifyEmail !== undefined ? notifyEmail : false, + }); + } else { + preferences.users[userIndex].notifyInApp = + notifyInApp !== undefined ? notifyInApp : false; + preferences.users[userIndex].notifyEmail = + notifyEmail !== undefined ? notifyEmail : false; + } + } else if (notifyInApp !== undefined || notifyEmail !== undefined) { + if (notifyInApp !== undefined) { + preferences.notifyInApp = notifyInApp; + } + if (notifyEmail !== undefined) { + preferences.notifyEmail = notifyEmail; + } + } - if (userIndex === -1) { - preferences.users.push({ - userNotifyingFor: selectedUserId, - notifyInApp: notifyInApp !== undefined ? notifyInApp : false, - notifyEmail: notifyEmail !== undefined ? notifyEmail : false, - }); - } else { - preferences.users[userIndex].notifyInApp = notifyInApp !== undefined ? notifyInApp : false; - preferences.users[userIndex].notifyEmail = notifyEmail !== undefined ? notifyEmail : false; + if (notifySms !== undefined || smsPhone !== undefined) { + const { normalized, last4 } = normalizePhone(smsPhone); + const digits = normalized.replace(/\D/g, ''); + const existingDigits = String(preferences.smsPhone || '').replace(/\D/g, ''); + if (notifySms && digits.length === 0 && existingDigits.length === 0) { + return res.status(400).json({ message: 'SMS phone number is required.' }); + } + if (digits.length > 0 && (digits.length < 8 || digits.length > 15)) { + return res.status(400).json({ message: 'Invalid phone number format.' }); + } + if (notifySms !== undefined) { + preferences.notifySms = notifySms; + } + if (smsPhone !== undefined && digits.length > 0) { + preferences.smsPhone = normalized; + preferences.smsPhoneLast4 = last4; + } } const updatedPreferences = await preferences.save(); diff --git a/src/models/lbdashboard/userPreferences.js b/src/models/lbdashboard/userPreferences.js index 45f3b1f4a..c060d59c7 100644 --- a/src/models/lbdashboard/userPreferences.js +++ b/src/models/lbdashboard/userPreferences.js @@ -2,6 +2,11 @@ const mongoose = require('mongoose'); const userPreferencesSchema = new mongoose.Schema({ user: { type: mongoose.Schema.Types.ObjectId, ref: 'userProfile', required: true }, + notifyInApp: { type: Boolean, default: false }, + notifyEmail: { type: Boolean, default: false }, + notifySms: { type: Boolean, default: false }, + smsPhone: { type: String, default: '' }, + smsPhoneLast4: { type: String, default: '' }, users: [ { userNotifyingFor: { diff --git a/src/utilities/smsQueue.js b/src/utilities/smsQueue.js new file mode 100644 index 000000000..2dbc801fc --- /dev/null +++ b/src/utilities/smsQueue.js @@ -0,0 +1,58 @@ +const { twilioSendSMS, TextbeltSMS, TelesignSMS } = require('./SMSSender'); + +const queue = []; +let processing = false; + +const getProvider = () => (process.env.SMS_PROVIDER || 'telesign').toLowerCase(); + +const sendSmsViaProvider = async (message, to) => { + const provider = getProvider(); + if (!to) { + throw new Error('SMS recipient is required.'); + } + + switch (provider) { + case 'twilio': { + const from = process.env.TWILIO_FROM_NUMBER; + if (!from) { + throw new Error('TWILIO_FROM_NUMBER is not configured.'); + } + return twilioSendSMS(message, from, to); + } + case 'textbelt': + return TextbeltSMS(message, to); + case 'telesign': + default: + return TelesignSMS(message, to); + } +}; + +const processQueue = async () => { + if (processing || queue.length === 0) return; + processing = true; + + while (queue.length > 0) { + const item = queue.shift(); + if (!item) continue; + try { + const response = await Promise.resolve(sendSmsViaProvider(item.message, item.to)); + if (process.env.SMS_LOG_RESPONSES === 'true') { + console.log('SMS send response:', response?.data || response); + } + } catch (error) { + console.error('Failed to send SMS:', error?.response?.data || error.message); + } + } + + processing = false; +}; + +const queueSmsNotification = ({ to, message }) => { + if (!to || !message) return; + queue.push({ to, message }); + setImmediate(() => { + processQueue().catch((error) => console.error('SMS queue error:', error.message)); + }); +}; + +module.exports = { queueSmsNotification }; diff --git a/src/websockets/lbMessaging/messagingSocket.js b/src/websockets/lbMessaging/messagingSocket.js index f2a199966..b4d6c7583 100644 --- a/src/websockets/lbMessaging/messagingSocket.js +++ b/src/websockets/lbMessaging/messagingSocket.js @@ -7,6 +7,8 @@ const Message = require('../../models/lbdashboard/message'); const Notification = require('../../models/notification'); const UserProfile = require('../../models/userProfile'); const UserPreference = require('../../models/lbdashboard/userPreferences'); +const { queueSmsNotification } = require('../../utilities/smsQueue'); +const emailSender = require('../../utilities/emailSender'); const { sendMessageHandler, updateMessageStatusHandler } = require('./lbMessageHandler'); const authenticate = (req, res) => { @@ -101,6 +103,36 @@ export default () => { }); }; + const sendNewMessageEmail = async (receiverId, senderName) => { + try { + const receiverProfile = await UserProfile.findById(receiverId).select( + 'email firstName lastName', + ); + if (!receiverProfile?.email) return; + const subject = `New message from ${senderName}`; + const body = ` +

Hi ${receiverProfile.firstName || 'there'},

+

You received a new message from ${senderName}.

+

Log in to view and reply.

+ `; + await emailSender(receiverProfile.email, subject, body, null, null, null, null, { + type: 'lb_message', + recipientUserId: receiverId, + }); + } catch (error) { + console.error('Failed to send message email:', error.message); + } + }; + + const sendNewMessageSms = (userPreference, senderName) => { + if (userPreference?.notifySms && userPreference?.smsPhone) { + queueSmsNotification({ + to: userPreference.smsPhone, + message: `New message from ${senderName}`, + }); + } + }; + const broadcastStatusUpdate = async (messageId, status /* userId */) => { const message = await Message.findByIdAndUpdate(messageId, { status }, { new: true }); @@ -164,24 +196,34 @@ export default () => { // Send notification if receiver is active but not in chat with sender if (isReceiverActive && !isReceiverInChat) { const userPreference = await UserPreference.findOne({ user: msg.receiver }); + const allowGlobalInApp = + userPreference?.notifyInApp === undefined ? true : userPreference.notifyInApp; const isSenderInPreference = userPreference?.users.some( (pref) => pref.userNotifyingFor.toString() === userId && pref.notifyInApp === true, ); - if (isSenderInPreference) { + if (allowGlobalInApp && isSenderInPreference) { broadcastToUser(msg.receiver, { action: 'NEW_NOTIFICATION', payload: `You got a message from ${senderName}`, }); } + + if (userPreference?.notifyEmail) { + sendNewMessageEmail(msg.receiver, senderName); + } + + sendNewMessageSms(userPreference, senderName); } } else { // Receiver is offline, create notification const userPreference = await UserPreference.findOne({ user: msg.receiver }); + const allowGlobalInApp = + userPreference?.notifyInApp === undefined ? true : userPreference.notifyInApp; const isSenderInPreference = userPreference?.users.some( (pref) => pref.userNotifyingFor.toString() === userId && pref.notifyInApp === true, ); - if (isSenderInPreference) { + if (allowGlobalInApp && isSenderInPreference) { const notification = new Notification({ message: `You got a message from ${senderName}`, sender: userId, @@ -190,6 +232,12 @@ export default () => { }); await notification.save(); } + + sendNewMessageSms(userPreference, senderName); + + if (userPreference?.notifyEmail) { + sendNewMessageEmail(msg.receiver, senderName); + } } broadcastStatusUpdate(savedMessage._id, savedMessage.status, userId); From ee5a3e7b77f4a611edc1a20a4f71ec31dd3a81e3 Mon Sep 17 00:00:00 2001 From: Uha Kruthi Kommi Date: Sat, 11 Apr 2026 13:16:51 -0400 Subject: [PATCH 2/4] fix: resolve messaging notification issue --- src/websockets/lbMessaging/messagingSocket.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/websockets/lbMessaging/messagingSocket.js b/src/websockets/lbMessaging/messagingSocket.js index b4d6c7583..6537f0849 100644 --- a/src/websockets/lbMessaging/messagingSocket.js +++ b/src/websockets/lbMessaging/messagingSocket.js @@ -201,8 +201,9 @@ export default () => { const isSenderInPreference = userPreference?.users.some( (pref) => pref.userNotifyingFor.toString() === userId && pref.notifyInApp === true, ); + const shouldNotifyInApp = allowGlobalInApp || isSenderInPreference; - if (allowGlobalInApp && isSenderInPreference) { + if (shouldNotifyInApp) { broadcastToUser(msg.receiver, { action: 'NEW_NOTIFICATION', payload: `You got a message from ${senderName}`, @@ -223,7 +224,8 @@ export default () => { const isSenderInPreference = userPreference?.users.some( (pref) => pref.userNotifyingFor.toString() === userId && pref.notifyInApp === true, ); - if (allowGlobalInApp && isSenderInPreference) { + const shouldNotifyInApp = allowGlobalInApp || isSenderInPreference; + if (shouldNotifyInApp) { const notification = new Notification({ message: `You got a message from ${senderName}`, sender: userId, From e6df7e69aa4da2dcca39620e002df79c172d89b2 Mon Sep 17 00:00:00 2001 From: Uha Kruthi Kommi Date: Sat, 11 Apr 2026 13:27:27 -0400 Subject: [PATCH 3/4] fix: resolve messaging notification issue --- jest.config.js | 3 +++ src/test/mocks/puppeteer.js | 1 + 2 files changed, 4 insertions(+) create mode 100644 src/test/mocks/puppeteer.js diff --git a/jest.config.js b/jest.config.js index 00ae81164..b0520c1bc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -42,6 +42,9 @@ module.exports = { transform: { '^.+\\.js$': 'babel-jest', }, + moduleNameMapper: { + '^puppeteer$': '/src/test/mocks/puppeteer.js', + }, setupFilesAfterEnv: ['/src/test/setup.js'], // Simple CI settings maxWorkers: 1, // Run tests sequentially diff --git a/src/test/mocks/puppeteer.js b/src/test/mocks/puppeteer.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/src/test/mocks/puppeteer.js @@ -0,0 +1 @@ +module.exports = {}; From 186c3ae811c824bf752304ca0d60100495f2c708 Mon Sep 17 00:00:00 2001 From: Uha Kruthi Kommi Date: Sat, 11 Apr 2026 21:07:33 -0400 Subject: [PATCH 4/4] fix: validate ids in lb user preferences --- .../lbdashboard/lbuserPrefController.js | 78 ++++++++++++++----- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/src/controllers/lbdashboard/lbuserPrefController.js b/src/controllers/lbdashboard/lbuserPrefController.js index 642ae2881..83ccd15fe 100644 --- a/src/controllers/lbdashboard/lbuserPrefController.js +++ b/src/controllers/lbdashboard/lbuserPrefController.js @@ -1,4 +1,22 @@ +const mongoose = require('mongoose'); + const lbUserPrefController = function (UserPreferences, Notification) { + const normalizeObjectId = (value) => { + if (typeof value !== 'string') return null; + + const trimmed = value.trim(); + if (!mongoose.Types.ObjectId.isValid(trimmed)) return null; + + return trimmed; + }; + + const normalizeObjectIdList = (values) => { + if (!Array.isArray(values)) return null; + + const normalizedIds = values.map(normalizeObjectId); + return normalizedIds.every(Boolean) ? normalizedIds : null; + }; + const normalizePhone = (phone) => { if (!phone) return { normalized: '', last4: '' }; const trimmed = String(phone).trim(); @@ -17,12 +35,20 @@ const lbUserPrefController = function (UserPreferences, Notification) { const getPreferences = async (req, res) => { try { const { userId, selectedUserId } = req.body; + const normalizedUserId = normalizeObjectId(userId); + const normalizedSelectedUserId = selectedUserId + ? normalizeObjectId(selectedUserId) + : null; + + if (!normalizedUserId) { + return res.status(400).json({ message: 'A valid user ID is required.' }); + } - if (!userId) { - return res.status(400).json({ message: 'User ID is required.' }); + if (selectedUserId && !normalizedSelectedUserId) { + return res.status(400).json({ message: 'Selected user ID must be a valid ID.' }); } - const preferences = await UserPreferences.findOne({ user: userId }).populate( + const preferences = await UserPreferences.findOne({ user: normalizedUserId }).populate( 'users.userNotifyingFor', ); @@ -30,9 +56,9 @@ const lbUserPrefController = function (UserPreferences, Notification) { return res.status(404).json({ message: 'Preferences not found for the user.' }); } - if (selectedUserId) { + if (normalizedSelectedUserId) { const selectedUserPref = preferences.users.find( - (pref) => pref.userNotifyingFor._id.toString() === selectedUserId, + (pref) => pref.userNotifyingFor._id.toString() === normalizedSelectedUserId, ); return res.status(200).json(selectedUserPref || { notifyInApp: false, notifyEmail: false }); @@ -50,25 +76,33 @@ const lbUserPrefController = function (UserPreferences, Notification) { const updatePreferences = async (req, res) => { try { const { userId, selectedUserId, notifyInApp, notifyEmail, notifySms, smsPhone } = req.body; + const normalizedUserId = normalizeObjectId(userId); + const normalizedSelectedUserId = selectedUserId + ? normalizeObjectId(selectedUserId) + : null; + + if (!normalizedUserId) { + return res.status(400).json({ message: 'A valid user ID is required.' }); + } - if (!userId) { - return res.status(400).json({ message: 'User ID is required.' }); + if (selectedUserId && !normalizedSelectedUserId) { + return res.status(400).json({ message: 'Selected user ID must be a valid ID.' }); } - let preferences = await UserPreferences.findOne({ user: userId }); + let preferences = await UserPreferences.findOne({ user: normalizedUserId }); if (!preferences) { - preferences = new UserPreferences({ user: userId, users: [] }); + preferences = new UserPreferences({ user: normalizedUserId, users: [] }); } - if (selectedUserId) { + if (normalizedSelectedUserId) { const userIndex = preferences.users.findIndex( - (user) => user.userNotifyingFor.toString() === selectedUserId, + (user) => user.userNotifyingFor.toString() === normalizedSelectedUserId, ); if (userIndex === -1) { preferences.users.push({ - userNotifyingFor: selectedUserId, + userNotifyingFor: normalizedSelectedUserId, notifyInApp: notifyInApp !== undefined ? notifyInApp : false, notifyEmail: notifyEmail !== undefined ? notifyEmail : false, }); @@ -117,15 +151,17 @@ const lbUserPrefController = function (UserPreferences, Notification) { const storeNotification = async (req, res) => { try { const { userId, senderId, message } = req.body; + const normalizedUserId = normalizeObjectId(userId); + const normalizedSenderId = normalizeObjectId(senderId); - if (!userId || !senderId || !message) { + if (!normalizedUserId || !normalizedSenderId || !message) { return res.status(400).json({ message: 'User ID, Sender ID, and Message are required.' }); } const notification = new Notification({ message, - sender: senderId, - recipient: userId, + sender: normalizedSenderId, + recipient: normalizedUserId, isSystemGenerated: false, }); @@ -140,13 +176,14 @@ const lbUserPrefController = function (UserPreferences, Notification) { const getUnreadNotifications = async (req, res) => { try { const { userId } = req.params; + const normalizedUserId = normalizeObjectId(userId); - if (!userId) { + if (!normalizedUserId) { console.error('❌ User ID is missing in the request.'); - return res.status(400).json({ message: 'User ID is required.' }); + return res.status(400).json({ message: 'A valid user ID is required.' }); } - const notifications = await Notification.find({ recipient: userId, isRead: false }) + const notifications = await Notification.find({ recipient: normalizedUserId, isRead: false }) .sort({ createdTimeStamps: -1 }) .populate('sender', 'firstName lastName'); // Include sender's name @@ -162,12 +199,13 @@ const lbUserPrefController = function (UserPreferences, Notification) { const markNotificationsAsRead = async (req, res) => { try { const { notificationIds } = req.body; + const normalizedNotificationIds = normalizeObjectIdList(notificationIds); - if (!notificationIds || !Array.isArray(notificationIds)) { + if (!normalizedNotificationIds) { return res.status(400).json({ message: 'Invalid notification IDs.' }); } const result = await Notification.updateMany( - { _id: { $in: notificationIds } }, + { _id: { $in: normalizedNotificationIds } }, { isRead: true }, );