Files
goodgo-platform/apps/api/src/modules/search/infrastructure/event-handlers/saved-search-alert.handler.ts
Ho Ngoc Hai 25420720e7 fix(api,ci): remove type-only imports for DI and isolate CI ports from dev
- Remove `type` keyword from NestJS injectable class imports across all
  modules to fix runtime DI resolution (330+ handler/listener files)
- Offset CI docker-compose ports (5433/6380/8109/9002) to avoid
  conflicts with running dev containers
- Update .env.test, playwright.config.ts, and e2e workflow to use
  isolated CI ports with configurable overrides
- Fix prisma/seed.ts to use deterministic IDs for Prisma 7 upsert
  compatibility (phoneHash replaced phone as unique index)
- Add dedicated Docker bridge network for CI service containers

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
2026-04-13 01:40:14 +07:00

175 lines
5.1 KiB
TypeScript

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<void> {
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<string, unknown>;
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<string, unknown>,
): 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<void> {
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',
);
}
}
}