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