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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
import { GetSavedSearchHandler } from '../queries/get-saved-search/get-saved-search.handler';
import { GetSavedSearchQuery } from '../queries/get-saved-search/get-saved-search.query';
describe('GetSavedSearchHandler', () => {
let handler: GetSavedSearchHandler;
let mockPrisma: any;
const existingSearch = {
id: 'saved-1',
userId: 'user-1',
name: 'Chung cư Q7',
filters: { district: 'Quan 7' },
alertEnabled: true,
lastAlertAt: null,
createdAt: new Date('2026-01-15'),
};
beforeEach(() => {
mockPrisma = {
savedSearch: {
findUnique: vi.fn(),
},
};
handler = new GetSavedSearchHandler(mockPrisma);
});
it('returns saved search detail for the owner', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue(existingSearch);
const query = new GetSavedSearchQuery('saved-1', 'user-1');
const result = await handler.execute(query);
expect(result.id).toBe('saved-1');
expect(result.name).toBe('Chung cư Q7');
expect(result.alertEnabled).toBe(true);
});
it('throws NotFoundException when saved search does not exist', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue(null);
const query = new GetSavedSearchQuery('non-existent', 'user-1');
await expect(handler.execute(query)).rejects.toThrow();
});
it('throws ForbiddenException when user does not own the saved search', async () => {
mockPrisma.savedSearch.findUnique.mockResolvedValue({
...existingSearch,
userId: 'other-user',
});
const query = new GetSavedSearchQuery('saved-1', 'user-1');
await expect(handler.execute(query)).rejects.toThrow('Bạn không có quyền xem tìm kiếm này');
});
});

View File

@@ -0,0 +1,79 @@
import { GetSavedSearchesHandler } from '../queries/get-saved-searches/get-saved-searches.handler';
import { GetSavedSearchesQuery } from '../queries/get-saved-searches/get-saved-searches.query';
describe('GetSavedSearchesHandler', () => {
let handler: GetSavedSearchesHandler;
let mockPrisma: any;
const savedSearches = [
{
id: 'saved-1',
userId: 'user-1',
name: 'Chung cư Q7',
filters: { district: 'Quan 7' },
alertEnabled: true,
lastAlertAt: null,
createdAt: new Date('2026-01-15'),
},
{
id: 'saved-2',
userId: 'user-1',
name: 'Nhà phố Q2',
filters: { district: 'Quan 2', propertyType: 'HOUSE' },
alertEnabled: false,
lastAlertAt: new Date('2026-01-20'),
createdAt: new Date('2026-01-10'),
},
];
beforeEach(() => {
mockPrisma = {
savedSearch: {
findMany: vi.fn(),
count: vi.fn(),
},
};
handler = new GetSavedSearchesHandler(mockPrisma);
});
it('returns paginated saved searches', async () => {
mockPrisma.savedSearch.findMany.mockResolvedValue(savedSearches);
mockPrisma.savedSearch.count.mockResolvedValue(2);
const query = new GetSavedSearchesQuery('user-1', 1, 20);
const result = await handler.execute(query);
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
expect(result.page).toBe(1);
expect(result.limit).toBe(20);
expect(result.data[0]!.name).toBe('Chung cư Q7');
});
it('applies pagination correctly', async () => {
mockPrisma.savedSearch.findMany.mockResolvedValue([savedSearches[1]]);
mockPrisma.savedSearch.count.mockResolvedValue(2);
const query = new GetSavedSearchesQuery('user-1', 2, 1);
await handler.execute(query);
expect(mockPrisma.savedSearch.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 1,
take: 1,
}),
);
});
it('returns empty list when no saved searches', async () => {
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
mockPrisma.savedSearch.count.mockResolvedValue(0);
const query = new GetSavedSearchesQuery('user-1');
const result = await handler.execute(query);
expect(result.data).toHaveLength(0);
expect(result.total).toBe(0);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,17 @@ export { SyncListingCommand } from './commands/sync-listing/sync-listing.command
export { SyncListingHandler } from './commands/sync-listing/sync-listing.handler';
export { ReindexAllCommand } from './commands/reindex-all/reindex-all.command';
export { ReindexAllHandler, type ReindexResult } from './commands/reindex-all/reindex-all.handler';
export { CreateSavedSearchCommand } from './commands/create-saved-search/create-saved-search.command';
export { CreateSavedSearchHandler, type CreateSavedSearchResult } from './commands/create-saved-search/create-saved-search.handler';
export { DeleteSavedSearchCommand } from './commands/delete-saved-search/delete-saved-search.command';
export { DeleteSavedSearchHandler } from './commands/delete-saved-search/delete-saved-search.handler';
export { UpdateSavedSearchCommand } from './commands/update-saved-search/update-saved-search.command';
export { UpdateSavedSearchHandler, type UpdateSavedSearchResult } from './commands/update-saved-search/update-saved-search.handler';
export { SearchPropertiesQuery } from './queries/search-properties/search-properties.query';
export { SearchPropertiesHandler } from './queries/search-properties/search-properties.handler';
export { GeoSearchQuery } from './queries/geo-search/geo-search.query';
export { GeoSearchHandler } from './queries/geo-search/geo-search.handler';
export { GetSavedSearchQuery } from './queries/get-saved-search/get-saved-search.query';
export { GetSavedSearchHandler, type SavedSearchDetail } from './queries/get-saved-search/get-saved-search.handler';
export { GetSavedSearchesQuery } from './queries/get-saved-searches/get-saved-searches.query';
export { GetSavedSearchesHandler, type SavedSearchListResult, type SavedSearchItem } from './queries/get-saved-searches/get-saved-searches.handler';

View File

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

View File

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

View File

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

View File

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