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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { MEDIA_STORAGE_SERVICE, type IMediaStorageService, MinioMediaStorageService } from './media-storage.service';
|
||||
export { PrismaPriceValidator } from './prisma-price-validator';
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user