email plus return item statuses

This commit is contained in:
jackiettran
2025-10-06 15:41:48 -04:00
parent 67cc997ddc
commit 5c3d505988
28 changed files with 5861 additions and 259 deletions

36
backend/config/aws.js Normal file
View File

@@ -0,0 +1,36 @@
const { fromIni } = require("@aws-sdk/credential-providers");
/**
* Get AWS configuration based on environment
* - Development: Uses AWS credential profiles from ~/.aws/credentials
* - Production: Uses IAM roles (EC2/Lambda/ECS instance roles)
*/
function getAWSCredentials() {
if (process.env.NODE_ENV === "dev") {
// Local development: use profile from ~/.aws/credentials
const profile = process.env.AWS_PROFILE;
return fromIni({ profile });
}
}
/**
* Get complete AWS client configuration
*/
function getAWSConfig() {
const config = {
region: process.env.AWS_REGION || "us-east-1",
};
const credentials = getAWSCredentials();
if (credentials) {
config.credentials = credentials;
}
return config;
}
module.exports = {
getAWSConfig,
getAWSCredentials,
};

View File

@@ -0,0 +1,258 @@
const cron = require("node-cron");
const {
Rental,
User,
Item,
ConditionCheck,
} = require("../models");
const { Op } = require("sequelize");
const emailService = require("../services/emailService");
const logger = require("../utils/logger");
const reminderSchedule = "0 * * * *"; // Run every hour
class ConditionCheckReminderJob {
static startScheduledReminders() {
console.log("Starting automated condition check reminder job...");
const reminderJob = cron.schedule(
reminderSchedule,
async () => {
try {
await this.sendConditionCheckReminders();
} catch (error) {
logger.error("Error in scheduled condition check reminders", {
error: error.message,
stack: error.stack,
});
}
},
{
scheduled: false,
timezone: "America/New_York",
}
);
// Start the job
reminderJob.start();
console.log("Condition check reminder job scheduled:");
console.log("- Reminders every hour: " + reminderSchedule);
return {
reminderJob,
stop() {
reminderJob.stop();
console.log("Condition check reminder job stopped");
},
getStatus() {
return {
reminderJobRunning: reminderJob.getStatus() === "scheduled",
};
},
};
}
// Send reminders for upcoming condition check windows
static async sendConditionCheckReminders() {
try {
const now = new Date();
const reminderWindow = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours ahead
// Find rentals with upcoming condition check windows
const rentals = await Rental.findAll({
where: {
status: {
[Op.in]: ["confirmed", "active", "completed"],
},
},
include: [
{ model: User, as: "owner" },
{ model: User, as: "renter" },
{ model: Item, as: "item" },
],
});
for (const rental of rentals) {
await this.checkAndSendConditionReminders(rental, now, reminderWindow);
}
console.log(
`Processed ${rentals.length} rentals for condition check reminders`
);
} catch (error) {
console.error("Error sending condition check reminders:", error);
}
}
// Check specific rental for reminder needs
static async checkAndSendConditionReminders(rental, now, reminderWindow) {
const rentalStart = new Date(rental.startDateTime);
const rentalEnd = new Date(rental.endDateTime);
// Pre-rental owner check (24 hours before rental start)
const preRentalWindow = new Date(
rentalStart.getTime() - 24 * 60 * 60 * 1000
);
if (now <= preRentalWindow && preRentalWindow <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "pre_rental_owner",
},
});
if (!existingCheck) {
await this.sendPreRentalOwnerReminder(rental);
}
}
// Rental start renter check (within 24 hours of rental start)
if (now <= rentalStart && rentalStart <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "rental_start_renter",
},
});
if (!existingCheck) {
await this.sendRentalStartRenterReminder(rental);
}
}
// Rental end renter check (within 24 hours of rental end)
if (now <= rentalEnd && rentalEnd <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "rental_end_renter",
},
});
if (!existingCheck) {
await this.sendRentalEndRenterReminder(rental);
}
}
// Post-rental owner check (24 hours after rental end)
const postRentalWindow = new Date(
rentalEnd.getTime() + 24 * 60 * 60 * 1000
);
if (now <= postRentalWindow && postRentalWindow <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "post_rental_owner",
},
});
if (!existingCheck) {
await this.sendPostRentalOwnerReminder(rental);
}
}
}
// Individual email senders
static async sendPreRentalOwnerReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "pre_rental_owner",
title: "Condition Check Reminder",
message: `Please take photos of "${rental.item.name}" before the rental begins tomorrow.`,
rentalId: rental.id,
userId: rental.ownerId,
metadata: {
checkType: "pre_rental_owner",
deadline: new Date(rental.startDateTime).toISOString(),
},
};
await emailService.sendConditionCheckReminder(
rental.owner.email,
notificationData,
rental
);
console.log(`Pre-rental owner reminder sent for rental ${rental.id}`);
}
static async sendRentalStartRenterReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "rental_start_renter",
title: "Condition Check Reminder",
message: `Please take photos when you receive "${rental.item.name}" to document its condition.`,
rentalId: rental.id,
userId: rental.renterId,
metadata: {
checkType: "rental_start_renter",
deadline: new Date(
rental.startDateTime.getTime() + 24 * 60 * 60 * 1000
).toISOString(),
},
};
await emailService.sendConditionCheckReminder(
rental.renter.email,
notificationData,
rental
);
console.log(`Rental start renter reminder sent for rental ${rental.id}`);
}
static async sendRentalEndRenterReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "rental_end_renter",
title: "Condition Check Reminder",
message: `Please take photos when returning "${rental.item.name}" to document its condition.`,
rentalId: rental.id,
userId: rental.renterId,
metadata: {
checkType: "rental_end_renter",
deadline: new Date(
rental.endDateTime.getTime() + 24 * 60 * 60 * 1000
).toISOString(),
},
};
await emailService.sendConditionCheckReminder(
rental.renter.email,
notificationData,
rental
);
console.log(`Rental end renter reminder sent for rental ${rental.id}`);
}
static async sendPostRentalOwnerReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "post_rental_owner",
title: "Condition Check Reminder",
message: `Please take photos and mark the return status for "${rental.item.name}".`,
rentalId: rental.id,
userId: rental.ownerId,
metadata: {
checkType: "post_rental_owner",
deadline: new Date(
rental.endDateTime.getTime() + 48 * 60 * 60 * 1000
).toISOString(),
},
};
await emailService.sendConditionCheckReminder(
rental.owner.email,
notificationData,
rental
);
console.log(`Post-rental owner reminder sent for rental ${rental.id}`);
}
}
module.exports = ConditionCheckReminderJob;

View File

@@ -0,0 +1,101 @@
const cron = require("node-cron");
const { Rental } = require("../models");
const { Op } = require("sequelize");
const logger = require("../utils/logger");
const statusUpdateSchedule = "*/15 * * * *"; // Run every 15 minutes
class RentalStatusJob {
static startScheduledStatusUpdates() {
console.log("Starting automated rental status updates...");
const statusJob = cron.schedule(
statusUpdateSchedule,
async () => {
try {
await this.activateStartedRentals();
} catch (error) {
logger.error("Error in scheduled rental status update", {
error: error.message,
stack: error.stack
});
}
},
{
scheduled: false,
timezone: "America/New_York",
}
);
// Start the job
statusJob.start();
console.log("Rental status job scheduled:");
console.log("- Status updates every 15 minutes: " + statusUpdateSchedule);
return {
statusJob,
stop() {
statusJob.stop();
console.log("Rental status job stopped");
},
getStatus() {
return {
statusJobRunning: statusJob.getStatus() === "scheduled",
};
},
};
}
static async activateStartedRentals() {
try {
const now = new Date();
// Find all confirmed rentals where start time has arrived
const rentalsToActivate = await Rental.findAll({
where: {
status: "confirmed",
startDateTime: {
[Op.lte]: now,
},
},
});
if (rentalsToActivate.length === 0) {
return { activated: 0 };
}
// Update all matching rentals to active status
const rentalIds = rentalsToActivate.map((r) => r.id);
const [updateCount] = await Rental.update(
{ status: "active" },
{
where: {
id: {
[Op.in]: rentalIds,
},
},
}
);
logger.info("Activated started rentals", {
count: updateCount,
rentalIds: rentalIds,
});
console.log(`Activated ${updateCount} rentals that have started`);
return { activated: updateCount, rentalIds };
} catch (error) {
logger.error("Error activating started rentals", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
}
module.exports = RentalStatusJob;

View File

@@ -0,0 +1,53 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const ConditionCheck = sequelize.define("ConditionCheck", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
rentalId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "Rentals",
key: "id",
},
},
checkType: {
type: DataTypes.ENUM(
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner"
),
allowNull: false,
},
photos: {
type: DataTypes.ARRAY(DataTypes.STRING),
defaultValue: [],
},
notes: {
type: DataTypes.TEXT,
},
submittedBy: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
},
submittedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
metadata: {
type: DataTypes.JSONB,
defaultValue: {},
},
});
module.exports = ConditionCheck;

View File

@@ -57,7 +57,11 @@ const Rental = sequelize.define("Rental", {
"confirmed", "confirmed",
"active", "active",
"completed", "completed",
"cancelled" "cancelled",
"returned_late",
"returned_late_and_damaged",
"damaged",
"lost"
), ),
defaultValue: "pending", defaultValue: "pending",
}, },
@@ -153,6 +157,29 @@ const Rental = sequelize.define("Rental", {
renterPrivateMessage: { renterPrivateMessage: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },
// Condition check and return handling fields
actualReturnDateTime: {
type: DataTypes.DATE,
},
lateFees: {
type: DataTypes.DECIMAL(10, 2),
defaultValue: 0.0,
},
damageFees: {
type: DataTypes.DECIMAL(10, 2),
defaultValue: 0.0,
},
replacementFees: {
type: DataTypes.DECIMAL(10, 2),
defaultValue: 0.0,
},
itemLostReportedAt: {
type: DataTypes.DATE,
},
damageAssessment: {
type: DataTypes.JSONB,
defaultValue: {},
},
}); });
module.exports = Rental; module.exports = Rental;

View File

@@ -1,41 +1,75 @@
const sequelize = require('../config/database'); const sequelize = require("../config/database");
const User = require('./User'); const User = require("./User");
const Item = require('./Item'); const Item = require("./Item");
const Rental = require('./Rental'); const Rental = require("./Rental");
const Message = require('./Message'); const Message = require("./Message");
const ItemRequest = require('./ItemRequest'); const ItemRequest = require("./ItemRequest");
const ItemRequestResponse = require('./ItemRequestResponse'); const ItemRequestResponse = require("./ItemRequestResponse");
const UserAddress = require('./UserAddress'); const UserAddress = require("./UserAddress");
const ConditionCheck = require("./ConditionCheck");
User.hasMany(Item, { as: 'ownedItems', foreignKey: 'ownerId' }); User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
Item.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' }); Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
User.hasMany(Rental, { as: 'rentalsAsRenter', foreignKey: 'renterId' }); User.hasMany(Rental, { as: "rentalsAsRenter", foreignKey: "renterId" });
User.hasMany(Rental, { as: 'rentalsAsOwner', foreignKey: 'ownerId' }); User.hasMany(Rental, { as: "rentalsAsOwner", foreignKey: "ownerId" });
Item.hasMany(Rental, { as: 'rentals', foreignKey: 'itemId' }); Item.hasMany(Rental, { as: "rentals", foreignKey: "itemId" });
Rental.belongsTo(Item, { as: 'item', foreignKey: 'itemId' }); Rental.belongsTo(Item, { as: "item", foreignKey: "itemId" });
Rental.belongsTo(User, { as: 'renter', foreignKey: 'renterId' }); Rental.belongsTo(User, { as: "renter", foreignKey: "renterId" });
Rental.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' }); Rental.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
User.hasMany(Message, { as: 'sentMessages', foreignKey: 'senderId' }); User.hasMany(Message, { as: "sentMessages", foreignKey: "senderId" });
User.hasMany(Message, { as: 'receivedMessages', foreignKey: 'receiverId' }); User.hasMany(Message, { as: "receivedMessages", foreignKey: "receiverId" });
Message.belongsTo(User, { as: 'sender', foreignKey: 'senderId' }); Message.belongsTo(User, { as: "sender", foreignKey: "senderId" });
Message.belongsTo(User, { as: 'receiver', foreignKey: 'receiverId' }); Message.belongsTo(User, { as: "receiver", foreignKey: "receiverId" });
Message.hasMany(Message, { as: 'replies', foreignKey: 'parentMessageId' }); Message.hasMany(Message, { as: "replies", foreignKey: "parentMessageId" });
Message.belongsTo(Message, { as: 'parentMessage', foreignKey: 'parentMessageId' }); Message.belongsTo(Message, {
as: "parentMessage",
foreignKey: "parentMessageId",
});
User.hasMany(ItemRequest, { as: 'itemRequests', foreignKey: 'requesterId' }); User.hasMany(ItemRequest, { as: "itemRequests", foreignKey: "requesterId" });
ItemRequest.belongsTo(User, { as: 'requester', foreignKey: 'requesterId' }); ItemRequest.belongsTo(User, { as: "requester", foreignKey: "requesterId" });
User.hasMany(ItemRequestResponse, { as: 'itemRequestResponses', foreignKey: 'responderId' }); User.hasMany(ItemRequestResponse, {
ItemRequest.hasMany(ItemRequestResponse, { as: 'responses', foreignKey: 'itemRequestId' }); as: "itemRequestResponses",
ItemRequestResponse.belongsTo(User, { as: 'responder', foreignKey: 'responderId' }); foreignKey: "responderId",
ItemRequestResponse.belongsTo(ItemRequest, { as: 'itemRequest', foreignKey: 'itemRequestId' }); });
ItemRequestResponse.belongsTo(Item, { as: 'existingItem', foreignKey: 'existingItemId' }); ItemRequest.hasMany(ItemRequestResponse, {
as: "responses",
foreignKey: "itemRequestId",
});
ItemRequestResponse.belongsTo(User, {
as: "responder",
foreignKey: "responderId",
});
ItemRequestResponse.belongsTo(ItemRequest, {
as: "itemRequest",
foreignKey: "itemRequestId",
});
ItemRequestResponse.belongsTo(Item, {
as: "existingItem",
foreignKey: "existingItemId",
});
User.hasMany(UserAddress, { as: 'addresses', foreignKey: 'userId' }); User.hasMany(UserAddress, { as: "addresses", foreignKey: "userId" });
UserAddress.belongsTo(User, { as: 'user', foreignKey: 'userId' }); UserAddress.belongsTo(User, { as: "user", foreignKey: "userId" });
// ConditionCheck associations
Rental.hasMany(ConditionCheck, {
as: "conditionChecks",
foreignKey: "rentalId",
});
ConditionCheck.belongsTo(Rental, { as: "rental", foreignKey: "rentalId" });
User.hasMany(ConditionCheck, {
as: "conditionChecks",
foreignKey: "submittedBy",
});
ConditionCheck.belongsTo(User, {
as: "submittedByUser",
foreignKey: "submittedBy",
});
module.exports = { module.exports = {
sequelize, sequelize,
@@ -45,5 +79,6 @@ module.exports = {
Message, Message,
ItemRequest, ItemRequest,
ItemRequestResponse, ItemRequestResponse,
UserAddress UserAddress,
}; ConditionCheck,
};

1271
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@aws-sdk/client-ses": "^3.896.0",
"@googlemaps/google-maps-services-js": "^3.4.2", "@googlemaps/google-maps-services-js": "^3.4.2",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",

View File

@@ -0,0 +1,165 @@
const express = require("express");
const multer = require("multer");
const { authenticateToken } = require("../middleware/auth");
const ConditionCheckService = require("../services/conditionCheckService");
const logger = require("../utils/logger");
const router = express.Router();
// Configure multer for photo uploads
const upload = multer({
dest: "uploads/condition-checks/",
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
files: 20, // Maximum 20 files
},
fileFilter: (req, file, cb) => {
// Accept only image files
if (file.mimetype.startsWith("image/")) {
cb(null, true);
} else {
cb(new Error("Only image files are allowed"), false);
}
},
});
// Submit a condition check
router.post(
"/:rentalId",
authenticateToken,
upload.array("photos"),
async (req, res) => {
try {
const { rentalId } = req.params;
const { checkType, notes } = req.body;
const userId = req.user.id;
// Get uploaded file paths
const photos = req.files ? req.files.map((file) => file.path) : [];
// Extract metadata from request
const metadata = {
userAgent: req.get("User-Agent"),
ipAddress: req.ip,
deviceType: req.get("X-Device-Type") || "web",
};
const conditionCheck = await ConditionCheckService.submitConditionCheck(
rentalId,
checkType,
userId,
photos,
notes,
metadata
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Condition check submitted", {
rentalId,
checkType,
userId,
photoCount: photos.length,
});
res.status(201).json({
success: true,
conditionCheck,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error submitting condition check", {
error: error.message,
rentalId: req.params.rentalId,
userId: req.user?.id,
});
res.status(400).json({
success: false,
error: error.message,
});
}
}
);
// Get condition checks for a rental
router.get("/:rentalId", authenticateToken, async (req, res) => {
try {
const { rentalId } = req.params;
const conditionChecks = await ConditionCheckService.getConditionChecks(
rentalId
);
res.json({
success: true,
conditionChecks,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching condition checks", {
error: error.message,
rentalId: req.params.rentalId,
});
res.status(500).json({
success: false,
error: "Failed to fetch condition checks",
});
}
});
// Get condition check timeline for a rental
router.get("/:rentalId/timeline", authenticateToken, async (req, res) => {
try {
const { rentalId } = req.params;
const timeline = await ConditionCheckService.getConditionCheckTimeline(
rentalId
);
res.json({
success: true,
timeline,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching condition check timeline", {
error: error.message,
rentalId: req.params.rentalId,
});
res.status(500).json({
success: false,
error: error.message,
});
}
});
// Get available condition checks for current user
router.get("/", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const availableChecks = await ConditionCheckService.getAvailableChecks(
userId
);
res.json({
success: true,
availableChecks,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching available checks", {
error: error.message,
userId: req.user?.id,
});
res.status(500).json({
success: false,
error: "Failed to fetch available checks",
});
}
});
module.exports = router;

View File

@@ -4,6 +4,9 @@ const { Rental, Item, User } = require("../models"); // Import from models/index
const { authenticateToken } = require("../middleware/auth"); const { authenticateToken } = require("../middleware/auth");
const FeeCalculator = require("../utils/feeCalculator"); const FeeCalculator = require("../utils/feeCalculator");
const RefundService = require("../services/refundService"); const RefundService = require("../services/refundService");
const LateReturnService = require("../services/lateReturnService");
const DamageAssessmentService = require("../services/damageAssessmentService");
const emailService = require("../services/emailService");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const router = express.Router(); const router = express.Router();
@@ -72,7 +75,7 @@ router.get("/my-rentals", authenticateToken, async (req, res) => {
reqLogger.error("Error in my-rentals route", { reqLogger.error("Error in my-rentals route", {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
userId: req.user.id userId: req.user.id,
}); });
res.status(500).json({ error: "Failed to fetch rentals" }); res.status(500).json({ error: "Failed to fetch rentals" });
} }
@@ -100,7 +103,7 @@ router.get("/my-listings", authenticateToken, async (req, res) => {
reqLogger.error("Error in my-listings route", { reqLogger.error("Error in my-listings route", {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
userId: req.user.id userId: req.user.id,
}); });
res.status(500).json({ error: "Failed to fetch listings" }); res.status(500).json({ error: "Failed to fetch listings" });
} }
@@ -131,7 +134,9 @@ router.get("/:id", authenticateToken, async (req, res) => {
// Check if user is authorized to view this rental // Check if user is authorized to view this rental
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) { if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
return res.status(403).json({ error: "Unauthorized to view this rental" }); return res
.status(403)
.json({ error: "Unauthorized to view this rental" });
} }
res.json(rental); res.json(rental);
@@ -141,7 +146,7 @@ router.get("/:id", authenticateToken, async (req, res) => {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
rentalId: req.params.id, rentalId: req.params.id,
userId: req.user.id userId: req.user.id,
}); });
res.status(500).json({ error: "Failed to fetch rental" }); res.status(500).json({ error: "Failed to fetch rental" });
} }
@@ -235,7 +240,9 @@ router.post("/", authenticateToken, async (req, res) => {
// Validate that payment method was provided for paid rentals // Validate that payment method was provided for paid rentals
if (totalAmount > 0 && !stripePaymentMethodId) { if (totalAmount > 0 && !stripePaymentMethodId) {
return res.status(400).json({ error: "Payment method is required for paid rentals" }); return res
.status(400)
.json({ error: "Payment method is required for paid rentals" });
} }
const rentalData = { const rentalData = {
@@ -313,7 +320,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
} }
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) { if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
return res.status(403).json({ error: "Unauthorized to update this rental" }); return res
.status(403)
.json({ error: "Unauthorized to update this rental" });
} }
// If owner is approving a pending rental, handle payment for paid rentals // If owner is approving a pending rental, handle payment for paid rentals
@@ -330,73 +339,76 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
.json({ error: "No payment method found for this rental" }); .json({ error: "No payment method found for this rental" });
} }
try { try {
// Import StripeService to process the payment // Import StripeService to process the payment
const StripeService = require("../services/stripeService"); const StripeService = require("../services/stripeService");
// Check if renter has a stripe customer ID // Check if renter has a stripe customer ID
if (!rental.renter.stripeCustomerId) { if (!rental.renter.stripeCustomerId) {
return res return res.status(400).json({
.status(400) error: "Renter does not have a Stripe customer account",
.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 // Create payment intent and charge the stored payment method
await rental.update({ const paymentResult = await StripeService.chargePaymentMethod(
status: "confirmed", rental.stripePaymentMethodId,
paymentStatus: "paid", rental.totalAmount,
stripePaymentIntentId: paymentResult.paymentIntentId, rental.renter.stripeCustomerId,
});
const updatedRental = await Rental.findByPk(rental.id, {
include: [
{ model: Item, as: "item" },
{ {
model: User, rentalId: rental.id,
as: "owner", itemName: rental.item.name,
attributes: ["id", "username", "firstName", "lastName"], renterId: rental.renterId,
}, ownerId: rental.ownerId,
{ }
model: User, );
as: "renter",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
res.json(updatedRental); // Update rental with payment completion
return; await rental.update({
} catch (paymentError) { status: "confirmed",
const reqLogger = logger.withRequestId(req.id); paymentStatus: "paid",
reqLogger.error("Payment failed during approval", { stripePaymentIntentId: paymentResult.paymentIntentId,
error: paymentError.message, });
stack: paymentError.stack,
rentalId: req.params.id, const updatedRental = await Rental.findByPk(rental.id, {
userId: req.user.id include: [
}); { model: Item, as: "item" },
// Keep rental as pending, but inform of payment failure {
return res.status(400).json({ model: User,
error: "Payment failed during approval", as: "owner",
details: paymentError.message, attributes: ["id", "username", "firstName", "lastName"],
}); },
} {
model: User,
as: "renter",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
// Send confirmation emails
await emailService.sendRentalConfirmationEmails(updatedRental);
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 { } else {
// For free rentals, just update status directly // For free rentals, just update status directly
await rental.update({ await rental.update({
status: "confirmed" status: "confirmed",
}); });
const updatedRental = await Rental.findByPk(rental.id, { const updatedRental = await Rental.findByPk(rental.id, {
@@ -415,6 +427,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
], ],
}); });
// Send confirmation emails
await emailService.sendRentalConfirmationEmails(updatedRental);
res.json(updatedRental); res.json(updatedRental);
return; return;
} }
@@ -601,7 +616,7 @@ router.post("/calculate-fees", authenticateToken, async (req, res) => {
userId: req.user.id, userId: req.user.id,
startDate: req.query.startDate, startDate: req.query.startDate,
endDate: req.query.endDate, endDate: req.query.endDate,
itemId: req.query.itemId itemId: req.query.itemId,
}); });
res.status(500).json({ error: "Failed to calculate fees" }); res.status(500).json({ error: "Failed to calculate fees" });
} }
@@ -634,7 +649,7 @@ router.get("/earnings/status", authenticateToken, async (req, res) => {
reqLogger.error("Error getting earnings status", { reqLogger.error("Error getting earnings status", {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
userId: req.user.id userId: req.user.id,
}); });
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -654,7 +669,47 @@ router.get("/:id/refund-preview", authenticateToken, async (req, res) => {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
rentalId: req.params.id, rentalId: req.params.id,
userId: req.user.id userId: req.user.id,
});
res.status(400).json({ error: error.message });
}
});
// Get late fee preview
router.get("/:id/late-fee-preview", authenticateToken, async (req, res) => {
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,
}); });
res.status(400).json({ error: error.message }); res.status(400).json({ error: error.message });
} }
@@ -698,10 +753,174 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
rentalId: req.params.id, rentalId: req.params.id,
userId: req.user.id userId: req.user.id,
}); });
res.status(400).json({ error: error.message }); res.status(400).json({ error: error.message });
} }
}); });
// Mark item return status (owner only)
router.post("/:id/mark-return", authenticateToken, async (req, res) => {
try {
const { status, actualReturnDateTime, notes, 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 (!["confirmed", "active"].includes(rental.status)) {
return res.status(400).json({
error: "Can only mark return status for confirmed or active rentals",
});
}
let updatedRental;
let additionalInfo = {};
switch (status) {
case "returned":
// Item returned on time
updatedRental = await rental.update({
status: "completed",
actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
notes: notes || null,
});
break;
case "damaged":
// Item returned damaged
const damageUpdates = {
status: "damaged",
actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
notes: notes || null,
};
// Check if ALSO returned late
if (statusOptions?.returned_late && actualReturnDateTime) {
const lateReturnDamaged = await LateReturnService.processLateReturn(
rentalId,
actualReturnDateTime,
notes
);
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,
notes
);
updatedRental = lateReturn.rental;
additionalInfo.lateCalculation = lateReturn.lateCalculation;
break;
case "lost":
// Item reported as lost
updatedRental = await rental.update({
status: "lost",
itemLostReportedAt: new Date(),
notes: notes || null,
});
// Send notification to customer service
await emailService.sendLostItemToCustomerService(updatedRental);
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,
rentalId: req.params.id,
userId: req.user.id,
});
res.status(400).json({ error: error.message });
}
});
// Report item as damaged (owner only)
router.post("/:id/report-damage", authenticateToken, async (req, res) => {
try {
const rentalId = req.params.id;
const userId = req.user.id;
const damageInfo = req.body;
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,
rentalId: req.params.id,
userId: req.user.id,
});
res.status(400).json({ error: error.message });
}
});
module.exports = router; module.exports = router;

View File

@@ -23,8 +23,11 @@ const messageRoutes = require("./routes/messages");
const itemRequestRoutes = require("./routes/itemRequests"); const itemRequestRoutes = require("./routes/itemRequests");
const stripeRoutes = require("./routes/stripe"); const stripeRoutes = require("./routes/stripe");
const mapsRoutes = require("./routes/maps"); const mapsRoutes = require("./routes/maps");
const conditionCheckRoutes = require("./routes/conditionChecks");
const PayoutProcessor = require("./jobs/payoutProcessor"); const PayoutProcessor = require("./jobs/payoutProcessor");
const RentalStatusJob = require("./jobs/rentalStatusJob");
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
const app = express(); const app = express();
@@ -65,7 +68,7 @@ app.use(
app.use(cookieParser); app.use(cookieParser);
// HTTP request logging // HTTP request logging
app.use(morgan('combined', { stream: logger.stream })); app.use(morgan("combined", { stream: logger.stream }));
// API request/response logging // API request/response logging
app.use("/api/", apiLogger); app.use("/api/", apiLogger);
@@ -111,6 +114,7 @@ app.use("/api/messages", messageRoutes);
app.use("/api/item-requests", itemRequestRoutes); app.use("/api/item-requests", itemRequestRoutes);
app.use("/api/stripe", stripeRoutes); app.use("/api/stripe", stripeRoutes);
app.use("/api/maps", mapsRoutes); app.use("/api/maps", mapsRoutes);
app.use("/api/condition-checks", conditionCheckRoutes);
app.get("/", (req, res) => { app.get("/", (req, res) => {
res.json({ message: "CommunityRentals.App API is running!" }); res.json({ message: "CommunityRentals.App API is running!" });
@@ -131,10 +135,24 @@ sequelize
const payoutJobs = PayoutProcessor.startScheduledPayouts(); const payoutJobs = PayoutProcessor.startScheduledPayouts();
logger.info("Payout processor started"); logger.info("Payout processor started");
// Start the rental status job
const rentalStatusJobs = RentalStatusJob.startScheduledStatusUpdates();
logger.info("Rental status job started");
// Start the condition check reminder job
const conditionCheckJobs = ConditionCheckReminderJob.startScheduledReminders();
logger.info("Condition check reminder job started");
app.listen(PORT, () => { app.listen(PORT, () => {
logger.info(`Server is running on port ${PORT}`, { port: PORT, environment: env }); logger.info(`Server is running on port ${PORT}`, {
port: PORT,
environment: env,
});
}); });
}) })
.catch((err) => { .catch((err) => {
logger.error("Unable to sync database", { error: err.message, stack: err.stack }); logger.error("Unable to sync database", {
error: err.message,
stack: err.stack,
});
}); });

View File

@@ -0,0 +1,363 @@
const { ConditionCheck, Rental, User } = require("../models");
const { Op } = require("sequelize");
class ConditionCheckService {
/**
* Validate if a condition check can be submitted
* @param {string} rentalId - Rental ID
* @param {string} checkType - Type of check (pre_rental_owner, rental_start_renter, etc.)
* @param {string} userId - User attempting to submit
* @returns {Object} - { canSubmit, reason, timeWindow }
*/
static async validateConditionCheck(rentalId, checkType, userId) {
const rental = await Rental.findByPk(rentalId);
if (!rental) {
return { canSubmit: false, reason: "Rental not found" };
}
// Check user permissions
const isOwner = rental.ownerId === userId;
const isRenter = rental.renterId === userId;
if (checkType.includes("owner") && !isOwner) {
return {
canSubmit: false,
reason: "Only the item owner can submit owner condition checks",
};
}
if (checkType.includes("renter") && !isRenter) {
return {
canSubmit: false,
reason: "Only the renter can submit renter condition checks",
};
}
// Check if already submitted
const existingCheck = await ConditionCheck.findOne({
where: { rentalId, checkType },
});
if (existingCheck) {
return {
canSubmit: false,
reason: "Condition check already submitted for this type",
};
}
// Check time windows (24 hour windows)
const now = new Date();
const startDate = new Date(rental.startDateTime);
const endDate = new Date(rental.endDateTime);
const twentyFourHours = 24 * 60 * 60 * 1000;
let timeWindow = {};
let canSubmit = false;
switch (checkType) {
case "pre_rental_owner":
// 24 hours before rental starts
timeWindow.start = new Date(startDate.getTime() - twentyFourHours);
timeWindow.end = startDate;
canSubmit = now >= timeWindow.start && now <= timeWindow.end;
break;
case "rental_start_renter":
// 24 hours after rental starts
timeWindow.start = startDate;
timeWindow.end = new Date(startDate.getTime() + twentyFourHours);
canSubmit =
now >= timeWindow.start &&
now <= timeWindow.end &&
rental.status === "active";
break;
case "rental_end_renter":
// 24 hours before rental ends
timeWindow.start = new Date(endDate.getTime() - twentyFourHours);
timeWindow.end = endDate;
canSubmit =
now >= timeWindow.start &&
now <= timeWindow.end &&
rental.status === "active";
break;
case "post_rental_owner":
// Can be submitted anytime (integrated into return flow)
timeWindow.start = endDate;
timeWindow.end = null; // No time limit
canSubmit = true; // Always allowed when owner marks return
break;
default:
return { canSubmit: false, reason: "Invalid check type" };
}
if (!canSubmit) {
const isBeforeWindow = now < timeWindow.start;
const isAfterWindow = now > timeWindow.end;
let reason = "Outside of allowed time window";
if (isBeforeWindow) {
reason = `Too early. Check can be submitted starting ${timeWindow.start.toLocaleString()}`;
} else if (isAfterWindow) {
reason = `Pre-Rental Condition can only be submitted before start of rental period`;
}
return { canSubmit: false, reason, timeWindow };
}
return { canSubmit: true, timeWindow };
}
/**
* Submit a condition check with photos
* @param {string} rentalId - Rental ID
* @param {string} checkType - Type of check
* @param {string} userId - User submitting the check
* @param {Array} photos - Array of photo URLs
* @param {string} notes - Optional notes
* @param {Object} metadata - Additional metadata (device info, location, etc.)
* @returns {Object} - Created condition check
*/
static async submitConditionCheck(
rentalId,
checkType,
userId,
photos = [],
notes = null,
metadata = {}
) {
// Validate the check
const validation = await this.validateConditionCheck(
rentalId,
checkType,
userId
);
if (!validation.canSubmit) {
throw new Error(validation.reason);
}
// Validate photos (basic validation)
if (photos.length > 20) {
throw new Error("Maximum 20 photos allowed per condition check");
}
// Add timestamp and user agent to metadata
const enrichedMetadata = {
...metadata,
submittedAt: new Date().toISOString(),
userAgent: metadata.userAgent || "Unknown",
ipAddress: metadata.ipAddress || "Unknown",
deviceType: metadata.deviceType || "Unknown",
};
const conditionCheck = await ConditionCheck.create({
rentalId,
checkType,
submittedBy: userId,
photos,
notes,
metadata: enrichedMetadata,
});
return conditionCheck;
}
/**
* Get all condition checks for a rental
* @param {string} rentalId - Rental ID
* @returns {Array} - Array of condition checks with user info
*/
static async getConditionChecks(rentalId) {
const checks = await ConditionCheck.findAll({
where: { rentalId },
include: [
{
model: User,
as: "submittedByUser",
attributes: ["id", "username", "firstName", "lastName"],
},
],
order: [["submittedAt", "ASC"]],
});
return checks;
}
/**
* Get condition check timeline for a rental
* @param {string} rentalId - Rental ID
* @returns {Object} - Timeline showing what checks are available/completed
*/
static async getConditionCheckTimeline(rentalId) {
const rental = await Rental.findByPk(rentalId);
if (!rental) {
throw new Error("Rental not found");
}
const existingChecks = await ConditionCheck.findAll({
where: { rentalId },
include: [
{
model: User,
as: "submittedByUser",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
const checkTypes = [
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner",
];
const timeline = {};
for (const checkType of checkTypes) {
const existingCheck = existingChecks.find(
(check) => check.checkType === checkType
);
if (existingCheck) {
timeline[checkType] = {
status: "completed",
submittedAt: existingCheck.submittedAt,
submittedBy: existingCheck.submittedBy,
photoCount: existingCheck.photos.length,
hasNotes: !!existingCheck.notes,
};
} else {
// Calculate if this check type is available
const now = new Date();
const startDate = new Date(rental.startDateTime);
const endDate = new Date(rental.endDateTime);
const twentyFourHours = 24 * 60 * 60 * 1000;
let timeWindow = {};
let status = "not_available";
switch (checkType) {
case "pre_rental_owner":
timeWindow.start = new Date(startDate.getTime() - twentyFourHours);
timeWindow.end = startDate;
break;
case "rental_start_renter":
timeWindow.start = startDate;
timeWindow.end = new Date(startDate.getTime() + twentyFourHours);
break;
case "rental_end_renter":
timeWindow.start = new Date(endDate.getTime() - twentyFourHours);
timeWindow.end = endDate;
break;
case "post_rental_owner":
timeWindow.start = endDate;
timeWindow.end = new Date(endDate.getTime() + twentyFourHours);
break;
}
if (now >= timeWindow.start && now <= timeWindow.end) {
status = "available";
} else if (now < timeWindow.start) {
status = "pending";
} else {
status = "expired";
}
timeline[checkType] = {
status,
timeWindow,
availableFrom: timeWindow.start,
availableUntil: timeWindow.end,
};
}
}
return {
rental: {
id: rental.id,
startDateTime: rental.startDateTime,
endDateTime: rental.endDateTime,
status: rental.status,
},
timeline,
};
}
/**
* Get available condition checks for a user
* @param {string} userId - User ID
* @returns {Array} - Array of available condition checks
*/
static async getAvailableChecks(userId) {
const now = new Date();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Find rentals where user is owner or renter
const rentals = await Rental.findAll({
where: {
[Op.or]: [{ ownerId: userId }, { renterId: userId }],
status: {
[Op.in]: ["confirmed", "active", "completed"],
},
},
});
const availableChecks = [];
for (const rental of rentals) {
const isOwner = rental.ownerId === userId;
const isRenter = rental.renterId === userId;
const startDate = new Date(rental.startDateTime);
const endDate = new Date(rental.endDateTime);
// Check each type of condition check
const checkTypes = [];
if (isOwner) {
// Only include pre_rental_owner; post_rental is now part of return flow
checkTypes.push("pre_rental_owner");
}
if (isRenter) {
checkTypes.push("rental_start_renter", "rental_end_renter");
}
for (const checkType of checkTypes) {
// Check if already submitted
const existing = await ConditionCheck.findOne({
where: { rentalId: rental.id, checkType },
});
if (!existing) {
const validation = await this.validateConditionCheck(
rental.id,
checkType,
userId
);
if (validation.canSubmit) {
availableChecks.push({
rentalId: rental.id,
checkType,
rental: {
id: rental.id,
itemId: rental.itemId,
startDateTime: rental.startDateTime,
endDateTime: rental.endDateTime,
},
timeWindow: validation.timeWindow,
});
}
}
}
}
return availableChecks;
}
}
module.exports = ConditionCheckService;

View File

@@ -0,0 +1,138 @@
const { Rental, Item, ConditionCheck } = require("../models");
const LateReturnService = require("./lateReturnService");
const emailService = require("./emailService");
class DamageAssessmentService {
/**
* Process damage assessment and calculate fees
* @param {string} rentalId - Rental ID
* @param {Object} damageInfo - Damage assessment information
* @param {string} userId - Owner reporting the damage
* @returns {Object} - Updated rental with damage fees
*/
static async processDamageAssessment(rentalId, damageInfo, userId) {
const {
description,
canBeFixed,
repairCost,
needsReplacement,
replacementCost,
proofOfOwnership,
actualReturnDateTime,
photos = [],
} = damageInfo;
const rental = await Rental.findByPk(rentalId, {
include: [{ model: Item, as: "item" }],
});
if (!rental) {
throw new Error("Rental not found");
}
if (rental.ownerId !== userId) {
throw new Error("Only the item owner can report damage");
}
if (rental.status !== "active") {
throw new Error("Can only assess damage for active rentals");
}
// Validate required fields
if (!description || description.trim().length === 0) {
throw new Error("Damage description is required");
}
if (canBeFixed && (!repairCost || repairCost <= 0)) {
throw new Error("Repair cost is required when item can be fixed");
}
if (needsReplacement && (!replacementCost || replacementCost <= 0)) {
throw new Error(
"Replacement cost is required when item needs replacement"
);
}
// Calculate damage fees
let damageFees = 0;
let feeCalculation = {};
if (needsReplacement) {
// Full replacement cost
damageFees = parseFloat(replacementCost);
feeCalculation = {
type: "replacement",
amount: damageFees,
originalCost: replacementCost,
depreciation: 0,
};
} else if (canBeFixed && repairCost > 0) {
// Repair cost
damageFees = parseFloat(repairCost);
feeCalculation = {
type: "repair",
amount: damageFees,
repairCost: repairCost,
};
}
// Process late return if applicable
let lateFees = 0;
let lateCalculation = null;
if (actualReturnDateTime) {
const lateReturn = await LateReturnService.processLateReturn(
rentalId,
actualReturnDateTime,
`Item returned damaged: ${description}`
);
lateFees = lateReturn.lateCalculation.lateFee;
lateCalculation = lateReturn.lateCalculation;
}
// Create damage assessment record as metadata
const damageAssessment = {
description,
canBeFixed,
repairCost: canBeFixed ? parseFloat(repairCost) : null,
needsReplacement,
replacementCost: needsReplacement ? parseFloat(replacementCost) : null,
proofOfOwnership: proofOfOwnership || [],
photos,
assessedAt: new Date(),
assessedBy: userId,
feeCalculation,
};
// Update rental
const updates = {
status: "damaged",
damageFees: damageFees,
damageAssessment: damageAssessment,
};
// Add late fees if applicable
if (lateFees > 0) {
updates.lateFees = lateFees;
updates.actualReturnDateTime = new Date(actualReturnDateTime);
}
const updatedRental = await rental.update(updates);
// Send damage report to customer service for review
await emailService.sendDamageReportToCustomerService(
updatedRental,
damageAssessment,
lateCalculation
);
return {
rental: updatedRental,
damageAssessment,
lateCalculation,
totalAdditionalFees: damageFees + lateFees,
};
}
}
module.exports = DamageAssessmentService;

View File

@@ -0,0 +1,497 @@
const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses");
const fs = require("fs").promises;
const path = require("path");
const { getAWSConfig } = require("../config/aws");
const { User } = require("../models");
class EmailService {
constructor() {
this.sesClient = null;
this.initialized = false;
this.templates = new Map();
}
async initialize() {
if (this.initialized) return;
try {
// Use centralized AWS configuration with credential profiles
const awsConfig = getAWSConfig();
this.sesClient = new SESClient(awsConfig);
await this.loadEmailTemplates();
this.initialized = true;
console.log("SES Email Service initialized successfully");
} catch (error) {
console.error("Failed to initialize SES Email Service:", error);
throw error;
}
}
async loadEmailTemplates() {
const templatesDir = path.join(__dirname, "..", "templates", "emails");
try {
const templateFiles = [
"conditionCheckReminder.html",
"rentalConfirmation.html",
"lateReturnCS.html",
"damageReportCS.html",
"lostItemCS.html",
];
for (const templateFile of templateFiles) {
try {
const templatePath = path.join(templatesDir, templateFile);
const templateContent = await fs.readFile(templatePath, "utf-8");
const templateName = path.basename(templateFile, ".html");
this.templates.set(templateName, templateContent);
} catch (error) {
console.warn(`Template ${templateFile} not found, will use fallback`);
}
}
console.log(`Loaded ${this.templates.size} email templates`);
} catch (error) {
console.warn("Templates directory not found, using fallback templates");
}
}
async sendEmail(to, subject, htmlContent, textContent = null) {
if (!this.initialized) {
await this.initialize();
}
if (!process.env.EMAIL_ENABLED || process.env.EMAIL_ENABLED !== "true") {
console.log("Email sending disabled in environment");
return { success: true, messageId: "disabled" };
}
const params = {
Source: process.env.SES_FROM_EMAIL,
Destination: {
ToAddresses: Array.isArray(to) ? to : [to],
},
Message: {
Subject: {
Data: subject,
Charset: "UTF-8",
},
Body: {
Html: {
Data: htmlContent,
Charset: "UTF-8",
},
},
},
};
if (textContent) {
params.Message.Body.Text = {
Data: textContent,
Charset: "UTF-8",
};
}
if (process.env.SES_REPLY_TO_EMAIL) {
params.ReplyToAddresses = [process.env.SES_REPLY_TO_EMAIL];
}
try {
const command = new SendEmailCommand(params);
const result = await this.sesClient.send(command);
console.log(
`Email sent successfully to ${to}, MessageId: ${result.MessageId}`
);
return { success: true, messageId: result.MessageId };
} catch (error) {
console.error("Failed to send email:", error);
return { success: false, error: error.message };
}
}
renderTemplate(templateName, variables = {}) {
let template = this.templates.get(templateName);
if (!template) {
template = this.getFallbackTemplate(templateName);
}
let rendered = template;
Object.keys(variables).forEach((key) => {
const regex = new RegExp(`{{${key}}}`, "g");
rendered = rendered.replace(regex, variables[key] || "");
});
return rendered;
}
getFallbackTemplate(templateName) {
const baseTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { text-align: center; border-bottom: 2px solid #e9ecef; padding-bottom: 20px; margin-bottom: 30px; }
.logo { font-size: 24px; font-weight: bold; color: #333; }
.content { line-height: 1.6; color: #555; }
.button { display: inline-block; background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0; }
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef; text-align: center; font-size: 12px; color: #6c757d; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">RentAll</div>
</div>
<div class="content">
{{content}}
</div>
<div class="footer">
<p>This email was sent from RentAll. If you have any questions, please contact support.</p>
</div>
</div>
</body>
</html>
`;
const templates = {
conditionCheckReminder: baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Rental Item:</strong> {{itemName}}</p>
<p><strong>Deadline:</strong> {{deadline}}</p>
<p>Please complete this condition check as soon as possible to ensure proper documentation.</p>
`
),
rentalConfirmation: baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p>Thank you for using RentAll!</p>
`
),
damageClaimNotification: baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Claim Amount:</strong> ${{ claimAmount }}</p>
<p><strong>Description:</strong> {{description}}</p>
<p>Please review this claim and respond accordingly through your account.</p>
`
),
returnIssueNotification: baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Return Status:</strong> {{returnStatus}}</p>
<p>Please check your account for more details and take appropriate action.</p>
`
),
};
return (
templates[templateName] ||
baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
`
)
);
}
async sendConditionCheckReminder(userEmail, notification, rental) {
const variables = {
title: notification.title,
message: notification.message,
itemName: rental?.item?.name || "Unknown Item",
deadline: notification.metadata?.deadline
? new Date(notification.metadata.deadline).toLocaleDateString()
: "Not specified",
};
const htmlContent = this.renderTemplate(
"conditionCheckReminder",
variables
);
return await this.sendEmail(
userEmail,
`RentAll: ${notification.title}`,
htmlContent
);
}
async sendRentalConfirmation(userEmail, notification, rental) {
const variables = {
title: notification.title,
message: notification.message,
itemName: rental?.item?.name || "Unknown Item",
startDate: rental?.startDateTime
? new Date(rental.startDateTime).toLocaleDateString()
: "Not specified",
endDate: rental?.endDateTime
? new Date(rental.endDateTime).toLocaleDateString()
: "Not specified",
};
const htmlContent = this.renderTemplate("rentalConfirmation", variables);
return await this.sendEmail(
userEmail,
`RentAll: ${notification.title}`,
htmlContent
);
}
async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
const htmlContent = this.renderTemplate(templateName, variables);
return await this.sendEmail(toEmail, subject, htmlContent);
}
async sendLateReturnToCustomerService(rental, lateCalculation) {
try {
// Get owner and renter details
const owner = await User.findByPk(rental.ownerId);
const renter = await User.findByPk(rental.renterId);
if (!owner || !renter) {
console.error("Owner or renter not found for late return notification");
return;
}
// Format dates
const scheduledEnd = new Date(rental.endDateTime).toLocaleString();
const actualReturn = new Date(
rental.actualReturnDateTime
).toLocaleString();
// Send email to customer service
await this.sendTemplateEmail(
process.env.CUSTOMER_SUPPORT_EMAIL,
"Late Return Detected - Action Required",
"lateReturnCS",
{
rentalId: rental.id,
itemName: rental.item.name,
ownerName: owner.name,
ownerEmail: owner.email,
renterName: renter.name,
renterEmail: renter.email,
scheduledEnd,
actualReturn,
hoursLate: lateCalculation.lateHours.toFixed(1),
lateFee: lateCalculation.lateFee.toFixed(2),
}
);
console.log(
`Late return notification sent to customer service for rental ${rental.id}`
);
} catch (error) {
console.error(
"Failed to send late return notification to customer service:",
error
);
}
}
async sendDamageReportToCustomerService(
rental,
damageAssessment,
lateCalculation = null
) {
try {
// Get owner and renter details
const owner = await User.findByPk(rental.ownerId);
const renter = await User.findByPk(rental.renterId);
if (!owner || !renter) {
console.error(
"Owner or renter not found for damage report notification"
);
return;
}
// Calculate total fees
const damageFee = damageAssessment.feeCalculation.amount;
const lateFee = lateCalculation?.lateFee || 0;
const totalFees = damageFee + lateFee;
// Determine fee type description
let feeTypeDescription = "";
if (damageAssessment.feeCalculation.type === "repair") {
feeTypeDescription = "Repair Cost";
} else if (damageAssessment.feeCalculation.type === "replacement") {
feeTypeDescription = "Replacement Cost";
} else {
feeTypeDescription = "Damage Assessment Fee";
}
// Send email to customer service
await this.sendTemplateEmail(
process.env.CUSTOMER_SUPPORT_EMAIL,
"Damage Report Filed - Action Required",
"damageReportCS",
{
rentalId: rental.id,
itemName: rental.item.name,
ownerName: `${owner.firstName} ${owner.lastName}`,
ownerEmail: owner.email,
renterName: `${renter.firstName} ${renter.lastName}`,
renterEmail: renter.email,
damageDescription: damageAssessment.description,
canBeFixed: damageAssessment.canBeFixed ? "Yes" : "No",
repairCost: damageAssessment.repairCost
? damageAssessment.repairCost.toFixed(2)
: "N/A",
needsReplacement: damageAssessment.needsReplacement ? "Yes" : "No",
replacementCost: damageAssessment.replacementCost
? damageAssessment.replacementCost.toFixed(2)
: "N/A",
feeTypeDescription,
damageFee: damageFee.toFixed(2),
lateFee: lateFee.toFixed(2),
totalFees: totalFees.toFixed(2),
hasProofOfOwnership:
damageAssessment.proofOfOwnership &&
damageAssessment.proofOfOwnership.length > 0
? "Yes"
: "No",
}
);
console.log(
`Damage report notification sent to customer service for rental ${rental.id}`
);
} catch (error) {
console.error(
"Failed to send damage report notification to customer service:",
error
);
}
}
async sendLostItemToCustomerService(rental) {
try {
// Get owner and renter details
const owner = await User.findByPk(rental.ownerId);
const renter = await User.findByPk(rental.renterId);
if (!owner || !renter) {
console.error("Owner or renter not found for lost item notification");
return;
}
// Format dates
const reportedAt = new Date(rental.itemLostReportedAt).toLocaleString();
const scheduledReturnDate = new Date(rental.endDateTime).toLocaleString();
// Send email to customer service
await this.sendTemplateEmail(
process.env.CUSTOMER_SUPPORT_EMAIL,
"Lost Item Claim Filed - Action Required",
"lostItemCS",
{
rentalId: rental.id,
itemName: rental.item.name,
ownerName: `${owner.firstName} ${owner.lastName}`,
ownerEmail: owner.email,
renterName: `${renter.firstName} ${renter.lastName}`,
renterEmail: renter.email,
reportedAt,
scheduledReturnDate,
replacementCost: parseFloat(rental.item.replacementCost).toFixed(2),
}
);
console.log(
`Lost item notification sent to customer service for rental ${rental.id}`
);
} catch (error) {
console.error(
"Failed to send lost item notification to customer service:",
error
);
}
}
async sendRentalConfirmationEmails(rental) {
try {
// Get owner and renter emails
const owner = await User.findByPk(rental.ownerId, {
attributes: ["email"],
});
const renter = await User.findByPk(rental.renterId, {
attributes: ["email"],
});
// Create notification data for owner
const ownerNotification = {
type: "rental_confirmed",
title: "Rental Confirmed",
message: `Your "${rental.item.name}" has been confirmed for rental.`,
rentalId: rental.id,
userId: rental.ownerId,
metadata: { rentalStart: rental.startDateTime },
};
// Create notification data for renter
const renterNotification = {
type: "rental_confirmed",
title: "Rental Confirmed",
message: `Your rental of "${rental.item.name}" has been confirmed.`,
rentalId: rental.id,
userId: rental.renterId,
metadata: { rentalStart: rental.startDateTime },
};
// Send email to owner
if (owner?.email) {
await this.sendRentalConfirmation(
owner.email,
ownerNotification,
rental
);
console.log(`Rental confirmation email sent to owner: ${owner.email}`);
}
// Send email to renter
if (renter?.email) {
await this.sendRentalConfirmation(
renter.email,
renterNotification,
rental
);
console.log(
`Rental confirmation email sent to renter: ${renter.email}`
);
}
} catch (error) {
console.error("Error sending rental confirmation emails:", error);
}
}
}
module.exports = new EmailService();

View File

@@ -0,0 +1,113 @@
const { Rental, Item } = require("../models");
const emailService = require("./emailService");
class LateReturnService {
/**
* Calculate late fees based on actual return time vs scheduled end time
* @param {Object} rental - Rental instance with populated item data
* @param {Date} actualReturnDateTime - When the item was actually returned
* @returns {Object} - { lateHours, lateFee, isLate }
*/
static calculateLateFee(rental, actualReturnDateTime) {
const scheduledEnd = new Date(rental.endDateTime);
const actualReturn = new Date(actualReturnDateTime);
// Calculate hours late
const hoursLate = (actualReturn - scheduledEnd) / (1000 * 60 * 60);
if (hoursLate <= 0) {
return {
lateHours: 0,
lateFee: 0.0,
isLate: false,
};
}
let lateFee = 0;
let pricingType = "daily";
// Check if item has hourly or daily pricing
if (rental.item?.pricePerHour && rental.item.pricePerHour > 0) {
// Hourly pricing - charge per hour late
lateFee = hoursLate * parseFloat(rental.item.pricePerHour);
pricingType = "hourly";
} else if (rental.item?.pricePerDay && rental.item.pricePerDay > 0) {
// Daily pricing - charge per day late (rounded up)
const billableDays = Math.ceil(hoursLate / 24);
lateFee = billableDays * parseFloat(rental.item.pricePerDay);
pricingType = "daily";
} else {
// Free borrows: determine pricing type based on rental duration
const rentalStart = new Date(rental.startDateTime);
const rentalEnd = new Date(rental.endDateTime);
const rentalDurationHours = (rentalEnd - rentalStart) / (1000 * 60 * 60);
if (rentalDurationHours <= 24) {
// Hourly rental - charge $10 per hour late
lateFee = hoursLate * 10.0;
pricingType = "hourly";
} else {
// Daily rental - charge $10 per day late
const billableDays = Math.ceil(hoursLate / 24);
lateFee = billableDays * 10.0;
pricingType = "daily";
}
}
return {
lateHours: hoursLate,
lateFee: parseFloat(lateFee.toFixed(2)),
isLate: true,
pricingType,
};
}
/**
* Process late return and update rental with fees
* @param {string} rentalId - Rental ID
* @param {Date} actualReturnDateTime - When item was returned
* @param {string} notes - Optional notes about the return
* @returns {Object} - Updated rental with late fee information
*/
static async processLateReturn(rentalId, actualReturnDateTime, notes = null) {
const rental = await Rental.findByPk(rentalId, {
include: [{ model: Item, as: "item" }],
});
if (!rental) {
throw new Error("Rental not found");
}
if (rental.status !== "active") {
throw new Error("Can only process late returns for active rentals");
}
const lateCalculation = this.calculateLateFee(rental, actualReturnDateTime);
const updates = {
actualReturnDateTime: new Date(actualReturnDateTime),
status: lateCalculation.isLate ? "returned_late" : "completed",
};
if (notes) {
updates.notes = notes;
}
const updatedRental = await rental.update(updates);
// Send notification to customer service if late return detected
if (lateCalculation.isLate && lateCalculation.lateFee > 0) {
await emailService.sendLateReturnToCustomerService(
updatedRental,
lateCalculation
);
}
return {
rental: updatedRental,
lateCalculation,
};
}
}
module.exports = LateReturnService;

View File

@@ -162,6 +162,9 @@ class StripeService {
mode: 'setup', mode: 'setup',
ui_mode: 'embedded', ui_mode: 'embedded',
redirect_on_completion: 'never', redirect_on_completion: 'never',
setup_intent_data: {
usage: 'off_session'
},
metadata: { metadata: {
type: 'payment_method_setup', type: 'payment_method_setup',
...metadata ...metadata

View File

@@ -0,0 +1,241 @@
<!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>{{title}}</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: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #1565c0;
}
.info-box .icon {
font-size: 24px;
margin-bottom: 10px;
}
/* Alert box */
.alert-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0;
color: #856404;
}
/* 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;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Your trusted rental marketplace</div>
</div>
<div class="content">
<h1>📸 {{title}}</h1>
<p>{{message}}</p>
<div class="info-box">
<div class="icon">📦</div>
<p><strong>Rental Item:</strong> {{itemName}}</p>
<p><strong>Deadline:</strong> {{deadline}}</p>
</div>
<p>Taking condition photos helps protect both renters and owners by providing clear documentation of the item's state. This is an important step in the rental process.</p>
<div class="alert-box">
<p><strong>Important:</strong> Please complete this condition check as soon as possible. Missing this deadline may affect dispute resolution if issues arise.</p>
</div>
<a href="#" class="button">Complete Condition Check</a>
<h2>What to photograph:</h2>
<ul>
<li>Overall view of the item</li>
<li>Any existing damage or wear</li>
<li>Serial numbers or identifying marks</li>
<li>Accessories or additional components</li>
</ul>
<p>If you have any questions about the condition check process, please don't hesitate to contact our support team.</p>
</div>
<div class="footer">
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>You received this email because you have an active rental on RentAll.</p>
<p>If you have any questions, please <a href="mailto:support@rentall.com">contact our support team</a>.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,61 @@
<h1>Damage Report Filed - Action Required</h1>
<p>Hello Customer Service Team,</p>
<p>A damage report has been filed by an item owner and requires review and processing:</p>
<div class="info-box">
<p><strong>Rental ID:</strong> {{rentalId}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Owner:</strong> {{ownerName}} ({{ownerEmail}})</p>
<p><strong>Renter:</strong> {{renterName}} ({{renterEmail}})</p>
</div>
<h2>Damage Details</h2>
<div class="alert-box">
<p><strong>Description:</strong></p>
<p style="background-color: #f8f9fa; padding: 10px; border-left: 3px solid #ffc107; margin: 10px 0;">{{damageDescription}}</p>
<p><strong>Can item be fixed?</strong> {{canBeFixed}}</p>
{{#if repairCost}}
<p><strong>Repair Cost:</strong> ${{repairCost}}</p>
{{/if}}
<p><strong>Needs replacement?</strong> {{needsReplacement}}</p>
{{#if replacementCost}}
<p><strong>Replacement Cost:</strong> ${{replacementCost}}</p>
{{/if}}
<p><strong>Proof of Ownership Provided:</strong> {{hasProofOfOwnership}}</p>
</div>
<h2>Fee Summary</h2>
<div class="info-box">
<p><strong>{{feeTypeDescription}}:</strong> ${{damageFee}}</p>
{{#if lateFee}}
<p><strong>Late Return Fee:</strong> ${{lateFee}}</p>
{{/if}}
<p style="font-size: 1.1em; border-top: 2px solid #dee2e6; padding-top: 10px; margin-top: 10px;"><strong>Total Additional Fees:</strong> ${{totalFees}}</p>
</div>
<h2>Next Steps</h2>
<p>Please follow this process:</p>
<ol style="color: #495057; margin: 20px 0; padding-left: 20px;">
<li>Review the damage description and supporting documentation (photos, proof of ownership)</li>
<li>Send an email to the renter ({{renterEmail}}) with the damage claim details</li>
<li>Include the calculated damage fee amount and breakdown</li>
<li>Request the renter's response and provide 48 hours to reply</li>
<li>If the renter agrees or does not respond within 48 hours, manually charge the damage fee through the Stripe dashboard</li>
<li>If the renter disputes, open a formal dispute case and review evidence from both parties</li>
<li>Consider requesting additional documentation if needed (repair receipts, replacement invoices)</li>
</ol>
<div class="info-box">
<p><strong>Note:</strong> The damage fees have NOT been automatically charged. Manual review and processing is required.</p>
</div>
<p>Thank you for your attention to this matter.</p>

View File

@@ -0,0 +1,39 @@
<h1>Late Return Detected - Action Required</h1>
<p>Hello Customer Service Team,</p>
<p>A late return has been reported and requires manual processing:</p>
<div class="info-box">
<p><strong>Rental ID:</strong> {{rentalId}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Owner:</strong> {{ownerName}} ({{ownerEmail}})</p>
<p><strong>Renter:</strong> {{renterName}} ({{renterEmail}})</p>
</div>
<h2>Return Details</h2>
<div class="alert-box">
<p><strong>Scheduled End:</strong> {{scheduledEnd}}</p>
<p><strong>Actual Return:</strong> {{actualReturn}}</p>
<p><strong>Hours Late:</strong> {{hoursLate}}</p>
<p><strong>Calculated Late Fee:</strong> ${{lateFee}}</p>
</div>
<h2>Next Steps</h2>
<p>Please follow this process:</p>
<ol style="color: #495057; margin: 20px 0; padding-left: 20px;">
<li>Send an email to the renter ({{renterEmail}}) confirming the late return details</li>
<li>Include the calculated late fee amount and reason for the charge</li>
<li>Provide the renter with 48 hours to respond</li>
<li>If the renter agrees or does not respond within 48 hours, manually charge the late fee through the Stripe dashboard</li>
<li>If the renter disputes, review the case and take appropriate action</li>
</ol>
<div class="info-box">
<p><strong>Note:</strong> The late fee has NOT been automatically charged. Manual processing is required.</p>
</div>
<p>Thank you for your attention to this matter.</p>

View File

@@ -0,0 +1,40 @@
<h1>Lost Item Claim Filed - Action Required</h1>
<p>Hello Customer Service Team,</p>
<p>A lost item claim has been filed by an item owner and requires review and processing:</p>
<div class="info-box">
<p><strong>Rental ID:</strong> {{rentalId}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Owner:</strong> {{ownerName}} ({{ownerEmail}})</p>
<p><strong>Renter:</strong> {{renterName}} ({{renterEmail}})</p>
</div>
<h2>Lost Item Details</h2>
<div class="alert-box">
<p><strong>Reported Lost At:</strong> {{reportedAt}}</p>
<p><strong>Scheduled Return Date:</strong> {{scheduledReturnDate}}</p>
<p><strong>Replacement Cost:</strong> ${{replacementCost}}</p>
</div>
<h2>Next Steps</h2>
<p>Please follow this process:</p>
<ol style="color: #495057; margin: 20px 0; padding-left: 20px;">
<li>Review the lost item claim and rental history</li>
<li>Send an email to the renter ({{renterEmail}}) with the lost item claim details</li>
<li>Include the replacement cost amount: ${{replacementCost}}</li>
<li>Request the renter's response and provide 48 hours to reply</li>
<li>If the renter agrees or does not respond within 48 hours, manually charge the replacement cost through the Stripe dashboard</li>
<li>If the renter disputes and claims they returned the item, open a formal dispute case and review evidence from both parties</li>
<li>Request proof of return from the renter if they dispute the claim</li>
</ol>
<div class="info-box">
<p><strong>Note:</strong> The replacement fee has NOT been automatically charged. Manual review and processing is required.</p>
</div>
<p>Thank you for your attention to this matter.</p>

View File

@@ -0,0 +1,281 @@
<!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>{{title}}</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, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
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, #28a745 0%, #20c997 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(40, 167, 69, 0.4);
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0;
color: #155724;
}
.success-box .icon {
font-size: 24px;
margin-bottom: 10px;
}
/* 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 Confirmed</div>
</div>
<div class="content">
<h1>✅ {{title}}</h1>
<p>{{message}}</p>
<div class="success-box">
<div class="icon">🎉</div>
<p><strong>Great news!</strong> Your rental has been successfully confirmed and you're all set.</p>
</div>
<h2>Rental Details</h2>
<table class="info-table">
<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>
</table>
<a href="#" class="button">View Rental Details</a>
<h2>What's next?</h2>
<ul>
<li><strong>Before pickup:</strong> You'll receive a reminder to take condition photos</li>
<li><strong>During rental:</strong> Enjoy your rental and treat it with care</li>
<li><strong>At return:</strong> Take photos and return the item as agreed</li>
<li><strong>After return:</strong> Leave a review to help the community</li>
</ul>
<p><strong>Important reminders:</strong></p>
<ul>
<li>Take condition photos at pickup and return</li>
<li>Follow any specific care instructions provided</li>
<li>Return the item on time and in good condition</li>
<li>Contact the owner if you have any questions</li>
</ul>
<p>Thank you for choosing RentAll! We hope you have a great rental experience.</p>
</div>
<div class="footer">
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>You received this email because you have a confirmed rental on RentAll.</p>
<p>If you have any questions, please <a href="mailto:support@rentall.com">contact our support team</a>.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,262 @@
import React, { useState } from "react";
import { conditionCheckAPI } from "../services/api";
interface ConditionCheckModalProps {
show: boolean;
onHide: () => void;
rentalId: string;
checkType: string;
itemName: string;
onSuccess: () => void;
}
const ConditionCheckModal: React.FC<ConditionCheckModalProps> = ({
show,
onHide,
rentalId,
checkType,
itemName,
onSuccess,
}) => {
const [photos, setPhotos] = useState<File[]>([]);
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const getCheckTypeInfo = () => {
const types = {
pre_rental_owner: {
title: "Pre-Rental Condition",
description:
"Document the current condition of your item before the rental begins. Take clear photos showing all sides and any existing wear or damage",
},
rental_start_renter: {
title: "Rental Start Condition",
description:
"Document the condition of the item when you receive it. Take photos of any damage or issues you notice upon receiving the item",
},
rental_end_renter: {
title: "Rental End Condition",
description:
"Document the condition of the item before returning it. Take photos showing the item's condition before you return it",
},
post_rental_owner: {
title: "Post-Rental Condition",
description:
"Document the condition of your item after it's been returned. Take photos of the returned item including any damage or issues",
},
};
return (
types[checkType as keyof typeof types] || {
title: "Condition Check",
description: "Document the item's condition",
}
);
};
const typeInfo = getCheckTypeInfo();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files);
if (selectedFiles.length + photos.length > 20) {
setError("Maximum 20 photos allowed");
return;
}
setPhotos((prev) => [...prev, ...selectedFiles]);
setError(null);
}
};
const removePhoto = (index: number) => {
setPhotos((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
if (photos.length === 0) {
setError("Please upload at least one photo");
return;
}
try {
setSubmitting(true);
setError(null);
const formData = new FormData();
formData.append("checkType", checkType);
if (notes.trim()) {
formData.append("notes", notes.trim());
}
photos.forEach((photo, index) => {
formData.append("photos", photo);
});
await conditionCheckAPI.submitConditionCheck(rentalId, formData);
// Reset form
setPhotos([]);
setNotes("");
onSuccess();
onHide();
} catch (error: any) {
setError(
error.response?.data?.error || "Failed to submit condition check"
);
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
setPhotos([]);
setNotes("");
setError(null);
onHide();
};
if (!show) return null;
return (
<div
className="modal show d-block"
tabIndex={-1}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="bi bi-camera me-2" />
{typeInfo.title}
</h5>
<button
type="button"
className="btn-close"
onClick={handleClose}
disabled={submitting}
/>
</div>
<div className="modal-body">
<div className="mb-4">
<h6 className="text-center text-dark">{itemName}</h6>
<p className="text-muted mb-2">{typeInfo.description}</p>
</div>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<div className="mb-3">
<label className="form-label">
Photos <span className="text-danger">*</span>
<small className="text-muted ms-2">(Maximum 20 photos)</small>
</label>
<input
type="file"
className="form-control"
multiple
accept="image/*"
onChange={handleFileChange}
disabled={submitting}
/>
</div>
{photos.length > 0 && (
<div className="mb-3">
<label className="form-label">
Selected Photos ({photos.length})
</label>
<div className="row">
{photos.map((photo, index) => (
<div key={index} className="col-md-3 mb-2">
<div className="position-relative">
<img
src={URL.createObjectURL(photo)}
alt={`Photo ${index + 1}`}
className="img-fluid rounded"
style={{
height: "100px",
objectFit: "cover",
width: "100%",
}}
/>
<button
type="button"
className="btn btn-danger btn-sm position-absolute top-0 end-0"
onClick={() => removePhoto(index)}
disabled={submitting}
style={{ transform: "translate(50%, -50%)" }}
>
<i className="bi bi-x" />
</button>
</div>
<small className="text-muted d-block text-center mt-1">
{photo.name.length > 15
? `${photo.name.substring(0, 15)}...`
: photo.name}
</small>
</div>
))}
</div>
</div>
)}
<div className="mb-3">
<label className="form-label">Notes (Optional)</label>
<textarea
className="form-control"
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any additional notes about the item's condition"
maxLength={500}
disabled={submitting}
/>
<div className="form-text">{notes.length}/500 characters</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={handleClose}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleSubmit}
disabled={submitting || photos.length === 0}
>
{submitting ? (
<>
<div
className="spinner-border spinner-border-sm me-2"
role="status"
>
<span className="visually-hidden">Loading...</span>
</div>
Submitting...
</>
) : (
<>
<i className="bi bi-check-lg me-2" />
Submit Condition Check
</>
)}
</button>
</div>
</div>
</div>
</div>
);
};
export default ConditionCheckModal;

View File

@@ -0,0 +1,954 @@
import React, { useState, useEffect, useCallback } from "react";
import { rentalAPI, conditionCheckAPI } from "../services/api";
import { Rental } from "../types";
interface ReturnStatusModalProps {
show: boolean;
onHide: () => void;
rental: Rental;
onReturnMarked: (updatedRental: Rental) => void;
onSubmitSuccess?: (updatedRental: Rental) => void;
}
const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
show,
onHide,
rental,
onReturnMarked,
onSubmitSuccess,
}) => {
const [statusOptions, setStatusOptions] = useState({
returned: false,
returned_late: false,
damaged: false,
lost: false,
});
const [actualReturnDateTime, setActualReturnDateTime] = useState("");
const [notes, setNotes] = useState("");
const [conditionNotes, setConditionNotes] = useState("");
const [photos, setPhotos] = useState<File[]>([]);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lateFeeCalculation, setLateFeeCalculation] = useState<{
lateHours: number;
lateFee: number;
isLate: boolean;
pricingType?: "hourly" | "daily";
billableDays?: number;
} | null>(null);
// Damage assessment fields
const [canBeFixed, setCanBeFixed] = useState<boolean | null>(null);
const [repairCost, setRepairCost] = useState("");
const [needsReplacement, setNeedsReplacement] = useState<boolean | null>(
null
);
const [replacementCost, setReplacementCost] = useState("");
const [proofOfOwnership, setProofOfOwnership] = useState<File[]>([]);
// Initialize form when modal opens
useEffect(() => {
if (show && rental) {
setStatusOptions({
returned: false,
returned_late: false,
damaged: false,
lost: false,
});
setActualReturnDateTime("");
setNotes("");
setConditionNotes("");
setPhotos([]);
setError(null);
setLateFeeCalculation(null);
setCanBeFixed(null);
setRepairCost("");
setNeedsReplacement(null);
setReplacementCost("");
setProofOfOwnership([]);
}
}, [show, rental]);
const formatCurrency = (amount: number | string | undefined) => {
const numAmount = Number(amount) || 0;
return `$${numAmount.toFixed(2)}`;
};
const formatDateTime = (date: Date) => {
// Format for datetime-local input in local timezone
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
// Calculate late fee when actual return date/time changes
useEffect(() => {
const fetchLateFeeCalculation = async () => {
if (statusOptions.returned_late && actualReturnDateTime && rental) {
try {
const response = await rentalAPI.getLateFeePreview(
rental.id,
actualReturnDateTime
);
setLateFeeCalculation(response.data);
} catch (error) {
console.error("Error fetching late fee calculation:", error);
setLateFeeCalculation(null);
}
} else {
setLateFeeCalculation(null);
}
};
fetchLateFeeCalculation();
}, [actualReturnDateTime, statusOptions.returned_late, rental]);
const handleStatusChange = (
statusType: "returned" | "returned_late" | "damaged" | "lost",
checked: boolean
) => {
setStatusOptions((prev) => {
const newOptions = { ...prev };
// Apply the change
newOptions[statusType] = checked;
// Apply mutual exclusion logic
if (statusType === "returned" && checked) {
newOptions.returned_late = false;
newOptions.lost = false;
}
if (statusType === "returned_late" && checked) {
newOptions.returned = false;
newOptions.lost = false;
// Set default return time for late returns
if (!actualReturnDateTime) {
setActualReturnDateTime(formatDateTime(new Date()));
}
}
if (statusType === "damaged" && checked) {
newOptions.lost = false;
}
if (statusType === "lost" && checked) {
// If item is lost, uncheck all other options
newOptions.returned = false;
newOptions.returned_late = false;
newOptions.damaged = false;
}
return newOptions;
});
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files);
if (selectedFiles.length + photos.length > 20) {
setError("Maximum 20 photos allowed");
return;
}
setPhotos((prev) => [...prev, ...selectedFiles]);
setError(null);
}
};
const removePhoto = (index: number) => {
setPhotos((prev) => prev.filter((_, i) => i !== index));
};
const handleProofOfOwnershipChange = (
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files);
if (selectedFiles.length + proofOfOwnership.length > 5) {
setError("Maximum 5 proof of ownership files allowed");
return;
}
setProofOfOwnership((prev) => [...prev, ...selectedFiles]);
setError(null);
}
};
const removeProofOfOwnership = (index: number) => {
setProofOfOwnership((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
if (!rental) return;
try {
setProcessing(true);
setError(null);
// Check if at least one option is selected
const hasSelection = Object.values(statusOptions).some(
(option) => option
);
if (!hasSelection) {
setError("Please select at least one return status option");
return;
}
// Validate required fields
if (statusOptions.returned_late && !actualReturnDateTime) {
setError("Please provide the actual return date and time");
setProcessing(false);
return;
}
// Validate damage assessment fields if damaged is selected
if (statusOptions.damaged) {
if (!conditionNotes.trim() || conditionNotes.trim().length < 10) {
setError(
"Please provide a detailed damage description (at least 10 characters)"
);
setProcessing(false);
return;
}
if (canBeFixed === null) {
setError("Please specify if the item can be fixed");
setProcessing(false);
return;
}
if (canBeFixed && (!repairCost || parseFloat(repairCost) <= 0)) {
setError("Please provide a repair cost estimate");
setProcessing(false);
return;
}
if (needsReplacement === null) {
setError("Please specify if the item needs replacement");
setProcessing(false);
return;
}
if (
needsReplacement &&
(!replacementCost || parseFloat(replacementCost) <= 0)
) {
setError("Please provide a replacement cost");
setProcessing(false);
return;
}
}
let response;
// If damaged is selected, use reportDamage API
if (statusOptions.damaged) {
const damageFormData = new FormData();
damageFormData.append("description", conditionNotes.trim());
damageFormData.append("canBeFixed", canBeFixed?.toString() || "false");
damageFormData.append(
"needsReplacement",
needsReplacement?.toString() || "false"
);
if (canBeFixed) {
damageFormData.append("repairCost", repairCost);
}
if (needsReplacement) {
damageFormData.append("replacementCost", replacementCost);
}
if (actualReturnDateTime) {
damageFormData.append("actualReturnDateTime", actualReturnDateTime);
}
// Add condition photos
photos.forEach((photo) => {
damageFormData.append("photos", photo);
});
// Add proof of ownership files
proofOfOwnership.forEach((file) => {
damageFormData.append("proofOfOwnership", file);
});
response = await rentalAPI.reportDamage(rental.id, damageFormData);
} else {
// Non-damaged returns: use existing flow
// Submit post-rental condition check if photos are provided
if (photos.length > 0) {
const conditionCheckFormData = new FormData();
conditionCheckFormData.append("checkType", "post_rental_owner");
if (conditionNotes.trim()) {
conditionCheckFormData.append("notes", conditionNotes.trim());
}
photos.forEach((photo) => {
conditionCheckFormData.append("photos", photo);
});
await conditionCheckAPI.submitConditionCheck(
rental.id,
conditionCheckFormData
);
}
// Determine primary status for API call
let primaryStatus = "returned";
if (statusOptions.returned_late) {
primaryStatus = "returned_late";
} else if (statusOptions.lost) {
primaryStatus = "lost";
}
const data: any = {
status: primaryStatus,
statusOptions, // Send all selected options
notes: notes.trim() || undefined,
};
if (statusOptions.returned_late) {
data.actualReturnDateTime = actualReturnDateTime;
}
response = await rentalAPI.markReturn(rental.id, data);
}
// Call success callback and close modal immediately
if (onSubmitSuccess) {
onSubmitSuccess(response.data.rental);
}
onHide();
} catch (error: any) {
setError(error.response?.data?.error || "Failed to mark return status");
setProcessing(false);
}
};
const handleClose = () => {
// Reset all states
setStatusOptions({
returned: false,
returned_late: false,
damaged: false,
lost: false,
});
setActualReturnDateTime("");
setNotes("");
setConditionNotes("");
setPhotos([]);
setError(null);
setLateFeeCalculation(null);
setCanBeFixed(null);
setRepairCost("");
setNeedsReplacement(null);
setReplacementCost("");
setProofOfOwnership([]);
onHide();
};
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
handleClose();
}
},
[handleClose]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
},
[handleClose]
);
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 (
<div
className="modal d-block"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onClick={handleBackdropClick}
>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Item Return</h5>
<button
type="button"
className="btn-close"
onClick={handleClose}
aria-label="Close"
/>
</div>
<div className="modal-body">
{error && (
<div className="alert alert-danger mb-3" role="alert">
{error}
</div>
)}
<div className="mb-4">
<h5>Rental Information</h5>
<div className="bg-light p-3 rounded">
<div className="row">
<div className="col-md-6">
<p className="mb-2">
<strong>Item:</strong> {rental.item?.name}
</p>
<p className="mb-2">
<strong>Renter:</strong> {rental.renter?.firstName}{" "}
{rental.renter?.lastName}
</p>
</div>
<div className="col-md-6">
<p className="mb-2">
<strong>Scheduled End:</strong>
<br />
{new Date(rental.endDateTime).toLocaleString()}
</p>
</div>
</div>
</div>
</div>
<form>
<div className="mb-4">
<label className="form-label">
<strong>Return Status</strong>
</label>
<div className="row g-3">
<div className="col-md-6">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="returned"
checked={statusOptions.returned}
onChange={(e) =>
handleStatusChange("returned", e.target.checked)
}
/>
<label className="form-check-label" htmlFor="returned">
<i className="bi bi-check-circle me-2 text-success" />
Item Returned On Time
</label>
</div>
</div>
<div className="col-md-6">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="returned_late"
checked={statusOptions.returned_late}
onChange={(e) =>
handleStatusChange("returned_late", e.target.checked)
}
/>
<label
className="form-check-label"
htmlFor="returned_late"
>
<i className="bi bi-clock me-2 text-warning" />
Item Returned Late
</label>
</div>
</div>
<div className="col-md-6">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="damaged"
checked={statusOptions.damaged}
onChange={(e) =>
handleStatusChange("damaged", e.target.checked)
}
/>
<label className="form-check-label" htmlFor="damaged">
<i className="bi bi-exclamation-triangle me-2 text-warning" />
Item Damaged
</label>
</div>
</div>
<div className="col-md-6">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="lost"
checked={statusOptions.lost}
onChange={(e) =>
handleStatusChange("lost", e.target.checked)
}
/>
<label className="form-check-label" htmlFor="lost">
<i className="bi bi-x-circle me-2 text-danger" />
Item Lost
</label>
</div>
</div>
</div>
</div>
{statusOptions.returned_late && (
<>
<div className="mb-4">
<label
className="form-label"
htmlFor="actualReturnDateTime"
>
<strong>Actual Return Date & Time</strong>
</label>
<div className="form-text mb-2">
When was the item actually returned to you?
</div>
<input
type="datetime-local"
id="actualReturnDateTime"
className="form-control"
value={actualReturnDateTime}
onChange={(e) => setActualReturnDateTime(e.target.value)}
max={formatDateTime(new Date())}
required
/>
</div>
{actualReturnDateTime && lateFeeCalculation && (
<div className="alert alert-warning mb-4">
<h6>
<i className="bi bi-exclamation-circle me-2" />
Late Fee Calculation
</h6>
{lateFeeCalculation.isLate ? (
<>
<div className="mb-2">
<strong>Time Overdue:</strong>{" "}
{lateFeeCalculation.lateHours < 24
? `${lateFeeCalculation.lateHours.toFixed(
1
)} hours`
: `${Math.floor(
lateFeeCalculation.lateHours / 24
)} days ${Math.floor(
lateFeeCalculation.lateHours % 24
)} hours`}
</div>
{lateFeeCalculation.pricingType === "hourly" && (
<div className="mb-2">
<strong>Calculation:</strong>{" "}
{lateFeeCalculation.lateHours.toFixed(1)} hours ×{" "}
{formatCurrency(rental.item?.pricePerHour)} per
hour
</div>
)}
{lateFeeCalculation.pricingType === "daily" &&
lateFeeCalculation.billableDays && (
<div className="mb-2">
<strong>Calculation:</strong>{" "}
{lateFeeCalculation.billableDays} billable day
{lateFeeCalculation.billableDays > 1
? "s"
: ""}{" "}
× {formatCurrency(rental.item?.pricePerDay)} per
day
</div>
)}
<div className="mb-2">
<strong>Estimated Late Fee:</strong>{" "}
{formatCurrency(lateFeeCalculation.lateFee)}
</div>
<small className="text-muted">
Customer service will contact the renter to confirm
the late return. If the renter agrees or does not
respond within 48 hours, the late fee will be
charged manually.
</small>
</>
) : (
<p className="mb-0 text-success">
<i className="bi bi-check-circle me-2" />
Item was returned on time - no late fee.
</p>
)}
</div>
)}
</>
)}
{!statusOptions.lost && (
<>
<div className="mb-4">
<label className="form-label">
<strong>Post-Rental Condition</strong>{" "}
<small className="text-muted ms-2">(Optional)</small>
</label>
<div className="form-text mb-2">
Document the condition of the item
</div>
<input
type="file"
className="form-control"
multiple
accept="image/*"
onChange={handleFileChange}
disabled={processing}
/>
</div>
{photos.length > 0 && (
<div className="mb-4">
<label className="form-label">
Selected Photos ({photos.length})
</label>
<div className="row">
{photos.map((photo, index) => (
<div key={index} className="col-md-3 mb-2">
<div className="position-relative">
<img
src={URL.createObjectURL(photo)}
alt={`Photo ${index + 1}`}
className="img-fluid rounded"
style={{
height: "100px",
objectFit: "cover",
width: "100%",
}}
/>
<button
type="button"
className="btn btn-danger btn-sm position-absolute top-0 end-0"
onClick={() => removePhoto(index)}
disabled={processing}
style={{ transform: "translate(50%, -50%)" }}
>
<i className="bi bi-x" />
</button>
</div>
<small className="text-muted d-block text-center mt-1">
{photo.name.length > 15
? `${photo.name.substring(0, 15)}...`
: photo.name}
</small>
</div>
))}
</div>
</div>
)}
<div className="mb-4">
<label className="form-label" htmlFor="conditionNotes">
<strong>
{statusOptions.damaged
? "Damage Description"
: "Condition Notes"}
</strong>
{statusOptions.damaged ? (
<span className="text-danger ms-1">*</span>
) : (
<span className="text-muted ms-1">(Optional)</span>
)}
</label>
<textarea
id="conditionNotes"
className="form-control"
rows={3}
value={conditionNotes}
onChange={(e) => setConditionNotes(e.target.value)}
placeholder={
statusOptions.damaged
? "Describe the damage in detail..."
: "Add any notes about the item's condition..."
}
maxLength={500}
disabled={processing}
required={statusOptions.damaged}
/>
<div className="form-text">
{conditionNotes.length}/500 characters
{statusOptions.damaged && " (minimum 10 required)"}
</div>
</div>
</>
)}
{statusOptions.lost && (
<div className="mb-4">
<div className="alert alert-danger">
<h6>
<i className="bi bi-exclamation-triangle me-2" />
Lost Item Claim
</h6>
<div className="mb-3">
<strong>Replacement Cost:</strong>{" "}
{formatCurrency(rental.item?.replacementCost)}
</div>
<p className="mb-2">
Customer service will review this lost item claim and
contact both you and the renter.
</p>
</div>
</div>
)}
{statusOptions.damaged && (
<div className="mb-4">
<div className="alert alert-warning">
<div className="mb-3">
<label className="form-label">
<strong>Can item be fixed?</strong>
</label>
<div>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="canBeFixed"
id="canBeFixedYes"
checked={canBeFixed === true}
onChange={() => setCanBeFixed(true)}
disabled={processing}
/>
<label
className="form-check-label"
htmlFor="canBeFixedYes"
>
Yes
</label>
</div>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="canBeFixed"
id="canBeFixedNo"
checked={canBeFixed === false}
onChange={() => setCanBeFixed(false)}
disabled={processing}
/>
<label
className="form-check-label"
htmlFor="canBeFixedNo"
>
No
</label>
</div>
</div>
</div>
{canBeFixed === true && (
<div className="mb-3">
<label className="form-label" htmlFor="repairCost">
<strong>Repair Cost</strong>{" "}
<span className="text-danger">*</span>
</label>
<div className="input-group">
<span className="input-group-text">$</span>
<input
type="number"
id="repairCost"
className="form-control"
value={repairCost}
onChange={(e) => setRepairCost(e.target.value)}
placeholder="0.00"
min="0"
step="0.01"
disabled={processing}
required
/>
</div>
</div>
)}
<div className="mb-3">
<label className="form-label">
<strong>Does item need replacement?</strong>
</label>
<div>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="needsReplacement"
id="needsReplacementYes"
checked={needsReplacement === true}
onChange={() => setNeedsReplacement(true)}
disabled={processing}
/>
<label
className="form-check-label"
htmlFor="needsReplacementYes"
>
Yes
</label>
</div>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="needsReplacement"
id="needsReplacementNo"
checked={needsReplacement === false}
onChange={() => setNeedsReplacement(false)}
disabled={processing}
/>
<label
className="form-check-label"
htmlFor="needsReplacementNo"
>
No
</label>
</div>
</div>
</div>
{needsReplacement === true && (
<div className="mb-3">
<label className="form-label" htmlFor="replacementCost">
<strong>Replacement Cost</strong>{" "}
<span className="text-danger">*</span>
</label>
<div className="input-group">
<span className="input-group-text">$</span>
<input
type="number"
id="replacementCost"
className="form-control"
value={replacementCost}
onChange={(e) => setReplacementCost(e.target.value)}
placeholder="0.00"
min="0"
step="0.01"
disabled={processing}
required
/>
</div>
</div>
)}
<div className="mb-3">
<label className="form-label">
<strong>Proof of Ownership</strong>{" "}
<small className="text-muted">(Optional)</small>
</label>
<div className="form-text mb-2">
Upload receipts, invoices, or other documents showing
proof of ownership
</div>
<input
type="file"
className="form-control"
multiple
accept="image/*,.pdf"
onChange={handleProofOfOwnershipChange}
disabled={processing}
/>
</div>
{proofOfOwnership.length > 0 && (
<div className="mb-3">
<label className="form-label">
Proof of Ownership Files ({proofOfOwnership.length})
</label>
<ul className="list-group">
{proofOfOwnership.map((file, index) => (
<li
key={index}
className="list-group-item d-flex justify-content-between align-items-center"
>
<span>
<i className="bi bi-file-earmark me-2" />
{file.name.length > 40
? `${file.name.substring(0, 40)}...`
: file.name}
</span>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => removeProofOfOwnership(index)}
disabled={processing}
>
<i className="bi bi-x" />
</button>
</li>
))}
</ul>
</div>
)}
<small className="text-muted">
<i className="bi bi-info-circle me-1" />
Customer service will review this damage claim and contact
you if additional information is needed.
</small>
</div>
</div>
)}
</form>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={handleClose}
disabled={processing}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleSubmit}
disabled={
processing ||
(statusOptions.returned_late && !actualReturnDateTime)
}
>
{processing ? (
<>
<div
className="spinner-border spinner-border-sm me-2"
role="status"
>
<span className="visually-hidden">Loading...</span>
</div>
Processing...
</>
) : (
"Submit"
)}
</button>
</div>
</div>
</div>
</div>
);
};
export default ReturnStatusModal;

View File

@@ -6,7 +6,12 @@ import React, {
ReactNode, ReactNode,
} from "react"; } from "react";
import { User } from "../types"; import { User } from "../types";
import { authAPI, userAPI, fetchCSRFToken, resetCSRFToken, hasAuthIndicators } from "../services/api"; import {
authAPI,
userAPI,
fetchCSRFToken,
resetCSRFToken,
} from "../services/api";
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
@@ -39,13 +44,14 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const checkAuth = async () => { const checkAuth = async () => {
try { try {
// The axios interceptor will automatically handle token refresh if needed
const response = await userAPI.getProfile(); const response = await userAPI.getProfile();
setUser(response.data); setUser(response.data);
} catch (error: any) { } catch (error: any) {
// Only log actual errors, not "user not logged in" // If we get here, either:
if (error.response?.data?.code !== "NO_TOKEN") { // 1. User is not logged in (expected for public browsing)
console.error("Auth check failed:", error); // 2. Token refresh failed (user needs to login again)
} // In both cases, silently set user to null without logging errors
setUser(null); setUser(null);
} }
}; };
@@ -54,26 +60,11 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
// Initialize authentication // Initialize authentication
const initializeAuth = async () => { const initializeAuth = async () => {
try { try {
// Check if we have any auth indicators before making API call
if (hasAuthIndicators()) {
// Only check auth if we have some indication of being logged in
// This avoids unnecessary 401 errors in the console
await checkAuth();
} else {
// No auth indicators - skip the API call
setUser(null);
}
// Always fetch CSRF token for subsequent requests
await fetchCSRFToken(); await fetchCSRFToken();
// Check if user is already authenticated
await checkAuth();
} catch (error) { } catch (error) {
console.error("Failed to initialize auth:", error); console.error("Failed to initialize authentication:", error);
// Even on error, try to get CSRF token for non-authenticated requests
try {
await fetchCSRFToken();
} catch (csrfError) {
console.error("Failed to fetch CSRF token:", csrfError);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -3,9 +3,11 @@ import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import api from "../services/api"; import api from "../services/api";
import { Item, Rental } from "../types"; import { Item, Rental } from "../types";
import { rentalAPI } from "../services/api"; import { rentalAPI, conditionCheckAPI } from "../services/api";
import ReviewRenterModal from "../components/ReviewRenterModal"; import ReviewRenterModal from "../components/ReviewRenterModal";
import RentalCancellationModal from "../components/RentalCancellationModal"; import RentalCancellationModal from "../components/RentalCancellationModal";
import ConditionCheckModal from "../components/ConditionCheckModal";
import ReturnStatusModal from "../components/ReturnStatusModal";
const MyListings: React.FC = () => { const MyListings: React.FC = () => {
// Helper function to format time // Helper function to format time
@@ -24,8 +26,17 @@ const MyListings: React.FC = () => {
// Helper function to format date and time together // Helper function to format date and time together
const formatDateTime = (dateTimeString: string) => { const formatDateTime = (dateTimeString: string) => {
const date = new Date(dateTimeString).toLocaleDateString(); const date = new Date(dateTimeString);
return date; return date
.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.replace(",", "");
}; };
const { user } = useAuth(); const { user } = useAuth();
@@ -42,12 +53,28 @@ const MyListings: React.FC = () => {
const [rentalToCancel, setRentalToCancel] = useState<Rental | null>(null); const [rentalToCancel, setRentalToCancel] = useState<Rental | null>(null);
const [isProcessingPayment, setIsProcessingPayment] = useState<string>(""); const [isProcessingPayment, setIsProcessingPayment] = useState<string>("");
const [processingSuccess, setProcessingSuccess] = useState<string>(""); const [processingSuccess, setProcessingSuccess] = useState<string>("");
const [showConditionCheckModal, setShowConditionCheckModal] = useState(false);
const [conditionCheckData, setConditionCheckData] = useState<{
rental: Rental;
checkType: string;
} | null>(null);
const [availableChecks, setAvailableChecks] = useState<any[]>([]);
const [conditionChecks, setConditionChecks] = useState<any[]>([]);
const [showReturnStatusModal, setShowReturnStatusModal] = useState(false);
const [rentalForReturn, setRentalForReturn] = useState<Rental | null>(null);
useEffect(() => { useEffect(() => {
fetchMyListings(); fetchMyListings();
fetchOwnerRentals(); fetchOwnerRentals();
fetchAvailableChecks();
}, [user]); }, [user]);
useEffect(() => {
if (ownerRentals.length > 0) {
fetchConditionChecks();
}
}, [ownerRentals]);
const fetchMyListings = async () => { const fetchMyListings = async () => {
if (!user) return; if (!user) return;
@@ -108,6 +135,44 @@ const MyListings: React.FC = () => {
} }
}; };
const fetchAvailableChecks = async () => {
try {
const response = await conditionCheckAPI.getAvailableChecks();
const checks = Array.isArray(response.data.availableChecks)
? response.data.availableChecks
: [];
setAvailableChecks(checks);
} catch (err: any) {
console.error("Failed to fetch available checks:", err);
setAvailableChecks([]);
}
};
const fetchConditionChecks = async () => {
try {
// Fetch condition checks for all owner rentals
const allChecks: any[] = [];
for (const rental of ownerRentals) {
try {
const response = await conditionCheckAPI.getConditionChecks(
rental.id
);
const checks = Array.isArray(response.data.conditionChecks)
? response.data.conditionChecks
: [];
allChecks.push(...checks);
} catch (err) {
// Continue even if one rental fails
console.error(`Failed to fetch checks for rental ${rental.id}:`, err);
}
}
setConditionChecks(allChecks);
} catch (err: any) {
console.error("Failed to fetch condition checks:", err);
setConditionChecks([]);
}
};
// Owner functionality handlers // Owner functionality handlers
const handleAcceptRental = async (rentalId: string) => { const handleAcceptRental = async (rentalId: string) => {
try { try {
@@ -127,6 +192,7 @@ const MyListings: React.FC = () => {
} }
fetchOwnerRentals(); fetchOwnerRentals();
fetchAvailableChecks(); // Refresh available checks after rental confirmation
} catch (err: any) { } catch (err: any) {
console.error("Failed to accept rental request:", err); console.error("Failed to accept rental request:", err);
@@ -155,21 +221,27 @@ const MyListings: React.FC = () => {
} }
}; };
const handleCompleteClick = async (rental: Rental) => { const handleCompleteClick = (rental: Rental) => {
try { setRentalForReturn(rental);
await rentalAPI.markAsCompleted(rental.id); setShowReturnStatusModal(true);
};
setSelectedRentalForReview(rental); const handleReturnStatusMarked = async (updatedRental: Rental) => {
setShowReviewRenterModal(true); // Update the rental in the list
setOwnerRentals((prev) =>
prev.map((rental) =>
rental.id === updatedRental.id ? updatedRental : rental
)
);
fetchOwnerRentals(); // Close the return status modal
} catch (err: any) { setShowReturnStatusModal(false);
console.error("Error marking rental as completed:", err); setRentalForReturn(null);
alert(
"Failed to mark rental as completed: " + // Show review modal (rental is already marked as completed by return status endpoint)
(err.response?.data?.error || err.message) setSelectedRentalForReview(updatedRental);
); setShowReviewRenterModal(true);
} fetchOwnerRentals();
}; };
const handleReviewRenterSuccess = () => { const handleReviewRenterSuccess = () => {
@@ -192,6 +264,35 @@ const MyListings: React.FC = () => {
setRentalToCancel(null); setRentalToCancel(null);
}; };
const handleConditionCheck = (rental: Rental, checkType: string) => {
setConditionCheckData({ rental, checkType });
setShowConditionCheckModal(true);
};
const handleConditionCheckSuccess = () => {
fetchAvailableChecks();
fetchConditionChecks();
alert("Condition check submitted successfully!");
};
const getAvailableChecksForRental = (rentalId: string) => {
if (!Array.isArray(availableChecks)) return [];
return availableChecks.filter(
(check) =>
check.rentalId === rentalId && check.checkType === "pre_rental_owner" // Only pre-rental; post-rental is in return modal
);
};
const getCompletedChecksForRental = (rentalId: string) => {
if (!Array.isArray(conditionChecks)) return [];
return conditionChecks.filter(
(check) =>
check.rentalId === rentalId &&
(check.checkType === "pre_rental_owner" ||
check.checkType === "post_rental_owner")
);
};
// Filter owner rentals // Filter owner rentals
const allOwnerRentals = ownerRentals const allOwnerRentals = ownerRentals
.filter((r) => .filter((r) =>
@@ -331,73 +432,115 @@ const MyListings: React.FC = () => {
</div> </div>
)} )}
<div className="d-flex gap-2 mt-3"> <div className="d-flex flex-column gap-2 mt-3">
{rental.status === "pending" && ( <div className="d-flex gap-2">
<> {rental.status === "pending" && (
<button <>
className="btn btn-sm btn-success" <button
onClick={() => handleAcceptRental(rental.id)} className="btn btn-sm btn-success"
disabled={isProcessingPayment === rental.id} onClick={() => handleAcceptRental(rental.id)}
> disabled={isProcessingPayment === rental.id}
{isProcessingPayment === rental.id ? ( >
<> {isProcessingPayment === rental.id ? (
<div <>
className="spinner-border spinner-border-sm me-2" <div
role="status" className="spinner-border spinner-border-sm me-2"
> role="status"
<span className="visually-hidden"> >
Loading... <span className="visually-hidden">
</span> Loading...
</div> </span>
Processing Payment... </div>
</> Processing Payment...
) : processingSuccess === rental.id ? ( </>
<> ) : processingSuccess === rental.id ? (
<i className="bi bi-check-circle me-1"></i> <>
Payment Success! <i className="bi bi-check-circle me-1"></i>
</> Payment Success!
) : ( </>
"Accept" ) : (
)} "Accept"
</button> )}
<button </button>
className="btn btn-sm btn-danger" <button
onClick={() => handleRejectRental(rental.id)} className="btn btn-sm btn-danger"
> onClick={() => handleRejectRental(rental.id)}
Reject >
</button> Reject
<button </button>
className="btn btn-sm btn-outline-danger" <button
onClick={() => handleCancelClick(rental)} className="btn btn-sm btn-outline-danger"
> onClick={() => handleCancelClick(rental)}
Cancel >
</button> Cancel
</> </button>
)} </>
{rental.status === "confirmed" && ( )}
<> {rental.status === "confirmed" && (
<>
<button
className="btn btn-sm btn-success"
onClick={() => handleCompleteClick(rental)}
>
Complete
</button>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => handleCancelClick(rental)}
>
Cancel
</button>
</>
)}
{rental.status === "active" && (
<button <button
className="btn btn-sm btn-success" className="btn btn-sm btn-success"
onClick={() => handleCompleteClick(rental)} onClick={() => handleCompleteClick(rental)}
> >
Complete Complete
</button> </button>
<button )}
className="btn btn-sm btn-outline-danger" </div>
onClick={() => handleCancelClick(rental)}
> {/* Condition Check Status */}
Cancel {getCompletedChecksForRental(rental.id).length > 0 && (
</button> <div className="mb-2">
</> {getCompletedChecksForRental(rental.id).map(
(check) => (
<div
key={`${rental.id}-${check.checkType}-status`}
className="text-success small"
>
<i className="bi bi-camera-fill me-1"></i>
{check.checkType === "pre_rental_owner"
? "Pre-Rental Check Completed"
: "Post-Rental Check Completed"}
<small className="text-muted ms-2">
{new Date(
check.createdAt
).toLocaleDateString()}
</small>
</div>
)
)}
</div>
)} )}
{rental.status === "active" && (
{/* Condition Check Buttons */}
{getAvailableChecksForRental(rental.id).map((check) => (
<button <button
className="btn btn-sm btn-success" key={`${rental.id}-${check.checkType}`}
onClick={() => handleCompleteClick(rental)} className="btn btn-sm btn-outline-primary"
onClick={() =>
handleConditionCheck(rental, check.checkType)
}
> >
Complete <i className="bi bi-camera me-2" />
{check.checkType === "pre_rental_owner"
? "Submit Pre-Rental Check"
: "Submit Post-Rental Check"}
</button> </button>
)} ))}
</div> </div>
</div> </div>
</div> </div>
@@ -572,6 +715,35 @@ const MyListings: React.FC = () => {
onCancellationComplete={handleCancellationComplete} onCancellationComplete={handleCancellationComplete}
/> />
)} )}
{/* Condition Check Modal */}
{conditionCheckData && (
<ConditionCheckModal
show={showConditionCheckModal}
onHide={() => {
setShowConditionCheckModal(false);
setConditionCheckData(null);
}}
rentalId={conditionCheckData.rental.id}
checkType={conditionCheckData.checkType}
itemName={conditionCheckData.rental.item?.name || "Item"}
onSuccess={handleConditionCheckSuccess}
/>
)}
{/* Return Status Modal */}
{rentalForReturn && (
<ReturnStatusModal
show={showReturnStatusModal}
onHide={() => {
setShowReturnStatusModal(false);
setRentalForReturn(null);
}}
rental={rentalForReturn}
onReturnMarked={handleReturnStatusMarked}
onSubmitSuccess={handleReturnStatusMarked}
/>
)}
</div> </div>
); );
}; };

View File

@@ -1,10 +1,11 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { rentalAPI } from "../services/api"; import { rentalAPI, conditionCheckAPI } from "../services/api";
import { Rental } from "../types"; import { Rental } from "../types";
import ReviewItemModal from "../components/ReviewModal"; import ReviewItemModal from "../components/ReviewModal";
import RentalCancellationModal from "../components/RentalCancellationModal"; import RentalCancellationModal from "../components/RentalCancellationModal";
import ConditionCheckModal from "../components/ConditionCheckModal";
const MyRentals: React.FC = () => { const MyRentals: React.FC = () => {
// Helper function to format time // Helper function to format time
@@ -21,6 +22,21 @@ const MyRentals: React.FC = () => {
} }
}; };
// Helper function to format date and time together
const formatDateTime = (dateTimeString: string) => {
const date = new Date(dateTimeString);
return date
.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.replace(",", "");
};
const { user } = useAuth(); const { user } = useAuth();
const [rentals, setRentals] = useState<Rental[]>([]); const [rentals, setRentals] = useState<Rental[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -29,11 +45,25 @@ const MyRentals: React.FC = () => {
const [selectedRental, setSelectedRental] = useState<Rental | null>(null); const [selectedRental, setSelectedRental] = useState<Rental | null>(null);
const [showCancelModal, setShowCancelModal] = useState(false); const [showCancelModal, setShowCancelModal] = useState(false);
const [rentalToCancel, setRentalToCancel] = useState<Rental | null>(null); const [rentalToCancel, setRentalToCancel] = useState<Rental | null>(null);
const [showConditionCheckModal, setShowConditionCheckModal] = useState(false);
const [conditionCheckData, setConditionCheckData] = useState<{
rental: Rental;
checkType: string;
} | null>(null);
const [availableChecks, setAvailableChecks] = useState<any[]>([]);
const [conditionChecks, setConditionChecks] = useState<any[]>([]);
useEffect(() => { useEffect(() => {
fetchRentals(); fetchRentals();
fetchAvailableChecks();
}, []); }, []);
useEffect(() => {
if (rentals.length > 0) {
fetchConditionChecks();
}
}, [rentals]);
const fetchRentals = async () => { const fetchRentals = async () => {
try { try {
const response = await rentalAPI.getMyRentals(); const response = await rentalAPI.getMyRentals();
@@ -45,6 +75,44 @@ const MyRentals: React.FC = () => {
} }
}; };
const fetchAvailableChecks = async () => {
try {
const response = await conditionCheckAPI.getAvailableChecks();
const checks = Array.isArray(response.data.availableChecks)
? response.data.availableChecks
: [];
setAvailableChecks(checks);
} catch (err: any) {
console.error("Failed to fetch available checks:", err);
setAvailableChecks([]);
}
};
const fetchConditionChecks = async () => {
try {
// Fetch condition checks for all rentals
const allChecks: any[] = [];
for (const rental of rentals) {
try {
const response = await conditionCheckAPI.getConditionChecks(
rental.id
);
const checks = Array.isArray(response.data.conditionChecks)
? response.data.conditionChecks
: [];
allChecks.push(...checks);
} catch (err) {
// Continue even if one rental fails
console.error(`Failed to fetch checks for rental ${rental.id}:`, err);
}
}
setConditionChecks(allChecks);
} catch (err: any) {
console.error("Failed to fetch condition checks:", err);
setConditionChecks([]);
}
};
const handleCancelClick = (rental: Rental) => { const handleCancelClick = (rental: Rental) => {
setRentalToCancel(rental); setRentalToCancel(rental);
setShowCancelModal(true); setShowCancelModal(true);
@@ -71,6 +139,37 @@ const MyRentals: React.FC = () => {
alert("Thank you for your review!"); alert("Thank you for your review!");
}; };
const handleConditionCheck = (rental: Rental, checkType: string) => {
setConditionCheckData({ rental, checkType });
setShowConditionCheckModal(true);
};
const handleConditionCheckSuccess = () => {
fetchAvailableChecks();
fetchConditionChecks();
alert("Condition check submitted successfully!");
};
const getAvailableChecksForRental = (rentalId: string) => {
if (!Array.isArray(availableChecks)) return [];
return availableChecks.filter(
(check) =>
check.rentalId === rentalId &&
(check.checkType === "rental_start_renter" ||
check.checkType === "rental_end_renter")
);
};
const getCompletedChecksForRental = (rentalId: string) => {
if (!Array.isArray(conditionChecks)) return [];
return conditionChecks.filter(
(check) =>
check.rentalId === rentalId &&
(check.checkType === "rental_start_renter" ||
check.checkType === "rental_end_renter")
);
};
// Filter rentals - only show active rentals (pending, confirmed, active) // Filter rentals - only show active rentals (pending, confirmed, active)
const renterActiveRentals = rentals.filter((r) => const renterActiveRentals = rentals.filter((r) =>
["pending", "confirmed", "active"].includes(r.status) ["pending", "confirmed", "active"].includes(r.status)
@@ -164,21 +263,13 @@ const MyRentals: React.FC = () => {
</span> </span>
</div> </div>
{rental.status === "pending" && (
<div className="alert alert-info mt-2 mb-2 p-2 small">
You'll only be charged if the owner approves your
request.
</div>
)}
<p className="mb-1 text-dark"> <p className="mb-1 text-dark">
<strong>Rental Period:</strong> <strong>Rental Period:</strong>
<br /> <br />
<strong>Start:</strong>{" "} <strong>Start:</strong>{" "}
{new Date(rental.startDateTime).toLocaleString()} {formatDateTime(rental.startDateTime)}
<br /> <br />
<strong>End:</strong>{" "} <strong>End:</strong> {formatDateTime(rental.endDateTime)}
{new Date(rental.endDateTime).toLocaleString()}
</p> </p>
<p className="mb-1 text-dark"> <p className="mb-1 text-dark">
@@ -237,26 +328,70 @@ const MyRentals: React.FC = () => {
</> </>
)} )}
<div className="d-flex gap-2 mt-3"> <div className="d-flex flex-column gap-2 mt-3">
{(rental.status === "pending" || <div className="d-flex gap-2">
rental.status === "confirmed") && ( {(rental.status === "pending" ||
<button rental.status === "confirmed") && (
className="btn btn-sm btn-danger"
onClick={() => handleCancelClick(rental)}
>
Cancel
</button>
)}
{rental.status === "active" &&
!rental.itemRating &&
!rental.itemReviewSubmittedAt && (
<button <button
className="btn btn-sm btn-primary" className="btn btn-sm btn-danger"
onClick={() => handleReviewClick(rental)} onClick={() => handleCancelClick(rental)}
> >
Review Cancel
</button> </button>
)} )}
{rental.status === "active" &&
!rental.itemRating &&
!rental.itemReviewSubmittedAt && (
<button
className="btn btn-sm btn-primary"
onClick={() => handleReviewClick(rental)}
>
Review
</button>
)}
</div>
{/* Condition Check Status */}
{getCompletedChecksForRental(rental.id).length > 0 && (
<div className="mb-2">
{getCompletedChecksForRental(rental.id).map(
(check) => (
<div
key={`${rental.id}-${check.checkType}-status`}
className="text-success small"
>
<i className="bi bi-camera-fill me-1"></i>
{check.checkType === "rental_start_renter"
? "Start Check Completed"
: "End Check Completed"}
<small className="text-muted ms-2">
{new Date(
check.createdAt
).toLocaleDateString()}
</small>
</div>
)
)}
</div>
)}
{/* Condition Check Buttons */}
{getAvailableChecksForRental(rental.id).map((check) => (
<button
key={`${rental.id}-${check.checkType}`}
className="btn btn-sm btn-outline-primary"
onClick={() =>
handleConditionCheck(rental, check.checkType)
}
>
<i className="bi bi-camera me-2" />
{check.checkType === "rental_start_renter"
? "Submit Start Check"
: "Submit End Check"}
</button>
))}
{/* Review Status */}
{rental.itemReviewSubmittedAt && {rental.itemReviewSubmittedAt &&
!rental.itemReviewVisible && ( !rental.itemReviewVisible && (
<div className="text-info small"> <div className="text-info small">
@@ -311,6 +446,21 @@ const MyRentals: React.FC = () => {
onCancellationComplete={handleCancellationComplete} onCancellationComplete={handleCancellationComplete}
/> />
)} )}
{/* Condition Check Modal */}
{conditionCheckData && (
<ConditionCheckModal
show={showConditionCheckModal}
onHide={() => {
setShowConditionCheckModal(false);
setConditionCheckData(null);
}}
rentalId={conditionCheckData.rental.id}
checkType={conditionCheckData.checkType}
itemName={conditionCheckData.rental.item?.name || "Item"}
onSuccess={handleConditionCheckSuccess}
/>
)}
</div> </div>
); );
}; };

View File

@@ -52,18 +52,6 @@ export const resetCSRFToken = () => {
csrfToken = null; csrfToken = null;
}; };
// Check if authentication cookie exists
export const hasAuthCookie = (): boolean => {
return document.cookie
.split("; ")
.some((cookie) => cookie.startsWith("accessToken="));
};
// Check if user has any auth indicators
export const hasAuthIndicators = (): boolean => {
return hasAuthCookie();
};
api.interceptors.request.use(async (config) => { api.interceptors.request.use(async (config) => {
// Add CSRF token to headers for state-changing requests // Add CSRF token to headers for state-changing requests
const method = config.method?.toUpperCase() || ""; const method = config.method?.toUpperCase() || "";
@@ -119,14 +107,14 @@ api.interceptors.response.use(
if (error.response?.status === 401) { if (error.response?.status === 401) {
const errorData = error.response?.data as any; const errorData = error.response?.data as any;
// Don't redirect for NO_TOKEN on public endpoints // Try to refresh for token errors
if (errorData?.code === "NO_TOKEN") { // Note: We can't check refresh token from JS (httpOnly cookies)
// Let the app handle this - user simply isn't logged in // The backend will determine if refresh is possible
return Promise.reject(error); if (
} (errorData?.code === "TOKEN_EXPIRED" ||
errorData?.code === "NO_TOKEN") &&
// If token is expired, try to refresh !originalRequest._retry
if (errorData?.code === "TOKEN_EXPIRED" && !originalRequest._retry) { ) {
if (isRefreshing) { if (isRefreshing) {
// If already refreshing, queue the request // If already refreshing, queue the request
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -152,18 +140,10 @@ api.interceptors.response.use(
isRefreshing = false; isRefreshing = false;
processQueue(refreshError as AxiosError); processQueue(refreshError as AxiosError);
// Refresh failed, redirect to login // Refresh failed - let React Router handle redirects via PrivateRoute
window.location.href = "/login";
return Promise.reject(refreshError); return Promise.reject(refreshError);
} }
} }
// For other 401 errors, check if we should redirect
// Only redirect if this is not a login/register request
const isAuthEndpoint = originalRequest.url?.includes("/auth/");
if (!isAuthEndpoint && errorData?.error !== "Access token required") {
window.location.href = "/login";
}
} }
return Promise.reject(error); return Promise.reject(error);
@@ -223,8 +203,19 @@ export const rentalAPI = {
reviewItem: (id: string, data: any) => reviewItem: (id: string, data: any) =>
api.post(`/rentals/${id}/review-item`, data), api.post(`/rentals/${id}/review-item`, data),
getRefundPreview: (id: string) => api.get(`/rentals/${id}/refund-preview`), getRefundPreview: (id: string) => api.get(`/rentals/${id}/refund-preview`),
getLateFeePreview: (id: string, actualReturnDateTime: string) =>
api.get(`/rentals/${id}/late-fee-preview`, {
params: { actualReturnDateTime },
}),
cancelRental: (id: string, reason?: string) => cancelRental: (id: string, reason?: string) =>
api.post(`/rentals/${id}/cancel`, { reason }), api.post(`/rentals/${id}/cancel`, { reason }),
// Return status marking
markReturn: (
id: string,
data: { status: string; actualReturnDateTime?: string; notes?: string }
) => api.post(`/rentals/${id}/mark-return`, data),
reportDamage: (id: string, data: any) =>
api.post(`/rentals/${id}/report-damage`, data),
}; };
export const messageAPI = { export const messageAPI = {
@@ -277,4 +268,35 @@ export const mapsAPI = {
getHealth: () => api.get("/maps/health"), getHealth: () => api.get("/maps/health"),
}; };
export const conditionCheckAPI = {
submitConditionCheck: (rentalId: string, formData: FormData) =>
api.post(`/condition-checks/${rentalId}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
}),
getConditionChecks: (rentalId: string) =>
api.get(`/condition-checks/${rentalId}`),
getConditionCheckTimeline: (rentalId: string) =>
api.get(`/condition-checks/${rentalId}/timeline`),
getAvailableChecks: () => api.get("/condition-checks"),
};
export const notificationAPI = {
getNotifications: (params?: { limit?: number; page?: number }) =>
api.get("/notifications", { params }),
getUnreadCount: () => api.get("/notifications/unread-count"),
markAsRead: (notificationId: string) =>
api.patch(`/notifications/${notificationId}/read`),
markAllAsRead: () => api.patch("/notifications/mark-all-read"),
// Development endpoints
createTestNotification: (data: {
type?: string;
title: string;
message: string;
metadata?: any;
}) => api.post("/notifications/test", data),
triggerConditionReminders: () =>
api.post("/notifications/test/condition-reminders"),
cleanupExpired: () => api.post("/notifications/test/cleanup-expired"),
};
export default api; export default api;

View File

@@ -107,7 +107,15 @@ export interface Rental {
// Fee tracking fields // Fee tracking fields
platformFee?: number; platformFee?: number;
payoutAmount?: number; payoutAmount?: number;
status: "pending" | "confirmed" | "active" | "completed" | "cancelled"; status:
| "pending"
| "confirmed"
| "active"
| "completed"
| "cancelled"
| "returned_late"
| "damaged"
| "lost";
paymentStatus: "pending" | "paid" | "refunded"; paymentStatus: "pending" | "paid" | "refunded";
// Refund tracking fields // Refund tracking fields
refundAmount?: number; refundAmount?: number;
@@ -140,6 +148,13 @@ export interface Rental {
// Private messages // Private messages
itemPrivateMessage?: string; itemPrivateMessage?: string;
renterPrivateMessage?: string; renterPrivateMessage?: string;
// New condition check and dispute fields
actualReturnDateTime?: string;
lateFees?: number;
damageFees?: number;
replacementFees?: number;
itemLostReportedAt?: string;
damageAssessment?: any;
item?: Item; item?: Item;
renter?: User; renter?: User;
owner?: User; owner?: User;
@@ -147,6 +162,82 @@ export interface Rental {
updatedAt: string; updatedAt: string;
} }
export interface ConditionCheck {
id: string;
rentalId: string;
checkType:
| "pre_rental_owner"
| "rental_start_renter"
| "rental_end_renter"
| "post_rental_owner";
photos: string[];
notes?: string;
submittedBy: string;
submittedAt: string;
metadata: any;
submittedByUser?: User;
createdAt: string;
updatedAt: string;
}
export interface LateReturnCalculation {
lateHours: number;
lateFee: number;
isLate: boolean;
gracePeriodUsed?: boolean;
billableHours?: number;
pricingType?: "hourly" | "daily";
}
export interface DamageAssessment {
description: string;
canBeFixed: boolean;
repairCost?: number;
needsReplacement: boolean;
replacementCost?: number;
proofOfOwnership?: string[];
photos?: string[];
assessedAt: string;
assessedBy: string;
feeCalculation: {
type: "repair" | "replacement" | "assessment";
amount: number;
originalCost?: number;
repairCost?: number;
percentage?: number;
baseAmount?: number;
};
}
export interface ConditionCheckTimeline {
rental: {
id: string;
startDateTime: string;
endDateTime: string;
status: string;
};
timeline: {
[key: string]: {
status:
| "completed"
| "available"
| "pending"
| "expired"
| "not_available";
submittedAt?: string;
submittedBy?: User;
photoCount?: number;
hasNotes?: boolean;
timeWindow?: {
start: string;
end: string;
};
availableFrom?: string;
availableUntil?: string;
};
};
}
export interface ItemRequest { export interface ItemRequest {
id: string; id: string;
title: string; title: string;