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:
@@ -0,0 +1,183 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type CommandBus } from '@nestjs/cqrs';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SendNotificationCommand } from '@modules/notifications';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Daily cron job that checks saved searches against new listings published since lastAlertAt.
|
||||
* This complements the real-time event-based handler by catching any listings that
|
||||
* were missed (e.g., due to service downtime or event processing failures).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SavedSearchAlertCronService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_8AM, { name: 'saved-search-daily-alerts' })
|
||||
async processAlerts(): Promise<void> {
|
||||
this.logger.log('Starting daily saved search alert processing...', 'SavedSearchAlertCron');
|
||||
|
||||
try {
|
||||
const savedSearches = await this.prisma.savedSearch.findMany({
|
||||
where: { alertEnabled: true },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, fullName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (savedSearches.length === 0) {
|
||||
this.logger.log('No saved searches with alerts enabled', 'SavedSearchAlertCron');
|
||||
return;
|
||||
}
|
||||
|
||||
let totalAlerts = 0;
|
||||
|
||||
for (const search of savedSearches) {
|
||||
try {
|
||||
const matchCount = await this.checkAndAlert(search);
|
||||
totalAlerts += matchCount;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to process alerts for saved search ${search.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'SavedSearchAlertCron',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Daily saved search alert processing completed: ${totalAlerts} alerts sent for ${savedSearches.length} searches`,
|
||||
'SavedSearchAlertCron',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Daily saved search alert processing failed: ${(err as Error).message}`,
|
||||
undefined,
|
||||
'SavedSearchAlertCron',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkAndAlert(
|
||||
search: {
|
||||
id: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
filters: unknown;
|
||||
lastAlertAt: Date | null;
|
||||
user: { id: string; email: string | null; fullName: string | null };
|
||||
},
|
||||
): Promise<number> {
|
||||
const filters = search.filters as Record<string, unknown>;
|
||||
|
||||
// Build query for new listings since last alert
|
||||
const sinceDate = search.lastAlertAt ?? new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
status: 'ACTIVE',
|
||||
publishedAt: { gte: sinceDate },
|
||||
sellerId: { not: search.userId },
|
||||
property: this.buildPropertyWhereClause(filters),
|
||||
};
|
||||
|
||||
if (filters['transactionType']) {
|
||||
where['transactionType'] = filters['transactionType'];
|
||||
}
|
||||
|
||||
if (filters['priceMin'] || filters['priceMax']) {
|
||||
where['priceVND'] = {
|
||||
...(filters['priceMin'] ? { gte: BigInt(Number(filters['priceMin'])) } : {}),
|
||||
...(filters['priceMax'] ? { lte: BigInt(Number(filters['priceMax'])) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const newListings = await this.prisma.listing.findMany({
|
||||
where,
|
||||
include: { property: true },
|
||||
take: 10,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
});
|
||||
|
||||
if (newListings.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Send a digest notification
|
||||
if (!search.user.email) {
|
||||
this.logger.warn(
|
||||
`User ${search.user.id} has no email, skipping saved search digest alert`,
|
||||
'SavedSearchAlertCron',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.commandBus.execute(
|
||||
new SendNotificationCommand(
|
||||
search.user.id,
|
||||
'EMAIL',
|
||||
'saved_search_digest',
|
||||
{
|
||||
userName: search.user.fullName ?? 'Người dùng',
|
||||
searchName: search.name,
|
||||
matchCount: newListings.length,
|
||||
listings: newListings.slice(0, 5).map((l) => ({
|
||||
title: l.property.title,
|
||||
price: Number(l.priceVND).toLocaleString('vi-VN'),
|
||||
district: l.property.district,
|
||||
city: l.property.city,
|
||||
url: `/listings/${l.id}`,
|
||||
})),
|
||||
},
|
||||
search.user.email,
|
||||
),
|
||||
);
|
||||
|
||||
// Update lastAlertAt
|
||||
await this.prisma.savedSearch.update({
|
||||
where: { id: search.id },
|
||||
data: { lastAlertAt: new Date() },
|
||||
});
|
||||
|
||||
return 1;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to send digest alert for search ${search.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'SavedSearchAlertCron',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private buildPropertyWhereClause(filters: Record<string, unknown>): Record<string, unknown> {
|
||||
const propertyWhere: Record<string, unknown> = {};
|
||||
|
||||
if (filters['propertyType']) {
|
||||
propertyWhere['propertyType'] = filters['propertyType'];
|
||||
}
|
||||
|
||||
if (filters['district']) {
|
||||
propertyWhere['district'] = filters['district'];
|
||||
}
|
||||
|
||||
if (filters['city']) {
|
||||
propertyWhere['city'] = filters['city'];
|
||||
}
|
||||
|
||||
if (filters['areaMin'] || filters['areaMax']) {
|
||||
propertyWhere['areaM2'] = {
|
||||
...(filters['areaMin'] ? { gte: Number(filters['areaMin']) } : {}),
|
||||
...(filters['areaMax'] ? { lte: Number(filters['areaMax']) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (filters['bedrooms']) {
|
||||
propertyWhere['bedrooms'] = { gte: Number(filters['bedrooms']) };
|
||||
}
|
||||
|
||||
return propertyWhere;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user