email plus return item statuses
This commit is contained in:
36
backend/config/aws.js
Normal file
36
backend/config/aws.js
Normal 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,
|
||||||
|
};
|
||||||
258
backend/jobs/conditionCheckReminder.js
Normal file
258
backend/jobs/conditionCheckReminder.js
Normal 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;
|
||||||
101
backend/jobs/rentalStatusJob.js
Normal file
101
backend/jobs/rentalStatusJob.js
Normal 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;
|
||||||
53
backend/models/ConditionCheck.js
Normal file
53
backend/models/ConditionCheck.js
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
1271
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
165
backend/routes/conditionChecks.js
Normal file
165
backend/routes/conditionChecks.js
Normal 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;
|
||||||
@@ -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
|
||||||
@@ -336,9 +345,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
// 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
|
// Create payment intent and charge the stored payment method
|
||||||
@@ -377,6 +386,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send confirmation emails
|
||||||
|
await emailService.sendRentalConfirmationEmails(updatedRental);
|
||||||
|
|
||||||
res.json(updatedRental);
|
res.json(updatedRental);
|
||||||
return;
|
return;
|
||||||
} catch (paymentError) {
|
} catch (paymentError) {
|
||||||
@@ -385,7 +397,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
error: paymentError.message,
|
error: paymentError.message,
|
||||||
stack: paymentError.stack,
|
stack: paymentError.stack,
|
||||||
rentalId: req.params.id,
|
rentalId: req.params.id,
|
||||||
userId: req.user.id
|
userId: req.user.id,
|
||||||
});
|
});
|
||||||
// Keep rental as pending, but inform of payment failure
|
// Keep rental as pending, but inform of payment failure
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -396,7 +408,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
} 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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
363
backend/services/conditionCheckService.js
Normal file
363
backend/services/conditionCheckService.js
Normal 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;
|
||||||
138
backend/services/damageAssessmentService.js
Normal file
138
backend/services/damageAssessmentService.js
Normal 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;
|
||||||
497
backend/services/emailService.js
Normal file
497
backend/services/emailService.js
Normal 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();
|
||||||
113
backend/services/lateReturnService.js
Normal file
113
backend/services/lateReturnService.js
Normal 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;
|
||||||
@@ -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
|
||||||
|
|||||||
241
backend/templates/emails/conditionCheckReminder.html
Normal file
241
backend/templates/emails/conditionCheckReminder.html
Normal 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>© 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>
|
||||||
61
backend/templates/emails/damageReportCS.html
Normal file
61
backend/templates/emails/damageReportCS.html
Normal 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>
|
||||||
39
backend/templates/emails/lateReturnCS.html
Normal file
39
backend/templates/emails/lateReturnCS.html
Normal 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>
|
||||||
40
backend/templates/emails/lostItemCS.html
Normal file
40
backend/templates/emails/lostItemCS.html
Normal 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>
|
||||||
281
backend/templates/emails/rentalConfirmation.html
Normal file
281
backend/templates/emails/rentalConfirmation.html
Normal 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>© 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>
|
||||||
262
frontend/src/components/ConditionCheckModal.tsx
Normal file
262
frontend/src/components/ConditionCheckModal.tsx
Normal 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;
|
||||||
954
frontend/src/components/ReturnStatusModal.tsx
Normal file
954
frontend/src/components/ReturnStatusModal.tsx
Normal 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;
|
||||||
@@ -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
|
await fetchCSRFToken();
|
||||||
if (hasAuthIndicators()) {
|
// Check if user is already authenticated
|
||||||
// Only check auth if we have some indication of being logged in
|
|
||||||
// This avoids unnecessary 401 errors in the console
|
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
} else {
|
|
||||||
// No auth indicators - skip the API call
|
|
||||||
setUser(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always fetch CSRF token for subsequent requests
|
|
||||||
await fetchCSRFToken();
|
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
fetchOwnerRentals();
|
prev.map((rental) =>
|
||||||
} catch (err: any) {
|
rental.id === updatedRental.id ? updatedRental : rental
|
||||||
console.error("Error marking rental as completed:", err);
|
)
|
||||||
alert(
|
|
||||||
"Failed to mark rental as completed: " +
|
|
||||||
(err.response?.data?.error || err.message)
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
// Close the return status modal
|
||||||
|
setShowReturnStatusModal(false);
|
||||||
|
setRentalForReturn(null);
|
||||||
|
|
||||||
|
// Show review modal (rental is already marked as completed by return status endpoint)
|
||||||
|
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,7 +432,8 @@ const MyListings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex gap-2 mt-3">
|
<div className="d-flex flex-column gap-2 mt-3">
|
||||||
|
<div className="d-flex gap-2">
|
||||||
{rental.status === "pending" && (
|
{rental.status === "pending" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -399,6 +501,47 @@ const MyListings: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 === "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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 === "pre_rental_owner"
|
||||||
|
? "Submit Pre-Rental Check"
|
||||||
|
: "Submit Post-Rental Check"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,7 +328,8 @@ const MyRentals: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex gap-2 mt-3">
|
<div className="d-flex flex-column gap-2 mt-3">
|
||||||
|
<div className="d-flex gap-2">
|
||||||
{(rental.status === "pending" ||
|
{(rental.status === "pending" ||
|
||||||
rental.status === "confirmed") && (
|
rental.status === "confirmed") && (
|
||||||
<button
|
<button
|
||||||
@@ -257,6 +349,49 @@ const MyRentals: React.FC = () => {
|
|||||||
Review
|
Review
|
||||||
</button>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user