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

@@ -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;
}
}