email sent when personal information changed

This commit is contained in:
jackiettran
2025-11-21 16:47:39 -05:00
parent f7767dfd13
commit f2d42dffee
9 changed files with 701 additions and 153 deletions

View File

@@ -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<User>} 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<UserAddress>} 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<UserAddress>} 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<void>}
*/
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();

View File

@@ -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<void>}
*/
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;
}
/**

View File

@@ -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<void>}
*/
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
}
}

View File

@@ -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;