Skip to content
This repository was archived by the owner on Nov 11, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions backend/vizzy-backend/src/dtos/listing/rental-availability.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -347,4 +348,27 @@ export class ListingDatabaseHelper {

return await ListingDatabaseHelper.getListingById(supabase, listingId);
}

static async getRentalAvailability(
supabase: SupabaseClient,
listingId: number,
): Promise<RentalAvailabilityDto[]> {
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[];
}
}
38 changes: 38 additions & 0 deletions backend/vizzy-backend/src/listing/listing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
ApiConsumes,
ApiHeader,
} from '@nestjs/swagger';
import { RentalAvailabilityDto } from '@/dtos/listing/rental-availability.dto';
/**
* Controller for managing listing operations
*/
Expand Down Expand Up @@ -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<RentalAvailabilityDto[]> {
this.logger.info(`Using controller getRentalAvailability for ID: ${id}`);
const skipCacheFlag = skipCache === 'true';
return this.listingService.getRentalAvailability(id, skipCacheFlag);
}
}
61 changes: 61 additions & 0 deletions backend/vizzy-backend/src/listing/listing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<RentalAvailabilityDto[]> {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -151,9 +152,9 @@ export class ProposalDatabaseHelper {
userId: string,
): Promise<void> {
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') {
Expand Down Expand Up @@ -217,4 +218,18 @@ export class ProposalDatabaseHelper {

return data;
}
/* static async createRentalAvailability(
supabase: SupabaseClient,
rentalAvailability: RentalAvailabilityDto,
): Promise<void> {
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,
);
}
} */
}
24 changes: 24 additions & 0 deletions backend/vizzy-backend/src/proposal/proposal.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -329,6 +338,10 @@ describe('ProposalService', () => {
userId,
true,
);
expect(ProposalDatabaseHelper.getProposalDataById).toHaveBeenCalledWith(
mockSupabaseClient,
proposalId,
);
expect(ProposalDatabaseHelper.updateProposalStatus).toHaveBeenCalledWith(
mockSupabaseClient,
proposalId,
Expand All @@ -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);
Expand All @@ -358,6 +374,10 @@ describe('ProposalService', () => {
userId,
true,
);
expect(ProposalDatabaseHelper.getProposalDataById).toHaveBeenCalledWith(
mockSupabaseClient,
proposalId,
);
expect(ProposalDatabaseHelper.updateProposalStatus).toHaveBeenCalledWith(
mockSupabaseClient,
proposalId,
Expand All @@ -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'));
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand All @@ -651,6 +695,10 @@ describe('ProposalService', () => {
userId,
true,
);
expect(ProposalDatabaseHelper.getProposalDataById).toHaveBeenCalledWith(
mockSupabaseClient,
proposalId,
);
expect(ProposalDatabaseHelper.updateProposalStatus).toHaveBeenCalledWith(
mockSupabaseClient,
proposalId,
Expand Down
Loading