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 { validateS3Keys } = require("../utils/s3KeyValidator");
const { IMAGE_LIMITS } = require("../config/imageLimits");
const { isActive, getEffectiveStatus } = require("../utils/rentalStatus");
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
const checkAndUpdateReviewVisibility = async (rental) => {
const now = new Date();
@@ -75,7 +91,7 @@ router.get("/renting", authenticateToken, async (req, res) => {
order: [["createdAt", "DESC"]],
});
res.json(rentals);
res.json(addDisplayStatusToArray(rentals));
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error in renting route", {
@@ -103,7 +119,7 @@ router.get("/owning", authenticateToken, async (req, res) => {
order: [["createdAt", "DESC"]],
});
res.json(rentals);
res.json(addDisplayStatusToArray(rentals));
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error in owning route", {
@@ -167,7 +183,7 @@ router.get("/:id", authenticateToken, async (req, res) => {
.json({ error: "Unauthorized to view this rental" });
}
res.json(rental);
res.json(addDisplayStatus(rental));
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching rental", {
@@ -235,10 +251,11 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
);
// Check for overlapping rentals using datetime ranges
// Note: "active" rentals are stored as "confirmed" with startDateTime in the past
const overlappingRental = await Rental.findOne({
where: {
itemId,
status: { [Op.in]: ["confirmed", "active"] },
status: "confirmed",
[Op.or]: [
{
[Op.and]: [
@@ -875,7 +892,7 @@ router.post("/:id/mark-completed", authenticateToken, async (req, res) => {
.json({ error: "Only owners can mark rentals as completed" });
}
if (rental.status !== "active") {
if (!isActive(rental)) {
return res.status(400).json({
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" });
}
if (rental.status !== "active") {
if (!isActive(rental)) {
return res.status(400).json({
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 PayoutProcessor = require("./jobs/payoutProcessor");
const RentalStatusJob = require("./jobs/rentalStatusJob");
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
const emailServices = require("./services/email");
const s3Service = require("./services/s3Service");
@@ -230,10 +229,6 @@ 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();

View File

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

View File

@@ -1,6 +1,7 @@
const { Rental, Item, ConditionCheck, User } = require("../models");
const LateReturnService = require("./lateReturnService");
const emailServices = require("./email");
const { isActive } = require("../utils/rentalStatus");
class DamageAssessmentService {
/**
@@ -34,7 +35,7 @@ class DamageAssessmentService {
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");
}

View File

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

View File

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

View File

@@ -790,11 +790,15 @@ describe('Rentals Routes', () => {
});
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 = {
id: 1,
ownerId: 1,
renterId: 2,
status: 'active',
status: 'confirmed',
startDateTime: pastDate,
update: jest.fn(),
};
@@ -837,7 +841,7 @@ describe('Rentals Routes', () => {
expect(response.status).toBe(400);
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', () => {
// 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 mockRental = {
id: 'rental-123',
@@ -17,7 +18,7 @@ describe('ConditionCheckService', () => {
renterId: 'renter-789',
startDateTime: new Date(now.getTime() - 1000 * 60 * 60), // 1 hour ago
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'];

View File

@@ -27,11 +27,15 @@ describe('DamageAssessmentService', () => {
beforeEach(() => {
// 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 = {
id: 'rental-123',
ownerId: 'owner-789',
renterId: 'renter-456',
status: 'active',
status: 'confirmed',
startDateTime: pastDate,
item: { name: 'Test Camera', dailyRate: 100 },
update: jest.fn().mockResolvedValue({
id: 'rental-123',

View File

@@ -91,9 +91,11 @@ describe('LateReturnService', () => {
let mockRental;
beforeEach(() => {
// Active status is computed: confirmed + startDateTime in the past
mockRental = {
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'),
item: { pricePerHour: 10, name: 'Test Item' },
renterId: 'renter-123',

View File

@@ -229,8 +229,11 @@ describe('RefundService', () => {
});
});
it('should reject cancellation for active rental', () => {
const rental = { ...baseRental, status: 'active' };
it('should reject cancellation for active rental (computed from confirmed + past start)', () => {
// 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);
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> = {
pre_rental_owner: "Pre-Rental Condition (Owner)",
rental_start_renter: "Rental Start Condition (Renter)",
rental_end_renter: "Rental End Condition (Renter)",
post_rental_owner: "Post-Rental Condition (Owner)",
pre_rental_owner: "Pre-Rental Condition",
rental_start_renter: "Rental Start Condition",
rental_end_renter: "Rental End Condition",
post_rental_owner: "Post-Rental Condition",
};
const ConditionCheckViewerModal: React.FC<ConditionCheckViewerModalProps> = ({
@@ -51,7 +51,7 @@ const ConditionCheckViewerModal: React.FC<ConditionCheckViewerModalProps> = ({
try {
await Promise.all(
validKeys.map(async (key) => {
const url = await getSignedImageUrl(key, 'medium');
const url = await getSignedImageUrl(key, "medium");
newUrls.set(key, url);
})
);

View File

@@ -41,11 +41,13 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
setError(null);
// 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";
if (rental.status === "active") {
if (effectiveStatus === "active") {
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";
} else if (rental.status === "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)
// Use displayStatus for filtering/sorting as it includes computed "active" status
const allOwnerRentals = ownerRentals
.filter((r) => ["pending", "confirmed", "active"].includes(r.status))
.filter((r) => ["pending", "confirmed", "active"].includes(r.displayStatus || r.status))
.sort((a, b) => {
const statusOrder = { pending: 0, confirmed: 1, active: 2 };
const aStatus = a.displayStatus || a.status;
const bStatus = b.displayStatus || b.status;
return (
statusOrder[a.status as keyof typeof statusOrder] -
statusOrder[b.status as keyof typeof statusOrder]
statusOrder[aStatus as keyof typeof statusOrder] -
statusOrder[bStatus as keyof typeof statusOrder]
);
});
@@ -401,17 +404,17 @@ const Owning: React.FC = () => {
<div className="mb-2">
<span
className={`badge ${
rental.status === "active"
(rental.displayStatus || rental.status) === "active"
? "bg-success"
: rental.status === "pending"
: (rental.displayStatus || rental.status) === "pending"
? "bg-warning"
: rental.status === "confirmed"
: (rental.displayStatus || rental.status) === "confirmed"
? "bg-info"
: "bg-danger"
}`}
>
{rental.status.charAt(0).toUpperCase() +
rental.status.slice(1)}
{(rental.displayStatus || rental.status).charAt(0).toUpperCase() +
(rental.displayStatus || rental.status).slice(1)}
</span>
</div>
@@ -512,7 +515,7 @@ const Owning: React.FC = () => {
</button>
</>
)}
{rental.status === "confirmed" && (
{(rental.displayStatus || rental.status) === "confirmed" && (
<button
className="btn btn-sm btn-outline-danger"
onClick={() => handleCancelClick(rental)}
@@ -520,7 +523,7 @@ const Owning: React.FC = () => {
Cancel
</button>
)}
{rental.status === "active" && (
{(rental.displayStatus || rental.status) === "active" && (
<button
className="btn btn-sm btn-success"
onClick={() => handleCompleteClick(rental)}

View File

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

View File

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