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",
|
||||
"active",
|
||||
"completed",
|
||||
"cancelled"
|
||||
"cancelled",
|
||||
"returned_late",
|
||||
"returned_late_and_damaged",
|
||||
"damaged",
|
||||
"lost"
|
||||
),
|
||||
defaultValue: "pending",
|
||||
},
|
||||
@@ -153,6 +157,29 @@ const Rental = sequelize.define("Rental", {
|
||||
renterPrivateMessage: {
|
||||
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;
|
||||
|
||||
@@ -1,41 +1,75 @@
|
||||
const sequelize = require('../config/database');
|
||||
const User = require('./User');
|
||||
const Item = require('./Item');
|
||||
const Rental = require('./Rental');
|
||||
const Message = require('./Message');
|
||||
const ItemRequest = require('./ItemRequest');
|
||||
const ItemRequestResponse = require('./ItemRequestResponse');
|
||||
const UserAddress = require('./UserAddress');
|
||||
const sequelize = require("../config/database");
|
||||
const User = require("./User");
|
||||
const Item = require("./Item");
|
||||
const Rental = require("./Rental");
|
||||
const Message = require("./Message");
|
||||
const ItemRequest = require("./ItemRequest");
|
||||
const ItemRequestResponse = require("./ItemRequestResponse");
|
||||
const UserAddress = require("./UserAddress");
|
||||
const ConditionCheck = require("./ConditionCheck");
|
||||
|
||||
User.hasMany(Item, { as: 'ownedItems', foreignKey: 'ownerId' });
|
||||
Item.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
|
||||
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
|
||||
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
|
||||
|
||||
User.hasMany(Rental, { as: 'rentalsAsRenter', foreignKey: 'renterId' });
|
||||
User.hasMany(Rental, { as: 'rentalsAsOwner', foreignKey: 'ownerId' });
|
||||
User.hasMany(Rental, { as: "rentalsAsRenter", foreignKey: "renterId" });
|
||||
User.hasMany(Rental, { as: "rentalsAsOwner", foreignKey: "ownerId" });
|
||||
|
||||
Item.hasMany(Rental, { as: 'rentals', foreignKey: 'itemId' });
|
||||
Rental.belongsTo(Item, { as: 'item', foreignKey: 'itemId' });
|
||||
Rental.belongsTo(User, { as: 'renter', foreignKey: 'renterId' });
|
||||
Rental.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
|
||||
Item.hasMany(Rental, { as: "rentals", foreignKey: "itemId" });
|
||||
Rental.belongsTo(Item, { as: "item", foreignKey: "itemId" });
|
||||
Rental.belongsTo(User, { as: "renter", foreignKey: "renterId" });
|
||||
Rental.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
|
||||
|
||||
User.hasMany(Message, { as: 'sentMessages', foreignKey: 'senderId' });
|
||||
User.hasMany(Message, { as: 'receivedMessages', foreignKey: 'receiverId' });
|
||||
Message.belongsTo(User, { as: 'sender', foreignKey: 'senderId' });
|
||||
Message.belongsTo(User, { as: 'receiver', foreignKey: 'receiverId' });
|
||||
Message.hasMany(Message, { as: 'replies', foreignKey: 'parentMessageId' });
|
||||
Message.belongsTo(Message, { as: 'parentMessage', foreignKey: 'parentMessageId' });
|
||||
User.hasMany(Message, { as: "sentMessages", foreignKey: "senderId" });
|
||||
User.hasMany(Message, { as: "receivedMessages", foreignKey: "receiverId" });
|
||||
Message.belongsTo(User, { as: "sender", foreignKey: "senderId" });
|
||||
Message.belongsTo(User, { as: "receiver", foreignKey: "receiverId" });
|
||||
Message.hasMany(Message, { as: "replies", foreignKey: "parentMessageId" });
|
||||
Message.belongsTo(Message, {
|
||||
as: "parentMessage",
|
||||
foreignKey: "parentMessageId",
|
||||
});
|
||||
|
||||
User.hasMany(ItemRequest, { as: 'itemRequests', foreignKey: 'requesterId' });
|
||||
ItemRequest.belongsTo(User, { as: 'requester', foreignKey: 'requesterId' });
|
||||
User.hasMany(ItemRequest, { as: "itemRequests", foreignKey: "requesterId" });
|
||||
ItemRequest.belongsTo(User, { as: "requester", foreignKey: "requesterId" });
|
||||
|
||||
User.hasMany(ItemRequestResponse, { as: 'itemRequestResponses', foreignKey: 'responderId' });
|
||||
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(ItemRequestResponse, {
|
||||
as: "itemRequestResponses",
|
||||
foreignKey: "responderId",
|
||||
});
|
||||
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' });
|
||||
UserAddress.belongsTo(User, { as: 'user', foreignKey: 'userId' });
|
||||
User.hasMany(UserAddress, { as: "addresses", 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 = {
|
||||
sequelize,
|
||||
@@ -45,5 +79,6 @@ module.exports = {
|
||||
Message,
|
||||
ItemRequest,
|
||||
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": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ses": "^3.896.0",
|
||||
"@googlemaps/google-maps-services-js": "^3.4.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"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 FeeCalculator = require("../utils/feeCalculator");
|
||||
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 router = express.Router();
|
||||
|
||||
@@ -72,7 +75,7 @@ router.get("/my-rentals", authenticateToken, async (req, res) => {
|
||||
reqLogger.error("Error in my-rentals route", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
userId: req.user.id,
|
||||
});
|
||||
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", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
userId: req.user.id,
|
||||
});
|
||||
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
|
||||
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);
|
||||
@@ -141,7 +146,7 @@ router.get("/:id", authenticateToken, async (req, res) => {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id
|
||||
userId: req.user.id,
|
||||
});
|
||||
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
|
||||
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 = {
|
||||
@@ -313,7 +320,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -336,9 +345,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
|
||||
// Check if renter has a stripe customer ID
|
||||
if (!rental.renter.stripeCustomerId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Renter does not have a Stripe customer account" });
|
||||
return res.status(400).json({
|
||||
error: "Renter does not have a Stripe customer account",
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
return;
|
||||
} catch (paymentError) {
|
||||
@@ -385,7 +397,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
error: paymentError.message,
|
||||
stack: paymentError.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id
|
||||
userId: req.user.id,
|
||||
});
|
||||
// Keep rental as pending, but inform of payment failure
|
||||
return res.status(400).json({
|
||||
@@ -396,7 +408,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
} else {
|
||||
// For free rentals, just update status directly
|
||||
await rental.update({
|
||||
status: "confirmed"
|
||||
status: "confirmed",
|
||||
});
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -601,7 +616,7 @@ router.post("/calculate-fees", authenticateToken, async (req, res) => {
|
||||
userId: req.user.id,
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
itemId: req.query.itemId
|
||||
itemId: req.query.itemId,
|
||||
});
|
||||
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", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
userId: req.user.id,
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -654,7 +669,47 @@ router.get("/:id/refund-preview", authenticateToken, async (req, res) => {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
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 });
|
||||
}
|
||||
@@ -698,10 +753,174 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id
|
||||
userId: req.user.id,
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -23,8 +23,11 @@ const messageRoutes = require("./routes/messages");
|
||||
const itemRequestRoutes = require("./routes/itemRequests");
|
||||
const stripeRoutes = require("./routes/stripe");
|
||||
const mapsRoutes = require("./routes/maps");
|
||||
const conditionCheckRoutes = require("./routes/conditionChecks");
|
||||
|
||||
const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||
const RentalStatusJob = require("./jobs/rentalStatusJob");
|
||||
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -65,7 +68,7 @@ app.use(
|
||||
app.use(cookieParser);
|
||||
|
||||
// HTTP request logging
|
||||
app.use(morgan('combined', { stream: logger.stream }));
|
||||
app.use(morgan("combined", { stream: logger.stream }));
|
||||
|
||||
// API request/response logging
|
||||
app.use("/api/", apiLogger);
|
||||
@@ -111,6 +114,7 @@ app.use("/api/messages", messageRoutes);
|
||||
app.use("/api/item-requests", itemRequestRoutes);
|
||||
app.use("/api/stripe", stripeRoutes);
|
||||
app.use("/api/maps", mapsRoutes);
|
||||
app.use("/api/condition-checks", conditionCheckRoutes);
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.json({ message: "CommunityRentals.App API is running!" });
|
||||
@@ -131,10 +135,24 @@ sequelize
|
||||
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
||||
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, () => {
|
||||
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) => {
|
||||
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',
|
||||
ui_mode: 'embedded',
|
||||
redirect_on_completion: 'never',
|
||||
setup_intent_data: {
|
||||
usage: 'off_session'
|
||||
},
|
||||
metadata: {
|
||||
type: 'payment_method_setup',
|
||||
...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,
|
||||
} from "react";
|
||||
import { User } from "../types";
|
||||
import { authAPI, userAPI, fetchCSRFToken, resetCSRFToken, hasAuthIndicators } from "../services/api";
|
||||
import {
|
||||
authAPI,
|
||||
userAPI,
|
||||
fetchCSRFToken,
|
||||
resetCSRFToken,
|
||||
} from "../services/api";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
@@ -39,13 +44,14 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// The axios interceptor will automatically handle token refresh if needed
|
||||
const response = await userAPI.getProfile();
|
||||
setUser(response.data);
|
||||
} catch (error: any) {
|
||||
// Only log actual errors, not "user not logged in"
|
||||
if (error.response?.data?.code !== "NO_TOKEN") {
|
||||
console.error("Auth check failed:", error);
|
||||
}
|
||||
// If we get here, either:
|
||||
// 1. User is not logged in (expected for public browsing)
|
||||
// 2. Token refresh failed (user needs to login again)
|
||||
// In both cases, silently set user to null without logging errors
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
@@ -54,26 +60,11 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
// Initialize authentication
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
// Check if we have any auth indicators before making API call
|
||||
if (hasAuthIndicators()) {
|
||||
// Only check auth if we have some indication of being logged in
|
||||
// This avoids unnecessary 401 errors in the console
|
||||
await fetchCSRFToken();
|
||||
// Check if user is already authenticated
|
||||
await checkAuth();
|
||||
} else {
|
||||
// No auth indicators - skip the API call
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
// Always fetch CSRF token for subsequent requests
|
||||
await fetchCSRFToken();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize auth:", 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);
|
||||
}
|
||||
console.error("Failed to initialize authentication:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import api from "../services/api";
|
||||
import { Item, Rental } from "../types";
|
||||
import { rentalAPI } from "../services/api";
|
||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||
import ConditionCheckModal from "../components/ConditionCheckModal";
|
||||
import ReturnStatusModal from "../components/ReturnStatusModal";
|
||||
|
||||
const MyListings: React.FC = () => {
|
||||
// Helper function to format time
|
||||
@@ -24,8 +26,17 @@ const MyListings: React.FC = () => {
|
||||
|
||||
// Helper function to format date and time together
|
||||
const formatDateTime = (dateTimeString: string) => {
|
||||
const date = new Date(dateTimeString).toLocaleDateString();
|
||||
return date;
|
||||
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();
|
||||
@@ -42,12 +53,28 @@ const MyListings: React.FC = () => {
|
||||
const [rentalToCancel, setRentalToCancel] = useState<Rental | null>(null);
|
||||
const [isProcessingPayment, setIsProcessingPayment] = 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(() => {
|
||||
fetchMyListings();
|
||||
fetchOwnerRentals();
|
||||
fetchAvailableChecks();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerRentals.length > 0) {
|
||||
fetchConditionChecks();
|
||||
}
|
||||
}, [ownerRentals]);
|
||||
|
||||
const fetchMyListings = async () => {
|
||||
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
|
||||
const handleAcceptRental = async (rentalId: string) => {
|
||||
try {
|
||||
@@ -127,6 +192,7 @@ const MyListings: React.FC = () => {
|
||||
}
|
||||
|
||||
fetchOwnerRentals();
|
||||
fetchAvailableChecks(); // Refresh available checks after rental confirmation
|
||||
} catch (err: any) {
|
||||
console.error("Failed to accept rental request:", err);
|
||||
|
||||
@@ -155,21 +221,27 @@ const MyListings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteClick = async (rental: Rental) => {
|
||||
try {
|
||||
await rentalAPI.markAsCompleted(rental.id);
|
||||
const handleCompleteClick = (rental: Rental) => {
|
||||
setRentalForReturn(rental);
|
||||
setShowReturnStatusModal(true);
|
||||
};
|
||||
|
||||
setSelectedRentalForReview(rental);
|
||||
setShowReviewRenterModal(true);
|
||||
|
||||
fetchOwnerRentals();
|
||||
} catch (err: any) {
|
||||
console.error("Error marking rental as completed:", err);
|
||||
alert(
|
||||
"Failed to mark rental as completed: " +
|
||||
(err.response?.data?.error || err.message)
|
||||
const handleReturnStatusMarked = async (updatedRental: Rental) => {
|
||||
// Update the rental in the list
|
||||
setOwnerRentals((prev) =>
|
||||
prev.map((rental) =>
|
||||
rental.id === updatedRental.id ? updatedRental : rental
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 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 = () => {
|
||||
@@ -192,6 +264,35 @@ const MyListings: React.FC = () => {
|
||||
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
|
||||
const allOwnerRentals = ownerRentals
|
||||
.filter((r) =>
|
||||
@@ -331,7 +432,8 @@ const MyListings: React.FC = () => {
|
||||
</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" && (
|
||||
<>
|
||||
<button
|
||||
@@ -399,6 +501,47 @@ const MyListings: React.FC = () => {
|
||||
</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 === "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>
|
||||
@@ -572,6 +715,35 @@ const MyListings: React.FC = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { rentalAPI } from "../services/api";
|
||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||
import { Rental } from "../types";
|
||||
import ReviewItemModal from "../components/ReviewModal";
|
||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||
import ConditionCheckModal from "../components/ConditionCheckModal";
|
||||
|
||||
const MyRentals: React.FC = () => {
|
||||
// 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 [rentals, setRentals] = useState<Rental[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -29,11 +45,25 @@ const MyRentals: React.FC = () => {
|
||||
const [selectedRental, setSelectedRental] = useState<Rental | null>(null);
|
||||
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||
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(() => {
|
||||
fetchRentals();
|
||||
fetchAvailableChecks();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (rentals.length > 0) {
|
||||
fetchConditionChecks();
|
||||
}
|
||||
}, [rentals]);
|
||||
|
||||
const fetchRentals = async () => {
|
||||
try {
|
||||
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) => {
|
||||
setRentalToCancel(rental);
|
||||
setShowCancelModal(true);
|
||||
@@ -71,6 +139,37 @@ const MyRentals: React.FC = () => {
|
||||
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)
|
||||
const renterActiveRentals = rentals.filter((r) =>
|
||||
["pending", "confirmed", "active"].includes(r.status)
|
||||
@@ -164,21 +263,13 @@ const MyRentals: React.FC = () => {
|
||||
</span>
|
||||
</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">
|
||||
<strong>Rental Period:</strong>
|
||||
<br />
|
||||
<strong>Start:</strong>{" "}
|
||||
{new Date(rental.startDateTime).toLocaleString()}
|
||||
{formatDateTime(rental.startDateTime)}
|
||||
<br />
|
||||
<strong>End:</strong>{" "}
|
||||
{new Date(rental.endDateTime).toLocaleString()}
|
||||
<strong>End:</strong> {formatDateTime(rental.endDateTime)}
|
||||
</p>
|
||||
|
||||
<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 === "confirmed") && (
|
||||
<button
|
||||
@@ -257,6 +349,49 @@ const MyRentals: React.FC = () => {
|
||||
Review
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Condition Check Status */}
|
||||
{getCompletedChecksForRental(rental.id).length > 0 && (
|
||||
<div className="mb-2">
|
||||
{getCompletedChecksForRental(rental.id).map(
|
||||
(check) => (
|
||||
<div
|
||||
key={`${rental.id}-${check.checkType}-status`}
|
||||
className="text-success small"
|
||||
>
|
||||
<i className="bi bi-camera-fill me-1"></i>
|
||||
{check.checkType === "rental_start_renter"
|
||||
? "Start Check Completed"
|
||||
: "End Check Completed"}
|
||||
<small className="text-muted ms-2">
|
||||
{new Date(
|
||||
check.createdAt
|
||||
).toLocaleDateString()}
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Condition Check Buttons */}
|
||||
{getAvailableChecksForRental(rental.id).map((check) => (
|
||||
<button
|
||||
key={`${rental.id}-${check.checkType}`}
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() =>
|
||||
handleConditionCheck(rental, check.checkType)
|
||||
}
|
||||
>
|
||||
<i className="bi bi-camera me-2" />
|
||||
{check.checkType === "rental_start_renter"
|
||||
? "Submit Start Check"
|
||||
: "Submit End Check"}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Review Status */}
|
||||
{rental.itemReviewSubmittedAt &&
|
||||
!rental.itemReviewVisible && (
|
||||
<div className="text-info small">
|
||||
@@ -311,6 +446,21 @@ const MyRentals: React.FC = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,18 +52,6 @@ export const resetCSRFToken = () => {
|
||||
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) => {
|
||||
// Add CSRF token to headers for state-changing requests
|
||||
const method = config.method?.toUpperCase() || "";
|
||||
@@ -119,14 +107,14 @@ api.interceptors.response.use(
|
||||
if (error.response?.status === 401) {
|
||||
const errorData = error.response?.data as any;
|
||||
|
||||
// Don't redirect for NO_TOKEN on public endpoints
|
||||
if (errorData?.code === "NO_TOKEN") {
|
||||
// Let the app handle this - user simply isn't logged in
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// If token is expired, try to refresh
|
||||
if (errorData?.code === "TOKEN_EXPIRED" && !originalRequest._retry) {
|
||||
// Try to refresh for token errors
|
||||
// Note: We can't check refresh token from JS (httpOnly cookies)
|
||||
// The backend will determine if refresh is possible
|
||||
if (
|
||||
(errorData?.code === "TOKEN_EXPIRED" ||
|
||||
errorData?.code === "NO_TOKEN") &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
// If already refreshing, queue the request
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -152,18 +140,10 @@ api.interceptors.response.use(
|
||||
isRefreshing = false;
|
||||
processQueue(refreshError as AxiosError);
|
||||
|
||||
// Refresh failed, redirect to login
|
||||
window.location.href = "/login";
|
||||
// Refresh failed - let React Router handle redirects via PrivateRoute
|
||||
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);
|
||||
@@ -223,8 +203,19 @@ export const rentalAPI = {
|
||||
reviewItem: (id: string, data: any) =>
|
||||
api.post(`/rentals/${id}/review-item`, data),
|
||||
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) =>
|
||||
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 = {
|
||||
@@ -277,4 +268,35 @@ export const mapsAPI = {
|
||||
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;
|
||||
|
||||
@@ -107,7 +107,15 @@ export interface Rental {
|
||||
// Fee tracking fields
|
||||
platformFee?: number;
|
||||
payoutAmount?: number;
|
||||
status: "pending" | "confirmed" | "active" | "completed" | "cancelled";
|
||||
status:
|
||||
| "pending"
|
||||
| "confirmed"
|
||||
| "active"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
| "returned_late"
|
||||
| "damaged"
|
||||
| "lost";
|
||||
paymentStatus: "pending" | "paid" | "refunded";
|
||||
// Refund tracking fields
|
||||
refundAmount?: number;
|
||||
@@ -140,6 +148,13 @@ export interface Rental {
|
||||
// Private messages
|
||||
itemPrivateMessage?: string;
|
||||
renterPrivateMessage?: string;
|
||||
// New condition check and dispute fields
|
||||
actualReturnDateTime?: string;
|
||||
lateFees?: number;
|
||||
damageFees?: number;
|
||||
replacementFees?: number;
|
||||
itemLostReportedAt?: string;
|
||||
damageAssessment?: any;
|
||||
item?: Item;
|
||||
renter?: User;
|
||||
owner?: User;
|
||||
@@ -147,6 +162,82 @@ export interface Rental {
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user