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
|
||||
@@ -330,73 +339,76 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
.json({ error: "No payment method found for this rental" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Import StripeService to process the payment
|
||||
const StripeService = require("../services/stripeService");
|
||||
try {
|
||||
// Import StripeService to process the payment
|
||||
const StripeService = require("../services/stripeService");
|
||||
|
||||
// Check if renter has a stripe customer ID
|
||||
if (!rental.renter.stripeCustomerId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Renter does not have a Stripe customer account" });
|
||||
}
|
||||
|
||||
// Create payment intent and charge the stored payment method
|
||||
const paymentResult = await StripeService.chargePaymentMethod(
|
||||
rental.stripePaymentMethodId,
|
||||
rental.totalAmount,
|
||||
rental.renter.stripeCustomerId,
|
||||
{
|
||||
rentalId: rental.id,
|
||||
itemName: rental.item.name,
|
||||
renterId: rental.renterId,
|
||||
ownerId: rental.ownerId,
|
||||
// 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",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Update rental with payment completion
|
||||
await rental.update({
|
||||
status: "confirmed",
|
||||
paymentStatus: "paid",
|
||||
stripePaymentIntentId: paymentResult.paymentIntentId,
|
||||
});
|
||||
|
||||
const updatedRental = await Rental.findByPk(rental.id, {
|
||||
include: [
|
||||
{ model: Item, as: "item" },
|
||||
// Create payment intent and charge the stored payment method
|
||||
const paymentResult = await StripeService.chargePaymentMethod(
|
||||
rental.stripePaymentMethodId,
|
||||
rental.totalAmount,
|
||||
rental.renter.stripeCustomerId,
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "username", "firstName", "lastName"],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "renter",
|
||||
attributes: ["id", "username", "firstName", "lastName"],
|
||||
},
|
||||
],
|
||||
});
|
||||
rentalId: rental.id,
|
||||
itemName: rental.item.name,
|
||||
renterId: rental.renterId,
|
||||
ownerId: rental.ownerId,
|
||||
}
|
||||
);
|
||||
|
||||
res.json(updatedRental);
|
||||
return;
|
||||
} catch (paymentError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Payment failed during approval", {
|
||||
error: paymentError.message,
|
||||
stack: paymentError.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id
|
||||
});
|
||||
// Keep rental as pending, but inform of payment failure
|
||||
return res.status(400).json({
|
||||
error: "Payment failed during approval",
|
||||
details: paymentError.message,
|
||||
});
|
||||
}
|
||||
// Update rental with payment completion
|
||||
await rental.update({
|
||||
status: "confirmed",
|
||||
paymentStatus: "paid",
|
||||
stripePaymentIntentId: paymentResult.paymentIntentId,
|
||||
});
|
||||
|
||||
const updatedRental = await Rental.findByPk(rental.id, {
|
||||
include: [
|
||||
{ model: Item, as: "item" },
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "username", "firstName", "lastName"],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "renter",
|
||||
attributes: ["id", "username", "firstName", "lastName"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Send confirmation emails
|
||||
await emailService.sendRentalConfirmationEmails(updatedRental);
|
||||
|
||||
res.json(updatedRental);
|
||||
return;
|
||||
} catch (paymentError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Payment failed during approval", {
|
||||
error: paymentError.message,
|
||||
stack: paymentError.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
// Keep rental as pending, but inform of payment failure
|
||||
return res.status(400).json({
|
||||
error: "Payment failed during approval",
|
||||
details: paymentError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 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>
|
||||
Reference in New Issue
Block a user