feat(listings): add price validator, moderation service, and improve handlers

Add domain-level price validator and moderation services with Prisma
implementation. Improve listing creation, status management, and media
upload handlers. Add price validator spec.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 09:43:06 +07:00
parent d9726d4961
commit c9fc1f52cb
29 changed files with 384 additions and 50 deletions

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Listing as PrismaListing, type Prisma, type ListingStatus } from '@prisma/client';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
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 IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Prisma, type Property as PrismaProperty, type PropertyMedia as PrismaMedia } from '@prisma/client';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import { PropertyMediaEntity, type PropertyMediaProps } from '../../domain/entities/property-media.entity';
import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity';
import { type IPropertyRepository } from '../../domain/repositories/property.repository';

View File

@@ -1 +1,2 @@
export { MEDIA_STORAGE_SERVICE, type IMediaStorageService, MinioMediaStorageService } from './media-storage.service';
export { PrismaPriceValidator } from './prisma-price-validator';

View File

@@ -9,7 +9,7 @@ import {
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type LoggerService } from '@modules/shared';
export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE');

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import {
type DuplicateCandidate,
type DuplicateCheckParams,

View File

@@ -0,0 +1,120 @@
import { Injectable, Logger } from '@nestjs/common';
import { type PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import {
type IPriceValidator,
type PriceValidationParams,
type PriceValidationResult,
} from '../../domain/services/price-validator';
/**
* Default price ranges per m2 (VND) by property type when no market data exists.
* These are conservative ranges for the Vietnamese real estate market.
*/
const DEFAULT_RANGES: Record<PropertyType, { min: number; max: number }> = {
APARTMENT: { min: 15_000_000, max: 200_000_000 },
HOUSE: { min: 20_000_000, max: 500_000_000 },
VILLA: { min: 50_000_000, max: 1_000_000_000 },
LAND: { min: 5_000_000, max: 800_000_000 },
OFFICE: { min: 10_000_000, max: 300_000_000 },
SHOPHOUSE: { min: 30_000_000, max: 600_000_000 },
};
/** Multiplier to widen default ranges for suspicious-but-not-invalid detection */
const SUSPICIOUS_MULTIPLIER = 0.5;
@Injectable()
export class PrismaPriceValidator implements IPriceValidator {
private readonly logger = new Logger(PrismaPriceValidator.name);
constructor(private readonly prisma: PrismaService) {}
async validate(params: PriceValidationParams): Promise<PriceValidationResult> {
const { priceVND, areaM2, propertyType, district } = params;
const pricePerM2 = areaM2 > 0 ? Number(priceVND) / areaM2 : 0;
// Try to get market data from recent listings in the same district + property type
const marketRange = await this.getMarketRange(propertyType, district);
const minPricePerM2 = marketRange?.min ?? DEFAULT_RANGES[propertyType].min;
const maxPricePerM2 = marketRange?.max ?? DEFAULT_RANGES[propertyType].max;
// Price is invalid if it's zero or negative area
if (pricePerM2 <= 0) {
return {
isValid: false,
isSuspicious: true,
reason: 'Giá hoặc diện tích không hợp lệ',
pricePerM2,
minPricePerM2,
maxPricePerM2,
};
}
// Too low — likely a data entry error
const suspiciousLow = minPricePerM2 * SUSPICIOUS_MULTIPLIER;
if (pricePerM2 < suspiciousLow) {
return {
isValid: true,
isSuspicious: true,
reason: `Giá ${Math.round(pricePerM2).toLocaleString()} VND/m² thấp hơn nhiều so với thị trường (${Math.round(minPricePerM2).toLocaleString()} - ${Math.round(maxPricePerM2).toLocaleString()} VND/m²)`,
pricePerM2,
minPricePerM2,
maxPricePerM2,
};
}
// Too high — likely a data entry error
const suspiciousHigh = maxPricePerM2 * (1 + (1 - SUSPICIOUS_MULTIPLIER));
if (pricePerM2 > suspiciousHigh) {
return {
isValid: true,
isSuspicious: true,
reason: `Giá ${Math.round(pricePerM2).toLocaleString()} VND/m² cao hơn nhiều so với thị trường (${Math.round(minPricePerM2).toLocaleString()} - ${Math.round(maxPricePerM2).toLocaleString()} VND/m²)`,
pricePerM2,
minPricePerM2,
maxPricePerM2,
};
}
return {
isValid: true,
isSuspicious: false,
pricePerM2,
minPricePerM2,
maxPricePerM2,
};
}
/**
* Get min/max price per m2 from active listings in the same district + property type.
* Uses P10/P90 percentiles to exclude outliers.
*/
private async getMarketRange(
propertyType: PropertyType,
district: string,
): Promise<{ min: number; max: number } | null> {
try {
const rows = await this.prisma.$queryRaw<{ min_price: number; max_price: number }[]>`
SELECT
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY l."priceVND"::float8 / p."areaM2") AS min_price,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY l."priceVND"::float8 / p."areaM2") AS max_price
FROM "Listing" l
JOIN "Property" p ON p.id = l."propertyId"
WHERE p."propertyType" = ${propertyType}::"PropertyType"
AND p.district = ${district}
AND l.status = 'ACTIVE'
AND p."areaM2" > 0
AND l."createdAt" > NOW() - INTERVAL '6 months'
`;
if (rows.length > 0 && rows[0].min_price && rows[0].max_price) {
return { min: rows[0].min_price, max: rows[0].max_price };
}
return null;
} catch (err) {
this.logger.warn('Failed to fetch market range, using defaults', err);
return null;
}
}
}