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