diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 20f393f..9fccf69 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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('*'); + } } } diff --git a/apps/api/src/instrument.ts b/apps/api/src/instrument.ts index c4a8cb8..57e4fe6 100644 --- a/apps/api/src/instrument.ts +++ b/apps/api/src/instrument.ts @@ -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'], }); diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts index da957ef..49d5b06 100644 --- a/apps/api/src/modules/admin/admin.module.ts +++ b/apps/api/src/modules/admin/admin.module.ts @@ -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 {} diff --git a/apps/api/src/modules/admin/application/__tests__/admin-audit.listener.spec.ts b/apps/api/src/modules/admin/application/__tests__/admin-audit.listener.spec.ts new file mode 100644 index 0000000..cc72d98 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/admin-audit.listener.spec.ts @@ -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 }; + let mockLogger: { + log: ReturnType; + error: ReturnType; + }; + + 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', + ); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/get-audit-logs.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/get-audit-logs.handler.spec.ts new file mode 100644 index 0000000..be19b80 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/get-audit-logs.handler.spec.ts @@ -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 }; + + 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 }), + ); + }); +}); diff --git a/apps/api/src/modules/admin/application/listeners/admin-audit.listener.ts b/apps/api/src/modules/admin/application/listeners/admin-audit.listener.ts new file mode 100644 index 0000000..edba8f6 --- /dev/null +++ b/apps/api/src/modules/admin/application/listeners/admin-audit.listener.ts @@ -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 { + 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 { + await this.log('LISTING_REJECTED', event.adminId, event.aggregateId, 'LISTING', { + reason: event.reason, + }); + } + + @OnEvent('user.banned', { async: true }) + async onUserBanned(event: UserBannedEvent): Promise { + await this.log('USER_BANNED', event.adminId, event.aggregateId, 'USER', { + reason: event.reason, + }); + } + + @OnEvent('user.unbanned', { async: true }) + async onUserUnbanned(event: UserUnbannedEvent): Promise { + await this.log('USER_UNBANNED', event.adminId, event.aggregateId, 'USER'); + } + + @OnEvent('kyc.approved', { async: true }) + async onKycApproved(event: KycApprovedEvent): Promise { + await this.log('KYC_APPROVED', event.adminId, event.aggregateId, 'USER', { + comments: event.comments, + }); + } + + @OnEvent('kyc.rejected', { async: true }) + async onKycRejected(event: KycRejectedEvent): Promise { + 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 { + 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, + ): Promise { + 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', + ); + } + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-audit-logs/get-audit-logs.handler.ts b/apps/api/src/modules/admin/application/queries/get-audit-logs/get-audit-logs.handler.ts new file mode 100644 index 0000000..6993dcf --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-audit-logs/get-audit-logs.handler.ts @@ -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 { + constructor( + @Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository, + ) {} + + async execute(query: GetAuditLogsQuery): Promise { + 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, + }); + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-audit-logs/get-audit-logs.query.ts b/apps/api/src/modules/admin/application/queries/get-audit-logs/get-audit-logs.query.ts new file mode 100644 index 0000000..61f8bec --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-audit-logs/get-audit-logs.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/admin/application/queries/index.ts b/apps/api/src/modules/admin/application/queries/index.ts index 63ac6fc..98f61d1 100644 --- a/apps/api/src/modules/admin/application/queries/index.ts +++ b/apps/api/src/modules/admin/application/queries/index.ts @@ -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'; diff --git a/apps/api/src/modules/admin/domain/repositories/audit-log.repository.ts b/apps/api/src/modules/admin/domain/repositories/audit-log.repository.ts new file mode 100644 index 0000000..3ad259f --- /dev/null +++ b/apps/api/src/modules/admin/domain/repositories/audit-log.repository.ts @@ -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 | 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; +} + +export interface IAuditLogRepository { + create(input: CreateAuditLogInput): Promise; + findAll(params: { + page: number; + limit: number; + action?: string; + actorId?: string; + targetId?: string; + targetType?: string; + startDate?: Date; + endDate?: Date; + }): Promise; +} diff --git a/apps/api/src/modules/admin/domain/repositories/index.ts b/apps/api/src/modules/admin/domain/repositories/index.ts index 893b466..9c7e230 100644 --- a/apps/api/src/modules/admin/domain/repositories/index.ts +++ b/apps/api/src/modules/admin/domain/repositories/index.ts @@ -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'; diff --git a/apps/api/src/modules/admin/index.ts b/apps/api/src/modules/admin/index.ts index 3357c59..dc89711 100644 --- a/apps/api/src/modules/admin/index.ts +++ b/apps/api/src/modules/admin/index.ts @@ -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'; diff --git a/apps/api/src/modules/admin/infrastructure/repositories/index.ts b/apps/api/src/modules/admin/infrastructure/repositories/index.ts index 1615270..0471918 100644 --- a/apps/api/src/modules/admin/infrastructure/repositories/index.ts +++ b/apps/api/src/modules/admin/infrastructure/repositories/index.ts @@ -1 +1,2 @@ export { PrismaAdminQueryRepository } from './prisma-admin-query.repository'; +export { PrismaAuditLogRepository } from './prisma-audit-log.repository'; diff --git a/apps/api/src/modules/admin/infrastructure/repositories/prisma-audit-log.repository.ts b/apps/api/src/modules/admin/infrastructure/repositories/prisma-audit-log.repository.ts new file mode 100644 index 0000000..41b1888 --- /dev/null +++ b/apps/api/src/modules/admin/infrastructure/repositories/prisma-audit-log.repository.ts @@ -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 { + 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 | 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 { + 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 | null, + ipAddress: r.ipAddress, + userAgent: r.userAgent, + createdAt: r.createdAt, + })), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } +} diff --git a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts index 738d43f..55dad48 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts @@ -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 { + 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, + ), + ); + } } diff --git a/apps/api/src/modules/admin/presentation/dto/get-audit-logs-query.dto.ts b/apps/api/src/modules/admin/presentation/dto/get-audit-logs-query.dto.ts new file mode 100644 index 0000000..7e913d1 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/get-audit-logs-query.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/admin/presentation/dto/index.ts b/apps/api/src/modules/admin/presentation/dto/index.ts index 244702d..43f6ed4 100644 --- a/apps/api/src/modules/admin/presentation/dto/index.ts +++ b/apps/api/src/modules/admin/presentation/dto/index.ts @@ -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'; diff --git a/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts b/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts index bb15cac..8248294 100644 --- a/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts +++ b/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts @@ -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() diff --git a/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts b/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts index 0d2eadd..827fb52 100644 --- a/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts +++ b/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts @@ -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') diff --git a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts index 4e89d9e..823366b 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts @@ -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, diff --git a/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts index 4d7ff8b..a30b057 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts @@ -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'; diff --git a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts index 4079535..faaeb5f 100644 --- a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -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') diff --git a/apps/api/src/modules/auth/presentation/controllers/oauth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/oauth.controller.ts index 2b9aaa9..ebac0e6 100644 --- a/apps/api/src/modules/auth/presentation/controllers/oauth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/oauth.controller.ts @@ -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'; diff --git a/apps/api/src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts b/apps/api/src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts index 1898b42..67a033d 100644 --- a/apps/api/src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts +++ b/apps/api/src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts @@ -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 { diff --git a/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts b/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts index 2d1e1a9..29c0f18 100644 --- a/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts +++ b/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts @@ -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') diff --git a/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts b/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts index ce1e6a9..ca6e5e8 100644 --- a/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts +++ b/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts @@ -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() diff --git a/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts b/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts index 5c3659f..afb093e 100644 --- a/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts +++ b/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts @@ -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') diff --git a/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts b/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts index a1feaa3..b5ea833 100644 --- a/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts +++ b/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts @@ -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') diff --git a/apps/api/src/modules/metrics/index.ts b/apps/api/src/modules/metrics/index.ts index 4eaa2f2..e2146e7 100644 --- a/apps/api/src/modules/metrics/index.ts +++ b/apps/api/src/modules/metrics/index.ts @@ -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'; diff --git a/apps/api/src/modules/metrics/infrastructure/metrics.service.ts b/apps/api/src/modules/metrics/infrastructure/metrics.service.ts index fe32d43..1af03e1 100644 --- a/apps/api/src/modules/metrics/infrastructure/metrics.service.ts +++ b/apps/api/src/modules/metrics/infrastructure/metrics.service.ts @@ -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 = {}; + + 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 }); + } } diff --git a/apps/api/src/modules/metrics/metrics.constants.ts b/apps/api/src/modules/metrics/metrics.constants.ts index a29fa05..56f27a9 100644 --- a/apps/api/src/modules/metrics/metrics.constants.ts +++ b/apps/api/src/modules/metrics/metrics.constants.ts @@ -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'; diff --git a/apps/api/src/modules/metrics/metrics.module.ts b/apps/api/src/modules/metrics/metrics.module.ts index 6c8c712..a923c87 100644 --- a/apps/api/src/modules/metrics/metrics.module.ts +++ b/apps/api/src/modules/metrics/metrics.module.ts @@ -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 {} diff --git a/apps/api/src/modules/metrics/presentation/controllers/web-vitals.controller.ts b/apps/api/src/modules/metrics/presentation/controllers/web-vitals.controller.ts new file mode 100644 index 0000000..db1eb78 --- /dev/null +++ b/apps/api/src/modules/metrics/presentation/controllers/web-vitals.controller.ts @@ -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(/\/$/, '') || '/' + ); + } +} diff --git a/apps/api/src/modules/metrics/presentation/dto/web-vitals.dto.ts b/apps/api/src/modules/metrics/presentation/dto/web-vitals.dto.ts new file mode 100644 index 0000000..1e862c2 --- /dev/null +++ b/apps/api/src/modules/metrics/presentation/dto/web-vitals.dto.ts @@ -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[]; +} diff --git a/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts b/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts index df7fb7b..35b996c 100644 --- a/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts +++ b/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts @@ -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'; diff --git a/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts b/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts index 9922a47..e4e6257 100644 --- a/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts +++ b/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts @@ -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'; diff --git a/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts b/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts index 9df6393..61a3e5b 100644 --- a/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts +++ b/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts @@ -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() diff --git a/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts b/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts index 26eb372..1da9991 100644 --- a/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts +++ b/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts @@ -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') diff --git a/apps/api/src/modules/search/application/__tests__/create-saved-search.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/create-saved-search.handler.spec.ts new file mode 100644 index 0000000..14aca04 --- /dev/null +++ b/apps/api/src/modules/search/application/__tests__/create-saved-search.handler.spec.ts @@ -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 }; + let mockCommandBus: { execute: ReturnType }; + let mockLogger: { log: ReturnType; warn: ReturnType }; + + 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', + ); + }); +}); diff --git a/apps/api/src/modules/search/application/__tests__/delete-saved-search.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/delete-saved-search.handler.spec.ts new file mode 100644 index 0000000..cb9d19b --- /dev/null +++ b/apps/api/src/modules/search/application/__tests__/delete-saved-search.handler.spec.ts @@ -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; warn: ReturnType }; + + 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'); + }); +}); diff --git a/apps/api/src/modules/search/application/__tests__/get-saved-search.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/get-saved-search.handler.spec.ts new file mode 100644 index 0000000..622efa7 --- /dev/null +++ b/apps/api/src/modules/search/application/__tests__/get-saved-search.handler.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/modules/search/application/__tests__/get-saved-searches.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/get-saved-searches.handler.spec.ts new file mode 100644 index 0000000..57d3f26 --- /dev/null +++ b/apps/api/src/modules/search/application/__tests__/get-saved-searches.handler.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/search/application/__tests__/update-saved-search.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/update-saved-search.handler.spec.ts new file mode 100644 index 0000000..aa6c338 --- /dev/null +++ b/apps/api/src/modules/search/application/__tests__/update-saved-search.handler.spec.ts @@ -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; warn: ReturnType }; + + 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'); + }); +}); diff --git a/apps/api/src/modules/search/application/commands/create-saved-search/create-saved-search.command.ts b/apps/api/src/modules/search/application/commands/create-saved-search/create-saved-search.command.ts new file mode 100644 index 0000000..33dac5b --- /dev/null +++ b/apps/api/src/modules/search/application/commands/create-saved-search/create-saved-search.command.ts @@ -0,0 +1,8 @@ +export class CreateSavedSearchCommand { + constructor( + public readonly userId: string, + public readonly name: string, + public readonly filters: Record, + public readonly alertEnabled: boolean, + ) {} +} diff --git a/apps/api/src/modules/search/application/commands/create-saved-search/create-saved-search.handler.ts b/apps/api/src/modules/search/application/commands/create-saved-search/create-saved-search.handler.ts new file mode 100644 index 0000000..1c7a382 --- /dev/null +++ b/apps/api/src/modules/search/application/commands/create-saved-search/create-saved-search.handler.ts @@ -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 { + constructor( + private readonly prisma: PrismaService, + private readonly queryBus: QueryBus, + private readonly commandBus: CommandBus, + private readonly logger: LoggerService, + ) {} + + async execute(command: CreateSavedSearchCommand): Promise { + // 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, + }; + } +} diff --git a/apps/api/src/modules/search/application/commands/delete-saved-search/delete-saved-search.command.ts b/apps/api/src/modules/search/application/commands/delete-saved-search/delete-saved-search.command.ts new file mode 100644 index 0000000..e8db729 --- /dev/null +++ b/apps/api/src/modules/search/application/commands/delete-saved-search/delete-saved-search.command.ts @@ -0,0 +1,6 @@ +export class DeleteSavedSearchCommand { + constructor( + public readonly id: string, + public readonly userId: string, + ) {} +} diff --git a/apps/api/src/modules/search/application/commands/delete-saved-search/delete-saved-search.handler.ts b/apps/api/src/modules/search/application/commands/delete-saved-search/delete-saved-search.handler.ts new file mode 100644 index 0000000..a442484 --- /dev/null +++ b/apps/api/src/modules/search/application/commands/delete-saved-search/delete-saved-search.handler.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/search/application/commands/update-saved-search/update-saved-search.command.ts b/apps/api/src/modules/search/application/commands/update-saved-search/update-saved-search.command.ts new file mode 100644 index 0000000..7b0ed8e --- /dev/null +++ b/apps/api/src/modules/search/application/commands/update-saved-search/update-saved-search.command.ts @@ -0,0 +1,9 @@ +export class UpdateSavedSearchCommand { + constructor( + public readonly id: string, + public readonly userId: string, + public readonly name?: string, + public readonly filters?: Record, + public readonly alertEnabled?: boolean, + ) {} +} diff --git a/apps/api/src/modules/search/application/commands/update-saved-search/update-saved-search.handler.ts b/apps/api/src/modules/search/application/commands/update-saved-search/update-saved-search.handler.ts new file mode 100644 index 0000000..8d17653 --- /dev/null +++ b/apps/api/src/modules/search/application/commands/update-saved-search/update-saved-search.handler.ts @@ -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 { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(command: UpdateSavedSearchCommand): Promise { + 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, + }; + } +} diff --git a/apps/api/src/modules/search/application/index.ts b/apps/api/src/modules/search/application/index.ts index f0d824b..9539e18 100644 --- a/apps/api/src/modules/search/application/index.ts +++ b/apps/api/src/modules/search/application/index.ts @@ -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'; diff --git a/apps/api/src/modules/search/application/queries/get-saved-search/get-saved-search.handler.ts b/apps/api/src/modules/search/application/queries/get-saved-search/get-saved-search.handler.ts new file mode 100644 index 0000000..633f1a2 --- /dev/null +++ b/apps/api/src/modules/search/application/queries/get-saved-search/get-saved-search.handler.ts @@ -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 { + constructor( + private readonly prisma: PrismaService, + ) {} + + async execute(query: GetSavedSearchQuery): Promise { + 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, + }; + } +} diff --git a/apps/api/src/modules/search/application/queries/get-saved-search/get-saved-search.query.ts b/apps/api/src/modules/search/application/queries/get-saved-search/get-saved-search.query.ts new file mode 100644 index 0000000..d337be2 --- /dev/null +++ b/apps/api/src/modules/search/application/queries/get-saved-search/get-saved-search.query.ts @@ -0,0 +1,6 @@ +export class GetSavedSearchQuery { + constructor( + public readonly id: string, + public readonly userId: string, + ) {} +} diff --git a/apps/api/src/modules/search/application/queries/get-saved-searches/get-saved-searches.handler.ts b/apps/api/src/modules/search/application/queries/get-saved-searches/get-saved-searches.handler.ts new file mode 100644 index 0000000..d9a60ad --- /dev/null +++ b/apps/api/src/modules/search/application/queries/get-saved-searches/get-saved-searches.handler.ts @@ -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 { + constructor( + private readonly prisma: PrismaService, + ) {} + + async execute(query: GetSavedSearchesQuery): Promise { + 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, + }; + } +} diff --git a/apps/api/src/modules/search/application/queries/get-saved-searches/get-saved-searches.query.ts b/apps/api/src/modules/search/application/queries/get-saved-searches/get-saved-searches.query.ts new file mode 100644 index 0000000..e4c7b10 --- /dev/null +++ b/apps/api/src/modules/search/application/queries/get-saved-searches/get-saved-searches.query.ts @@ -0,0 +1,7 @@ +export class GetSavedSearchesQuery { + constructor( + public readonly userId: string, + public readonly page: number = 1, + public readonly limit: number = 20, + ) {} +} diff --git a/apps/api/src/modules/search/infrastructure/__tests__/resilient-search.repository.spec.ts b/apps/api/src/modules/search/infrastructure/__tests__/resilient-search.repository.spec.ts new file mode 100644 index 0000000..7dd27b0 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/__tests__/resilient-search.repository.spec.ts @@ -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 { + 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 }; + let mockPostgres: { [K in keyof PostgresSearchRepository]: ReturnType }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + let mockCounter: { inc: ReturnType }; + + 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', + ); + }); + }); +}); diff --git a/apps/api/src/modules/search/infrastructure/__tests__/saved-search-alert.handler.spec.ts b/apps/api/src/modules/search/infrastructure/__tests__/saved-search-alert.handler.spec.ts new file mode 100644 index 0000000..5a526b6 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/__tests__/saved-search-alert.handler.spec.ts @@ -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 }; + let mockLogger: { log: ReturnType; warn: ReturnType }; + + 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(); + }); +}); diff --git a/apps/api/src/modules/search/infrastructure/cron/saved-search-alert-cron.service.ts b/apps/api/src/modules/search/infrastructure/cron/saved-search-alert-cron.service.ts new file mode 100644 index 0000000..f6aa84a --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/cron/saved-search-alert-cron.service.ts @@ -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 { + 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 { + const filters = search.filters as Record; + + // Build query for new listings since last alert + const sinceDate = search.lastAlertAt ?? new Date(Date.now() - 24 * 60 * 60 * 1000); + + const where: Record = { + 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): Record { + const propertyWhere: Record = {}; + + 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; + } +} diff --git a/apps/api/src/modules/search/infrastructure/event-handlers/index.ts b/apps/api/src/modules/search/infrastructure/event-handlers/index.ts index ae7135e..70d9325 100644 --- a/apps/api/src/modules/search/infrastructure/event-handlers/index.ts +++ b/apps/api/src/modules/search/infrastructure/event-handlers/index.ts @@ -1,2 +1,3 @@ export { ListingApprovedEventHandler } from './listing-approved.handler'; export { ListingStatusChangedHandler } from './listing-status-changed.handler'; +export { SavedSearchAlertHandler } from './saved-search-alert.handler'; diff --git a/apps/api/src/modules/search/infrastructure/event-handlers/saved-search-alert.handler.ts b/apps/api/src/modules/search/infrastructure/event-handlers/saved-search-alert.handler.ts new file mode 100644 index 0000000..fd1c231 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/event-handlers/saved-search-alert.handler.ts @@ -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 { + this.logger.log( + `Checking saved search alerts for approved listing ${payload.listingId}`, + 'SavedSearchAlertHandler', + ); + + try { + // Fetch the listing with property details + const listing = await this.prisma.listing.findUnique({ + where: { id: payload.listingId }, + include: { property: true }, + }); + + if (!listing || !listing.property) { + return; + } + + // Find all saved searches with alerts enabled + const savedSearches = await this.prisma.savedSearch.findMany({ + where: { alertEnabled: true }, + include: { + user: { select: { id: true, email: true, fullName: true } }, + }, + }); + + let matchCount = 0; + + for (const search of savedSearches) { + // Skip if search belongs to the listing owner + if (search.userId === listing.sellerId) { + continue; + } + + const filters = search.filters as Record; + if (this.matchesFilters(listing, listing.property, filters)) { + matchCount++; + await this.sendAlert(search, listing, listing.property); + } + } + + if (matchCount > 0) { + this.logger.log( + `Sent ${matchCount} saved search alerts for listing ${payload.listingId}`, + 'SavedSearchAlertHandler', + ); + } + } catch (err) { + this.logger.warn( + `Saved search alert processing failed for listing ${payload.listingId}: ${err instanceof Error ? err.message : String(err)}`, + 'SavedSearchAlertHandler', + ); + } + } + + /** + * Check if a listing matches the saved search filters. + * Filters are a flexible JSON object matching SearchPropertiesDto fields. + */ + private matchesFilters( + listing: { transactionType: string; priceVND: bigint; sellerId: string }, + property: { + propertyType: string; + areaM2: number; + bedrooms: number | null; + district: string; + city: string; + }, + filters: Record, + ): boolean { + if (filters['transactionType'] && filters['transactionType'] !== listing.transactionType) { + return false; + } + + if (filters['propertyType'] && filters['propertyType'] !== property.propertyType) { + return false; + } + + if (filters['district'] && filters['district'] !== property.district) { + return false; + } + + if (filters['city'] && filters['city'] !== property.city) { + return false; + } + + const price = Number(listing.priceVND); + + if (filters['priceMin'] && price < Number(filters['priceMin'])) { + return false; + } + + if (filters['priceMax'] && price > Number(filters['priceMax'])) { + return false; + } + + if (filters['areaMin'] && property.areaM2 < Number(filters['areaMin'])) { + return false; + } + + if (filters['areaMax'] && property.areaM2 > Number(filters['areaMax'])) { + return false; + } + + if (filters['bedrooms'] && property.bedrooms !== null && property.bedrooms < Number(filters['bedrooms'])) { + return false; + } + + return true; + } + + private async sendAlert( + search: { id: string; name: string; user: { id: string; email: string | null; fullName: string | null } }, + listing: { id: string; priceVND: bigint }, + property: { title: string; district: string; city: string }, + ): Promise { + if (!search.user.email) { + this.logger.warn( + `User ${search.user.id} has no email, skipping saved search alert`, + 'SavedSearchAlertHandler', + ); + return; + } + + try { + await this.commandBus.execute( + new SendNotificationCommand( + search.user.id, + 'EMAIL', + 'saved_search_alert', + { + userName: search.user.fullName ?? 'Người dùng', + searchName: search.name, + listingTitle: property.title, + listingPrice: Number(listing.priceVND).toLocaleString('vi-VN'), + listingDistrict: property.district, + listingCity: property.city, + listingUrl: `/listings/${listing.id}`, + }, + search.user.email, + ), + ); + + // Update lastAlertAt + await this.prisma.savedSearch.update({ + where: { id: search.id }, + data: { lastAlertAt: new Date() }, + }); + } catch (err) { + this.logger.warn( + `Failed to send saved search alert to user ${search.user.id}: ${err instanceof Error ? err.message : String(err)}`, + 'SavedSearchAlertHandler', + ); + } + } +} diff --git a/apps/api/src/modules/search/infrastructure/services/index.ts b/apps/api/src/modules/search/infrastructure/services/index.ts index c9a087c..fe9c7c4 100644 --- a/apps/api/src/modules/search/infrastructure/services/index.ts +++ b/apps/api/src/modules/search/infrastructure/services/index.ts @@ -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'; diff --git a/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts b/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts new file mode 100644 index 0000000..2ce572b --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts @@ -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 { + 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( + 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 { + // Data already lives in PostgreSQL — nothing to do. + } + + async indexDocuments(_docs: ListingDocument[]): Promise { + // Data already lives in PostgreSQL — nothing to do. + } + + async removeDocument(_id: string): Promise { + // No separate index to clean up. + } + + async ensureCollection(): Promise { + // PostgreSQL tables/indexes are managed by Prisma migrations. + } + + async dropCollection(): Promise { + // 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; +} diff --git a/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts b/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts index 273fe46..de05e3a 100644 --- a/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts +++ b/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts @@ -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, diff --git a/apps/api/src/modules/search/presentation/controllers/index.ts b/apps/api/src/modules/search/presentation/controllers/index.ts index d44b466..afe38b4 100644 --- a/apps/api/src/modules/search/presentation/controllers/index.ts +++ b/apps/api/src/modules/search/presentation/controllers/index.ts @@ -1 +1,2 @@ export { SearchController } from './search.controller'; +export { SavedSearchController } from './saved-search.controller'; diff --git a/apps/api/src/modules/search/presentation/controllers/saved-search.controller.ts b/apps/api/src/modules/search/presentation/controllers/saved-search.controller.ts new file mode 100644 index 0000000..e623cc7 --- /dev/null +++ b/apps/api/src/modules/search/presentation/controllers/saved-search.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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), + ); + } +} diff --git a/apps/api/src/modules/search/presentation/dto/index.ts b/apps/api/src/modules/search/presentation/dto/index.ts index 3dd5af9..b48839f 100644 --- a/apps/api/src/modules/search/presentation/dto/index.ts +++ b/apps/api/src/modules/search/presentation/dto/index.ts @@ -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'; diff --git a/apps/api/src/modules/search/presentation/dto/saved-search.dto.ts b/apps/api/src/modules/search/presentation/dto/saved-search.dto.ts new file mode 100644 index 0000000..97a44d5 --- /dev/null +++ b/apps/api/src/modules/search/presentation/dto/saved-search.dto.ts @@ -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; + + @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; + + @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; +} diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts index 6f4a313..7942636 100644 --- a/apps/api/src/modules/search/search.module.ts +++ b/apps/api/src/modules/search/search.module.ts @@ -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', ); } diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/cache.service.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/cache.service.spec.ts index 3834dbd..ef4d61c 100644 --- a/apps/api/src/modules/shared/infrastructure/__tests__/cache.service.spec.ts +++ b/apps/api/src/modules/shared/infrastructure/__tests__/cache.service.spec.ts @@ -8,10 +8,12 @@ describe('CacheService', () => { set: ReturnType; del: ReturnType; getClient: ReturnType; + isAvailable: ReturnType; }; let mockLogger: { log: ReturnType; warn: ReturnType }; let mockHitCounter: { inc: ReturnType }; let mockMissCounter: { inc: ReturnType }; + let mockDegradationCounter: { inc: ReturnType }; 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', () => { diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/circuit-breaker.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/circuit-breaker.spec.ts new file mode 100644 index 0000000..1222066 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/circuit-breaker.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/user-rate-limit.guard.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/user-rate-limit.guard.spec.ts new file mode 100644 index 0000000..3ba9be5 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/user-rate-limit.guard.spec.ts @@ -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 = {}; + 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); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/cache.service.ts b/apps/api/src/modules/shared/infrastructure/cache.service.ts index 8e81fd5..1bd36d7 100644 --- a/apps/api/src/modules/shared/infrastructure/cache.service.ts +++ b/apps/api/src/modules/shared/infrastructure/cache.service.ts @@ -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( key: string, @@ -67,6 +75,13 @@ export class CacheService implements OnModuleInit { ttlSeconds: number, resource: string, ): Promise { + // 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'); } } diff --git a/apps/api/src/modules/shared/infrastructure/circuit-breaker.ts b/apps/api/src/modules/shared/infrastructure/circuit-breaker.ts new file mode 100644 index 0000000..1ec21ba --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/circuit-breaker.ts @@ -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(fn: () => Promise): Promise { + 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'; + } +} diff --git a/apps/api/src/modules/shared/infrastructure/decorators/user-rate-limit.decorator.ts b/apps/api/src/modules/shared/infrastructure/decorators/user-rate-limit.decorator.ts new file mode 100644 index 0000000..1bdfa36 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/decorators/user-rate-limit.decorator.ts @@ -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>; + windowSeconds?: number; +}) => SetMetadata(USER_RATE_LIMIT_KEY, options); diff --git a/apps/api/src/modules/shared/infrastructure/event-bus.service.ts b/apps/api/src/modules/shared/infrastructure/event-bus.service.ts index 9e7b234..e46943a 100644 --- a/apps/api/src/modules/shared/infrastructure/event-bus.service.ts +++ b/apps/api/src/modules/shared/infrastructure/event-bus.service.ts @@ -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() diff --git a/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts b/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts index b13c01c..d7dc695 100644 --- a/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts +++ b/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts @@ -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'; diff --git a/apps/api/src/modules/shared/infrastructure/guards/throttler-behind-proxy.guard.ts b/apps/api/src/modules/shared/infrastructure/guards/throttler-behind-proxy.guard.ts index c8ccdf9..f643ff4 100644 --- a/apps/api/src/modules/shared/infrastructure/guards/throttler-behind-proxy.guard.ts +++ b/apps/api/src/modules/shared/infrastructure/guards/throttler-behind-proxy.guard.ts @@ -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 diff --git a/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts b/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts new file mode 100644 index 0000000..2f9a2ab --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts @@ -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 = { + 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>; + /** 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 { + 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( + 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; + } + } +} diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index 109d042..676dab3 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -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'; diff --git a/apps/api/src/modules/shared/infrastructure/middleware/correlation-id.middleware.ts b/apps/api/src/modules/shared/infrastructure/middleware/correlation-id.middleware.ts index e846a6e..52d65a5 100644 --- a/apps/api/src/modules/shared/infrastructure/middleware/correlation-id.middleware.ts +++ b/apps/api/src/modules/shared/infrastructure/middleware/correlation-id.middleware.ts @@ -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'; diff --git a/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts b/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts index 265f860..35770f5 100644 --- a/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts +++ b/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts @@ -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'; diff --git a/apps/api/src/modules/shared/infrastructure/middleware/request-logging.middleware.ts b/apps/api/src/modules/shared/infrastructure/middleware/request-logging.middleware.ts index 562cc17..b083119 100644 --- a/apps/api/src/modules/shared/infrastructure/middleware/request-logging.middleware.ts +++ b/apps/api/src/modules/shared/infrastructure/middleware/request-logging.middleware.ts @@ -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() diff --git a/apps/api/src/modules/shared/infrastructure/middleware/sanitize-input.middleware.ts b/apps/api/src/modules/shared/infrastructure/middleware/sanitize-input.middleware.ts index 77224af..67cc477 100644 --- a/apps/api/src/modules/shared/infrastructure/middleware/sanitize-input.middleware.ts +++ b/apps/api/src/modules/shared/infrastructure/middleware/sanitize-input.middleware.ts @@ -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 = { diff --git a/apps/api/src/modules/shared/infrastructure/redis.service.ts b/apps/api/src/modules/shared/infrastructure/redis.service.ts index 43bb4b2..08ac6b5 100644 --- a/apps/api/src/modules/shared/infrastructure/redis.service.ts +++ b/apps/api/src/modules/shared/infrastructure/redis.service.ts @@ -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 { return this.client.get(key); } diff --git a/apps/api/src/modules/shared/shared.module.ts b/apps/api/src/modules/shared/shared.module.ts index 49aedb0..6593aaf 100644 --- a/apps/api/src/modules/shared/shared.module.ts +++ b/apps/api/src/modules/shared/shared.module.ts @@ -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 { diff --git a/apps/api/src/modules/subscriptions/index.ts b/apps/api/src/modules/subscriptions/index.ts index e4ff893..8299f95 100644 --- a/apps/api/src/modules/subscriptions/index.ts +++ b/apps/api/src/modules/subscriptions/index.ts @@ -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';