email sent when personal information changed
This commit is contained in:
234
backend/services/UserService.js
Normal file
234
backend/services/UserService.js
Normal 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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user