From f2d42dffee4321247e1661f7c35988ec5c5592b4 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Fri, 21 Nov 2025 16:47:39 -0500 Subject: [PATCH] email sent when personal information changed --- backend/models/UserAddress.js | 36 +-- backend/routes/users.js | 99 ++----- backend/server.js | 21 +- backend/services/UserService.js | 234 +++++++++++++++++ backend/services/email/core/EmailClient.js | 39 ++- .../services/email/core/TemplateManager.js | 59 ++++- .../services/email/domain/AuthEmailService.js | 35 +++ .../emails/personalInfoChangedToUser.html | 246 ++++++++++++++++++ frontend/src/pages/Profile.tsx | 85 +++--- 9 files changed, 701 insertions(+), 153 deletions(-) create mode 100644 backend/services/UserService.js create mode 100644 backend/templates/emails/personalInfoChangedToUser.html diff --git a/backend/models/UserAddress.js b/backend/models/UserAddress.js index ee7a39d..4da7dc4 100644 --- a/backend/models/UserAddress.js +++ b/backend/models/UserAddress.js @@ -1,54 +1,54 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); -const UserAddress = sequelize.define('UserAddress', { +const UserAddress = sequelize.define("UserAddress", { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, - primaryKey: true + primaryKey: true, }, userId: { type: DataTypes.UUID, allowNull: false, references: { - model: 'Users', - key: 'id' - } + model: "Users", + key: "id", + }, }, address1: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, address2: { - type: DataTypes.STRING + type: DataTypes.STRING, }, city: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, state: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, zipCode: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, country: { type: DataTypes.STRING, allowNull: false, - defaultValue: 'US' + defaultValue: "US", }, latitude: { - type: DataTypes.DECIMAL(10, 8) + type: DataTypes.DECIMAL(10, 8), }, longitude: { - type: DataTypes.DECIMAL(11, 8) + type: DataTypes.DECIMAL(11, 8), }, isPrimary: { type: DataTypes.BOOLEAN, - defaultValue: false - } + defaultValue: false, + }, }); -module.exports = UserAddress; \ No newline at end of file +module.exports = UserAddress; diff --git a/backend/routes/users.js b/backend/routes/users.js index d3baf49..8d94da0 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -3,6 +3,7 @@ const { User, UserAddress } = require('../models'); // Import from models/index. const { authenticateToken } = require('../middleware/auth'); const { uploadProfileImage } = require('../middleware/upload'); const logger = require('../utils/logger'); +const userService = require('../services/UserService'); const fs = require('fs').promises; const path = require('path'); const router = express.Router(); @@ -57,15 +58,7 @@ router.get('/addresses', authenticateToken, async (req, res) => { router.post('/addresses', authenticateToken, async (req, res) => { try { - const address = await UserAddress.create({ - ...req.body, - userId: req.user.id - }); - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("User address created", { - userId: req.user.id, - addressId: address.id - }); + const address = await userService.createUserAddress(req.user.id, req.body); res.status(201).json(address); } catch (error) { @@ -82,23 +75,7 @@ router.post('/addresses', authenticateToken, async (req, res) => { router.put('/addresses/:id', authenticateToken, async (req, res) => { try { - const address = await UserAddress.findByPk(req.params.id); - - if (!address) { - return res.status(404).json({ error: 'Address not found' }); - } - - if (address.userId !== req.user.id) { - return res.status(403).json({ error: 'Unauthorized' }); - } - - await address.update(req.body); - - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("User address updated", { - userId: req.user.id, - addressId: req.params.id - }); + const address = await userService.updateUserAddress(req.user.id, req.params.id, req.body); res.json(address); } catch (error) { @@ -109,29 +86,18 @@ router.put('/addresses/:id', authenticateToken, async (req, res) => { userId: req.user.id, addressId: req.params.id }); + + if (error.message === 'Address not found') { + return res.status(404).json({ error: error.message }); + } + res.status(500).json({ error: error.message }); } }); router.delete('/addresses/:id', authenticateToken, async (req, res) => { try { - const address = await UserAddress.findByPk(req.params.id); - - if (!address) { - return res.status(404).json({ error: 'Address not found' }); - } - - if (address.userId !== req.user.id) { - return res.status(403).json({ error: 'Unauthorized' }); - } - - await address.destroy(); - - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("User address deleted", { - userId: req.user.id, - addressId: req.params.id - }); + await userService.deleteUserAddress(req.user.id, req.params.id); res.status(204).send(); } catch (error) { @@ -142,6 +108,11 @@ router.delete('/addresses/:id', authenticateToken, async (req, res) => { userId: req.user.id, addressId: req.params.id }); + + if (error.message === 'Address not found') { + return res.status(404).json({ error: error.message }); + } + res.status(500).json({ error: error.message }); } }); @@ -211,49 +182,13 @@ router.get('/:id', async (req, res) => { router.put('/profile', authenticateToken, async (req, res) => { try { - const { - firstName, - lastName, - email, - phone, - address1, - address2, - city, - state, - zipCode, - country, - itemRequestNotificationRadius - } = req.body; - - // Build update object, excluding empty email - const updateData = { - firstName, - lastName, - phone, - address1, - address2, - city, - state, - zipCode, - country, - itemRequestNotificationRadius - }; - - // Only include email if it's not empty - if (email && email.trim() !== '') { - updateData.email = email; - } - - await req.user.update(updateData); - - const updatedUser = await User.findByPk(req.user.id, { - attributes: { exclude: ['password'] } - }); + // Use UserService to handle update and email notification + const updatedUser = await userService.updateProfile(req.user.id, req.body); res.json(updatedUser); } catch (error) { console.error('Profile update error:', error); - res.status(500).json({ + res.status(500).json({ error: error.message, details: error.errors ? error.errors.map(e => ({ field: e.path, message: e.message })) : undefined }); diff --git a/backend/server.js b/backend/server.js index 3bf28ff..55d97e8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -32,6 +32,7 @@ const feedbackRoutes = require("./routes/feedback"); const PayoutProcessor = require("./jobs/payoutProcessor"); const RentalStatusJob = require("./jobs/rentalStatusJob"); const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder"); +const emailServices = require("./services/email"); // Socket.io setup const { authenticateSocket } = require("./sockets/socketAuth"); @@ -166,9 +167,27 @@ const PORT = process.env.PORT || 5000; sequelize .sync({ alter: true }) - .then(() => { + .then(async () => { logger.info("Database synced successfully"); + // Initialize email services and load templates + try { + await emailServices.initialize(); + logger.info("Email services initialized successfully"); + } catch (err) { + logger.error("Failed to initialize email services", { + error: err.message, + stack: err.stack, + }); + // Fail fast - don't start server if email templates can't load + if (env === "prod" || env === "production") { + logger.error("Cannot start server without email services in production"); + process.exit(1); + } else { + logger.warn("Email services failed to initialize - continuing in dev mode"); + } + } + // Start the payout processor const payoutJobs = PayoutProcessor.startScheduledPayouts(); logger.info("Payout processor started"); diff --git a/backend/services/UserService.js b/backend/services/UserService.js new file mode 100644 index 0000000..a980abc --- /dev/null +++ b/backend/services/UserService.js @@ -0,0 +1,234 @@ +const { User, UserAddress } = require("../models"); +const emailServices = require("./email"); +const logger = require("../utils/logger"); + +/** + * UserService handles user-related business logic + * Including profile updates and associated notifications + */ +class UserService { + /** + * Update user profile and send notification if personal info changed + * @param {string} userId - User ID + * @param {Object} rawUpdateData - Data to update + * @param {Object} options - Optional transaction or other options + * @returns {Promise} Updated user (without password field) + */ + async updateProfile(userId, rawUpdateData, options = {}) { + const user = await User.findByPk(userId); + if (!user) { + throw new Error("User not found"); + } + + // Store original values for comparison + const originalValues = { + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + address1: user.address1, + address2: user.address2, + city: user.city, + state: user.state, + zipCode: user.zipCode, + country: user.country, + }; + + // Prepare update data with preprocessing + const updateData = { ...rawUpdateData }; + + // Only include email if it's not empty + if (updateData.email !== undefined) { + if (updateData.email && updateData.email.trim() !== "") { + updateData.email = updateData.email.trim(); + } else { + delete updateData.email; // Don't update if empty + } + } + + // Handle phone: convert empty strings to null to avoid unique constraint issues + if (updateData.phone !== undefined) { + updateData.phone = + updateData.phone && updateData.phone.trim() !== "" + ? updateData.phone.trim() + : null; + } + + // Perform the update + await user.update(updateData, options); + + // Check if personal information changed + const personalInfoFields = [ + "email", + "firstName", + "lastName", + "address1", + "address2", + "city", + "state", + "zipCode", + "country", + ]; + + const changedFields = personalInfoFields.filter( + (field) => + updateData[field] !== undefined && + originalValues[field] !== updateData[field] + ); + + // Send notification email if personal info changed + if (changedFields.length > 0 && process.env.NODE_ENV !== "test") { + try { + await emailServices.auth.sendPersonalInfoChangedEmail(user); + logger.info("Personal information changed notification sent", { + userId: user.id, + email: user.email, + changedFields, + }); + } catch (emailError) { + logger.error( + "Failed to send personal information changed notification", + { + error: emailError.message, + userId: user.id, + email: user.email, + changedFields, + } + ); + // Don't throw - email failure shouldn't fail the update + } + } + + // Return user without password + const updatedUser = await User.findByPk(user.id, { + attributes: { exclude: ["password"] }, + }); + + return updatedUser; + } + + /** + * Create a new address for a user and send notification + * @param {string} userId - User ID + * @param {Object} addressData - Address data + * @returns {Promise} Created address + */ + async createUserAddress(userId, addressData) { + const user = await User.findByPk(userId); + if (!user) { + throw new Error("User not found"); + } + + const address = await UserAddress.create({ + ...addressData, + userId, + }); + + // Send notification for address creation + if (process.env.NODE_ENV !== "test") { + try { + await emailServices.auth.sendPersonalInfoChangedEmail(user); + logger.info( + "Personal information changed notification sent (address created)", + { + userId: user.id, + email: user.email, + addressId: address.id, + } + ); + } catch (emailError) { + logger.error("Failed to send notification for address creation", { + error: emailError.message, + userId: user.id, + addressId: address.id, + }); + } + } + + return address; + } + + /** + * Update a user address and send notification + * @param {string} userId - User ID + * @param {string} addressId - Address ID + * @param {Object} updateData - Data to update + * @returns {Promise} Updated address + */ + async updateUserAddress(userId, addressId, updateData) { + const address = await UserAddress.findOne({ + where: { id: addressId, userId }, + }); + + if (!address) { + throw new Error("Address not found"); + } + + await address.update(updateData); + + // Send notification for address update + if (process.env.NODE_ENV !== "test") { + try { + const user = await User.findByPk(userId); + await emailServices.auth.sendPersonalInfoChangedEmail(user); + logger.info( + "Personal information changed notification sent (address updated)", + { + userId: user.id, + email: user.email, + addressId: address.id, + } + ); + } catch (emailError) { + logger.error("Failed to send notification for address update", { + error: emailError.message, + userId, + addressId: address.id, + }); + } + } + + return address; + } + + /** + * Delete a user address and send notification + * @param {string} userId - User ID + * @param {string} addressId - Address ID + * @returns {Promise} + */ + async deleteUserAddress(userId, addressId) { + const address = await UserAddress.findOne({ + where: { id: addressId, userId }, + }); + + if (!address) { + throw new Error("Address not found"); + } + + await address.destroy(); + + // Send notification for address deletion + if (process.env.NODE_ENV !== "test") { + try { + const user = await User.findByPk(userId); + await emailServices.auth.sendPersonalInfoChangedEmail(user); + logger.info( + "Personal information changed notification sent (address deleted)", + { + userId: user.id, + email: user.email, + addressId, + } + ); + } catch (emailError) { + logger.error("Failed to send notification for address deletion", { + error: emailError.message, + userId, + addressId, + }); + } + } + } +} + +module.exports = new UserService(); diff --git a/backend/services/email/core/EmailClient.js b/backend/services/email/core/EmailClient.js index 252821b..923021b 100644 --- a/backend/services/email/core/EmailClient.js +++ b/backend/services/email/core/EmailClient.js @@ -11,8 +11,16 @@ const { htmlToPlainText } = require("./emailUtils"); */ class EmailClient { constructor() { + // Singleton pattern - return existing instance if already created + if (EmailClient.instance) { + return EmailClient.instance; + } + this.sesClient = null; this.initialized = false; + this.initializationPromise = null; + + EmailClient.instance = this; } /** @@ -20,19 +28,30 @@ class EmailClient { * @returns {Promise} */ async initialize() { + // If already initialized, return immediately if (this.initialized) return; - try { - // Use centralized AWS configuration with credential profiles - const awsConfig = getAWSConfig(); - this.sesClient = new SESClient(awsConfig); - - this.initialized = true; - console.log("AWS SES Email Client initialized successfully"); - } catch (error) { - console.error("Failed to initialize AWS SES Email Client:", error); - throw error; + // If initialization is in progress, wait for it + if (this.initializationPromise) { + return this.initializationPromise; } + + // Start initialization and store the promise + this.initializationPromise = (async () => { + try { + // Use centralized AWS configuration with credential profiles + const awsConfig = getAWSConfig(); + this.sesClient = new SESClient(awsConfig); + + this.initialized = true; + console.log("AWS SES Email Client initialized successfully"); + } catch (error) { + console.error("Failed to initialize AWS SES Email Client:", error); + throw error; + } + })(); + + return this.initializationPromise; } /** diff --git a/backend/services/email/core/TemplateManager.js b/backend/services/email/core/TemplateManager.js index d4f7ba5..bd852c2 100644 --- a/backend/services/email/core/TemplateManager.js +++ b/backend/services/email/core/TemplateManager.js @@ -11,8 +11,16 @@ const path = require("path"); */ class TemplateManager { constructor() { + // Singleton pattern - return existing instance if already created + if (TemplateManager.instance) { + return TemplateManager.instance; + } + this.templates = new Map(); this.initialized = false; + this.initializationPromise = null; + + TemplateManager.instance = this; } /** @@ -20,11 +28,22 @@ class TemplateManager { * @returns {Promise} */ async initialize() { + // If already initialized, return immediately if (this.initialized) return; - await this.loadEmailTemplates(); - this.initialized = true; - console.log("Email Template Manager initialized successfully"); + // If initialization is in progress, wait for it + if (this.initializationPromise) { + return this.initializationPromise; + } + + // Start initialization and store the promise + this.initializationPromise = (async () => { + await this.loadEmailTemplates(); + this.initialized = true; + console.log("Email Template Manager initialized successfully"); + })(); + + return this.initializationPromise; } /** @@ -34,6 +53,14 @@ class TemplateManager { async loadEmailTemplates() { const templatesDir = path.join(__dirname, "..", "..", "..", "templates", "emails"); + // Critical templates that must load for the app to function + const criticalTemplates = [ + "emailVerificationToUser.html", + "passwordResetToUser.html", + "passwordChangedToUser.html", + "personalInfoChangedToUser.html", + ]; + try { const templateFiles = [ "conditionCheckReminderToUser.html", @@ -41,6 +68,7 @@ class TemplateManager { "emailVerificationToUser.html", "passwordResetToUser.html", "passwordChangedToUser.html", + "personalInfoChangedToUser.html", "lateReturnToCS.html", "damageReportToCS.html", "lostItemToCS.html", @@ -69,6 +97,8 @@ class TemplateManager { "forumCommentDeletionToAuthor.html", ]; + const failedTemplates = []; + for (const templateFile of templateFiles) { try { const templatePath = path.join(templatesDir, templateFile); @@ -84,16 +114,39 @@ class TemplateManager { console.error( ` Template path: ${path.join(templatesDir, templateFile)}` ); + failedTemplates.push(templateFile); } } console.log( `Loaded ${this.templates.size} of ${templateFiles.length} email templates` ); + + // Check if critical templates are missing + const missingCriticalTemplates = criticalTemplates.filter( + (template) => !this.templates.has(path.basename(template, ".html")) + ); + + if (missingCriticalTemplates.length > 0) { + const error = new Error( + `Critical email templates failed to load: ${missingCriticalTemplates.join(", ")}` + ); + error.missingTemplates = missingCriticalTemplates; + throw error; + } + + // Warn if non-critical templates failed + if (failedTemplates.length > 0) { + console.warn( + `⚠️ Non-critical templates failed to load: ${failedTemplates.join(", ")}` + ); + console.warn("These templates will use fallback versions"); + } } catch (error) { console.error("Failed to load email templates:", error); console.error("Templates directory:", templatesDir); console.error("Error stack:", error.stack); + throw error; // Re-throw to fail server startup } } diff --git a/backend/services/email/domain/AuthEmailService.js b/backend/services/email/domain/AuthEmailService.js index 8da5276..d358b37 100644 --- a/backend/services/email/domain/AuthEmailService.js +++ b/backend/services/email/domain/AuthEmailService.js @@ -131,6 +131,41 @@ class AuthEmailService { htmlContent ); } + + /** + * Send personal information changed notification email + * @param {Object} user - User object + * @param {string} user.firstName - User's first name + * @param {string} user.email - User's email address + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendPersonalInfoChangedEmail(user) { + if (!this.initialized) { + await this.initialize(); + } + + const timestamp = new Date().toLocaleString("en-US", { + dateStyle: "long", + timeStyle: "short", + }); + + const variables = { + recipientName: user.firstName || "there", + email: user.email, + timestamp: timestamp, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "personalInfoChangedToUser", + variables + ); + + return await this.emailClient.sendEmail( + user.email, + "Personal Information Updated - RentAll", + htmlContent + ); + } } module.exports = AuthEmailService; diff --git a/backend/templates/emails/personalInfoChangedToUser.html b/backend/templates/emails/personalInfoChangedToUser.html new file mode 100644 index 0000000..81c476e --- /dev/null +++ b/backend/templates/emails/personalInfoChangedToUser.html @@ -0,0 +1,246 @@ + + + + + + + Personal Information Updated - RentAll + + + + + + diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 1101279..ba8758c 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -807,20 +807,52 @@ const Profile: React.FC = () => { {/* Personal Information Card */}
-
-
Personal Information
- +
+
+
Personal Information
+ +
+ {showPersonalInfo && ( +
+ {editing ? ( +
+ + +
+ ) : ( + + )} +
+ )}
{showPersonalInfo && (
@@ -1125,31 +1157,6 @@ const Profile: React.FC = () => { )}
- -
- - {editing ? ( -
- - -
- ) : ( - - )} )}