From 95ee60b1aff4ed86558f8930e924cf2ed0247d83 Mon Sep 17 00:00:00 2001 From: zealves99 Date: Wed, 28 May 2025 16:43:11 +0100 Subject: [PATCH] feat: rental availability Added all the features to handle the insertion/fetching of new rental periods. Also changed the update status function to handle this. --- .../dtos/listing/rental-availability.dto.ts | 19 ++++++ .../helpers/listing-database.helper.ts | 24 +++++++ .../src/listing/listing.controller.ts | 38 +++++++++++ .../src/listing/listing.service.ts | 61 ++++++++++++++++++ .../helpers/proposal-database.helper.ts | 17 ++++- .../src/proposal/proposal.service.ts | 24 +++++++ .../tests/unit/proposal.service.spec.ts | 48 ++++++++++++++ .../components/proposals/rent-now-dialog.tsx | 63 +++++++++++++++++-- .../proposals/rental-proposal-dialog.tsx | 52 ++++++++++++++- .../api/listings/get-rental-availability.ts | 23 +++++++ .../tests/unit/proposal.service.spec.ts | 1 - 11 files changed, 359 insertions(+), 11 deletions(-) create mode 100644 backend/vizzy-backend/src/dtos/listing/rental-availability.dto.ts create mode 100644 frontend/vizzy/lib/api/listings/get-rental-availability.ts delete mode 100644 src/proposal/tests/unit/proposal.service.spec.ts diff --git a/backend/vizzy-backend/src/dtos/listing/rental-availability.dto.ts b/backend/vizzy-backend/src/dtos/listing/rental-availability.dto.ts new file mode 100644 index 00000000..e781cfb5 --- /dev/null +++ b/backend/vizzy-backend/src/dtos/listing/rental-availability.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class RentalAvailabilityDto { + @ApiProperty({ + description: 'Start date of the rental period', + type: String, + format: 'date-time', + example: '2024-03-15T00:00:00Z', + }) + start_date: string; + + @ApiProperty({ + description: 'End date of the rental period', + type: String, + format: 'date-time', + example: '2024-03-20T00:00:00Z', + }) + end_date: string; +} diff --git a/backend/vizzy-backend/src/listing/helpers/listing-database.helper.ts b/backend/vizzy-backend/src/listing/helpers/listing-database.helper.ts index 2f90ee92..b2af9997 100644 --- a/backend/vizzy-backend/src/listing/helpers/listing-database.helper.ts +++ b/backend/vizzy-backend/src/listing/helpers/listing-database.helper.ts @@ -4,6 +4,7 @@ import { ListingBasic } from '@/dtos/listing/listing-basic.dto'; import { ListingOptionsDto } from '@/dtos/listing/listing-options.dto'; import { HttpException, HttpStatus } from '@nestjs/common'; import { CreateListingDto } from '@/dtos/listing/create-listing.dto'; +import { RentalAvailabilityDto } from '@/dtos/listing/rental-availability.dto'; /** * Helper class for database operations related to listings * Provides methods for CRUD operations on listing data in Supabase @@ -347,4 +348,27 @@ export class ListingDatabaseHelper { return await ListingDatabaseHelper.getListingById(supabase, listingId); } + + static async getRentalAvailability( + supabase: SupabaseClient, + listingId: number, + ): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const { data, error } = await supabase + .from('rental_availability') + .select('start_date, end_date') + .eq('rental_listing_id', listingId) + .gte('end_date', today.toISOString()) // Only get periods that end today or later + .order('start_date', { ascending: true }); + + if (error) { + throw new HttpException( + `Failed to fetch rental availability: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + return data as RentalAvailabilityDto[]; + } } diff --git a/backend/vizzy-backend/src/listing/listing.controller.ts b/backend/vizzy-backend/src/listing/listing.controller.ts index dc8eaddd..5fdfe9ce 100644 --- a/backend/vizzy-backend/src/listing/listing.controller.ts +++ b/backend/vizzy-backend/src/listing/listing.controller.ts @@ -45,6 +45,7 @@ import { ApiConsumes, ApiHeader, } from '@nestjs/swagger'; +import { RentalAvailabilityDto } from '@/dtos/listing/rental-availability.dto'; /** * Controller for managing listing operations */ @@ -597,4 +598,41 @@ export class ListingController { const userId = req.user.sub; return await this.listingService.softDeleteListing(listingId, userId); } + + /** + * Retrieves rental availability periods for a specific listing + * @param id ID of the rental listing + * @param skipCache Flag to bypass cache (for testing) + * @returns Array of rental availability periods + */ + @Get(':id/availability') + @Version(API_VERSIONS.V1) + @ApiOperation({ + summary: 'Get rental availability', + description: 'Retrieves rental availability periods for a specific listing', + }) + @ApiParam({ + name: 'id', + description: 'ID of the rental listing', + type: Number, + }) + @ApiHeader({ + name: 'x-skip-cache', + description: 'Set to "true" to bypass cache (for testing purposes)', + required: false, + }) + @ApiResponse({ + status: 200, + description: 'Rental availability successfully retrieved', + type: [RentalAvailabilityDto], + }) + @ApiResponse({ status: 404, description: 'Listing not found' }) + async getRentalAvailability( + @Param('id', ParseIntPipe) id: number, + @Headers('x-skip-cache') skipCache: string, + ): Promise { + this.logger.info(`Using controller getRentalAvailability for ID: ${id}`); + const skipCacheFlag = skipCache === 'true'; + return this.listingService.getRentalAvailability(id, skipCacheFlag); + } } diff --git a/backend/vizzy-backend/src/listing/listing.service.ts b/backend/vizzy-backend/src/listing/listing.service.ts index 11e038d4..0bfb41ba 100644 --- a/backend/vizzy-backend/src/listing/listing.service.ts +++ b/backend/vizzy-backend/src/listing/listing.service.ts @@ -14,6 +14,7 @@ import { UpdateListingImagesDto, ListingImagesResponseDto, } from '@/dtos/listing/listing-images.dto'; +import { RentalAvailabilityDto } from '@/dtos/listing/rental-availability.dto'; /** * Service responsible for managing listing operations * Handles CRUD operations for listings with caching support @@ -742,4 +743,64 @@ export class ListingService { ); return result; } + + /** + * Retrieves rental availability periods for a specific listing + * Attempts to fetch from cache first, falls back to database if cache miss + * @param listingId ID of the rental listing + * @param skipCache Flag to bypass cache (for testing) + * @returns Array of rental availability periods + */ + async getRentalAvailability( + listingId: number, + skipCache = false, + ): Promise { + this.logger.info( + `Fetching rental availability for listing ID: ${listingId}`, + ); + + const redisClient = this.redisService.getRedisClient(); + const cacheKey = `listing:availability:${listingId}`; + + if (!skipCache) { + const cachedAvailability = await GlobalCacheHelper.getFromCache< + RentalAvailabilityDto[] + >(redisClient, cacheKey); + + if (cachedAvailability) { + this.logger.info( + `Cache hit for rental availability, listing ID: ${listingId}`, + ); + return cachedAvailability; + } + } else { + this.logger.info( + `Skipping cache for rental availability, listing ID: ${listingId} (skipCache flag set)`, + ); + } + + this.logger.info( + `Cache miss for rental availability, listing ID: ${listingId}, querying database`, + ); + + const supabase = this.supabaseService.getAdminClient(); + const availability = await ListingDatabaseHelper.getRentalAvailability( + supabase, + listingId, + ); + + if (availability.length > 0 && !skipCache) { + this.logger.info( + `Caching rental availability data for listing ID: ${listingId}`, + ); + await GlobalCacheHelper.setCache( + redisClient, + cacheKey, + availability, + this.CACHE_EXPIRATION, + ); + } + + return availability; + } } diff --git a/backend/vizzy-backend/src/proposal/helpers/proposal-database.helper.ts b/backend/vizzy-backend/src/proposal/helpers/proposal-database.helper.ts index f4a32a6f..d4bd3e3b 100644 --- a/backend/vizzy-backend/src/proposal/helpers/proposal-database.helper.ts +++ b/backend/vizzy-backend/src/proposal/helpers/proposal-database.helper.ts @@ -14,6 +14,7 @@ import { RawProposalListData, RawSingleProposalData, } from './proposal-database.types'; +//import { RentalAvailabilityDto } from '@/dtos/listing/rental-availability.dto'; export class ProposalDatabaseHelper { private static mapRawToProposalDto( @@ -151,9 +152,9 @@ export class ProposalDatabaseHelper { userId: string, ): Promise { const { error } = await supabase.rpc('update_proposal_status', { - user_id: userId, proposal_id: proposalId, new_status: status, + user_id: userId, }); if (error) { if (error.code === 'P0001') { @@ -217,4 +218,18 @@ export class ProposalDatabaseHelper { return data; } + /* static async createRentalAvailability( + supabase: SupabaseClient, + rentalAvailability: RentalAvailabilityDto, + ): Promise { + const { error } = await supabase + .from('rental_availability') + .insert(rentalAvailability); + if (error) { + throw new HttpException( + `Failed to create rental availability: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } */ } diff --git a/backend/vizzy-backend/src/proposal/proposal.service.ts b/backend/vizzy-backend/src/proposal/proposal.service.ts index 60d13512..b7d14b1d 100644 --- a/backend/vizzy-backend/src/proposal/proposal.service.ts +++ b/backend/vizzy-backend/src/proposal/proposal.service.ts @@ -18,6 +18,7 @@ import { ProposalDatabaseHelper } from './helpers/proposal-database.helper'; import { CreateProposalDto } from '@/dtos/proposal/create-proposal.dto'; import { ProposalImageHelper } from './helpers/proposal-image.helper'; import { ProposalStatus } from '@/constants/proposal-status.enum'; +import { ProposalType } from '@/constants/proposal-types.enum'; import { ProposalImageDto } from '@/dtos/proposal/proposal-images.dto'; import { FetchProposalsOptions } from './helpers/proposal-database.types'; import { GlobalCacheHelper } from '@/common/helpers/global-cache.helper'; @@ -242,6 +243,16 @@ export class ProposalService { await this.verifyProposalAccess(proposalId, userId, true); } + // Get proposal data before updating to check if it's a rental proposal + const proposal = await ProposalDatabaseHelper.getProposalDataById( + this.supabase, + proposalId, + ); + + if (!proposal) { + throw new NotFoundException(`Proposal with ID ${proposalId} not found`); + } + await ProposalDatabaseHelper.updateProposalStatus( this.supabase, proposalId, @@ -267,6 +278,19 @@ export class ProposalService { // Invalidate sender and receiver caches await this.invalidateUserProposalCaches(proposalMeta.sender_id); await this.invalidateUserProposalCaches(proposalMeta.receiver_id); + + // If this is a rental proposal, invalidate the rental availability cache + if (proposal.proposal_type === ProposalType.RENTAL) { + const listingId = proposal.listing_id; + const rentalAvailabilityCacheKey = `listing:availability:${listingId}`; + await GlobalCacheHelper.invalidateCache( + redisClient, + rentalAvailabilityCacheKey, + ); + this.logger.info( + `Service: Invalidated rental availability cache for listing ${listingId}`, + ); + } } this.logger.info( diff --git a/backend/vizzy-backend/src/proposal/tests/unit/proposal.service.spec.ts b/backend/vizzy-backend/src/proposal/tests/unit/proposal.service.spec.ts index 5fca34a5..34677a53 100644 --- a/backend/vizzy-backend/src/proposal/tests/unit/proposal.service.spec.ts +++ b/backend/vizzy-backend/src/proposal/tests/unit/proposal.service.spec.ts @@ -309,9 +309,18 @@ describe('ProposalService', () => { sender_id: 'user-456', receiver_id: userId, }; + const mockProposal = { + id: proposalId, + proposal_type: ProposalType.SALE, + listing_id: 123, + ...mockProposalMeta, + }; it('should update proposal status successfully', async () => { jest.spyOn(service, 'verifyProposalAccess').mockResolvedValue(undefined); + ( + ProposalDatabaseHelper.getProposalDataById as jest.Mock + ).mockResolvedValue(mockProposal); ( ProposalDatabaseHelper.updateProposalStatus as jest.Mock ).mockResolvedValue(undefined); @@ -329,6 +338,10 @@ describe('ProposalService', () => { userId, true, ); + expect(ProposalDatabaseHelper.getProposalDataById).toHaveBeenCalledWith( + mockSupabaseClient, + proposalId, + ); expect(ProposalDatabaseHelper.updateProposalStatus).toHaveBeenCalledWith( mockSupabaseClient, proposalId, @@ -341,6 +354,9 @@ describe('ProposalService', () => { it('should handle cancel status by verifying sender', async () => { jest.spyOn(service, 'verifyProposalSender').mockResolvedValue(undefined); + ( + ProposalDatabaseHelper.getProposalDataById as jest.Mock + ).mockResolvedValue(mockProposal); ( ProposalDatabaseHelper.updateProposalStatus as jest.Mock ).mockResolvedValue(undefined); @@ -358,6 +374,10 @@ describe('ProposalService', () => { userId, true, ); + expect(ProposalDatabaseHelper.getProposalDataById).toHaveBeenCalledWith( + mockSupabaseClient, + proposalId, + ); expect(ProposalDatabaseHelper.updateProposalStatus).toHaveBeenCalledWith( mockSupabaseClient, proposalId, @@ -368,6 +388,9 @@ describe('ProposalService', () => { it('should throw error when update fails', async () => { jest.spyOn(service, 'verifyProposalAccess').mockResolvedValue(undefined); + ( + ProposalDatabaseHelper.getProposalDataById as jest.Mock + ).mockResolvedValue(mockProposal); ( ProposalDatabaseHelper.updateProposalStatus as jest.Mock ).mockRejectedValue(new Error('Database error')); @@ -377,6 +400,18 @@ describe('ProposalService', () => { ).rejects.toThrow(HttpException); expect(mockLogger.error).toHaveBeenCalled(); }); + + it('should throw NotFoundException when proposal not found', async () => { + jest.spyOn(service, 'verifyProposalAccess').mockResolvedValue(undefined); + ( + ProposalDatabaseHelper.getProposalDataById as jest.Mock + ).mockResolvedValue(null); + + await expect( + service.updateStatus(proposalId, status, userId), + ).rejects.toThrow(NotFoundException); + expect(mockLogger.error).toHaveBeenCalled(); + }); }); describe('verifyProposalAccess', () => { @@ -631,9 +666,18 @@ describe('ProposalService', () => { sender_id: userId, receiver_id: 'user-456', }; + const mockProposal = { + id: proposalId, + proposal_type: ProposalType.SALE, + listing_id: 123, + ...mockProposalMeta, + }; it('should cancel proposal successfully', async () => { jest.spyOn(service, 'verifyProposalSender').mockResolvedValue(undefined); + ( + ProposalDatabaseHelper.getProposalDataById as jest.Mock + ).mockResolvedValue(mockProposal); ( ProposalDatabaseHelper.updateProposalStatus as jest.Mock ).mockResolvedValue(undefined); @@ -651,6 +695,10 @@ describe('ProposalService', () => { userId, true, ); + expect(ProposalDatabaseHelper.getProposalDataById).toHaveBeenCalledWith( + mockSupabaseClient, + proposalId, + ); expect(ProposalDatabaseHelper.updateProposalStatus).toHaveBeenCalledWith( mockSupabaseClient, proposalId, diff --git a/frontend/vizzy/components/proposals/rent-now-dialog.tsx b/frontend/vizzy/components/proposals/rent-now-dialog.tsx index 41bdd16b..a074c10b 100644 --- a/frontend/vizzy/components/proposals/rent-now-dialog.tsx +++ b/frontend/vizzy/components/proposals/rent-now-dialog.tsx @@ -1,7 +1,7 @@ 'use client'; import type React from 'react'; -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import Image from 'next/image'; import { Button } from '@/components/ui/common/button'; import { @@ -20,12 +20,16 @@ import type { DateRange } from 'react-day-picker'; import { CreateProposalDto } from '@/types/create-proposal'; import { createProposal } from '@/lib/api/proposals/create-proposal'; import { CalendarIcon } from 'lucide-react'; -import { format } from 'date-fns'; +import { format, isWithinInterval } from 'date-fns'; import { Calendar } from '@/components/ui/data-display/calendar'; import { cn } from '@/lib/utils/shadcn-merge'; import { stripTimezone } from '@/lib/utils/dates'; import { useTranslations } from 'next-intl'; import { toast } from 'sonner'; +import { + getRentalAvailability, + type RentalAvailability, +} from '@/lib/api/listings/get-rental-availability'; interface Product { id: string; @@ -62,6 +66,35 @@ export function RentNowDialog({ from: undefined, to: undefined, }); + const [unavailableDates, setUnavailableDates] = useState< + RentalAvailability[] + >([]); + + useEffect(() => { + if (open) { + // Fetch rental availability when dialog opens + getRentalAvailability(Number(product.id)) + .then((availability) => { + setUnavailableDates(availability); + }) + .catch((error) => { + console.error('Failed to fetch rental availability:', error); + toast.error('Failed to load rental availability', { + description: 'Some dates may be unavailable', + duration: 4000, + }); + }); + } + }, [open, product.id]); + + const isDateUnavailable = (date: Date) => { + return unavailableDates.some((period) => { + const start = new Date(period.start_date); + const end = new Date(period.end_date); + return isWithinInterval(date, { start, end }); + }); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -190,10 +223,15 @@ export function RentNowDialog({ {calendarOpen && (
e.stopPropagation()} > @@ -212,8 +250,21 @@ export function RentNowDialog({ mode="range" selected={dateRange} onSelect={setDateRange} - numberOfMonths={2} - disabled={{ before: new Date() }} + numberOfMonths={window?.innerWidth < 768 ? 1 : 2} + disabled={(date) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return date < today || isDateUnavailable(date); + }} + modifiers={{ + unavailable: (date) => isDateUnavailable(date), + }} + modifiersStyles={{ + unavailable: { + color: 'var(--destructive)', + textDecoration: 'line-through', + }, + }} initialFocus />
diff --git a/frontend/vizzy/components/proposals/rental-proposal-dialog.tsx b/frontend/vizzy/components/proposals/rental-proposal-dialog.tsx index 6a732515..cacc5f06 100644 --- a/frontend/vizzy/components/proposals/rental-proposal-dialog.tsx +++ b/frontend/vizzy/components/proposals/rental-proposal-dialog.tsx @@ -1,7 +1,7 @@ 'use client'; import type React from 'react'; -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import Image from 'next/image'; import { Button } from '@/components/ui/common/button'; import { @@ -21,12 +21,16 @@ import type { DateRange } from 'react-day-picker'; import { CreateProposalDto } from '@/types/create-proposal'; import { createProposal } from '@/lib/api/proposals/create-proposal'; import { CalendarIcon } from 'lucide-react'; -import { format } from 'date-fns'; +import { format, isWithinInterval } from 'date-fns'; import { Calendar } from '@/components/ui/data-display/calendar'; import { cn } from '@/lib/utils/shadcn-merge'; import { stripTimezone } from '@/lib/utils/dates'; import { useTranslations } from 'next-intl'; import { toast } from 'sonner'; +import { + getRentalAvailability, + type RentalAvailability, +} from '@/lib/api/listings/get-rental-availability'; interface Product { id: string; @@ -65,6 +69,35 @@ export function RentalProposalDialog({ from: undefined, to: undefined, }); + const [unavailableDates, setUnavailableDates] = useState< + RentalAvailability[] + >([]); + + useEffect(() => { + if (open) { + // Fetch rental availability when dialog opens + getRentalAvailability(Number(product.id)) + .then((availability) => { + setUnavailableDates(availability); + }) + .catch((error) => { + console.error('Failed to fetch rental availability:', error); + toast.error('Failed to load rental availability', { + description: 'Some dates may be unavailable', + duration: 4000, + }); + }); + } + }, [open, product.id]); + + const isDateUnavailable = (date: Date) => { + return unavailableDates.some((period) => { + const start = new Date(period.start_date); + const end = new Date(period.end_date); + return isWithinInterval(date, { start, end }); + }); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -236,7 +269,20 @@ export function RentalProposalDialog({ selected={dateRange} onSelect={setDateRange} numberOfMonths={window?.innerWidth < 768 ? 1 : 2} - disabled={{ before: new Date() }} + disabled={(date) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return date < today || isDateUnavailable(date); + }} + modifiers={{ + unavailable: (date) => isDateUnavailable(date), + }} + modifiersStyles={{ + unavailable: { + color: 'var(--destructive)', + textDecoration: 'line-through', + }, + }} initialFocus /> diff --git a/frontend/vizzy/lib/api/listings/get-rental-availability.ts b/frontend/vizzy/lib/api/listings/get-rental-availability.ts new file mode 100644 index 00000000..c96690f5 --- /dev/null +++ b/frontend/vizzy/lib/api/listings/get-rental-availability.ts @@ -0,0 +1,23 @@ +import { getApiUrl } from '../core/client'; + +export interface RentalAvailability { + start_date: string; + end_date: string; +} + +export async function getRentalAvailability( + listingId: number, +): Promise { + const response = await fetch( + getApiUrl(`listings/${listingId}/availability`), + { + method: 'GET', + }, + ); + + if (!response.ok) { + throw new Error('Failed to get rental availability'); + } + + return await response.json(); +} diff --git a/src/proposal/tests/unit/proposal.service.spec.ts b/src/proposal/tests/unit/proposal.service.spec.ts deleted file mode 100644 index 0519ecba..00000000 --- a/src/proposal/tests/unit/proposal.service.spec.ts +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file