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,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,
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '@modules/shared/domain/base-entity';
|
||||
import { BaseEntity } from '@modules/shared';
|
||||
|
||||
export interface PropertyMediaProps {
|
||||
propertyId: string;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user