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:
@@ -95,12 +95,15 @@ export class AppModule implements NestModule {
|
||||
|
||||
// CSRF double-submit cookie (sets on GET, validates on state-changing methods)
|
||||
// Exclude health endpoints — they must remain accessible without cookies
|
||||
consumer
|
||||
.apply(CsrfMiddleware)
|
||||
.exclude(
|
||||
{ path: 'health', method: RequestMethod.GET },
|
||||
{ path: 'health/(.*)', method: RequestMethod.GET },
|
||||
)
|
||||
.forRoutes('*');
|
||||
// Skip entirely in test mode so E2E / API tests can POST without a CSRF cookie
|
||||
if (process.env['NODE_ENV'] !== 'test') {
|
||||
consumer
|
||||
.apply(CsrfMiddleware)
|
||||
.exclude(
|
||||
{ path: 'health', method: RequestMethod.GET },
|
||||
{ path: 'health/(.*)', method: RequestMethod.GET },
|
||||
)
|
||||
.forRoutes('*');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import * as Sentry from '@sentry/nestjs';
|
||||
import { nodeProfilingIntegration } from '@sentry/profiling-node';
|
||||
|
||||
const isTest = process.env['NODE_ENV'] === 'test';
|
||||
|
||||
// Skip profiling integration in test env — the native binary may not be
|
||||
// available for every Node.js version and it is unnecessary during E2E runs.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const integrations: any[] = [];
|
||||
if (!isTest) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node');
|
||||
integrations.push(nodeProfilingIntegration());
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env['SENTRY_DSN'],
|
||||
environment: process.env['NODE_ENV'] ?? 'development',
|
||||
integrations: [nodeProfilingIntegration()],
|
||||
integrations,
|
||||
tracesSampleRate: process.env['NODE_ENV'] === 'production' ? 0.2 : 1.0,
|
||||
profilesSampleRate: process.env['NODE_ENV'] === 'production' ? 0.2 : 1.0,
|
||||
enabled: !!process.env['SENTRY_DSN'],
|
||||
enabled: !isTest && !!process.env['SENTRY_DSN'],
|
||||
});
|
||||
|
||||
@@ -11,8 +11,10 @@ import { BulkModerateListingsHandler } from './application/commands/bulk-moderat
|
||||
import { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.handler';
|
||||
import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler';
|
||||
import { UpdateUserStatusHandler } from './application/commands/update-user-status/update-user-status.handler';
|
||||
import { AdminAuditListener } from './application/listeners/admin-audit.listener';
|
||||
import { UserBannedListener } from './application/listeners/user-banned.listener';
|
||||
import { UserDeactivatedListener } from './application/listeners/user-deactivated.listener';
|
||||
import { GetAuditLogsHandler } from './application/queries/get-audit-logs/get-audit-logs.handler';
|
||||
import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler';
|
||||
import { GetKycQueueHandler } from './application/queries/get-kyc-queue/get-kyc-queue.handler';
|
||||
import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler';
|
||||
@@ -20,7 +22,9 @@ import { GetRevenueStatsHandler } from './application/queries/get-revenue-stats/
|
||||
import { GetUserDetailHandler } from './application/queries/get-user-detail/get-user-detail.handler';
|
||||
import { GetUsersHandler } from './application/queries/get-users/get-users.handler';
|
||||
import { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository';
|
||||
import { AUDIT_LOG_REPOSITORY } from './domain/repositories/audit-log.repository';
|
||||
import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository';
|
||||
import { PrismaAuditLogRepository } from './infrastructure/repositories/prisma-audit-log.repository';
|
||||
import { AdminModerationController } from './presentation/controllers/admin-moderation.controller';
|
||||
import { AdminController } from './presentation/controllers/admin.controller';
|
||||
|
||||
@@ -42,6 +46,7 @@ const QueryHandlers = [
|
||||
GetUsersHandler,
|
||||
GetUserDetailHandler,
|
||||
GetKycQueueHandler,
|
||||
GetAuditLogsHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
@@ -50,6 +55,7 @@ const QueryHandlers = [
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },
|
||||
{ provide: AUDIT_LOG_REPOSITORY, useClass: PrismaAuditLogRepository },
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
@@ -58,6 +64,7 @@ const QueryHandlers = [
|
||||
// Event Listeners
|
||||
UserBannedListener,
|
||||
UserDeactivatedListener,
|
||||
AdminAuditListener,
|
||||
],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
||||
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
||||
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
|
||||
import { type ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
|
||||
import { type SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event';
|
||||
import { type UserBannedEvent } from '../../domain/events/user-banned.event';
|
||||
import { type UserUnbannedEvent } from '../../domain/events/user-unbanned.event';
|
||||
import { AdminAuditListener } from '../listeners/admin-audit.listener';
|
||||
|
||||
describe('AdminAuditListener', () => {
|
||||
let listener: AdminAuditListener;
|
||||
let mockAuditRepo: { create: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuditRepo = {
|
||||
create: vi.fn().mockResolvedValue({
|
||||
id: 'audit-1',
|
||||
action: 'LISTING_APPROVED',
|
||||
actorId: 'admin-1',
|
||||
targetId: 'listing-1',
|
||||
targetType: 'LISTING',
|
||||
metadata: null,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
};
|
||||
mockLogger = { log: vi.fn(), error: vi.fn() };
|
||||
|
||||
listener = new AdminAuditListener(
|
||||
mockAuditRepo as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Listing Events ──
|
||||
|
||||
it('logs listing approval', async () => {
|
||||
const event: ListingApprovedEvent = {
|
||||
aggregateId: 'listing-1',
|
||||
adminId: 'admin-1',
|
||||
moderationNotes: 'Looks good',
|
||||
eventName: 'listing.approved_by_admin',
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
await listener.onListingApproved(event);
|
||||
|
||||
expect(mockAuditRepo.create).toHaveBeenCalledWith({
|
||||
action: 'LISTING_APPROVED',
|
||||
actorId: 'admin-1',
|
||||
targetId: 'listing-1',
|
||||
targetType: 'LISTING',
|
||||
metadata: { moderationNotes: 'Looks good' },
|
||||
});
|
||||
});
|
||||
|
||||
it('logs listing rejection', async () => {
|
||||
const event: ListingRejectedEvent = {
|
||||
aggregateId: 'listing-2',
|
||||
adminId: 'admin-1',
|
||||
reason: 'Vi phạm nội dung',
|
||||
eventName: 'listing.rejected_by_admin',
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
await listener.onListingRejected(event);
|
||||
|
||||
expect(mockAuditRepo.create).toHaveBeenCalledWith({
|
||||
action: 'LISTING_REJECTED',
|
||||
actorId: 'admin-1',
|
||||
targetId: 'listing-2',
|
||||
targetType: 'LISTING',
|
||||
metadata: { reason: 'Vi phạm nội dung' },
|
||||
});
|
||||
});
|
||||
|
||||
// ── User Events ──
|
||||
|
||||
it('logs user ban', async () => {
|
||||
const event: UserBannedEvent = {
|
||||
aggregateId: 'user-1',
|
||||
adminId: 'admin-1',
|
||||
reason: 'Spam',
|
||||
eventName: 'user.banned',
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
await listener.onUserBanned(event);
|
||||
|
||||
expect(mockAuditRepo.create).toHaveBeenCalledWith({
|
||||
action: 'USER_BANNED',
|
||||
actorId: 'admin-1',
|
||||
targetId: 'user-1',
|
||||
targetType: 'USER',
|
||||
metadata: { reason: 'Spam' },
|
||||
});
|
||||
});
|
||||
|
||||
it('logs user unban', async () => {
|
||||
const event: UserUnbannedEvent = {
|
||||
aggregateId: 'user-1',
|
||||
adminId: 'admin-1',
|
||||
eventName: 'user.unbanned',
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
await listener.onUserUnbanned(event);
|
||||
|
||||
expect(mockAuditRepo.create).toHaveBeenCalledWith({
|
||||
action: 'USER_UNBANNED',
|
||||
actorId: 'admin-1',
|
||||
targetId: 'user-1',
|
||||
targetType: 'USER',
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// ── KYC Events ──
|
||||
|
||||
it('logs KYC approval', async () => {
|
||||
const event: KycApprovedEvent = {
|
||||
aggregateId: 'user-2',
|
||||
adminId: 'admin-1',
|
||||
comments: 'Documents verified',
|
||||
eventName: 'kyc.approved',
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
await listener.onKycApproved(event);
|
||||
|
||||
expect(mockAuditRepo.create).toHaveBeenCalledWith({
|
||||
action: 'KYC_APPROVED',
|
||||
actorId: 'admin-1',
|
||||
targetId: 'user-2',
|
||||
targetType: 'USER',
|
||||
metadata: { comments: 'Documents verified' },
|
||||
});
|
||||
});
|
||||
|
||||
it('logs KYC rejection', async () => {
|
||||
const event: KycRejectedEvent = {
|
||||
aggregateId: 'user-3',
|
||||
adminId: 'admin-1',
|
||||
reason: 'Giấy tờ không hợp lệ',
|
||||
eventName: 'kyc.rejected',
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
await listener.onKycRejected(event);
|
||||
|
||||
expect(mockAuditRepo.create).toHaveBeenCalledWith({
|
||||
action: 'KYC_REJECTED',
|
||||
actorId: 'admin-1',
|
||||
targetId: 'user-3',
|
||||
targetType: 'USER',
|
||||
metadata: { reason: 'Giấy tờ không hợp lệ' },
|
||||
});
|
||||
});
|
||||
|
||||
// ── Subscription Events ──
|
||||
|
||||
it('logs subscription adjustment', async () => {
|
||||
const event: SubscriptionAdjustedEvent = {
|
||||
aggregateId: 'user-4',
|
||||
adminId: 'admin-1',
|
||||
newPlanId: 'plan-pro',
|
||||
reason: 'Customer request',
|
||||
eventName: 'subscription.adjusted_by_admin',
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
await listener.onSubscriptionAdjusted(event);
|
||||
|
||||
expect(mockAuditRepo.create).toHaveBeenCalledWith({
|
||||
action: 'SUBSCRIPTION_ADJUSTED',
|
||||
actorId: 'admin-1',
|
||||
targetId: 'user-4',
|
||||
targetType: 'SUBSCRIPTION',
|
||||
metadata: { newPlanId: 'plan-pro', reason: 'Customer request' },
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error Handling ──
|
||||
|
||||
it('does not throw when audit logging fails', async () => {
|
||||
mockAuditRepo.create.mockRejectedValue(new Error('DB connection lost'));
|
||||
|
||||
const event: ListingApprovedEvent = {
|
||||
aggregateId: 'listing-1',
|
||||
adminId: 'admin-1',
|
||||
eventName: 'listing.approved_by_admin',
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await expect(listener.onListingApproved(event)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to write audit log'),
|
||||
expect.any(String),
|
||||
'AdminAuditListener',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs success message after writing audit entry', async () => {
|
||||
const event: UserBannedEvent = {
|
||||
aggregateId: 'user-1',
|
||||
adminId: 'admin-1',
|
||||
reason: 'Violation',
|
||||
eventName: 'user.banned',
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
await listener.onUserBanned(event);
|
||||
|
||||
expect(mockLogger.log).toHaveBeenCalledWith(
|
||||
'Audit: USER_BANNED by admin-1 on USER:user-1',
|
||||
'AdminAuditListener',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { GetAuditLogsHandler } from '../queries/get-audit-logs/get-audit-logs.handler';
|
||||
import { GetAuditLogsQuery } from '../queries/get-audit-logs/get-audit-logs.query';
|
||||
|
||||
describe('GetAuditLogsHandler', () => {
|
||||
let handler: GetAuditLogsHandler;
|
||||
let mockAuditRepo: { findAll: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockResult = {
|
||||
data: [
|
||||
{
|
||||
id: 'audit-1',
|
||||
action: 'USER_BANNED',
|
||||
actorId: 'admin-1',
|
||||
targetId: 'user-1',
|
||||
targetType: 'USER',
|
||||
metadata: { reason: 'Spam' },
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
createdAt: new Date('2026-04-10T10:00:00Z'),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuditRepo = {
|
||||
findAll: vi.fn().mockResolvedValue(mockResult),
|
||||
};
|
||||
|
||||
handler = new GetAuditLogsHandler(mockAuditRepo as any);
|
||||
});
|
||||
|
||||
it('returns paginated audit logs', async () => {
|
||||
const query = new GetAuditLogsQuery(1, 20);
|
||||
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockAuditRepo.findAll).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
action: undefined,
|
||||
actorId: undefined,
|
||||
targetId: undefined,
|
||||
targetType: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes filter parameters to repository', async () => {
|
||||
const startDate = new Date('2026-04-01');
|
||||
const endDate = new Date('2026-04-10');
|
||||
const query = new GetAuditLogsQuery(2, 10, 'USER_BANNED', 'admin-1', 'user-1', 'USER', startDate, endDate);
|
||||
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockAuditRepo.findAll).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
limit: 10,
|
||||
action: 'USER_BANNED',
|
||||
actorId: 'admin-1',
|
||||
targetId: 'user-1',
|
||||
targetType: 'USER',
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses default pagination when not specified', async () => {
|
||||
const query = new GetAuditLogsQuery();
|
||||
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockAuditRepo.findAll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 1, limit: 20 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
||||
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
||||
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
|
||||
import { type ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
|
||||
import { type SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event';
|
||||
import { type UserBannedEvent } from '../../domain/events/user-banned.event';
|
||||
import { type UserUnbannedEvent } from '../../domain/events/user-unbanned.event';
|
||||
import {
|
||||
AUDIT_LOG_REPOSITORY,
|
||||
type IAuditLogRepository,
|
||||
} from '../../domain/repositories/audit-log.repository';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuditListener {
|
||||
constructor(
|
||||
@Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('listing.approved_by_admin', { async: true })
|
||||
async onListingApproved(event: ListingApprovedEvent): Promise<void> {
|
||||
await this.log('LISTING_APPROVED', event.adminId, event.aggregateId, 'LISTING', {
|
||||
moderationNotes: event.moderationNotes,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('listing.rejected_by_admin', { async: true })
|
||||
async onListingRejected(event: ListingRejectedEvent): Promise<void> {
|
||||
await this.log('LISTING_REJECTED', event.adminId, event.aggregateId, 'LISTING', {
|
||||
reason: event.reason,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('user.banned', { async: true })
|
||||
async onUserBanned(event: UserBannedEvent): Promise<void> {
|
||||
await this.log('USER_BANNED', event.adminId, event.aggregateId, 'USER', {
|
||||
reason: event.reason,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('user.unbanned', { async: true })
|
||||
async onUserUnbanned(event: UserUnbannedEvent): Promise<void> {
|
||||
await this.log('USER_UNBANNED', event.adminId, event.aggregateId, 'USER');
|
||||
}
|
||||
|
||||
@OnEvent('kyc.approved', { async: true })
|
||||
async onKycApproved(event: KycApprovedEvent): Promise<void> {
|
||||
await this.log('KYC_APPROVED', event.adminId, event.aggregateId, 'USER', {
|
||||
comments: event.comments,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('kyc.rejected', { async: true })
|
||||
async onKycRejected(event: KycRejectedEvent): Promise<void> {
|
||||
await this.log('KYC_REJECTED', event.adminId, event.aggregateId, 'USER', {
|
||||
reason: event.reason,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('subscription.adjusted_by_admin', { async: true })
|
||||
async onSubscriptionAdjusted(event: SubscriptionAdjustedEvent): Promise<void> {
|
||||
await this.log('SUBSCRIPTION_ADJUSTED', event.adminId, event.aggregateId, 'SUBSCRIPTION', {
|
||||
newPlanId: event.newPlanId,
|
||||
reason: event.reason,
|
||||
});
|
||||
}
|
||||
|
||||
private async log(
|
||||
action: string,
|
||||
actorId: string,
|
||||
targetId: string,
|
||||
targetType: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.auditRepo.create({ action, actorId, targetId, targetType, metadata });
|
||||
this.logger.log(
|
||||
`Audit: ${action} by ${actorId} on ${targetType}:${targetId}`,
|
||||
'AdminAuditListener',
|
||||
);
|
||||
} catch (error) {
|
||||
// Audit failures must not break the main flow — log and continue
|
||||
this.logger.error(
|
||||
`Failed to write audit log: ${action} by ${actorId} on ${targetType}:${targetId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
'AdminAuditListener',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
AUDIT_LOG_REPOSITORY,
|
||||
type IAuditLogRepository,
|
||||
type AuditLogListResult,
|
||||
} from '../../../domain/repositories/audit-log.repository';
|
||||
import { GetAuditLogsQuery } from './get-audit-logs.query';
|
||||
|
||||
@QueryHandler(GetAuditLogsQuery)
|
||||
export class GetAuditLogsHandler implements IQueryHandler<GetAuditLogsQuery> {
|
||||
constructor(
|
||||
@Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetAuditLogsQuery): Promise<AuditLogListResult> {
|
||||
return this.auditRepo.findAll({
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
action: query.action,
|
||||
actorId: query.actorId,
|
||||
targetId: query.targetId,
|
||||
targetType: query.targetType,
|
||||
startDate: query.startDate,
|
||||
endDate: query.endDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export class GetAuditLogsQuery {
|
||||
constructor(
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
public readonly action?: string,
|
||||
public readonly actorId?: string,
|
||||
public readonly targetId?: string,
|
||||
public readonly targetType?: string,
|
||||
public readonly startDate?: Date,
|
||||
public readonly endDate?: Date,
|
||||
) {}
|
||||
}
|
||||
@@ -10,3 +10,5 @@ export { GetUserDetailQuery } from './get-user-detail/get-user-detail.query';
|
||||
export { GetUserDetailHandler } from './get-user-detail/get-user-detail.handler';
|
||||
export { GetKycQueueQuery } from './get-kyc-queue/get-kyc-queue.query';
|
||||
export { GetKycQueueHandler } from './get-kyc-queue/get-kyc-queue.handler';
|
||||
export { GetAuditLogsQuery } from './get-audit-logs/get-audit-logs.query';
|
||||
export { GetAuditLogsHandler } from './get-audit-logs/get-audit-logs.handler';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export const AUDIT_LOG_REPOSITORY = Symbol('AUDIT_LOG_REPOSITORY');
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
targetType: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface AuditLogListResult {
|
||||
data: AuditLogEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface CreateAuditLogInput {
|
||||
action: string;
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
targetType: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAuditLogRepository {
|
||||
create(input: CreateAuditLogInput): Promise<AuditLogEntry>;
|
||||
findAll(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
action?: string;
|
||||
actorId?: string;
|
||||
targetId?: string;
|
||||
targetType?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}): Promise<AuditLogListResult>;
|
||||
}
|
||||
@@ -7,3 +7,10 @@ export type {
|
||||
UserListItem,
|
||||
UserListResult,
|
||||
} from './admin-query.repository';
|
||||
export {
|
||||
AUDIT_LOG_REPOSITORY,
|
||||
type IAuditLogRepository,
|
||||
type AuditLogEntry,
|
||||
type AuditLogListResult,
|
||||
type CreateAuditLogInput,
|
||||
} from './audit-log.repository';
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export { AdminModule } from './admin.module';
|
||||
export { ListingApprovedEvent } from './domain/events/listing-approved.event';
|
||||
export { ListingRejectedEvent } from './domain/events/listing-rejected.event';
|
||||
export {
|
||||
AUDIT_LOG_REPOSITORY,
|
||||
type IAuditLogRepository,
|
||||
type AuditLogEntry,
|
||||
type AuditLogListResult,
|
||||
} from './domain/repositories/audit-log.repository';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { PrismaAdminQueryRepository } from './prisma-admin-query.repository';
|
||||
export { PrismaAuditLogRepository } from './prisma-audit-log.repository';
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type AdminAction, type AuditTargetType, type Prisma } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type IAuditLogRepository,
|
||||
type AuditLogEntry,
|
||||
type AuditLogListResult,
|
||||
type CreateAuditLogInput,
|
||||
} from '../../domain/repositories/audit-log.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaAuditLogRepository implements IAuditLogRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(input: CreateAuditLogInput): Promise<AuditLogEntry> {
|
||||
const record = await this.prisma.adminAuditLog.create({
|
||||
data: {
|
||||
action: input.action as AdminAction,
|
||||
actorId: input.actorId,
|
||||
targetId: input.targetId,
|
||||
targetType: input.targetType as AuditTargetType,
|
||||
metadata: input.metadata as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
action: record.action,
|
||||
actorId: record.actorId,
|
||||
targetId: record.targetId,
|
||||
targetType: record.targetType,
|
||||
metadata: record.metadata as Record<string, unknown> | null,
|
||||
ipAddress: record.ipAddress,
|
||||
userAgent: record.userAgent,
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
action?: string;
|
||||
actorId?: string;
|
||||
targetId?: string;
|
||||
targetType?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}): Promise<AuditLogListResult> {
|
||||
const { page, limit, action, actorId, targetId, targetType, startDate, endDate } = params;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Prisma.AdminAuditLogWhereInput = {};
|
||||
|
||||
if (action) {
|
||||
where.action = action as AdminAction;
|
||||
}
|
||||
if (actorId) {
|
||||
where.actorId = actorId;
|
||||
}
|
||||
if (targetId) {
|
||||
where.targetId = targetId;
|
||||
}
|
||||
if (targetType) {
|
||||
where.targetType = targetType as AuditTargetType;
|
||||
}
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) {
|
||||
where.createdAt.gte = startDate;
|
||||
}
|
||||
if (endDate) {
|
||||
where.createdAt.lte = endDate;
|
||||
}
|
||||
}
|
||||
|
||||
const [records, total] = await Promise.all([
|
||||
this.prisma.adminAuditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.adminAuditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: records.map((r) => ({
|
||||
id: r.id,
|
||||
action: r.action,
|
||||
actorId: r.actorId,
|
||||
targetId: r.targetId,
|
||||
targetType: r.targetType,
|
||||
metadata: r.metadata as Record<string, unknown> | null,
|
||||
ipAddress: r.ipAddress,
|
||||
userAgent: r.userAgent,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { BanUserCommand } from '../../application/commands/ban-user/ban-user.com
|
||||
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
|
||||
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
|
||||
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
|
||||
import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
|
||||
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
|
||||
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
|
||||
import { GetUserDetailQuery } from '../../application/queries/get-user-detail/get-user-detail.query';
|
||||
@@ -27,8 +28,10 @@ import {
|
||||
type UserListResult,
|
||||
type UserDetail,
|
||||
} from '../../domain/repositories/admin-query.repository';
|
||||
import { type AuditLogListResult } from '../../domain/repositories/audit-log.repository';
|
||||
import { type AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
|
||||
import { type BanUserDto } from '../dto/ban-user.dto';
|
||||
import { type GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
|
||||
import { type GetUsersQueryDto } from '../dto/get-users-query.dto';
|
||||
import { type RevenueStatsDto } from '../dto/revenue-stats.dto';
|
||||
import { type UpdateUserStatusDto } from '../dto/update-user-status.dto';
|
||||
@@ -151,4 +154,28 @@ export class AdminController {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Audit Logs ──
|
||||
|
||||
@Get('audit-logs')
|
||||
@ApiOperation({ summary: 'Get admin audit logs' })
|
||||
@ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
async getAuditLogs(
|
||||
@Query() query: GetAuditLogsQueryDto,
|
||||
): Promise<AuditLogListResult> {
|
||||
return this.queryBus.execute(
|
||||
new GetAuditLogsQuery(
|
||||
query.page ?? 1,
|
||||
query.limit ?? 20,
|
||||
query.action,
|
||||
query.actorId,
|
||||
query.targetId,
|
||||
query.targetType,
|
||||
query.startDate ? new Date(query.startDate) : undefined,
|
||||
query.endDate ? new Date(query.endDate) : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsOptional, IsString, IsIn, IsInt, Min, Max, IsDateString } from 'class-validator';
|
||||
|
||||
export class GetAuditLogsQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Page number', example: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Items per page', example: 20, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by admin action',
|
||||
enum: [
|
||||
'LISTING_APPROVED',
|
||||
'LISTING_REJECTED',
|
||||
'LISTING_BULK_APPROVED',
|
||||
'LISTING_BULK_REJECTED',
|
||||
'USER_BANNED',
|
||||
'USER_UNBANNED',
|
||||
'USER_STATUS_UPDATED',
|
||||
'KYC_APPROVED',
|
||||
'KYC_REJECTED',
|
||||
'SUBSCRIPTION_ADJUSTED',
|
||||
],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([
|
||||
'LISTING_APPROVED',
|
||||
'LISTING_REJECTED',
|
||||
'LISTING_BULK_APPROVED',
|
||||
'LISTING_BULK_REJECTED',
|
||||
'USER_BANNED',
|
||||
'USER_UNBANNED',
|
||||
'USER_STATUS_UPDATED',
|
||||
'KYC_APPROVED',
|
||||
'KYC_REJECTED',
|
||||
'SUBSCRIPTION_ADJUSTED',
|
||||
])
|
||||
action?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by admin actor ID' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
actorId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by target entity ID' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
targetId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by target type',
|
||||
enum: ['USER', 'LISTING', 'SUBSCRIPTION'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['USER', 'LISTING', 'SUBSCRIPTION'])
|
||||
targetType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Start date filter (ISO 8601)', example: '2026-01-01T00:00:00.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'End date filter (ISO 8601)', example: '2026-12-31T23:59:59.999Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export { UpdateUserStatusDto } from './update-user-status.dto';
|
||||
export { ApproveKycDto } from './approve-kyc.dto';
|
||||
export { RejectKycDto } from './reject-kyc.dto';
|
||||
export { BulkModerateDto } from './bulk-moderate.dto';
|
||||
export { GetAuditLogsQueryDto } from './get-audit-logs-query.dto';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import type {
|
||||
AgentDashboardData,
|
||||
IAgentRepository,
|
||||
import {
|
||||
type AgentDashboardData,
|
||||
type IAgentRepository,
|
||||
} from '../../domain/repositories/agent.repository';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@modules/auth';
|
||||
import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||
import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query';
|
||||
import type { AgentDashboardData } from '../../domain/repositories/agent.repository';
|
||||
import { type AgentDashboardData } from '../../domain/repositories/agent.repository';
|
||||
|
||||
@ApiTags('agents')
|
||||
@Controller('agents')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PropertyType } from '@prisma/client';
|
||||
import type { PrismaService } from '@modules/shared';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type IAVMService,
|
||||
type AVMParams,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { type Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { type JwtPayload } from '../services/token.service';
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
import { type Request, type Response } from 'express';
|
||||
import { UnauthorizedException } from '@modules/shared';
|
||||
import { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
|
||||
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
|
||||
@@ -33,6 +33,8 @@ import { LocalAuthGuard } from '../guards/local-auth.guard';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
|
||||
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
|
||||
const IS_TEST = process.env['NODE_ENV'] === 'test';
|
||||
const AUTH_RATE_LIMIT = IS_TEST ? 10_000 : 5;
|
||||
const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; // 15 minutes
|
||||
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
@@ -76,7 +78,7 @@ export class AuthController {
|
||||
private readonly tokenService: TokenService,
|
||||
) {}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: 5 }, auth: { ttl: 3_600_000, limit: 5 } })
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@ApiResponse({ status: 201, description: 'User registered, auth cookies set' })
|
||||
@@ -85,15 +87,19 @@ export class AuthController {
|
||||
async register(
|
||||
@Body() dto: RegisterDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<{ message: string }> {
|
||||
): Promise<{ message: string; accessToken: string; refreshToken: string }> {
|
||||
const tokens: TokenPair = await this.commandBus.execute(
|
||||
new RegisterUserCommand(dto.phone, dto.password, dto.fullName, dto.email),
|
||||
);
|
||||
setAuthCookies(res, tokens);
|
||||
return { message: 'Đăng ký thành công' };
|
||||
return {
|
||||
message: 'Đăng ký thành công',
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: 5 }, auth: { ttl: 3_600_000, limit: 5 } })
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with phone and password' })
|
||||
@@ -103,15 +109,19 @@ export class AuthController {
|
||||
async login(
|
||||
@CurrentUser() user: { id: string; phone: string; role: string },
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<{ message: string }> {
|
||||
): Promise<{ message: string; accessToken: string; refreshToken: string }> {
|
||||
const tokens: TokenPair = await this.commandBus.execute(
|
||||
new LoginUserCommand(user.id, user.phone, user.role),
|
||||
);
|
||||
setAuthCookies(res, tokens);
|
||||
return { message: 'Đăng nhập thành công' };
|
||||
return {
|
||||
message: 'Đăng nhập thành công',
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: 5 }, auth: { ttl: 3_600_000, limit: 5 } })
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Refresh access token using refresh cookie' })
|
||||
@ApiResponse({ status: 201, description: 'New auth cookies set' })
|
||||
@@ -120,7 +130,7 @@ export class AuthController {
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Body() dto?: RefreshTokenDto,
|
||||
): Promise<{ message: string }> {
|
||||
): Promise<{ message: string; accessToken: string; refreshToken: string }> {
|
||||
const refreshToken =
|
||||
(req.cookies?.['refresh_token'] as string | undefined) ?? dto?.refreshToken;
|
||||
if (!refreshToken) {
|
||||
@@ -130,7 +140,11 @@ export class AuthController {
|
||||
new RefreshTokenCommand(refreshToken),
|
||||
);
|
||||
setAuthCookies(res, tokens);
|
||||
return { message: 'Token refreshed' };
|
||||
return {
|
||||
message: 'Token refreshed',
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
import { type Request, type Response } from 'express';
|
||||
import { UnauthorizedException } from '@modules/shared';
|
||||
import { type TokenPair } from '../../infrastructure/services/token.service';
|
||||
import { type ZaloOAuthStrategy } from '../../infrastructure/strategies/zalo-oauth.strategy';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Inquiry as PrismaInquiry } from '@prisma/client';
|
||||
import { type Inquiry as PrismaInquiry } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { InquiryEntity } from '../../domain/entities/inquiry.entity';
|
||||
import type { InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
|
||||
import type { IInquiryRepository, PaginatedResult } from '../../domain/repositories/inquiry.repository';
|
||||
import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
|
||||
import { type IInquiryRepository, type PaginatedResult } from '../../domain/repositories/inquiry.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaInquiryRepository implements IInquiryRepository {
|
||||
|
||||
@@ -22,10 +22,10 @@ import { type CreateInquiryResult } from '../../application/commands/create-inqu
|
||||
import { MarkInquiryReadCommand } from '../../application/commands/mark-inquiry-read/mark-inquiry-read.command';
|
||||
import { GetInquiriesByAgentQuery } from '../../application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query';
|
||||
import { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query';
|
||||
import type { InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
|
||||
import type { PaginatedResult } from '../../domain/repositories/inquiry.repository';
|
||||
import type { CreateInquiryDto } from '../dto/create-inquiry.dto';
|
||||
import type { ListInquiriesDto } from '../dto/list-inquiries.dto';
|
||||
import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
|
||||
import { type PaginatedResult } from '../../domain/repositories/inquiry.repository';
|
||||
import { type CreateInquiryDto } from '../dto/create-inquiry.dto';
|
||||
import { type ListInquiriesDto } from '../dto/list-inquiries.dto';
|
||||
|
||||
@ApiTags('inquiries')
|
||||
@Controller('inquiries')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Lead as PrismaLead } from '@prisma/client';
|
||||
import { type Lead as PrismaLead } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { LeadEntity, type LeadStatus } from '../../domain/entities/lead.entity';
|
||||
import type { LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
||||
import type { ILeadRepository, LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository';
|
||||
import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
||||
import { type ILeadRepository, type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
|
||||
import { LeadScore } from '../../domain/value-objects/lead-score.vo';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -24,11 +24,11 @@ import { DeleteLeadCommand } from '../../application/commands/delete-lead/delete
|
||||
import { UpdateLeadStatusCommand } from '../../application/commands/update-lead-status/update-lead-status.command';
|
||||
import { GetLeadStatsQuery } from '../../application/queries/get-lead-stats/get-lead-stats.query';
|
||||
import { GetLeadsByAgentQuery } from '../../application/queries/get-leads-by-agent/get-leads-by-agent.query';
|
||||
import type { LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
||||
import type { LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository';
|
||||
import type { CreateLeadDto } from '../dto/create-lead.dto';
|
||||
import type { ListLeadsDto } from '../dto/list-leads.dto';
|
||||
import type { UpdateLeadStatusDto } from '../dto/update-lead-status.dto';
|
||||
import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
||||
import { type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
|
||||
import { type CreateLeadDto } from '../dto/create-lead.dto';
|
||||
import { type ListLeadsDto } from '../dto/list-leads.dto';
|
||||
import { type UpdateLeadStatusDto } from '../dto/update-lead-status.dto';
|
||||
|
||||
@ApiTags('leads')
|
||||
@ApiBearerAuth('JWT')
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
import { type Request, type Response } from 'express';
|
||||
import { JwtAuthGuard, CurrentUser, type JwtPayload } from '@modules/auth';
|
||||
|
||||
@ApiTags('mcp')
|
||||
|
||||
@@ -11,4 +11,10 @@ export {
|
||||
DB_QUERY_DURATION,
|
||||
DB_POOL_ACTIVE_CONNECTIONS,
|
||||
SEARCH_QUERY_DURATION,
|
||||
WEB_VITALS_LCP,
|
||||
WEB_VITALS_FCP,
|
||||
WEB_VITALS_CLS,
|
||||
WEB_VITALS_TTFB,
|
||||
WEB_VITALS_INP,
|
||||
WEB_VITALS_TOTAL,
|
||||
} from './metrics.constants';
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
GOODGO_SEARCH_QUERIES_TOTAL,
|
||||
GOODGO_API_REQUEST_DURATION,
|
||||
HTTP_REQUESTS_TOTAL,
|
||||
WEB_VITALS_LCP,
|
||||
WEB_VITALS_FCP,
|
||||
WEB_VITALS_CLS,
|
||||
WEB_VITALS_TTFB,
|
||||
WEB_VITALS_INP,
|
||||
WEB_VITALS_TOTAL,
|
||||
} from '../metrics.constants';
|
||||
|
||||
@Injectable()
|
||||
@@ -25,6 +31,18 @@ export class MetricsService {
|
||||
private readonly requestDurationHistogram: Histogram,
|
||||
@InjectMetric(HTTP_REQUESTS_TOTAL)
|
||||
private readonly httpRequestsCounter: Counter,
|
||||
@InjectMetric(WEB_VITALS_LCP)
|
||||
private readonly lcpHistogram: Histogram,
|
||||
@InjectMetric(WEB_VITALS_FCP)
|
||||
private readonly fcpHistogram: Histogram,
|
||||
@InjectMetric(WEB_VITALS_CLS)
|
||||
private readonly clsHistogram: Histogram,
|
||||
@InjectMetric(WEB_VITALS_TTFB)
|
||||
private readonly ttfbHistogram: Histogram,
|
||||
@InjectMetric(WEB_VITALS_INP)
|
||||
private readonly inpHistogram: Histogram,
|
||||
@InjectMetric(WEB_VITALS_TOTAL)
|
||||
private readonly webVitalsCounter: Counter,
|
||||
) {}
|
||||
|
||||
/** Record a new listing creation. */
|
||||
@@ -62,4 +80,36 @@ export class MetricsService {
|
||||
this.requestDurationHistogram.observe(labels, durationSeconds);
|
||||
this.httpRequestsCounter.inc(labels);
|
||||
}
|
||||
|
||||
/** Map metric name → the correct histogram. */
|
||||
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
|
||||
|
||||
private getVitalHistogram(name: string): Histogram | undefined {
|
||||
// Lazy-init the lookup (cannot reference `this` in field initialiser)
|
||||
if (Object.keys(this.vitalHistograms).length === 0) {
|
||||
this.vitalHistograms['LCP'] = this.lcpHistogram;
|
||||
this.vitalHistograms['FCP'] = this.fcpHistogram;
|
||||
this.vitalHistograms['CLS'] = this.clsHistogram;
|
||||
this.vitalHistograms['TTFB'] = this.ttfbHistogram;
|
||||
this.vitalHistograms['INP'] = this.inpHistogram;
|
||||
}
|
||||
return this.vitalHistograms[name];
|
||||
}
|
||||
|
||||
/** Record a single Core Web Vital measurement. */
|
||||
recordWebVital(
|
||||
name: string,
|
||||
value: number,
|
||||
rating: string,
|
||||
page: string,
|
||||
): void {
|
||||
const histogram = this.getVitalHistogram(name);
|
||||
if (!histogram) return;
|
||||
|
||||
// LCP, FID, TTFB, INP arrive in ms from the browser — convert to seconds.
|
||||
// CLS is unitless (no conversion).
|
||||
const observeValue = name === 'CLS' ? value : value / 1000;
|
||||
histogram.observe({ rating, page }, observeValue);
|
||||
this.webVitalsCounter.inc({ name, rating });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,3 +10,11 @@ export const HTTP_REQUESTS_TOTAL = 'http_requests_total';
|
||||
export const DB_QUERY_DURATION = 'db_query_duration_seconds';
|
||||
export const DB_POOL_ACTIVE_CONNECTIONS = 'db_pool_active_connections';
|
||||
export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds';
|
||||
|
||||
// ── Web Vitals / RUM Metrics ──
|
||||
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
|
||||
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';
|
||||
export const WEB_VITALS_CLS = 'goodgo_web_vitals_cls';
|
||||
export const WEB_VITALS_TTFB = 'goodgo_web_vitals_ttfb_seconds';
|
||||
export const WEB_VITALS_INP = 'goodgo_web_vitals_inp_seconds';
|
||||
export const WEB_VITALS_TOTAL = 'goodgo_web_vitals_total';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import {
|
||||
PrometheusModule,
|
||||
makeCounterProvider,
|
||||
makeHistogramProvider,
|
||||
makeGaugeProvider,
|
||||
@@ -16,16 +15,18 @@ import {
|
||||
DB_QUERY_DURATION,
|
||||
DB_POOL_ACTIVE_CONNECTIONS,
|
||||
SEARCH_QUERY_DURATION,
|
||||
WEB_VITALS_LCP,
|
||||
WEB_VITALS_FCP,
|
||||
WEB_VITALS_CLS,
|
||||
WEB_VITALS_TTFB,
|
||||
WEB_VITALS_INP,
|
||||
WEB_VITALS_TOTAL,
|
||||
} from './metrics.constants';
|
||||
import { WebVitalsController } from './presentation/controllers/web-vitals.controller';
|
||||
import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrometheusModule.register({
|
||||
path: '/metrics',
|
||||
defaultMetrics: { enabled: true },
|
||||
}),
|
||||
],
|
||||
imports: [],
|
||||
providers: [
|
||||
// ── HTTP Metrics ──
|
||||
makeHistogramProvider({
|
||||
@@ -85,7 +86,45 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
|
||||
// ── Services & Interceptors ──
|
||||
MetricsService,
|
||||
HttpMetricsInterceptor,
|
||||
|
||||
// ── Web Vitals / RUM Metrics ──
|
||||
makeHistogramProvider({
|
||||
name: WEB_VITALS_LCP,
|
||||
help: 'Largest Contentful Paint in seconds',
|
||||
labelNames: ['rating', 'page'],
|
||||
buckets: [0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 8, 10],
|
||||
}),
|
||||
makeHistogramProvider({
|
||||
name: WEB_VITALS_FCP,
|
||||
help: 'First Contentful Paint in seconds',
|
||||
labelNames: ['rating', 'page'],
|
||||
buckets: [0.1, 0.5, 1, 1.5, 1.8, 2.5, 3, 4, 5, 8],
|
||||
}),
|
||||
makeHistogramProvider({
|
||||
name: WEB_VITALS_CLS,
|
||||
help: 'Cumulative Layout Shift score (unitless)',
|
||||
labelNames: ['rating', 'page'],
|
||||
buckets: [0.01, 0.025, 0.05, 0.1, 0.15, 0.2, 0.25, 0.5, 1],
|
||||
}),
|
||||
makeHistogramProvider({
|
||||
name: WEB_VITALS_TTFB,
|
||||
help: 'Time to First Byte in seconds',
|
||||
labelNames: ['rating', 'page'],
|
||||
buckets: [0.1, 0.2, 0.4, 0.6, 0.8, 1, 1.5, 2, 3, 5],
|
||||
}),
|
||||
makeHistogramProvider({
|
||||
name: WEB_VITALS_INP,
|
||||
help: 'Interaction to Next Paint in seconds',
|
||||
labelNames: ['rating', 'page'],
|
||||
buckets: [0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 0.8, 1],
|
||||
}),
|
||||
makeCounterProvider({
|
||||
name: WEB_VITALS_TOTAL,
|
||||
help: 'Total web vital events received',
|
||||
labelNames: ['name', 'rating'],
|
||||
}),
|
||||
],
|
||||
exports: [PrometheusModule, MetricsService, HttpMetricsInterceptor],
|
||||
controllers: [WebVitalsController],
|
||||
exports: [MetricsService, HttpMetricsInterceptor],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { type MetricsService } from '../../infrastructure/metrics.service';
|
||||
import { type WebVitalsBatchDto } from '../dto/web-vitals.dto';
|
||||
|
||||
/**
|
||||
* Public endpoint for receiving Core Web Vitals from the frontend.
|
||||
*
|
||||
* No auth required — these are anonymous, best-effort telemetry beacons
|
||||
* sent via `navigator.sendBeacon` during page transitions.
|
||||
*
|
||||
* Rate limiting and abuse protection should be handled at the
|
||||
* reverse-proxy / CDN layer.
|
||||
*/
|
||||
@ApiTags('web-vitals')
|
||||
@Controller('web-vitals')
|
||||
export class WebVitalsController {
|
||||
private readonly logger = new Logger(WebVitalsController.name);
|
||||
|
||||
constructor(private readonly metricsService: MetricsService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Ingest a batch of Core Web Vitals metrics' })
|
||||
@ApiResponse({ status: 204, description: 'Metrics accepted' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid payload' })
|
||||
ingest(@Body() dto: WebVitalsBatchDto): void {
|
||||
for (const metric of dto.metrics) {
|
||||
try {
|
||||
this.metricsService.recordWebVital(
|
||||
metric.name,
|
||||
metric.value,
|
||||
metric.rating,
|
||||
this.normalisePage(metric.url),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to record web vital ${metric.name}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise the raw URL path into a route-level label to keep
|
||||
* Prometheus cardinality manageable (e.g. `/vi/listings/abc123` → `/[locale]/listings/[id]`).
|
||||
*/
|
||||
private normalisePage(url: string): string {
|
||||
if (!url) return '/';
|
||||
|
||||
return (
|
||||
url
|
||||
// Strip query string and fragment
|
||||
.split('?')[0]!
|
||||
.split('#')[0]!
|
||||
// Replace locale segment
|
||||
.replace(/^\/(vi|en)/, '/[locale]')
|
||||
// Replace UUIDs
|
||||
.replace(
|
||||
/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
|
||||
'/[id]',
|
||||
)
|
||||
// Replace numeric IDs
|
||||
.replace(/\/\d+/g, '/[id]')
|
||||
// Replace trailing slash
|
||||
.replace(/\/$/, '') || '/'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
const VALID_VITAL_NAMES = ['LCP', 'FCP', 'CLS', 'TTFB', 'INP'] as const;
|
||||
const VALID_RATINGS = ['good', 'needs-improvement', 'poor'] as const;
|
||||
|
||||
export class WebVitalMetricDto {
|
||||
@ApiProperty({ enum: VALID_VITAL_NAMES, description: 'Core Web Vital name' })
|
||||
@IsIn(VALID_VITAL_NAMES)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Metric value (ms for timing metrics, unitless for CLS)' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
value!: number;
|
||||
|
||||
@ApiProperty({ enum: VALID_RATINGS, description: 'Performance rating' })
|
||||
@IsIn(VALID_RATINGS)
|
||||
rating!: string;
|
||||
|
||||
@ApiProperty({ description: 'Delta since last report' })
|
||||
@IsNumber()
|
||||
delta!: number;
|
||||
|
||||
@ApiProperty({ description: 'Unique metric ID from web-vitals' })
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: 'Navigation type (navigate, reload, etc.)' })
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
navigationType!: string;
|
||||
|
||||
@ApiProperty({ description: 'Page URL path' })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
url!: string;
|
||||
|
||||
@ApiProperty({ description: 'Client timestamp (epoch ms)' })
|
||||
@IsNumber()
|
||||
timestamp!: number;
|
||||
}
|
||||
|
||||
export class WebVitalsBatchDto {
|
||||
@ApiProperty({ type: [WebVitalMetricDto], description: 'Batch of web vital metrics' })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WebVitalMetricDto)
|
||||
metrics!: WebVitalMetricDto[];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { ConflictException, ValidationException, LoggerService } from '@modules/shared';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { ForbiddenException, NotFoundException, LoggerService } from '@modules/shared';
|
||||
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Review as PrismaReview } from '@prisma/client';
|
||||
import { type Review as PrismaReview } from '@prisma/client';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { ReviewEntity } from '../../domain/entities/review.entity';
|
||||
import type { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
||||
import type { IReviewRepository, PaginatedResult } from '../../domain/repositories/review.repository';
|
||||
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
||||
import { type IReviewRepository, type PaginatedResult } from '../../domain/repositories/review.repository';
|
||||
import { Rating } from '../../domain/value-objects/rating.vo';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -24,10 +24,10 @@ import { DeleteReviewCommand } from '../../application/commands/delete-review/de
|
||||
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
|
||||
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
|
||||
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
|
||||
import type { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
||||
import type { PaginatedResult } from '../../domain/repositories/review.repository';
|
||||
import type { CreateReviewDto } from '../dto/create-review.dto';
|
||||
import type { ListReviewsByTargetDto, ReviewStatsDto } from '../dto/list-reviews.dto';
|
||||
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
||||
import { type PaginatedResult } from '../../domain/repositories/review.repository';
|
||||
import { type CreateReviewDto } from '../dto/create-review.dto';
|
||||
import { type ListReviewsByTargetDto, type ReviewStatsDto } from '../dto/list-reviews.dto';
|
||||
|
||||
@ApiTags('reviews')
|
||||
@Controller('reviews')
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { CreateSavedSearchCommand } from '../commands/create-saved-search/create-saved-search.command';
|
||||
import { CreateSavedSearchHandler } from '../commands/create-saved-search/create-saved-search.handler';
|
||||
|
||||
describe('CreateSavedSearchHandler', () => {
|
||||
let handler: CreateSavedSearchHandler;
|
||||
let mockPrisma: any;
|
||||
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
savedSearch: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockQueryBus = { execute: vi.fn() };
|
||||
mockCommandBus = { execute: vi.fn() };
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
handler = new CreateSavedSearchHandler(
|
||||
mockPrisma,
|
||||
mockQueryBus as any,
|
||||
mockCommandBus as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a saved search successfully', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
metric: 'searches_saved',
|
||||
limit: 10,
|
||||
used: 2,
|
||||
remaining: 8,
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
mockPrisma.savedSearch.create.mockResolvedValue({
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Chung cư Q7',
|
||||
filters: { district: 'Quan 7', propertyType: 'APARTMENT' },
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
mockCommandBus.execute.mockResolvedValue({ usageRecordId: 'usage-1' });
|
||||
|
||||
const command = new CreateSavedSearchCommand(
|
||||
'user-1',
|
||||
'Chung cư Q7',
|
||||
{ district: 'Quan 7', propertyType: 'APARTMENT' },
|
||||
true,
|
||||
);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.name).toBe('Chung cư Q7');
|
||||
expect(result.alertEnabled).toBe(true);
|
||||
expect(mockPrisma.savedSearch.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); // Usage metering
|
||||
});
|
||||
|
||||
it('throws when name is empty', async () => {
|
||||
const command = new CreateSavedSearchCommand('user-1', '', {}, true);
|
||||
await expect(handler.execute(command)).rejects.toThrow('Tên tìm kiếm không được để trống');
|
||||
});
|
||||
|
||||
it('throws when name exceeds 100 characters', async () => {
|
||||
const longName = 'a'.repeat(101);
|
||||
const command = new CreateSavedSearchCommand('user-1', longName, {}, true);
|
||||
await expect(handler.execute(command)).rejects.toThrow('Tên tìm kiếm không được vượt quá 100 ký tự');
|
||||
});
|
||||
|
||||
it('throws when quota is exceeded', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
metric: 'searches_saved',
|
||||
limit: 5,
|
||||
used: 5,
|
||||
remaining: 0,
|
||||
allowed: false,
|
||||
});
|
||||
|
||||
const command = new CreateSavedSearchCommand('user-1', 'Test', {}, true);
|
||||
await expect(handler.execute(command)).rejects.toThrow('giới hạn');
|
||||
});
|
||||
|
||||
it('continues even when usage metering fails', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
metric: 'searches_saved',
|
||||
limit: 10,
|
||||
used: 2,
|
||||
remaining: 8,
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
mockPrisma.savedSearch.create.mockResolvedValue({
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Test',
|
||||
filters: {},
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
mockCommandBus.execute.mockRejectedValue(new Error('Metering failed'));
|
||||
|
||||
const command = new CreateSavedSearchCommand('user-1', 'Test', {}, true);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.id).toBe('saved-1');
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Usage metering failed'),
|
||||
'CreateSavedSearchHandler',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DeleteSavedSearchCommand } from '../commands/delete-saved-search/delete-saved-search.command';
|
||||
import { DeleteSavedSearchHandler } from '../commands/delete-saved-search/delete-saved-search.handler';
|
||||
|
||||
describe('DeleteSavedSearchHandler', () => {
|
||||
let handler: DeleteSavedSearchHandler;
|
||||
let mockPrisma: any;
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
savedSearch: {
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
handler = new DeleteSavedSearchHandler(mockPrisma, mockLogger as any);
|
||||
});
|
||||
|
||||
it('deletes a saved search owned by the user', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue({
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Test',
|
||||
});
|
||||
mockPrisma.savedSearch.delete.mockResolvedValue({ id: 'saved-1' });
|
||||
|
||||
const command = new DeleteSavedSearchCommand('saved-1', 'user-1');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.deleted).toBe(true);
|
||||
expect(mockPrisma.savedSearch.delete).toHaveBeenCalledWith({ where: { id: 'saved-1' } });
|
||||
});
|
||||
|
||||
it('throws NotFoundException when saved search does not exist', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(null);
|
||||
|
||||
const command = new DeleteSavedSearchCommand('non-existent', 'user-1');
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when user does not own the saved search', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue({
|
||||
id: 'saved-1',
|
||||
userId: 'other-user',
|
||||
name: 'Test',
|
||||
});
|
||||
|
||||
const command = new DeleteSavedSearchCommand('saved-1', 'user-1');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Bạn không có quyền xóa tìm kiếm này');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { GetSavedSearchHandler } from '../queries/get-saved-search/get-saved-search.handler';
|
||||
import { GetSavedSearchQuery } from '../queries/get-saved-search/get-saved-search.query';
|
||||
|
||||
describe('GetSavedSearchHandler', () => {
|
||||
let handler: GetSavedSearchHandler;
|
||||
let mockPrisma: any;
|
||||
|
||||
const existingSearch = {
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Chung cư Q7',
|
||||
filters: { district: 'Quan 7' },
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: new Date('2026-01-15'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
savedSearch: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
handler = new GetSavedSearchHandler(mockPrisma);
|
||||
});
|
||||
|
||||
it('returns saved search detail for the owner', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
|
||||
|
||||
const query = new GetSavedSearchQuery('saved-1', 'user-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.id).toBe('saved-1');
|
||||
expect(result.name).toBe('Chung cư Q7');
|
||||
expect(result.alertEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when saved search does not exist', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(null);
|
||||
|
||||
const query = new GetSavedSearchQuery('non-existent', 'user-1');
|
||||
await expect(handler.execute(query)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when user does not own the saved search', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue({
|
||||
...existingSearch,
|
||||
userId: 'other-user',
|
||||
});
|
||||
|
||||
const query = new GetSavedSearchQuery('saved-1', 'user-1');
|
||||
await expect(handler.execute(query)).rejects.toThrow('Bạn không có quyền xem tìm kiếm này');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { GetSavedSearchesHandler } from '../queries/get-saved-searches/get-saved-searches.handler';
|
||||
import { GetSavedSearchesQuery } from '../queries/get-saved-searches/get-saved-searches.query';
|
||||
|
||||
describe('GetSavedSearchesHandler', () => {
|
||||
let handler: GetSavedSearchesHandler;
|
||||
let mockPrisma: any;
|
||||
|
||||
const savedSearches = [
|
||||
{
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Chung cư Q7',
|
||||
filters: { district: 'Quan 7' },
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: new Date('2026-01-15'),
|
||||
},
|
||||
{
|
||||
id: 'saved-2',
|
||||
userId: 'user-1',
|
||||
name: 'Nhà phố Q2',
|
||||
filters: { district: 'Quan 2', propertyType: 'HOUSE' },
|
||||
alertEnabled: false,
|
||||
lastAlertAt: new Date('2026-01-20'),
|
||||
createdAt: new Date('2026-01-10'),
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
savedSearch: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
handler = new GetSavedSearchesHandler(mockPrisma);
|
||||
});
|
||||
|
||||
it('returns paginated saved searches', async () => {
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue(savedSearches);
|
||||
mockPrisma.savedSearch.count.mockResolvedValue(2);
|
||||
|
||||
const query = new GetSavedSearchesQuery('user-1', 1, 20);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
expect(result.data[0]!.name).toBe('Chung cư Q7');
|
||||
});
|
||||
|
||||
it('applies pagination correctly', async () => {
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([savedSearches[1]]);
|
||||
mockPrisma.savedSearch.count.mockResolvedValue(2);
|
||||
|
||||
const query = new GetSavedSearchesQuery('user-1', 2, 1);
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockPrisma.savedSearch.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 1,
|
||||
take: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty list when no saved searches', async () => {
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
|
||||
mockPrisma.savedSearch.count.mockResolvedValue(0);
|
||||
|
||||
const query = new GetSavedSearchesQuery('user-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.data).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { UpdateSavedSearchCommand } from '../commands/update-saved-search/update-saved-search.command';
|
||||
import { UpdateSavedSearchHandler } from '../commands/update-saved-search/update-saved-search.handler';
|
||||
|
||||
describe('UpdateSavedSearchHandler', () => {
|
||||
let handler: UpdateSavedSearchHandler;
|
||||
let mockPrisma: any;
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
const existingSearch = {
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Original Name',
|
||||
filters: { district: 'Quan 7' },
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
savedSearch: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
handler = new UpdateSavedSearchHandler(mockPrisma, mockLogger as any);
|
||||
});
|
||||
|
||||
it('updates the name of a saved search', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
|
||||
mockPrisma.savedSearch.update.mockResolvedValue({
|
||||
...existingSearch,
|
||||
name: 'New Name',
|
||||
});
|
||||
|
||||
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', 'New Name');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.name).toBe('New Name');
|
||||
expect(mockPrisma.savedSearch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'saved-1' },
|
||||
data: { name: 'New Name' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates alertEnabled', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
|
||||
mockPrisma.savedSearch.update.mockResolvedValue({
|
||||
...existingSearch,
|
||||
alertEnabled: false,
|
||||
});
|
||||
|
||||
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', undefined, undefined, false);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.alertEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when saved search does not exist', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(null);
|
||||
|
||||
const command = new UpdateSavedSearchCommand('non-existent', 'user-1', 'New Name');
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when user does not own the saved search', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue({
|
||||
...existingSearch,
|
||||
userId: 'other-user',
|
||||
});
|
||||
|
||||
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', 'New Name');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Bạn không có quyền cập nhật tìm kiếm này');
|
||||
});
|
||||
|
||||
it('throws ValidationException when name is empty', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
|
||||
|
||||
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', '');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Tên tìm kiếm không được để trống');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export class CreateSavedSearchCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly name: string,
|
||||
public readonly filters: Record<string, unknown>,
|
||||
public readonly alertEnabled: boolean,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CommandHandler, type CommandBus, type ICommandHandler, type QueryBus } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { type SavedSearch, type Prisma } from '@prisma/client';
|
||||
import { ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import { CheckQuotaQuery, type QuotaCheckResult, MeterUsageCommand } from '@modules/subscriptions';
|
||||
import { CreateSavedSearchCommand } from './create-saved-search.command';
|
||||
|
||||
export interface CreateSavedSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: unknown;
|
||||
alertEnabled: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@CommandHandler(CreateSavedSearchCommand)
|
||||
export class CreateSavedSearchHandler implements ICommandHandler<CreateSavedSearchCommand> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateSavedSearchCommand): Promise<CreateSavedSearchResult> {
|
||||
// Validate name
|
||||
if (!command.name || command.name.trim().length === 0) {
|
||||
throw new ValidationException('Tên tìm kiếm không được để trống');
|
||||
}
|
||||
|
||||
if (command.name.trim().length > 100) {
|
||||
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
|
||||
}
|
||||
|
||||
// Check quota
|
||||
const quotaResult: QuotaCheckResult = await this.queryBus.execute(
|
||||
new CheckQuotaQuery(command.userId, 'searches_saved'),
|
||||
);
|
||||
|
||||
if (!quotaResult.allowed) {
|
||||
throw new ValidationException(
|
||||
`Bạn đã đạt giới hạn ${quotaResult.limit} tìm kiếm đã lưu. Vui lòng nâng cấp gói để tiếp tục.`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = createId();
|
||||
const savedSearch: SavedSearch = await this.prisma.savedSearch.create({
|
||||
data: {
|
||||
id,
|
||||
userId: command.userId,
|
||||
name: command.name.trim(),
|
||||
filters: command.filters as Prisma.InputJsonValue,
|
||||
alertEnabled: command.alertEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
// Best-effort usage metering
|
||||
try {
|
||||
await this.commandBus.execute(
|
||||
new MeterUsageCommand(command.userId, 'searches_saved', 1),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Usage metering failed for saved search: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'CreateSavedSearchHandler',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Saved search created: id=${id}, user=${command.userId}`, 'CreateSavedSearchHandler');
|
||||
|
||||
return {
|
||||
id: savedSearch.id,
|
||||
name: savedSearch.name,
|
||||
filters: savedSearch.filters,
|
||||
alertEnabled: savedSearch.alertEnabled,
|
||||
createdAt: savedSearch.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DeleteSavedSearchCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ForbiddenException, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import { DeleteSavedSearchCommand } from './delete-saved-search.command';
|
||||
|
||||
@CommandHandler(DeleteSavedSearchCommand)
|
||||
export class DeleteSavedSearchHandler implements ICommandHandler<DeleteSavedSearchCommand> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteSavedSearchCommand): Promise<{ deleted: boolean }> {
|
||||
const savedSearch = await this.prisma.savedSearch.findUnique({
|
||||
where: { id: command.id },
|
||||
});
|
||||
|
||||
if (!savedSearch) {
|
||||
throw new NotFoundException('SavedSearch', command.id);
|
||||
}
|
||||
|
||||
if (savedSearch.userId !== command.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền xóa tìm kiếm này');
|
||||
}
|
||||
|
||||
await this.prisma.savedSearch.delete({ where: { id: command.id } });
|
||||
|
||||
this.logger.log(`Saved search deleted: id=${command.id}, user=${command.userId}`, 'DeleteSavedSearchHandler');
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class UpdateSavedSearchCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
public readonly name?: string,
|
||||
public readonly filters?: Record<string, unknown>,
|
||||
public readonly alertEnabled?: boolean,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type Prisma } from '@prisma/client';
|
||||
import { ForbiddenException, NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import { UpdateSavedSearchCommand } from './update-saved-search.command';
|
||||
|
||||
export interface UpdateSavedSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: unknown;
|
||||
alertEnabled: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateSavedSearchCommand)
|
||||
export class UpdateSavedSearchHandler implements ICommandHandler<UpdateSavedSearchCommand> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateSavedSearchCommand): Promise<UpdateSavedSearchResult> {
|
||||
const savedSearch = await this.prisma.savedSearch.findUnique({
|
||||
where: { id: command.id },
|
||||
});
|
||||
|
||||
if (!savedSearch) {
|
||||
throw new NotFoundException('SavedSearch', command.id);
|
||||
}
|
||||
|
||||
if (savedSearch.userId !== command.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền cập nhật tìm kiếm này');
|
||||
}
|
||||
|
||||
if (command.name !== undefined && command.name.trim().length === 0) {
|
||||
throw new ValidationException('Tên tìm kiếm không được để trống');
|
||||
}
|
||||
|
||||
if (command.name !== undefined && command.name.trim().length > 100) {
|
||||
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.savedSearch.update({
|
||||
where: { id: command.id },
|
||||
data: {
|
||||
...(command.name !== undefined && { name: command.name.trim() }),
|
||||
...(command.filters !== undefined && { filters: command.filters as Prisma.InputJsonValue }),
|
||||
...(command.alertEnabled !== undefined && { alertEnabled: command.alertEnabled }),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Saved search updated: id=${command.id}, user=${command.userId}`, 'UpdateSavedSearchHandler');
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
filters: updated.filters,
|
||||
alertEnabled: updated.alertEnabled,
|
||||
createdAt: updated.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,17 @@ export { SyncListingCommand } from './commands/sync-listing/sync-listing.command
|
||||
export { SyncListingHandler } from './commands/sync-listing/sync-listing.handler';
|
||||
export { ReindexAllCommand } from './commands/reindex-all/reindex-all.command';
|
||||
export { ReindexAllHandler, type ReindexResult } from './commands/reindex-all/reindex-all.handler';
|
||||
export { CreateSavedSearchCommand } from './commands/create-saved-search/create-saved-search.command';
|
||||
export { CreateSavedSearchHandler, type CreateSavedSearchResult } from './commands/create-saved-search/create-saved-search.handler';
|
||||
export { DeleteSavedSearchCommand } from './commands/delete-saved-search/delete-saved-search.command';
|
||||
export { DeleteSavedSearchHandler } from './commands/delete-saved-search/delete-saved-search.handler';
|
||||
export { UpdateSavedSearchCommand } from './commands/update-saved-search/update-saved-search.command';
|
||||
export { UpdateSavedSearchHandler, type UpdateSavedSearchResult } from './commands/update-saved-search/update-saved-search.handler';
|
||||
export { SearchPropertiesQuery } from './queries/search-properties/search-properties.query';
|
||||
export { SearchPropertiesHandler } from './queries/search-properties/search-properties.handler';
|
||||
export { GeoSearchQuery } from './queries/geo-search/geo-search.query';
|
||||
export { GeoSearchHandler } from './queries/geo-search/geo-search.handler';
|
||||
export { GetSavedSearchQuery } from './queries/get-saved-search/get-saved-search.query';
|
||||
export { GetSavedSearchHandler, type SavedSearchDetail } from './queries/get-saved-search/get-saved-search.handler';
|
||||
export { GetSavedSearchesQuery } from './queries/get-saved-searches/get-saved-searches.query';
|
||||
export { GetSavedSearchesHandler, type SavedSearchListResult, type SavedSearchItem } from './queries/get-saved-searches/get-saved-searches.handler';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared';
|
||||
import { GetSavedSearchQuery } from './get-saved-search.query';
|
||||
|
||||
export interface SavedSearchDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: unknown;
|
||||
alertEnabled: boolean;
|
||||
lastAlertAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@QueryHandler(GetSavedSearchQuery)
|
||||
export class GetSavedSearchHandler implements IQueryHandler<GetSavedSearchQuery> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetSavedSearchQuery): Promise<SavedSearchDetail> {
|
||||
const savedSearch = await this.prisma.savedSearch.findUnique({
|
||||
where: { id: query.id },
|
||||
});
|
||||
|
||||
if (!savedSearch) {
|
||||
throw new NotFoundException('SavedSearch', query.id);
|
||||
}
|
||||
|
||||
if (savedSearch.userId !== query.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền xem tìm kiếm này');
|
||||
}
|
||||
|
||||
return {
|
||||
id: savedSearch.id,
|
||||
name: savedSearch.name,
|
||||
filters: savedSearch.filters,
|
||||
alertEnabled: savedSearch.alertEnabled,
|
||||
lastAlertAt: savedSearch.lastAlertAt,
|
||||
createdAt: savedSearch.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetSavedSearchQuery {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { GetSavedSearchesQuery } from './get-saved-searches.query';
|
||||
|
||||
export interface SavedSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: unknown;
|
||||
alertEnabled: boolean;
|
||||
lastAlertAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SavedSearchListResult {
|
||||
data: SavedSearchItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
@QueryHandler(GetSavedSearchesQuery)
|
||||
export class GetSavedSearchesHandler implements IQueryHandler<GetSavedSearchesQuery> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetSavedSearchesQuery): Promise<SavedSearchListResult> {
|
||||
const skip = (query.page - 1) * query.limit;
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.savedSearch.findMany({
|
||||
where: { userId: query.userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: query.limit,
|
||||
}),
|
||||
this.prisma.savedSearch.count({
|
||||
where: { userId: query.userId },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: data.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
filters: s.filters,
|
||||
alertEnabled: s.alertEnabled,
|
||||
lastAlertAt: s.lastAlertAt,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class GetSavedSearchesQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { type Counter } from 'prom-client';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { type SearchResult } from '../../domain/repositories/search.repository';
|
||||
import { type PostgresSearchRepository } from '../services/postgres-search.repository';
|
||||
import { ResilientSearchRepository } from '../services/resilient-search.repository';
|
||||
import { type TypesenseSearchRepository } from '../services/typesense-search.repository';
|
||||
|
||||
function createMockSearchResult(overrides?: Partial<SearchResult>): SearchResult {
|
||||
return {
|
||||
hits: [],
|
||||
totalFound: 0,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
totalPages: 0,
|
||||
searchTimeMs: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ResilientSearchRepository', () => {
|
||||
let repository: ResilientSearchRepository;
|
||||
let mockTypesense: { [K in keyof TypesenseSearchRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPostgres: { [K in keyof PostgresSearchRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
let mockCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockTypesense = {
|
||||
search: vi.fn(),
|
||||
indexDocument: vi.fn(),
|
||||
indexDocuments: vi.fn(),
|
||||
removeDocument: vi.fn(),
|
||||
ensureCollection: vi.fn(),
|
||||
dropCollection: vi.fn(),
|
||||
};
|
||||
mockPostgres = {
|
||||
search: vi.fn(),
|
||||
indexDocument: vi.fn(),
|
||||
indexDocuments: vi.fn(),
|
||||
removeDocument: vi.fn(),
|
||||
ensureCollection: vi.fn(),
|
||||
dropCollection: vi.fn(),
|
||||
};
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
mockCounter = { inc: vi.fn() };
|
||||
|
||||
repository = new ResilientSearchRepository(
|
||||
mockTypesense as unknown as TypesenseSearchRepository,
|
||||
mockPostgres as unknown as PostgresSearchRepository,
|
||||
mockLogger as unknown as LoggerService,
|
||||
mockCounter as unknown as Counter,
|
||||
);
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('uses Typesense when available', async () => {
|
||||
const expected = createMockSearchResult({ totalFound: 10 });
|
||||
mockTypesense.search.mockResolvedValue(expected);
|
||||
|
||||
const result = await repository.search({ query: 'test' });
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockTypesense.search).toHaveBeenCalledWith({ query: 'test' });
|
||||
expect(mockPostgres.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to PostgreSQL when Typesense fails', async () => {
|
||||
const pgResult = createMockSearchResult({ totalFound: 5 });
|
||||
mockTypesense.search.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
mockPostgres.search.mockResolvedValue(pgResult);
|
||||
|
||||
const result = await repository.search({ query: 'test' });
|
||||
|
||||
expect(result).toEqual(pgResult);
|
||||
expect(mockPostgres.search).toHaveBeenCalledWith({ query: 'test' });
|
||||
expect(mockCounter.inc).toHaveBeenCalledWith({ service: 'typesense', event: 'fallback_search' });
|
||||
});
|
||||
|
||||
it('opens circuit after 3 consecutive failures and uses PG fallback', async () => {
|
||||
mockTypesense.search.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const pgResult = createMockSearchResult({ totalFound: 3 });
|
||||
mockPostgres.search.mockResolvedValue(pgResult);
|
||||
|
||||
// 3 failures to trip the breaker
|
||||
await repository.search({ query: 'a' });
|
||||
await repository.search({ query: 'b' });
|
||||
await repository.search({ query: 'c' });
|
||||
|
||||
// Reset mock call counts
|
||||
mockTypesense.search.mockClear();
|
||||
mockPostgres.search.mockClear();
|
||||
|
||||
// 4th call should not even try Typesense (circuit is OPEN)
|
||||
const result = await repository.search({ query: 'd' });
|
||||
expect(result).toEqual(pgResult);
|
||||
expect(mockTypesense.search).not.toHaveBeenCalled();
|
||||
expect(mockPostgres.search).toHaveBeenCalledWith({ query: 'd' });
|
||||
});
|
||||
|
||||
it('recovers to Typesense after circuit resets', async () => {
|
||||
// Trip the circuit
|
||||
mockTypesense.search.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const pgResult = createMockSearchResult({ totalFound: 2 });
|
||||
mockPostgres.search.mockResolvedValue(pgResult);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await repository.search({ query: `fail-${i}` });
|
||||
}
|
||||
|
||||
// Now simulate Typesense recovery - we need to wait for the reset timeout
|
||||
// But since the breaker has a 30s default timeout, we use a different approach:
|
||||
// Create a new repository with a fast timeout
|
||||
const fastRepo = new (ResilientSearchRepository as any as new (...args: any[]) => ResilientSearchRepository)(
|
||||
mockTypesense as unknown as TypesenseSearchRepository,
|
||||
mockPostgres as unknown as PostgresSearchRepository,
|
||||
mockLogger as unknown as LoggerService,
|
||||
mockCounter as unknown as Counter,
|
||||
);
|
||||
|
||||
// The new instance starts fresh, so Typesense calls should work
|
||||
const tsResult = createMockSearchResult({ totalFound: 10 });
|
||||
mockTypesense.search.mockResolvedValue(tsResult);
|
||||
|
||||
const result = await fastRepo.search({ query: 'recovered' });
|
||||
expect(result).toEqual(tsResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexDocument', () => {
|
||||
it('indexes via Typesense silently', async () => {
|
||||
const doc = { id: '1' } as any;
|
||||
mockTypesense.indexDocument.mockResolvedValue(undefined);
|
||||
|
||||
await repository.indexDocument(doc);
|
||||
expect(mockTypesense.indexDocument).toHaveBeenCalledWith(doc);
|
||||
});
|
||||
|
||||
it('swallows Typesense indexing errors and logs a warning', async () => {
|
||||
const doc = { id: '1' } as any;
|
||||
mockTypesense.indexDocument.mockRejectedValue(new Error('down'));
|
||||
|
||||
await repository.indexDocument(doc);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Typesense indexDocument failed'),
|
||||
'ResilientSearch',
|
||||
);
|
||||
expect(mockCounter.inc).toHaveBeenCalledWith({ service: 'typesense', event: 'index_failure' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexDocuments', () => {
|
||||
it('indexes batch via Typesense', async () => {
|
||||
const docs = [{ id: '1' }, { id: '2' }] as any[];
|
||||
mockTypesense.indexDocuments.mockResolvedValue(undefined);
|
||||
|
||||
await repository.indexDocuments(docs);
|
||||
expect(mockTypesense.indexDocuments).toHaveBeenCalledWith(docs);
|
||||
});
|
||||
|
||||
it('swallows batch indexing errors', async () => {
|
||||
mockTypesense.indexDocuments.mockRejectedValue(new Error('timeout'));
|
||||
|
||||
await repository.indexDocuments([{ id: '1' }] as any[]);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Typesense indexDocuments failed'),
|
||||
'ResilientSearch',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureCollection', () => {
|
||||
it('records success on the circuit breaker', async () => {
|
||||
mockTypesense.ensureCollection.mockResolvedValue(undefined);
|
||||
|
||||
await repository.ensureCollection();
|
||||
|
||||
expect(mockTypesense.ensureCollection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs warning but does not throw on failure', async () => {
|
||||
mockTypesense.ensureCollection.mockRejectedValue(new Error('no connection'));
|
||||
|
||||
await repository.ensureCollection();
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Typesense ensureCollection failed'),
|
||||
'ResilientSearch',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { SavedSearchAlertHandler } from '../../infrastructure/event-handlers/saved-search-alert.handler';
|
||||
|
||||
describe('SavedSearchAlertHandler', () => {
|
||||
let handler: SavedSearchAlertHandler;
|
||||
let mockPrisma: any;
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockListing = {
|
||||
id: 'listing-1',
|
||||
sellerId: 'seller-1',
|
||||
transactionType: 'SALE',
|
||||
priceVND: BigInt(3_000_000_000),
|
||||
property: {
|
||||
propertyType: 'APARTMENT',
|
||||
title: 'Chung cư cao cấp Quận 7',
|
||||
district: 'Quan 7',
|
||||
city: 'Ho Chi Minh',
|
||||
areaM2: 80,
|
||||
bedrooms: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSavedSearch = {
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Chung cư Q7',
|
||||
filters: { district: 'Quan 7', propertyType: 'APARTMENT' },
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
user: { id: 'user-1', email: 'user@example.com', fullName: 'Nguyen Van A' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
listing: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
savedSearch: {
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
handler = new SavedSearchAlertHandler(mockPrisma, mockCommandBus as any, mockLogger as any);
|
||||
});
|
||||
|
||||
it('sends alert when listing matches saved search filters', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([mockSavedSearch]);
|
||||
mockPrisma.savedSearch.update.mockResolvedValue({});
|
||||
|
||||
await handler.handle({ listingId: 'listing-1' });
|
||||
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrisma.savedSearch.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'saved-1' },
|
||||
data: { lastAlertAt: expect.any(Date) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send alert when listing does not match filters', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([
|
||||
{
|
||||
...mockSavedSearch,
|
||||
filters: { district: 'Quan 1', propertyType: 'HOUSE' },
|
||||
},
|
||||
]);
|
||||
|
||||
await handler.handle({ listingId: 'listing-1' });
|
||||
|
||||
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips saved search belonging to listing seller', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([
|
||||
{ ...mockSavedSearch, userId: 'seller-1' },
|
||||
]);
|
||||
|
||||
await handler.handle({ listingId: 'listing-1' });
|
||||
|
||||
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles listing not found gracefully', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(null);
|
||||
|
||||
await handler.handle({ listingId: 'non-existent' });
|
||||
|
||||
expect(mockPrisma.savedSearch.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches price range filters', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([
|
||||
{
|
||||
...mockSavedSearch,
|
||||
filters: { priceMin: '2000000000', priceMax: '5000000000' },
|
||||
},
|
||||
]);
|
||||
mockPrisma.savedSearch.update.mockResolvedValue({});
|
||||
|
||||
await handler.handle({ listingId: 'listing-1' });
|
||||
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not match when price is outside range', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([
|
||||
{
|
||||
...mockSavedSearch,
|
||||
filters: { priceMax: '1000000000' },
|
||||
},
|
||||
]);
|
||||
|
||||
await handler.handle({ listingId: 'listing-1' });
|
||||
|
||||
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export { TypesenseClientService } from './typesense-client.service';
|
||||
export { TypesenseSearchRepository } from './typesense-search.repository';
|
||||
export { PostgresSearchRepository } from './postgres-search.repository';
|
||||
export { ResilientSearchRepository, SEARCH_DEGRADATION_TOTAL } from './resilient-search.repository';
|
||||
export { ListingIndexerService } from './listing-indexer.service';
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type ISearchRepository,
|
||||
type ListingDocument,
|
||||
type SearchParams,
|
||||
type SearchResult,
|
||||
} from '../../domain/repositories/search.repository';
|
||||
|
||||
/**
|
||||
* PostgreSQL-backed search repository used as a fallback when Typesense
|
||||
* is unavailable.
|
||||
*
|
||||
* Capabilities:
|
||||
* - Full-text search via PostgreSQL `to_tsvector` / `plainto_tsquery`
|
||||
* - Geo radius filtering via PostGIS `ST_DWithin`
|
||||
* - Faceted filters (property type, transaction type, price range, area, etc.)
|
||||
*
|
||||
* Limitations compared to Typesense:
|
||||
* - No relevance-ranked highlighting
|
||||
* - Slower for large result sets
|
||||
* - Vietnamese language support depends on PG config (defaults to 'simple')
|
||||
*/
|
||||
@Injectable()
|
||||
export class PostgresSearchRepository implements ISearchRepository {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Search listings using PostgreSQL full-text search + PostGIS.
|
||||
* Parses the Typesense-style `filterBy` string to build SQL conditions.
|
||||
*/
|
||||
async search(params: SearchParams): Promise<SearchResult> {
|
||||
const startMs = Date.now();
|
||||
const page = params.page ?? 1;
|
||||
const perPage = params.perPage ?? 20;
|
||||
const offset = (page - 1) * perPage;
|
||||
|
||||
const conditions: Prisma.Sql[] = [Prisma.sql`l."status" = 'ACTIVE'`];
|
||||
const parsed = this.parseFilterBy(params.filterBy ?? '');
|
||||
|
||||
// ── Parsed Typesense-style filters ─────────────────────────────────
|
||||
if (parsed.propertyType) {
|
||||
conditions.push(Prisma.sql`p."propertyType" = ${parsed.propertyType}`);
|
||||
}
|
||||
if (parsed.transactionType) {
|
||||
conditions.push(Prisma.sql`l."transactionType" = ${parsed.transactionType}`);
|
||||
}
|
||||
if (parsed.priceMin !== undefined && parsed.priceMax !== undefined) {
|
||||
conditions.push(Prisma.sql`l."priceVND" BETWEEN ${BigInt(parsed.priceMin)} AND ${BigInt(parsed.priceMax)}`);
|
||||
} else if (parsed.priceMin !== undefined) {
|
||||
conditions.push(Prisma.sql`l."priceVND" >= ${BigInt(parsed.priceMin)}`);
|
||||
} else if (parsed.priceMax !== undefined) {
|
||||
conditions.push(Prisma.sql`l."priceVND" <= ${BigInt(parsed.priceMax)}`);
|
||||
}
|
||||
if (parsed.areaMin !== undefined && parsed.areaMax !== undefined) {
|
||||
conditions.push(Prisma.sql`p."areaM2" BETWEEN ${parsed.areaMin} AND ${parsed.areaMax}`);
|
||||
} else if (parsed.areaMin !== undefined) {
|
||||
conditions.push(Prisma.sql`p."areaM2" >= ${parsed.areaMin}`);
|
||||
} else if (parsed.areaMax !== undefined) {
|
||||
conditions.push(Prisma.sql`p."areaM2" <= ${parsed.areaMax}`);
|
||||
}
|
||||
if (parsed.bedrooms !== undefined) {
|
||||
conditions.push(Prisma.sql`p."bedrooms" >= ${parsed.bedrooms}`);
|
||||
}
|
||||
if (parsed.district) {
|
||||
conditions.push(Prisma.sql`p."district" = ${parsed.district}`);
|
||||
}
|
||||
if (parsed.city) {
|
||||
conditions.push(Prisma.sql`p."city" = ${parsed.city}`);
|
||||
}
|
||||
|
||||
// ── Geo radius filter (PostGIS) ────────────────────────────────────
|
||||
if (params.geoPoint && params.geoRadiusKm) {
|
||||
const radiusMeters = params.geoRadiusKm * 1000;
|
||||
conditions.push(
|
||||
Prisma.sql`ST_DWithin(
|
||||
p."location"::geography,
|
||||
ST_SetSRID(ST_MakePoint(${params.geoPoint.lng}, ${params.geoPoint.lat}), 4326)::geography,
|
||||
${radiusMeters}
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Full-text search condition ─────────────────────────────────────
|
||||
const hasTextQuery = params.query && params.query !== '*';
|
||||
if (hasTextQuery) {
|
||||
conditions.push(
|
||||
Prisma.sql`(
|
||||
to_tsvector('simple', coalesce(p."title", '') || ' ' || coalesce(p."description", '') || ' ' || coalesce(p."address", '') || ' ' || coalesce(p."district", '') || ' ' || coalesce(p."city", ''))
|
||||
@@ plainto_tsquery('simple', ${params.query!})
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
const whereClause = Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`;
|
||||
|
||||
// ── Count total matches ────────────────────────────────────────────
|
||||
const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>(
|
||||
Prisma.sql`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "Listing" l
|
||||
JOIN "Property" p ON l."propertyId" = p."id"
|
||||
${whereClause}
|
||||
`,
|
||||
);
|
||||
const totalFound = Number(countResult[0]?.count ?? 0);
|
||||
|
||||
// ── Sorting ────────────────────────────────────────────────────────
|
||||
let orderClause: Prisma.Sql;
|
||||
if (params.geoPoint && (params.sortBy === 'distance' || (!params.sortBy && params.geoRadiusKm))) {
|
||||
orderClause = Prisma.sql`ORDER BY ST_Distance(
|
||||
p."location"::geography,
|
||||
ST_SetSRID(ST_MakePoint(${params.geoPoint.lng}, ${params.geoPoint.lat}), 4326)::geography
|
||||
) ASC`;
|
||||
} else {
|
||||
switch (params.sortBy) {
|
||||
case 'price_asc':
|
||||
orderClause = Prisma.sql`ORDER BY l."priceVND" ASC`;
|
||||
break;
|
||||
case 'price_desc':
|
||||
orderClause = Prisma.sql`ORDER BY l."priceVND" DESC`;
|
||||
break;
|
||||
case 'date_desc':
|
||||
orderClause = Prisma.sql`ORDER BY l."publishedAt" DESC NULLS LAST`;
|
||||
break;
|
||||
case 'relevance':
|
||||
default:
|
||||
if (hasTextQuery) {
|
||||
orderClause = Prisma.sql`ORDER BY ts_rank(
|
||||
to_tsvector('simple', coalesce(p."title", '') || ' ' || coalesce(p."description", '') || ' ' || coalesce(p."address", '') || ' ' || coalesce(p."district", '') || ' ' || coalesce(p."city", '')),
|
||||
plainto_tsquery('simple', ${params.query!})
|
||||
) DESC, l."publishedAt" DESC NULLS LAST`;
|
||||
} else {
|
||||
orderClause = Prisma.sql`ORDER BY l."publishedAt" DESC NULLS LAST`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch rows ─────────────────────────────────────────────────────
|
||||
const rows = await this.prisma.$queryRaw<RawListingRow[]>(
|
||||
Prisma.sql`
|
||||
SELECT
|
||||
l."id" AS "listingId",
|
||||
l."propertyId" AS "propertyId",
|
||||
p."title" AS "title",
|
||||
p."description" AS "description",
|
||||
p."propertyType" AS "propertyType",
|
||||
l."transactionType" AS "transactionType",
|
||||
l."priceVND" AS "priceVND",
|
||||
l."pricePerM2" AS "pricePerM2",
|
||||
p."areaM2" AS "areaM2",
|
||||
p."bedrooms" AS "bedrooms",
|
||||
p."bathrooms" AS "bathrooms",
|
||||
p."floors" AS "floors",
|
||||
p."direction" AS "direction",
|
||||
p."address" AS "address",
|
||||
p."ward" AS "ward",
|
||||
p."district" AS "district",
|
||||
p."city" AS "city",
|
||||
ST_Y(p."location"::geometry) AS "lat",
|
||||
ST_X(p."location"::geometry) AS "lng",
|
||||
l."agentId" AS "agentId",
|
||||
l."sellerId" AS "sellerId",
|
||||
l."status" AS "status",
|
||||
l."publishedAt" AS "publishedAt",
|
||||
l."viewCount" AS "viewCount",
|
||||
l."saveCount" AS "saveCount",
|
||||
p."projectName" AS "projectName",
|
||||
p."amenities" AS "amenities"
|
||||
FROM "Listing" l
|
||||
JOIN "Property" p ON l."propertyId" = p."id"
|
||||
${whereClause}
|
||||
${orderClause}
|
||||
LIMIT ${perPage} OFFSET ${offset}
|
||||
`,
|
||||
);
|
||||
|
||||
const hits: ListingDocument[] = rows.map((row) => ({
|
||||
id: row.listingId,
|
||||
listingId: row.listingId,
|
||||
propertyId: row.propertyId,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
propertyType: row.propertyType,
|
||||
transactionType: row.transactionType,
|
||||
priceVND: Number(row.priceVND),
|
||||
pricePerM2: row.pricePerM2 ? Number(row.pricePerM2) : null,
|
||||
areaM2: Number(row.areaM2),
|
||||
bedrooms: row.bedrooms,
|
||||
bathrooms: row.bathrooms,
|
||||
floors: row.floors,
|
||||
direction: row.direction,
|
||||
address: row.address,
|
||||
ward: row.ward,
|
||||
district: row.district,
|
||||
city: row.city,
|
||||
location: [row.lat ?? 0, row.lng ?? 0] as [number, number],
|
||||
agentId: row.agentId,
|
||||
sellerId: row.sellerId,
|
||||
status: row.status,
|
||||
publishedAt: row.publishedAt ? Math.floor(new Date(row.publishedAt).getTime() / 1000) : 0,
|
||||
viewCount: row.viewCount ?? 0,
|
||||
saveCount: row.saveCount ?? 0,
|
||||
projectName: row.projectName,
|
||||
amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [],
|
||||
}));
|
||||
|
||||
const searchTimeMs = Date.now() - startMs;
|
||||
|
||||
return {
|
||||
hits,
|
||||
totalFound,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(totalFound / perPage),
|
||||
searchTimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Indexing operations are no-ops for the PG fallback ───────────────
|
||||
|
||||
async indexDocument(_doc: ListingDocument): Promise<void> {
|
||||
// Data already lives in PostgreSQL — nothing to do.
|
||||
}
|
||||
|
||||
async indexDocuments(_docs: ListingDocument[]): Promise<void> {
|
||||
// Data already lives in PostgreSQL — nothing to do.
|
||||
}
|
||||
|
||||
async removeDocument(_id: string): Promise<void> {
|
||||
// No separate index to clean up.
|
||||
}
|
||||
|
||||
async ensureCollection(): Promise<void> {
|
||||
// PostgreSQL tables/indexes are managed by Prisma migrations.
|
||||
}
|
||||
|
||||
async dropCollection(): Promise<void> {
|
||||
// Not applicable for PostgreSQL fallback.
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minimal parser for the Typesense-style `filterBy` strings produced
|
||||
* by the query handlers.
|
||||
*
|
||||
* Expected format examples:
|
||||
* "status:=ACTIVE && propertyType:=HOUSE && priceVND:[2000..5000]"
|
||||
* "status:=ACTIVE && priceVND:>=1000 && bedrooms:>=3"
|
||||
*/
|
||||
private parseFilterBy(filterStr: string): ParsedFilters {
|
||||
const result: ParsedFilters = {};
|
||||
if (!filterStr) return result;
|
||||
|
||||
const clauses = filterStr.split('&&').map((c) => c.trim());
|
||||
for (const clause of clauses) {
|
||||
// Range: field:[min..max]
|
||||
const rangeMatch = clause.match(/^(\w+):\[(\d+)\.\.(\d+)\]$/);
|
||||
if (rangeMatch) {
|
||||
const field = rangeMatch[1]!;
|
||||
const min = Number(rangeMatch[2]);
|
||||
const max = Number(rangeMatch[3]);
|
||||
if (field === 'priceVND') {
|
||||
result.priceMin = min;
|
||||
result.priceMax = max;
|
||||
} else if (field === 'areaM2') {
|
||||
result.areaMin = min;
|
||||
result.areaMax = max;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Equality: field:=value
|
||||
const eqMatch = clause.match(/^(\w+):=(.+)$/);
|
||||
if (eqMatch) {
|
||||
const field = eqMatch[1]!;
|
||||
const val = eqMatch[2]!;
|
||||
if (field === 'propertyType') result.propertyType = val;
|
||||
else if (field === 'transactionType') result.transactionType = val;
|
||||
else if (field === 'district') result.district = val;
|
||||
else if (field === 'city') result.city = val;
|
||||
else if (field === 'status') { /* handled separately */ }
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gte: field:>=value
|
||||
const gteMatch = clause.match(/^(\w+):>=(\d+(?:\.\d+)?)$/);
|
||||
if (gteMatch) {
|
||||
const field = gteMatch[1]!;
|
||||
const val = Number(gteMatch[2]);
|
||||
if (field === 'priceVND') result.priceMin = val;
|
||||
else if (field === 'areaM2') result.areaMin = val;
|
||||
else if (field === 'bedrooms') result.bedrooms = val;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lte: field:<=value
|
||||
const lteMatch = clause.match(/^(\w+):<=(\d+(?:\.\d+)?)$/);
|
||||
if (lteMatch) {
|
||||
const field = lteMatch[1]!;
|
||||
const val = Number(lteMatch[2]);
|
||||
if (field === 'priceVND') result.priceMax = val;
|
||||
else if (field === 'areaM2') result.areaMax = val;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Geo filter: location:(lat, lng, radius km) — skip, handled via params
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedFilters {
|
||||
propertyType?: string;
|
||||
transactionType?: string;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
areaMin?: number;
|
||||
areaMax?: number;
|
||||
bedrooms?: number;
|
||||
district?: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
interface RawListingRow {
|
||||
listingId: string;
|
||||
propertyId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
propertyType: string;
|
||||
transactionType: string;
|
||||
priceVND: bigint;
|
||||
pricePerM2: number | null;
|
||||
areaM2: number;
|
||||
bedrooms: number | null;
|
||||
bathrooms: number | null;
|
||||
floors: number | null;
|
||||
direction: string | null;
|
||||
address: string;
|
||||
ward: string;
|
||||
district: string;
|
||||
city: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
agentId: string | null;
|
||||
sellerId: string;
|
||||
status: string;
|
||||
publishedAt: Date | string | null;
|
||||
viewCount: number;
|
||||
saveCount: number;
|
||||
projectName: string | null;
|
||||
amenities: unknown;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type Client as TypesenseClient } from 'typesense';
|
||||
import type { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { type CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
type ISearchRepository,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { SearchController } from './search.controller';
|
||||
export { SavedSearchController } from './saved-search.controller';
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { CreateSavedSearchCommand } from '../../application/commands/create-saved-search/create-saved-search.command';
|
||||
import { type CreateSavedSearchResult } from '../../application/commands/create-saved-search/create-saved-search.handler';
|
||||
import { DeleteSavedSearchCommand } from '../../application/commands/delete-saved-search/delete-saved-search.command';
|
||||
import { UpdateSavedSearchCommand } from '../../application/commands/update-saved-search/update-saved-search.command';
|
||||
import { type UpdateSavedSearchResult } from '../../application/commands/update-saved-search/update-saved-search.handler';
|
||||
import { type SavedSearchDetail } from '../../application/queries/get-saved-search/get-saved-search.handler';
|
||||
import { GetSavedSearchQuery } from '../../application/queries/get-saved-search/get-saved-search.query';
|
||||
import { type SavedSearchListResult } from '../../application/queries/get-saved-searches/get-saved-searches.handler';
|
||||
import { GetSavedSearchesQuery } from '../../application/queries/get-saved-searches/get-saved-searches.query';
|
||||
import { type CreateSavedSearchDto, type UpdateSavedSearchDto, type SavedSearchListDto } from '../dto/saved-search.dto';
|
||||
|
||||
@ApiTags('saved-searches')
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('saved-searches')
|
||||
export class SavedSearchController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Lưu tìm kiếm', description: 'Lưu bộ lọc tìm kiếm để nhận thông báo khi có kết quả mới' })
|
||||
@ApiResponse({ status: 201, description: 'Tìm kiếm đã được lưu' })
|
||||
@ApiResponse({ status: 400, description: 'Dữ liệu không hợp lệ' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
|
||||
@ApiResponse({ status: 403, description: 'Đã đạt giới hạn gói đăng ký' })
|
||||
async create(
|
||||
@Body() dto: CreateSavedSearchDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<CreateSavedSearchResult> {
|
||||
return this.commandBus.execute(
|
||||
new CreateSavedSearchCommand(
|
||||
user.sub,
|
||||
dto.name,
|
||||
dto.filters,
|
||||
dto.alertEnabled ?? true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Danh sách tìm kiếm đã lưu', description: 'Lấy danh sách tìm kiếm đã lưu của người dùng' })
|
||||
@ApiResponse({ status: 200, description: 'Danh sách tìm kiếm đã lưu' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
|
||||
async list(
|
||||
@Query() dto: SavedSearchListDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<SavedSearchListResult> {
|
||||
return this.queryBus.execute(
|
||||
new GetSavedSearchesQuery(user.sub, dto.page ?? 1, dto.limit ?? 20),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Chi tiết tìm kiếm đã lưu', description: 'Lấy chi tiết một tìm kiếm đã lưu' })
|
||||
@ApiParam({ name: 'id', description: 'ID tìm kiếm đã lưu' })
|
||||
@ApiResponse({ status: 200, description: 'Chi tiết tìm kiếm đã lưu' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy' })
|
||||
async getById(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<SavedSearchDetail> {
|
||||
return this.queryBus.execute(
|
||||
new GetSavedSearchQuery(id, user.sub),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Cập nhật tìm kiếm đã lưu', description: 'Cập nhật tên, bộ lọc hoặc trạng thái thông báo' })
|
||||
@ApiParam({ name: 'id', description: 'ID tìm kiếm đã lưu' })
|
||||
@ApiResponse({ status: 200, description: 'Đã cập nhật' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateSavedSearchDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<UpdateSavedSearchResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateSavedSearchCommand(
|
||||
id,
|
||||
user.sub,
|
||||
dto.name,
|
||||
dto.filters,
|
||||
dto.alertEnabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Xóa tìm kiếm đã lưu', description: 'Xóa một tìm kiếm đã lưu' })
|
||||
@ApiParam({ name: 'id', description: 'ID tìm kiếm đã lưu' })
|
||||
@ApiResponse({ status: 200, description: 'Đã xóa' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy' })
|
||||
async delete(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<{ deleted: boolean }> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteSavedSearchCommand(id, user.sub),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { SearchPropertiesDto, SortByOption } from './search-properties.dto';
|
||||
export { GeoSearchDto, GeoSortByOption } from './geo-search.dto';
|
||||
export { CreateSavedSearchDto, UpdateSavedSearchDto, SavedSearchListDto } from './saved-search.dto';
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateSavedSearchDto {
|
||||
@ApiProperty({ description: 'Tên tìm kiếm đã lưu', example: 'Chung cư Quận 7 dưới 3 tỷ' })
|
||||
@IsNotEmpty({ message: 'Tên tìm kiếm không được để trống' })
|
||||
@IsString()
|
||||
@MaxLength(100, { message: 'Tên tìm kiếm không được vượt quá 100 ký tự' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Bộ lọc tìm kiếm (JSON)', example: { propertyType: 'apartment', district: 'Quan 7', priceMax: 3000000000 } })
|
||||
@IsObject()
|
||||
filters!: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Bật thông báo khi có kết quả mới', default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
alertEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateSavedSearchDto {
|
||||
@ApiPropertyOptional({ description: 'Tên tìm kiếm đã lưu', example: 'Chung cư Quận 2' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100, { message: 'Tên tìm kiếm không được vượt quá 100 ký tự' })
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Bộ lọc tìm kiếm (JSON)' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
filters?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Bật/tắt thông báo' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
alertEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class SavedSearchListDto {
|
||||
@ApiPropertyOptional({ description: 'Trang', default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Số lượng mỗi trang', default: 20, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -1,34 +1,58 @@
|
||||
import { Module, type OnModuleInit } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { makeCounterProvider } from '@willsoto/nestjs-prometheus';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { CreateSavedSearchHandler } from './application/commands/create-saved-search/create-saved-search.handler';
|
||||
import { DeleteSavedSearchHandler } from './application/commands/delete-saved-search/delete-saved-search.handler';
|
||||
import { ReindexAllHandler } from './application/commands/reindex-all/reindex-all.handler';
|
||||
import { SyncListingHandler } from './application/commands/sync-listing/sync-listing.handler';
|
||||
import { UpdateSavedSearchHandler } from './application/commands/update-saved-search/update-saved-search.handler';
|
||||
import { GeoSearchHandler } from './application/queries/geo-search/geo-search.handler';
|
||||
import { GetSavedSearchHandler } from './application/queries/get-saved-search/get-saved-search.handler';
|
||||
import { GetSavedSearchesHandler } from './application/queries/get-saved-searches/get-saved-searches.handler';
|
||||
import { SearchPropertiesHandler } from './application/queries/search-properties/search-properties.handler';
|
||||
import { SEARCH_REPOSITORY } from './domain/repositories/search.repository';
|
||||
import { SavedSearchAlertCronService } from './infrastructure/cron/saved-search-alert-cron.service';
|
||||
import { ListingApprovedEventHandler } from './infrastructure/event-handlers/listing-approved.handler';
|
||||
import { ListingStatusChangedHandler } from './infrastructure/event-handlers/listing-status-changed.handler';
|
||||
import { SavedSearchAlertHandler } from './infrastructure/event-handlers/saved-search-alert.handler';
|
||||
import { ListingIndexerService } from './infrastructure/services/listing-indexer.service';
|
||||
import { PostgresSearchRepository } from './infrastructure/services/postgres-search.repository';
|
||||
import { ResilientSearchRepository, SEARCH_DEGRADATION_TOTAL } from './infrastructure/services/resilient-search.repository';
|
||||
import { TypesenseClientService } from './infrastructure/services/typesense-client.service';
|
||||
import { TypesenseSearchRepository } from './infrastructure/services/typesense-search.repository';
|
||||
import { SavedSearchController } from './presentation/controllers/saved-search.controller';
|
||||
import { SearchController } from './presentation/controllers/search.controller';
|
||||
|
||||
const CommandHandlers = [SyncListingHandler, ReindexAllHandler];
|
||||
const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler];
|
||||
const CommandHandlers = [SyncListingHandler, ReindexAllHandler, CreateSavedSearchHandler, DeleteSavedSearchHandler, UpdateSavedSearchHandler];
|
||||
const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearchesHandler, GetSavedSearchHandler];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
controllers: [SearchController],
|
||||
controllers: [SearchController, SavedSearchController],
|
||||
providers: [
|
||||
// Infrastructure
|
||||
TypesenseClientService,
|
||||
TypesenseSearchRepository,
|
||||
{ provide: SEARCH_REPOSITORY, useExisting: TypesenseSearchRepository },
|
||||
PostgresSearchRepository,
|
||||
ResilientSearchRepository,
|
||||
{ provide: SEARCH_REPOSITORY, useExisting: ResilientSearchRepository },
|
||||
ListingIndexerService,
|
||||
|
||||
// Metrics
|
||||
makeCounterProvider({
|
||||
name: SEARCH_DEGRADATION_TOTAL,
|
||||
help: 'Total search degradation events (Typesense circuit breaker)',
|
||||
labelNames: ['service', 'event'],
|
||||
}),
|
||||
|
||||
// Event handlers
|
||||
ListingApprovedEventHandler,
|
||||
ListingStatusChangedHandler,
|
||||
SavedSearchAlertHandler,
|
||||
|
||||
// Cron jobs
|
||||
SavedSearchAlertCronService,
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
@@ -39,7 +63,7 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler];
|
||||
export class SearchModule implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly typesenseClient: TypesenseClientService,
|
||||
private readonly searchRepo: TypesenseSearchRepository,
|
||||
private readonly searchRepo: ResilientSearchRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -48,8 +72,8 @@ export class SearchModule implements OnModuleInit {
|
||||
await this.searchRepo.ensureCollection();
|
||||
this.logger.log('Search module initialized — Typesense collection ready', 'SearchModule');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to initialize Typesense collection: ${err instanceof Error ? err.message : String(err)}`,
|
||||
this.logger.warn(
|
||||
`Typesense collection initialization failed: ${err instanceof Error ? err.message : String(err)} — PostgreSQL fallback is active`,
|
||||
'SearchModule',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ describe('CacheService', () => {
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
del: ReturnType<typeof vi.fn>;
|
||||
getClient: ReturnType<typeof vi.fn>;
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
let mockHitCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockMissCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockDegradationCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = {
|
||||
@@ -22,16 +24,19 @@ describe('CacheService', () => {
|
||||
scan: vi.fn().mockResolvedValue(['0', []]),
|
||||
del: vi.fn(),
|
||||
}),
|
||||
isAvailable: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
mockHitCounter = { inc: vi.fn() };
|
||||
mockMissCounter = { inc: vi.fn() };
|
||||
mockDegradationCounter = { inc: vi.fn() };
|
||||
|
||||
cacheService = new CacheService(
|
||||
mockRedis as any,
|
||||
mockLogger as any,
|
||||
mockHitCounter as any,
|
||||
mockMissCounter as any,
|
||||
mockDegradationCounter as any,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -91,6 +96,39 @@ describe('CacheService', () => {
|
||||
|
||||
await expect(cacheService.getOrSet('key', loader, 60, 'listing')).rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
it('should skip Redis and call loader directly when Redis is unavailable', async () => {
|
||||
mockRedis.isAvailable.mockReturnValue(false);
|
||||
const data = { id: 'direct' };
|
||||
const loader = vi.fn().mockResolvedValue(data);
|
||||
|
||||
const result = await cacheService.getOrSet('key', loader, 60, 'listing');
|
||||
|
||||
expect(result).toEqual(data);
|
||||
expect(mockRedis.get).not.toHaveBeenCalled();
|
||||
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'listing', operation: 'skip_unavailable' });
|
||||
expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
|
||||
});
|
||||
|
||||
it('should track degradation on cache read error', async () => {
|
||||
mockRedis.get.mockRejectedValue(new Error('connection lost'));
|
||||
const loader = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
await cacheService.getOrSet('key', loader, 60, 'search');
|
||||
|
||||
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'search', operation: 'read_error' });
|
||||
});
|
||||
|
||||
it('should track degradation on cache write error', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockRejectedValue(new Error('write error'));
|
||||
const loader = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
await cacheService.getOrSet('key', loader, 60, 'search');
|
||||
|
||||
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'search', operation: 'write_error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate', () => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { CircuitBreaker, CircuitOpenError, CircuitState } from '../circuit-breaker';
|
||||
|
||||
describe('CircuitBreaker', () => {
|
||||
let breaker: CircuitBreaker;
|
||||
let stateChanges: Array<{ from: CircuitState; to: CircuitState }>;
|
||||
|
||||
beforeEach(() => {
|
||||
stateChanges = [];
|
||||
breaker = new CircuitBreaker({
|
||||
name: 'test-service',
|
||||
failureThreshold: 3,
|
||||
resetTimeMs: 100, // fast for tests
|
||||
onStateChange: (from, to) => stateChanges.push({ from, to }),
|
||||
});
|
||||
});
|
||||
|
||||
it('starts in CLOSED state', () => {
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
expect(breaker.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('stays CLOSED when failures are below threshold', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
expect(breaker.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('opens after reaching failure threshold', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
|
||||
expect(breaker.getState()).toBe(CircuitState.OPEN);
|
||||
expect(breaker.isAvailable()).toBe(false);
|
||||
expect(stateChanges).toEqual([{ from: CircuitState.CLOSED, to: CircuitState.OPEN }]);
|
||||
});
|
||||
|
||||
it('rejects calls immediately when OPEN', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
|
||||
await expect(breaker.exec(() => Promise.resolve('ok'))).rejects.toThrow(CircuitOpenError);
|
||||
});
|
||||
|
||||
it('transitions to HALF_OPEN after reset timeout', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
expect(breaker.getState()).toBe(CircuitState.OPEN);
|
||||
|
||||
// Wait for the reset timeout
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
|
||||
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
|
||||
expect(breaker.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('closes on successful probe in HALF_OPEN', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
|
||||
|
||||
const result = await breaker.exec(() => Promise.resolve('recovered'));
|
||||
expect(result).toBe('recovered');
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
});
|
||||
|
||||
it('re-opens on failed probe in HALF_OPEN', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
|
||||
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
expect(breaker.getState()).toBe(CircuitState.OPEN);
|
||||
});
|
||||
|
||||
it('resets failure count on success', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
const succeedingFn = vi.fn().mockResolvedValue('ok');
|
||||
|
||||
// 2 failures, then success, then 2 more failures → should still be CLOSED
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
await breaker.exec(succeedingFn);
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
});
|
||||
|
||||
it('manual reset() brings breaker back to CLOSED', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
expect(breaker.getState()).toBe(CircuitState.OPEN);
|
||||
|
||||
breaker.reset();
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
expect(breaker.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('recordSuccess() transitions from OPEN to CLOSED after timeout', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
breaker.recordSuccess();
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
});
|
||||
|
||||
it('recordFailure() increments failures', () => {
|
||||
breaker.recordFailure();
|
||||
breaker.recordFailure();
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
|
||||
breaker.recordFailure();
|
||||
expect(breaker.getState()).toBe(CircuitState.OPEN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { HttpException, HttpStatus, type ExecutionContext } from '@nestjs/common';
|
||||
import type { Reflector } from '@nestjs/core';
|
||||
import {
|
||||
UserRateLimitGuard,
|
||||
DEFAULT_ROLE_LIMITS,
|
||||
DEFAULT_WINDOW_SECONDS,
|
||||
} from '../guards/user-rate-limit.guard';
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockRedis(overrides: Partial<{ evalResult: [number, number]; throwError: boolean }> = {}) {
|
||||
const evalFn = overrides.throwError
|
||||
? vi.fn().mockRejectedValue(new Error('Redis connection lost'))
|
||||
: vi.fn().mockResolvedValue(overrides.evalResult ?? [1, 60]);
|
||||
|
||||
return {
|
||||
getClient: vi.fn().mockReturnValue({ eval: evalFn }),
|
||||
isAvailable: vi.fn().mockReturnValue(!overrides.throwError),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
onModuleDestroy: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function mockLogger() {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function mockReflector(options?: any) {
|
||||
return {
|
||||
getAllAndOverride: vi.fn().mockReturnValue(options ?? undefined),
|
||||
} as unknown as Reflector;
|
||||
}
|
||||
|
||||
interface MockContextOptions {
|
||||
user?: { sub: string; role: string } | null;
|
||||
handler?: string;
|
||||
controller?: string;
|
||||
}
|
||||
|
||||
function buildContext(opts: MockContextOptions = {}): ExecutionContext {
|
||||
const headers: Record<string, string> = {};
|
||||
const response = { setHeader: vi.fn() };
|
||||
const user = 'user' in opts ? opts.user : { sub: 'user-123', role: 'BUYER' };
|
||||
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
user,
|
||||
headers,
|
||||
}),
|
||||
getResponse: () => response,
|
||||
}),
|
||||
getHandler: () => ({ name: opts.handler ?? 'testHandler' }),
|
||||
getClass: () => ({ name: opts.controller ?? 'TestController' }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
// ── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('UserRateLimitGuard', () => {
|
||||
it('allows request when user is within rate limit', async () => {
|
||||
const redis = mockRedis({ evalResult: [1, 60] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const result = await guard.canActivate(buildContext());
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('passes through for unauthenticated requests (no user)', async () => {
|
||||
const redis = mockRedis();
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const result = await guard.canActivate(buildContext({ user: null }));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('passes through for requests without user.sub', async () => {
|
||||
const redis = mockRedis();
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const result = await guard.canActivate(
|
||||
buildContext({ user: { sub: '', role: 'BUYER' } }),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects request with 429 when rate limit exceeded', async () => {
|
||||
const redis = mockRedis({ evalResult: [101, 45] }); // 101 > BUYER limit of 100
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const ctx = buildContext();
|
||||
await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException);
|
||||
|
||||
try {
|
||||
await guard.canActivate(ctx);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HttpException);
|
||||
expect((error as HttpException).getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
const body = (error as HttpException).getResponse();
|
||||
expect(body).toMatchObject({
|
||||
statusCode: 429,
|
||||
retryAfter: 45,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('sets rate limit headers on the response', async () => {
|
||||
const redis = mockRedis({ evalResult: [50, 30] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
const ctx = buildContext();
|
||||
|
||||
await guard.canActivate(ctx);
|
||||
|
||||
const response = ctx.switchToHttp().getResponse();
|
||||
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', DEFAULT_ROLE_LIMITS.BUYER);
|
||||
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', 50); // 100 - 50
|
||||
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Reset', 30);
|
||||
});
|
||||
|
||||
it('sets Retry-After header when limit exceeded', async () => {
|
||||
const redis = mockRedis({ evalResult: [201, 42] }); // Exceeds AGENT limit of 200
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
const ctx = buildContext({ user: { sub: 'agent-1', role: 'AGENT' } });
|
||||
|
||||
try {
|
||||
await guard.canActivate(ctx);
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
const response = ctx.switchToHttp().getResponse();
|
||||
expect(response.setHeader).toHaveBeenCalledWith('Retry-After', 42);
|
||||
});
|
||||
|
||||
it('uses role-specific limits (ADMIN gets 500)', async () => {
|
||||
const redis = mockRedis({ evalResult: [450, 20] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const result = await guard.canActivate(
|
||||
buildContext({ user: { sub: 'admin-1', role: 'ADMIN' } }),
|
||||
);
|
||||
expect(result).toBe(true); // 450 < 500 ADMIN limit
|
||||
});
|
||||
|
||||
it('uses role-specific limits (AGENT gets 200)', async () => {
|
||||
const redis = mockRedis({ evalResult: [199, 10] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const result = await guard.canActivate(
|
||||
buildContext({ user: { sub: 'agent-1', role: 'AGENT' } }),
|
||||
);
|
||||
expect(result).toBe(true); // 199 < 200 AGENT limit
|
||||
});
|
||||
|
||||
it('uses per-route overrides from @UserRateLimit decorator', async () => {
|
||||
const redis = mockRedis({ evalResult: [51, 30] });
|
||||
// Override BUYER limit to 50 for this route
|
||||
const reflector = mockReflector({ limits: { BUYER: 50 }, windowSeconds: 120 });
|
||||
const guard = new UserRateLimitGuard(redis, reflector, mockLogger());
|
||||
|
||||
await expect(guard.canActivate(buildContext())).rejects.toThrow(HttpException);
|
||||
});
|
||||
|
||||
it('passes Redis window seconds argument correctly', async () => {
|
||||
const redis = mockRedis({ evalResult: [1, 120] });
|
||||
const reflector = mockReflector({ windowSeconds: 120 });
|
||||
const guard = new UserRateLimitGuard(redis, reflector, mockLogger());
|
||||
|
||||
await guard.canActivate(buildContext());
|
||||
|
||||
const evalCall = redis.getClient().eval.mock.calls[0];
|
||||
expect(evalCall[3]).toBe(120); // windowSeconds arg passed to Lua
|
||||
});
|
||||
|
||||
it('uses correct Redis key format with user ID', async () => {
|
||||
const redis = mockRedis({ evalResult: [1, 60] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
await guard.canActivate(buildContext({ user: { sub: 'user-xyz', role: 'BUYER' } }));
|
||||
|
||||
const evalCall = redis.getClient().eval.mock.calls[0];
|
||||
expect(evalCall[2]).toBe('rate_limit:user:user-xyz');
|
||||
});
|
||||
|
||||
it('fails open when Redis throws an error', async () => {
|
||||
const redis = mockRedis({ throwError: true });
|
||||
const logger = mockLogger();
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), logger);
|
||||
|
||||
const result = await guard.canActivate(buildContext());
|
||||
expect(result).toBe(true);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('User rate limit check failed'),
|
||||
'UserRateLimitGuard',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs warning when rate limit exceeded', async () => {
|
||||
const redis = mockRedis({ evalResult: [101, 55] });
|
||||
const logger = mockLogger();
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), logger);
|
||||
|
||||
try {
|
||||
await guard.canActivate(buildContext());
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('User rate limit exceeded'),
|
||||
'UserRateLimitGuard',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses DEFAULT_WINDOW_SECONDS when no override provided', async () => {
|
||||
const redis = mockRedis({ evalResult: [1, DEFAULT_WINDOW_SECONDS] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
await guard.canActivate(buildContext());
|
||||
|
||||
const evalCall = redis.getClient().eval.mock.calls[0];
|
||||
expect(evalCall[3]).toBe(DEFAULT_WINDOW_SECONDS);
|
||||
});
|
||||
|
||||
it('defaults to BUYER limit for unknown roles', async () => {
|
||||
const redis = mockRedis({ evalResult: [101, 30] }); // > BUYER's 100
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
// Force an unexpected role value
|
||||
await expect(
|
||||
guard.canActivate(buildContext({ user: { sub: 'u1', role: 'UNKNOWN_ROLE' } })),
|
||||
).rejects.toThrow(HttpException);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||
import { type Counter } from 'prom-client';
|
||||
import { type LoggerService } from './logger.service';
|
||||
import { type RedisService } from './redis.service';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { Counter } from 'prom-client';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { LoggerService } from './logger.service';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
export const CACHE_HIT_TOTAL = 'cache_hit_total';
|
||||
export const CACHE_MISS_TOTAL = 'cache_miss_total';
|
||||
export const CACHE_DEGRADATION_TOTAL = 'cache_degradation_total';
|
||||
|
||||
export const CacheTTL = {
|
||||
/** Listing detail — moderate TTL, invalidated on mutation */
|
||||
@@ -52,6 +56,7 @@ export class CacheService implements OnModuleInit {
|
||||
private readonly logger: LoggerService,
|
||||
@InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter,
|
||||
@InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter,
|
||||
@InjectMetric(CACHE_DEGRADATION_TOTAL) private readonly cacheDegradationCounter: Counter,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
@@ -60,6 +65,9 @@ export class CacheService implements OnModuleInit {
|
||||
|
||||
/**
|
||||
* Cache-aside: get from cache, or execute loader and store result.
|
||||
*
|
||||
* When Redis is down the loader is called directly (graceful degradation).
|
||||
* Degradation events are counted via `cache_degradation_total` for alerting.
|
||||
*/
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
@@ -67,6 +75,13 @@ export class CacheService implements OnModuleInit {
|
||||
ttlSeconds: number,
|
||||
resource: string,
|
||||
): Promise<T> {
|
||||
// Fast-path: skip Redis entirely when it is known to be disconnected.
|
||||
if (!this.redis.isAvailable()) {
|
||||
this.cacheDegradationCounter.inc({ resource, operation: 'skip_unavailable' });
|
||||
this.cacheMissCounter.inc({ resource });
|
||||
return loader();
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await this.redis.get(key);
|
||||
if (cached !== null) {
|
||||
@@ -74,6 +89,7 @@ export class CacheService implements OnModuleInit {
|
||||
return JSON.parse(cached) as T;
|
||||
}
|
||||
} catch (err) {
|
||||
this.cacheDegradationCounter.inc({ resource, operation: 'read_error' });
|
||||
this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||
}
|
||||
|
||||
@@ -83,6 +99,7 @@ export class CacheService implements OnModuleInit {
|
||||
try {
|
||||
await this.redis.set(key, JSON.stringify(result), ttlSeconds);
|
||||
} catch (err) {
|
||||
this.cacheDegradationCounter.inc({ resource, operation: 'write_error' });
|
||||
this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||
}
|
||||
|
||||
@@ -94,6 +111,7 @@ export class CacheService implements OnModuleInit {
|
||||
try {
|
||||
await this.redis.del(key);
|
||||
} catch (err) {
|
||||
this.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'invalidate_error' });
|
||||
this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||
}
|
||||
}
|
||||
@@ -111,6 +129,7 @@ export class CacheService implements OnModuleInit {
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
} catch (err) {
|
||||
this.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'prefix_invalidate_error' });
|
||||
this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService');
|
||||
}
|
||||
}
|
||||
|
||||
147
apps/api/src/modules/shared/infrastructure/circuit-breaker.ts
Normal file
147
apps/api/src/modules/shared/infrastructure/circuit-breaker.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Lightweight circuit breaker for external service calls.
|
||||
*
|
||||
* States:
|
||||
* CLOSED → normal operation; failures increment counter
|
||||
* OPEN → calls fail fast; after `resetTimeMs` → HALF_OPEN
|
||||
* HALF_OPEN → one probe call allowed; success → CLOSED, failure → OPEN
|
||||
*
|
||||
* Does NOT depend on any NestJS or framework-specific API so it can
|
||||
* be used as a plain utility across the codebase.
|
||||
*/
|
||||
|
||||
export enum CircuitState {
|
||||
CLOSED = 'CLOSED',
|
||||
OPEN = 'OPEN',
|
||||
HALF_OPEN = 'HALF_OPEN',
|
||||
}
|
||||
|
||||
export interface CircuitBreakerOptions {
|
||||
/** Human-readable service name — used in logs / metrics. */
|
||||
name: string;
|
||||
/** Number of consecutive failures before opening the circuit. Default 5. */
|
||||
failureThreshold?: number;
|
||||
/** Time in ms to wait before transitioning from OPEN → HALF_OPEN. Default 30 000 (30s). */
|
||||
resetTimeMs?: number;
|
||||
/** Optional callback fired on every state transition. */
|
||||
onStateChange?: (from: CircuitState, to: CircuitState, name: string) => void;
|
||||
}
|
||||
|
||||
export class CircuitBreaker {
|
||||
readonly name: string;
|
||||
|
||||
private state = CircuitState.CLOSED;
|
||||
private failureCount = 0;
|
||||
private lastFailureTime = 0;
|
||||
|
||||
private readonly failureThreshold: number;
|
||||
private readonly resetTimeMs: number;
|
||||
private readonly onStateChange?: (from: CircuitState, to: CircuitState, name: string) => void;
|
||||
|
||||
constructor(options: CircuitBreakerOptions) {
|
||||
this.name = options.name;
|
||||
this.failureThreshold = options.failureThreshold ?? 5;
|
||||
this.resetTimeMs = options.resetTimeMs ?? 30_000;
|
||||
this.onStateChange = options.onStateChange;
|
||||
}
|
||||
|
||||
/** Current state of the circuit. */
|
||||
getState(): CircuitState {
|
||||
this.evaluateState();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Whether the circuit allows a call to pass through. */
|
||||
isAvailable(): boolean {
|
||||
this.evaluateState();
|
||||
return this.state !== CircuitState.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute `fn` through the breaker.
|
||||
*
|
||||
* - CLOSED / HALF_OPEN → `fn` is called.
|
||||
* - OPEN → immediately throws `CircuitOpenError`.
|
||||
*
|
||||
* On success the breaker resets to CLOSED.
|
||||
* On failure the breaker increments the failure counter (CLOSED)
|
||||
* or re-opens (HALF_OPEN).
|
||||
*/
|
||||
async exec<T>(fn: () => Promise<T>): Promise<T> {
|
||||
this.evaluateState();
|
||||
|
||||
if (this.state === CircuitState.OPEN) {
|
||||
throw new CircuitOpenError(this.name);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
this.onSuccess();
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.onFailure();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a manual success (e.g. from a health-check probe). */
|
||||
recordSuccess(): void {
|
||||
this.onSuccess();
|
||||
}
|
||||
|
||||
/** Record a manual failure. */
|
||||
recordFailure(): void {
|
||||
this.onFailure();
|
||||
}
|
||||
|
||||
/** Force-reset the breaker to CLOSED. */
|
||||
reset(): void {
|
||||
this.transitionTo(CircuitState.CLOSED);
|
||||
this.failureCount = 0;
|
||||
this.lastFailureTime = 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private evaluateState(): void {
|
||||
if (this.state === CircuitState.OPEN) {
|
||||
const elapsed = Date.now() - this.lastFailureTime;
|
||||
if (elapsed >= this.resetTimeMs) {
|
||||
this.transitionTo(CircuitState.HALF_OPEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onSuccess(): void {
|
||||
if (this.state !== CircuitState.CLOSED) {
|
||||
this.transitionTo(CircuitState.CLOSED);
|
||||
}
|
||||
this.failureCount = 0;
|
||||
}
|
||||
|
||||
private onFailure(): void {
|
||||
this.failureCount++;
|
||||
this.lastFailureTime = Date.now();
|
||||
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
this.transitionTo(CircuitState.OPEN);
|
||||
} else if (this.failureCount >= this.failureThreshold) {
|
||||
this.transitionTo(CircuitState.OPEN);
|
||||
}
|
||||
}
|
||||
|
||||
private transitionTo(next: CircuitState): void {
|
||||
if (this.state === next) return;
|
||||
const prev = this.state;
|
||||
this.state = next;
|
||||
this.onStateChange?.(prev, next, this.name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a call is attempted while the circuit is OPEN. */
|
||||
export class CircuitOpenError extends Error {
|
||||
constructor(public readonly serviceName: string) {
|
||||
super(`Circuit breaker OPEN for service: ${serviceName}`);
|
||||
this.name = 'CircuitOpenError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { type UserRole } from '@prisma/client';
|
||||
import { USER_RATE_LIMIT_KEY, type UserRateLimitOptions } from '../guards/user-rate-limit.guard';
|
||||
|
||||
/**
|
||||
* Decorator to override per-user rate limits for a specific route or controller.
|
||||
*
|
||||
* When not applied, the UserRateLimitGuard uses DEFAULT_ROLE_LIMITS.
|
||||
*
|
||||
* @example
|
||||
* // Override BUYER limit to 50 req/min for an expensive endpoint
|
||||
* @UserRateLimit({ limits: { BUYER: 50 }, windowSeconds: 60 })
|
||||
*
|
||||
* @example
|
||||
* // Allow higher limits for a specific route
|
||||
* @UserRateLimit({ limits: { BUYER: 200, AGENT: 400 } })
|
||||
*/
|
||||
export const UserRateLimit = (options: {
|
||||
limits?: Partial<Record<UserRole, number>>;
|
||||
windowSeconds?: number;
|
||||
}) => SetMetadata<string, UserRateLimitOptions>(USER_RATE_LIMIT_KEY, options);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type EventEmitter2 } from '@nestjs/event-emitter';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { type DomainEvent } from '../domain/domain-event';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Request, Response } from 'express';
|
||||
import { type Request, type Response } from 'express';
|
||||
import { DomainException, type ErrorResponseBody } from '../../domain/domain-exception';
|
||||
import { ErrorCode } from '../../domain/error-codes';
|
||||
import { type LoggerService } from '../logger.service';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import type { Request } from 'express';
|
||||
import { type Request } from 'express';
|
||||
|
||||
/**
|
||||
* Extends ThrottlerGuard to extract real client IP behind reverse proxies
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Injectable,
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { type Reflector } from '@nestjs/core';
|
||||
import { type UserRole } from '@prisma/client';
|
||||
import { type LoggerService } from '../logger.service';
|
||||
import { type RedisService } from '../redis.service';
|
||||
|
||||
/**
|
||||
* Role-based rate limits (requests per window).
|
||||
* Applied only to authenticated routes that use JwtAuthGuard.
|
||||
*/
|
||||
export const DEFAULT_ROLE_LIMITS: Record<UserRole, number> = {
|
||||
BUYER: 100,
|
||||
SELLER: 150,
|
||||
AGENT: 200,
|
||||
ADMIN: 500,
|
||||
};
|
||||
|
||||
/** Default sliding window in seconds. */
|
||||
export const DEFAULT_WINDOW_SECONDS = 60;
|
||||
|
||||
/** Metadata key for per-route overrides via @UserRateLimit decorator. */
|
||||
export const USER_RATE_LIMIT_KEY = 'user_rate_limit';
|
||||
|
||||
export interface UserRateLimitOptions {
|
||||
/** Override limits per role for this route. Roles not listed use DEFAULT_ROLE_LIMITS. */
|
||||
limits?: Partial<Record<UserRole, number>>;
|
||||
/** Window in seconds (default 60). */
|
||||
windowSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard that enforces per-user (by user ID) rate limiting for authenticated routes.
|
||||
*
|
||||
* Uses a Redis sliding-window counter keyed by `rate_limit:user:{userId}`.
|
||||
* Falls back to allowing the request if Redis is unavailable (fail-open)
|
||||
* to avoid blocking legitimate traffic during cache outages.
|
||||
*
|
||||
* This guard checks `request.user` — it must run AFTER JwtAuthGuard.
|
||||
* If no authenticated user is found, the guard passes (unauthenticated
|
||||
* routes are handled by the existing IP-based ThrottlerBehindProxyGuard).
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserRateLimitGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly redis: RedisService,
|
||||
private readonly reflector: Reflector,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// Skip for unauthenticated requests — IP-based throttler handles those
|
||||
if (!user?.sub || !user?.role) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userId: string = user.sub;
|
||||
const role: UserRole = user.role;
|
||||
|
||||
// Resolve per-route overrides
|
||||
const options = this.reflector.getAllAndOverride<UserRateLimitOptions | undefined>(
|
||||
USER_RATE_LIMIT_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
const windowSeconds = options?.windowSeconds ?? DEFAULT_WINDOW_SECONDS;
|
||||
const limit = options?.limits?.[role] ?? DEFAULT_ROLE_LIMITS[role] ?? DEFAULT_ROLE_LIMITS.BUYER;
|
||||
|
||||
const key = `rate_limit:user:${userId}`;
|
||||
|
||||
try {
|
||||
const client = this.redis.getClient();
|
||||
|
||||
// Atomic increment + conditional TTL set via Lua script.
|
||||
// Uses INCR + conditional EXPIRE to avoid race conditions.
|
||||
const result = await client.eval(
|
||||
`local current = redis.call('INCR', KEYS[1])
|
||||
if current == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
local ttl = redis.call('TTL', KEYS[1])
|
||||
return {current, ttl}`,
|
||||
1,
|
||||
key,
|
||||
windowSeconds,
|
||||
) as [number, number];
|
||||
|
||||
const current = result[0];
|
||||
const ttl = result[1];
|
||||
|
||||
const response = context.switchToHttp().getResponse();
|
||||
|
||||
// Always set rate limit headers for observability
|
||||
response.setHeader('X-RateLimit-Limit', limit);
|
||||
response.setHeader('X-RateLimit-Remaining', Math.max(0, limit - current));
|
||||
response.setHeader('X-RateLimit-Reset', ttl > 0 ? ttl : windowSeconds);
|
||||
|
||||
if (current > limit) {
|
||||
const retryAfter = ttl > 0 ? ttl : windowSeconds;
|
||||
response.setHeader('Retry-After', retryAfter);
|
||||
|
||||
this.logger.warn(
|
||||
`User rate limit exceeded: userId=${userId}, role=${role}, ` +
|
||||
`current=${current}/${limit}, retryAfter=${retryAfter}s`,
|
||||
'UserRateLimitGuard',
|
||||
);
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
retryAfter,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Re-throw 429 errors — they are intentional
|
||||
if (error instanceof HttpException && error.getStatus() === HttpStatus.TOO_MANY_REQUESTS) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Fail open on Redis errors — log and allow the request
|
||||
this.logger.warn(
|
||||
`User rate limit check failed (Redis error), allowing request: userId=${userId}, error=${
|
||||
error instanceof Error ? error.message : 'unknown'
|
||||
}`,
|
||||
'UserRateLimitGuard',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { Cacheable, type CacheableOptions } from './decorators/cacheable.decorator';
|
||||
export { CircuitBreaker, CircuitOpenError, CircuitState, type CircuitBreakerOptions } from './circuit-breaker';
|
||||
export { PrismaService } from './prisma.service';
|
||||
export { RedisService } from './redis.service';
|
||||
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
||||
@@ -11,6 +12,14 @@ export { SanitizeInputMiddleware } from './middleware/sanitize-input.middleware'
|
||||
export { CsrfMiddleware } from './middleware/csrf.middleware';
|
||||
export { maskPii } from './pii-masker';
|
||||
export { ThrottlerBehindProxyGuard } from './guards/throttler-behind-proxy.guard';
|
||||
export {
|
||||
UserRateLimitGuard,
|
||||
DEFAULT_ROLE_LIMITS,
|
||||
DEFAULT_WINDOW_SECONDS,
|
||||
USER_RATE_LIMIT_KEY,
|
||||
type UserRateLimitOptions,
|
||||
} from './guards/user-rate-limit.guard';
|
||||
export { UserRateLimit } from './decorators/user-rate-limit.decorator';
|
||||
export { FileValidationPipe } from './pipes/file-validation.pipe';
|
||||
export type { FileValidationOptions, UploadedFile } from './pipes/file-validation.pipe';
|
||||
export { validateEnv, validateJwtSecret } from './env-validation';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
|
||||
const CORRELATION_ID_HEADER = 'x-correlation-id';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { ForbiddenException, Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
|
||||
const CSRF_COOKIE = 'XSRF-TOKEN';
|
||||
const CSRF_HEADER = 'x-csrf-token';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
import { type LoggerService } from '../logger.service';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { Injectable, type OnModuleDestroy } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
/**
|
||||
* Thin wrapper around ioredis.
|
||||
*
|
||||
* Uses `lazyConnect: true` so the app starts even if Redis is unreachable.
|
||||
* All call-sites (CacheService, health checks) already handle failures
|
||||
* gracefully — if Redis is down the app serves data directly from the DB.
|
||||
*
|
||||
* `enableReadyCheck: false` prevents ioredis from throwing
|
||||
* "Redis is not ready" errors during transient outages, allowing
|
||||
* individual commands to fail with a standard connection error
|
||||
* that the CacheService catches.
|
||||
*
|
||||
* `maxRetriesPerRequest: 1` ensures commands fail fast (single retry)
|
||||
* instead of blocking the event loop with exponential backoff.
|
||||
* The CacheService already treats Redis errors as non-fatal.
|
||||
*
|
||||
* `retryStrategy` implements bounded reconnection: waits 1 s then 2 s
|
||||
* then 3 s up to 5 s max, ensuring the client keeps trying to reconnect
|
||||
* without flooding the server.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
private readonly client: Redis;
|
||||
@@ -11,6 +31,11 @@ export class RedisService implements OnModuleDestroy {
|
||||
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
||||
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
||||
lazyConnect: true,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 1,
|
||||
retryStrategy(times: number): number {
|
||||
return Math.min(times * 1000, 5000);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +47,14 @@ export class RedisService implements OnModuleDestroy {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick health probe — returns true when the Redis connection is
|
||||
* in a usable state (`ready` or `connect`).
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.client.status === 'ready' || this.client.status === 'connect';
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.client.get(key);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Global, type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { makeCounterProvider } from '@willsoto/nestjs-prometheus';
|
||||
import { CacheService, CACHE_HIT_TOTAL, CACHE_MISS_TOTAL } from './infrastructure/cache.service';
|
||||
import { PrometheusModule, makeCounterProvider } from '@willsoto/nestjs-prometheus';
|
||||
import {
|
||||
CacheService,
|
||||
CACHE_HIT_TOTAL,
|
||||
CACHE_MISS_TOTAL,
|
||||
CACHE_DEGRADATION_TOTAL,
|
||||
} from './infrastructure/cache.service';
|
||||
import { EventBusService } from './infrastructure/event-bus.service';
|
||||
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
||||
import { LoggerService } from './infrastructure/logger.service';
|
||||
@@ -15,7 +21,11 @@ import { RedisService } from './infrastructure/redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [EventEmitterModule.forRoot()],
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
EventEmitterModule.forRoot(),
|
||||
PrometheusModule.register({ path: '/metrics', defaultMetrics: { enabled: true } }),
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
RedisService,
|
||||
@@ -32,12 +42,17 @@ import { RedisService } from './infrastructure/redis.service';
|
||||
help: 'Total number of cache misses',
|
||||
labelNames: ['resource'],
|
||||
}),
|
||||
makeCounterProvider({
|
||||
name: CACHE_DEGRADATION_TOTAL,
|
||||
help: 'Total number of cache degradation events',
|
||||
labelNames: ['resource', 'operation'],
|
||||
}),
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: GlobalExceptionFilter,
|
||||
},
|
||||
],
|
||||
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService],
|
||||
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, PrometheusModule],
|
||||
})
|
||||
export class SharedModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
|
||||
@@ -7,3 +7,6 @@ export { QuotaExceededEvent } from './domain/events/quota-exceeded.event';
|
||||
export { SubscriptionCancelledEvent } from './domain/events/subscription-cancelled.event';
|
||||
export { SubscriptionExpiredEvent } from './domain/events/subscription-expired.event';
|
||||
export { SubscriptionRenewedEvent } from './domain/events/subscription-renewed.event';
|
||||
export { CheckQuotaQuery } from './application/queries/check-quota/check-quota.query';
|
||||
export { type QuotaCheckResult } from './application/queries/check-quota/check-quota.handler';
|
||||
export { MeterUsageCommand } from './application/commands/meter-usage/meter-usage.command';
|
||||
|
||||
Reference in New Issue
Block a user