diff --git a/backend/routes/conditionChecks.js b/backend/routes/conditionChecks.js index d984c5a..2154079 100644 --- a/backend/routes/conditionChecks.js +++ b/backend/routes/conditionChecks.js @@ -7,6 +7,49 @@ const { IMAGE_LIMITS } = require("../config/imageLimits"); const router = express.Router(); +// Get condition checks for multiple rentals in a single request (batch) +router.get("/batch", authenticateToken, async (req, res) => { + try { + const { rentalIds } = req.query; + + if (!rentalIds) { + return res.json({ + success: true, + conditionChecks: [], + }); + } + + const ids = rentalIds.split(",").filter((id) => id.trim()); + + if (ids.length === 0) { + return res.json({ + success: true, + conditionChecks: [], + }); + } + + const conditionChecks = + await ConditionCheckService.getConditionChecksForRentals(ids); + + res.json({ + success: true, + conditionChecks, + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Error fetching batch condition checks", { + error: error.message, + stack: error.stack, + rentalIds: req.query.rentalIds, + }); + + res.status(500).json({ + success: false, + error: "Failed to fetch condition checks", + }); + } +}); + // Submit a condition check router.post("/:rentalId", authenticateToken, async (req, res) => { try { @@ -20,9 +63,13 @@ router.post("/:rentalId", authenticateToken, async (req, res) => { : []; // Validate S3 keys format and folder - const keyValidation = validateS3Keys(imageFilenamesArray, "condition-checks", { - maxKeys: IMAGE_LIMITS.conditionChecks, - }); + const keyValidation = validateS3Keys( + imageFilenamesArray, + "condition-checks", + { + maxKeys: IMAGE_LIMITS.conditionChecks, + } + ); if (!keyValidation.valid) { return res.status(400).json({ success: false, @@ -69,69 +116,16 @@ router.post("/:rentalId", authenticateToken, async (req, res) => { } }); -// 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, - stack: error.stack, - 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, - stack: error.stack, - 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 { rentalIds } = req.query; + const ids = rentalIds ? rentalIds.split(",").filter((id) => id.trim()) : []; const availableChecks = await ConditionCheckService.getAvailableChecks( - userId + userId, + ids ); res.json({ diff --git a/backend/services/conditionCheckService.js b/backend/services/conditionCheckService.js index d4fefa8..cfe7718 100644 --- a/backend/services/conditionCheckService.js +++ b/backend/services/conditionCheckService.js @@ -156,13 +156,21 @@ class ConditionCheckService { } /** - * Get all condition checks for a rental - * @param {string} rentalId - Rental ID + * Get all condition checks for multiple rentals (batch) + * @param {Array} rentalIds - Array of Rental IDs * @returns {Array} - Array of condition checks with user info */ - static async getConditionChecks(rentalId) { + static async getConditionChecksForRentals(rentalIds) { + if (!rentalIds || rentalIds.length === 0) { + return []; + } + const checks = await ConditionCheck.findAll({ - where: { rentalId }, + where: { + rentalId: { + [Op.in]: rentalIds, + }, + }, include: [ { model: User, @@ -176,119 +184,24 @@ class ConditionCheckService { 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", "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.imageFilenames.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 + * @param {Array} rentalIds - Array of rental IDs to check * @returns {Array} - Array of available condition checks */ - static async getAvailableChecks(userId) { + static async getAvailableChecks(userId, rentalIds) { + if (!rentalIds || rentalIds.length === 0) { + return []; + } + const now = new Date(); const twentyFourHours = 24 * 60 * 60 * 1000; - // Find rentals where user is owner or renter + // Find specified rentals where user is owner or renter const rentals = await Rental.findAll({ where: { + id: { [Op.in]: rentalIds }, [Op.or]: [{ ownerId: userId }, { renterId: userId }], status: { [Op.in]: ["confirmed", "active", "completed"], diff --git a/backend/tests/unit/routes/conditionChecks.test.js b/backend/tests/unit/routes/conditionChecks.test.js index 9443206..9f532a5 100644 --- a/backend/tests/unit/routes/conditionChecks.test.js +++ b/backend/tests/unit/routes/conditionChecks.test.js @@ -11,8 +11,6 @@ jest.mock('../../../middleware/auth', () => ({ jest.mock('../../../services/conditionCheckService', () => ({ submitConditionCheck: jest.fn(), - getConditionChecks: jest.fn(), - getConditionCheckTimeline: jest.fn(), getAvailableChecks: jest.fn(), })); @@ -183,97 +181,8 @@ describe('Condition Check Routes', () => { }); }); - describe('GET /condition-checks/:rentalId', () => { - it('should return condition checks for a rental', async () => { - const mockChecks = [ - { - id: 'check-1', - checkType: 'pre_rental', - notes: 'Good condition', - createdAt: '2024-01-01T00:00:00Z', - }, - { - id: 'check-2', - checkType: 'post_rental', - notes: 'Minor wear', - createdAt: '2024-01-15T00:00:00Z', - }, - ]; - - ConditionCheckService.getConditionChecks.mockResolvedValue(mockChecks); - - const response = await request(app) - .get('/condition-checks/rental-123'); - - expect(response.status).toBe(200); - expect(response.body.success).toBe(true); - expect(response.body.conditionChecks).toHaveLength(2); - expect(response.body.conditionChecks[0].checkType).toBe('pre_rental'); - expect(ConditionCheckService.getConditionChecks).toHaveBeenCalledWith('rental-123'); - }); - - it('should return empty array when no checks exist', async () => { - ConditionCheckService.getConditionChecks.mockResolvedValue([]); - - const response = await request(app) - .get('/condition-checks/rental-456'); - - expect(response.status).toBe(200); - expect(response.body.success).toBe(true); - expect(response.body.conditionChecks).toHaveLength(0); - }); - - it('should handle service errors', async () => { - ConditionCheckService.getConditionChecks.mockRejectedValue( - new Error('Database error') - ); - - const response = await request(app) - .get('/condition-checks/rental-123'); - - expect(response.status).toBe(500); - expect(response.body.success).toBe(false); - expect(response.body.error).toBe('Failed to fetch condition checks'); - }); - }); - - describe('GET /condition-checks/:rentalId/timeline', () => { - it('should return condition check timeline', async () => { - const mockTimeline = { - rental: { id: 'rental-123', status: 'completed' }, - checks: [ - { type: 'pre_rental', status: 'completed', completedAt: '2024-01-01' }, - { type: 'post_rental', status: 'pending', completedAt: null }, - ], - }; - - ConditionCheckService.getConditionCheckTimeline.mockResolvedValue(mockTimeline); - - const response = await request(app) - .get('/condition-checks/rental-123/timeline'); - - expect(response.status).toBe(200); - expect(response.body.success).toBe(true); - expect(response.body.timeline).toMatchObject(mockTimeline); - expect(ConditionCheckService.getConditionCheckTimeline).toHaveBeenCalledWith('rental-123'); - }); - - it('should handle service errors', async () => { - ConditionCheckService.getConditionCheckTimeline.mockRejectedValue( - new Error('Rental not found') - ); - - const response = await request(app) - .get('/condition-checks/rental-123/timeline'); - - expect(response.status).toBe(500); - expect(response.body.success).toBe(false); - expect(response.body.error).toBe('Rental not found'); - }); - }); - describe('GET /condition-checks', () => { - it('should return available checks for current user', async () => { + it('should return available checks for specified rentals', async () => { const mockAvailableChecks = [ { rentalId: 'rental-1', @@ -292,16 +201,16 @@ describe('Condition Check Routes', () => { ConditionCheckService.getAvailableChecks.mockResolvedValue(mockAvailableChecks); const response = await request(app) - .get('/condition-checks'); + .get('/condition-checks?rentalIds=rental-1,rental-2'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.availableChecks).toHaveLength(2); expect(response.body.availableChecks[0].itemName).toBe('Camera'); - expect(ConditionCheckService.getAvailableChecks).toHaveBeenCalledWith('user-123'); + expect(ConditionCheckService.getAvailableChecks).toHaveBeenCalledWith('user-123', ['rental-1', 'rental-2']); }); - it('should return empty array when no checks available', async () => { + it('should return empty array when no rentalIds provided', async () => { ConditionCheckService.getAvailableChecks.mockResolvedValue([]); const response = await request(app) @@ -310,6 +219,7 @@ describe('Condition Check Routes', () => { expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.availableChecks).toHaveLength(0); + expect(ConditionCheckService.getAvailableChecks).toHaveBeenCalledWith('user-123', []); }); it('should handle service errors', async () => { @@ -318,7 +228,7 @@ describe('Condition Check Routes', () => { ); const response = await request(app) - .get('/condition-checks'); + .get('/condition-checks?rentalIds=rental-1'); expect(response.status).toBe(500); expect(response.body.success).toBe(false); diff --git a/backend/tests/unit/services/conditionCheckService.test.js b/backend/tests/unit/services/conditionCheckService.test.js index 18d7752..f27ec87 100644 --- a/backend/tests/unit/services/conditionCheckService.test.js +++ b/backend/tests/unit/services/conditionCheckService.test.js @@ -122,44 +122,4 @@ describe('ConditionCheckService', () => { ).rejects.toThrow('Rental not found'); }); }); - - describe('getConditionChecks', () => { - it('should retrieve condition checks for rental', async () => { - const mockChecks = [ - { - id: 'check-1', - checkType: 'pre_rental_owner', - submittedBy: 'owner-456', - submittedAt: '2023-05-31T12:00:00Z', - photos: ['/uploads/photo1.jpg'], - notes: 'Item ready' - }, - { - id: 'check-2', - checkType: 'rental_start_renter', - submittedBy: 'renter-789', - submittedAt: '2023-06-01T11:00:00Z', - photos: ['/uploads/photo2.jpg'], - notes: 'Item received' - } - ]; - - ConditionCheck.findAll.mockResolvedValue(mockChecks); - - const result = await ConditionCheckService.getConditionChecks('rental-123'); - - expect(ConditionCheck.findAll).toHaveBeenCalledWith({ - where: { rentalId: 'rental-123' }, - include: [{ - model: User, - as: 'submittedByUser', - attributes: ['id', 'firstName', 'lastName'] - }], - order: [['submittedAt', 'ASC']] - }); - - expect(result).toEqual(mockChecks); - expect(result.length).toBe(2); - }); - }); }); \ No newline at end of file diff --git a/frontend/src/__tests__/services/api.test.ts b/frontend/src/__tests__/services/api.test.ts index e3abc46..428ba74 100644 --- a/frontend/src/__tests__/services/api.test.ts +++ b/frontend/src/__tests__/services/api.test.ts @@ -151,8 +151,7 @@ describe('API Namespaces', () => { it('has all condition check methods', () => { const expectedMethods = [ 'submitConditionCheck', - 'getConditionChecks', - 'getConditionCheckTimeline', + 'getBatchConditionChecks', 'getAvailableChecks', ]; diff --git a/frontend/src/pages/Owning.tsx b/frontend/src/pages/Owning.tsx index 6a64bc0..233a3ec 100644 --- a/frontend/src/pages/Owning.tsx +++ b/frontend/src/pages/Owning.tsx @@ -11,6 +11,7 @@ import DeclineRentalModal from "../components/DeclineRentalModal"; import ConditionCheckModal from "../components/ConditionCheckModal"; import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal"; import ReturnStatusModal from "../components/ReturnStatusModal"; +import PaymentFailedModal from "../components/PaymentFailedModal"; const Owning: React.FC = () => { // Helper function to format time @@ -73,16 +74,27 @@ const Owning: React.FC = () => { const [rentalForReturn, setRentalForReturn] = useState(null); const [showDeleteModal, setShowDeleteModal] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); + const [showPaymentFailedModal, setShowPaymentFailedModal] = useState(false); + const [paymentFailedError, setPaymentFailedError] = useState(null); + const [paymentFailedRental, setPaymentFailedRental] = useState(null); useEffect(() => { fetchListings(); fetchOwnerRentals(); - fetchAvailableChecks(); }, [user]); useEffect(() => { - if (ownerRentals.length > 0) { - fetchConditionChecks(); + // Only fetch condition checks for rentals that will be displayed (pending/confirmed/active) + const displayedRentals = ownerRentals.filter((r) => + ["pending", "confirmed", "active"].includes(r.displayStatus || r.status) + ); + if (displayedRentals.length > 0) { + const rentalIds = displayedRentals.map((r) => r.id); + fetchConditionChecks(displayedRentals); + fetchAvailableChecks(rentalIds); + } else { + setConditionChecks([]); + setAvailableChecks([]); } }, [ownerRentals]); @@ -154,9 +166,9 @@ const Owning: React.FC = () => { } }; - const fetchAvailableChecks = async () => { + const fetchAvailableChecks = async (rentalIds: string[]) => { try { - const response = await conditionCheckAPI.getAvailableChecks(); + const response = await conditionCheckAPI.getAvailableChecks(rentalIds); const checks = Array.isArray(response.data.availableChecks) ? response.data.availableChecks : []; @@ -167,22 +179,15 @@ const Owning: React.FC = () => { } }; - const fetchConditionChecks = async () => { + const fetchConditionChecks = async (rentalsToFetch: Rental[]) => { try { - const allChecks: ConditionCheck[] = []; - for (const rental of ownerRentals) { - try { - const response = await conditionCheckAPI.getConditionChecks( - rental.id - ); - if (response.data.conditionChecks) { - allChecks.push(...response.data.conditionChecks); - } - } catch (err) { - // Skip rentals with no condition checks - } + if (rentalsToFetch.length === 0) { + setConditionChecks([]); + return; } - setConditionChecks(allChecks); + const rentalIds = rentalsToFetch.map((r) => r.id); + const response = await conditionCheckAPI.getBatchConditionChecks(rentalIds); + setConditionChecks(response.data.conditionChecks || []); } catch (err) { console.error("Failed to fetch condition checks:", err); setConditionChecks([]); @@ -208,22 +213,29 @@ const Owning: React.FC = () => { } fetchOwnerRentals(); - fetchAvailableChecks(); // Refresh available checks after rental confirmation + // Note: fetchAvailableChecks() removed - it will be triggered via ownerRentals useEffect // Notify Navbar to update pending count window.dispatchEvent(new CustomEvent("rentalStatusChanged")); } catch (err: any) { console.error("Failed to accept rental request:", err); - // Check if it's a payment failure - if (err.response?.data?.error?.includes("Payment failed")) { - alert( - `Payment failed during approval: ${ - err.response.data.details || "Unknown payment error" - }` - ); + // Check if it's a payment failure (HTTP 402 or payment_failed error) + if ( + err.response?.status === 402 || + err.response?.data?.error === "payment_failed" + ) { + // Find the rental to get the item name + const rental = ownerRentals.find((r) => r.id === rentalId); + setPaymentFailedError(err.response.data); + setPaymentFailedRental(rental || null); + setShowPaymentFailedModal(true); } else { - alert("Failed to accept rental request"); + alert( + err.response?.data?.error || + err.response?.data?.details || + "Failed to accept rental request" + ); } } finally { setIsProcessingPayment(""); @@ -295,8 +307,13 @@ const Owning: React.FC = () => { }; const handleConditionCheckSuccess = () => { - fetchAvailableChecks(); - fetchConditionChecks(); + // Refetch condition checks for displayed rentals + const displayedRentals = ownerRentals.filter((r) => + ["pending", "confirmed", "active"].includes(r.displayStatus || r.status) + ); + const rentalIds = displayedRentals.map((r) => r.id); + fetchAvailableChecks(rentalIds); + fetchConditionChecks(displayedRentals); }; const handleViewConditionCheck = (check: ConditionCheck) => { @@ -481,32 +498,44 @@ const Owning: React.FC = () => {
{rental.status === "pending" && ( <> - + {rental.paymentFailedNotifiedAt && + (!rental.paymentMethodUpdatedAt || + new Date(rental.paymentFailedNotifiedAt) > + new Date(rental.paymentMethodUpdatedAt)) ? ( + + ) : ( + + )} +
+ + )} +
{((rental.displayStatus || rental.status) === "pending" || @@ -507,6 +546,20 @@ const Renting: React.FC = () => { }} conditionCheck={selectedConditionCheck} /> + + {/* Update Payment Method Modal */} + {rentalForPaymentUpdate && ( + { + setShowUpdatePaymentModal(false); + setRentalForPaymentUpdate(null); + }} + rentalId={rentalForPaymentUpdate.id} + itemName={rentalForPaymentUpdate.item?.name || "Item"} + onSuccess={handlePaymentMethodUpdated} + /> + )}
); }; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1f919e5..6df35fe 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -240,6 +240,8 @@ export const rentalAPI = { startDateTime: string; endDateTime: string; }) => api.post("/rentals/cost-preview", data), + updatePaymentMethod: (id: string, stripePaymentMethodId: string) => + api.put(`/rentals/${id}/payment-method`, { stripePaymentMethodId }), }; export const messageAPI = { @@ -335,11 +337,14 @@ export const conditionCheckAPI = { rentalId: string, data: { checkType: string; imageFilenames: string[]; notes?: string } ) => api.post(`/condition-checks/${rentalId}`, data), - getConditionChecks: (rentalId: string) => - api.get(`/condition-checks/${rentalId}`), - getConditionCheckTimeline: (rentalId: string) => - api.get(`/condition-checks/${rentalId}/timeline`), - getAvailableChecks: () => api.get("/condition-checks"), + getBatchConditionChecks: (rentalIds: string[]) => + api.get(`/condition-checks/batch`, { + params: { rentalIds: rentalIds.join(",") }, + }), + getAvailableChecks: (rentalIds: string[]) => + api.get("/condition-checks", { + params: { rentalIds: rentalIds.join(",") }, + }), }; export const feedbackAPI = {