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,
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user