1434 lines
42 KiB
JavaScript
1434 lines
42 KiB
JavaScript
const express = require("express");
|
|
const { Op } = require("sequelize");
|
|
const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations
|
|
const {
|
|
authenticateToken,
|
|
requireVerifiedEmail,
|
|
} = require("../middleware/auth");
|
|
const FeeCalculator = require("../utils/feeCalculator");
|
|
const RentalDurationCalculator = require("../utils/rentalDurationCalculator");
|
|
const RefundService = require("../services/refundService");
|
|
const LateReturnService = require("../services/lateReturnService");
|
|
const DamageAssessmentService = require("../services/damageAssessmentService");
|
|
const emailServices = require("../services/email");
|
|
const logger = require("../utils/logger");
|
|
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
|
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
|
const router = express.Router();
|
|
|
|
// Helper function to check and update review visibility
|
|
const checkAndUpdateReviewVisibility = async (rental) => {
|
|
const now = new Date();
|
|
const tenMinutesInMs = 10 * 60 * 1000; // 10 minutes
|
|
|
|
let needsUpdate = false;
|
|
let updates = {};
|
|
|
|
// Check if both reviews are submitted
|
|
if (rental.itemReviewSubmittedAt && rental.renterReviewSubmittedAt) {
|
|
if (!rental.itemReviewVisible || !rental.renterReviewVisible) {
|
|
updates.itemReviewVisible = true;
|
|
updates.renterReviewVisible = true;
|
|
needsUpdate = true;
|
|
}
|
|
} else {
|
|
// Check item review visibility (10-minute rule)
|
|
if (rental.itemReviewSubmittedAt && !rental.itemReviewVisible) {
|
|
const timeSinceSubmission = now - new Date(rental.itemReviewSubmittedAt);
|
|
if (timeSinceSubmission >= tenMinutesInMs) {
|
|
updates.itemReviewVisible = true;
|
|
needsUpdate = true;
|
|
}
|
|
}
|
|
|
|
// Check renter review visibility (10-minute rule)
|
|
if (rental.renterReviewSubmittedAt && !rental.renterReviewVisible) {
|
|
const timeSinceSubmission =
|
|
now - new Date(rental.renterReviewSubmittedAt);
|
|
if (timeSinceSubmission >= tenMinutesInMs) {
|
|
updates.renterReviewVisible = true;
|
|
needsUpdate = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (needsUpdate) {
|
|
await rental.update(updates);
|
|
}
|
|
|
|
return rental;
|
|
};
|
|
|
|
router.get("/renting", authenticateToken, async (req, res) => {
|
|
try {
|
|
const rentals = await Rental.findAll({
|
|
where: { renterId: req.user.id },
|
|
// Remove explicit attributes to let Sequelize handle missing columns gracefully
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "firstName", "lastName", "imageFilename"],
|
|
},
|
|
],
|
|
order: [["createdAt", "DESC"]],
|
|
});
|
|
|
|
res.json(rentals);
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error in renting route", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
userId: req.user.id,
|
|
});
|
|
res.status(500).json({ error: "Failed to fetch rentals" });
|
|
}
|
|
});
|
|
|
|
router.get("/owning", authenticateToken, async (req, res) => {
|
|
try {
|
|
const rentals = await Rental.findAll({
|
|
where: { ownerId: req.user.id },
|
|
// Remove explicit attributes to let Sequelize handle missing columns gracefully
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName", "imageFilename"],
|
|
},
|
|
],
|
|
order: [["createdAt", "DESC"]],
|
|
});
|
|
|
|
res.json(rentals);
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error in owning route", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
userId: req.user.id,
|
|
});
|
|
res.status(500).json({ error: "Failed to fetch listings" });
|
|
}
|
|
});
|
|
|
|
// Get count of pending rental requests for owner
|
|
router.get("/pending-requests-count", authenticateToken, async (req, res) => {
|
|
try {
|
|
const count = await Rental.count({
|
|
where: {
|
|
ownerId: req.user.id,
|
|
status: "pending",
|
|
},
|
|
});
|
|
|
|
res.json({ count });
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error getting pending rental count", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
userId: req.user.id,
|
|
});
|
|
res.status(500).json({ error: "Failed to get pending rental count" });
|
|
}
|
|
});
|
|
|
|
// Get rental by ID
|
|
router.get("/:id", authenticateToken, async (req, res) => {
|
|
try {
|
|
const rental = await Rental.findByPk(req.params.id, {
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "firstName", "lastName"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName"],
|
|
},
|
|
],
|
|
});
|
|
|
|
if (!rental) {
|
|
return res.status(404).json({ error: "Rental not found" });
|
|
}
|
|
|
|
// Check if user is authorized to view this rental
|
|
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Unauthorized to view this rental" });
|
|
}
|
|
|
|
res.json(rental);
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error fetching rental", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
rentalId: req.params.id,
|
|
userId: req.user.id,
|
|
});
|
|
res.status(500).json({ error: "Failed to fetch rental" });
|
|
}
|
|
});
|
|
|
|
router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
|
try {
|
|
const {
|
|
itemId,
|
|
startDateTime,
|
|
endDateTime,
|
|
deliveryMethod,
|
|
deliveryAddress,
|
|
intendedUse,
|
|
stripePaymentMethodId,
|
|
} = req.body;
|
|
|
|
const item = await Item.findByPk(itemId);
|
|
if (!item) {
|
|
return res.status(404).json({ error: "Item not found" });
|
|
}
|
|
|
|
if (!item.isAvailable) {
|
|
return res.status(400).json({ error: "Item is not available" });
|
|
}
|
|
|
|
let rentalStartDateTime, rentalEndDateTime, totalAmount;
|
|
|
|
// New UTC datetime format
|
|
rentalStartDateTime = new Date(startDateTime);
|
|
rentalEndDateTime = new Date(endDateTime);
|
|
|
|
// Validate date formats
|
|
if (isNaN(rentalStartDateTime.getTime())) {
|
|
return res.status(400).json({ error: "Invalid start date format" });
|
|
}
|
|
if (isNaN(rentalEndDateTime.getTime())) {
|
|
return res.status(400).json({ error: "Invalid end date format" });
|
|
}
|
|
|
|
// Validate start date is not in the past (with 5 minute grace period for form submission)
|
|
const now = new Date();
|
|
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
|
if (rentalStartDateTime < fiveMinutesAgo) {
|
|
return res.status(400).json({ error: "Start date cannot be in the past" });
|
|
}
|
|
|
|
// Validate end date/time is after start date/time
|
|
if (rentalEndDateTime <= rentalStartDateTime) {
|
|
return res.status(400).json({ error: "End date/time must be after start date/time" });
|
|
}
|
|
|
|
// Calculate rental cost using duration calculator
|
|
totalAmount = RentalDurationCalculator.calculateRentalCost(
|
|
rentalStartDateTime,
|
|
rentalEndDateTime,
|
|
item
|
|
);
|
|
|
|
// Check for overlapping rentals using datetime ranges
|
|
const overlappingRental = await Rental.findOne({
|
|
where: {
|
|
itemId,
|
|
status: { [Op.in]: ["confirmed", "active"] },
|
|
[Op.or]: [
|
|
{
|
|
[Op.and]: [
|
|
{ startDateTime: { [Op.not]: null } },
|
|
{ endDateTime: { [Op.not]: null } },
|
|
{
|
|
[Op.or]: [
|
|
{
|
|
startDateTime: {
|
|
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
|
|
},
|
|
},
|
|
{
|
|
endDateTime: {
|
|
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
|
|
},
|
|
},
|
|
{
|
|
[Op.and]: [
|
|
{ startDateTime: { [Op.lte]: rentalStartDateTime } },
|
|
{ endDateTime: { [Op.gte]: rentalEndDateTime } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
if (overlappingRental) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Item is already booked for these dates" });
|
|
}
|
|
|
|
// Calculate fees using FeeCalculator
|
|
const fees = FeeCalculator.calculateRentalFees(totalAmount);
|
|
|
|
// Validate that payment method was provided for paid rentals
|
|
if (totalAmount > 0 && !stripePaymentMethodId) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Payment method is required for paid rentals" });
|
|
}
|
|
|
|
const rentalData = {
|
|
itemId,
|
|
renterId: req.user.id,
|
|
ownerId: item.ownerId,
|
|
startDateTime: rentalStartDateTime,
|
|
endDateTime: rentalEndDateTime,
|
|
totalAmount: fees.totalChargedAmount,
|
|
platformFee: fees.platformFee,
|
|
payoutAmount: fees.payoutAmount,
|
|
paymentStatus: totalAmount > 0 ? "pending" : "not_required",
|
|
status: "pending",
|
|
deliveryMethod,
|
|
deliveryAddress,
|
|
intendedUse,
|
|
};
|
|
|
|
// Only add stripePaymentMethodId if it's provided (for paid rentals)
|
|
if (stripePaymentMethodId) {
|
|
rentalData.stripePaymentMethodId = stripePaymentMethodId;
|
|
}
|
|
|
|
const rental = await Rental.create(rentalData);
|
|
|
|
const rentalWithDetails = await Rental.findByPk(rental.id, {
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "firstName", "lastName", "email"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName", "email"],
|
|
},
|
|
],
|
|
});
|
|
|
|
// Send rental request notification to owner
|
|
try {
|
|
await emailServices.rentalFlow.sendRentalRequestEmail(
|
|
rentalWithDetails.owner,
|
|
rentalWithDetails.renter,
|
|
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,
|
|
stack: emailError.stack,
|
|
rentalId: rental.id,
|
|
ownerId: rentalWithDetails.ownerId,
|
|
});
|
|
}
|
|
|
|
// Send rental request confirmation to renter
|
|
try {
|
|
await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(
|
|
rentalWithDetails.renter,
|
|
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.message,
|
|
stack: emailError.stack,
|
|
rentalId: rental.id,
|
|
renterId: rentalWithDetails.renterId,
|
|
});
|
|
}
|
|
|
|
res.status(201).json(rentalWithDetails);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to create rental" });
|
|
}
|
|
});
|
|
|
|
router.put("/:id/status", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { status } = req.body;
|
|
const rental = await Rental.findByPk(req.params.id, {
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName", "email", "stripeCustomerId"],
|
|
},
|
|
],
|
|
});
|
|
|
|
if (!rental) {
|
|
return res.status(404).json({ error: "Rental not found" });
|
|
}
|
|
|
|
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Unauthorized to update this rental" });
|
|
}
|
|
|
|
// If owner is approving a pending rental, handle payment for paid rentals
|
|
if (
|
|
status === "confirmed" &&
|
|
rental.status === "pending" &&
|
|
rental.ownerId === req.user.id
|
|
) {
|
|
// Skip payment processing for free rentals
|
|
if (rental.totalAmount > 0) {
|
|
if (!rental.stripePaymentMethodId) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "No payment method found for this rental" });
|
|
}
|
|
|
|
try {
|
|
// Import StripeService to process the payment
|
|
const StripeService = require("../services/stripeService");
|
|
|
|
// Check if renter has a stripe customer ID
|
|
if (!rental.renter.stripeCustomerId) {
|
|
return res.status(400).json({
|
|
error: "Renter does not have a Stripe customer account",
|
|
});
|
|
}
|
|
|
|
// Create payment intent and charge the stored payment method
|
|
const paymentResult = await StripeService.chargePaymentMethod(
|
|
rental.stripePaymentMethodId,
|
|
rental.totalAmount,
|
|
rental.renter.stripeCustomerId,
|
|
{
|
|
rentalId: rental.id,
|
|
itemName: rental.item.name,
|
|
renterId: rental.renterId,
|
|
ownerId: rental.ownerId,
|
|
}
|
|
);
|
|
|
|
// Update rental with payment completion
|
|
await rental.update({
|
|
status: "confirmed",
|
|
paymentStatus: "paid",
|
|
stripePaymentIntentId: paymentResult.paymentIntentId,
|
|
paymentMethodBrand: paymentResult.paymentMethod?.brand || null,
|
|
paymentMethodLast4: paymentResult.paymentMethod?.last4 || null,
|
|
chargedAt: paymentResult.chargedAt || new Date(),
|
|
});
|
|
|
|
const updatedRental = await Rental.findByPk(rental.id, {
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName", "email"],
|
|
},
|
|
],
|
|
});
|
|
|
|
// Send confirmation emails
|
|
// Send approval confirmation to owner with Stripe reminder
|
|
try {
|
|
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
|
updatedRental.owner,
|
|
updatedRental.renter,
|
|
updatedRental
|
|
);
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.info("Rental approval confirmation sent to owner", {
|
|
rentalId: updatedRental.id,
|
|
ownerId: updatedRental.ownerId,
|
|
});
|
|
} catch (emailError) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error(
|
|
"Failed to send rental approval confirmation email to owner",
|
|
{
|
|
error: emailError.message,
|
|
stack: emailError.stack,
|
|
rentalId: updatedRental.id,
|
|
ownerId: updatedRental.ownerId,
|
|
}
|
|
);
|
|
}
|
|
|
|
// Send rental confirmation to renter with payment receipt
|
|
try {
|
|
const renter = await User.findByPk(updatedRental.renterId, {
|
|
attributes: ["email", "firstName"],
|
|
});
|
|
if (renter) {
|
|
const renterNotification = {
|
|
type: "rental_confirmed",
|
|
title: "Rental Confirmed",
|
|
message: `Your rental of "${updatedRental.item.name}" has been confirmed.`,
|
|
rentalId: updatedRental.id,
|
|
userId: updatedRental.renterId,
|
|
metadata: { rentalStart: updatedRental.startDateTime },
|
|
};
|
|
await emailServices.rentalFlow.sendRentalConfirmation(
|
|
renter.email,
|
|
renterNotification,
|
|
updatedRental,
|
|
renter.firstName,
|
|
true // isRenter = true to show payment receipt
|
|
);
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.info("Rental confirmation sent to renter", {
|
|
rentalId: updatedRental.id,
|
|
renterId: updatedRental.renterId,
|
|
});
|
|
}
|
|
} catch (emailError) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error(
|
|
"Failed to send rental confirmation email to renter",
|
|
{
|
|
error: emailError.message,
|
|
stack: emailError.stack,
|
|
rentalId: updatedRental.id,
|
|
renterId: updatedRental.renterId,
|
|
}
|
|
);
|
|
}
|
|
|
|
res.json(updatedRental);
|
|
return;
|
|
} catch (paymentError) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Payment failed during approval", {
|
|
error: paymentError.message,
|
|
stack: paymentError.stack,
|
|
rentalId: req.params.id,
|
|
userId: req.user.id,
|
|
});
|
|
// Keep rental as pending, but inform of payment failure
|
|
return res.status(400).json({
|
|
error: "Payment failed during approval",
|
|
details: paymentError.message,
|
|
});
|
|
}
|
|
} else {
|
|
// For free rentals, just update status directly
|
|
await rental.update({
|
|
status: "confirmed",
|
|
});
|
|
|
|
const updatedRental = await Rental.findByPk(rental.id, {
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName", "email"],
|
|
},
|
|
],
|
|
});
|
|
|
|
// Send confirmation emails
|
|
// Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
|
|
try {
|
|
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
|
updatedRental.owner,
|
|
updatedRental.renter,
|
|
updatedRental
|
|
);
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.info("Rental approval confirmation sent to owner", {
|
|
rentalId: updatedRental.id,
|
|
ownerId: updatedRental.ownerId,
|
|
});
|
|
} catch (emailError) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error(
|
|
"Failed to send rental approval confirmation email to owner",
|
|
{
|
|
error: emailError.message,
|
|
stack: emailError.stack,
|
|
rentalId: updatedRental.id,
|
|
ownerId: updatedRental.ownerId,
|
|
}
|
|
);
|
|
}
|
|
|
|
// Send rental confirmation to renter
|
|
try {
|
|
const renter = await User.findByPk(updatedRental.renterId, {
|
|
attributes: ["email", "firstName"],
|
|
});
|
|
if (renter) {
|
|
const renterNotification = {
|
|
type: "rental_confirmed",
|
|
title: "Rental Confirmed",
|
|
message: `Your rental of "${updatedRental.item.name}" has been confirmed.`,
|
|
rentalId: updatedRental.id,
|
|
userId: updatedRental.renterId,
|
|
metadata: { rentalStart: updatedRental.startDateTime },
|
|
};
|
|
await emailServices.rentalFlow.sendRentalConfirmation(
|
|
renter.email,
|
|
renterNotification,
|
|
updatedRental,
|
|
renter.firstName,
|
|
true // isRenter = true (for free rentals, shows "no payment required")
|
|
);
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.info("Rental confirmation sent to renter", {
|
|
rentalId: updatedRental.id,
|
|
renterId: updatedRental.renterId,
|
|
});
|
|
}
|
|
} catch (emailError) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error(
|
|
"Failed to send rental confirmation email to renter",
|
|
{
|
|
error: emailError.message,
|
|
stack: emailError.stack,
|
|
rentalId: updatedRental.id,
|
|
renterId: updatedRental.renterId,
|
|
}
|
|
);
|
|
}
|
|
|
|
res.json(updatedRental);
|
|
return;
|
|
}
|
|
}
|
|
|
|
await rental.update({ status });
|
|
|
|
const updatedRental = await Rental.findByPk(rental.id, {
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "firstName", "lastName"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName"],
|
|
},
|
|
],
|
|
});
|
|
|
|
res.json(updatedRental);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to update rental status" });
|
|
}
|
|
});
|
|
|
|
// 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", "firstName", "lastName"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "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", "firstName", "lastName", "email"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName", "email"],
|
|
},
|
|
],
|
|
});
|
|
|
|
// Send decline notification email to renter
|
|
try {
|
|
await emailServices.rentalFlow.sendRentalDeclinedEmail(
|
|
updatedRental.renter,
|
|
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.message,
|
|
stack: emailError.stack,
|
|
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 {
|
|
const { rating, review, privateMessage } = req.body;
|
|
const rental = await Rental.findByPk(req.params.id);
|
|
|
|
if (!rental) {
|
|
return res.status(404).json({ error: "Rental not found" });
|
|
}
|
|
|
|
if (rental.ownerId !== req.user.id) {
|
|
return res.status(403).json({ error: "Only owners can review renters" });
|
|
}
|
|
|
|
if (rental.status !== "completed") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Can only review completed rentals" });
|
|
}
|
|
|
|
if (rental.renterReviewSubmittedAt) {
|
|
return res.status(400).json({ error: "Renter review already submitted" });
|
|
}
|
|
|
|
// Submit the review and private message
|
|
await rental.update({
|
|
renterRating: rating,
|
|
renterReview: review,
|
|
renterReviewSubmittedAt: new Date(),
|
|
renterPrivateMessage: privateMessage,
|
|
});
|
|
|
|
// Check and update visibility
|
|
const updatedRental = await checkAndUpdateReviewVisibility(rental);
|
|
|
|
res.json({
|
|
success: true,
|
|
reviewVisible: updatedRental.renterReviewVisible,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to submit review" });
|
|
}
|
|
});
|
|
|
|
// Renter reviews item
|
|
router.post("/:id/review-item", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { rating, review, privateMessage } = req.body;
|
|
const rental = await Rental.findByPk(req.params.id);
|
|
|
|
if (!rental) {
|
|
return res.status(404).json({ error: "Rental not found" });
|
|
}
|
|
|
|
if (rental.renterId !== req.user.id) {
|
|
return res.status(403).json({ error: "Only renters can review items" });
|
|
}
|
|
|
|
if (rental.status !== "completed") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Can only review completed rentals" });
|
|
}
|
|
|
|
if (rental.itemReviewSubmittedAt) {
|
|
return res.status(400).json({ error: "Item review already submitted" });
|
|
}
|
|
|
|
// Submit the review and private message
|
|
await rental.update({
|
|
itemRating: rating,
|
|
itemReview: review,
|
|
itemReviewSubmittedAt: new Date(),
|
|
itemPrivateMessage: privateMessage,
|
|
});
|
|
|
|
// Check and update visibility
|
|
const updatedRental = await checkAndUpdateReviewVisibility(rental);
|
|
|
|
res.json({
|
|
success: true,
|
|
reviewVisible: updatedRental.itemReviewVisible,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to submit review" });
|
|
}
|
|
});
|
|
|
|
// Mark rental as completed (owner only)
|
|
router.post("/:id/mark-completed", authenticateToken, async (req, res) => {
|
|
try {
|
|
const rental = await Rental.findByPk(req.params.id);
|
|
|
|
if (!rental) {
|
|
return res.status(404).json({ error: "Rental not found" });
|
|
}
|
|
|
|
if (rental.ownerId !== req.user.id) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Only owners can mark rentals as completed" });
|
|
}
|
|
|
|
if (rental.status !== "active") {
|
|
return res.status(400).json({
|
|
error: "Can only mark active rentals as completed",
|
|
});
|
|
}
|
|
|
|
await rental.update({ status: "completed", payoutStatus: "pending" });
|
|
|
|
const updatedRental = await Rental.findByPk(rental.id, {
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "firstName", "lastName"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName"],
|
|
},
|
|
],
|
|
});
|
|
|
|
res.json(updatedRental);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to update rental" });
|
|
}
|
|
});
|
|
|
|
// Calculate fees for rental pricing display
|
|
router.post("/calculate-fees", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { totalAmount } = req.body;
|
|
|
|
if (!totalAmount || totalAmount <= 0) {
|
|
return res.status(400).json({ error: "Valid base amount is required" });
|
|
}
|
|
|
|
const fees = FeeCalculator.calculateRentalFees(totalAmount);
|
|
const displayFees = FeeCalculator.formatFeesForDisplay(fees);
|
|
|
|
res.json({
|
|
fees,
|
|
display: displayFees,
|
|
});
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error calculating fees", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
userId: req.user.id,
|
|
startDate: req.query.startDate,
|
|
endDate: req.query.endDate,
|
|
itemId: req.query.itemId,
|
|
});
|
|
res.status(500).json({ error: "Failed to calculate fees" });
|
|
}
|
|
});
|
|
|
|
// Preview rental cost calculation (no rental creation)
|
|
router.post("/cost-preview", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { itemId, startDateTime, endDateTime } = req.body;
|
|
|
|
// Validate inputs
|
|
if (!itemId || !startDateTime || !endDateTime) {
|
|
return res.status(400).json({
|
|
error: "itemId, startDateTime, and endDateTime are required",
|
|
});
|
|
}
|
|
|
|
// Fetch item
|
|
const item = await Item.findByPk(itemId);
|
|
if (!item) {
|
|
return res.status(404).json({ error: "Item not found" });
|
|
}
|
|
|
|
// Parse datetimes
|
|
const rentalStartDateTime = new Date(startDateTime);
|
|
const rentalEndDateTime = new Date(endDateTime);
|
|
|
|
// Validate date formats
|
|
if (isNaN(rentalStartDateTime.getTime())) {
|
|
return res.status(400).json({ error: "Invalid start date format" });
|
|
}
|
|
if (isNaN(rentalEndDateTime.getTime())) {
|
|
return res.status(400).json({ error: "Invalid end date format" });
|
|
}
|
|
|
|
// Validate start date is not in the past (with 5 minute grace period)
|
|
const now = new Date();
|
|
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
|
if (rentalStartDateTime < fiveMinutesAgo) {
|
|
return res.status(400).json({ error: "Start date cannot be in the past" });
|
|
}
|
|
|
|
// Validate date range
|
|
if (rentalEndDateTime <= rentalStartDateTime) {
|
|
return res.status(400).json({
|
|
error: "End date/time must be after start date/time",
|
|
});
|
|
}
|
|
|
|
// Calculate rental cost using duration calculator
|
|
const totalAmount = RentalDurationCalculator.calculateRentalCost(
|
|
rentalStartDateTime,
|
|
rentalEndDateTime,
|
|
item
|
|
);
|
|
|
|
// Calculate fees
|
|
const fees = FeeCalculator.calculateRentalFees(totalAmount);
|
|
|
|
res.json({
|
|
baseAmount: totalAmount,
|
|
fees,
|
|
});
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error calculating rental cost preview", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
userId: req.user.id,
|
|
});
|
|
res.status(500).json({ error: "Failed to calculate rental cost preview" });
|
|
}
|
|
});
|
|
|
|
// Get earnings status for owner's rentals
|
|
router.get("/earnings/status", authenticateToken, async (req, res, next) => {
|
|
try {
|
|
const ownerRentals = await Rental.findAll({
|
|
where: {
|
|
ownerId: req.user.id,
|
|
status: "completed",
|
|
},
|
|
attributes: [
|
|
"id",
|
|
"totalAmount",
|
|
"platformFee",
|
|
"payoutAmount",
|
|
"payoutStatus",
|
|
"payoutProcessedAt",
|
|
"stripeTransferId",
|
|
],
|
|
include: [{ model: Item, as: "item", attributes: ["name"] }],
|
|
order: [["createdAt", "DESC"]],
|
|
});
|
|
|
|
res.json(ownerRentals);
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error getting earnings status", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
userId: req.user.id,
|
|
});
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Get refund preview (what would happen if cancelled now)
|
|
router.get("/:id/refund-preview", authenticateToken, async (req, res, next) => {
|
|
try {
|
|
const preview = await RefundService.getRefundPreview(
|
|
req.params.id,
|
|
req.user.id
|
|
);
|
|
res.json(preview);
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error getting refund preview", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
rentalId: req.params.id,
|
|
userId: req.user.id,
|
|
});
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Get late fee preview
|
|
router.get(
|
|
"/:id/late-fee-preview",
|
|
authenticateToken,
|
|
async (req, res, next) => {
|
|
try {
|
|
const { actualReturnDateTime } = req.query;
|
|
|
|
if (!actualReturnDateTime) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "actualReturnDateTime is required" });
|
|
}
|
|
|
|
const rental = await Rental.findByPk(req.params.id, {
|
|
include: [{ model: Item, as: "item" }],
|
|
});
|
|
|
|
if (!rental) {
|
|
return res.status(404).json({ error: "Rental not found" });
|
|
}
|
|
|
|
// Check authorization
|
|
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
|
|
return res.status(403).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const lateCalculation = LateReturnService.calculateLateFee(
|
|
rental,
|
|
actualReturnDateTime
|
|
);
|
|
|
|
res.json(lateCalculation);
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error getting late fee preview", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
rentalId: req.params.id,
|
|
userId: req.user.id,
|
|
});
|
|
next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Cancel rental with refund processing
|
|
router.post("/:id/cancel", authenticateToken, async (req, res, next) => {
|
|
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.trim()
|
|
);
|
|
|
|
// Return the updated rental with refund information
|
|
const updatedRental = await Rental.findByPk(result.rental.id, {
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "firstName", "lastName", "email"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName", "email"],
|
|
},
|
|
],
|
|
});
|
|
|
|
// Send cancellation notification emails
|
|
try {
|
|
await emailServices.rentalFlow.sendRentalCancellationEmails(
|
|
updatedRental.owner,
|
|
updatedRental.renter,
|
|
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,
|
|
stack: emailError.stack,
|
|
rentalId: updatedRental.id,
|
|
cancelledBy: updatedRental.cancelledBy,
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
rental: updatedRental,
|
|
refund: result.refund,
|
|
});
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error cancelling rental", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
rentalId: req.params.id,
|
|
userId: req.user.id,
|
|
});
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Mark item return status (owner only)
|
|
router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
|
try {
|
|
const { status, actualReturnDateTime, statusOptions } = req.body;
|
|
const rentalId = req.params.id;
|
|
const userId = req.user.id;
|
|
|
|
const rental = await Rental.findByPk(rentalId, {
|
|
include: [{ model: Item, as: "item" }],
|
|
});
|
|
|
|
if (!rental) {
|
|
return res.status(404).json({ error: "Rental not found" });
|
|
}
|
|
|
|
if (rental.ownerId !== userId) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Only the item owner can mark return status" });
|
|
}
|
|
|
|
if (rental.status !== "active") {
|
|
return res.status(400).json({
|
|
error: "Can only mark return status for active rentals",
|
|
});
|
|
}
|
|
|
|
let updatedRental;
|
|
let additionalInfo = {};
|
|
|
|
switch (status) {
|
|
case "returned":
|
|
// Item returned on time
|
|
updatedRental = await rental.update({
|
|
status: "completed",
|
|
payoutStatus: "pending",
|
|
actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
|
|
});
|
|
|
|
// Fetch full rental details with associations for email
|
|
const rentalWithDetails = await Rental.findByPk(rentalId, {
|
|
include: [
|
|
{ model: Item, as: "item" },
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
|
},
|
|
{
|
|
model: User,
|
|
as: "renter",
|
|
attributes: ["id", "firstName", "lastName", "email"],
|
|
},
|
|
],
|
|
});
|
|
|
|
// Send completion emails to both renter and owner
|
|
try {
|
|
await emailServices.rentalFlow.sendRentalCompletionEmails(
|
|
rentalWithDetails.owner,
|
|
rentalWithDetails.renter,
|
|
rentalWithDetails
|
|
);
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.info("Rental completion emails sent", {
|
|
rentalId,
|
|
ownerId: rental.ownerId,
|
|
renterId: rental.renterId,
|
|
});
|
|
} catch (emailError) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Failed to send rental completion emails", {
|
|
error: emailError.message,
|
|
stack: emailError.stack,
|
|
rentalId,
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "damaged":
|
|
// Item returned damaged
|
|
const damageUpdates = {
|
|
status: "damaged",
|
|
payoutStatus: "pending",
|
|
actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
|
|
};
|
|
|
|
// Check if ALSO returned late
|
|
if (statusOptions?.returned_late && actualReturnDateTime) {
|
|
const lateReturnDamaged = await LateReturnService.processLateReturn(
|
|
rentalId,
|
|
actualReturnDateTime
|
|
);
|
|
damageUpdates.status = "returned_late_and_damaged";
|
|
damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee;
|
|
damageUpdates.actualReturnDateTime =
|
|
lateReturnDamaged.rental.actualReturnDateTime;
|
|
additionalInfo.lateCalculation = lateReturnDamaged.lateCalculation;
|
|
}
|
|
|
|
updatedRental = await rental.update(damageUpdates);
|
|
break;
|
|
|
|
case "returned_late":
|
|
// Item returned late - calculate late fees
|
|
if (!actualReturnDateTime) {
|
|
return res.status(400).json({
|
|
error: "Actual return date/time is required for late returns",
|
|
});
|
|
}
|
|
|
|
const lateReturn = await LateReturnService.processLateReturn(
|
|
rentalId,
|
|
actualReturnDateTime
|
|
);
|
|
|
|
updatedRental = lateReturn.rental;
|
|
additionalInfo.lateCalculation = lateReturn.lateCalculation;
|
|
break;
|
|
|
|
case "lost":
|
|
// Item reported as lost
|
|
updatedRental = await rental.update({
|
|
status: "lost",
|
|
payoutStatus: "pending",
|
|
itemLostReportedAt: new Date(),
|
|
});
|
|
|
|
// Send notification to customer service
|
|
const owner = await User.findByPk(rental.ownerId);
|
|
const renter = await User.findByPk(rental.renterId);
|
|
await emailServices.customerService.sendLostItemToCustomerService(
|
|
updatedRental,
|
|
owner,
|
|
renter
|
|
);
|
|
break;
|
|
|
|
default:
|
|
return res.status(400).json({
|
|
error:
|
|
"Invalid status. Use 'returned', 'returned_late', 'damaged', or 'lost'",
|
|
});
|
|
}
|
|
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.info("Return status marked", {
|
|
rentalId,
|
|
status,
|
|
ownerId: userId,
|
|
lateFee: updatedRental.lateFees || 0,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
rental: updatedRental,
|
|
...additionalInfo,
|
|
});
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error marking return status", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
rentalId: req.params.id,
|
|
userId: req.user.id,
|
|
});
|
|
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Allowed fields for damage report (prevents mass assignment)
|
|
const ALLOWED_DAMAGE_REPORT_FIELDS = [
|
|
"description",
|
|
"canBeFixed",
|
|
"repairCost",
|
|
"needsReplacement",
|
|
"replacementCost",
|
|
"proofOfOwnership",
|
|
"actualReturnDateTime",
|
|
"imageFilenames",
|
|
];
|
|
|
|
function extractAllowedDamageFields(body) {
|
|
const result = {};
|
|
for (const field of ALLOWED_DAMAGE_REPORT_FIELDS) {
|
|
if (body[field] !== undefined) {
|
|
result[field] = body[field];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Report item as damaged (owner only)
|
|
router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
|
|
try {
|
|
const rentalId = req.params.id;
|
|
const userId = req.user.id;
|
|
// Extract only allowed fields (prevents mass assignment)
|
|
const damageInfo = extractAllowedDamageFields(req.body);
|
|
|
|
// Validate imageFilenames if provided
|
|
if (damageInfo.imageFilenames !== undefined) {
|
|
const imageFilenamesArray = Array.isArray(damageInfo.imageFilenames)
|
|
? damageInfo.imageFilenames
|
|
: [];
|
|
|
|
const keyValidation = validateS3Keys(
|
|
imageFilenamesArray,
|
|
"damage-reports",
|
|
{
|
|
maxKeys: IMAGE_LIMITS.damageReports,
|
|
}
|
|
);
|
|
if (!keyValidation.valid) {
|
|
return res.status(400).json({
|
|
error: keyValidation.error,
|
|
details: keyValidation.invalidKeys,
|
|
});
|
|
}
|
|
damageInfo.imageFilenames = imageFilenamesArray;
|
|
}
|
|
|
|
const result = await DamageAssessmentService.processDamageAssessment(
|
|
rentalId,
|
|
damageInfo,
|
|
userId
|
|
);
|
|
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.info("Damage reported", {
|
|
rentalId,
|
|
ownerId: userId,
|
|
damageFee: result.damageAssessment.feeCalculation.amount,
|
|
lateFee: result.lateCalculation?.lateFee || 0,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
...result,
|
|
});
|
|
} catch (error) {
|
|
const reqLogger = logger.withRequestId(req.id);
|
|
reqLogger.error("Error reporting damage", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
rentalId: req.params.id,
|
|
userId: req.user.id,
|
|
});
|
|
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|