Compare commits

...

2 Commits

18 changed files with 147 additions and 167 deletions

View File

@@ -1,101 +0,0 @@
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

@@ -14,8 +14,24 @@ const emailServices = require("../services/email");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const { validateS3Keys } = require("../utils/s3KeyValidator"); const { validateS3Keys } = require("../utils/s3KeyValidator");
const { IMAGE_LIMITS } = require("../config/imageLimits"); const { IMAGE_LIMITS } = require("../config/imageLimits");
const { isActive, getEffectiveStatus } = require("../utils/rentalStatus");
const router = express.Router(); const router = express.Router();
// Helper to add displayStatus to rental response
const addDisplayStatus = (rental) => {
if (!rental) return rental;
const rentalJson = rental.toJSON ? rental.toJSON() : rental;
return {
...rentalJson,
displayStatus: getEffectiveStatus(rentalJson),
};
};
// Helper to add displayStatus to array of rentals
const addDisplayStatusToArray = (rentals) => {
return rentals.map(addDisplayStatus);
};
// Helper function to check and update review visibility // Helper function to check and update review visibility
const checkAndUpdateReviewVisibility = async (rental) => { const checkAndUpdateReviewVisibility = async (rental) => {
const now = new Date(); const now = new Date();
@@ -75,7 +91,7 @@ router.get("/renting", authenticateToken, async (req, res) => {
order: [["createdAt", "DESC"]], order: [["createdAt", "DESC"]],
}); });
res.json(rentals); res.json(addDisplayStatusToArray(rentals));
} catch (error) { } catch (error) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error in renting route", { reqLogger.error("Error in renting route", {
@@ -103,7 +119,7 @@ router.get("/owning", authenticateToken, async (req, res) => {
order: [["createdAt", "DESC"]], order: [["createdAt", "DESC"]],
}); });
res.json(rentals); res.json(addDisplayStatusToArray(rentals));
} catch (error) { } catch (error) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error in owning route", { reqLogger.error("Error in owning route", {
@@ -167,7 +183,7 @@ router.get("/:id", authenticateToken, async (req, res) => {
.json({ error: "Unauthorized to view this rental" }); .json({ error: "Unauthorized to view this rental" });
} }
res.json(rental); res.json(addDisplayStatus(rental));
} catch (error) { } catch (error) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching rental", { reqLogger.error("Error fetching rental", {
@@ -235,10 +251,11 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
); );
// Check for overlapping rentals using datetime ranges // Check for overlapping rentals using datetime ranges
// Note: "active" rentals are stored as "confirmed" with startDateTime in the past
const overlappingRental = await Rental.findOne({ const overlappingRental = await Rental.findOne({
where: { where: {
itemId, itemId,
status: { [Op.in]: ["confirmed", "active"] }, status: "confirmed",
[Op.or]: [ [Op.or]: [
{ {
[Op.and]: [ [Op.and]: [
@@ -875,7 +892,7 @@ router.post("/:id/mark-completed", authenticateToken, async (req, res) => {
.json({ error: "Only owners can mark rentals as completed" }); .json({ error: "Only owners can mark rentals as completed" });
} }
if (rental.status !== "active") { if (!isActive(rental)) {
return res.status(400).json({ return res.status(400).json({
error: "Can only mark active rentals as completed", error: "Can only mark active rentals as completed",
}); });
@@ -1197,7 +1214,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
.json({ error: "Only the item owner can mark return status" }); .json({ error: "Only the item owner can mark return status" });
} }
if (rental.status !== "active") { if (!isActive(rental)) {
return res.status(400).json({ return res.status(400).json({
error: "Can only mark return status for active rentals", error: "Can only mark return status for active rentals",
}); });

View File

@@ -32,7 +32,6 @@ const uploadRoutes = require("./routes/upload");
const healthRoutes = require("./routes/health"); const healthRoutes = require("./routes/health");
const PayoutProcessor = require("./jobs/payoutProcessor"); const PayoutProcessor = require("./jobs/payoutProcessor");
const RentalStatusJob = require("./jobs/rentalStatusJob");
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder"); const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
const emailServices = require("./services/email"); const emailServices = require("./services/email");
const s3Service = require("./services/s3Service"); const s3Service = require("./services/s3Service");
@@ -230,10 +229,6 @@ sequelize
const payoutJobs = PayoutProcessor.startScheduledPayouts(); const payoutJobs = PayoutProcessor.startScheduledPayouts();
logger.info("Payout processor started"); logger.info("Payout processor started");
// Start the rental status job
const rentalStatusJobs = RentalStatusJob.startScheduledStatusUpdates();
logger.info("Rental status job started");
// Start the condition check reminder job // Start the condition check reminder job
const conditionCheckJobs = const conditionCheckJobs =
ConditionCheckReminderJob.startScheduledReminders(); ConditionCheckReminderJob.startScheduledReminders();

View File

@@ -1,5 +1,6 @@
const { ConditionCheck, Rental, User } = require("../models"); const { ConditionCheck, Rental, User } = require("../models");
const { Op } = require("sequelize"); const { Op } = require("sequelize");
const { isActive } = require("../utils/rentalStatus");
class ConditionCheckService { class ConditionCheckService {
/** /**
@@ -70,7 +71,7 @@ class ConditionCheckService {
canSubmit = canSubmit =
now >= timeWindow.start && now >= timeWindow.start &&
now <= timeWindow.end && now <= timeWindow.end &&
rental.status === "active"; isActive(rental);
break; break;
case "rental_end_renter": case "rental_end_renter":
@@ -80,7 +81,7 @@ class ConditionCheckService {
canSubmit = canSubmit =
now >= timeWindow.start && now >= timeWindow.start &&
now <= timeWindow.end && now <= timeWindow.end &&
rental.status === "active"; isActive(rental);
break; break;
case "post_rental_owner": case "post_rental_owner":

View File

@@ -1,6 +1,7 @@
const { Rental, Item, ConditionCheck, User } = require("../models"); const { Rental, Item, ConditionCheck, User } = require("../models");
const LateReturnService = require("./lateReturnService"); const LateReturnService = require("./lateReturnService");
const emailServices = require("./email"); const emailServices = require("./email");
const { isActive } = require("../utils/rentalStatus");
class DamageAssessmentService { class DamageAssessmentService {
/** /**
@@ -34,7 +35,7 @@ class DamageAssessmentService {
throw new Error("Only the item owner can report damage"); throw new Error("Only the item owner can report damage");
} }
if (rental.status !== "active") { if (!isActive(rental)) {
throw new Error("Can only assess damage for active rentals"); throw new Error("Can only assess damage for active rentals");
} }

View File

@@ -1,5 +1,6 @@
const { Rental, Item, User } = require("../models"); const { Rental, Item, User } = require("../models");
const emailServices = require("./email"); const emailServices = require("./email");
const { isActive } = require("../utils/rentalStatus");
class LateReturnService { class LateReturnService {
/** /**
@@ -71,7 +72,7 @@ class LateReturnService {
throw new Error("Rental not found"); throw new Error("Rental not found");
} }
if (rental.status !== "active") { if (!isActive(rental)) {
throw new Error("Can only process late returns for active rentals"); throw new Error("Can only process late returns for active rentals");
} }

View File

@@ -1,5 +1,6 @@
const { Rental } = require("../models"); const { Rental } = require("../models");
const StripeService = require("./stripeService"); const StripeService = require("./stripeService");
const { isActive } = require("../utils/rentalStatus");
class RefundService { class RefundService {
/** /**
@@ -69,8 +70,8 @@ class RefundService {
}; };
} }
// Check if rental is active // Check if rental is active (computed from confirmed + start time passed)
if (rental.status === "active") { if (isActive(rental)) {
return { return {
canCancel: false, canCancel: false,
reason: "Cannot cancel active rental", reason: "Cannot cancel active rental",

View File

@@ -790,11 +790,15 @@ describe('Rentals Routes', () => {
}); });
describe('POST /:id/mark-completed', () => { describe('POST /:id/mark-completed', () => {
// Active status is computed: confirmed + startDateTime in the past
const pastDate = new Date();
pastDate.setHours(pastDate.getHours() - 1); // 1 hour ago
const mockRental = { const mockRental = {
id: 1, id: 1,
ownerId: 1, ownerId: 1,
renterId: 2, renterId: 2,
status: 'active', status: 'confirmed',
startDateTime: pastDate,
update: jest.fn(), update: jest.fn(),
}; };
@@ -837,7 +841,7 @@ describe('Rentals Routes', () => {
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(response.body).toEqual({ expect(response.body).toEqual({
error: 'Can only mark active or confirmed rentals as completed', error: 'Can only mark active rentals as completed',
}); });
}); });
}); });

View File

@@ -10,6 +10,7 @@ describe('ConditionCheckService', () => {
describe('submitConditionCheck', () => { describe('submitConditionCheck', () => {
// Set rental dates relative to current time for valid time window // Set rental dates relative to current time for valid time window
// Active status is computed: confirmed + startDateTime in the past
const now = new Date(); const now = new Date();
const mockRental = { const mockRental = {
id: 'rental-123', id: 'rental-123',
@@ -17,7 +18,7 @@ describe('ConditionCheckService', () => {
renterId: 'renter-789', renterId: 'renter-789',
startDateTime: new Date(now.getTime() - 1000 * 60 * 60), // 1 hour ago startDateTime: new Date(now.getTime() - 1000 * 60 * 60), // 1 hour ago
endDateTime: new Date(now.getTime() + 1000 * 60 * 60 * 24), // 24 hours from now endDateTime: new Date(now.getTime() + 1000 * 60 * 60 * 24), // 24 hours from now
status: 'active' status: 'confirmed' // Will be computed as "active" since startDateTime is in the past
}; };
const mockPhotos = ['/uploads/photo1.jpg', '/uploads/photo2.jpg']; const mockPhotos = ['/uploads/photo1.jpg', '/uploads/photo2.jpg'];

View File

@@ -27,11 +27,15 @@ describe('DamageAssessmentService', () => {
beforeEach(() => { beforeEach(() => {
// Reset mockRental for each test to avoid state pollution // Reset mockRental for each test to avoid state pollution
// Active status is computed: confirmed + startDateTime in the past
const pastDate = new Date();
pastDate.setHours(pastDate.getHours() - 1); // 1 hour ago
mockRental = { mockRental = {
id: 'rental-123', id: 'rental-123',
ownerId: 'owner-789', ownerId: 'owner-789',
renterId: 'renter-456', renterId: 'renter-456',
status: 'active', status: 'confirmed',
startDateTime: pastDate,
item: { name: 'Test Camera', dailyRate: 100 }, item: { name: 'Test Camera', dailyRate: 100 },
update: jest.fn().mockResolvedValue({ update: jest.fn().mockResolvedValue({
id: 'rental-123', id: 'rental-123',

View File

@@ -91,9 +91,11 @@ describe('LateReturnService', () => {
let mockRental; let mockRental;
beforeEach(() => { beforeEach(() => {
// Active status is computed: confirmed + startDateTime in the past
mockRental = { mockRental = {
id: '123', id: '123',
status: 'active', status: 'confirmed',
startDateTime: new Date('2023-05-01T08:00:00Z'), // In the past
endDateTime: new Date('2023-06-01T10:00:00Z'), endDateTime: new Date('2023-06-01T10:00:00Z'),
item: { pricePerHour: 10, name: 'Test Item' }, item: { pricePerHour: 10, name: 'Test Item' },
renterId: 'renter-123', renterId: 'renter-123',

View File

@@ -229,8 +229,11 @@ describe('RefundService', () => {
}); });
}); });
it('should reject cancellation for active rental', () => { it('should reject cancellation for active rental (computed from confirmed + past start)', () => {
const rental = { ...baseRental, status: 'active' }; // Active status is now computed: confirmed + startDateTime in the past
const pastDate = new Date();
pastDate.setHours(pastDate.getHours() - 1); // 1 hour ago
const rental = { ...baseRental, status: 'confirmed', startDateTime: pastDate };
const result = RefundService.validateCancellationEligibility(rental, 100); const result = RefundService.validateCancellationEligibility(rental, 100);
expect(result).toEqual({ expect(result).toEqual({

View File

@@ -0,0 +1,41 @@
/**
* Utility functions for computing rental status on-the-fly.
* The "active" status is computed based on timestamps rather than stored in the database.
* A rental is considered "active" when it has status "confirmed" and startDateTime has passed.
*/
/**
* Returns the effective/display status of a rental.
* If the rental is "confirmed" and the start time has passed, returns "active".
* Otherwise returns the stored status.
* @param {Object} rental - The rental object with status and startDateTime
* @returns {string} The effective status
*/
function getEffectiveStatus(rental) {
const now = new Date();
if (
rental.status === "confirmed" &&
new Date(rental.startDateTime) <= now
) {
return "active";
}
return rental.status;
}
/**
* Checks if a rental is currently active.
* A rental is active when it's confirmed and the start time has passed.
* @param {Object} rental - The rental object with status and startDateTime
* @returns {boolean} True if the rental is currently active
*/
function isActive(rental) {
const now = new Date();
return (
rental.status === "confirmed" && new Date(rental.startDateTime) <= now
);
}
module.exports = {
getEffectiveStatus,
isActive,
};

View File

@@ -9,10 +9,10 @@ interface ConditionCheckViewerModalProps {
} }
const checkTypeLabels: Record<string, string> = { const checkTypeLabels: Record<string, string> = {
pre_rental_owner: "Pre-Rental Condition (Owner)", pre_rental_owner: "Pre-Rental Condition",
rental_start_renter: "Rental Start Condition (Renter)", rental_start_renter: "Rental Start Condition",
rental_end_renter: "Rental End Condition (Renter)", rental_end_renter: "Rental End Condition",
post_rental_owner: "Post-Rental Condition (Owner)", post_rental_owner: "Post-Rental Condition",
}; };
const ConditionCheckViewerModal: React.FC<ConditionCheckViewerModalProps> = ({ const ConditionCheckViewerModal: React.FC<ConditionCheckViewerModalProps> = ({
@@ -51,7 +51,7 @@ const ConditionCheckViewerModal: React.FC<ConditionCheckViewerModalProps> = ({
try { try {
await Promise.all( await Promise.all(
validKeys.map(async (key) => { validKeys.map(async (key) => {
const url = await getSignedImageUrl(key, 'medium'); const url = await getSignedImageUrl(key, "medium");
newUrls.set(key, url); newUrls.set(key, url);
}) })
); );

View File

@@ -41,11 +41,13 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
setError(null); setError(null);
// Check if rental status allows cancellation before making API call // Check if rental status allows cancellation before making API call
if (rental.status !== "pending" && rental.status !== "confirmed") { // Use displayStatus as it includes computed "active" status
const effectiveStatus = rental.displayStatus || rental.status;
if (effectiveStatus !== "pending" && effectiveStatus !== "confirmed") {
let errorMessage = "This rental cannot be cancelled"; let errorMessage = "This rental cannot be cancelled";
if (rental.status === "active") { if (effectiveStatus === "active") {
errorMessage = "Cannot cancel rental - the rental period has already started"; errorMessage = "Cannot cancel rental - the rental period has already started";
} else if (rental.status === "completed") { } else if (effectiveStatus === "completed") {
errorMessage = "Cannot cancel rental - the rental has already been completed"; errorMessage = "Cannot cancel rental - the rental has already been completed";
} else if (rental.status === "cancelled") { } else if (rental.status === "cancelled") {
errorMessage = "This rental has already been cancelled"; errorMessage = "This rental has already been cancelled";

View File

@@ -318,13 +318,16 @@ const Owning: React.FC = () => {
}; };
// Filter owner rentals - exclude cancelled (shown in Rental History) // Filter owner rentals - exclude cancelled (shown in Rental History)
// Use displayStatus for filtering/sorting as it includes computed "active" status
const allOwnerRentals = ownerRentals const allOwnerRentals = ownerRentals
.filter((r) => ["pending", "confirmed", "active"].includes(r.status)) .filter((r) => ["pending", "confirmed", "active"].includes(r.displayStatus || r.status))
.sort((a, b) => { .sort((a, b) => {
const statusOrder = { pending: 0, confirmed: 1, active: 2 }; const statusOrder = { pending: 0, confirmed: 1, active: 2 };
const aStatus = a.displayStatus || a.status;
const bStatus = b.displayStatus || b.status;
return ( return (
statusOrder[a.status as keyof typeof statusOrder] - statusOrder[aStatus as keyof typeof statusOrder] -
statusOrder[b.status as keyof typeof statusOrder] statusOrder[bStatus as keyof typeof statusOrder]
); );
}); });
@@ -401,17 +404,17 @@ const Owning: React.FC = () => {
<div className="mb-2"> <div className="mb-2">
<span <span
className={`badge ${ className={`badge ${
rental.status === "active" (rental.displayStatus || rental.status) === "active"
? "bg-success" ? "bg-success"
: rental.status === "pending" : (rental.displayStatus || rental.status) === "pending"
? "bg-warning" ? "bg-warning"
: rental.status === "confirmed" : (rental.displayStatus || rental.status) === "confirmed"
? "bg-info" ? "bg-info"
: "bg-danger" : "bg-danger"
}`} }`}
> >
{rental.status.charAt(0).toUpperCase() + {(rental.displayStatus || rental.status).charAt(0).toUpperCase() +
rental.status.slice(1)} (rental.displayStatus || rental.status).slice(1)}
</span> </span>
</div> </div>
@@ -512,7 +515,7 @@ const Owning: React.FC = () => {
</button> </button>
</> </>
)} )}
{rental.status === "confirmed" && ( {(rental.displayStatus || rental.status) === "confirmed" && (
<button <button
className="btn btn-sm btn-outline-danger" className="btn btn-sm btn-outline-danger"
onClick={() => handleCancelClick(rental)} onClick={() => handleCancelClick(rental)}
@@ -520,7 +523,7 @@ const Owning: React.FC = () => {
Cancel Cancel
</button> </button>
)} )}
{rental.status === "active" && ( {(rental.displayStatus || rental.status) === "active" && (
<button <button
className="btn btn-sm btn-success" className="btn btn-sm btn-success"
onClick={() => handleCompleteClick(rental)} onClick={() => handleCompleteClick(rental)}

View File

@@ -182,8 +182,9 @@ const Renting: React.FC = () => {
}; };
// Filter rentals - show only active rentals (declined go to history) // Filter rentals - show only active rentals (declined go to history)
// Use displayStatus for filtering as it includes computed "active" status
const renterActiveRentals = rentals.filter((r) => const renterActiveRentals = rentals.filter((r) =>
["pending", "confirmed", "active"].includes(r.status) ["pending", "confirmed", "active"].includes(r.displayStatus || r.status)
); );
if (loading) { if (loading) {
@@ -268,25 +269,25 @@ const Renting: React.FC = () => {
<div className="mb-2"> <div className="mb-2">
<span <span
className={`badge ${ className={`badge ${
rental.status === "active" (rental.displayStatus || rental.status) === "active"
? "bg-success" ? "bg-success"
: rental.status === "pending" : (rental.displayStatus || rental.status) === "pending"
? "bg-warning" ? "bg-warning"
: rental.status === "confirmed" : (rental.displayStatus || rental.status) === "confirmed"
? "bg-info" ? "bg-info"
: rental.status === "declined" : (rental.displayStatus || rental.status) === "declined"
? "bg-secondary" ? "bg-secondary"
: "bg-danger" : "bg-danger"
}`} }`}
> >
{rental.status === "pending" {(rental.displayStatus || rental.status) === "pending"
? "Awaiting Owner Approval" ? "Awaiting Owner Approval"
: rental.status === "confirmed" : (rental.displayStatus || rental.status) === "confirmed"
? "Confirmed & Paid" ? "Confirmed & Paid"
: rental.status === "declined" : (rental.displayStatus || rental.status) === "declined"
? "Declined by Owner" ? "Declined by Owner"
: rental.status.charAt(0).toUpperCase() + : (rental.displayStatus || rental.status).charAt(0).toUpperCase() +
rental.status.slice(1)} (rental.displayStatus || rental.status).slice(1)}
</span> </span>
</div> </div>
@@ -362,8 +363,8 @@ const Renting: React.FC = () => {
<div className="d-flex flex-column gap-2 mt-3"> <div className="d-flex flex-column gap-2 mt-3">
<div className="d-flex gap-2"> <div className="d-flex gap-2">
{(rental.status === "pending" || {((rental.displayStatus || rental.status) === "pending" ||
rental.status === "confirmed") && ( (rental.displayStatus || rental.status) === "confirmed") && (
<button <button
className="btn btn-sm btn-danger" className="btn btn-sm btn-danger"
onClick={() => handleCancelClick(rental)} onClick={() => handleCancelClick(rental)}
@@ -371,7 +372,7 @@ const Renting: React.FC = () => {
Cancel Cancel
</button> </button>
)} )}
{rental.status === "active" && {(rental.displayStatus || rental.status) === "active" &&
!rental.itemRating && !rental.itemRating &&
!rental.itemReviewSubmittedAt && ( !rental.itemReviewSubmittedAt && (
<button <button

View File

@@ -106,6 +106,18 @@ export interface Item {
updatedAt: string; updatedAt: string;
} }
export type RentalStatus =
| "pending"
| "confirmed"
| "declined"
| "active"
| "completed"
| "cancelled"
| "returned_late"
| "returned_late_and_damaged"
| "damaged"
| "lost";
export interface Rental { export interface Rental {
id: string; id: string;
itemId: string; itemId: string;
@@ -117,17 +129,9 @@ export interface Rental {
// Fee tracking fields // Fee tracking fields
platformFee?: number; platformFee?: number;
payoutAmount?: number; payoutAmount?: number;
status: status: RentalStatus;
| "pending" // Computed status (includes "active" when confirmed + start time passed)
| "confirmed" displayStatus?: RentalStatus;
| "declined"
| "active"
| "completed"
| "cancelled"
| "returned_late"
| "returned_late_and_damaged"
| "damaged"
| "lost";
paymentStatus: "pending" | "paid" | "refunded"; paymentStatus: "pending" | "paid" | "refunded";
// Refund tracking fields // Refund tracking fields
refundAmount?: number; refundAmount?: number;