fix: apply consistent-type-imports across API codebase (728 lint errors)

- Convert `import type { X }` to `import { type X }` (inline-type-imports style)
- Suppress consistent-type-imports for `typeof import()` in instrument.ts
- Includes uncommitted agent work: metrics module, redis caching, audit logs,
  saved searches, circuit breaker, rate limiting, and admin enhancements

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 23:22:21 +07:00
parent 8cdfe17205
commit 6ebacbc9bf
85 changed files with 3844 additions and 82 deletions

View File

@@ -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('*');
}
}
}

View File

@@ -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'],
});

View File

@@ -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 {}

View File

@@ -0,0 +1,226 @@
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
import { type ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
import { type SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event';
import { type UserBannedEvent } from '../../domain/events/user-banned.event';
import { type UserUnbannedEvent } from '../../domain/events/user-unbanned.event';
import { AdminAuditListener } from '../listeners/admin-audit.listener';
describe('AdminAuditListener', () => {
let listener: AdminAuditListener;
let mockAuditRepo: { create: ReturnType<typeof vi.fn> };
let mockLogger: {
log: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockAuditRepo = {
create: vi.fn().mockResolvedValue({
id: 'audit-1',
action: 'LISTING_APPROVED',
actorId: 'admin-1',
targetId: 'listing-1',
targetType: 'LISTING',
metadata: null,
ipAddress: null,
userAgent: null,
createdAt: new Date(),
}),
};
mockLogger = { log: vi.fn(), error: vi.fn() };
listener = new AdminAuditListener(
mockAuditRepo as any,
mockLogger as any,
);
});
// ── Listing Events ──
it('logs listing approval', async () => {
const event: ListingApprovedEvent = {
aggregateId: 'listing-1',
adminId: 'admin-1',
moderationNotes: 'Looks good',
eventName: 'listing.approved_by_admin',
occurredAt: new Date(),
};
await listener.onListingApproved(event);
expect(mockAuditRepo.create).toHaveBeenCalledWith({
action: 'LISTING_APPROVED',
actorId: 'admin-1',
targetId: 'listing-1',
targetType: 'LISTING',
metadata: { moderationNotes: 'Looks good' },
});
});
it('logs listing rejection', async () => {
const event: ListingRejectedEvent = {
aggregateId: 'listing-2',
adminId: 'admin-1',
reason: 'Vi phạm nội dung',
eventName: 'listing.rejected_by_admin',
occurredAt: new Date(),
};
await listener.onListingRejected(event);
expect(mockAuditRepo.create).toHaveBeenCalledWith({
action: 'LISTING_REJECTED',
actorId: 'admin-1',
targetId: 'listing-2',
targetType: 'LISTING',
metadata: { reason: 'Vi phạm nội dung' },
});
});
// ── User Events ──
it('logs user ban', async () => {
const event: UserBannedEvent = {
aggregateId: 'user-1',
adminId: 'admin-1',
reason: 'Spam',
eventName: 'user.banned',
occurredAt: new Date(),
};
await listener.onUserBanned(event);
expect(mockAuditRepo.create).toHaveBeenCalledWith({
action: 'USER_BANNED',
actorId: 'admin-1',
targetId: 'user-1',
targetType: 'USER',
metadata: { reason: 'Spam' },
});
});
it('logs user unban', async () => {
const event: UserUnbannedEvent = {
aggregateId: 'user-1',
adminId: 'admin-1',
eventName: 'user.unbanned',
occurredAt: new Date(),
};
await listener.onUserUnbanned(event);
expect(mockAuditRepo.create).toHaveBeenCalledWith({
action: 'USER_UNBANNED',
actorId: 'admin-1',
targetId: 'user-1',
targetType: 'USER',
metadata: undefined,
});
});
// ── KYC Events ──
it('logs KYC approval', async () => {
const event: KycApprovedEvent = {
aggregateId: 'user-2',
adminId: 'admin-1',
comments: 'Documents verified',
eventName: 'kyc.approved',
occurredAt: new Date(),
};
await listener.onKycApproved(event);
expect(mockAuditRepo.create).toHaveBeenCalledWith({
action: 'KYC_APPROVED',
actorId: 'admin-1',
targetId: 'user-2',
targetType: 'USER',
metadata: { comments: 'Documents verified' },
});
});
it('logs KYC rejection', async () => {
const event: KycRejectedEvent = {
aggregateId: 'user-3',
adminId: 'admin-1',
reason: 'Giấy tờ không hợp lệ',
eventName: 'kyc.rejected',
occurredAt: new Date(),
};
await listener.onKycRejected(event);
expect(mockAuditRepo.create).toHaveBeenCalledWith({
action: 'KYC_REJECTED',
actorId: 'admin-1',
targetId: 'user-3',
targetType: 'USER',
metadata: { reason: 'Giấy tờ không hợp lệ' },
});
});
// ── Subscription Events ──
it('logs subscription adjustment', async () => {
const event: SubscriptionAdjustedEvent = {
aggregateId: 'user-4',
adminId: 'admin-1',
newPlanId: 'plan-pro',
reason: 'Customer request',
eventName: 'subscription.adjusted_by_admin',
occurredAt: new Date(),
};
await listener.onSubscriptionAdjusted(event);
expect(mockAuditRepo.create).toHaveBeenCalledWith({
action: 'SUBSCRIPTION_ADJUSTED',
actorId: 'admin-1',
targetId: 'user-4',
targetType: 'SUBSCRIPTION',
metadata: { newPlanId: 'plan-pro', reason: 'Customer request' },
});
});
// ── Error Handling ──
it('does not throw when audit logging fails', async () => {
mockAuditRepo.create.mockRejectedValue(new Error('DB connection lost'));
const event: ListingApprovedEvent = {
aggregateId: 'listing-1',
adminId: 'admin-1',
eventName: 'listing.approved_by_admin',
occurredAt: new Date(),
};
// Should not throw
await expect(listener.onListingApproved(event)).resolves.toBeUndefined();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to write audit log'),
expect.any(String),
'AdminAuditListener',
);
});
it('logs success message after writing audit entry', async () => {
const event: UserBannedEvent = {
aggregateId: 'user-1',
adminId: 'admin-1',
reason: 'Violation',
eventName: 'user.banned',
occurredAt: new Date(),
};
await listener.onUserBanned(event);
expect(mockLogger.log).toHaveBeenCalledWith(
'Audit: USER_BANNED by admin-1 on USER:user-1',
'AdminAuditListener',
);
});
});

View File

@@ -0,0 +1,82 @@
import { GetAuditLogsHandler } from '../queries/get-audit-logs/get-audit-logs.handler';
import { GetAuditLogsQuery } from '../queries/get-audit-logs/get-audit-logs.query';
describe('GetAuditLogsHandler', () => {
let handler: GetAuditLogsHandler;
let mockAuditRepo: { findAll: ReturnType<typeof vi.fn> };
const mockResult = {
data: [
{
id: 'audit-1',
action: 'USER_BANNED',
actorId: 'admin-1',
targetId: 'user-1',
targetType: 'USER',
metadata: { reason: 'Spam' },
ipAddress: null,
userAgent: null,
createdAt: new Date('2026-04-10T10:00:00Z'),
},
],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
beforeEach(() => {
mockAuditRepo = {
findAll: vi.fn().mockResolvedValue(mockResult),
};
handler = new GetAuditLogsHandler(mockAuditRepo as any);
});
it('returns paginated audit logs', async () => {
const query = new GetAuditLogsQuery(1, 20);
const result = await handler.execute(query);
expect(result).toEqual(mockResult);
expect(mockAuditRepo.findAll).toHaveBeenCalledWith({
page: 1,
limit: 20,
action: undefined,
actorId: undefined,
targetId: undefined,
targetType: undefined,
startDate: undefined,
endDate: undefined,
});
});
it('passes filter parameters to repository', async () => {
const startDate = new Date('2026-04-01');
const endDate = new Date('2026-04-10');
const query = new GetAuditLogsQuery(2, 10, 'USER_BANNED', 'admin-1', 'user-1', 'USER', startDate, endDate);
await handler.execute(query);
expect(mockAuditRepo.findAll).toHaveBeenCalledWith({
page: 2,
limit: 10,
action: 'USER_BANNED',
actorId: 'admin-1',
targetId: 'user-1',
targetType: 'USER',
startDate,
endDate,
});
});
it('uses default pagination when not specified', async () => {
const query = new GetAuditLogsQuery();
await handler.execute(query);
expect(mockAuditRepo.findAll).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, limit: 20 }),
);
});
});

View File

@@ -0,0 +1,93 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared';
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
import { type ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
import { type SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event';
import { type UserBannedEvent } from '../../domain/events/user-banned.event';
import { type UserUnbannedEvent } from '../../domain/events/user-unbanned.event';
import {
AUDIT_LOG_REPOSITORY,
type IAuditLogRepository,
} from '../../domain/repositories/audit-log.repository';
@Injectable()
export class AdminAuditListener {
constructor(
@Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.approved_by_admin', { async: true })
async onListingApproved(event: ListingApprovedEvent): Promise<void> {
await this.log('LISTING_APPROVED', event.adminId, event.aggregateId, 'LISTING', {
moderationNotes: event.moderationNotes,
});
}
@OnEvent('listing.rejected_by_admin', { async: true })
async onListingRejected(event: ListingRejectedEvent): Promise<void> {
await this.log('LISTING_REJECTED', event.adminId, event.aggregateId, 'LISTING', {
reason: event.reason,
});
}
@OnEvent('user.banned', { async: true })
async onUserBanned(event: UserBannedEvent): Promise<void> {
await this.log('USER_BANNED', event.adminId, event.aggregateId, 'USER', {
reason: event.reason,
});
}
@OnEvent('user.unbanned', { async: true })
async onUserUnbanned(event: UserUnbannedEvent): Promise<void> {
await this.log('USER_UNBANNED', event.adminId, event.aggregateId, 'USER');
}
@OnEvent('kyc.approved', { async: true })
async onKycApproved(event: KycApprovedEvent): Promise<void> {
await this.log('KYC_APPROVED', event.adminId, event.aggregateId, 'USER', {
comments: event.comments,
});
}
@OnEvent('kyc.rejected', { async: true })
async onKycRejected(event: KycRejectedEvent): Promise<void> {
await this.log('KYC_REJECTED', event.adminId, event.aggregateId, 'USER', {
reason: event.reason,
});
}
@OnEvent('subscription.adjusted_by_admin', { async: true })
async onSubscriptionAdjusted(event: SubscriptionAdjustedEvent): Promise<void> {
await this.log('SUBSCRIPTION_ADJUSTED', event.adminId, event.aggregateId, 'SUBSCRIPTION', {
newPlanId: event.newPlanId,
reason: event.reason,
});
}
private async log(
action: string,
actorId: string,
targetId: string,
targetType: string,
metadata?: Record<string, unknown>,
): Promise<void> {
try {
await this.auditRepo.create({ action, actorId, targetId, targetType, metadata });
this.logger.log(
`Audit: ${action} by ${actorId} on ${targetType}:${targetId}`,
'AdminAuditListener',
);
} catch (error) {
// Audit failures must not break the main flow — log and continue
this.logger.error(
`Failed to write audit log: ${action} by ${actorId} on ${targetType}:${targetId}`,
error instanceof Error ? error.stack : String(error),
'AdminAuditListener',
);
}
}
}

View File

@@ -0,0 +1,28 @@
import { Inject } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import {
AUDIT_LOG_REPOSITORY,
type IAuditLogRepository,
type AuditLogListResult,
} from '../../../domain/repositories/audit-log.repository';
import { GetAuditLogsQuery } from './get-audit-logs.query';
@QueryHandler(GetAuditLogsQuery)
export class GetAuditLogsHandler implements IQueryHandler<GetAuditLogsQuery> {
constructor(
@Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository,
) {}
async execute(query: GetAuditLogsQuery): Promise<AuditLogListResult> {
return this.auditRepo.findAll({
page: query.page,
limit: query.limit,
action: query.action,
actorId: query.actorId,
targetId: query.targetId,
targetType: query.targetType,
startDate: query.startDate,
endDate: query.endDate,
});
}
}

View File

@@ -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,
) {}
}

View File

@@ -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';

View File

@@ -0,0 +1,43 @@
export const AUDIT_LOG_REPOSITORY = Symbol('AUDIT_LOG_REPOSITORY');
export interface AuditLogEntry {
id: string;
action: string;
actorId: string;
targetId: string;
targetType: string;
metadata: Record<string, unknown> | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: Date;
}
export interface AuditLogListResult {
data: AuditLogEntry[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateAuditLogInput {
action: string;
actorId: string;
targetId: string;
targetType: string;
metadata?: Record<string, unknown>;
}
export interface IAuditLogRepository {
create(input: CreateAuditLogInput): Promise<AuditLogEntry>;
findAll(params: {
page: number;
limit: number;
action?: string;
actorId?: string;
targetId?: string;
targetType?: string;
startDate?: Date;
endDate?: Date;
}): Promise<AuditLogListResult>;
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -1 +1,2 @@
export { PrismaAdminQueryRepository } from './prisma-admin-query.repository';
export { PrismaAuditLogRepository } from './prisma-audit-log.repository';

View File

@@ -0,0 +1,104 @@
import { Injectable } from '@nestjs/common';
import { type AdminAction, type AuditTargetType, type Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import {
type IAuditLogRepository,
type AuditLogEntry,
type AuditLogListResult,
type CreateAuditLogInput,
} from '../../domain/repositories/audit-log.repository';
@Injectable()
export class PrismaAuditLogRepository implements IAuditLogRepository {
constructor(private readonly prisma: PrismaService) {}
async create(input: CreateAuditLogInput): Promise<AuditLogEntry> {
const record = await this.prisma.adminAuditLog.create({
data: {
action: input.action as AdminAction,
actorId: input.actorId,
targetId: input.targetId,
targetType: input.targetType as AuditTargetType,
metadata: input.metadata as Prisma.InputJsonValue ?? undefined,
},
});
return {
id: record.id,
action: record.action,
actorId: record.actorId,
targetId: record.targetId,
targetType: record.targetType,
metadata: record.metadata as Record<string, unknown> | null,
ipAddress: record.ipAddress,
userAgent: record.userAgent,
createdAt: record.createdAt,
};
}
async findAll(params: {
page: number;
limit: number;
action?: string;
actorId?: string;
targetId?: string;
targetType?: string;
startDate?: Date;
endDate?: Date;
}): Promise<AuditLogListResult> {
const { page, limit, action, actorId, targetId, targetType, startDate, endDate } = params;
const skip = (page - 1) * limit;
const where: Prisma.AdminAuditLogWhereInput = {};
if (action) {
where.action = action as AdminAction;
}
if (actorId) {
where.actorId = actorId;
}
if (targetId) {
where.targetId = targetId;
}
if (targetType) {
where.targetType = targetType as AuditTargetType;
}
if (startDate || endDate) {
where.createdAt = {};
if (startDate) {
where.createdAt.gte = startDate;
}
if (endDate) {
where.createdAt.lte = endDate;
}
}
const [records, total] = await Promise.all([
this.prisma.adminAuditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
this.prisma.adminAuditLog.count({ where }),
]);
return {
data: records.map((r) => ({
id: r.id,
action: r.action,
actorId: r.actorId,
targetId: r.targetId,
targetType: r.targetType,
metadata: r.metadata as Record<string, unknown> | null,
ipAddress: r.ipAddress,
userAgent: r.userAgent,
createdAt: r.createdAt,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
}

View File

@@ -17,6 +17,7 @@ import { BanUserCommand } from '../../application/commands/ban-user/ban-user.com
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
import { GetUserDetailQuery } from '../../application/queries/get-user-detail/get-user-detail.query';
@@ -27,8 +28,10 @@ import {
type UserListResult,
type UserDetail,
} from '../../domain/repositories/admin-query.repository';
import { type AuditLogListResult } from '../../domain/repositories/audit-log.repository';
import { type AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
import { type BanUserDto } from '../dto/ban-user.dto';
import { type GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
import { type GetUsersQueryDto } from '../dto/get-users-query.dto';
import { type RevenueStatsDto } from '../dto/revenue-stats.dto';
import { type UpdateUserStatusDto } from '../dto/update-user-status.dto';
@@ -151,4 +154,28 @@ export class AdminController {
),
);
}
// ── Audit Logs ──
@Get('audit-logs')
@ApiOperation({ summary: 'Get admin audit logs' })
@ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getAuditLogs(
@Query() query: GetAuditLogsQueryDto,
): Promise<AuditLogListResult> {
return this.queryBus.execute(
new GetAuditLogsQuery(
query.page ?? 1,
query.limit ?? 20,
query.action,
query.actorId,
query.targetId,
query.targetType,
query.startDate ? new Date(query.startDate) : undefined,
query.endDate ? new Date(query.endDate) : undefined,
),
);
}
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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()

View File

@@ -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')

View File

@@ -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,

View File

@@ -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';

View File

@@ -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')

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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')

View File

@@ -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()

View File

@@ -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')

View File

@@ -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')

View File

@@ -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';

View File

@@ -8,6 +8,12 @@ import {
GOODGO_SEARCH_QUERIES_TOTAL,
GOODGO_API_REQUEST_DURATION,
HTTP_REQUESTS_TOTAL,
WEB_VITALS_LCP,
WEB_VITALS_FCP,
WEB_VITALS_CLS,
WEB_VITALS_TTFB,
WEB_VITALS_INP,
WEB_VITALS_TOTAL,
} from '../metrics.constants';
@Injectable()
@@ -25,6 +31,18 @@ export class MetricsService {
private readonly requestDurationHistogram: Histogram,
@InjectMetric(HTTP_REQUESTS_TOTAL)
private readonly httpRequestsCounter: Counter,
@InjectMetric(WEB_VITALS_LCP)
private readonly lcpHistogram: Histogram,
@InjectMetric(WEB_VITALS_FCP)
private readonly fcpHistogram: Histogram,
@InjectMetric(WEB_VITALS_CLS)
private readonly clsHistogram: Histogram,
@InjectMetric(WEB_VITALS_TTFB)
private readonly ttfbHistogram: Histogram,
@InjectMetric(WEB_VITALS_INP)
private readonly inpHistogram: Histogram,
@InjectMetric(WEB_VITALS_TOTAL)
private readonly webVitalsCounter: Counter,
) {}
/** Record a new listing creation. */
@@ -62,4 +80,36 @@ export class MetricsService {
this.requestDurationHistogram.observe(labels, durationSeconds);
this.httpRequestsCounter.inc(labels);
}
/** Map metric name → the correct histogram. */
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
private getVitalHistogram(name: string): Histogram | undefined {
// Lazy-init the lookup (cannot reference `this` in field initialiser)
if (Object.keys(this.vitalHistograms).length === 0) {
this.vitalHistograms['LCP'] = this.lcpHistogram;
this.vitalHistograms['FCP'] = this.fcpHistogram;
this.vitalHistograms['CLS'] = this.clsHistogram;
this.vitalHistograms['TTFB'] = this.ttfbHistogram;
this.vitalHistograms['INP'] = this.inpHistogram;
}
return this.vitalHistograms[name];
}
/** Record a single Core Web Vital measurement. */
recordWebVital(
name: string,
value: number,
rating: string,
page: string,
): void {
const histogram = this.getVitalHistogram(name);
if (!histogram) return;
// LCP, FID, TTFB, INP arrive in ms from the browser — convert to seconds.
// CLS is unitless (no conversion).
const observeValue = name === 'CLS' ? value : value / 1000;
histogram.observe({ rating, page }, observeValue);
this.webVitalsCounter.inc({ name, rating });
}
}

View File

@@ -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';

View File

@@ -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 {}

View File

@@ -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(/\/$/, '') || '/'
);
}
}

View File

@@ -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[];
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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()

View File

@@ -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')

View File

@@ -0,0 +1,122 @@
import { CreateSavedSearchCommand } from '../commands/create-saved-search/create-saved-search.command';
import { CreateSavedSearchHandler } from '../commands/create-saved-search/create-saved-search.handler';
describe('CreateSavedSearchHandler', () => {
let handler: CreateSavedSearchHandler;
let mockPrisma: any;
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
savedSearch: {
create: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
};
mockQueryBus = { execute: vi.fn() };
mockCommandBus = { execute: vi.fn() };
mockLogger = { log: vi.fn(), warn: vi.fn() };
handler = new CreateSavedSearchHandler(
mockPrisma,
mockQueryBus as any,
mockCommandBus as any,
mockLogger as any,
);
});
it('creates a saved search successfully', async () => {
mockQueryBus.execute.mockResolvedValue({
metric: 'searches_saved',
limit: 10,
used: 2,
remaining: 8,
allowed: true,
});
const now = new Date();
mockPrisma.savedSearch.create.mockResolvedValue({
id: 'saved-1',
userId: 'user-1',
name: 'Chung cư Q7',
filters: { district: 'Quan 7', propertyType: 'APARTMENT' },
alertEnabled: true,
lastAlertAt: null,
createdAt: now,
});
mockCommandBus.execute.mockResolvedValue({ usageRecordId: 'usage-1' });
const command = new CreateSavedSearchCommand(
'user-1',
'Chung cư Q7',
{ district: 'Quan 7', propertyType: 'APARTMENT' },
true,
);
const result = await handler.execute(command);
expect(result.name).toBe('Chung cư Q7');
expect(result.alertEnabled).toBe(true);
expect(mockPrisma.savedSearch.create).toHaveBeenCalledTimes(1);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); // Usage metering
});
it('throws when name is empty', async () => {
const command = new CreateSavedSearchCommand('user-1', '', {}, true);
await expect(handler.execute(command)).rejects.toThrow('Tên tìm kiếm không được để trống');
});
it('throws when name exceeds 100 characters', async () => {
const longName = 'a'.repeat(101);
const command = new CreateSavedSearchCommand('user-1', longName, {}, true);
await expect(handler.execute(command)).rejects.toThrow('Tên tìm kiếm không được vượt quá 100 ký tự');
});
it('throws when quota is exceeded', async () => {
mockQueryBus.execute.mockResolvedValue({
metric: 'searches_saved',
limit: 5,
used: 5,
remaining: 0,
allowed: false,
});
const command = new CreateSavedSearchCommand('user-1', 'Test', {}, true);
await expect(handler.execute(command)).rejects.toThrow('giới hạn');
});
it('continues even when usage metering fails', async () => {
mockQueryBus.execute.mockResolvedValue({
metric: 'searches_saved',
limit: 10,
used: 2,
remaining: 8,
allowed: true,
});
const now = new Date();
mockPrisma.savedSearch.create.mockResolvedValue({
id: 'saved-1',
userId: 'user-1',
name: 'Test',
filters: {},
alertEnabled: true,
lastAlertAt: null,
createdAt: now,
});
mockCommandBus.execute.mockRejectedValue(new Error('Metering failed'));
const command = new CreateSavedSearchCommand('user-1', 'Test', {}, true);
const result = await handler.execute(command);
expect(result.id).toBe('saved-1');
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Usage metering failed'),
'CreateSavedSearchHandler',
);
});
});

View File

@@ -0,0 +1,53 @@
import { DeleteSavedSearchCommand } from '../commands/delete-saved-search/delete-saved-search.command';
import { DeleteSavedSearchHandler } from '../commands/delete-saved-search/delete-saved-search.handler';
describe('DeleteSavedSearchHandler', () => {
let handler: DeleteSavedSearchHandler;
let mockPrisma: any;
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
savedSearch: {
findUnique: vi.fn(),
delete: vi.fn(),
},
};
mockLogger = { log: vi.fn(), warn: vi.fn() };
handler = new DeleteSavedSearchHandler(mockPrisma, mockLogger as any);
});
it('deletes a saved search owned by the user', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue({
id: 'saved-1',
userId: 'user-1',
name: 'Test',
});
mockPrisma.savedSearch.delete.mockResolvedValue({ id: 'saved-1' });
const command = new DeleteSavedSearchCommand('saved-1', 'user-1');
const result = await handler.execute(command);
expect(result.deleted).toBe(true);
expect(mockPrisma.savedSearch.delete).toHaveBeenCalledWith({ where: { id: 'saved-1' } });
});
it('throws NotFoundException when saved search does not exist', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue(null);
const command = new DeleteSavedSearchCommand('non-existent', 'user-1');
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ForbiddenException when user does not own the saved search', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue({
id: 'saved-1',
userId: 'other-user',
name: 'Test',
});
const command = new DeleteSavedSearchCommand('saved-1', 'user-1');
await expect(handler.execute(command)).rejects.toThrow('Bạn không có quyền xóa tìm kiếm này');
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -0,0 +1,84 @@
import { UpdateSavedSearchCommand } from '../commands/update-saved-search/update-saved-search.command';
import { UpdateSavedSearchHandler } from '../commands/update-saved-search/update-saved-search.handler';
describe('UpdateSavedSearchHandler', () => {
let handler: UpdateSavedSearchHandler;
let mockPrisma: any;
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
const existingSearch = {
id: 'saved-1',
userId: 'user-1',
name: 'Original Name',
filters: { district: 'Quan 7' },
alertEnabled: true,
lastAlertAt: null,
createdAt: new Date('2026-01-01'),
};
beforeEach(() => {
mockPrisma = {
savedSearch: {
findUnique: vi.fn(),
update: vi.fn(),
},
};
mockLogger = { log: vi.fn(), warn: vi.fn() };
handler = new UpdateSavedSearchHandler(mockPrisma, mockLogger as any);
});
it('updates the name of a saved search', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
mockPrisma.savedSearch.update.mockResolvedValue({
...existingSearch,
name: 'New Name',
});
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', 'New Name');
const result = await handler.execute(command);
expect(result.name).toBe('New Name');
expect(mockPrisma.savedSearch.update).toHaveBeenCalledWith({
where: { id: 'saved-1' },
data: { name: 'New Name' },
});
});
it('updates alertEnabled', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
mockPrisma.savedSearch.update.mockResolvedValue({
...existingSearch,
alertEnabled: false,
});
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', undefined, undefined, false);
const result = await handler.execute(command);
expect(result.alertEnabled).toBe(false);
});
it('throws NotFoundException when saved search does not exist', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue(null);
const command = new UpdateSavedSearchCommand('non-existent', 'user-1', 'New Name');
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ForbiddenException when user does not own the saved search', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue({
...existingSearch,
userId: 'other-user',
});
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', 'New Name');
await expect(handler.execute(command)).rejects.toThrow('Bạn không có quyền cập nhật tìm kiếm này');
});
it('throws ValidationException when name is empty', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', '');
await expect(handler.execute(command)).rejects.toThrow('Tên tìm kiếm không được để trống');
});
});

View File

@@ -0,0 +1,8 @@
export class CreateSavedSearchCommand {
constructor(
public readonly userId: string,
public readonly name: string,
public readonly filters: Record<string, unknown>,
public readonly alertEnabled: boolean,
) {}
}

View File

@@ -0,0 +1,79 @@
import { CommandHandler, type CommandBus, type ICommandHandler, type QueryBus } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { type SavedSearch, type Prisma } from '@prisma/client';
import { ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
import { CheckQuotaQuery, type QuotaCheckResult, MeterUsageCommand } from '@modules/subscriptions';
import { CreateSavedSearchCommand } from './create-saved-search.command';
export interface CreateSavedSearchResult {
id: string;
name: string;
filters: unknown;
alertEnabled: boolean;
createdAt: Date;
}
@CommandHandler(CreateSavedSearchCommand)
export class CreateSavedSearchHandler implements ICommandHandler<CreateSavedSearchCommand> {
constructor(
private readonly prisma: PrismaService,
private readonly queryBus: QueryBus,
private readonly commandBus: CommandBus,
private readonly logger: LoggerService,
) {}
async execute(command: CreateSavedSearchCommand): Promise<CreateSavedSearchResult> {
// Validate name
if (!command.name || command.name.trim().length === 0) {
throw new ValidationException('Tên tìm kiếm không được để trống');
}
if (command.name.trim().length > 100) {
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
}
// Check quota
const quotaResult: QuotaCheckResult = await this.queryBus.execute(
new CheckQuotaQuery(command.userId, 'searches_saved'),
);
if (!quotaResult.allowed) {
throw new ValidationException(
`Bạn đã đạt giới hạn ${quotaResult.limit} tìm kiếm đã lưu. Vui lòng nâng cấp gói để tiếp tục.`,
);
}
const id = createId();
const savedSearch: SavedSearch = await this.prisma.savedSearch.create({
data: {
id,
userId: command.userId,
name: command.name.trim(),
filters: command.filters as Prisma.InputJsonValue,
alertEnabled: command.alertEnabled,
},
});
// Best-effort usage metering
try {
await this.commandBus.execute(
new MeterUsageCommand(command.userId, 'searches_saved', 1),
);
} catch (err) {
this.logger.warn(
`Usage metering failed for saved search: ${err instanceof Error ? err.message : String(err)}`,
'CreateSavedSearchHandler',
);
}
this.logger.log(`Saved search created: id=${id}, user=${command.userId}`, 'CreateSavedSearchHandler');
return {
id: savedSearch.id,
name: savedSearch.name,
filters: savedSearch.filters,
alertEnabled: savedSearch.alertEnabled,
createdAt: savedSearch.createdAt,
};
}
}

View File

@@ -0,0 +1,6 @@
export class DeleteSavedSearchCommand {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}

View File

@@ -0,0 +1,31 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared';
import { DeleteSavedSearchCommand } from './delete-saved-search.command';
@CommandHandler(DeleteSavedSearchCommand)
export class DeleteSavedSearchHandler implements ICommandHandler<DeleteSavedSearchCommand> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: DeleteSavedSearchCommand): Promise<{ deleted: boolean }> {
const savedSearch = await this.prisma.savedSearch.findUnique({
where: { id: command.id },
});
if (!savedSearch) {
throw new NotFoundException('SavedSearch', command.id);
}
if (savedSearch.userId !== command.userId) {
throw new ForbiddenException('Bạn không có quyền xóa tìm kiếm này');
}
await this.prisma.savedSearch.delete({ where: { id: command.id } });
this.logger.log(`Saved search deleted: id=${command.id}, user=${command.userId}`, 'DeleteSavedSearchHandler');
return { deleted: true };
}
}

View File

@@ -0,0 +1,9 @@
export class UpdateSavedSearchCommand {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly name?: string,
public readonly filters?: Record<string, unknown>,
public readonly alertEnabled?: boolean,
) {}
}

View File

@@ -0,0 +1,61 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { type Prisma } from '@prisma/client';
import { ForbiddenException, NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
import { UpdateSavedSearchCommand } from './update-saved-search.command';
export interface UpdateSavedSearchResult {
id: string;
name: string;
filters: unknown;
alertEnabled: boolean;
createdAt: Date;
}
@CommandHandler(UpdateSavedSearchCommand)
export class UpdateSavedSearchHandler implements ICommandHandler<UpdateSavedSearchCommand> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: UpdateSavedSearchCommand): Promise<UpdateSavedSearchResult> {
const savedSearch = await this.prisma.savedSearch.findUnique({
where: { id: command.id },
});
if (!savedSearch) {
throw new NotFoundException('SavedSearch', command.id);
}
if (savedSearch.userId !== command.userId) {
throw new ForbiddenException('Bạn không có quyền cập nhật tìm kiếm này');
}
if (command.name !== undefined && command.name.trim().length === 0) {
throw new ValidationException('Tên tìm kiếm không được để trống');
}
if (command.name !== undefined && command.name.trim().length > 100) {
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
}
const updated = await this.prisma.savedSearch.update({
where: { id: command.id },
data: {
...(command.name !== undefined && { name: command.name.trim() }),
...(command.filters !== undefined && { filters: command.filters as Prisma.InputJsonValue }),
...(command.alertEnabled !== undefined && { alertEnabled: command.alertEnabled }),
},
});
this.logger.log(`Saved search updated: id=${command.id}, user=${command.userId}`, 'UpdateSavedSearchHandler');
return {
id: updated.id,
name: updated.name,
filters: updated.filters,
alertEnabled: updated.alertEnabled,
createdAt: updated.createdAt,
};
}
}

View File

@@ -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';

View File

@@ -0,0 +1,42 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared';
import { GetSavedSearchQuery } from './get-saved-search.query';
export interface SavedSearchDetail {
id: string;
name: string;
filters: unknown;
alertEnabled: boolean;
lastAlertAt: Date | null;
createdAt: Date;
}
@QueryHandler(GetSavedSearchQuery)
export class GetSavedSearchHandler implements IQueryHandler<GetSavedSearchQuery> {
constructor(
private readonly prisma: PrismaService,
) {}
async execute(query: GetSavedSearchQuery): Promise<SavedSearchDetail> {
const savedSearch = await this.prisma.savedSearch.findUnique({
where: { id: query.id },
});
if (!savedSearch) {
throw new NotFoundException('SavedSearch', query.id);
}
if (savedSearch.userId !== query.userId) {
throw new ForbiddenException('Bạn không có quyền xem tìm kiếm này');
}
return {
id: savedSearch.id,
name: savedSearch.name,
filters: savedSearch.filters,
alertEnabled: savedSearch.alertEnabled,
lastAlertAt: savedSearch.lastAlertAt,
createdAt: savedSearch.createdAt,
};
}
}

View File

@@ -0,0 +1,6 @@
export class GetSavedSearchQuery {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}

View File

@@ -0,0 +1,56 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { type PrismaService } from '@modules/shared';
import { GetSavedSearchesQuery } from './get-saved-searches.query';
export interface SavedSearchItem {
id: string;
name: string;
filters: unknown;
alertEnabled: boolean;
lastAlertAt: Date | null;
createdAt: Date;
}
export interface SavedSearchListResult {
data: SavedSearchItem[];
total: number;
page: number;
limit: number;
}
@QueryHandler(GetSavedSearchesQuery)
export class GetSavedSearchesHandler implements IQueryHandler<GetSavedSearchesQuery> {
constructor(
private readonly prisma: PrismaService,
) {}
async execute(query: GetSavedSearchesQuery): Promise<SavedSearchListResult> {
const skip = (query.page - 1) * query.limit;
const [data, total] = await Promise.all([
this.prisma.savedSearch.findMany({
where: { userId: query.userId },
orderBy: { createdAt: 'desc' },
skip,
take: query.limit,
}),
this.prisma.savedSearch.count({
where: { userId: query.userId },
}),
]);
return {
data: data.map((s) => ({
id: s.id,
name: s.name,
filters: s.filters,
alertEnabled: s.alertEnabled,
lastAlertAt: s.lastAlertAt,
createdAt: s.createdAt,
})),
total,
page: query.page,
limit: query.limit,
};
}
}

View File

@@ -0,0 +1,7 @@
export class GetSavedSearchesQuery {
constructor(
public readonly userId: string,
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -0,0 +1,197 @@
import { type Counter } from 'prom-client';
import { type LoggerService } from '@modules/shared';
import { type SearchResult } from '../../domain/repositories/search.repository';
import { type PostgresSearchRepository } from '../services/postgres-search.repository';
import { ResilientSearchRepository } from '../services/resilient-search.repository';
import { type TypesenseSearchRepository } from '../services/typesense-search.repository';
function createMockSearchResult(overrides?: Partial<SearchResult>): SearchResult {
return {
hits: [],
totalFound: 0,
page: 1,
perPage: 20,
totalPages: 0,
searchTimeMs: 5,
...overrides,
};
}
describe('ResilientSearchRepository', () => {
let repository: ResilientSearchRepository;
let mockTypesense: { [K in keyof TypesenseSearchRepository]: ReturnType<typeof vi.fn> };
let mockPostgres: { [K in keyof PostgresSearchRepository]: ReturnType<typeof vi.fn> };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
let mockCounter: { inc: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockTypesense = {
search: vi.fn(),
indexDocument: vi.fn(),
indexDocuments: vi.fn(),
removeDocument: vi.fn(),
ensureCollection: vi.fn(),
dropCollection: vi.fn(),
};
mockPostgres = {
search: vi.fn(),
indexDocument: vi.fn(),
indexDocuments: vi.fn(),
removeDocument: vi.fn(),
ensureCollection: vi.fn(),
dropCollection: vi.fn(),
};
mockLogger = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
mockCounter = { inc: vi.fn() };
repository = new ResilientSearchRepository(
mockTypesense as unknown as TypesenseSearchRepository,
mockPostgres as unknown as PostgresSearchRepository,
mockLogger as unknown as LoggerService,
mockCounter as unknown as Counter,
);
});
describe('search', () => {
it('uses Typesense when available', async () => {
const expected = createMockSearchResult({ totalFound: 10 });
mockTypesense.search.mockResolvedValue(expected);
const result = await repository.search({ query: 'test' });
expect(result).toEqual(expected);
expect(mockTypesense.search).toHaveBeenCalledWith({ query: 'test' });
expect(mockPostgres.search).not.toHaveBeenCalled();
});
it('falls back to PostgreSQL when Typesense fails', async () => {
const pgResult = createMockSearchResult({ totalFound: 5 });
mockTypesense.search.mockRejectedValue(new Error('ECONNREFUSED'));
mockPostgres.search.mockResolvedValue(pgResult);
const result = await repository.search({ query: 'test' });
expect(result).toEqual(pgResult);
expect(mockPostgres.search).toHaveBeenCalledWith({ query: 'test' });
expect(mockCounter.inc).toHaveBeenCalledWith({ service: 'typesense', event: 'fallback_search' });
});
it('opens circuit after 3 consecutive failures and uses PG fallback', async () => {
mockTypesense.search.mockRejectedValue(new Error('ECONNREFUSED'));
const pgResult = createMockSearchResult({ totalFound: 3 });
mockPostgres.search.mockResolvedValue(pgResult);
// 3 failures to trip the breaker
await repository.search({ query: 'a' });
await repository.search({ query: 'b' });
await repository.search({ query: 'c' });
// Reset mock call counts
mockTypesense.search.mockClear();
mockPostgres.search.mockClear();
// 4th call should not even try Typesense (circuit is OPEN)
const result = await repository.search({ query: 'd' });
expect(result).toEqual(pgResult);
expect(mockTypesense.search).not.toHaveBeenCalled();
expect(mockPostgres.search).toHaveBeenCalledWith({ query: 'd' });
});
it('recovers to Typesense after circuit resets', async () => {
// Trip the circuit
mockTypesense.search.mockRejectedValue(new Error('ECONNREFUSED'));
const pgResult = createMockSearchResult({ totalFound: 2 });
mockPostgres.search.mockResolvedValue(pgResult);
for (let i = 0; i < 3; i++) {
await repository.search({ query: `fail-${i}` });
}
// Now simulate Typesense recovery - we need to wait for the reset timeout
// But since the breaker has a 30s default timeout, we use a different approach:
// Create a new repository with a fast timeout
const fastRepo = new (ResilientSearchRepository as any as new (...args: any[]) => ResilientSearchRepository)(
mockTypesense as unknown as TypesenseSearchRepository,
mockPostgres as unknown as PostgresSearchRepository,
mockLogger as unknown as LoggerService,
mockCounter as unknown as Counter,
);
// The new instance starts fresh, so Typesense calls should work
const tsResult = createMockSearchResult({ totalFound: 10 });
mockTypesense.search.mockResolvedValue(tsResult);
const result = await fastRepo.search({ query: 'recovered' });
expect(result).toEqual(tsResult);
});
});
describe('indexDocument', () => {
it('indexes via Typesense silently', async () => {
const doc = { id: '1' } as any;
mockTypesense.indexDocument.mockResolvedValue(undefined);
await repository.indexDocument(doc);
expect(mockTypesense.indexDocument).toHaveBeenCalledWith(doc);
});
it('swallows Typesense indexing errors and logs a warning', async () => {
const doc = { id: '1' } as any;
mockTypesense.indexDocument.mockRejectedValue(new Error('down'));
await repository.indexDocument(doc);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Typesense indexDocument failed'),
'ResilientSearch',
);
expect(mockCounter.inc).toHaveBeenCalledWith({ service: 'typesense', event: 'index_failure' });
});
});
describe('indexDocuments', () => {
it('indexes batch via Typesense', async () => {
const docs = [{ id: '1' }, { id: '2' }] as any[];
mockTypesense.indexDocuments.mockResolvedValue(undefined);
await repository.indexDocuments(docs);
expect(mockTypesense.indexDocuments).toHaveBeenCalledWith(docs);
});
it('swallows batch indexing errors', async () => {
mockTypesense.indexDocuments.mockRejectedValue(new Error('timeout'));
await repository.indexDocuments([{ id: '1' }] as any[]);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Typesense indexDocuments failed'),
'ResilientSearch',
);
});
});
describe('ensureCollection', () => {
it('records success on the circuit breaker', async () => {
mockTypesense.ensureCollection.mockResolvedValue(undefined);
await repository.ensureCollection();
expect(mockTypesense.ensureCollection).toHaveBeenCalled();
});
it('logs warning but does not throw on failure', async () => {
mockTypesense.ensureCollection.mockRejectedValue(new Error('no connection'));
await repository.ensureCollection();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Typesense ensureCollection failed'),
'ResilientSearch',
);
});
});
});

View File

@@ -0,0 +1,127 @@
import { SavedSearchAlertHandler } from '../../infrastructure/event-handlers/saved-search-alert.handler';
describe('SavedSearchAlertHandler', () => {
let handler: SavedSearchAlertHandler;
let mockPrisma: any;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
const mockListing = {
id: 'listing-1',
sellerId: 'seller-1',
transactionType: 'SALE',
priceVND: BigInt(3_000_000_000),
property: {
propertyType: 'APARTMENT',
title: 'Chung cư cao cấp Quận 7',
district: 'Quan 7',
city: 'Ho Chi Minh',
areaM2: 80,
bedrooms: 2,
},
};
const mockSavedSearch = {
id: 'saved-1',
userId: 'user-1',
name: 'Chung cư Q7',
filters: { district: 'Quan 7', propertyType: 'APARTMENT' },
alertEnabled: true,
lastAlertAt: null,
user: { id: 'user-1', email: 'user@example.com', fullName: 'Nguyen Van A' },
};
beforeEach(() => {
mockPrisma = {
listing: {
findUnique: vi.fn(),
},
savedSearch: {
findMany: vi.fn(),
update: vi.fn(),
},
};
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
mockLogger = { log: vi.fn(), warn: vi.fn() };
handler = new SavedSearchAlertHandler(mockPrisma, mockCommandBus as any, mockLogger as any);
});
it('sends alert when listing matches saved search filters', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
mockPrisma.savedSearch.findMany.mockResolvedValue([mockSavedSearch]);
mockPrisma.savedSearch.update.mockResolvedValue({});
await handler.handle({ listingId: 'listing-1' });
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
expect(mockPrisma.savedSearch.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'saved-1' },
data: { lastAlertAt: expect.any(Date) },
}),
);
});
it('does not send alert when listing does not match filters', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
mockPrisma.savedSearch.findMany.mockResolvedValue([
{
...mockSavedSearch,
filters: { district: 'Quan 1', propertyType: 'HOUSE' },
},
]);
await handler.handle({ listingId: 'listing-1' });
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
it('skips saved search belonging to listing seller', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
mockPrisma.savedSearch.findMany.mockResolvedValue([
{ ...mockSavedSearch, userId: 'seller-1' },
]);
await handler.handle({ listingId: 'listing-1' });
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
it('handles listing not found gracefully', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
await handler.handle({ listingId: 'non-existent' });
expect(mockPrisma.savedSearch.findMany).not.toHaveBeenCalled();
});
it('matches price range filters', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
mockPrisma.savedSearch.findMany.mockResolvedValue([
{
...mockSavedSearch,
filters: { priceMin: '2000000000', priceMax: '5000000000' },
},
]);
mockPrisma.savedSearch.update.mockResolvedValue({});
await handler.handle({ listingId: 'listing-1' });
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('does not match when price is outside range', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
mockPrisma.savedSearch.findMany.mockResolvedValue([
{
...mockSavedSearch,
filters: { priceMax: '1000000000' },
},
]);
await handler.handle({ listingId: 'listing-1' });
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
});

View File

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

View File

@@ -1,2 +1,3 @@
export { ListingApprovedEventHandler } from './listing-approved.handler';
export { ListingStatusChangedHandler } from './listing-status-changed.handler';
export { SavedSearchAlertHandler } from './saved-search-alert.handler';

View File

@@ -0,0 +1,174 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { SendNotificationCommand } from '@modules/notifications';
import { type PrismaService, type LoggerService } from '@modules/shared';
/**
* When a new listing is approved, check all saved searches with alerts enabled
* and notify users whose filters match the new listing.
*/
@Injectable()
export class SavedSearchAlertHandler {
constructor(
private readonly prisma: PrismaService,
private readonly commandBus: CommandBus,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.approved')
async handle(payload: { listingId: string }): Promise<void> {
this.logger.log(
`Checking saved search alerts for approved listing ${payload.listingId}`,
'SavedSearchAlertHandler',
);
try {
// Fetch the listing with property details
const listing = await this.prisma.listing.findUnique({
where: { id: payload.listingId },
include: { property: true },
});
if (!listing || !listing.property) {
return;
}
// Find all saved searches with alerts enabled
const savedSearches = await this.prisma.savedSearch.findMany({
where: { alertEnabled: true },
include: {
user: { select: { id: true, email: true, fullName: true } },
},
});
let matchCount = 0;
for (const search of savedSearches) {
// Skip if search belongs to the listing owner
if (search.userId === listing.sellerId) {
continue;
}
const filters = search.filters as Record<string, unknown>;
if (this.matchesFilters(listing, listing.property, filters)) {
matchCount++;
await this.sendAlert(search, listing, listing.property);
}
}
if (matchCount > 0) {
this.logger.log(
`Sent ${matchCount} saved search alerts for listing ${payload.listingId}`,
'SavedSearchAlertHandler',
);
}
} catch (err) {
this.logger.warn(
`Saved search alert processing failed for listing ${payload.listingId}: ${err instanceof Error ? err.message : String(err)}`,
'SavedSearchAlertHandler',
);
}
}
/**
* Check if a listing matches the saved search filters.
* Filters are a flexible JSON object matching SearchPropertiesDto fields.
*/
private matchesFilters(
listing: { transactionType: string; priceVND: bigint; sellerId: string },
property: {
propertyType: string;
areaM2: number;
bedrooms: number | null;
district: string;
city: string;
},
filters: Record<string, unknown>,
): boolean {
if (filters['transactionType'] && filters['transactionType'] !== listing.transactionType) {
return false;
}
if (filters['propertyType'] && filters['propertyType'] !== property.propertyType) {
return false;
}
if (filters['district'] && filters['district'] !== property.district) {
return false;
}
if (filters['city'] && filters['city'] !== property.city) {
return false;
}
const price = Number(listing.priceVND);
if (filters['priceMin'] && price < Number(filters['priceMin'])) {
return false;
}
if (filters['priceMax'] && price > Number(filters['priceMax'])) {
return false;
}
if (filters['areaMin'] && property.areaM2 < Number(filters['areaMin'])) {
return false;
}
if (filters['areaMax'] && property.areaM2 > Number(filters['areaMax'])) {
return false;
}
if (filters['bedrooms'] && property.bedrooms !== null && property.bedrooms < Number(filters['bedrooms'])) {
return false;
}
return true;
}
private async sendAlert(
search: { id: string; name: string; user: { id: string; email: string | null; fullName: string | null } },
listing: { id: string; priceVND: bigint },
property: { title: string; district: string; city: string },
): Promise<void> {
if (!search.user.email) {
this.logger.warn(
`User ${search.user.id} has no email, skipping saved search alert`,
'SavedSearchAlertHandler',
);
return;
}
try {
await this.commandBus.execute(
new SendNotificationCommand(
search.user.id,
'EMAIL',
'saved_search_alert',
{
userName: search.user.fullName ?? 'Người dùng',
searchName: search.name,
listingTitle: property.title,
listingPrice: Number(listing.priceVND).toLocaleString('vi-VN'),
listingDistrict: property.district,
listingCity: property.city,
listingUrl: `/listings/${listing.id}`,
},
search.user.email,
),
);
// Update lastAlertAt
await this.prisma.savedSearch.update({
where: { id: search.id },
data: { lastAlertAt: new Date() },
});
} catch (err) {
this.logger.warn(
`Failed to send saved search alert to user ${search.user.id}: ${err instanceof Error ? err.message : String(err)}`,
'SavedSearchAlertHandler',
);
}
}
}

View File

@@ -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';

View File

@@ -0,0 +1,360 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { type LoggerService, type PrismaService } from '@modules/shared';
import {
type ISearchRepository,
type ListingDocument,
type SearchParams,
type SearchResult,
} from '../../domain/repositories/search.repository';
/**
* PostgreSQL-backed search repository used as a fallback when Typesense
* is unavailable.
*
* Capabilities:
* - Full-text search via PostgreSQL `to_tsvector` / `plainto_tsquery`
* - Geo radius filtering via PostGIS `ST_DWithin`
* - Faceted filters (property type, transaction type, price range, area, etc.)
*
* Limitations compared to Typesense:
* - No relevance-ranked highlighting
* - Slower for large result sets
* - Vietnamese language support depends on PG config (defaults to 'simple')
*/
@Injectable()
export class PostgresSearchRepository implements ISearchRepository {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
/**
* Search listings using PostgreSQL full-text search + PostGIS.
* Parses the Typesense-style `filterBy` string to build SQL conditions.
*/
async search(params: SearchParams): Promise<SearchResult> {
const startMs = Date.now();
const page = params.page ?? 1;
const perPage = params.perPage ?? 20;
const offset = (page - 1) * perPage;
const conditions: Prisma.Sql[] = [Prisma.sql`l."status" = 'ACTIVE'`];
const parsed = this.parseFilterBy(params.filterBy ?? '');
// ── Parsed Typesense-style filters ─────────────────────────────────
if (parsed.propertyType) {
conditions.push(Prisma.sql`p."propertyType" = ${parsed.propertyType}`);
}
if (parsed.transactionType) {
conditions.push(Prisma.sql`l."transactionType" = ${parsed.transactionType}`);
}
if (parsed.priceMin !== undefined && parsed.priceMax !== undefined) {
conditions.push(Prisma.sql`l."priceVND" BETWEEN ${BigInt(parsed.priceMin)} AND ${BigInt(parsed.priceMax)}`);
} else if (parsed.priceMin !== undefined) {
conditions.push(Prisma.sql`l."priceVND" >= ${BigInt(parsed.priceMin)}`);
} else if (parsed.priceMax !== undefined) {
conditions.push(Prisma.sql`l."priceVND" <= ${BigInt(parsed.priceMax)}`);
}
if (parsed.areaMin !== undefined && parsed.areaMax !== undefined) {
conditions.push(Prisma.sql`p."areaM2" BETWEEN ${parsed.areaMin} AND ${parsed.areaMax}`);
} else if (parsed.areaMin !== undefined) {
conditions.push(Prisma.sql`p."areaM2" >= ${parsed.areaMin}`);
} else if (parsed.areaMax !== undefined) {
conditions.push(Prisma.sql`p."areaM2" <= ${parsed.areaMax}`);
}
if (parsed.bedrooms !== undefined) {
conditions.push(Prisma.sql`p."bedrooms" >= ${parsed.bedrooms}`);
}
if (parsed.district) {
conditions.push(Prisma.sql`p."district" = ${parsed.district}`);
}
if (parsed.city) {
conditions.push(Prisma.sql`p."city" = ${parsed.city}`);
}
// ── Geo radius filter (PostGIS) ────────────────────────────────────
if (params.geoPoint && params.geoRadiusKm) {
const radiusMeters = params.geoRadiusKm * 1000;
conditions.push(
Prisma.sql`ST_DWithin(
p."location"::geography,
ST_SetSRID(ST_MakePoint(${params.geoPoint.lng}, ${params.geoPoint.lat}), 4326)::geography,
${radiusMeters}
)`,
);
}
// ── Full-text search condition ─────────────────────────────────────
const hasTextQuery = params.query && params.query !== '*';
if (hasTextQuery) {
conditions.push(
Prisma.sql`(
to_tsvector('simple', coalesce(p."title", '') || ' ' || coalesce(p."description", '') || ' ' || coalesce(p."address", '') || ' ' || coalesce(p."district", '') || ' ' || coalesce(p."city", ''))
@@ plainto_tsquery('simple', ${params.query!})
)`,
);
}
const whereClause = Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`;
// ── Count total matches ────────────────────────────────────────────
const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>(
Prisma.sql`
SELECT COUNT(*) as count
FROM "Listing" l
JOIN "Property" p ON l."propertyId" = p."id"
${whereClause}
`,
);
const totalFound = Number(countResult[0]?.count ?? 0);
// ── Sorting ────────────────────────────────────────────────────────
let orderClause: Prisma.Sql;
if (params.geoPoint && (params.sortBy === 'distance' || (!params.sortBy && params.geoRadiusKm))) {
orderClause = Prisma.sql`ORDER BY ST_Distance(
p."location"::geography,
ST_SetSRID(ST_MakePoint(${params.geoPoint.lng}, ${params.geoPoint.lat}), 4326)::geography
) ASC`;
} else {
switch (params.sortBy) {
case 'price_asc':
orderClause = Prisma.sql`ORDER BY l."priceVND" ASC`;
break;
case 'price_desc':
orderClause = Prisma.sql`ORDER BY l."priceVND" DESC`;
break;
case 'date_desc':
orderClause = Prisma.sql`ORDER BY l."publishedAt" DESC NULLS LAST`;
break;
case 'relevance':
default:
if (hasTextQuery) {
orderClause = Prisma.sql`ORDER BY ts_rank(
to_tsvector('simple', coalesce(p."title", '') || ' ' || coalesce(p."description", '') || ' ' || coalesce(p."address", '') || ' ' || coalesce(p."district", '') || ' ' || coalesce(p."city", '')),
plainto_tsquery('simple', ${params.query!})
) DESC, l."publishedAt" DESC NULLS LAST`;
} else {
orderClause = Prisma.sql`ORDER BY l."publishedAt" DESC NULLS LAST`;
}
break;
}
}
// ── Fetch rows ─────────────────────────────────────────────────────
const rows = await this.prisma.$queryRaw<RawListingRow[]>(
Prisma.sql`
SELECT
l."id" AS "listingId",
l."propertyId" AS "propertyId",
p."title" AS "title",
p."description" AS "description",
p."propertyType" AS "propertyType",
l."transactionType" AS "transactionType",
l."priceVND" AS "priceVND",
l."pricePerM2" AS "pricePerM2",
p."areaM2" AS "areaM2",
p."bedrooms" AS "bedrooms",
p."bathrooms" AS "bathrooms",
p."floors" AS "floors",
p."direction" AS "direction",
p."address" AS "address",
p."ward" AS "ward",
p."district" AS "district",
p."city" AS "city",
ST_Y(p."location"::geometry) AS "lat",
ST_X(p."location"::geometry) AS "lng",
l."agentId" AS "agentId",
l."sellerId" AS "sellerId",
l."status" AS "status",
l."publishedAt" AS "publishedAt",
l."viewCount" AS "viewCount",
l."saveCount" AS "saveCount",
p."projectName" AS "projectName",
p."amenities" AS "amenities"
FROM "Listing" l
JOIN "Property" p ON l."propertyId" = p."id"
${whereClause}
${orderClause}
LIMIT ${perPage} OFFSET ${offset}
`,
);
const hits: ListingDocument[] = rows.map((row) => ({
id: row.listingId,
listingId: row.listingId,
propertyId: row.propertyId,
title: row.title,
description: row.description,
propertyType: row.propertyType,
transactionType: row.transactionType,
priceVND: Number(row.priceVND),
pricePerM2: row.pricePerM2 ? Number(row.pricePerM2) : null,
areaM2: Number(row.areaM2),
bedrooms: row.bedrooms,
bathrooms: row.bathrooms,
floors: row.floors,
direction: row.direction,
address: row.address,
ward: row.ward,
district: row.district,
city: row.city,
location: [row.lat ?? 0, row.lng ?? 0] as [number, number],
agentId: row.agentId,
sellerId: row.sellerId,
status: row.status,
publishedAt: row.publishedAt ? Math.floor(new Date(row.publishedAt).getTime() / 1000) : 0,
viewCount: row.viewCount ?? 0,
saveCount: row.saveCount ?? 0,
projectName: row.projectName,
amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [],
}));
const searchTimeMs = Date.now() - startMs;
return {
hits,
totalFound,
page,
perPage,
totalPages: Math.ceil(totalFound / perPage),
searchTimeMs,
};
}
// ── Indexing operations are no-ops for the PG fallback ───────────────
async indexDocument(_doc: ListingDocument): Promise<void> {
// Data already lives in PostgreSQL — nothing to do.
}
async indexDocuments(_docs: ListingDocument[]): Promise<void> {
// Data already lives in PostgreSQL — nothing to do.
}
async removeDocument(_id: string): Promise<void> {
// No separate index to clean up.
}
async ensureCollection(): Promise<void> {
// PostgreSQL tables/indexes are managed by Prisma migrations.
}
async dropCollection(): Promise<void> {
// Not applicable for PostgreSQL fallback.
}
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Minimal parser for the Typesense-style `filterBy` strings produced
* by the query handlers.
*
* Expected format examples:
* "status:=ACTIVE && propertyType:=HOUSE && priceVND:[2000..5000]"
* "status:=ACTIVE && priceVND:>=1000 && bedrooms:>=3"
*/
private parseFilterBy(filterStr: string): ParsedFilters {
const result: ParsedFilters = {};
if (!filterStr) return result;
const clauses = filterStr.split('&&').map((c) => c.trim());
for (const clause of clauses) {
// Range: field:[min..max]
const rangeMatch = clause.match(/^(\w+):\[(\d+)\.\.(\d+)\]$/);
if (rangeMatch) {
const field = rangeMatch[1]!;
const min = Number(rangeMatch[2]);
const max = Number(rangeMatch[3]);
if (field === 'priceVND') {
result.priceMin = min;
result.priceMax = max;
} else if (field === 'areaM2') {
result.areaMin = min;
result.areaMax = max;
}
continue;
}
// Equality: field:=value
const eqMatch = clause.match(/^(\w+):=(.+)$/);
if (eqMatch) {
const field = eqMatch[1]!;
const val = eqMatch[2]!;
if (field === 'propertyType') result.propertyType = val;
else if (field === 'transactionType') result.transactionType = val;
else if (field === 'district') result.district = val;
else if (field === 'city') result.city = val;
else if (field === 'status') { /* handled separately */ }
continue;
}
// Gte: field:>=value
const gteMatch = clause.match(/^(\w+):>=(\d+(?:\.\d+)?)$/);
if (gteMatch) {
const field = gteMatch[1]!;
const val = Number(gteMatch[2]);
if (field === 'priceVND') result.priceMin = val;
else if (field === 'areaM2') result.areaMin = val;
else if (field === 'bedrooms') result.bedrooms = val;
continue;
}
// Lte: field:<=value
const lteMatch = clause.match(/^(\w+):<=(\d+(?:\.\d+)?)$/);
if (lteMatch) {
const field = lteMatch[1]!;
const val = Number(lteMatch[2]);
if (field === 'priceVND') result.priceMax = val;
else if (field === 'areaM2') result.areaMax = val;
continue;
}
// Geo filter: location:(lat, lng, radius km) — skip, handled via params
}
return result;
}
}
interface ParsedFilters {
propertyType?: string;
transactionType?: string;
priceMin?: number;
priceMax?: number;
areaMin?: number;
areaMax?: number;
bedrooms?: number;
district?: string;
city?: string;
}
interface RawListingRow {
listingId: string;
propertyId: string;
title: string;
description: string;
propertyType: string;
transactionType: string;
priceVND: bigint;
pricePerM2: number | null;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
direction: string | null;
address: string;
ward: string;
district: string;
city: string;
lat: number | null;
lng: number | null;
agentId: string | null;
sellerId: string;
status: string;
publishedAt: Date | string | null;
viewCount: number;
saveCount: number;
projectName: string | null;
amenities: unknown;
}

View File

@@ -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,

View File

@@ -1 +1,2 @@
export { SearchController } from './search.controller';
export { SavedSearchController } from './saved-search.controller';

View File

@@ -0,0 +1,126 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
import { CreateSavedSearchCommand } from '../../application/commands/create-saved-search/create-saved-search.command';
import { type CreateSavedSearchResult } from '../../application/commands/create-saved-search/create-saved-search.handler';
import { DeleteSavedSearchCommand } from '../../application/commands/delete-saved-search/delete-saved-search.command';
import { UpdateSavedSearchCommand } from '../../application/commands/update-saved-search/update-saved-search.command';
import { type UpdateSavedSearchResult } from '../../application/commands/update-saved-search/update-saved-search.handler';
import { type SavedSearchDetail } from '../../application/queries/get-saved-search/get-saved-search.handler';
import { GetSavedSearchQuery } from '../../application/queries/get-saved-search/get-saved-search.query';
import { type SavedSearchListResult } from '../../application/queries/get-saved-searches/get-saved-searches.handler';
import { GetSavedSearchesQuery } from '../../application/queries/get-saved-searches/get-saved-searches.query';
import { type CreateSavedSearchDto, type UpdateSavedSearchDto, type SavedSearchListDto } from '../dto/saved-search.dto';
@ApiTags('saved-searches')
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Controller('saved-searches')
export class SavedSearchController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
@ApiOperation({ summary: 'Lưu tìm kiếm', description: 'Lưu bộ lọc tìm kiếm để nhận thông báo khi có kết quả mới' })
@ApiResponse({ status: 201, description: 'Tìm kiếm đã được lưu' })
@ApiResponse({ status: 400, description: 'Dữ liệu không hợp lệ' })
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
@ApiResponse({ status: 403, description: 'Đã đạt giới hạn gói đăng ký' })
async create(
@Body() dto: CreateSavedSearchDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateSavedSearchResult> {
return this.commandBus.execute(
new CreateSavedSearchCommand(
user.sub,
dto.name,
dto.filters,
dto.alertEnabled ?? true,
),
);
}
@Get()
@ApiOperation({ summary: 'Danh sách tìm kiếm đã lưu', description: 'Lấy danh sách tìm kiếm đã lưu của người dùng' })
@ApiResponse({ status: 200, description: 'Danh sách tìm kiếm đã lưu' })
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
async list(
@Query() dto: SavedSearchListDto,
@CurrentUser() user: JwtPayload,
): Promise<SavedSearchListResult> {
return this.queryBus.execute(
new GetSavedSearchesQuery(user.sub, dto.page ?? 1, dto.limit ?? 20),
);
}
@Get(':id')
@ApiOperation({ summary: 'Chi tiết tìm kiếm đã lưu', description: 'Lấy chi tiết một tìm kiếm đã lưu' })
@ApiParam({ name: 'id', description: 'ID tìm kiếm đã lưu' })
@ApiResponse({ status: 200, description: 'Chi tiết tìm kiếm đã lưu' })
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
@ApiResponse({ status: 404, description: 'Không tìm thấy' })
async getById(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<SavedSearchDetail> {
return this.queryBus.execute(
new GetSavedSearchQuery(id, user.sub),
);
}
@Patch(':id')
@ApiOperation({ summary: 'Cập nhật tìm kiếm đã lưu', description: 'Cập nhật tên, bộ lọc hoặc trạng thái thông báo' })
@ApiParam({ name: 'id', description: 'ID tìm kiếm đã lưu' })
@ApiResponse({ status: 200, description: 'Đã cập nhật' })
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
@ApiResponse({ status: 404, description: 'Không tìm thấy' })
async update(
@Param('id') id: string,
@Body() dto: UpdateSavedSearchDto,
@CurrentUser() user: JwtPayload,
): Promise<UpdateSavedSearchResult> {
return this.commandBus.execute(
new UpdateSavedSearchCommand(
id,
user.sub,
dto.name,
dto.filters,
dto.alertEnabled,
),
);
}
@Delete(':id')
@ApiOperation({ summary: 'Xóa tìm kiếm đã lưu', description: 'Xóa một tìm kiếm đã lưu' })
@ApiParam({ name: 'id', description: 'ID tìm kiếm đã lưu' })
@ApiResponse({ status: 200, description: 'Đã xóa' })
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
@ApiResponse({ status: 404, description: 'Không tìm thấy' })
async delete(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ deleted: boolean }> {
return this.commandBus.execute(
new DeleteSavedSearchCommand(id, user.sub),
);
}
}

View File

@@ -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';

View File

@@ -0,0 +1,63 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsInt,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
export class CreateSavedSearchDto {
@ApiProperty({ description: 'Tên tìm kiếm đã lưu', example: 'Chung cư Quận 7 dưới 3 tỷ' })
@IsNotEmpty({ message: 'Tên tìm kiếm không được để trống' })
@IsString()
@MaxLength(100, { message: 'Tên tìm kiếm không được vượt quá 100 ký tự' })
name!: string;
@ApiProperty({ description: 'Bộ lọc tìm kiếm (JSON)', example: { propertyType: 'apartment', district: 'Quan 7', priceMax: 3000000000 } })
@IsObject()
filters!: Record<string, unknown>;
@ApiPropertyOptional({ description: 'Bật thông báo khi có kết quả mới', default: true })
@IsOptional()
@IsBoolean()
alertEnabled?: boolean;
}
export class UpdateSavedSearchDto {
@ApiPropertyOptional({ description: 'Tên tìm kiếm đã lưu', example: 'Chung cư Quận 2' })
@IsOptional()
@IsString()
@MaxLength(100, { message: 'Tên tìm kiếm không được vượt quá 100 ký tự' })
name?: string;
@ApiPropertyOptional({ description: 'Bộ lọc tìm kiếm (JSON)' })
@IsOptional()
@IsObject()
filters?: Record<string, unknown>;
@ApiPropertyOptional({ description: 'Bật/tắt thông báo' })
@IsOptional()
@IsBoolean()
alertEnabled?: boolean;
}
export class SavedSearchListDto {
@ApiPropertyOptional({ description: 'Trang', default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ description: 'Số lượng mỗi trang', default: 20, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number;
}

View File

@@ -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',
);
}

View File

@@ -8,10 +8,12 @@ describe('CacheService', () => {
set: ReturnType<typeof vi.fn>;
del: ReturnType<typeof vi.fn>;
getClient: ReturnType<typeof vi.fn>;
isAvailable: ReturnType<typeof vi.fn>;
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
let mockHitCounter: { inc: ReturnType<typeof vi.fn> };
let mockMissCounter: { inc: ReturnType<typeof vi.fn> };
let mockDegradationCounter: { inc: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRedis = {
@@ -22,16 +24,19 @@ describe('CacheService', () => {
scan: vi.fn().mockResolvedValue(['0', []]),
del: vi.fn(),
}),
isAvailable: vi.fn().mockReturnValue(true),
};
mockLogger = { log: vi.fn(), warn: vi.fn() };
mockHitCounter = { inc: vi.fn() };
mockMissCounter = { inc: vi.fn() };
mockDegradationCounter = { inc: vi.fn() };
cacheService = new CacheService(
mockRedis as any,
mockLogger as any,
mockHitCounter as any,
mockMissCounter as any,
mockDegradationCounter as any,
);
});
@@ -91,6 +96,39 @@ describe('CacheService', () => {
await expect(cacheService.getOrSet('key', loader, 60, 'listing')).rejects.toThrow('not found');
});
it('should skip Redis and call loader directly when Redis is unavailable', async () => {
mockRedis.isAvailable.mockReturnValue(false);
const data = { id: 'direct' };
const loader = vi.fn().mockResolvedValue(data);
const result = await cacheService.getOrSet('key', loader, 60, 'listing');
expect(result).toEqual(data);
expect(mockRedis.get).not.toHaveBeenCalled();
expect(mockRedis.set).not.toHaveBeenCalled();
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'listing', operation: 'skip_unavailable' });
expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
});
it('should track degradation on cache read error', async () => {
mockRedis.get.mockRejectedValue(new Error('connection lost'));
const loader = vi.fn().mockResolvedValue({ ok: true });
await cacheService.getOrSet('key', loader, 60, 'search');
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'search', operation: 'read_error' });
});
it('should track degradation on cache write error', async () => {
mockRedis.get.mockResolvedValue(null);
mockRedis.set.mockRejectedValue(new Error('write error'));
const loader = vi.fn().mockResolvedValue({ ok: true });
await cacheService.getOrSet('key', loader, 60, 'search');
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'search', operation: 'write_error' });
});
});
describe('invalidate', () => {

View File

@@ -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);
});
});

View File

@@ -0,0 +1,241 @@
import { HttpException, HttpStatus, type ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import {
UserRateLimitGuard,
DEFAULT_ROLE_LIMITS,
DEFAULT_WINDOW_SECONDS,
} from '../guards/user-rate-limit.guard';
// ── helpers ──────────────────────────────────────────────────────────────────
function mockRedis(overrides: Partial<{ evalResult: [number, number]; throwError: boolean }> = {}) {
const evalFn = overrides.throwError
? vi.fn().mockRejectedValue(new Error('Redis connection lost'))
: vi.fn().mockResolvedValue(overrides.evalResult ?? [1, 60]);
return {
getClient: vi.fn().mockReturnValue({ eval: evalFn }),
isAvailable: vi.fn().mockReturnValue(!overrides.throwError),
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
onModuleDestroy: vi.fn(),
} as any;
}
function mockLogger() {
return {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as any;
}
function mockReflector(options?: any) {
return {
getAllAndOverride: vi.fn().mockReturnValue(options ?? undefined),
} as unknown as Reflector;
}
interface MockContextOptions {
user?: { sub: string; role: string } | null;
handler?: string;
controller?: string;
}
function buildContext(opts: MockContextOptions = {}): ExecutionContext {
const headers: Record<string, string> = {};
const response = { setHeader: vi.fn() };
const user = 'user' in opts ? opts.user : { sub: 'user-123', role: 'BUYER' };
return {
switchToHttp: () => ({
getRequest: () => ({
user,
headers,
}),
getResponse: () => response,
}),
getHandler: () => ({ name: opts.handler ?? 'testHandler' }),
getClass: () => ({ name: opts.controller ?? 'TestController' }),
} as unknown as ExecutionContext;
}
// ── tests ────────────────────────────────────────────────────────────────────
describe('UserRateLimitGuard', () => {
it('allows request when user is within rate limit', async () => {
const redis = mockRedis({ evalResult: [1, 60] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const result = await guard.canActivate(buildContext());
expect(result).toBe(true);
});
it('passes through for unauthenticated requests (no user)', async () => {
const redis = mockRedis();
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const result = await guard.canActivate(buildContext({ user: null }));
expect(result).toBe(true);
});
it('passes through for requests without user.sub', async () => {
const redis = mockRedis();
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const result = await guard.canActivate(
buildContext({ user: { sub: '', role: 'BUYER' } }),
);
expect(result).toBe(true);
});
it('rejects request with 429 when rate limit exceeded', async () => {
const redis = mockRedis({ evalResult: [101, 45] }); // 101 > BUYER limit of 100
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const ctx = buildContext();
await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException);
try {
await guard.canActivate(ctx);
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
const body = (error as HttpException).getResponse();
expect(body).toMatchObject({
statusCode: 429,
retryAfter: 45,
});
}
});
it('sets rate limit headers on the response', async () => {
const redis = mockRedis({ evalResult: [50, 30] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const ctx = buildContext();
await guard.canActivate(ctx);
const response = ctx.switchToHttp().getResponse();
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', DEFAULT_ROLE_LIMITS.BUYER);
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', 50); // 100 - 50
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Reset', 30);
});
it('sets Retry-After header when limit exceeded', async () => {
const redis = mockRedis({ evalResult: [201, 42] }); // Exceeds AGENT limit of 200
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const ctx = buildContext({ user: { sub: 'agent-1', role: 'AGENT' } });
try {
await guard.canActivate(ctx);
} catch {
// expected
}
const response = ctx.switchToHttp().getResponse();
expect(response.setHeader).toHaveBeenCalledWith('Retry-After', 42);
});
it('uses role-specific limits (ADMIN gets 500)', async () => {
const redis = mockRedis({ evalResult: [450, 20] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const result = await guard.canActivate(
buildContext({ user: { sub: 'admin-1', role: 'ADMIN' } }),
);
expect(result).toBe(true); // 450 < 500 ADMIN limit
});
it('uses role-specific limits (AGENT gets 200)', async () => {
const redis = mockRedis({ evalResult: [199, 10] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const result = await guard.canActivate(
buildContext({ user: { sub: 'agent-1', role: 'AGENT' } }),
);
expect(result).toBe(true); // 199 < 200 AGENT limit
});
it('uses per-route overrides from @UserRateLimit decorator', async () => {
const redis = mockRedis({ evalResult: [51, 30] });
// Override BUYER limit to 50 for this route
const reflector = mockReflector({ limits: { BUYER: 50 }, windowSeconds: 120 });
const guard = new UserRateLimitGuard(redis, reflector, mockLogger());
await expect(guard.canActivate(buildContext())).rejects.toThrow(HttpException);
});
it('passes Redis window seconds argument correctly', async () => {
const redis = mockRedis({ evalResult: [1, 120] });
const reflector = mockReflector({ windowSeconds: 120 });
const guard = new UserRateLimitGuard(redis, reflector, mockLogger());
await guard.canActivate(buildContext());
const evalCall = redis.getClient().eval.mock.calls[0];
expect(evalCall[3]).toBe(120); // windowSeconds arg passed to Lua
});
it('uses correct Redis key format with user ID', async () => {
const redis = mockRedis({ evalResult: [1, 60] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
await guard.canActivate(buildContext({ user: { sub: 'user-xyz', role: 'BUYER' } }));
const evalCall = redis.getClient().eval.mock.calls[0];
expect(evalCall[2]).toBe('rate_limit:user:user-xyz');
});
it('fails open when Redis throws an error', async () => {
const redis = mockRedis({ throwError: true });
const logger = mockLogger();
const guard = new UserRateLimitGuard(redis, mockReflector(), logger);
const result = await guard.canActivate(buildContext());
expect(result).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('User rate limit check failed'),
'UserRateLimitGuard',
);
});
it('logs warning when rate limit exceeded', async () => {
const redis = mockRedis({ evalResult: [101, 55] });
const logger = mockLogger();
const guard = new UserRateLimitGuard(redis, mockReflector(), logger);
try {
await guard.canActivate(buildContext());
} catch {
// expected
}
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('User rate limit exceeded'),
'UserRateLimitGuard',
);
});
it('uses DEFAULT_WINDOW_SECONDS when no override provided', async () => {
const redis = mockRedis({ evalResult: [1, DEFAULT_WINDOW_SECONDS] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
await guard.canActivate(buildContext());
const evalCall = redis.getClient().eval.mock.calls[0];
expect(evalCall[3]).toBe(DEFAULT_WINDOW_SECONDS);
});
it('defaults to BUYER limit for unknown roles', async () => {
const redis = mockRedis({ evalResult: [101, 30] }); // > BUYER's 100
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
// Force an unexpected role value
await expect(
guard.canActivate(buildContext({ user: { sub: 'u1', role: 'UNKNOWN_ROLE' } })),
).rejects.toThrow(HttpException);
});
});

View File

@@ -1,11 +1,15 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { type Counter } from 'prom-client';
import { type LoggerService } from './logger.service';
import { type RedisService } from './redis.service';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { Counter } from 'prom-client';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { LoggerService } from './logger.service';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { RedisService } from './redis.service';
export const CACHE_HIT_TOTAL = 'cache_hit_total';
export const CACHE_MISS_TOTAL = 'cache_miss_total';
export const CACHE_DEGRADATION_TOTAL = 'cache_degradation_total';
export const CacheTTL = {
/** Listing detail — moderate TTL, invalidated on mutation */
@@ -52,6 +56,7 @@ export class CacheService implements OnModuleInit {
private readonly logger: LoggerService,
@InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter,
@InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter,
@InjectMetric(CACHE_DEGRADATION_TOTAL) private readonly cacheDegradationCounter: Counter,
) {}
onModuleInit(): void {
@@ -60,6 +65,9 @@ export class CacheService implements OnModuleInit {
/**
* Cache-aside: get from cache, or execute loader and store result.
*
* When Redis is down the loader is called directly (graceful degradation).
* Degradation events are counted via `cache_degradation_total` for alerting.
*/
async getOrSet<T>(
key: string,
@@ -67,6 +75,13 @@ export class CacheService implements OnModuleInit {
ttlSeconds: number,
resource: string,
): Promise<T> {
// Fast-path: skip Redis entirely when it is known to be disconnected.
if (!this.redis.isAvailable()) {
this.cacheDegradationCounter.inc({ resource, operation: 'skip_unavailable' });
this.cacheMissCounter.inc({ resource });
return loader();
}
try {
const cached = await this.redis.get(key);
if (cached !== null) {
@@ -74,6 +89,7 @@ export class CacheService implements OnModuleInit {
return JSON.parse(cached) as T;
}
} catch (err) {
this.cacheDegradationCounter.inc({ resource, operation: 'read_error' });
this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
}
@@ -83,6 +99,7 @@ export class CacheService implements OnModuleInit {
try {
await this.redis.set(key, JSON.stringify(result), ttlSeconds);
} catch (err) {
this.cacheDegradationCounter.inc({ resource, operation: 'write_error' });
this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
}
@@ -94,6 +111,7 @@ export class CacheService implements OnModuleInit {
try {
await this.redis.del(key);
} catch (err) {
this.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'invalidate_error' });
this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService');
}
}
@@ -111,6 +129,7 @@ export class CacheService implements OnModuleInit {
}
} while (cursor !== '0');
} catch (err) {
this.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'prefix_invalidate_error' });
this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService');
}
}

View File

@@ -0,0 +1,147 @@
/**
* Lightweight circuit breaker for external service calls.
*
* States:
* CLOSED → normal operation; failures increment counter
* OPEN → calls fail fast; after `resetTimeMs` → HALF_OPEN
* HALF_OPEN → one probe call allowed; success → CLOSED, failure → OPEN
*
* Does NOT depend on any NestJS or framework-specific API so it can
* be used as a plain utility across the codebase.
*/
export enum CircuitState {
CLOSED = 'CLOSED',
OPEN = 'OPEN',
HALF_OPEN = 'HALF_OPEN',
}
export interface CircuitBreakerOptions {
/** Human-readable service name — used in logs / metrics. */
name: string;
/** Number of consecutive failures before opening the circuit. Default 5. */
failureThreshold?: number;
/** Time in ms to wait before transitioning from OPEN → HALF_OPEN. Default 30 000 (30s). */
resetTimeMs?: number;
/** Optional callback fired on every state transition. */
onStateChange?: (from: CircuitState, to: CircuitState, name: string) => void;
}
export class CircuitBreaker {
readonly name: string;
private state = CircuitState.CLOSED;
private failureCount = 0;
private lastFailureTime = 0;
private readonly failureThreshold: number;
private readonly resetTimeMs: number;
private readonly onStateChange?: (from: CircuitState, to: CircuitState, name: string) => void;
constructor(options: CircuitBreakerOptions) {
this.name = options.name;
this.failureThreshold = options.failureThreshold ?? 5;
this.resetTimeMs = options.resetTimeMs ?? 30_000;
this.onStateChange = options.onStateChange;
}
/** Current state of the circuit. */
getState(): CircuitState {
this.evaluateState();
return this.state;
}
/** Whether the circuit allows a call to pass through. */
isAvailable(): boolean {
this.evaluateState();
return this.state !== CircuitState.OPEN;
}
/**
* Execute `fn` through the breaker.
*
* - CLOSED / HALF_OPEN → `fn` is called.
* - OPEN → immediately throws `CircuitOpenError`.
*
* On success the breaker resets to CLOSED.
* On failure the breaker increments the failure counter (CLOSED)
* or re-opens (HALF_OPEN).
*/
async exec<T>(fn: () => Promise<T>): Promise<T> {
this.evaluateState();
if (this.state === CircuitState.OPEN) {
throw new CircuitOpenError(this.name);
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
/** Record a manual success (e.g. from a health-check probe). */
recordSuccess(): void {
this.onSuccess();
}
/** Record a manual failure. */
recordFailure(): void {
this.onFailure();
}
/** Force-reset the breaker to CLOSED. */
reset(): void {
this.transitionTo(CircuitState.CLOSED);
this.failureCount = 0;
this.lastFailureTime = 0;
}
// -------------------------------------------------------------------------
private evaluateState(): void {
if (this.state === CircuitState.OPEN) {
const elapsed = Date.now() - this.lastFailureTime;
if (elapsed >= this.resetTimeMs) {
this.transitionTo(CircuitState.HALF_OPEN);
}
}
}
private onSuccess(): void {
if (this.state !== CircuitState.CLOSED) {
this.transitionTo(CircuitState.CLOSED);
}
this.failureCount = 0;
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.state === CircuitState.HALF_OPEN) {
this.transitionTo(CircuitState.OPEN);
} else if (this.failureCount >= this.failureThreshold) {
this.transitionTo(CircuitState.OPEN);
}
}
private transitionTo(next: CircuitState): void {
if (this.state === next) return;
const prev = this.state;
this.state = next;
this.onStateChange?.(prev, next, this.name);
}
}
/** Thrown when a call is attempted while the circuit is OPEN. */
export class CircuitOpenError extends Error {
constructor(public readonly serviceName: string) {
super(`Circuit breaker OPEN for service: ${serviceName}`);
this.name = 'CircuitOpenError';
}
}

View File

@@ -0,0 +1,21 @@
import { SetMetadata } from '@nestjs/common';
import { type UserRole } from '@prisma/client';
import { USER_RATE_LIMIT_KEY, type UserRateLimitOptions } from '../guards/user-rate-limit.guard';
/**
* Decorator to override per-user rate limits for a specific route or controller.
*
* When not applied, the UserRateLimitGuard uses DEFAULT_ROLE_LIMITS.
*
* @example
* // Override BUYER limit to 50 req/min for an expensive endpoint
* @UserRateLimit({ limits: { BUYER: 50 }, windowSeconds: 60 })
*
* @example
* // Allow higher limits for a specific route
* @UserRateLimit({ limits: { BUYER: 200, AGENT: 400 } })
*/
export const UserRateLimit = (options: {
limits?: Partial<Record<UserRole, number>>;
windowSeconds?: number;
}) => SetMetadata<string, UserRateLimitOptions>(USER_RATE_LIMIT_KEY, options);

View File

@@ -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()

View File

@@ -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';

View File

@@ -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

View File

@@ -0,0 +1,143 @@
import {
Injectable,
type CanActivate,
type ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { type Reflector } from '@nestjs/core';
import { type UserRole } from '@prisma/client';
import { type LoggerService } from '../logger.service';
import { type RedisService } from '../redis.service';
/**
* Role-based rate limits (requests per window).
* Applied only to authenticated routes that use JwtAuthGuard.
*/
export const DEFAULT_ROLE_LIMITS: Record<UserRole, number> = {
BUYER: 100,
SELLER: 150,
AGENT: 200,
ADMIN: 500,
};
/** Default sliding window in seconds. */
export const DEFAULT_WINDOW_SECONDS = 60;
/** Metadata key for per-route overrides via @UserRateLimit decorator. */
export const USER_RATE_LIMIT_KEY = 'user_rate_limit';
export interface UserRateLimitOptions {
/** Override limits per role for this route. Roles not listed use DEFAULT_ROLE_LIMITS. */
limits?: Partial<Record<UserRole, number>>;
/** Window in seconds (default 60). */
windowSeconds?: number;
}
/**
* Guard that enforces per-user (by user ID) rate limiting for authenticated routes.
*
* Uses a Redis sliding-window counter keyed by `rate_limit:user:{userId}`.
* Falls back to allowing the request if Redis is unavailable (fail-open)
* to avoid blocking legitimate traffic during cache outages.
*
* This guard checks `request.user` — it must run AFTER JwtAuthGuard.
* If no authenticated user is found, the guard passes (unauthenticated
* routes are handled by the existing IP-based ThrottlerBehindProxyGuard).
*/
@Injectable()
export class UserRateLimitGuard implements CanActivate {
constructor(
private readonly redis: RedisService,
private readonly reflector: Reflector,
private readonly logger: LoggerService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
// Skip for unauthenticated requests — IP-based throttler handles those
if (!user?.sub || !user?.role) {
return true;
}
const userId: string = user.sub;
const role: UserRole = user.role;
// Resolve per-route overrides
const options = this.reflector.getAllAndOverride<UserRateLimitOptions | undefined>(
USER_RATE_LIMIT_KEY,
[context.getHandler(), context.getClass()],
);
const windowSeconds = options?.windowSeconds ?? DEFAULT_WINDOW_SECONDS;
const limit = options?.limits?.[role] ?? DEFAULT_ROLE_LIMITS[role] ?? DEFAULT_ROLE_LIMITS.BUYER;
const key = `rate_limit:user:${userId}`;
try {
const client = this.redis.getClient();
// Atomic increment + conditional TTL set via Lua script.
// Uses INCR + conditional EXPIRE to avoid race conditions.
const result = await client.eval(
`local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {current, ttl}`,
1,
key,
windowSeconds,
) as [number, number];
const current = result[0];
const ttl = result[1];
const response = context.switchToHttp().getResponse();
// Always set rate limit headers for observability
response.setHeader('X-RateLimit-Limit', limit);
response.setHeader('X-RateLimit-Remaining', Math.max(0, limit - current));
response.setHeader('X-RateLimit-Reset', ttl > 0 ? ttl : windowSeconds);
if (current > limit) {
const retryAfter = ttl > 0 ? ttl : windowSeconds;
response.setHeader('Retry-After', retryAfter);
this.logger.warn(
`User rate limit exceeded: userId=${userId}, role=${role}, ` +
`current=${current}/${limit}, retryAfter=${retryAfter}s`,
'UserRateLimitGuard',
);
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: 'Too many requests. Please try again later.',
retryAfter,
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
return true;
} catch (error) {
// Re-throw 429 errors — they are intentional
if (error instanceof HttpException && error.getStatus() === HttpStatus.TOO_MANY_REQUESTS) {
throw error;
}
// Fail open on Redis errors — log and allow the request
this.logger.warn(
`User rate limit check failed (Redis error), allowing request: userId=${userId}, error=${
error instanceof Error ? error.message : 'unknown'
}`,
'UserRateLimitGuard',
);
return true;
}
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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()

View File

@@ -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 = {

View File

@@ -1,6 +1,26 @@
import { Injectable, type OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
/**
* Thin wrapper around ioredis.
*
* Uses `lazyConnect: true` so the app starts even if Redis is unreachable.
* All call-sites (CacheService, health checks) already handle failures
* gracefully — if Redis is down the app serves data directly from the DB.
*
* `enableReadyCheck: false` prevents ioredis from throwing
* "Redis is not ready" errors during transient outages, allowing
* individual commands to fail with a standard connection error
* that the CacheService catches.
*
* `maxRetriesPerRequest: 1` ensures commands fail fast (single retry)
* instead of blocking the event loop with exponential backoff.
* The CacheService already treats Redis errors as non-fatal.
*
* `retryStrategy` implements bounded reconnection: waits 1 s then 2 s
* then 3 s up to 5 s max, ensuring the client keeps trying to reconnect
* without flooding the server.
*/
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly client: Redis;
@@ -11,6 +31,11 @@ export class RedisService implements OnModuleDestroy {
port: Number(process.env['REDIS_PORT'] ?? 6379),
password: process.env['REDIS_PASSWORD'] ?? undefined,
lazyConnect: true,
enableReadyCheck: false,
maxRetriesPerRequest: 1,
retryStrategy(times: number): number {
return Math.min(times * 1000, 5000);
},
});
}
@@ -22,6 +47,14 @@ export class RedisService implements OnModuleDestroy {
return this.client;
}
/**
* Quick health probe — returns true when the Redis connection is
* in a usable state (`ready` or `connect`).
*/
isAvailable(): boolean {
return this.client.status === 'ready' || this.client.status === 'connect';
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}

View File

@@ -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 {

View File

@@ -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';