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 IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository'; import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository';
import { type IDuplicateDetector } from '@modules/listings/domain/services/duplicate-detector'; 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 { CreateListingCommand } from '../commands/create-listing/create-listing.command';
import { CreateListingHandler } from '../commands/create-listing/create-listing.handler'; 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 mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType<typeof vi.fn> };
let mockListingRepo: { [K in keyof IListingRepository]: 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 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 mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockCache: { invalidateByPrefix: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; getOrSet: 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([]), 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() }; mockEventBus = { publish: vi.fn() };
mockCache = { mockCache = {
invalidateByPrefix: vi.fn().mockResolvedValue(undefined), invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
@@ -48,6 +54,7 @@ describe('CreateListingHandler', () => {
mockPropertyRepo as any, mockPropertyRepo as any,
mockListingRepo as any, mockListingRepo as any,
mockDuplicateDetector as any, mockDuplicateDetector as any,
mockPriceValidator as any,
mockEventBus as any, mockEventBus as any,
mockCache as any, mockCache as any,
); );

View File

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

View File

@@ -1,13 +1,13 @@
import { Inject, Logger } from '@nestjs/common'; import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2'; import { createId } from '@paralleldrive/cuid2';
import { ValidationException } from '@modules/shared/domain/domain-exception'; import { ValidationException, type CacheService, CachePrefix } from '@modules/shared';
import { type CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { ListingEntity } from '../../../domain/entities/listing.entity'; import { ListingEntity } from '../../../domain/entities/listing.entity';
import { PropertyEntity } from '../../../domain/entities/property.entity'; import { PropertyEntity } from '../../../domain/entities/property.entity';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository'; import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
import { DUPLICATE_DETECTOR, type IDuplicateDetector } from '../../../domain/services/duplicate-detector'; 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 { Address } from '../../../domain/value-objects/address.vo';
import { GeoPoint } from '../../../domain/value-objects/geo-point.vo'; import { GeoPoint } from '../../../domain/value-objects/geo-point.vo';
import { Price } from '../../../domain/value-objects/price.vo'; import { Price } from '../../../domain/value-objects/price.vo';
@@ -23,11 +23,19 @@ export interface DuplicateWarning {
titleSimilarity: number; titleSimilarity: number;
} }
export interface PriceWarning {
pricePerM2: number;
minPricePerM2: number;
maxPricePerM2: number;
reason: string;
}
export interface CreateListingResult { export interface CreateListingResult {
listingId: string; listingId: string;
propertyId: string; propertyId: string;
status: string; status: string;
duplicateWarnings: DuplicateWarning[]; duplicateWarnings: DuplicateWarning[];
priceWarning?: PriceWarning;
} }
@CommandHandler(CreateListingCommand) @CommandHandler(CreateListingCommand)
@@ -38,6 +46,7 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository, @Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
@Inject(DUPLICATE_DETECTOR) private readonly duplicateDetector: IDuplicateDetector, @Inject(DUPLICATE_DETECTOR) private readonly duplicateDetector: IDuplicateDetector,
@Inject(PRICE_VALIDATOR) private readonly priceValidator: IPriceValidator,
private readonly eventBus: EventBus, private readonly eventBus: EventBus,
private readonly cache: CacheService, 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); 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 { return {
listingId, listingId,
propertyId, propertyId,
status: listing.status, status: listing.status,
duplicateWarnings, duplicateWarnings,
priceWarning,
}; };
} }
} }

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2'; 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 { PropertyMediaEntity } from '../../../domain/entities/property-media.entity';
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository'; import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
import { MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '../../../infrastructure/services/media-storage.service'; import { MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '../../../infrastructure/services/media-storage.service';

View File

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

View File

@@ -1,6 +1,5 @@
import { type ListingStatus, type TransactionType } from '@prisma/client'; import { type ListingStatus, type TransactionType } from '@prisma/client';
import { AggregateRoot } from '@modules/shared/domain/aggregate-root'; import { AggregateRoot, ValidationException } from '@modules/shared';
import { ValidationException } from '@modules/shared/domain/domain-exception';
import { ListingApprovedEvent } from '../events/listing-approved.event'; import { ListingApprovedEvent } from '../events/listing-approved.event';
import { ListingCreatedEvent } from '../events/listing-created.event'; import { ListingCreatedEvent } from '../events/listing-created.event';
import { ListingSoldEvent } from '../events/listing-sold.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 { export interface PropertyMediaProps {
propertyId: string; propertyId: string;

View File

@@ -1,5 +1,5 @@
import { type PropertyType, type Direction } from '@prisma/client'; 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 Address } from '../value-objects/address.vo';
import { type GeoPoint } from '../value-objects/geo-point.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 { export class ListingApprovedEvent implements DomainEvent {
readonly eventName = 'listing.approved'; readonly eventName = 'listing.approved';

View File

@@ -1,5 +1,5 @@
import { type TransactionType } from '@prisma/client'; 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 { export class ListingCreatedEvent implements DomainEvent {
readonly eventName = 'listing.created'; readonly eventName = 'listing.created';

View File

@@ -1,5 +1,5 @@
import { type ListingStatus } from '@prisma/client'; 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 { export class ListingSoldEvent implements DomainEvent {
readonly eventName = 'listing.sold'; 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 { Result, ValueObject } from '@modules/shared';
import { ValueObject } from '@modules/shared/domain/value-object';
interface AddressProps { interface AddressProps {
address: string; address: string;

View File

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

View File

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

View File

@@ -1 +1,6 @@
export { ListingsModule } from './listings.module'; 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 { Injectable } from '@nestjs/common';
import { type Listing as PrismaListing, type Prisma, type ListingStatus } from '@prisma/client'; 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 { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto'; 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'; import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { type Prisma, type Property as PrismaProperty, type PropertyMedia as PrismaMedia } from '@prisma/client'; 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 { PropertyMediaEntity, type PropertyMediaProps } from '../../domain/entities/property-media.entity';
import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity'; import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity';
import { type IPropertyRepository } from '../../domain/repositories/property.repository'; 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 { 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'; } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable, type OnModuleInit } from '@nestjs/common'; 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'); export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE');

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { type PropertyType } from '@prisma/client'; import { type PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; import { type PrismaService } from '@modules/shared';
import { import {
type DuplicateCandidate, type DuplicateCandidate,
type DuplicateCheckParams, 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 { LISTING_REPOSITORY } from './domain/repositories/listing.repository';
import { PROPERTY_REPOSITORY } from './domain/repositories/property.repository'; import { PROPERTY_REPOSITORY } from './domain/repositories/property.repository';
import { DUPLICATE_DETECTOR } from './domain/services/duplicate-detector'; 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 { PrismaListingRepository } from './infrastructure/repositories/prisma-listing.repository';
import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository'; import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository';
import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service'; import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service';
import { PrismaDuplicateDetector } from './infrastructure/services/prisma-duplicate-detector'; import { PrismaDuplicateDetector } from './infrastructure/services/prisma-duplicate-detector';
import { PrismaPriceValidator } from './infrastructure/services/prisma-price-validator';
import { ListingsController } from './presentation/controllers/listings.controller'; import { ListingsController } from './presentation/controllers/listings.controller';
const CommandHandlers = [ const CommandHandlers = [
@@ -45,6 +48,8 @@ const QueryHandlers = [
// Services // Services
{ provide: DUPLICATE_DETECTOR, useClass: PrismaDuplicateDetector }, { provide: DUPLICATE_DETECTOR, useClass: PrismaDuplicateDetector },
{ provide: PRICE_VALIDATOR, useClass: PrismaPriceValidator },
ModerationService,
{ provide: MEDIA_STORAGE_SERVICE, useClass: MinioMediaStorageService }, { provide: MEDIA_STORAGE_SERVICE, useClass: MinioMediaStorageService },
// CQRS // CQRS

View File

@@ -21,14 +21,9 @@ import {
ApiQuery, ApiQuery,
ApiParam, ApiParam,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service'; import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator'; import { FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared';
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator'; import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
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 { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command'; import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
import { type CreateListingResult } from '../../application/commands/create-listing/create-listing.handler'; import { type CreateListingResult } from '../../application/commands/create-listing/create-listing.handler';
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command'; import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';