import { Injectable } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; import { OnEvent } from '@nestjs/event-emitter'; import { SendNotificationCommand } from '@modules/notifications'; import { PrismaService, LoggerService } from '@modules/shared'; /** * When a new listing is approved, check all saved searches with alerts enabled * and notify users whose filters match the new listing. */ @Injectable() export class SavedSearchAlertHandler { constructor( private readonly prisma: PrismaService, private readonly commandBus: CommandBus, private readonly logger: LoggerService, ) {} @OnEvent('listing.approved') async handle(payload: { listingId: string }): Promise { this.logger.log( `Checking saved search alerts for approved listing ${payload.listingId}`, 'SavedSearchAlertHandler', ); try { // Fetch the listing with property details const listing = await this.prisma.listing.findUnique({ where: { id: payload.listingId }, include: { property: true }, }); if (!listing || !listing.property) { return; } // Find all saved searches with alerts enabled const savedSearches = await this.prisma.savedSearch.findMany({ where: { alertEnabled: true }, include: { user: { select: { id: true, email: true, fullName: true } }, }, }); let matchCount = 0; for (const search of savedSearches) { // Skip if search belongs to the listing owner if (search.userId === listing.sellerId) { continue; } const filters = search.filters as Record; if (this.matchesFilters(listing, listing.property, filters)) { matchCount++; await this.sendAlert(search, listing, listing.property); } } if (matchCount > 0) { this.logger.log( `Sent ${matchCount} saved search alerts for listing ${payload.listingId}`, 'SavedSearchAlertHandler', ); } } catch (err) { this.logger.warn( `Saved search alert processing failed for listing ${payload.listingId}: ${err instanceof Error ? err.message : String(err)}`, 'SavedSearchAlertHandler', ); } } /** * Check if a listing matches the saved search filters. * Filters are a flexible JSON object matching SearchPropertiesDto fields. */ private matchesFilters( listing: { transactionType: string; priceVND: bigint; sellerId: string }, property: { propertyType: string; areaM2: number; bedrooms: number | null; district: string; city: string; }, filters: Record, ): boolean { if (filters['transactionType'] && filters['transactionType'] !== listing.transactionType) { return false; } if (filters['propertyType'] && filters['propertyType'] !== property.propertyType) { return false; } if (filters['district'] && filters['district'] !== property.district) { return false; } if (filters['city'] && filters['city'] !== property.city) { return false; } const price = Number(listing.priceVND); if (filters['priceMin'] && price < Number(filters['priceMin'])) { return false; } if (filters['priceMax'] && price > Number(filters['priceMax'])) { return false; } if (filters['areaMin'] && property.areaM2 < Number(filters['areaMin'])) { return false; } if (filters['areaMax'] && property.areaM2 > Number(filters['areaMax'])) { return false; } if (filters['bedrooms'] && property.bedrooms !== null && property.bedrooms < Number(filters['bedrooms'])) { return false; } return true; } private async sendAlert( search: { id: string; name: string; user: { id: string; email: string | null; fullName: string | null } }, listing: { id: string; priceVND: bigint }, property: { title: string; district: string; city: string }, ): Promise { if (!search.user.email) { this.logger.warn( `User ${search.user.id} has no email, skipping saved search alert`, 'SavedSearchAlertHandler', ); return; } try { await this.commandBus.execute( new SendNotificationCommand( search.user.id, 'EMAIL', 'saved_search_alert', { userName: search.user.fullName ?? 'Người dùng', searchName: search.name, listingTitle: property.title, listingPrice: Number(listing.priceVND).toLocaleString('vi-VN'), listingDistrict: property.district, listingCity: property.city, listingUrl: `/listings/${listing.id}`, }, search.user.email, ), ); // Update lastAlertAt await this.prisma.savedSearch.update({ where: { id: search.id }, data: { lastAlertAt: new Date() }, }); } catch (err) { this.logger.warn( `Failed to send saved search alert to user ${search.user.id}: ${err instanceof Error ? err.message : String(err)}`, 'SavedSearchAlertHandler', ); } } }