email plus return item statuses
This commit is contained in:
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
|
||||
|
||||
Reference in New Issue
Block a user