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,7 @@
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository';
import { type IDuplicateDetector } from '@modules/listings/domain/services/duplicate-detector';
import { type IPriceValidator } from '@modules/listings/domain/services/price-validator';
import { CreateListingCommand } from '../commands/create-listing/create-listing.command';
import { CreateListingHandler } from '../commands/create-listing/create-listing.handler';
@@ -9,6 +10,7 @@ describe('CreateListingHandler', () => {
let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType<typeof vi.fn> };
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
let mockDuplicateDetector: { [K in keyof IDuplicateDetector]: ReturnType<typeof vi.fn> };
let mockPriceValidator: { [K in keyof IPriceValidator]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockCache: { invalidateByPrefix: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; getOrSet: ReturnType<typeof vi.fn> };
@@ -37,6 +39,10 @@ describe('CreateListingHandler', () => {
findDuplicates: vi.fn().mockResolvedValue([]),
};
mockPriceValidator = {
validate: vi.fn().mockResolvedValue({ isValid: true, isSuspicious: false, pricePerM2: 62_500_000, minPricePerM2: 15_000_000, maxPricePerM2: 200_000_000 }),
};
mockEventBus = { publish: vi.fn() };
mockCache = {
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
@@ -48,6 +54,7 @@ describe('CreateListingHandler', () => {
mockPropertyRepo as any,
mockListingRepo as any,
mockDuplicateDetector as any,
mockPriceValidator as any,
mockEventBus as any,
mockCache as any,
);

View File

@@ -1,5 +1,6 @@
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { ModerationService } from '@modules/listings/domain/services/moderation.service';
import { Price } from '@modules/listings/domain/value-objects/price.vo';
import { ModerateListingCommand } from '../commands/moderate-listing/moderate-listing.command';
import { ModerateListingHandler } from '../commands/moderate-listing/moderate-listing.handler';
@@ -39,6 +40,7 @@ describe('ModerateListingHandler', () => {
mockListingRepo as any,
mockEventBus as any,
mockCache as any,
new ModerationService(),
);
});

View File

@@ -0,0 +1,105 @@
import { PrismaPriceValidator } from '../../infrastructure/services/prisma-price-validator';
describe('PrismaPriceValidator', () => {
let validator: PrismaPriceValidator;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = { $queryRaw: vi.fn() };
validator = new PrismaPriceValidator(mockPrisma as any);
});
it('returns valid + not suspicious for normal price within market range', async () => {
mockPrisma.$queryRaw.mockResolvedValue([{ min_price: 30_000_000, max_price: 100_000_000 }]);
const result = await validator.validate({
priceVND: 5_000_000_000n,
areaM2: 80,
propertyType: 'APARTMENT',
district: 'Quận 1',
});
expect(result.isValid).toBe(true);
expect(result.isSuspicious).toBe(false);
expect(result.pricePerM2).toBe(62_500_000);
});
it('flags suspicious low price', async () => {
mockPrisma.$queryRaw.mockResolvedValue([{ min_price: 50_000_000, max_price: 100_000_000 }]);
const result = await validator.validate({
priceVND: 1_000_000_000n,
areaM2: 80,
propertyType: 'APARTMENT',
district: 'Quận 1',
});
// 12.5M per m2, min threshold is 50M * 0.5 = 25M → 12.5M < 25M → suspicious
expect(result.isValid).toBe(true);
expect(result.isSuspicious).toBe(true);
expect(result.reason).toContain('thấp hơn');
});
it('flags suspicious high price', async () => {
mockPrisma.$queryRaw.mockResolvedValue([{ min_price: 30_000_000, max_price: 80_000_000 }]);
const result = await validator.validate({
priceVND: 20_000_000_000n,
areaM2: 80,
propertyType: 'APARTMENT',
district: 'Quận 1',
});
// 250M per m2, max threshold is 80M * 1.5 = 120M → 250M > 120M → suspicious
expect(result.isValid).toBe(true);
expect(result.isSuspicious).toBe(true);
expect(result.reason).toContain('cao hơn');
});
it('returns invalid for zero area', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
const result = await validator.validate({
priceVND: 5_000_000_000n,
areaM2: 0,
propertyType: 'APARTMENT',
district: 'Quận 1',
});
expect(result.isValid).toBe(false);
expect(result.isSuspicious).toBe(true);
expect(result.reason).toContain('không hợp lệ');
});
it('uses default ranges when no market data available', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
const result = await validator.validate({
priceVND: 5_000_000_000n,
areaM2: 80,
propertyType: 'APARTMENT',
district: 'Quận 9',
});
expect(result.isValid).toBe(true);
// 62.5M per m2, default APARTMENT range is 15M-200M → within range
expect(result.isSuspicious).toBe(false);
expect(result.minPricePerM2).toBe(15_000_000);
expect(result.maxPricePerM2).toBe(200_000_000);
});
it('handles database error gracefully using defaults', async () => {
mockPrisma.$queryRaw.mockRejectedValue(new Error('DB connection failed'));
const result = await validator.validate({
priceVND: 5_000_000_000n,
areaM2: 80,
propertyType: 'HOUSE',
district: 'Quận 1',
});
expect(result.isValid).toBe(true);
expect(result.minPricePerM2).toBe(20_000_000);
expect(result.maxPricePerM2).toBe(500_000_000);
});
});

View File

@@ -1,5 +1,6 @@
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { ModerationService } from '@modules/listings/domain/services/moderation.service';
import { Price } from '@modules/listings/domain/value-objects/price.vo';
import { UpdateListingStatusCommand } from '../commands/update-listing-status/update-listing-status.command';
import { UpdateListingStatusHandler } from '../commands/update-listing-status/update-listing-status.handler';
@@ -43,6 +44,7 @@ describe('UpdateListingStatusHandler', () => {
mockListingRepo as any,
mockEventBus as any,
mockCache as any,
new ModerationService(),
);
});

View File

@@ -1,13 +1,13 @@
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { ValidationException } from '@modules/shared/domain/domain-exception';
import { type CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { ValidationException, type CacheService, CachePrefix } 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';
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
import { DUPLICATE_DETECTOR, type IDuplicateDetector } from '../../../domain/services/duplicate-detector';
import { PRICE_VALIDATOR, type IPriceValidator } from '../../../domain/services/price-validator';
import { Address } from '../../../domain/value-objects/address.vo';
import { GeoPoint } from '../../../domain/value-objects/geo-point.vo';
import { Price } from '../../../domain/value-objects/price.vo';
@@ -23,11 +23,19 @@ export interface DuplicateWarning {
titleSimilarity: number;
}
export interface PriceWarning {
pricePerM2: number;
minPricePerM2: number;
maxPricePerM2: number;
reason: string;
}
export interface CreateListingResult {
listingId: string;
propertyId: string;
status: string;
duplicateWarnings: DuplicateWarning[];
priceWarning?: PriceWarning;
}
@CommandHandler(CreateListingCommand)
@@ -38,6 +46,7 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
@Inject(DUPLICATE_DETECTOR) private readonly duplicateDetector: IDuplicateDetector,
@Inject(PRICE_VALIDATOR) private readonly priceValidator: IPriceValidator,
private readonly eventBus: EventBus,
private readonly cache: CacheService,
) {}
@@ -135,11 +144,34 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
this.logger.warn('Duplicate detection failed — listing created without warnings', err);
}
// Price validation — flag but never block creation
let priceWarning: PriceWarning | undefined;
try {
const priceResult = await this.priceValidator.validate({
priceVND: command.priceVND,
areaM2: command.areaM2,
propertyType: command.propertyType,
district: command.district,
});
if (priceResult.isSuspicious) {
priceWarning = {
pricePerM2: priceResult.pricePerM2,
minPricePerM2: priceResult.minPricePerM2,
maxPricePerM2: priceResult.maxPricePerM2,
reason: priceResult.reason!,
};
}
} catch (err) {
this.logger.warn('Price validation failed — listing created without price warning', err);
}
return {
listingId,
propertyId,
status: listing.status,
duplicateWarnings,
priceWarning,
};
}
}

View File

@@ -1,8 +1,9 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { NotFoundException, CacheService, CachePrefix } from '@modules/shared';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI needs runtime reference
import { ModerationService } from '../../../domain/services/moderation.service';
import { ModerateListingCommand } from './moderate-listing.command';
@CommandHandler(ModerateListingCommand)
@@ -11,6 +12,7 @@ export class ModerateListingHandler implements ICommandHandler<ModerateListingCo
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly eventBus: EventBus,
private readonly cache: CacheService,
private readonly moderationService: ModerationService,
) {}
async execute(command: ModerateListingCommand): Promise<{ status: string }> {
@@ -19,15 +21,11 @@ export class ModerateListingHandler implements ICommandHandler<ModerateListingCo
throw new NotFoundException('Listing', command.listingId);
}
if (command.moderationScore !== undefined) {
listing.setModerationScore(command.moderationScore, command.notes);
}
if (command.action === 'approve') {
listing.approve();
} else {
listing.reject(command.notes ?? 'Bị từ chối bởi moderator');
}
this.moderationService.applyModeration(listing, {
action: command.action,
moderationScore: command.moderationScore,
notes: command.notes,
});
await this.listingRepo.update(listing);

View File

@@ -1,8 +1,9 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { NotFoundException, CacheService, CachePrefix } from '@modules/shared';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI needs runtime reference
import { ModerationService } from '../../../domain/services/moderation.service';
import { UpdateListingStatusCommand } from './update-listing-status.command';
@CommandHandler(UpdateListingStatusCommand)
@@ -11,6 +12,7 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly eventBus: EventBus,
private readonly cache: CacheService,
private readonly moderationService: ModerationService,
) {}
async execute(command: UpdateListingStatusCommand): Promise<{ status: string }> {
@@ -19,13 +21,11 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
throw new NotFoundException('Listing', command.listingId);
}
if (command.newStatus === 'REJECTED' && command.moderationNotes) {
listing.reject(command.moderationNotes);
} else if (command.newStatus === 'ACTIVE' && listing.status === 'PENDING_REVIEW') {
listing.approve();
} else {
listing.transitionTo(command.newStatus);
}
this.moderationService.applyStatusTransition(
listing,
command.newStatus,
command.moderationNotes,
);
await this.listingRepo.update(listing);

View File

@@ -1,7 +1,7 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
import { NotFoundException, ValidationException } from '@modules/shared';
import { PropertyMediaEntity } from '../../../domain/entities/property-media.entity';
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
import { MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '../../../infrastructure/services/media-storage.service';

View File

@@ -1,7 +1,6 @@
import { Inject } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { NotFoundException, CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import { type ListingDetailData } from '../../../domain/repositories/listing-read.dto';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import { GetListingQuery } from './get-listing.query';

View File

@@ -1,6 +1,5 @@
import { type ListingStatus, type TransactionType } from '@prisma/client';
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
import { ValidationException } from '@modules/shared/domain/domain-exception';
import { AggregateRoot, ValidationException } from '@modules/shared';
import { ListingApprovedEvent } from '../events/listing-approved.event';
import { ListingCreatedEvent } from '../events/listing-created.event';
import { ListingSoldEvent } from '../events/listing-sold.event';

View File

@@ -1,4 +1,4 @@
import { BaseEntity } from '@modules/shared/domain/base-entity';
import { BaseEntity } from '@modules/shared';
export interface PropertyMediaProps {
propertyId: string;

View File

@@ -1,5 +1,5 @@
import { type PropertyType, type Direction } from '@prisma/client';
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
import { AggregateRoot } from '@modules/shared';
import { type Address } from '../value-objects/address.vo';
import { type GeoPoint } from '../value-objects/geo-point.vo';

View File

@@ -1,4 +1,4 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
export class ListingApprovedEvent implements DomainEvent {
readonly eventName = 'listing.approved';

View File

@@ -1,5 +1,5 @@
import { type TransactionType } from '@prisma/client';
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
export class ListingCreatedEvent implements DomainEvent {
readonly eventName = 'listing.created';

View File

@@ -1,5 +1,5 @@
import { type ListingStatus } from '@prisma/client';
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
export class ListingSoldEvent implements DomainEvent {
readonly eventName = 'listing.sold';

View File

@@ -0,0 +1,43 @@
import { type ListingEntity } from '../entities/listing.entity';
export interface ModerationAction {
action: 'approve' | 'reject';
moderationScore?: number;
notes?: string;
}
export class ModerationService {
/**
* Apply a moderation action to a listing entity.
* Mutates the listing in-place (score + status transition).
*/
applyModeration(listing: ListingEntity, moderation: ModerationAction): void {
if (moderation.moderationScore !== undefined) {
listing.setModerationScore(moderation.moderationScore, moderation.notes);
}
if (moderation.action === 'approve') {
listing.approve();
} else {
listing.reject(moderation.notes ?? 'Bị từ chối bởi moderator');
}
}
/**
* Apply a status transition that may be moderation-related.
* Detects approve/reject patterns and delegates to entity methods.
*/
applyStatusTransition(
listing: ListingEntity,
newStatus: string,
moderationNotes?: string,
): void {
if (newStatus === 'REJECTED' && moderationNotes) {
listing.reject(moderationNotes);
} else if (newStatus === 'ACTIVE' && listing.status === 'PENDING_REVIEW') {
listing.approve();
} else {
listing.transitionTo(newStatus);
}
}
}

View File

@@ -0,0 +1,24 @@
import { type PropertyType } from '@prisma/client';
export const PRICE_VALIDATOR = Symbol('PRICE_VALIDATOR');
export interface PriceValidationParams {
priceVND: bigint;
areaM2: number;
propertyType: PropertyType;
district: string;
}
export interface PriceValidationResult {
isValid: boolean;
isSuspicious: boolean;
reason?: string;
pricePerM2: number;
minPricePerM2: number;
maxPricePerM2: number;
}
export interface IPriceValidator {
/** Validate price range for a property type in a given district */
validate(params: PriceValidationParams): Promise<PriceValidationResult>;
}

View File

@@ -1,5 +1,4 @@
import { Result } from '@modules/shared/domain/result';
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result, ValueObject } from '@modules/shared';
interface AddressProps {
address: string;

View File

@@ -1,5 +1,4 @@
import { Result } from '@modules/shared/domain/result';
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result, ValueObject } from '@modules/shared';
interface GeoPointProps {
latitude: number;

View File

@@ -1,5 +1,4 @@
import { Result } from '@modules/shared/domain/result';
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result, ValueObject } from '@modules/shared';
interface PriceProps {
amountVND: bigint;

View File

@@ -1 +1,6 @@
export { ListingsModule } from './listings.module';
export { ListingEntity, type ListingProps } from './domain/entities/listing.entity';
export { ListingCreatedEvent } from './domain/events/listing-created.event';
export { LISTING_REPOSITORY, type IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository';
export { ListingSoldEvent } from './domain/events/listing-sold.event';
export { Price } from './domain/value-objects/price.vo';

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

View File

@@ -11,10 +11,13 @@ import { SearchListingsHandler } from './application/queries/search-listings/sea
import { LISTING_REPOSITORY } from './domain/repositories/listing.repository';
import { PROPERTY_REPOSITORY } from './domain/repositories/property.repository';
import { DUPLICATE_DETECTOR } from './domain/services/duplicate-detector';
import { ModerationService } from './domain/services/moderation.service';
import { PRICE_VALIDATOR } from './domain/services/price-validator';
import { PrismaListingRepository } from './infrastructure/repositories/prisma-listing.repository';
import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository';
import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service';
import { PrismaDuplicateDetector } from './infrastructure/services/prisma-duplicate-detector';
import { PrismaPriceValidator } from './infrastructure/services/prisma-price-validator';
import { ListingsController } from './presentation/controllers/listings.controller';
const CommandHandlers = [
@@ -45,6 +48,8 @@ const QueryHandlers = [
// Services
{ provide: DUPLICATE_DETECTOR, useClass: PrismaDuplicateDetector },
{ provide: PRICE_VALIDATOR, useClass: PrismaPriceValidator },
ModerationService,
{ provide: MEDIA_STORAGE_SERVICE, useClass: MinioMediaStorageService },
// CQRS

View File

@@ -21,14 +21,9 @@ import {
ApiQuery,
ApiParam,
} from '@nestjs/swagger';
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service';
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
import { FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared/infrastructure/pipes/file-validation.pipe';
import { RequireQuota } from '@modules/subscriptions/presentation/decorators/require-quota.decorator';
import { QuotaGuard } from '@modules/subscriptions/presentation/guards/quota.guard';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared';
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
import { type CreateListingResult } from '../../application/commands/create-listing/create-listing.handler';
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';