fix: apply consistent-type-imports across API codebase (728 lint errors)
- Convert `import type { X }` to `import { type X }` (inline-type-imports style)
- Suppress consistent-type-imports for `typeof import()` in instrument.ts
- Includes uncommitted agent work: metrics module, redis caching, audit logs,
saved searches, circuit breaker, rate limiting, and admin enhancements
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export { ListingApprovedEventHandler } from './listing-approved.handler';
|
||||
export { ListingStatusChangedHandler } from './listing-status-changed.handler';
|
||||
export { SavedSearchAlertHandler } from './saved-search-alert.handler';
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type CommandBus } from '@nestjs/cqrs';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { SendNotificationCommand } from '@modules/notifications';
|
||||
import { type PrismaService, type 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user