rental request email to owner
This commit is contained in:
@@ -13,7 +13,7 @@ const Item = sequelize.define("Item", {
|
|||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
allowNull: false,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
pickUpAvailable: {
|
pickUpAvailable: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"/places/details",
|
"/places/details",
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
rateLimiter.burstProtection,
|
|
||||||
rateLimiter.placeDetails,
|
rateLimiter.placeDetails,
|
||||||
validateInput,
|
validateInput,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
|||||||
@@ -284,6 +284,24 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send rental request notification to owner
|
||||||
|
try {
|
||||||
|
await emailService.sendRentalRequestEmail(rentalWithDetails);
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Rental request notification sent to owner", {
|
||||||
|
rentalId: rental.id,
|
||||||
|
ownerId: rentalWithDetails.ownerId,
|
||||||
|
});
|
||||||
|
} catch (emailError) {
|
||||||
|
// Log error but don't fail the request
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Failed to send rental request email", {
|
||||||
|
error: emailError.message,
|
||||||
|
rentalId: rental.id,
|
||||||
|
ownerId: rentalWithDetails.ownerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json(rentalWithDetails);
|
res.status(201).json(rentalWithDetails);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: "Failed to create rental" });
|
res.status(500).json({ error: "Failed to create rental" });
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class EmailService {
|
|||||||
"lateReturnCS.html",
|
"lateReturnCS.html",
|
||||||
"damageReportCS.html",
|
"damageReportCS.html",
|
||||||
"lostItemCS.html",
|
"lostItemCS.html",
|
||||||
|
"rentalRequest.html",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const templateFile of templateFiles) {
|
for (const templateFile of templateFiles) {
|
||||||
@@ -65,38 +66,40 @@ class EmailService {
|
|||||||
* Strips HTML tags and formats content for plain text email clients
|
* Strips HTML tags and formats content for plain text email clients
|
||||||
*/
|
*/
|
||||||
htmlToPlainText(html) {
|
htmlToPlainText(html) {
|
||||||
return html
|
return (
|
||||||
|
html
|
||||||
// Remove style and script tags and their content
|
// Remove style and script tags and their content
|
||||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
||||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||||
// Convert common HTML elements to text equivalents
|
// Convert common HTML elements to text equivalents
|
||||||
.replace(/<br\s*\/?>/gi, '\n')
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
.replace(/<\/p>/gi, '\n\n')
|
.replace(/<\/p>/gi, "\n\n")
|
||||||
.replace(/<\/div>/gi, '\n')
|
.replace(/<\/div>/gi, "\n")
|
||||||
.replace(/<\/li>/gi, '\n')
|
.replace(/<\/li>/gi, "\n")
|
||||||
.replace(/<\/h[1-6]>/gi, '\n\n')
|
.replace(/<\/h[1-6]>/gi, "\n\n")
|
||||||
.replace(/<li>/gi, '• ')
|
.replace(/<li>/gi, "• ")
|
||||||
// Remove remaining HTML tags
|
// Remove remaining HTML tags
|
||||||
.replace(/<[^>]+>/g, '')
|
.replace(/<[^>]+>/g, "")
|
||||||
// Decode HTML entities
|
// Decode HTML entities
|
||||||
.replace(/ /g, ' ')
|
.replace(/ /g, " ")
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, '<')
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, "'")
|
.replace(/'/g, "'")
|
||||||
// Remove emojis and special characters that don't render well in plain text
|
// Remove emojis and special characters that don't render well in plain text
|
||||||
.replace(/[\u{1F600}-\u{1F64F}]/gu, '') // Emoticons
|
.replace(/[\u{1F600}-\u{1F64F}]/gu, "") // Emoticons
|
||||||
.replace(/[\u{1F300}-\u{1F5FF}]/gu, '') // Misc Symbols and Pictographs
|
.replace(/[\u{1F300}-\u{1F5FF}]/gu, "") // Misc Symbols and Pictographs
|
||||||
.replace(/[\u{1F680}-\u{1F6FF}]/gu, '') // Transport and Map
|
.replace(/[\u{1F680}-\u{1F6FF}]/gu, "") // Transport and Map
|
||||||
.replace(/[\u{2600}-\u{26FF}]/gu, '') // Misc symbols
|
.replace(/[\u{2600}-\u{26FF}]/gu, "") // Misc symbols
|
||||||
.replace(/[\u{2700}-\u{27BF}]/gu, '') // Dingbats
|
.replace(/[\u{2700}-\u{27BF}]/gu, "") // Dingbats
|
||||||
.replace(/[\u{FE00}-\u{FE0F}]/gu, '') // Variation Selectors
|
.replace(/[\u{FE00}-\u{FE0F}]/gu, "") // Variation Selectors
|
||||||
.replace(/[\u{1F900}-\u{1F9FF}]/gu, '') // Supplemental Symbols and Pictographs
|
.replace(/[\u{1F900}-\u{1F9FF}]/gu, "") // Supplemental Symbols and Pictographs
|
||||||
.replace(/[\u{1FA70}-\u{1FAFF}]/gu, '') // Symbols and Pictographs Extended-A
|
.replace(/[\u{1FA70}-\u{1FAFF}]/gu, "") // Symbols and Pictographs Extended-A
|
||||||
// Clean up excessive whitespace
|
// Clean up excessive whitespace
|
||||||
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
.replace(/\n\s*\n\s*\n/g, "\n\n")
|
||||||
.trim();
|
.trim()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendEmail(to, subject, htmlContent, textContent = null) {
|
async sendEmail(to, subject, htmlContent, textContent = null) {
|
||||||
@@ -271,6 +274,22 @@ class EmailService {
|
|||||||
<p><strong>Didn't change your password?</strong> If you did not make this change, please contact our support team immediately.</p>
|
<p><strong>Didn't change your password?</strong> If you did not make this change, please contact our support team immediately.</p>
|
||||||
`
|
`
|
||||||
),
|
),
|
||||||
|
|
||||||
|
rentalRequest: baseTemplate.replace(
|
||||||
|
"{{content}}",
|
||||||
|
`
|
||||||
|
<p>Hi {{ownerName}},</p>
|
||||||
|
<h2>New Rental Request for {{itemName}}</h2>
|
||||||
|
<p>{{renterName}} would like to rent your item.</p>
|
||||||
|
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
|
||||||
|
<p><strong>Total Amount:</strong> \${{totalAmount}}</p>
|
||||||
|
<p><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
|
||||||
|
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
|
||||||
|
<p><strong>Renter Notes:</strong> {{rentalNotes}}</p>
|
||||||
|
<p><a href="{{approveUrl}}" class="button">Review & Respond</a></p>
|
||||||
|
<p>Please respond to this request within 24 hours.</p>
|
||||||
|
`
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -307,7 +326,12 @@ class EmailService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendRentalConfirmation(userEmail, notification, rental, recipientName = null) {
|
async sendRentalConfirmation(
|
||||||
|
userEmail,
|
||||||
|
notification,
|
||||||
|
rental,
|
||||||
|
recipientName = null
|
||||||
|
) {
|
||||||
const itemName = rental?.item?.name || "Unknown Item";
|
const itemName = rental?.item?.name || "Unknown Item";
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
@@ -328,11 +352,7 @@ class EmailService {
|
|||||||
// Use clear, transactional subject line with item name
|
// Use clear, transactional subject line with item name
|
||||||
const subject = `Rental Confirmation - ${itemName}`;
|
const subject = `Rental Confirmation - ${itemName}`;
|
||||||
|
|
||||||
return await this.sendEmail(
|
return await this.sendEmail(userEmail, subject, htmlContent);
|
||||||
userEmail,
|
|
||||||
subject,
|
|
||||||
htmlContent
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendVerificationEmail(user, verificationToken) {
|
async sendVerificationEmail(user, verificationToken) {
|
||||||
@@ -392,6 +412,62 @@ class EmailService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendRentalRequestEmail(rental) {
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||||
|
const approveUrl = `${frontendUrl}/my-listings?rentalId=${rental.id}`;
|
||||||
|
|
||||||
|
// Fetch owner details
|
||||||
|
const owner = await User.findByPk(rental.ownerId, {
|
||||||
|
attributes: ["email", "firstName", "lastName"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch renter details
|
||||||
|
const renter = await User.findByPk(rental.renterId, {
|
||||||
|
attributes: ["firstName", "lastName"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!owner || !renter) {
|
||||||
|
console.error(
|
||||||
|
"Owner or renter not found for rental request notification"
|
||||||
|
);
|
||||||
|
return { success: false, error: "User not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
ownerName: owner.firstName,
|
||||||
|
renterName: `${renter.firstName} ${renter.lastName}`.trim() || "A renter",
|
||||||
|
itemName: rental.item?.name || "your item",
|
||||||
|
startDate: rental.startDateTime
|
||||||
|
? new Date(rental.startDateTime).toLocaleString("en-US", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
})
|
||||||
|
: "Not specified",
|
||||||
|
endDate: rental.endDateTime
|
||||||
|
? new Date(rental.endDateTime).toLocaleString("en-US", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
})
|
||||||
|
: "Not specified",
|
||||||
|
totalAmount: rental.totalAmount
|
||||||
|
? parseFloat(rental.totalAmount).toFixed(2)
|
||||||
|
: "0.00",
|
||||||
|
payoutAmount: rental.payoutAmount
|
||||||
|
? parseFloat(rental.payoutAmount).toFixed(2)
|
||||||
|
: "0.00",
|
||||||
|
deliveryMethod: rental.deliveryMethod || "Not specified",
|
||||||
|
approveUrl: approveUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlContent = this.renderTemplate("rentalRequest", variables);
|
||||||
|
|
||||||
|
return await this.sendEmail(
|
||||||
|
owner.email,
|
||||||
|
`Rental Request for ${rental.item?.name || "Your Item"}`,
|
||||||
|
htmlContent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
|
async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
|
||||||
const htmlContent = this.renderTemplate(templateName, variables);
|
const htmlContent = this.renderTemplate(templateName, variables);
|
||||||
return await this.sendEmail(toEmail, subject, htmlContent);
|
return await this.sendEmail(toEmail, subject, htmlContent);
|
||||||
@@ -461,9 +537,9 @@ class EmailService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total fees
|
// Calculate total fees (ensure numeric values)
|
||||||
const damageFee = damageAssessment.feeCalculation.amount;
|
const damageFee = parseFloat(damageAssessment.feeCalculation.amount) || 0;
|
||||||
const lateFee = lateCalculation?.lateFee || 0;
|
const lateFee = parseFloat(lateCalculation?.lateFee || 0);
|
||||||
const totalFees = damageFee + lateFee;
|
const totalFees = damageFee + lateFee;
|
||||||
|
|
||||||
// Determine fee type description
|
// Determine fee type description
|
||||||
|
|||||||
333
backend/templates/emails/rentalRequest.html
Normal file
333
backend/templates/emails/rentalRequest.html
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<!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>Rental Request - 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, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #e9ecef;
|
||||||
|
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 h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 30px 0 15px 0;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #6c757d;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info box */
|
||||||
|
.info-box {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left: 4px solid #2196f3;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight box */
|
||||||
|
.highlight-box {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #155724;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info table */
|
||||||
|
.info-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th,
|
||||||
|
.info-table td {
|
||||||
|
padding: 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table td {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th,
|
||||||
|
.info-table td {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">RentAll</div>
|
||||||
|
<div class="tagline">Rental Request</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hi {{ownerName}},</p>
|
||||||
|
|
||||||
|
<h1>You have a new rental request!</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{renterName}} would like to rent your <strong>{{itemName}}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="highlight-box">
|
||||||
|
<p>You'll earn: ${{payoutAmount}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Request Details</h2>
|
||||||
|
<table class="info-table">
|
||||||
|
<tr>
|
||||||
|
<th>Renter</th>
|
||||||
|
<td>{{renterName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<td>{{itemName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Start Date</th>
|
||||||
|
<td>{{startDate}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>End Date</th>
|
||||||
|
<td>{{endDate}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Delivery Method</th>
|
||||||
|
<td>{{deliveryMethod}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Total Amount</th>
|
||||||
|
<td>${{totalAmount}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Your Earnings</th>
|
||||||
|
<td>${{payoutAmount}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Renter's Notes:</strong></p>
|
||||||
|
<p>{{rentalNotes}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center">
|
||||||
|
<a href="{{approveUrl}}" class="button">Review & Respond</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>What happens next?</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Review the rental request and renter's profile</li>
|
||||||
|
<li>Approve or decline the request within 24 hours</li>
|
||||||
|
<li>If approved, payment will be processed automatically</li>
|
||||||
|
<li>Communicate with the renter about pickup/delivery details</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Important reminder:</strong> Please respond to rental requests
|
||||||
|
promptly. Quick responses help build trust and improve your listing's
|
||||||
|
visibility.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>RentAll</strong></p>
|
||||||
|
<p>
|
||||||
|
This is a transactional email about a rental request for your listing.
|
||||||
|
You received this message because you have an active listing on
|
||||||
|
RentAll.
|
||||||
|
</p>
|
||||||
|
<p>If you have any questions, please contact our support team.</p>
|
||||||
|
<p>© 2024 RentAll. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -416,4 +416,189 @@ describe('EmailService', () => {
|
|||||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sendRentalRequestEmail', () => {
|
||||||
|
const { User } = require('../../../models');
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockSend.mockResolvedValue({ MessageId: 'test-message-id' });
|
||||||
|
await emailService.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send rental request email to owner', async () => {
|
||||||
|
const mockOwner = {
|
||||||
|
email: 'owner@example.com',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Smith'
|
||||||
|
};
|
||||||
|
const mockRenter = {
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk
|
||||||
|
.mockResolvedValueOnce(mockOwner) // First call for owner
|
||||||
|
.mockResolvedValueOnce(mockRenter); // Second call for renter
|
||||||
|
|
||||||
|
const rental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 10,
|
||||||
|
renterId: 20,
|
||||||
|
startDateTime: new Date('2024-12-01T10:00:00Z'),
|
||||||
|
endDateTime: new Date('2024-12-03T10:00:00Z'),
|
||||||
|
totalAmount: 150.00,
|
||||||
|
payoutAmount: 135.00,
|
||||||
|
deliveryMethod: 'pickup',
|
||||||
|
notes: 'Please have it ready by 9am',
|
||||||
|
item: { name: 'Power Drill' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await emailService.sendRentalRequestEmail(rental);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.messageId).toBe('test-message-id');
|
||||||
|
expect(mockSend).toHaveBeenCalledWith(expect.any(SendEmailCommand));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing owner gracefully', async () => {
|
||||||
|
User.findByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const rental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 1,
|
||||||
|
renterId: 2,
|
||||||
|
item: { name: 'Power Drill' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await emailService.sendRentalRequestEmail(rental);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('User not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing renter gracefully', async () => {
|
||||||
|
const mockOwner = {
|
||||||
|
email: 'owner@example.com',
|
||||||
|
firstName: 'John'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk
|
||||||
|
.mockResolvedValueOnce(mockOwner)
|
||||||
|
.mockResolvedValueOnce(null); // Renter not found
|
||||||
|
|
||||||
|
const rental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 1,
|
||||||
|
renterId: 2,
|
||||||
|
item: { name: 'Power Drill' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await emailService.sendRentalRequestEmail(rental);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('User not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle free rentals (amount = 0)', async () => {
|
||||||
|
const mockOwner = {
|
||||||
|
email: 'owner@example.com',
|
||||||
|
firstName: 'John'
|
||||||
|
};
|
||||||
|
const mockRenter = {
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk
|
||||||
|
.mockResolvedValueOnce(mockOwner)
|
||||||
|
.mockResolvedValueOnce(mockRenter);
|
||||||
|
|
||||||
|
const rental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 10,
|
||||||
|
renterId: 20,
|
||||||
|
startDateTime: new Date('2024-12-01T10:00:00Z'),
|
||||||
|
endDateTime: new Date('2024-12-03T10:00:00Z'),
|
||||||
|
totalAmount: 0,
|
||||||
|
payoutAmount: 0,
|
||||||
|
deliveryMethod: 'pickup',
|
||||||
|
notes: null,
|
||||||
|
item: { name: 'Free Item' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await emailService.sendRentalRequestEmail(rental);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing rental notes', async () => {
|
||||||
|
const mockOwner = {
|
||||||
|
email: 'owner@example.com',
|
||||||
|
firstName: 'John'
|
||||||
|
};
|
||||||
|
const mockRenter = {
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk
|
||||||
|
.mockResolvedValueOnce(mockOwner)
|
||||||
|
.mockResolvedValueOnce(mockRenter);
|
||||||
|
|
||||||
|
const rental = {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 10,
|
||||||
|
renterId: 20,
|
||||||
|
startDateTime: new Date('2024-12-01T10:00:00Z'),
|
||||||
|
endDateTime: new Date('2024-12-03T10:00:00Z'),
|
||||||
|
totalAmount: 100,
|
||||||
|
payoutAmount: 90,
|
||||||
|
deliveryMethod: 'delivery',
|
||||||
|
notes: null, // No notes
|
||||||
|
item: { name: 'Test Item' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await emailService.sendRentalRequestEmail(rental);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockSend).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate correct approval URL', async () => {
|
||||||
|
const mockOwner = {
|
||||||
|
email: 'owner@example.com',
|
||||||
|
firstName: 'John'
|
||||||
|
};
|
||||||
|
const mockRenter = {
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe'
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findByPk
|
||||||
|
.mockResolvedValueOnce(mockOwner)
|
||||||
|
.mockResolvedValueOnce(mockRenter);
|
||||||
|
|
||||||
|
process.env.FRONTEND_URL = 'https://rentall.com';
|
||||||
|
|
||||||
|
const rental = {
|
||||||
|
id: 123,
|
||||||
|
ownerId: 10,
|
||||||
|
renterId: 20,
|
||||||
|
startDateTime: new Date('2024-12-01T10:00:00Z'),
|
||||||
|
endDateTime: new Date('2024-12-03T10:00:00Z'),
|
||||||
|
totalAmount: 100,
|
||||||
|
payoutAmount: 90,
|
||||||
|
deliveryMethod: 'pickup',
|
||||||
|
notes: 'Test notes',
|
||||||
|
item: { name: 'Test Item' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await emailService.sendRentalRequestEmail(rental);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
// The URL should be constructed correctly
|
||||||
|
// We can't directly test the content, but we know it was called
|
||||||
|
expect(mockSend).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -33,7 +33,7 @@ const ItemInformation: React.FC<ItemInformationProps> = ({
|
|||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label htmlFor="description" className="form-label">
|
<label htmlFor="description" className="form-label">
|
||||||
Description *
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="form-control"
|
className="form-control"
|
||||||
@@ -42,7 +42,6 @@ const ItemInformation: React.FC<ItemInformationProps> = ({
|
|||||||
rows={4}
|
rows={4}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -267,10 +267,6 @@ const RentItem: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : totalCost === 0 ? (
|
) : totalCost === 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="alert alert-success">
|
|
||||||
<i className="bi bi-check-circle me-2"></i>
|
|
||||||
This item is free to borrow! No payment required
|
|
||||||
</div>
|
|
||||||
<div className="d-grid gap-2">
|
<div className="d-grid gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -58,13 +58,9 @@ api.interceptors.request.use(async (config) => {
|
|||||||
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
||||||
// If we don't have a CSRF token yet, try to fetch it
|
// If we don't have a CSRF token yet, try to fetch it
|
||||||
if (!csrfToken) {
|
if (!csrfToken) {
|
||||||
// Skip fetching for most auth endpoints to avoid circular dependency
|
// Skip fetching only for the CSRF token endpoint itself to avoid circular dependency
|
||||||
// Exception: /auth/google needs CSRF token and should auto-fetch as fallback
|
const isCsrfEndpoint = config.url?.includes("/auth/csrf-token");
|
||||||
const isAuthEndpoint =
|
if (!isCsrfEndpoint) {
|
||||||
config.url?.includes("/auth/") &&
|
|
||||||
!config.url?.includes("/auth/refresh") &&
|
|
||||||
!config.url?.includes("/auth/google");
|
|
||||||
if (!isAuthEndpoint) {
|
|
||||||
await fetchCSRFToken();
|
await fetchCSRFToken();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user