refactor(api): replace new Logger() with DI LoggerService and split large files

- Migrate 30 files from `new Logger(ClassName.name)` to injected LoggerService
  for consistent PII masking and centralized logging config
- Split prisma-admin-query.repository.ts (313→121 lines) into admin-stats.queries.ts
  and admin-user.queries.ts
- Split admin.controller.ts (285→154 lines) into admin-moderation.controller.ts
- Split prisma-listing.repository.ts (274→111 lines) into listing-read.queries.ts
- Update 28 test files with mock LoggerService
- All 831 tests passing, zero direct new Logger() calls remaining

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 05:35:04 +07:00
parent 4e71036ddd
commit 34202f2527
67 changed files with 851 additions and 653 deletions

View File

@@ -50,6 +50,8 @@ describe('CreateListingHandler', () => {
getOrSet: vi.fn(),
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreateListingHandler(
mockPropertyRepo as any,
mockListingRepo as any,
@@ -57,6 +59,7 @@ describe('CreateListingHandler', () => {
mockPriceValidator as any,
mockEventBus as any,
mockCache as any,
mockLogger as any,
);
});

View File

@@ -6,7 +6,8 @@ describe('PrismaPriceValidator', () => {
beforeEach(() => {
mockPrisma = { $queryRaw: vi.fn() };
validator = new PrismaPriceValidator(mockPrisma as any);
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
validator = new PrismaPriceValidator(mockPrisma as any, mockLogger as any);
});
it('returns valid + not suspicious for normal price within market range', async () => {

View File

@@ -1,7 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { ValidationException, type CacheService, CachePrefix } from '@modules/shared';
import { ValidationException, type CacheService, CachePrefix, type LoggerService } from '@modules/shared';
import { ListingEntity } from '../../../domain/entities/listing.entity';
import { PropertyEntity } from '../../../domain/entities/property.entity';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
@@ -40,8 +40,6 @@ export interface CreateListingResult {
@CommandHandler(CreateListingCommand)
export class CreateListingHandler implements ICommandHandler<CreateListingCommand> {
private readonly logger = new Logger(CreateListingHandler.name);
constructor(
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
@@ -49,6 +47,7 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
@Inject(PRICE_VALIDATOR) private readonly priceValidator: IPriceValidator,
private readonly eventBus: EventBus,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(command: CreateListingCommand): Promise<CreateListingResult> {
@@ -141,7 +140,7 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
titleSimilarity: c.titleSimilarity,
}));
} catch (err) {
this.logger.warn('Duplicate detection failed — listing created without warnings', err);
this.logger.warn('Duplicate detection failed — listing created without warnings', 'CreateListingHandler');
}
// Price validation — flag but never block creation
@@ -163,7 +162,7 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
};
}
} catch (err) {
this.logger.warn('Price validation failed — listing created without price warning', err);
this.logger.warn('Price validation failed — listing created without price warning', 'CreateListingHandler');
}
return {

View File

@@ -0,0 +1,192 @@
import { type Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
import { type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
export async function findByIdWithProperty(
prisma: PrismaService,
id: string,
): Promise<ListingDetailData | null> {
const listing = await prisma.listing.findUnique({
where: { id },
include: {
property: {
include: {
media: { orderBy: { order: 'asc' }, take: 10 },
},
},
seller: { select: { id: true, fullName: true, phone: true } },
agent: { select: { id: true, userId: true, agency: true } },
},
});
if (!listing) return null;
return {
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
pricePerM2: listing.pricePerM2,
rentPriceMonthly: listing.rentPriceMonthly?.toString() ?? null,
commissionPct: listing.commissionPct,
viewCount: listing.viewCount,
saveCount: listing.saveCount,
inquiryCount: listing.inquiryCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
createdAt: listing.createdAt.toISOString(),
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
title: listing.property.title,
description: listing.property.description,
address: listing.property.address,
ward: listing.property.ward,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
floors: listing.property.floors,
direction: listing.property.direction,
yearBuilt: listing.property.yearBuilt,
legalStatus: listing.property.legalStatus,
amenities: listing.property.amenities,
projectName: listing.property.projectName,
media: listing.property.media.map((m) => ({
id: m.id,
url: m.url,
type: m.type,
order: m.order,
caption: m.caption,
})),
},
seller: listing.seller,
agent: listing.agent,
};
}
export async function searchListings(
prisma: PrismaService,
params: ListingSearchParams,
): Promise<PaginatedResult<ListingSearchItem>> {
const page = params.page ?? 1;
const limit = Math.min(params.limit ?? 20, 100);
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = {};
if (params.status) where.status = params.status;
if (params.transactionType) where.transactionType = params.transactionType;
if (params.minPrice || params.maxPrice) {
where.priceVND = {};
if (params.minPrice) where.priceVND.gte = params.minPrice;
if (params.maxPrice) where.priceVND.lte = params.maxPrice;
}
if (params.propertyType || params.city || params.district || params.minArea || params.maxArea || params.bedrooms) {
where.property = {};
if (params.propertyType) where.property.propertyType = params.propertyType;
if (params.city) where.property.city = params.city;
if (params.district) where.property.district = params.district;
if (params.minArea || params.maxArea) {
where.property.areaM2 = {};
if (params.minArea) where.property.areaM2.gte = params.minArea;
if (params.maxArea) where.property.areaM2.lte = params.maxArea;
}
if (params.bedrooms) where.property.bedrooms = { gte: params.bedrooms };
}
const [data, total] = await Promise.all([
prisma.listing.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
property: {
include: {
media: { orderBy: { order: 'asc' }, take: 1 },
},
},
seller: { select: { id: true, fullName: true } },
},
}),
prisma.listing.count({ where }),
]);
return {
data: data.map((listing) => ({
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
pricePerM2: listing.pricePerM2,
viewCount: listing.viewCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
title: listing.property.title,
address: listing.property.address,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
thumbnail: listing.property.media[0]?.url ?? null,
},
seller: listing.seller,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
export async function findBySellerIdQuery(
prisma: PrismaService,
sellerId: string,
page: number,
limit: number,
): Promise<PaginatedResult<ListingSellerItem>> {
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = { sellerId };
const [data, total] = await Promise.all([
prisma.listing.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
property: {
include: { media: { orderBy: { order: 'asc' }, take: 1 } },
},
},
}),
prisma.listing.count({ where }),
]);
return {
data: data.map((listing) => ({
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
property: {
id: listing.property.id,
title: listing.property.title,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
thumbnail: listing.property.media[0]?.url ?? null,
},
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import { type Listing as PrismaListing, type Prisma, type ListingStatus } from '@prisma/client';
import { type Listing as PrismaListing, type ListingStatus } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
import { type ListingDetailData } from '../../domain/repositories/listing-read.dto';
import { type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
import { Price } from '../../domain/value-objects/price.vo';
import { findByIdWithProperty, searchListings, findBySellerIdQuery } from './listing-read.queries';
@Injectable()
export class PrismaListingRepository implements IListingRepository {
@@ -16,63 +18,7 @@ export class PrismaListingRepository implements IListingRepository {
}
async findByIdWithProperty(id: string): Promise<ListingDetailData | null> {
const listing = await this.prisma.listing.findUnique({
where: { id },
include: {
property: {
include: {
media: { orderBy: { order: 'asc' }, take: 10 },
},
},
seller: { select: { id: true, fullName: true, phone: true } },
agent: { select: { id: true, userId: true, agency: true } },
},
});
if (!listing) return null;
return {
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
pricePerM2: listing.pricePerM2,
rentPriceMonthly: listing.rentPriceMonthly?.toString() ?? null,
commissionPct: listing.commissionPct,
viewCount: listing.viewCount,
saveCount: listing.saveCount,
inquiryCount: listing.inquiryCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
createdAt: listing.createdAt.toISOString(),
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
title: listing.property.title,
description: listing.property.description,
address: listing.property.address,
ward: listing.property.ward,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
floors: listing.property.floors,
direction: listing.property.direction,
yearBuilt: listing.property.yearBuilt,
legalStatus: listing.property.legalStatus,
amenities: listing.property.amenities,
projectName: listing.property.projectName,
media: listing.property.media.map((m) => ({
id: m.id,
url: m.url,
type: m.type,
order: m.order,
caption: m.caption,
})),
},
seller: listing.seller,
agent: listing.agent,
};
return findByIdWithProperty(this.prisma, id);
}
async save(entity: ListingEntity): Promise<void> {
@@ -124,79 +70,7 @@ export class PrismaListingRepository implements IListingRepository {
}
async search(params: ListingSearchParams): Promise<PaginatedResult<ListingSearchItem>> {
const page = params.page ?? 1;
const limit = Math.min(params.limit ?? 20, 100);
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = {};
if (params.status) where.status = params.status;
if (params.transactionType) where.transactionType = params.transactionType;
if (params.minPrice || params.maxPrice) {
where.priceVND = {};
if (params.minPrice) where.priceVND.gte = params.minPrice;
if (params.maxPrice) where.priceVND.lte = params.maxPrice;
}
if (params.propertyType || params.city || params.district || params.minArea || params.maxArea || params.bedrooms) {
where.property = {};
if (params.propertyType) where.property.propertyType = params.propertyType;
if (params.city) where.property.city = params.city;
if (params.district) where.property.district = params.district;
if (params.minArea || params.maxArea) {
where.property.areaM2 = {};
if (params.minArea) where.property.areaM2.gte = params.minArea;
if (params.maxArea) where.property.areaM2.lte = params.maxArea;
}
if (params.bedrooms) where.property.bedrooms = { gte: params.bedrooms };
}
const [data, total] = await Promise.all([
this.prisma.listing.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
property: {
include: {
media: { orderBy: { order: 'asc' }, take: 1 },
},
},
seller: { select: { id: true, fullName: true } },
},
}),
this.prisma.listing.count({ where }),
]);
return {
data: data.map((listing) => ({
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
pricePerM2: listing.pricePerM2,
viewCount: listing.viewCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
title: listing.property.title,
address: listing.property.address,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
thumbnail: listing.property.media[0]?.url ?? null,
},
seller: listing.seller,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
return searchListings(this.prisma, params);
}
async findByStatus(status: ListingStatus, page: number, limit: number): Promise<PaginatedResult<ListingSearchItem>> {
@@ -204,44 +78,7 @@ export class PrismaListingRepository implements IListingRepository {
}
async findBySellerId(sellerId: string, page: number, limit: number): Promise<PaginatedResult<ListingSellerItem>> {
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = { sellerId };
const [data, total] = await Promise.all([
this.prisma.listing.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
property: {
include: { media: { orderBy: { order: 'asc' }, take: 1 } },
},
},
}),
this.prisma.listing.count({ where }),
]);
return {
data: data.map((listing) => ({
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
property: {
id: listing.property.id,
title: listing.property.title,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
thumbnail: listing.property.media[0]?.url ?? null,
},
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
return findBySellerIdQuery(this.prisma, sellerId, page, limit);
}
private toDomain(raw: PrismaListing): ListingEntity {

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { type PrismaService, type LoggerService } from '@modules/shared';
import {
type IPriceValidator,
type PriceValidationParams,
@@ -25,9 +25,10 @@ const SUSPICIOUS_MULTIPLIER = 0.5;
@Injectable()
export class PrismaPriceValidator implements IPriceValidator {
private readonly logger = new Logger(PrismaPriceValidator.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async validate(params: PriceValidationParams): Promise<PriceValidationResult> {
const { priceVND, areaM2, propertyType, district } = params;
@@ -113,7 +114,7 @@ export class PrismaPriceValidator implements IPriceValidator {
}
return null;
} catch (err) {
this.logger.warn('Failed to fetch market range, using defaults', err);
this.logger.warn('Failed to fetch market range, using defaults', 'PrismaPriceValidator');
return null;
}
}