email plus return item statuses

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

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

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

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,11 @@ const Rental = sequelize.define("Rental", {
"confirmed",
"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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

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

View File

@@ -4,6 +4,9 @@ const { Rental, Item, User } = require("../models"); // Import from models/index
const { authenticateToken } = require("../middleware/auth");
const 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;

View File

@@ -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,
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{title}}</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #1565c0;
}
.info-box .icon {
font-size: 24px;
margin-bottom: 10px;
}
/* Alert box */
.alert-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0;
color: #856404;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Your trusted rental marketplace</div>
</div>
<div class="content">
<h1>📸 {{title}}</h1>
<p>{{message}}</p>
<div class="info-box">
<div class="icon">📦</div>
<p><strong>Rental Item:</strong> {{itemName}}</p>
<p><strong>Deadline:</strong> {{deadline}}</p>
</div>
<p>Taking condition photos helps protect both renters and owners by providing clear documentation of the item's state. This is an important step in the rental process.</p>
<div class="alert-box">
<p><strong>Important:</strong> Please complete this condition check as soon as possible. Missing this deadline may affect dispute resolution if issues arise.</p>
</div>
<a href="#" class="button">Complete Condition Check</a>
<h2>What to photograph:</h2>
<ul>
<li>Overall view of the item</li>
<li>Any existing damage or wear</li>
<li>Serial numbers or identifying marks</li>
<li>Accessories or additional components</li>
</ul>
<p>If you have any questions about the condition check process, please don't hesitate to contact our support team.</p>
</div>
<div class="footer">
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>You received this email because you have an active rental on RentAll.</p>
<p>If you have any questions, please <a href="mailto:support@rentall.com">contact our support team</a>.</p>
</div>
</div>
</body>
</html>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,281 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{title}}</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0;
color: #155724;
}
.success-box .icon {
font-size: 24px;
margin-bottom: 10px;
}
/* Info table */
.info-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background-color: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.info-table th,
.info-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.info-table th {
background-color: #e9ecef;
font-weight: 600;
color: #495057;
}
.info-table td {
color: #6c757d;
}
.info-table tr:last-child td {
border-bottom: none;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.info-table th,
.info-table td {
padding: 10px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Rental Confirmed</div>
</div>
<div class="content">
<h1>✅ {{title}}</h1>
<p>{{message}}</p>
<div class="success-box">
<div class="icon">🎉</div>
<p><strong>Great news!</strong> Your rental has been successfully confirmed and you're all set.</p>
</div>
<h2>Rental Details</h2>
<table class="info-table">
<tr>
<th>Item</th>
<td>{{itemName}}</td>
</tr>
<tr>
<th>Start Date</th>
<td>{{startDate}}</td>
</tr>
<tr>
<th>End Date</th>
<td>{{endDate}}</td>
</tr>
</table>
<a href="#" class="button">View Rental Details</a>
<h2>What's next?</h2>
<ul>
<li><strong>Before pickup:</strong> You'll receive a reminder to take condition photos</li>
<li><strong>During rental:</strong> Enjoy your rental and treat it with care</li>
<li><strong>At return:</strong> Take photos and return the item as agreed</li>
<li><strong>After return:</strong> Leave a review to help the community</li>
</ul>
<p><strong>Important reminders:</strong></p>
<ul>
<li>Take condition photos at pickup and return</li>
<li>Follow any specific care instructions provided</li>
<li>Return the item on time and in good condition</li>
<li>Contact the owner if you have any questions</li>
</ul>
<p>Thank you for choosing RentAll! We hope you have a great rental experience.</p>
</div>
<div class="footer">
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>You received this email because you have a confirmed rental on RentAll.</p>
<p>If you have any questions, please <a href="mailto:support@rentall.com">contact our support team</a>.</p>
</div>
</div>
</body>
</html>