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

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