fix: apply consistent-type-imports across API codebase (728 lint errors)
- Convert `import type { X }` to `import { type X }` (inline-type-imports style)
- Suppress consistent-type-imports for `typeof import()` in instrument.ts
- Includes uncommitted agent work: metrics module, redis caching, audit logs,
saved searches, circuit breaker, rate limiting, and admin enhancements
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { CreateSavedSearchCommand } from '../commands/create-saved-search/create-saved-search.command';
|
||||
import { CreateSavedSearchHandler } from '../commands/create-saved-search/create-saved-search.handler';
|
||||
|
||||
describe('CreateSavedSearchHandler', () => {
|
||||
let handler: CreateSavedSearchHandler;
|
||||
let mockPrisma: any;
|
||||
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
savedSearch: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockQueryBus = { execute: vi.fn() };
|
||||
mockCommandBus = { execute: vi.fn() };
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
handler = new CreateSavedSearchHandler(
|
||||
mockPrisma,
|
||||
mockQueryBus as any,
|
||||
mockCommandBus as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a saved search successfully', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
metric: 'searches_saved',
|
||||
limit: 10,
|
||||
used: 2,
|
||||
remaining: 8,
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
mockPrisma.savedSearch.create.mockResolvedValue({
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Chung cư Q7',
|
||||
filters: { district: 'Quan 7', propertyType: 'APARTMENT' },
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
mockCommandBus.execute.mockResolvedValue({ usageRecordId: 'usage-1' });
|
||||
|
||||
const command = new CreateSavedSearchCommand(
|
||||
'user-1',
|
||||
'Chung cư Q7',
|
||||
{ district: 'Quan 7', propertyType: 'APARTMENT' },
|
||||
true,
|
||||
);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.name).toBe('Chung cư Q7');
|
||||
expect(result.alertEnabled).toBe(true);
|
||||
expect(mockPrisma.savedSearch.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); // Usage metering
|
||||
});
|
||||
|
||||
it('throws when name is empty', async () => {
|
||||
const command = new CreateSavedSearchCommand('user-1', '', {}, true);
|
||||
await expect(handler.execute(command)).rejects.toThrow('Tên tìm kiếm không được để trống');
|
||||
});
|
||||
|
||||
it('throws when name exceeds 100 characters', async () => {
|
||||
const longName = 'a'.repeat(101);
|
||||
const command = new CreateSavedSearchCommand('user-1', longName, {}, true);
|
||||
await expect(handler.execute(command)).rejects.toThrow('Tên tìm kiếm không được vượt quá 100 ký tự');
|
||||
});
|
||||
|
||||
it('throws when quota is exceeded', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
metric: 'searches_saved',
|
||||
limit: 5,
|
||||
used: 5,
|
||||
remaining: 0,
|
||||
allowed: false,
|
||||
});
|
||||
|
||||
const command = new CreateSavedSearchCommand('user-1', 'Test', {}, true);
|
||||
await expect(handler.execute(command)).rejects.toThrow('giới hạn');
|
||||
});
|
||||
|
||||
it('continues even when usage metering fails', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
metric: 'searches_saved',
|
||||
limit: 10,
|
||||
used: 2,
|
||||
remaining: 8,
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
mockPrisma.savedSearch.create.mockResolvedValue({
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Test',
|
||||
filters: {},
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
mockCommandBus.execute.mockRejectedValue(new Error('Metering failed'));
|
||||
|
||||
const command = new CreateSavedSearchCommand('user-1', 'Test', {}, true);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.id).toBe('saved-1');
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Usage metering failed'),
|
||||
'CreateSavedSearchHandler',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DeleteSavedSearchCommand } from '../commands/delete-saved-search/delete-saved-search.command';
|
||||
import { DeleteSavedSearchHandler } from '../commands/delete-saved-search/delete-saved-search.handler';
|
||||
|
||||
describe('DeleteSavedSearchHandler', () => {
|
||||
let handler: DeleteSavedSearchHandler;
|
||||
let mockPrisma: any;
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
savedSearch: {
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
handler = new DeleteSavedSearchHandler(mockPrisma, mockLogger as any);
|
||||
});
|
||||
|
||||
it('deletes a saved search owned by the user', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue({
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Test',
|
||||
});
|
||||
mockPrisma.savedSearch.delete.mockResolvedValue({ id: 'saved-1' });
|
||||
|
||||
const command = new DeleteSavedSearchCommand('saved-1', 'user-1');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.deleted).toBe(true);
|
||||
expect(mockPrisma.savedSearch.delete).toHaveBeenCalledWith({ where: { id: 'saved-1' } });
|
||||
});
|
||||
|
||||
it('throws NotFoundException when saved search does not exist', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(null);
|
||||
|
||||
const command = new DeleteSavedSearchCommand('non-existent', 'user-1');
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when user does not own the saved search', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue({
|
||||
id: 'saved-1',
|
||||
userId: 'other-user',
|
||||
name: 'Test',
|
||||
});
|
||||
|
||||
const command = new DeleteSavedSearchCommand('saved-1', 'user-1');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Bạn không có quyền xóa tìm kiếm này');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { GetSavedSearchHandler } from '../queries/get-saved-search/get-saved-search.handler';
|
||||
import { GetSavedSearchQuery } from '../queries/get-saved-search/get-saved-search.query';
|
||||
|
||||
describe('GetSavedSearchHandler', () => {
|
||||
let handler: GetSavedSearchHandler;
|
||||
let mockPrisma: any;
|
||||
|
||||
const existingSearch = {
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Chung cư Q7',
|
||||
filters: { district: 'Quan 7' },
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: new Date('2026-01-15'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
savedSearch: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
handler = new GetSavedSearchHandler(mockPrisma);
|
||||
});
|
||||
|
||||
it('returns saved search detail for the owner', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
|
||||
|
||||
const query = new GetSavedSearchQuery('saved-1', 'user-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.id).toBe('saved-1');
|
||||
expect(result.name).toBe('Chung cư Q7');
|
||||
expect(result.alertEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when saved search does not exist', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(null);
|
||||
|
||||
const query = new GetSavedSearchQuery('non-existent', 'user-1');
|
||||
await expect(handler.execute(query)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when user does not own the saved search', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue({
|
||||
...existingSearch,
|
||||
userId: 'other-user',
|
||||
});
|
||||
|
||||
const query = new GetSavedSearchQuery('saved-1', 'user-1');
|
||||
await expect(handler.execute(query)).rejects.toThrow('Bạn không có quyền xem tìm kiếm này');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { GetSavedSearchesHandler } from '../queries/get-saved-searches/get-saved-searches.handler';
|
||||
import { GetSavedSearchesQuery } from '../queries/get-saved-searches/get-saved-searches.query';
|
||||
|
||||
describe('GetSavedSearchesHandler', () => {
|
||||
let handler: GetSavedSearchesHandler;
|
||||
let mockPrisma: any;
|
||||
|
||||
const savedSearches = [
|
||||
{
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Chung cư Q7',
|
||||
filters: { district: 'Quan 7' },
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: new Date('2026-01-15'),
|
||||
},
|
||||
{
|
||||
id: 'saved-2',
|
||||
userId: 'user-1',
|
||||
name: 'Nhà phố Q2',
|
||||
filters: { district: 'Quan 2', propertyType: 'HOUSE' },
|
||||
alertEnabled: false,
|
||||
lastAlertAt: new Date('2026-01-20'),
|
||||
createdAt: new Date('2026-01-10'),
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
savedSearch: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
handler = new GetSavedSearchesHandler(mockPrisma);
|
||||
});
|
||||
|
||||
it('returns paginated saved searches', async () => {
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue(savedSearches);
|
||||
mockPrisma.savedSearch.count.mockResolvedValue(2);
|
||||
|
||||
const query = new GetSavedSearchesQuery('user-1', 1, 20);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
expect(result.data[0]!.name).toBe('Chung cư Q7');
|
||||
});
|
||||
|
||||
it('applies pagination correctly', async () => {
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([savedSearches[1]]);
|
||||
mockPrisma.savedSearch.count.mockResolvedValue(2);
|
||||
|
||||
const query = new GetSavedSearchesQuery('user-1', 2, 1);
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockPrisma.savedSearch.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 1,
|
||||
take: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty list when no saved searches', async () => {
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
|
||||
mockPrisma.savedSearch.count.mockResolvedValue(0);
|
||||
|
||||
const query = new GetSavedSearchesQuery('user-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.data).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { UpdateSavedSearchCommand } from '../commands/update-saved-search/update-saved-search.command';
|
||||
import { UpdateSavedSearchHandler } from '../commands/update-saved-search/update-saved-search.handler';
|
||||
|
||||
describe('UpdateSavedSearchHandler', () => {
|
||||
let handler: UpdateSavedSearchHandler;
|
||||
let mockPrisma: any;
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
const existingSearch = {
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Original Name',
|
||||
filters: { district: 'Quan 7' },
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
savedSearch: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
handler = new UpdateSavedSearchHandler(mockPrisma, mockLogger as any);
|
||||
});
|
||||
|
||||
it('updates the name of a saved search', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
|
||||
mockPrisma.savedSearch.update.mockResolvedValue({
|
||||
...existingSearch,
|
||||
name: 'New Name',
|
||||
});
|
||||
|
||||
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', 'New Name');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.name).toBe('New Name');
|
||||
expect(mockPrisma.savedSearch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'saved-1' },
|
||||
data: { name: 'New Name' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates alertEnabled', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
|
||||
mockPrisma.savedSearch.update.mockResolvedValue({
|
||||
...existingSearch,
|
||||
alertEnabled: false,
|
||||
});
|
||||
|
||||
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', undefined, undefined, false);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.alertEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when saved search does not exist', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(null);
|
||||
|
||||
const command = new UpdateSavedSearchCommand('non-existent', 'user-1', 'New Name');
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when user does not own the saved search', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue({
|
||||
...existingSearch,
|
||||
userId: 'other-user',
|
||||
});
|
||||
|
||||
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', 'New Name');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Bạn không có quyền cập nhật tìm kiếm này');
|
||||
});
|
||||
|
||||
it('throws ValidationException when name is empty', async () => {
|
||||
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
|
||||
|
||||
const command = new UpdateSavedSearchCommand('saved-1', 'user-1', '');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Tên tìm kiếm không được để trống');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export class CreateSavedSearchCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly name: string,
|
||||
public readonly filters: Record<string, unknown>,
|
||||
public readonly alertEnabled: boolean,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CommandHandler, type CommandBus, type ICommandHandler, type QueryBus } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { type SavedSearch, type Prisma } from '@prisma/client';
|
||||
import { ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import { CheckQuotaQuery, type QuotaCheckResult, MeterUsageCommand } from '@modules/subscriptions';
|
||||
import { CreateSavedSearchCommand } from './create-saved-search.command';
|
||||
|
||||
export interface CreateSavedSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: unknown;
|
||||
alertEnabled: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@CommandHandler(CreateSavedSearchCommand)
|
||||
export class CreateSavedSearchHandler implements ICommandHandler<CreateSavedSearchCommand> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateSavedSearchCommand): Promise<CreateSavedSearchResult> {
|
||||
// Validate name
|
||||
if (!command.name || command.name.trim().length === 0) {
|
||||
throw new ValidationException('Tên tìm kiếm không được để trống');
|
||||
}
|
||||
|
||||
if (command.name.trim().length > 100) {
|
||||
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
|
||||
}
|
||||
|
||||
// Check quota
|
||||
const quotaResult: QuotaCheckResult = await this.queryBus.execute(
|
||||
new CheckQuotaQuery(command.userId, 'searches_saved'),
|
||||
);
|
||||
|
||||
if (!quotaResult.allowed) {
|
||||
throw new ValidationException(
|
||||
`Bạn đã đạt giới hạn ${quotaResult.limit} tìm kiếm đã lưu. Vui lòng nâng cấp gói để tiếp tục.`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = createId();
|
||||
const savedSearch: SavedSearch = await this.prisma.savedSearch.create({
|
||||
data: {
|
||||
id,
|
||||
userId: command.userId,
|
||||
name: command.name.trim(),
|
||||
filters: command.filters as Prisma.InputJsonValue,
|
||||
alertEnabled: command.alertEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
// Best-effort usage metering
|
||||
try {
|
||||
await this.commandBus.execute(
|
||||
new MeterUsageCommand(command.userId, 'searches_saved', 1),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Usage metering failed for saved search: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'CreateSavedSearchHandler',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Saved search created: id=${id}, user=${command.userId}`, 'CreateSavedSearchHandler');
|
||||
|
||||
return {
|
||||
id: savedSearch.id,
|
||||
name: savedSearch.name,
|
||||
filters: savedSearch.filters,
|
||||
alertEnabled: savedSearch.alertEnabled,
|
||||
createdAt: savedSearch.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DeleteSavedSearchCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ForbiddenException, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import { DeleteSavedSearchCommand } from './delete-saved-search.command';
|
||||
|
||||
@CommandHandler(DeleteSavedSearchCommand)
|
||||
export class DeleteSavedSearchHandler implements ICommandHandler<DeleteSavedSearchCommand> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteSavedSearchCommand): Promise<{ deleted: boolean }> {
|
||||
const savedSearch = await this.prisma.savedSearch.findUnique({
|
||||
where: { id: command.id },
|
||||
});
|
||||
|
||||
if (!savedSearch) {
|
||||
throw new NotFoundException('SavedSearch', command.id);
|
||||
}
|
||||
|
||||
if (savedSearch.userId !== command.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền xóa tìm kiếm này');
|
||||
}
|
||||
|
||||
await this.prisma.savedSearch.delete({ where: { id: command.id } });
|
||||
|
||||
this.logger.log(`Saved search deleted: id=${command.id}, user=${command.userId}`, 'DeleteSavedSearchHandler');
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class UpdateSavedSearchCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
public readonly name?: string,
|
||||
public readonly filters?: Record<string, unknown>,
|
||||
public readonly alertEnabled?: boolean,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type Prisma } from '@prisma/client';
|
||||
import { ForbiddenException, NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import { UpdateSavedSearchCommand } from './update-saved-search.command';
|
||||
|
||||
export interface UpdateSavedSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: unknown;
|
||||
alertEnabled: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateSavedSearchCommand)
|
||||
export class UpdateSavedSearchHandler implements ICommandHandler<UpdateSavedSearchCommand> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateSavedSearchCommand): Promise<UpdateSavedSearchResult> {
|
||||
const savedSearch = await this.prisma.savedSearch.findUnique({
|
||||
where: { id: command.id },
|
||||
});
|
||||
|
||||
if (!savedSearch) {
|
||||
throw new NotFoundException('SavedSearch', command.id);
|
||||
}
|
||||
|
||||
if (savedSearch.userId !== command.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền cập nhật tìm kiếm này');
|
||||
}
|
||||
|
||||
if (command.name !== undefined && command.name.trim().length === 0) {
|
||||
throw new ValidationException('Tên tìm kiếm không được để trống');
|
||||
}
|
||||
|
||||
if (command.name !== undefined && command.name.trim().length > 100) {
|
||||
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.savedSearch.update({
|
||||
where: { id: command.id },
|
||||
data: {
|
||||
...(command.name !== undefined && { name: command.name.trim() }),
|
||||
...(command.filters !== undefined && { filters: command.filters as Prisma.InputJsonValue }),
|
||||
...(command.alertEnabled !== undefined && { alertEnabled: command.alertEnabled }),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Saved search updated: id=${command.id}, user=${command.userId}`, 'UpdateSavedSearchHandler');
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
filters: updated.filters,
|
||||
alertEnabled: updated.alertEnabled,
|
||||
createdAt: updated.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,17 @@ export { SyncListingCommand } from './commands/sync-listing/sync-listing.command
|
||||
export { SyncListingHandler } from './commands/sync-listing/sync-listing.handler';
|
||||
export { ReindexAllCommand } from './commands/reindex-all/reindex-all.command';
|
||||
export { ReindexAllHandler, type ReindexResult } from './commands/reindex-all/reindex-all.handler';
|
||||
export { CreateSavedSearchCommand } from './commands/create-saved-search/create-saved-search.command';
|
||||
export { CreateSavedSearchHandler, type CreateSavedSearchResult } from './commands/create-saved-search/create-saved-search.handler';
|
||||
export { DeleteSavedSearchCommand } from './commands/delete-saved-search/delete-saved-search.command';
|
||||
export { DeleteSavedSearchHandler } from './commands/delete-saved-search/delete-saved-search.handler';
|
||||
export { UpdateSavedSearchCommand } from './commands/update-saved-search/update-saved-search.command';
|
||||
export { UpdateSavedSearchHandler, type UpdateSavedSearchResult } from './commands/update-saved-search/update-saved-search.handler';
|
||||
export { SearchPropertiesQuery } from './queries/search-properties/search-properties.query';
|
||||
export { SearchPropertiesHandler } from './queries/search-properties/search-properties.handler';
|
||||
export { GeoSearchQuery } from './queries/geo-search/geo-search.query';
|
||||
export { GeoSearchHandler } from './queries/geo-search/geo-search.handler';
|
||||
export { GetSavedSearchQuery } from './queries/get-saved-search/get-saved-search.query';
|
||||
export { GetSavedSearchHandler, type SavedSearchDetail } from './queries/get-saved-search/get-saved-search.handler';
|
||||
export { GetSavedSearchesQuery } from './queries/get-saved-searches/get-saved-searches.query';
|
||||
export { GetSavedSearchesHandler, type SavedSearchListResult, type SavedSearchItem } from './queries/get-saved-searches/get-saved-searches.handler';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared';
|
||||
import { GetSavedSearchQuery } from './get-saved-search.query';
|
||||
|
||||
export interface SavedSearchDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: unknown;
|
||||
alertEnabled: boolean;
|
||||
lastAlertAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@QueryHandler(GetSavedSearchQuery)
|
||||
export class GetSavedSearchHandler implements IQueryHandler<GetSavedSearchQuery> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetSavedSearchQuery): Promise<SavedSearchDetail> {
|
||||
const savedSearch = await this.prisma.savedSearch.findUnique({
|
||||
where: { id: query.id },
|
||||
});
|
||||
|
||||
if (!savedSearch) {
|
||||
throw new NotFoundException('SavedSearch', query.id);
|
||||
}
|
||||
|
||||
if (savedSearch.userId !== query.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền xem tìm kiếm này');
|
||||
}
|
||||
|
||||
return {
|
||||
id: savedSearch.id,
|
||||
name: savedSearch.name,
|
||||
filters: savedSearch.filters,
|
||||
alertEnabled: savedSearch.alertEnabled,
|
||||
lastAlertAt: savedSearch.lastAlertAt,
|
||||
createdAt: savedSearch.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetSavedSearchQuery {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { GetSavedSearchesQuery } from './get-saved-searches.query';
|
||||
|
||||
export interface SavedSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: unknown;
|
||||
alertEnabled: boolean;
|
||||
lastAlertAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SavedSearchListResult {
|
||||
data: SavedSearchItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
@QueryHandler(GetSavedSearchesQuery)
|
||||
export class GetSavedSearchesHandler implements IQueryHandler<GetSavedSearchesQuery> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetSavedSearchesQuery): Promise<SavedSearchListResult> {
|
||||
const skip = (query.page - 1) * query.limit;
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.savedSearch.findMany({
|
||||
where: { userId: query.userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: query.limit,
|
||||
}),
|
||||
this.prisma.savedSearch.count({
|
||||
where: { userId: query.userId },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: data.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
filters: s.filters,
|
||||
alertEnabled: s.alertEnabled,
|
||||
lastAlertAt: s.lastAlertAt,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class GetSavedSearchesQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { type Counter } from 'prom-client';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { type SearchResult } from '../../domain/repositories/search.repository';
|
||||
import { type PostgresSearchRepository } from '../services/postgres-search.repository';
|
||||
import { ResilientSearchRepository } from '../services/resilient-search.repository';
|
||||
import { type TypesenseSearchRepository } from '../services/typesense-search.repository';
|
||||
|
||||
function createMockSearchResult(overrides?: Partial<SearchResult>): SearchResult {
|
||||
return {
|
||||
hits: [],
|
||||
totalFound: 0,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
totalPages: 0,
|
||||
searchTimeMs: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ResilientSearchRepository', () => {
|
||||
let repository: ResilientSearchRepository;
|
||||
let mockTypesense: { [K in keyof TypesenseSearchRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPostgres: { [K in keyof PostgresSearchRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
let mockCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockTypesense = {
|
||||
search: vi.fn(),
|
||||
indexDocument: vi.fn(),
|
||||
indexDocuments: vi.fn(),
|
||||
removeDocument: vi.fn(),
|
||||
ensureCollection: vi.fn(),
|
||||
dropCollection: vi.fn(),
|
||||
};
|
||||
mockPostgres = {
|
||||
search: vi.fn(),
|
||||
indexDocument: vi.fn(),
|
||||
indexDocuments: vi.fn(),
|
||||
removeDocument: vi.fn(),
|
||||
ensureCollection: vi.fn(),
|
||||
dropCollection: vi.fn(),
|
||||
};
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
mockCounter = { inc: vi.fn() };
|
||||
|
||||
repository = new ResilientSearchRepository(
|
||||
mockTypesense as unknown as TypesenseSearchRepository,
|
||||
mockPostgres as unknown as PostgresSearchRepository,
|
||||
mockLogger as unknown as LoggerService,
|
||||
mockCounter as unknown as Counter,
|
||||
);
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('uses Typesense when available', async () => {
|
||||
const expected = createMockSearchResult({ totalFound: 10 });
|
||||
mockTypesense.search.mockResolvedValue(expected);
|
||||
|
||||
const result = await repository.search({ query: 'test' });
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockTypesense.search).toHaveBeenCalledWith({ query: 'test' });
|
||||
expect(mockPostgres.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to PostgreSQL when Typesense fails', async () => {
|
||||
const pgResult = createMockSearchResult({ totalFound: 5 });
|
||||
mockTypesense.search.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
mockPostgres.search.mockResolvedValue(pgResult);
|
||||
|
||||
const result = await repository.search({ query: 'test' });
|
||||
|
||||
expect(result).toEqual(pgResult);
|
||||
expect(mockPostgres.search).toHaveBeenCalledWith({ query: 'test' });
|
||||
expect(mockCounter.inc).toHaveBeenCalledWith({ service: 'typesense', event: 'fallback_search' });
|
||||
});
|
||||
|
||||
it('opens circuit after 3 consecutive failures and uses PG fallback', async () => {
|
||||
mockTypesense.search.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const pgResult = createMockSearchResult({ totalFound: 3 });
|
||||
mockPostgres.search.mockResolvedValue(pgResult);
|
||||
|
||||
// 3 failures to trip the breaker
|
||||
await repository.search({ query: 'a' });
|
||||
await repository.search({ query: 'b' });
|
||||
await repository.search({ query: 'c' });
|
||||
|
||||
// Reset mock call counts
|
||||
mockTypesense.search.mockClear();
|
||||
mockPostgres.search.mockClear();
|
||||
|
||||
// 4th call should not even try Typesense (circuit is OPEN)
|
||||
const result = await repository.search({ query: 'd' });
|
||||
expect(result).toEqual(pgResult);
|
||||
expect(mockTypesense.search).not.toHaveBeenCalled();
|
||||
expect(mockPostgres.search).toHaveBeenCalledWith({ query: 'd' });
|
||||
});
|
||||
|
||||
it('recovers to Typesense after circuit resets', async () => {
|
||||
// Trip the circuit
|
||||
mockTypesense.search.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const pgResult = createMockSearchResult({ totalFound: 2 });
|
||||
mockPostgres.search.mockResolvedValue(pgResult);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await repository.search({ query: `fail-${i}` });
|
||||
}
|
||||
|
||||
// Now simulate Typesense recovery - we need to wait for the reset timeout
|
||||
// But since the breaker has a 30s default timeout, we use a different approach:
|
||||
// Create a new repository with a fast timeout
|
||||
const fastRepo = new (ResilientSearchRepository as any as new (...args: any[]) => ResilientSearchRepository)(
|
||||
mockTypesense as unknown as TypesenseSearchRepository,
|
||||
mockPostgres as unknown as PostgresSearchRepository,
|
||||
mockLogger as unknown as LoggerService,
|
||||
mockCounter as unknown as Counter,
|
||||
);
|
||||
|
||||
// The new instance starts fresh, so Typesense calls should work
|
||||
const tsResult = createMockSearchResult({ totalFound: 10 });
|
||||
mockTypesense.search.mockResolvedValue(tsResult);
|
||||
|
||||
const result = await fastRepo.search({ query: 'recovered' });
|
||||
expect(result).toEqual(tsResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexDocument', () => {
|
||||
it('indexes via Typesense silently', async () => {
|
||||
const doc = { id: '1' } as any;
|
||||
mockTypesense.indexDocument.mockResolvedValue(undefined);
|
||||
|
||||
await repository.indexDocument(doc);
|
||||
expect(mockTypesense.indexDocument).toHaveBeenCalledWith(doc);
|
||||
});
|
||||
|
||||
it('swallows Typesense indexing errors and logs a warning', async () => {
|
||||
const doc = { id: '1' } as any;
|
||||
mockTypesense.indexDocument.mockRejectedValue(new Error('down'));
|
||||
|
||||
await repository.indexDocument(doc);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Typesense indexDocument failed'),
|
||||
'ResilientSearch',
|
||||
);
|
||||
expect(mockCounter.inc).toHaveBeenCalledWith({ service: 'typesense', event: 'index_failure' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexDocuments', () => {
|
||||
it('indexes batch via Typesense', async () => {
|
||||
const docs = [{ id: '1' }, { id: '2' }] as any[];
|
||||
mockTypesense.indexDocuments.mockResolvedValue(undefined);
|
||||
|
||||
await repository.indexDocuments(docs);
|
||||
expect(mockTypesense.indexDocuments).toHaveBeenCalledWith(docs);
|
||||
});
|
||||
|
||||
it('swallows batch indexing errors', async () => {
|
||||
mockTypesense.indexDocuments.mockRejectedValue(new Error('timeout'));
|
||||
|
||||
await repository.indexDocuments([{ id: '1' }] as any[]);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Typesense indexDocuments failed'),
|
||||
'ResilientSearch',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureCollection', () => {
|
||||
it('records success on the circuit breaker', async () => {
|
||||
mockTypesense.ensureCollection.mockResolvedValue(undefined);
|
||||
|
||||
await repository.ensureCollection();
|
||||
|
||||
expect(mockTypesense.ensureCollection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs warning but does not throw on failure', async () => {
|
||||
mockTypesense.ensureCollection.mockRejectedValue(new Error('no connection'));
|
||||
|
||||
await repository.ensureCollection();
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Typesense ensureCollection failed'),
|
||||
'ResilientSearch',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { SavedSearchAlertHandler } from '../../infrastructure/event-handlers/saved-search-alert.handler';
|
||||
|
||||
describe('SavedSearchAlertHandler', () => {
|
||||
let handler: SavedSearchAlertHandler;
|
||||
let mockPrisma: any;
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockListing = {
|
||||
id: 'listing-1',
|
||||
sellerId: 'seller-1',
|
||||
transactionType: 'SALE',
|
||||
priceVND: BigInt(3_000_000_000),
|
||||
property: {
|
||||
propertyType: 'APARTMENT',
|
||||
title: 'Chung cư cao cấp Quận 7',
|
||||
district: 'Quan 7',
|
||||
city: 'Ho Chi Minh',
|
||||
areaM2: 80,
|
||||
bedrooms: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSavedSearch = {
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Chung cư Q7',
|
||||
filters: { district: 'Quan 7', propertyType: 'APARTMENT' },
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
user: { id: 'user-1', email: 'user@example.com', fullName: 'Nguyen Van A' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
listing: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
savedSearch: {
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
handler = new SavedSearchAlertHandler(mockPrisma, mockCommandBus as any, mockLogger as any);
|
||||
});
|
||||
|
||||
it('sends alert when listing matches saved search filters', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([mockSavedSearch]);
|
||||
mockPrisma.savedSearch.update.mockResolvedValue({});
|
||||
|
||||
await handler.handle({ listingId: 'listing-1' });
|
||||
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrisma.savedSearch.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'saved-1' },
|
||||
data: { lastAlertAt: expect.any(Date) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send alert when listing does not match filters', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([
|
||||
{
|
||||
...mockSavedSearch,
|
||||
filters: { district: 'Quan 1', propertyType: 'HOUSE' },
|
||||
},
|
||||
]);
|
||||
|
||||
await handler.handle({ listingId: 'listing-1' });
|
||||
|
||||
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips saved search belonging to listing seller', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([
|
||||
{ ...mockSavedSearch, userId: 'seller-1' },
|
||||
]);
|
||||
|
||||
await handler.handle({ listingId: 'listing-1' });
|
||||
|
||||
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles listing not found gracefully', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(null);
|
||||
|
||||
await handler.handle({ listingId: 'non-existent' });
|
||||
|
||||
expect(mockPrisma.savedSearch.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches price range filters', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([
|
||||
{
|
||||
...mockSavedSearch,
|
||||
filters: { priceMin: '2000000000', priceMax: '5000000000' },
|
||||
},
|
||||
]);
|
||||
mockPrisma.savedSearch.update.mockResolvedValue({});
|
||||
|
||||
await handler.handle({ listingId: 'listing-1' });
|
||||
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not match when price is outside range', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([
|
||||
{
|
||||
...mockSavedSearch,
|
||||
filters: { priceMax: '1000000000' },
|
||||
},
|
||||
]);
|
||||
|
||||
await handler.handle({ listingId: 'listing-1' });
|
||||
|
||||
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type CommandBus } from '@nestjs/cqrs';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SendNotificationCommand } from '@modules/notifications';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Daily cron job that checks saved searches against new listings published since lastAlertAt.
|
||||
* This complements the real-time event-based handler by catching any listings that
|
||||
* were missed (e.g., due to service downtime or event processing failures).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SavedSearchAlertCronService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_8AM, { name: 'saved-search-daily-alerts' })
|
||||
async processAlerts(): Promise<void> {
|
||||
this.logger.log('Starting daily saved search alert processing...', 'SavedSearchAlertCron');
|
||||
|
||||
try {
|
||||
const savedSearches = await this.prisma.savedSearch.findMany({
|
||||
where: { alertEnabled: true },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, fullName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (savedSearches.length === 0) {
|
||||
this.logger.log('No saved searches with alerts enabled', 'SavedSearchAlertCron');
|
||||
return;
|
||||
}
|
||||
|
||||
let totalAlerts = 0;
|
||||
|
||||
for (const search of savedSearches) {
|
||||
try {
|
||||
const matchCount = await this.checkAndAlert(search);
|
||||
totalAlerts += matchCount;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to process alerts for saved search ${search.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'SavedSearchAlertCron',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Daily saved search alert processing completed: ${totalAlerts} alerts sent for ${savedSearches.length} searches`,
|
||||
'SavedSearchAlertCron',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Daily saved search alert processing failed: ${(err as Error).message}`,
|
||||
undefined,
|
||||
'SavedSearchAlertCron',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkAndAlert(
|
||||
search: {
|
||||
id: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
filters: unknown;
|
||||
lastAlertAt: Date | null;
|
||||
user: { id: string; email: string | null; fullName: string | null };
|
||||
},
|
||||
): Promise<number> {
|
||||
const filters = search.filters as Record<string, unknown>;
|
||||
|
||||
// Build query for new listings since last alert
|
||||
const sinceDate = search.lastAlertAt ?? new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
status: 'ACTIVE',
|
||||
publishedAt: { gte: sinceDate },
|
||||
sellerId: { not: search.userId },
|
||||
property: this.buildPropertyWhereClause(filters),
|
||||
};
|
||||
|
||||
if (filters['transactionType']) {
|
||||
where['transactionType'] = filters['transactionType'];
|
||||
}
|
||||
|
||||
if (filters['priceMin'] || filters['priceMax']) {
|
||||
where['priceVND'] = {
|
||||
...(filters['priceMin'] ? { gte: BigInt(Number(filters['priceMin'])) } : {}),
|
||||
...(filters['priceMax'] ? { lte: BigInt(Number(filters['priceMax'])) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const newListings = await this.prisma.listing.findMany({
|
||||
where,
|
||||
include: { property: true },
|
||||
take: 10,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
});
|
||||
|
||||
if (newListings.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Send a digest notification
|
||||
if (!search.user.email) {
|
||||
this.logger.warn(
|
||||
`User ${search.user.id} has no email, skipping saved search digest alert`,
|
||||
'SavedSearchAlertCron',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.commandBus.execute(
|
||||
new SendNotificationCommand(
|
||||
search.user.id,
|
||||
'EMAIL',
|
||||
'saved_search_digest',
|
||||
{
|
||||
userName: search.user.fullName ?? 'Người dùng',
|
||||
searchName: search.name,
|
||||
matchCount: newListings.length,
|
||||
listings: newListings.slice(0, 5).map((l) => ({
|
||||
title: l.property.title,
|
||||
price: Number(l.priceVND).toLocaleString('vi-VN'),
|
||||
district: l.property.district,
|
||||
city: l.property.city,
|
||||
url: `/listings/${l.id}`,
|
||||
})),
|
||||
},
|
||||
search.user.email,
|
||||
),
|
||||
);
|
||||
|
||||
// Update lastAlertAt
|
||||
await this.prisma.savedSearch.update({
|
||||
where: { id: search.id },
|
||||
data: { lastAlertAt: new Date() },
|
||||
});
|
||||
|
||||
return 1;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to send digest alert for search ${search.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'SavedSearchAlertCron',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private buildPropertyWhereClause(filters: Record<string, unknown>): Record<string, unknown> {
|
||||
const propertyWhere: Record<string, unknown> = {};
|
||||
|
||||
if (filters['propertyType']) {
|
||||
propertyWhere['propertyType'] = filters['propertyType'];
|
||||
}
|
||||
|
||||
if (filters['district']) {
|
||||
propertyWhere['district'] = filters['district'];
|
||||
}
|
||||
|
||||
if (filters['city']) {
|
||||
propertyWhere['city'] = filters['city'];
|
||||
}
|
||||
|
||||
if (filters['areaMin'] || filters['areaMax']) {
|
||||
propertyWhere['areaM2'] = {
|
||||
...(filters['areaMin'] ? { gte: Number(filters['areaMin']) } : {}),
|
||||
...(filters['areaMax'] ? { lte: Number(filters['areaMax']) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (filters['bedrooms']) {
|
||||
propertyWhere['bedrooms'] = { gte: Number(filters['bedrooms']) };
|
||||
}
|
||||
|
||||
return propertyWhere;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { ListingApprovedEventHandler } from './listing-approved.handler';
|
||||
export { ListingStatusChangedHandler } from './listing-status-changed.handler';
|
||||
export { SavedSearchAlertHandler } from './saved-search-alert.handler';
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type CommandBus } from '@nestjs/cqrs';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { SendNotificationCommand } from '@modules/notifications';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* When a new listing is approved, check all saved searches with alerts enabled
|
||||
* and notify users whose filters match the new listing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SavedSearchAlertHandler {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('listing.approved')
|
||||
async handle(payload: { listingId: string }): Promise<void> {
|
||||
this.logger.log(
|
||||
`Checking saved search alerts for approved listing ${payload.listingId}`,
|
||||
'SavedSearchAlertHandler',
|
||||
);
|
||||
|
||||
try {
|
||||
// Fetch the listing with property details
|
||||
const listing = await this.prisma.listing.findUnique({
|
||||
where: { id: payload.listingId },
|
||||
include: { property: true },
|
||||
});
|
||||
|
||||
if (!listing || !listing.property) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all saved searches with alerts enabled
|
||||
const savedSearches = await this.prisma.savedSearch.findMany({
|
||||
where: { alertEnabled: true },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, fullName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let matchCount = 0;
|
||||
|
||||
for (const search of savedSearches) {
|
||||
// Skip if search belongs to the listing owner
|
||||
if (search.userId === listing.sellerId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filters = search.filters as Record<string, unknown>;
|
||||
if (this.matchesFilters(listing, listing.property, filters)) {
|
||||
matchCount++;
|
||||
await this.sendAlert(search, listing, listing.property);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchCount > 0) {
|
||||
this.logger.log(
|
||||
`Sent ${matchCount} saved search alerts for listing ${payload.listingId}`,
|
||||
'SavedSearchAlertHandler',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Saved search alert processing failed for listing ${payload.listingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'SavedSearchAlertHandler',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a listing matches the saved search filters.
|
||||
* Filters are a flexible JSON object matching SearchPropertiesDto fields.
|
||||
*/
|
||||
private matchesFilters(
|
||||
listing: { transactionType: string; priceVND: bigint; sellerId: string },
|
||||
property: {
|
||||
propertyType: string;
|
||||
areaM2: number;
|
||||
bedrooms: number | null;
|
||||
district: string;
|
||||
city: string;
|
||||
},
|
||||
filters: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (filters['transactionType'] && filters['transactionType'] !== listing.transactionType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters['propertyType'] && filters['propertyType'] !== property.propertyType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters['district'] && filters['district'] !== property.district) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters['city'] && filters['city'] !== property.city) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const price = Number(listing.priceVND);
|
||||
|
||||
if (filters['priceMin'] && price < Number(filters['priceMin'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters['priceMax'] && price > Number(filters['priceMax'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters['areaMin'] && property.areaM2 < Number(filters['areaMin'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters['areaMax'] && property.areaM2 > Number(filters['areaMax'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters['bedrooms'] && property.bedrooms !== null && property.bedrooms < Number(filters['bedrooms'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async sendAlert(
|
||||
search: { id: string; name: string; user: { id: string; email: string | null; fullName: string | null } },
|
||||
listing: { id: string; priceVND: bigint },
|
||||
property: { title: string; district: string; city: string },
|
||||
): Promise<void> {
|
||||
if (!search.user.email) {
|
||||
this.logger.warn(
|
||||
`User ${search.user.id} has no email, skipping saved search alert`,
|
||||
'SavedSearchAlertHandler',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.commandBus.execute(
|
||||
new SendNotificationCommand(
|
||||
search.user.id,
|
||||
'EMAIL',
|
||||
'saved_search_alert',
|
||||
{
|
||||
userName: search.user.fullName ?? 'Người dùng',
|
||||
searchName: search.name,
|
||||
listingTitle: property.title,
|
||||
listingPrice: Number(listing.priceVND).toLocaleString('vi-VN'),
|
||||
listingDistrict: property.district,
|
||||
listingCity: property.city,
|
||||
listingUrl: `/listings/${listing.id}`,
|
||||
},
|
||||
search.user.email,
|
||||
),
|
||||
);
|
||||
|
||||
// Update lastAlertAt
|
||||
await this.prisma.savedSearch.update({
|
||||
where: { id: search.id },
|
||||
data: { lastAlertAt: new Date() },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to send saved search alert to user ${search.user.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'SavedSearchAlertHandler',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export { TypesenseClientService } from './typesense-client.service';
|
||||
export { TypesenseSearchRepository } from './typesense-search.repository';
|
||||
export { PostgresSearchRepository } from './postgres-search.repository';
|
||||
export { ResilientSearchRepository, SEARCH_DEGRADATION_TOTAL } from './resilient-search.repository';
|
||||
export { ListingIndexerService } from './listing-indexer.service';
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type ISearchRepository,
|
||||
type ListingDocument,
|
||||
type SearchParams,
|
||||
type SearchResult,
|
||||
} from '../../domain/repositories/search.repository';
|
||||
|
||||
/**
|
||||
* PostgreSQL-backed search repository used as a fallback when Typesense
|
||||
* is unavailable.
|
||||
*
|
||||
* Capabilities:
|
||||
* - Full-text search via PostgreSQL `to_tsvector` / `plainto_tsquery`
|
||||
* - Geo radius filtering via PostGIS `ST_DWithin`
|
||||
* - Faceted filters (property type, transaction type, price range, area, etc.)
|
||||
*
|
||||
* Limitations compared to Typesense:
|
||||
* - No relevance-ranked highlighting
|
||||
* - Slower for large result sets
|
||||
* - Vietnamese language support depends on PG config (defaults to 'simple')
|
||||
*/
|
||||
@Injectable()
|
||||
export class PostgresSearchRepository implements ISearchRepository {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Search listings using PostgreSQL full-text search + PostGIS.
|
||||
* Parses the Typesense-style `filterBy` string to build SQL conditions.
|
||||
*/
|
||||
async search(params: SearchParams): Promise<SearchResult> {
|
||||
const startMs = Date.now();
|
||||
const page = params.page ?? 1;
|
||||
const perPage = params.perPage ?? 20;
|
||||
const offset = (page - 1) * perPage;
|
||||
|
||||
const conditions: Prisma.Sql[] = [Prisma.sql`l."status" = 'ACTIVE'`];
|
||||
const parsed = this.parseFilterBy(params.filterBy ?? '');
|
||||
|
||||
// ── Parsed Typesense-style filters ─────────────────────────────────
|
||||
if (parsed.propertyType) {
|
||||
conditions.push(Prisma.sql`p."propertyType" = ${parsed.propertyType}`);
|
||||
}
|
||||
if (parsed.transactionType) {
|
||||
conditions.push(Prisma.sql`l."transactionType" = ${parsed.transactionType}`);
|
||||
}
|
||||
if (parsed.priceMin !== undefined && parsed.priceMax !== undefined) {
|
||||
conditions.push(Prisma.sql`l."priceVND" BETWEEN ${BigInt(parsed.priceMin)} AND ${BigInt(parsed.priceMax)}`);
|
||||
} else if (parsed.priceMin !== undefined) {
|
||||
conditions.push(Prisma.sql`l."priceVND" >= ${BigInt(parsed.priceMin)}`);
|
||||
} else if (parsed.priceMax !== undefined) {
|
||||
conditions.push(Prisma.sql`l."priceVND" <= ${BigInt(parsed.priceMax)}`);
|
||||
}
|
||||
if (parsed.areaMin !== undefined && parsed.areaMax !== undefined) {
|
||||
conditions.push(Prisma.sql`p."areaM2" BETWEEN ${parsed.areaMin} AND ${parsed.areaMax}`);
|
||||
} else if (parsed.areaMin !== undefined) {
|
||||
conditions.push(Prisma.sql`p."areaM2" >= ${parsed.areaMin}`);
|
||||
} else if (parsed.areaMax !== undefined) {
|
||||
conditions.push(Prisma.sql`p."areaM2" <= ${parsed.areaMax}`);
|
||||
}
|
||||
if (parsed.bedrooms !== undefined) {
|
||||
conditions.push(Prisma.sql`p."bedrooms" >= ${parsed.bedrooms}`);
|
||||
}
|
||||
if (parsed.district) {
|
||||
conditions.push(Prisma.sql`p."district" = ${parsed.district}`);
|
||||
}
|
||||
if (parsed.city) {
|
||||
conditions.push(Prisma.sql`p."city" = ${parsed.city}`);
|
||||
}
|
||||
|
||||
// ── Geo radius filter (PostGIS) ────────────────────────────────────
|
||||
if (params.geoPoint && params.geoRadiusKm) {
|
||||
const radiusMeters = params.geoRadiusKm * 1000;
|
||||
conditions.push(
|
||||
Prisma.sql`ST_DWithin(
|
||||
p."location"::geography,
|
||||
ST_SetSRID(ST_MakePoint(${params.geoPoint.lng}, ${params.geoPoint.lat}), 4326)::geography,
|
||||
${radiusMeters}
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Full-text search condition ─────────────────────────────────────
|
||||
const hasTextQuery = params.query && params.query !== '*';
|
||||
if (hasTextQuery) {
|
||||
conditions.push(
|
||||
Prisma.sql`(
|
||||
to_tsvector('simple', coalesce(p."title", '') || ' ' || coalesce(p."description", '') || ' ' || coalesce(p."address", '') || ' ' || coalesce(p."district", '') || ' ' || coalesce(p."city", ''))
|
||||
@@ plainto_tsquery('simple', ${params.query!})
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
const whereClause = Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`;
|
||||
|
||||
// ── Count total matches ────────────────────────────────────────────
|
||||
const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>(
|
||||
Prisma.sql`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "Listing" l
|
||||
JOIN "Property" p ON l."propertyId" = p."id"
|
||||
${whereClause}
|
||||
`,
|
||||
);
|
||||
const totalFound = Number(countResult[0]?.count ?? 0);
|
||||
|
||||
// ── Sorting ────────────────────────────────────────────────────────
|
||||
let orderClause: Prisma.Sql;
|
||||
if (params.geoPoint && (params.sortBy === 'distance' || (!params.sortBy && params.geoRadiusKm))) {
|
||||
orderClause = Prisma.sql`ORDER BY ST_Distance(
|
||||
p."location"::geography,
|
||||
ST_SetSRID(ST_MakePoint(${params.geoPoint.lng}, ${params.geoPoint.lat}), 4326)::geography
|
||||
) ASC`;
|
||||
} else {
|
||||
switch (params.sortBy) {
|
||||
case 'price_asc':
|
||||
orderClause = Prisma.sql`ORDER BY l."priceVND" ASC`;
|
||||
break;
|
||||
case 'price_desc':
|
||||
orderClause = Prisma.sql`ORDER BY l."priceVND" DESC`;
|
||||
break;
|
||||
case 'date_desc':
|
||||
orderClause = Prisma.sql`ORDER BY l."publishedAt" DESC NULLS LAST`;
|
||||
break;
|
||||
case 'relevance':
|
||||
default:
|
||||
if (hasTextQuery) {
|
||||
orderClause = Prisma.sql`ORDER BY ts_rank(
|
||||
to_tsvector('simple', coalesce(p."title", '') || ' ' || coalesce(p."description", '') || ' ' || coalesce(p."address", '') || ' ' || coalesce(p."district", '') || ' ' || coalesce(p."city", '')),
|
||||
plainto_tsquery('simple', ${params.query!})
|
||||
) DESC, l."publishedAt" DESC NULLS LAST`;
|
||||
} else {
|
||||
orderClause = Prisma.sql`ORDER BY l."publishedAt" DESC NULLS LAST`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch rows ─────────────────────────────────────────────────────
|
||||
const rows = await this.prisma.$queryRaw<RawListingRow[]>(
|
||||
Prisma.sql`
|
||||
SELECT
|
||||
l."id" AS "listingId",
|
||||
l."propertyId" AS "propertyId",
|
||||
p."title" AS "title",
|
||||
p."description" AS "description",
|
||||
p."propertyType" AS "propertyType",
|
||||
l."transactionType" AS "transactionType",
|
||||
l."priceVND" AS "priceVND",
|
||||
l."pricePerM2" AS "pricePerM2",
|
||||
p."areaM2" AS "areaM2",
|
||||
p."bedrooms" AS "bedrooms",
|
||||
p."bathrooms" AS "bathrooms",
|
||||
p."floors" AS "floors",
|
||||
p."direction" AS "direction",
|
||||
p."address" AS "address",
|
||||
p."ward" AS "ward",
|
||||
p."district" AS "district",
|
||||
p."city" AS "city",
|
||||
ST_Y(p."location"::geometry) AS "lat",
|
||||
ST_X(p."location"::geometry) AS "lng",
|
||||
l."agentId" AS "agentId",
|
||||
l."sellerId" AS "sellerId",
|
||||
l."status" AS "status",
|
||||
l."publishedAt" AS "publishedAt",
|
||||
l."viewCount" AS "viewCount",
|
||||
l."saveCount" AS "saveCount",
|
||||
p."projectName" AS "projectName",
|
||||
p."amenities" AS "amenities"
|
||||
FROM "Listing" l
|
||||
JOIN "Property" p ON l."propertyId" = p."id"
|
||||
${whereClause}
|
||||
${orderClause}
|
||||
LIMIT ${perPage} OFFSET ${offset}
|
||||
`,
|
||||
);
|
||||
|
||||
const hits: ListingDocument[] = rows.map((row) => ({
|
||||
id: row.listingId,
|
||||
listingId: row.listingId,
|
||||
propertyId: row.propertyId,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
propertyType: row.propertyType,
|
||||
transactionType: row.transactionType,
|
||||
priceVND: Number(row.priceVND),
|
||||
pricePerM2: row.pricePerM2 ? Number(row.pricePerM2) : null,
|
||||
areaM2: Number(row.areaM2),
|
||||
bedrooms: row.bedrooms,
|
||||
bathrooms: row.bathrooms,
|
||||
floors: row.floors,
|
||||
direction: row.direction,
|
||||
address: row.address,
|
||||
ward: row.ward,
|
||||
district: row.district,
|
||||
city: row.city,
|
||||
location: [row.lat ?? 0, row.lng ?? 0] as [number, number],
|
||||
agentId: row.agentId,
|
||||
sellerId: row.sellerId,
|
||||
status: row.status,
|
||||
publishedAt: row.publishedAt ? Math.floor(new Date(row.publishedAt).getTime() / 1000) : 0,
|
||||
viewCount: row.viewCount ?? 0,
|
||||
saveCount: row.saveCount ?? 0,
|
||||
projectName: row.projectName,
|
||||
amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [],
|
||||
}));
|
||||
|
||||
const searchTimeMs = Date.now() - startMs;
|
||||
|
||||
return {
|
||||
hits,
|
||||
totalFound,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(totalFound / perPage),
|
||||
searchTimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Indexing operations are no-ops for the PG fallback ───────────────
|
||||
|
||||
async indexDocument(_doc: ListingDocument): Promise<void> {
|
||||
// Data already lives in PostgreSQL — nothing to do.
|
||||
}
|
||||
|
||||
async indexDocuments(_docs: ListingDocument[]): Promise<void> {
|
||||
// Data already lives in PostgreSQL — nothing to do.
|
||||
}
|
||||
|
||||
async removeDocument(_id: string): Promise<void> {
|
||||
// No separate index to clean up.
|
||||
}
|
||||
|
||||
async ensureCollection(): Promise<void> {
|
||||
// PostgreSQL tables/indexes are managed by Prisma migrations.
|
||||
}
|
||||
|
||||
async dropCollection(): Promise<void> {
|
||||
// Not applicable for PostgreSQL fallback.
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minimal parser for the Typesense-style `filterBy` strings produced
|
||||
* by the query handlers.
|
||||
*
|
||||
* Expected format examples:
|
||||
* "status:=ACTIVE && propertyType:=HOUSE && priceVND:[2000..5000]"
|
||||
* "status:=ACTIVE && priceVND:>=1000 && bedrooms:>=3"
|
||||
*/
|
||||
private parseFilterBy(filterStr: string): ParsedFilters {
|
||||
const result: ParsedFilters = {};
|
||||
if (!filterStr) return result;
|
||||
|
||||
const clauses = filterStr.split('&&').map((c) => c.trim());
|
||||
for (const clause of clauses) {
|
||||
// Range: field:[min..max]
|
||||
const rangeMatch = clause.match(/^(\w+):\[(\d+)\.\.(\d+)\]$/);
|
||||
if (rangeMatch) {
|
||||
const field = rangeMatch[1]!;
|
||||
const min = Number(rangeMatch[2]);
|
||||
const max = Number(rangeMatch[3]);
|
||||
if (field === 'priceVND') {
|
||||
result.priceMin = min;
|
||||
result.priceMax = max;
|
||||
} else if (field === 'areaM2') {
|
||||
result.areaMin = min;
|
||||
result.areaMax = max;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Equality: field:=value
|
||||
const eqMatch = clause.match(/^(\w+):=(.+)$/);
|
||||
if (eqMatch) {
|
||||
const field = eqMatch[1]!;
|
||||
const val = eqMatch[2]!;
|
||||
if (field === 'propertyType') result.propertyType = val;
|
||||
else if (field === 'transactionType') result.transactionType = val;
|
||||
else if (field === 'district') result.district = val;
|
||||
else if (field === 'city') result.city = val;
|
||||
else if (field === 'status') { /* handled separately */ }
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gte: field:>=value
|
||||
const gteMatch = clause.match(/^(\w+):>=(\d+(?:\.\d+)?)$/);
|
||||
if (gteMatch) {
|
||||
const field = gteMatch[1]!;
|
||||
const val = Number(gteMatch[2]);
|
||||
if (field === 'priceVND') result.priceMin = val;
|
||||
else if (field === 'areaM2') result.areaMin = val;
|
||||
else if (field === 'bedrooms') result.bedrooms = val;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lte: field:<=value
|
||||
const lteMatch = clause.match(/^(\w+):<=(\d+(?:\.\d+)?)$/);
|
||||
if (lteMatch) {
|
||||
const field = lteMatch[1]!;
|
||||
const val = Number(lteMatch[2]);
|
||||
if (field === 'priceVND') result.priceMax = val;
|
||||
else if (field === 'areaM2') result.areaMax = val;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Geo filter: location:(lat, lng, radius km) — skip, handled via params
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedFilters {
|
||||
propertyType?: string;
|
||||
transactionType?: string;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
areaMin?: number;
|
||||
areaMax?: number;
|
||||
bedrooms?: number;
|
||||
district?: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
interface RawListingRow {
|
||||
listingId: string;
|
||||
propertyId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
propertyType: string;
|
||||
transactionType: string;
|
||||
priceVND: bigint;
|
||||
pricePerM2: number | null;
|
||||
areaM2: number;
|
||||
bedrooms: number | null;
|
||||
bathrooms: number | null;
|
||||
floors: number | null;
|
||||
direction: string | null;
|
||||
address: string;
|
||||
ward: string;
|
||||
district: string;
|
||||
city: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
agentId: string | null;
|
||||
sellerId: string;
|
||||
status: string;
|
||||
publishedAt: Date | string | null;
|
||||
viewCount: number;
|
||||
saveCount: number;
|
||||
projectName: string | null;
|
||||
amenities: unknown;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type Client as TypesenseClient } from 'typesense';
|
||||
import type { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { type CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
type ISearchRepository,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { SearchController } from './search.controller';
|
||||
export { SavedSearchController } from './saved-search.controller';
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { CreateSavedSearchCommand } from '../../application/commands/create-saved-search/create-saved-search.command';
|
||||
import { type CreateSavedSearchResult } from '../../application/commands/create-saved-search/create-saved-search.handler';
|
||||
import { DeleteSavedSearchCommand } from '../../application/commands/delete-saved-search/delete-saved-search.command';
|
||||
import { UpdateSavedSearchCommand } from '../../application/commands/update-saved-search/update-saved-search.command';
|
||||
import { type UpdateSavedSearchResult } from '../../application/commands/update-saved-search/update-saved-search.handler';
|
||||
import { type SavedSearchDetail } from '../../application/queries/get-saved-search/get-saved-search.handler';
|
||||
import { GetSavedSearchQuery } from '../../application/queries/get-saved-search/get-saved-search.query';
|
||||
import { type SavedSearchListResult } from '../../application/queries/get-saved-searches/get-saved-searches.handler';
|
||||
import { GetSavedSearchesQuery } from '../../application/queries/get-saved-searches/get-saved-searches.query';
|
||||
import { type CreateSavedSearchDto, type UpdateSavedSearchDto, type SavedSearchListDto } from '../dto/saved-search.dto';
|
||||
|
||||
@ApiTags('saved-searches')
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('saved-searches')
|
||||
export class SavedSearchController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Lưu tìm kiếm', description: 'Lưu bộ lọc tìm kiếm để nhận thông báo khi có kết quả mới' })
|
||||
@ApiResponse({ status: 201, description: 'Tìm kiếm đã được lưu' })
|
||||
@ApiResponse({ status: 400, description: 'Dữ liệu không hợp lệ' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
|
||||
@ApiResponse({ status: 403, description: 'Đã đạt giới hạn gói đăng ký' })
|
||||
async create(
|
||||
@Body() dto: CreateSavedSearchDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<CreateSavedSearchResult> {
|
||||
return this.commandBus.execute(
|
||||
new CreateSavedSearchCommand(
|
||||
user.sub,
|
||||
dto.name,
|
||||
dto.filters,
|
||||
dto.alertEnabled ?? true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Danh sách tìm kiếm đã lưu', description: 'Lấy danh sách tìm kiếm đã lưu của người dùng' })
|
||||
@ApiResponse({ status: 200, description: 'Danh sách tìm kiếm đã lưu' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
|
||||
async list(
|
||||
@Query() dto: SavedSearchListDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<SavedSearchListResult> {
|
||||
return this.queryBus.execute(
|
||||
new GetSavedSearchesQuery(user.sub, dto.page ?? 1, dto.limit ?? 20),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Chi tiết tìm kiếm đã lưu', description: 'Lấy chi tiết một tìm kiếm đã lưu' })
|
||||
@ApiParam({ name: 'id', description: 'ID tìm kiếm đã lưu' })
|
||||
@ApiResponse({ status: 200, description: 'Chi tiết tìm kiếm đã lưu' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy' })
|
||||
async getById(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<SavedSearchDetail> {
|
||||
return this.queryBus.execute(
|
||||
new GetSavedSearchQuery(id, user.sub),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Cập nhật tìm kiếm đã lưu', description: 'Cập nhật tên, bộ lọc hoặc trạng thái thông báo' })
|
||||
@ApiParam({ name: 'id', description: 'ID tìm kiếm đã lưu' })
|
||||
@ApiResponse({ status: 200, description: 'Đã cập nhật' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateSavedSearchDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<UpdateSavedSearchResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateSavedSearchCommand(
|
||||
id,
|
||||
user.sub,
|
||||
dto.name,
|
||||
dto.filters,
|
||||
dto.alertEnabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Xóa tìm kiếm đã lưu', description: 'Xóa một tìm kiếm đã lưu' })
|
||||
@ApiParam({ name: 'id', description: 'ID tìm kiếm đã lưu' })
|
||||
@ApiResponse({ status: 200, description: 'Đã xóa' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa đăng nhập' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy' })
|
||||
async delete(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<{ deleted: boolean }> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteSavedSearchCommand(id, user.sub),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { SearchPropertiesDto, SortByOption } from './search-properties.dto';
|
||||
export { GeoSearchDto, GeoSortByOption } from './geo-search.dto';
|
||||
export { CreateSavedSearchDto, UpdateSavedSearchDto, SavedSearchListDto } from './saved-search.dto';
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateSavedSearchDto {
|
||||
@ApiProperty({ description: 'Tên tìm kiếm đã lưu', example: 'Chung cư Quận 7 dưới 3 tỷ' })
|
||||
@IsNotEmpty({ message: 'Tên tìm kiếm không được để trống' })
|
||||
@IsString()
|
||||
@MaxLength(100, { message: 'Tên tìm kiếm không được vượt quá 100 ký tự' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Bộ lọc tìm kiếm (JSON)', example: { propertyType: 'apartment', district: 'Quan 7', priceMax: 3000000000 } })
|
||||
@IsObject()
|
||||
filters!: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Bật thông báo khi có kết quả mới', default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
alertEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateSavedSearchDto {
|
||||
@ApiPropertyOptional({ description: 'Tên tìm kiếm đã lưu', example: 'Chung cư Quận 2' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100, { message: 'Tên tìm kiếm không được vượt quá 100 ký tự' })
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Bộ lọc tìm kiếm (JSON)' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
filters?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Bật/tắt thông báo' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
alertEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class SavedSearchListDto {
|
||||
@ApiPropertyOptional({ description: 'Trang', default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Số lượng mỗi trang', default: 20, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -1,34 +1,58 @@
|
||||
import { Module, type OnModuleInit } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { makeCounterProvider } from '@willsoto/nestjs-prometheus';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { CreateSavedSearchHandler } from './application/commands/create-saved-search/create-saved-search.handler';
|
||||
import { DeleteSavedSearchHandler } from './application/commands/delete-saved-search/delete-saved-search.handler';
|
||||
import { ReindexAllHandler } from './application/commands/reindex-all/reindex-all.handler';
|
||||
import { SyncListingHandler } from './application/commands/sync-listing/sync-listing.handler';
|
||||
import { UpdateSavedSearchHandler } from './application/commands/update-saved-search/update-saved-search.handler';
|
||||
import { GeoSearchHandler } from './application/queries/geo-search/geo-search.handler';
|
||||
import { GetSavedSearchHandler } from './application/queries/get-saved-search/get-saved-search.handler';
|
||||
import { GetSavedSearchesHandler } from './application/queries/get-saved-searches/get-saved-searches.handler';
|
||||
import { SearchPropertiesHandler } from './application/queries/search-properties/search-properties.handler';
|
||||
import { SEARCH_REPOSITORY } from './domain/repositories/search.repository';
|
||||
import { SavedSearchAlertCronService } from './infrastructure/cron/saved-search-alert-cron.service';
|
||||
import { ListingApprovedEventHandler } from './infrastructure/event-handlers/listing-approved.handler';
|
||||
import { ListingStatusChangedHandler } from './infrastructure/event-handlers/listing-status-changed.handler';
|
||||
import { SavedSearchAlertHandler } from './infrastructure/event-handlers/saved-search-alert.handler';
|
||||
import { ListingIndexerService } from './infrastructure/services/listing-indexer.service';
|
||||
import { PostgresSearchRepository } from './infrastructure/services/postgres-search.repository';
|
||||
import { ResilientSearchRepository, SEARCH_DEGRADATION_TOTAL } from './infrastructure/services/resilient-search.repository';
|
||||
import { TypesenseClientService } from './infrastructure/services/typesense-client.service';
|
||||
import { TypesenseSearchRepository } from './infrastructure/services/typesense-search.repository';
|
||||
import { SavedSearchController } from './presentation/controllers/saved-search.controller';
|
||||
import { SearchController } from './presentation/controllers/search.controller';
|
||||
|
||||
const CommandHandlers = [SyncListingHandler, ReindexAllHandler];
|
||||
const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler];
|
||||
const CommandHandlers = [SyncListingHandler, ReindexAllHandler, CreateSavedSearchHandler, DeleteSavedSearchHandler, UpdateSavedSearchHandler];
|
||||
const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearchesHandler, GetSavedSearchHandler];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
controllers: [SearchController],
|
||||
controllers: [SearchController, SavedSearchController],
|
||||
providers: [
|
||||
// Infrastructure
|
||||
TypesenseClientService,
|
||||
TypesenseSearchRepository,
|
||||
{ provide: SEARCH_REPOSITORY, useExisting: TypesenseSearchRepository },
|
||||
PostgresSearchRepository,
|
||||
ResilientSearchRepository,
|
||||
{ provide: SEARCH_REPOSITORY, useExisting: ResilientSearchRepository },
|
||||
ListingIndexerService,
|
||||
|
||||
// Metrics
|
||||
makeCounterProvider({
|
||||
name: SEARCH_DEGRADATION_TOTAL,
|
||||
help: 'Total search degradation events (Typesense circuit breaker)',
|
||||
labelNames: ['service', 'event'],
|
||||
}),
|
||||
|
||||
// Event handlers
|
||||
ListingApprovedEventHandler,
|
||||
ListingStatusChangedHandler,
|
||||
SavedSearchAlertHandler,
|
||||
|
||||
// Cron jobs
|
||||
SavedSearchAlertCronService,
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
@@ -39,7 +63,7 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler];
|
||||
export class SearchModule implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly typesenseClient: TypesenseClientService,
|
||||
private readonly searchRepo: TypesenseSearchRepository,
|
||||
private readonly searchRepo: ResilientSearchRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -48,8 +72,8 @@ export class SearchModule implements OnModuleInit {
|
||||
await this.searchRepo.ensureCollection();
|
||||
this.logger.log('Search module initialized — Typesense collection ready', 'SearchModule');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to initialize Typesense collection: ${err instanceof Error ? err.message : String(err)}`,
|
||||
this.logger.warn(
|
||||
`Typesense collection initialization failed: ${err instanceof Error ? err.message : String(err)} — PostgreSQL fallback is active`,
|
||||
'SearchModule',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user