rental request email to owner
This commit is contained in:
@@ -13,7 +13,7 @@ const Item = sequelize.define("Item", {
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
},
|
||||
pickUpAvailable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
|
||||
@@ -113,7 +113,6 @@ router.post(
|
||||
router.post(
|
||||
"/places/details",
|
||||
authenticateToken,
|
||||
rateLimiter.burstProtection,
|
||||
rateLimiter.placeDetails,
|
||||
validateInput,
|
||||
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);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: "Failed to create rental" });
|
||||
|
||||
@@ -41,6 +41,7 @@ class EmailService {
|
||||
"lateReturnCS.html",
|
||||
"damageReportCS.html",
|
||||
"lostItemCS.html",
|
||||
"rentalRequest.html",
|
||||
];
|
||||
|
||||
for (const templateFile of templateFiles) {
|
||||
@@ -65,38 +66,40 @@ class EmailService {
|
||||
* Strips HTML tags and formats content for plain text email clients
|
||||
*/
|
||||
htmlToPlainText(html) {
|
||||
return html
|
||||
return (
|
||||
html
|
||||
// Remove style and script tags and their content
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
// Convert common HTML elements to text equivalents
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>/gi, '\n\n')
|
||||
.replace(/<\/div>/gi, '\n')
|
||||
.replace(/<\/li>/gi, '\n')
|
||||
.replace(/<\/h[1-6]>/gi, '\n\n')
|
||||
.replace(/<li>/gi, '• ')
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n\n")
|
||||
.replace(/<\/div>/gi, "\n")
|
||||
.replace(/<\/li>/gi, "\n")
|
||||
.replace(/<\/h[1-6]>/gi, "\n\n")
|
||||
.replace(/<li>/gi, "• ")
|
||||
// Remove remaining HTML tags
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/<[^>]+>/g, "")
|
||||
// Decode HTML entities
|
||||
.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
|
||||
.replace(/[\u{1F600}-\u{1F64F}]/gu, '') // Emoticons
|
||||
.replace(/[\u{1F300}-\u{1F5FF}]/gu, '') // Misc Symbols and Pictographs
|
||||
.replace(/[\u{1F680}-\u{1F6FF}]/gu, '') // Transport and Map
|
||||
.replace(/[\u{2600}-\u{26FF}]/gu, '') // Misc symbols
|
||||
.replace(/[\u{2700}-\u{27BF}]/gu, '') // Dingbats
|
||||
.replace(/[\u{FE00}-\u{FE0F}]/gu, '') // Variation Selectors
|
||||
.replace(/[\u{1F900}-\u{1F9FF}]/gu, '') // Supplemental Symbols and Pictographs
|
||||
.replace(/[\u{1FA70}-\u{1FAFF}]/gu, '') // Symbols and Pictographs Extended-A
|
||||
.replace(/[\u{1F600}-\u{1F64F}]/gu, "") // Emoticons
|
||||
.replace(/[\u{1F300}-\u{1F5FF}]/gu, "") // Misc Symbols and Pictographs
|
||||
.replace(/[\u{1F680}-\u{1F6FF}]/gu, "") // Transport and Map
|
||||
.replace(/[\u{2600}-\u{26FF}]/gu, "") // Misc symbols
|
||||
.replace(/[\u{2700}-\u{27BF}]/gu, "") // Dingbats
|
||||
.replace(/[\u{FE00}-\u{FE0F}]/gu, "") // Variation Selectors
|
||||
.replace(/[\u{1F900}-\u{1F9FF}]/gu, "") // Supplemental Symbols and Pictographs
|
||||
.replace(/[\u{1FA70}-\u{1FAFF}]/gu, "") // Symbols and Pictographs Extended-A
|
||||
// Clean up excessive whitespace
|
||||
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
||||
.trim();
|
||||
.replace(/\n\s*\n\s*\n/g, "\n\n")
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
`
|
||||
),
|
||||
|
||||
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 (
|
||||
@@ -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 variables = {
|
||||
@@ -328,11 +352,7 @@ class EmailService {
|
||||
// Use clear, transactional subject line with item name
|
||||
const subject = `Rental Confirmation - ${itemName}`;
|
||||
|
||||
return await this.sendEmail(
|
||||
userEmail,
|
||||
subject,
|
||||
htmlContent
|
||||
);
|
||||
return await this.sendEmail(userEmail, subject, htmlContent);
|
||||
}
|
||||
|
||||
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 = {}) {
|
||||
const htmlContent = this.renderTemplate(templateName, variables);
|
||||
return await this.sendEmail(toEmail, subject, htmlContent);
|
||||
@@ -461,9 +537,9 @@ class EmailService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate total fees
|
||||
const damageFee = damageAssessment.feeCalculation.amount;
|
||||
const lateFee = lateCalculation?.lateFee || 0;
|
||||
// Calculate total fees (ensure numeric values)
|
||||
const damageFee = parseFloat(damageAssessment.feeCalculation.amount) || 0;
|
||||
const lateFee = parseFloat(lateCalculation?.lateFee || 0);
|
||||
const totalFees = damageFee + lateFee;
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
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">
|
||||
<label htmlFor="description" className="form-label">
|
||||
Description *
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
@@ -42,7 +42,6 @@ const ItemInformation: React.FC<ItemInformationProps> = ({
|
||||
rows={4}
|
||||
value={description}
|
||||
onChange={onChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -267,10 +267,6 @@ const RentItem: React.FC = () => {
|
||||
</div>
|
||||
) : 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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -58,13 +58,9 @@ api.interceptors.request.use(async (config) => {
|
||||
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
||||
// If we don't have a CSRF token yet, try to fetch it
|
||||
if (!csrfToken) {
|
||||
// Skip fetching for most auth endpoints to avoid circular dependency
|
||||
// Exception: /auth/google needs CSRF token and should auto-fetch as fallback
|
||||
const isAuthEndpoint =
|
||||
config.url?.includes("/auth/") &&
|
||||
!config.url?.includes("/auth/refresh") &&
|
||||
!config.url?.includes("/auth/google");
|
||||
if (!isAuthEndpoint) {
|
||||
// Skip fetching only for the CSRF token endpoint itself to avoid circular dependency
|
||||
const isCsrfEndpoint = config.url?.includes("/auth/csrf-token");
|
||||
if (!isCsrfEndpoint) {
|
||||
await fetchCSRFToken();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user