From 502d84a741a8e80fd69b10de50c5440f0e44471f Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:07:02 -0400 Subject: [PATCH] emails for rental cancelation, rental declined, rental request confirmation, payout received --- backend/models/Rental.js | 4 + backend/routes/rentals.js | 147 +++++- backend/services/emailService.js | 475 +++++++++++++++++- backend/services/payoutService.js | 25 +- backend/templates/emails/payoutReceived.html | 419 +++++++++++++++ .../rentalCancellationConfirmation.html | 311 ++++++++++++ .../rentalCancellationNotification.html | 310 ++++++++++++ backend/templates/emails/rentalDeclined.html | 317 ++++++++++++ .../emails/rentalRequestConfirmation.html | 320 ++++++++++++ backend/utils/logger.js | 35 +- .../src/components/DeclineRentalModal.tsx | 252 ++++++++++ .../components/RentalCancellationModal.tsx | 29 +- frontend/src/pages/MyListings.tsx | 56 ++- frontend/src/pages/MyRentals.tsx | 21 +- frontend/src/pages/Profile.tsx | 8 +- frontend/src/services/api.ts | 2 + frontend/src/types/index.ts | 4 +- 17 files changed, 2690 insertions(+), 45 deletions(-) create mode 100644 backend/templates/emails/payoutReceived.html create mode 100644 backend/templates/emails/rentalCancellationConfirmation.html create mode 100644 backend/templates/emails/rentalCancellationNotification.html create mode 100644 backend/templates/emails/rentalDeclined.html create mode 100644 backend/templates/emails/rentalRequestConfirmation.html create mode 100644 frontend/src/components/DeclineRentalModal.tsx diff --git a/backend/models/Rental.js b/backend/models/Rental.js index 653ef2a..27ebeb6 100644 --- a/backend/models/Rental.js +++ b/backend/models/Rental.js @@ -55,6 +55,7 @@ const Rental = sequelize.define("Rental", { type: DataTypes.ENUM( "pending", "confirmed", + "declined", "active", "completed", "cancelled", @@ -98,6 +99,9 @@ const Rental = sequelize.define("Rental", { cancelledAt: { type: DataTypes.DATE, }, + declineReason: { + type: DataTypes.TEXT, + }, stripePaymentMethodId: { type: DataTypes.STRING, }, diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index 0cb6d01..0afd412 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -296,12 +296,30 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => { // 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, + error: emailError, rentalId: rental.id, ownerId: rentalWithDetails.ownerId, }); } + // Send rental request confirmation to renter + try { + await emailService.sendRentalRequestConfirmationEmail(rentalWithDetails); + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Rental request confirmation sent to renter", { + rentalId: rental.id, + renterId: rentalWithDetails.renterId, + }); + } catch (emailError) { + // Log error but don't fail the request + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Failed to send rental request confirmation email", { + error: emailError, + rentalId: rental.id, + renterId: rentalWithDetails.renterId, + }); + } + res.status(201).json(rentalWithDetails); } catch (error) { res.status(500).json({ error: "Failed to create rental" }); @@ -477,6 +495,105 @@ router.put("/:id/status", authenticateToken, async (req, res) => { } }); +// Decline rental request (owner only) +router.put("/:id/decline", authenticateToken, async (req, res) => { + try { + const { reason } = req.body; + + // Validate that reason is provided + if (!reason || reason.trim() === "") { + return res.status(400).json({ + error: "A reason for declining is required", + }); + } + + const rental = await Rental.findByPk(req.params.id, { + include: [ + { model: Item, as: "item" }, + { + model: User, + as: "owner", + attributes: ["id", "username", "firstName", "lastName"], + }, + { + model: User, + as: "renter", + attributes: ["id", "username", "firstName", "lastName", "email"], + }, + ], + }); + + if (!rental) { + return res.status(404).json({ error: "Rental not found" }); + } + + // Only owner can decline + if (rental.ownerId !== req.user.id) { + return res + .status(403) + .json({ error: "Only the item owner can decline rental requests" }); + } + + // Can only decline pending rentals + if (rental.status !== "pending") { + return res.status(400).json({ + error: "Can only decline pending rental requests", + }); + } + + // Update rental status to declined + await rental.update({ + status: "declined", + declineReason: reason, + }); + + const updatedRental = await Rental.findByPk(rental.id, { + include: [ + { model: Item, as: "item" }, + { + model: User, + as: "owner", + attributes: ["id", "username", "firstName", "lastName"], + }, + { + model: User, + as: "renter", + attributes: ["id", "username", "firstName", "lastName"], + }, + ], + }); + + // Send decline notification email to renter + try { + await emailService.sendRentalDeclinedEmail(updatedRental, reason); + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Rental decline notification sent to renter", { + rentalId: rental.id, + renterId: updatedRental.renterId, + }); + } catch (emailError) { + // Log error but don't fail the request + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Failed to send rental decline email", { + error: emailError, + rentalId: rental.id, + renterId: updatedRental.renterId, + }); + } + + res.json(updatedRental); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Error declining rental", { + error: error.message, + stack: error.stack, + rentalId: req.params.id, + userId: req.user.id, + }); + res.status(500).json({ error: "Failed to decline rental" }); + } +}); + // Owner reviews renter router.post("/:id/review-renter", authenticateToken, async (req, res) => { try { @@ -738,10 +855,15 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => { try { const { reason } = req.body; + // Validate that reason is provided + if (!reason || !reason.trim()) { + return res.status(400).json({ error: "Cancellation reason is required" }); + } + const result = await RefundService.processCancellation( req.params.id, req.user.id, - reason + reason.trim() ); // Return the updated rental with refund information @@ -761,6 +883,27 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => { ], }); + // Send cancellation notification emails + try { + await emailService.sendRentalCancellationEmails( + updatedRental, + result.refund + ); + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Cancellation emails sent", { + rentalId: updatedRental.id, + cancelledBy: updatedRental.cancelledBy, + }); + } catch (emailError) { + // Log error but don't fail the request + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Failed to send cancellation emails", { + error: emailError.message, + rentalId: updatedRental.id, + cancelledBy: updatedRental.cancelledBy, + }); + } + res.json({ rental: updatedRental, refund: result.refund, diff --git a/backend/services/emailService.js b/backend/services/emailService.js index 13b0411..a14f7d5 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -42,6 +42,11 @@ class EmailService { "damageReportCS.html", "lostItemCS.html", "rentalRequest.html", + "rentalRequestConfirmation.html", + "rentalCancellationConfirmation.html", + "rentalCancellationNotification.html", + "rentalDeclined.html", + "payoutReceived.html", ]; for (const templateFile of templateFiles) { @@ -50,12 +55,14 @@ class EmailService { const templateContent = await fs.readFile(templatePath, "utf-8"); const templateName = path.basename(templateFile, ".html"); this.templates.set(templateName, templateContent); + console.log(`✓ Loaded template: ${templateName}`); } catch (error) { - console.warn(`Template ${templateFile} not found, will use fallback`); + console.error(`✗ Failed to load template ${templateFile}:`, error.message); + console.error(` Template path: ${path.join(templatesDir, templateFile)}`); } } - console.log(`Loaded ${this.templates.size} email templates`); + console.log(`Loaded ${this.templates.size} of ${templateFiles.length} email templates`); } catch (error) { console.warn("Templates directory not found, using fallback templates"); } @@ -290,6 +297,93 @@ class EmailService {

Please respond to this request within 24 hours.

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

Hi {{renterName}},

+

Your Rental Request Has Been Submitted!

+

Your request to rent {{itemName}} has been sent to the owner.

+

Item: {{itemName}}

+

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

+

Delivery Method: {{deliveryMethod}}

+

Total Amount: \${{totalAmount}}

+

{{paymentMessage}}

+

You'll receive an email notification once the owner responds to your request.

+

View My Rentals

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

Hi {{recipientName}},

+

Rental Cancelled Successfully

+

This confirms that your rental for {{itemName}} has been cancelled.

+

Item: {{itemName}}

+

Start Date: {{startDate}}

+

End Date: {{endDate}}

+

Cancelled On: {{cancelledAt}}

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

Hi {{recipientName}},

+

Rental Cancellation Notice

+

{{cancellationMessage}}

+

Item: {{itemName}}

+

Start Date: {{startDate}}

+

End Date: {{endDate}}

+

Cancelled On: {{cancelledAt}}

+ {{additionalInfo}} +

If you have any questions or concerns, please reach out to our support team.

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

Hi {{ownerName}},

+

Earnings Received: \${{payoutAmount}}

+

Great news! Your earnings from the rental of {{itemName}} have been transferred to your account.

+

Rental Details

+

Item: {{itemName}}

+

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

+

Transfer ID: {{stripeTransferId}}

+

Earnings Breakdown

+

Rental Amount: \${{totalAmount}}

+

Platform Fee (20%): -\${{platformFee}}

+

Your Earnings: \${{payoutAmount}}

+

Funds are typically available in your bank account within 2-3 business days.

+

View Earnings Dashboard

+

Thank you for being a valued member of the RentAll community!

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

Hi {{renterName}},

+

Rental Request Declined

+

Thank you for your interest in renting {{itemName}}. Unfortunately, the owner is unable to accept your rental request at this time.

+

Request Details

+

Item: {{itemName}}

+

Start Date: {{startDate}}

+

End Date: {{endDate}}

+

Delivery Method: {{deliveryMethod}}

+ {{ownerMessage}} +
+

What happens next?

+

{{paymentMessage}}

+

We encourage you to explore other similar items available for rent on RentAll. There are many great options waiting for you!

+
+

Browse Available Items

+

If you have any questions or concerns, please don't hesitate to contact our support team.

+ ` + ), }; return ( @@ -456,6 +550,7 @@ class EmailService { ? parseFloat(rental.payoutAmount).toFixed(2) : "0.00", deliveryMethod: rental.deliveryMethod || "Not specified", + rentalNotes: rental.notes || "No additional notes provided", approveUrl: approveUrl, }; @@ -468,6 +563,382 @@ class EmailService { ); } + async sendRentalRequestConfirmationEmail(rental) { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const viewRentalsUrl = `${frontendUrl}/my-rentals`; + + // Fetch renter details + const renter = await User.findByPk(rental.renterId, { + attributes: ["email", "firstName", "lastName"], + }); + + if (!renter) { + console.error( + "Renter not found for rental request confirmation notification" + ); + return { success: false, error: "Renter not found" }; + } + + // Determine payment message based on rental amount + const totalAmount = parseFloat(rental.totalAmount) || 0; + const paymentMessage = + totalAmount > 0 + ? "The owner will review your request. You'll only be charged if they approve it." + : "The owner will review your request and respond soon."; + + const variables = { + renterName: renter.firstName || "there", + itemName: rental.item?.name || "the 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: totalAmount.toFixed(2), + deliveryMethod: rental.deliveryMethod || "Not specified", + paymentMessage: paymentMessage, + viewRentalsUrl: viewRentalsUrl, + }; + + const htmlContent = this.renderTemplate( + "rentalRequestConfirmation", + variables + ); + + return await this.sendEmail( + renter.email, + `Rental Request Submitted - ${rental.item?.name || "Item"}`, + htmlContent + ); + } + + async sendRentalDeclinedEmail(rental, declineReason) { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const browseItemsUrl = `${frontendUrl}/`; + + // Fetch renter details + const renter = await User.findByPk(rental.renterId, { + attributes: ["email", "firstName", "lastName"], + }); + + if (!renter) { + console.error( + "Renter not found for rental decline notification" + ); + return { success: false, error: "Renter not found" }; + } + + // Determine payment message based on rental amount + const totalAmount = parseFloat(rental.totalAmount) || 0; + const paymentMessage = + totalAmount > 0 + ? "Since your request was declined before payment was processed, you will not be charged." + : "No payment was required for this rental request."; + + // Build owner message section if decline reason provided + const ownerMessage = declineReason + ? ` +
+

Message from the owner:

+

${declineReason}

+
+ ` + : ""; + + const variables = { + renterName: renter.firstName || "there", + itemName: rental.item?.name || "the 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", + deliveryMethod: rental.deliveryMethod || "Not specified", + paymentMessage: paymentMessage, + ownerMessage: ownerMessage, + browseItemsUrl: browseItemsUrl, + payoutAmount: rental.payoutAmount + ? parseFloat(rental.payoutAmount).toFixed(2) + : "0.00", + totalAmount: totalAmount.toFixed(2), + }; + + const htmlContent = this.renderTemplate( + "rentalDeclined", + variables + ); + + return await this.sendEmail( + renter.email, + `Rental Request Declined - ${rental.item?.name || "Item"}`, + htmlContent + ); + } + + async sendPayoutReceivedEmail(rental) { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const earningsDashboardUrl = `${frontendUrl}/earnings`; + + // Fetch owner details + const owner = await User.findByPk(rental.ownerId, { + attributes: ["email", "firstName", "lastName"], + }); + + if (!owner) { + console.error("Owner not found for payout notification"); + return { success: false, error: "Owner not found" }; + } + + // Format currency values + const totalAmount = parseFloat(rental.totalAmount) || 0; + const platformFee = parseFloat(rental.platformFee) || 0; + const payoutAmount = parseFloat(rental.payoutAmount) || 0; + + const variables = { + ownerName: owner.firstName || "there", + 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: totalAmount.toFixed(2), + platformFee: platformFee.toFixed(2), + payoutAmount: payoutAmount.toFixed(2), + stripeTransferId: rental.stripeTransferId || "N/A", + earningsDashboardUrl: earningsDashboardUrl, + }; + + const htmlContent = this.renderTemplate("payoutReceived", variables); + + return await this.sendEmail( + owner.email, + `Earnings Received - $${payoutAmount.toFixed(2)} for ${rental.item?.name || "Your Item"}`, + htmlContent + ); + } + + async sendRentalCancellationEmails(rental, refundInfo) { + const results = { + confirmationEmailSent: false, + notificationEmailSent: false, + }; + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const browseUrl = `${frontendUrl}/`; + + // Fetch both owner and renter details + const owner = await User.findByPk(rental.ownerId, { + attributes: ["email", "firstName", "lastName"], + }); + const renter = await User.findByPk(rental.renterId, { + attributes: ["email", "firstName", "lastName"], + }); + + if (!owner || !renter) { + console.error( + "Owner or renter not found for rental cancellation emails" + ); + return { success: false, error: "User not found" }; + } + + const cancelledBy = rental.cancelledBy; // 'owner' or 'renter' + const itemName = rental.item?.name || "the item"; + const startDate = rental.startDateTime + ? new Date(rental.startDateTime).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + : "Not specified"; + const endDate = rental.endDateTime + ? new Date(rental.endDateTime).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + : "Not specified"; + const cancelledAt = rental.cancelledAt + ? new Date(rental.cancelledAt).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + : "Not specified"; + + // Determine who gets confirmation and who gets notification + let confirmationRecipient, notificationRecipient; + let confirmationRecipientName, notificationRecipientName; + let cancellationMessage, additionalInfo; + + if (cancelledBy === "owner") { + // Owner cancelled: owner gets confirmation, renter gets notification + confirmationRecipient = owner.email; + confirmationRecipientName = owner.firstName || "there"; + notificationRecipient = renter.email; + notificationRecipientName = renter.firstName || "there"; + + cancellationMessage = `The owner has cancelled the rental for ${itemName}. We apologize for any inconvenience this may cause.`; + + // Only show refund info if rental had a cost + if (rental.totalAmount > 0) { + additionalInfo = ` +
+

Full Refund Processed

+

You will receive a full refund of $${refundInfo.amount.toFixed(2)}. The refund will appear in your account within 5-10 business days.

+
+
+ Browse Other Items +
+ `; + } else { + additionalInfo = ` +
+

This rental has been cancelled by the owner. We apologize for any inconvenience.

+
+
+ Browse Other Items +
+ `; + } + } else { + // Renter cancelled: renter gets confirmation, owner gets notification + confirmationRecipient = renter.email; + confirmationRecipientName = renter.firstName || "there"; + notificationRecipient = owner.email; + notificationRecipientName = owner.firstName || "there"; + + cancellationMessage = `The renter has cancelled their rental for ${itemName}.`; + additionalInfo = ` +
+

Your item is now available

+

Your item is now available for other renters to book for these dates.

+
+ `; + } + + // Build refund section for confirmation email (only for paid rentals) + let refundSection = ""; + if (rental.totalAmount > 0) { + if (refundInfo.amount > 0) { + const refundPercentage = (refundInfo.percentage * 100).toFixed(0); + refundSection = ` +

Refund Information

+
$${refundInfo.amount.toFixed(2)}
+
+

Refund Amount: $${refundInfo.amount.toFixed(2)} (${refundPercentage}% of total)

+

Reason: ${refundInfo.reason}

+

Processing Time: Refunds typically appear within 5-10 business days.

+
+ `; + } else { + refundSection = ` +

Refund Information

+
+

No Refund Available

+

${refundInfo.reason}

+
+ `; + } + } + // For free rentals (totalAmount = 0), refundSection stays empty + + // Send confirmation email to canceller + try { + const confirmationVariables = { + recipientName: confirmationRecipientName, + itemName: itemName, + startDate: startDate, + endDate: endDate, + cancelledAt: cancelledAt, + refundSection: refundSection, + }; + + const confirmationHtml = this.renderTemplate( + "rentalCancellationConfirmation", + confirmationVariables + ); + + const confirmationResult = await this.sendEmail( + confirmationRecipient, + `Cancellation Confirmed - ${itemName}`, + confirmationHtml + ); + + if (confirmationResult.success) { + console.log( + `Cancellation confirmation email sent to ${cancelledBy}: ${confirmationRecipient}` + ); + results.confirmationEmailSent = true; + } + } catch (error) { + console.error( + `Failed to send cancellation confirmation email to ${cancelledBy}:`, + error.message + ); + } + + // Send notification email to other party + try { + const notificationVariables = { + recipientName: notificationRecipientName, + itemName: itemName, + startDate: startDate, + endDate: endDate, + cancelledAt: cancelledAt, + cancellationMessage: cancellationMessage, + additionalInfo: additionalInfo, + }; + + const notificationHtml = this.renderTemplate( + "rentalCancellationNotification", + notificationVariables + ); + + const notificationResult = await this.sendEmail( + notificationRecipient, + `Rental Cancelled - ${itemName}`, + notificationHtml + ); + + if (notificationResult.success) { + console.log( + `Cancellation notification email sent to ${cancelledBy === "owner" ? "renter" : "owner"}: ${notificationRecipient}` + ); + results.notificationEmailSent = true; + } + } catch (error) { + console.error( + `Failed to send cancellation notification email:`, + error.message + ); + } + } catch (error) { + console.error("Error sending cancellation emails:", error); + } + + return results; + } + async sendTemplateEmail(toEmail, subject, templateName, variables = {}) { const htmlContent = this.renderTemplate(templateName, variables); return await this.sendEmail(toEmail, subject, htmlContent); diff --git a/backend/services/payoutService.js b/backend/services/payoutService.js index 88d7f8a..9e0f066 100644 --- a/backend/services/payoutService.js +++ b/backend/services/payoutService.js @@ -1,5 +1,6 @@ -const { Rental, User } = require("../models"); +const { Rental, User, Item } = require("../models"); const StripeService = require("./stripeService"); +const emailService = require("./emailService"); const { Op } = require("sequelize"); class PayoutService { @@ -21,6 +22,10 @@ class PayoutService { }, }, }, + { + model: Item, + as: "item", + }, ], }); @@ -75,6 +80,20 @@ class PayoutService { `Payout completed for rental ${rental.id}: $${rental.payoutAmount} to ${rental.owner.stripeConnectedAccountId}` ); + // Send payout notification email to owner + try { + await emailService.sendPayoutReceivedEmail(rental); + console.log( + `Payout notification email sent to owner for rental ${rental.id}` + ); + } catch (emailError) { + // Log error but don't fail the payout + console.error( + `Failed to send payout notification email for rental ${rental.id}:`, + emailError.message + ); + } + return { success: true, transferId: transfer.id, @@ -151,6 +170,10 @@ class PayoutService { }, }, }, + { + model: Item, + as: "item", + }, ], }); diff --git a/backend/templates/emails/payoutReceived.html b/backend/templates/emails/payoutReceived.html new file mode 100644 index 0000000..52e65fe --- /dev/null +++ b/backend/templates/emails/payoutReceived.html @@ -0,0 +1,419 @@ + + + + + + + Earnings Received - RentAll + + + +
+
+ +
Earnings Received
+
+ +
+

Hi {{ownerName}},

+ +

Great news! Your earnings have been transferred to your account.

+ +
+
You Earned
+
${{payoutAmount}}
+
+ From rental of {{itemName}} +
+
+ +

Rental Details

+ + + + + + + + + + + + + +
Item Rented{{itemName}}
Rental Period{{startDate}} to {{endDate}}
Transfer ID{{stripeTransferId}}
+ +

Earnings Breakdown

+ + + + + + + + + + + + + +
Rental Amount (charged to renter)${{totalAmount}}
Platform Fee (20%)-${{platformFee}}
Your Earnings${{payoutAmount}}
+ +
+

When will I receive the funds?

+

+ Funds are typically available in your bank account within + 2-3 business days from the transfer date. +

+

+ You can track this transfer in your Stripe Dashboard using the + Transfer ID above. +

+
+ +
+ View Earnings Dashboard +
+ +

+ Thank you for being a valued member of the RentAll community! Keep + sharing your items to earn more. +

+
+ + +
+ + diff --git a/backend/templates/emails/rentalCancellationConfirmation.html b/backend/templates/emails/rentalCancellationConfirmation.html new file mode 100644 index 0000000..47e3b9b --- /dev/null +++ b/backend/templates/emails/rentalCancellationConfirmation.html @@ -0,0 +1,311 @@ + + + + + + + Cancellation Confirmed - RentAll + + + +
+
+ +
Cancellation Confirmation
+
+ +
+

Hi {{recipientName}},

+ +
+

Your cancellation has been confirmed

+
+ +

Rental Cancelled Successfully

+ +

+ This confirms that your rental for {{itemName}} has + been cancelled. +

+ +

Cancelled Rental Details

+ + + + + + + + + + + + + + + + + +
Item{{itemName}}
Start Date{{startDate}}
End Date{{endDate}}
Cancelled On{{cancelledAt}}
+ + {{refundSection}} + +

+ If you have any questions about this cancellation, please don't + hesitate to contact our support team. +

+
+ + +
+ + diff --git a/backend/templates/emails/rentalCancellationNotification.html b/backend/templates/emails/rentalCancellationNotification.html new file mode 100644 index 0000000..d873077 --- /dev/null +++ b/backend/templates/emails/rentalCancellationNotification.html @@ -0,0 +1,310 @@ + + + + + + + Rental Cancelled - RentAll + + + +
+
+ +
Rental Update
+
+ +
+

Hi {{recipientName}},

+ +

Rental Cancellation Notice

+ +

{{cancellationMessage}}

+ +

Cancelled Rental Details

+ + + + + + + + + + + + + + + + + +
Item{{itemName}}
Start Date{{startDate}}
End Date{{endDate}}
Cancelled On{{cancelledAt}}
+ + {{additionalInfo}} + +

+ We understand this may be inconvenient. If you have any questions or + concerns, please don't hesitate to reach out to our support team. +

+
+ + +
+ + diff --git a/backend/templates/emails/rentalDeclined.html b/backend/templates/emails/rentalDeclined.html new file mode 100644 index 0000000..648403c --- /dev/null +++ b/backend/templates/emails/rentalDeclined.html @@ -0,0 +1,317 @@ + + + + + + + Rental Request Declined - RentAll + + + +
+
+ +
Rental Request Update
+
+ +
+

Hi {{renterName}},

+ +

+ Thank you for your interest in renting {{itemName}}. + Unfortunately, the owner is unable to accept your rental request at + this time. +

+ +

Request Details

+ + + + + + + + + + + + + + + + + +
Item{{itemName}}
Start Date{{startDate}}
End Date{{endDate}}
Delivery Method{{deliveryMethod}}
+ + {{ownerMessage}} + +
+

What happens next?

+

+ {{paymentMessage}} +

+

+ We encourage you to explore other similar items available for rent + on RentAll. There are many great options waiting for you! +

+
+ +
+ Browse Available Items +
+ +

+ If you have any questions or concerns, please don't hesitate to + contact our support team. +

+
+ + +
+ + diff --git a/backend/templates/emails/rentalRequestConfirmation.html b/backend/templates/emails/rentalRequestConfirmation.html new file mode 100644 index 0000000..212256b --- /dev/null +++ b/backend/templates/emails/rentalRequestConfirmation.html @@ -0,0 +1,320 @@ + + + + + + + Rental Request Submitted - RentAll + + + +
+
+ +
Request Submitted
+
+ +
+

Hi {{renterName}},

+ +
+

Your rental request has been submitted!

+
+ +

+ Your request to rent {{itemName}} has been sent to + the owner. They'll review your request and respond soon. +

+ +

Request Details

+ + + + + + + + + + + + + + + + + + + + + +
Item{{itemName}}
Start Date{{startDate}}
End Date{{endDate}}
Delivery Method{{deliveryMethod}}
Total Amount${{totalAmount}}
+ +
+

What happens next?

+

+ {{paymentMessage}} +

+

+ You'll receive an email notification once the owner responds to your + request. +

+
+ +
+ View My Rentals +
+ +

+ Need to make changes? If you need to cancel or modify + your request, you can do so from the My Rentals page. +

+
+ + +
+ + diff --git a/backend/utils/logger.js b/backend/utils/logger.js index 0bc9f47..db6a264 100644 --- a/backend/utils/logger.js +++ b/backend/utils/logger.js @@ -25,15 +25,43 @@ const level = () => { return isDevelopment ? 'debug' : process.env.LOG_LEVEL || 'info'; }; +// Custom format to extract stack traces from Error objects in metadata +const extractErrorStack = winston.format((info) => { + // Check if any metadata value is an Error object and extract its stack + Object.keys(info).forEach(key => { + if (info[key] instanceof Error) { + info[key] = { + message: info[key].message, + stack: info[key].stack, + name: info[key].name + }; + } + }); + return info; +}); + // Define log format const logFormat = winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), winston.format.colorize({ all: true }), + extractErrorStack(), winston.format.printf((info) => { - if (info.stack) { - return `${info.timestamp} ${info.level}: ${info.message}\n${info.stack}`; + const { timestamp, level, message, stack, ...metadata } = info; + let output = `${timestamp} ${level}: ${message}`; + + // Check for stack trace in the info object itself + if (stack) { + output += `\n${stack}`; } - return `${info.timestamp} ${info.level}: ${info.message}`; + + // Check for Error objects in metadata + Object.keys(metadata).forEach(key => { + if (metadata[key] && metadata[key].stack) { + output += `\n${key}: ${metadata[key].message}\n${metadata[key].stack}`; + } + }); + + return output; }), ); @@ -41,6 +69,7 @@ const logFormat = winston.format.combine( const jsonFormat = winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), winston.format.errors({ stack: true }), + extractErrorStack(), winston.format.json() ); diff --git a/frontend/src/components/DeclineRentalModal.tsx b/frontend/src/components/DeclineRentalModal.tsx new file mode 100644 index 0000000..60570cd --- /dev/null +++ b/frontend/src/components/DeclineRentalModal.tsx @@ -0,0 +1,252 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { rentalAPI } from "../services/api"; +import { Rental } from "../types"; + +interface DeclineRentalModalProps { + show: boolean; + onHide: () => void; + rental: Rental; + onDeclineComplete: (updatedRental: Rental) => void; +} + +const DeclineRentalModal: React.FC = ({ + show, + onHide, + rental, + onDeclineComplete, +}) => { + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + const [reason, setReason] = useState(""); + const [success, setSuccess] = useState(false); + const [updatedRental, setUpdatedRental] = useState(null); + + const handleDecline = async () => { + if (!reason.trim()) { + setError("Please provide a reason for declining this request"); + return; + } + + try { + setProcessing(true); + setError(null); + + const response = await rentalAPI.declineRental(rental.id, reason.trim()); + + // Store updated rental data for later callback + setUpdatedRental(response.data); + + // Show success confirmation + setSuccess(true); + } catch (error: any) { + setError( + error.response?.data?.error || "Failed to decline rental request" + ); + } finally { + setProcessing(false); + } + }; + + const handleClose = () => { + // Call parent callback with updated rental data if we have it + if (updatedRental) { + onDeclineComplete(updatedRental); + } + + // Reset all states when closing + setProcessing(false); + setError(null); + setReason(""); + setSuccess(false); + setUpdatedRental(null); + onHide(); + }; + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget && !processing) { + handleClose(); + } + }, + [handleClose, processing] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" && !processing) { + handleClose(); + } + }, + [handleClose, processing] + ); + + useEffect(() => { + if (show) { + document.addEventListener("keydown", handleKeyDown); + document.body.style.overflow = "hidden"; + } else { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = "unset"; + } + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = "unset"; + }; + }, [show, handleKeyDown]); + + if (!show) return null; + + return ( +
+
+
+
+
+ {success ? "Request Declined" : "Decline Rental Request"} +
+ +
+
+ {success ? ( +
+
+ +
+

Request Declined

+
+

+ The renter has been notified that their request was + declined. +

+
+
+ ) : ( + <> +
+
Rental Details
+
+

+ Item: {rental.item?.name} +

+

+ Renter: {rental.renter?.firstName}{" "} + {rental.renter?.lastName} +

+

+ Start:{" "} + {new Date(rental.startDateTime).toLocaleString()} +

+

+ End:{" "} + {new Date(rental.endDateTime).toLocaleString()} +

+
+
+ + {error && ( +
+ {error} +
+ )} + +
{ + e.preventDefault(); + handleDecline(); + }} + > +
+ +