rental request email to owner

This commit is contained in:
jackiettran
2025-10-15 15:19:23 -04:00
parent b9e6cfc54d
commit 407c69aa22
9 changed files with 658 additions and 56 deletions

View File

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

View File

@@ -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) => {

View File

@@ -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" });

View File

@@ -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(/&nbsp;/g, ' ') .replace(/&nbsp;/g, " ")
.replace(/&amp;/g, '&') .replace(/&amp;/g, "&")
.replace(/&lt;/g, '<') .replace(/&lt;/g, "<")
.replace(/&gt;/g, '>') .replace(/&gt;/g, ">")
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.replace(/&#39;/g, "'") .replace(/&#39;/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

View 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>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -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();
});
});
}); });

View File

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

View File

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

View File

@@ -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();
} }
} }