From 407c69aa2207f5948511cbadd88ad24386abe52b Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:19:23 -0400 Subject: [PATCH] rental request email to owner --- backend/models/Item.js | 2 +- backend/routes/maps.js | 1 - backend/routes/rentals.js | 18 + backend/services/emailService.js | 158 ++++++--- backend/templates/emails/rentalRequest.html | 333 ++++++++++++++++++ .../tests/unit/services/emailService.test.js | 185 ++++++++++ frontend/src/components/ItemInformation.tsx | 3 +- frontend/src/pages/RentItem.tsx | 4 - frontend/src/services/api.ts | 10 +- 9 files changed, 658 insertions(+), 56 deletions(-) create mode 100644 backend/templates/emails/rentalRequest.html diff --git a/backend/models/Item.js b/backend/models/Item.js index 103dc7f..8ad8c7b 100644 --- a/backend/models/Item.js +++ b/backend/models/Item.js @@ -13,7 +13,7 @@ const Item = sequelize.define("Item", { }, description: { type: DataTypes.TEXT, - allowNull: false, + allowNull: true, }, pickUpAvailable: { type: DataTypes.BOOLEAN, diff --git a/backend/routes/maps.js b/backend/routes/maps.js index 65d526c..f29b381 100644 --- a/backend/routes/maps.js +++ b/backend/routes/maps.js @@ -113,7 +113,6 @@ router.post( router.post( "/places/details", authenticateToken, - rateLimiter.burstProtection, rateLimiter.placeDetails, validateInput, async (req, res) => { diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index c67f39b..0cb6d01 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -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" }); diff --git a/backend/services/emailService.js b/backend/services/emailService.js index 1f69762..13b0411 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -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 - // Remove style and script tags and their content - .replace(/]*>[\s\S]*?<\/style>/gi, '') - .replace(/]*>[\s\S]*?<\/script>/gi, '') - // Convert common HTML elements to text equivalents - .replace(//gi, '\n') - .replace(/<\/p>/gi, '\n\n') - .replace(/<\/div>/gi, '\n') - .replace(/<\/li>/gi, '\n') - .replace(/<\/h[1-6]>/gi, '\n\n') - .replace(/
  • /gi, '• ') - // Remove remaining HTML tags - .replace(/<[^>]+>/g, '') - // Decode HTML entities - .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 - // Clean up excessive whitespace - .replace(/\n\s*\n\s*\n/g, '\n\n') - .trim(); + return ( + html + // Remove style and script tags and their content + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/]*>[\s\S]*?<\/script>/gi, "") + // Convert common HTML elements to text equivalents + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n\n") + .replace(/<\/div>/gi, "\n") + .replace(/<\/li>/gi, "\n") + .replace(/<\/h[1-6]>/gi, "\n\n") + .replace(/
  • /gi, "• ") + // Remove remaining HTML tags + .replace(/<[^>]+>/g, "") + // Decode HTML entities + .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 + // Clean up excessive whitespace + .replace(/\n\s*\n\s*\n/g, "\n\n") + .trim() + ); } async sendEmail(to, subject, htmlContent, textContent = null) { @@ -271,6 +274,22 @@ class EmailService {

    Didn't change your password? If you did not make this change, please contact our support team immediately.

    ` ), + + rentalRequest: baseTemplate.replace( + "{{content}}", + ` +

    Hi {{ownerName}},

    +

    New Rental Request for {{itemName}}

    +

    {{renterName}} would like to rent your item.

    +

    Rental Period: {{startDate}} to {{endDate}}

    +

    Total Amount: \${{totalAmount}}

    +

    Your Earnings: \${{payoutAmount}}

    +

    Delivery Method: {{deliveryMethod}}

    +

    Renter Notes: {{rentalNotes}}

    +

    Review & Respond

    +

    Please respond to this request within 24 hours.

    + ` + ), }; 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 diff --git a/backend/templates/emails/rentalRequest.html b/backend/templates/emails/rentalRequest.html new file mode 100644 index 0000000..6c371cf --- /dev/null +++ b/backend/templates/emails/rentalRequest.html @@ -0,0 +1,333 @@ + + + + + + + Rental Request - RentAll + + + + + + diff --git a/backend/tests/unit/services/emailService.test.js b/backend/tests/unit/services/emailService.test.js index b34d22c..bac0594 100644 --- a/backend/tests/unit/services/emailService.test.js +++ b/backend/tests/unit/services/emailService.test.js @@ -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(); + }); + }); }); \ No newline at end of file diff --git a/frontend/src/components/ItemInformation.tsx b/frontend/src/components/ItemInformation.tsx index 0015a7f..de84ec1 100644 --- a/frontend/src/components/ItemInformation.tsx +++ b/frontend/src/components/ItemInformation.tsx @@ -33,7 +33,7 @@ const ItemInformation: React.FC = ({