email sent when personal information changed
This commit is contained in:
@@ -1,54 +1,54 @@
|
|||||||
const { DataTypes } = require('sequelize');
|
const { DataTypes } = require("sequelize");
|
||||||
const sequelize = require('../config/database');
|
const sequelize = require("../config/database");
|
||||||
|
|
||||||
const UserAddress = sequelize.define('UserAddress', {
|
const UserAddress = sequelize.define("UserAddress", {
|
||||||
id: {
|
id: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
defaultValue: DataTypes.UUIDV4,
|
defaultValue: DataTypes.UUIDV4,
|
||||||
primaryKey: true
|
primaryKey: true,
|
||||||
},
|
},
|
||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
references: {
|
references: {
|
||||||
model: 'Users',
|
model: "Users",
|
||||||
key: 'id'
|
key: "id",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
address1: {
|
address1: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
},
|
},
|
||||||
address2: {
|
address2: {
|
||||||
type: DataTypes.STRING
|
type: DataTypes.STRING,
|
||||||
},
|
},
|
||||||
city: {
|
city: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
},
|
},
|
||||||
zipCode: {
|
zipCode: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
},
|
},
|
||||||
country: {
|
country: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 'US'
|
defaultValue: "US",
|
||||||
},
|
},
|
||||||
latitude: {
|
latitude: {
|
||||||
type: DataTypes.DECIMAL(10, 8)
|
type: DataTypes.DECIMAL(10, 8),
|
||||||
},
|
},
|
||||||
longitude: {
|
longitude: {
|
||||||
type: DataTypes.DECIMAL(11, 8)
|
type: DataTypes.DECIMAL(11, 8),
|
||||||
},
|
},
|
||||||
isPrimary: {
|
isPrimary: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = UserAddress;
|
module.exports = UserAddress;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { User, UserAddress } = require('../models'); // Import from models/index.
|
|||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { uploadProfileImage } = require('../middleware/upload');
|
const { uploadProfileImage } = require('../middleware/upload');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const userService = require('../services/UserService');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -57,15 +58,7 @@ router.get('/addresses', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
router.post('/addresses', authenticateToken, async (req, res) => {
|
router.post('/addresses', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const address = await UserAddress.create({
|
const address = await userService.createUserAddress(req.user.id, req.body);
|
||||||
...req.body,
|
|
||||||
userId: req.user.id
|
|
||||||
});
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.info("User address created", {
|
|
||||||
userId: req.user.id,
|
|
||||||
addressId: address.id
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(address);
|
res.status(201).json(address);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -82,23 +75,7 @@ router.post('/addresses', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
router.put('/addresses/:id', authenticateToken, async (req, res) => {
|
router.put('/addresses/:id', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const address = await UserAddress.findByPk(req.params.id);
|
const address = await userService.updateUserAddress(req.user.id, req.params.id, req.body);
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(address);
|
res.json(address);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -109,29 +86,18 @@ router.put('/addresses/:id', authenticateToken, async (req, res) => {
|
|||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
addressId: req.params.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 });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/addresses/:id', authenticateToken, async (req, res) => {
|
router.delete('/addresses/:id', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const address = await UserAddress.findByPk(req.params.id);
|
await userService.deleteUserAddress(req.user.id, 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
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -142,6 +108,11 @@ router.delete('/addresses/:id', authenticateToken, async (req, res) => {
|
|||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
addressId: req.params.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 });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -211,49 +182,13 @@ router.get('/:id', async (req, res) => {
|
|||||||
|
|
||||||
router.put('/profile', authenticateToken, async (req, res) => {
|
router.put('/profile', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
// Use UserService to handle update and email notification
|
||||||
firstName,
|
const updatedUser = await userService.updateProfile(req.user.id, req.body);
|
||||||
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'] }
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(updatedUser);
|
res.json(updatedUser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Profile update error:', error);
|
console.error('Profile update error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: error.message,
|
error: error.message,
|
||||||
details: error.errors ? error.errors.map(e => ({ field: e.path, message: e.message })) : undefined
|
details: error.errors ? error.errors.map(e => ({ field: e.path, message: e.message })) : undefined
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const feedbackRoutes = require("./routes/feedback");
|
|||||||
const PayoutProcessor = require("./jobs/payoutProcessor");
|
const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||||
const RentalStatusJob = require("./jobs/rentalStatusJob");
|
const RentalStatusJob = require("./jobs/rentalStatusJob");
|
||||||
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
|
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
|
||||||
|
const emailServices = require("./services/email");
|
||||||
|
|
||||||
// Socket.io setup
|
// Socket.io setup
|
||||||
const { authenticateSocket } = require("./sockets/socketAuth");
|
const { authenticateSocket } = require("./sockets/socketAuth");
|
||||||
@@ -166,9 +167,27 @@ const PORT = process.env.PORT || 5000;
|
|||||||
|
|
||||||
sequelize
|
sequelize
|
||||||
.sync({ alter: true })
|
.sync({ alter: true })
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
logger.info("Database synced successfully");
|
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
|
// Start the payout processor
|
||||||
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
||||||
logger.info("Payout processor started");
|
logger.info("Payout processor started");
|
||||||
|
|||||||
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 {
|
class EmailClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Singleton pattern - return existing instance if already created
|
||||||
|
if (EmailClient.instance) {
|
||||||
|
return EmailClient.instance;
|
||||||
|
}
|
||||||
|
|
||||||
this.sesClient = null;
|
this.sesClient = null;
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
this.initializationPromise = null;
|
||||||
|
|
||||||
|
EmailClient.instance = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,19 +28,30 @@ class EmailClient {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
// If already initialized, return immediately
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
|
||||||
try {
|
// If initialization is in progress, wait for it
|
||||||
// Use centralized AWS configuration with credential profiles
|
if (this.initializationPromise) {
|
||||||
const awsConfig = getAWSConfig();
|
return this.initializationPromise;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
class TemplateManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Singleton pattern - return existing instance if already created
|
||||||
|
if (TemplateManager.instance) {
|
||||||
|
return TemplateManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
this.templates = new Map();
|
this.templates = new Map();
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
this.initializationPromise = null;
|
||||||
|
|
||||||
|
TemplateManager.instance = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,11 +28,22 @@ class TemplateManager {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
// If already initialized, return immediately
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
|
||||||
await this.loadEmailTemplates();
|
// If initialization is in progress, wait for it
|
||||||
this.initialized = true;
|
if (this.initializationPromise) {
|
||||||
console.log("Email Template Manager initialized successfully");
|
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() {
|
async loadEmailTemplates() {
|
||||||
const templatesDir = path.join(__dirname, "..", "..", "..", "templates", "emails");
|
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 {
|
try {
|
||||||
const templateFiles = [
|
const templateFiles = [
|
||||||
"conditionCheckReminderToUser.html",
|
"conditionCheckReminderToUser.html",
|
||||||
@@ -41,6 +68,7 @@ class TemplateManager {
|
|||||||
"emailVerificationToUser.html",
|
"emailVerificationToUser.html",
|
||||||
"passwordResetToUser.html",
|
"passwordResetToUser.html",
|
||||||
"passwordChangedToUser.html",
|
"passwordChangedToUser.html",
|
||||||
|
"personalInfoChangedToUser.html",
|
||||||
"lateReturnToCS.html",
|
"lateReturnToCS.html",
|
||||||
"damageReportToCS.html",
|
"damageReportToCS.html",
|
||||||
"lostItemToCS.html",
|
"lostItemToCS.html",
|
||||||
@@ -69,6 +97,8 @@ class TemplateManager {
|
|||||||
"forumCommentDeletionToAuthor.html",
|
"forumCommentDeletionToAuthor.html",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const failedTemplates = [];
|
||||||
|
|
||||||
for (const templateFile of templateFiles) {
|
for (const templateFile of templateFiles) {
|
||||||
try {
|
try {
|
||||||
const templatePath = path.join(templatesDir, templateFile);
|
const templatePath = path.join(templatesDir, templateFile);
|
||||||
@@ -84,16 +114,39 @@ class TemplateManager {
|
|||||||
console.error(
|
console.error(
|
||||||
` Template path: ${path.join(templatesDir, templateFile)}`
|
` Template path: ${path.join(templatesDir, templateFile)}`
|
||||||
);
|
);
|
||||||
|
failedTemplates.push(templateFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Loaded ${this.templates.size} of ${templateFiles.length} email templates`
|
`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) {
|
} catch (error) {
|
||||||
console.error("Failed to load email templates:", error);
|
console.error("Failed to load email templates:", error);
|
||||||
console.error("Templates directory:", templatesDir);
|
console.error("Templates directory:", templatesDir);
|
||||||
console.error("Error stack:", error.stack);
|
console.error("Error stack:", error.stack);
|
||||||
|
throw error; // Re-throw to fail server startup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,41 @@ class AuthEmailService {
|
|||||||
htmlContent
|
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;
|
module.exports = AuthEmailService;
|
||||||
|
|||||||
246
backend/templates/emails/personalInfoChangedToUser.html
Normal file
246
backend/templates/emails/personalInfoChangedToUser.html
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Personal Information Updated - RentAll</title>
|
||||||
|
<style>
|
||||||
|
/* Reset styles */
|
||||||
|
body, table, td, p, a, li, blockquote {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table, td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #d4edda;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #6c757d;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success box */
|
||||||
|
.success-box {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #155724;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info box */
|
||||||
|
.info-box {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left: 4px solid #0066cc;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #004085;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Security box */
|
||||||
|
.security-box {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #721c24;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Details table */
|
||||||
|
.details-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td:first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td:last-child {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header, .content, .footer {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">RentAll</div>
|
||||||
|
<div class="tagline">Personal Information Updated</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hi {{recipientName}},</p>
|
||||||
|
|
||||||
|
<h1>Your Personal Information Has Been Updated</h1>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Your account information was recently updated.</strong> This email is to notify you that changes were made to your personal information on your RentAll account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>We're sending you this notification as part of our commitment to keeping your account secure. If you made these changes, no further action is required.</p>
|
||||||
|
|
||||||
|
<table class="details-table">
|
||||||
|
<tr>
|
||||||
|
<td>Date & Time:</td>
|
||||||
|
<td>{{timestamp}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Account Email:</td>
|
||||||
|
<td>{{email}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="security-box">
|
||||||
|
<p><strong>Didn't make these changes?</strong> If you did not update your personal information, your account may be compromised. Please contact our support team immediately at support@rentall.com and consider changing your password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Security tip:</strong> Regularly review your account information to ensure it's accurate and up to date. If you notice any suspicious activity, contact our support team right away.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Thanks for using RentAll!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>RentAll</strong></p>
|
||||||
|
<p>This is a security notification sent to confirm changes to your account. If you have any concerns about your account security, please contact our support team immediately.</p>
|
||||||
|
<p>© 2024 RentAll. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -807,20 +807,52 @@ const Profile: React.FC = () => {
|
|||||||
{/* Personal Information Card */}
|
{/* Personal Information Card */}
|
||||||
<div className="card mb-4">
|
<div className="card mb-4">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="d-flex align-items-center mb-3">
|
<div className="d-flex align-items-center justify-content-between mb-3">
|
||||||
<h5 className="card-title mb-0">Personal Information</h5>
|
<div className="d-flex align-items-center">
|
||||||
<button
|
<h5 className="card-title mb-0">Personal Information</h5>
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-link text-primary p-0 ms-2"
|
type="button"
|
||||||
onClick={() => setShowPersonalInfo(!showPersonalInfo)}
|
className="btn btn-link text-primary p-0 ms-2"
|
||||||
style={{ textDecoration: "none" }}
|
onClick={() => setShowPersonalInfo(!showPersonalInfo)}
|
||||||
>
|
style={{ textDecoration: "none" }}
|
||||||
<i
|
>
|
||||||
className={`bi ${
|
<i
|
||||||
showPersonalInfo ? "bi-eye" : "bi-eye-slash"
|
className={`bi ${
|
||||||
} fs-5`}
|
showPersonalInfo ? "bi-eye" : "bi-eye-slash"
|
||||||
></i>
|
} fs-5`}
|
||||||
</button>
|
></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showPersonalInfo && (
|
||||||
|
<div>
|
||||||
|
{editing ? (
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
Edit Information
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showPersonalInfo && (
|
{showPersonalInfo && (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -1125,31 +1157,6 @@ const Profile: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
{editing ? (
|
|
||||||
<div className="d-flex gap-2">
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={handleCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
Edit Information
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user