feat(listings): implement Listings module with CRUD, media upload, and moderation

Full DDD/CQRS implementation for the Listings module (TEC-1423):
- Domain: Property, Listing, PropertyMedia entities with status machine
- Value Objects: Address, GeoPoint, Price with validation
- Events: ListingCreated, ListingApproved, ListingSold
- Commands: CreateListing, UpdateListingStatus, UploadMedia, ModerateListing
- Queries: GetListing, SearchListings, GetPendingModeration
- Infrastructure: Prisma repositories with PostGIS support, MinIO media storage
- Presentation: REST controller with JWT auth, role-based moderation
- 21 domain unit tests (all passing)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 01:47:15 +07:00
parent 6741592cbe
commit 8a33aae026
50 changed files with 2108 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { ListingEntity } from '../entities/listing.entity';
import { Price } from '../value-objects/price.vo';
describe('ListingEntity', () => {
const makeDefaultListing = () => {
const price = Price.create(5_000_000_000n).unwrap();
return ListingEntity.createNew(
'listing-1',
'property-1',
'seller-1',
'SALE',
price,
100,
'agent-1',
);
};
it('should create a new listing in DRAFT status', () => {
const listing = makeDefaultListing();
expect(listing.status).toBe('DRAFT');
expect(listing.propertyId).toBe('property-1');
expect(listing.sellerId).toBe('seller-1');
expect(listing.transactionType).toBe('SALE');
expect(listing.pricePerM2).toBe(50_000_000);
expect(listing.viewCount).toBe(0);
});
it('should emit ListingCreatedEvent on creation', () => {
const listing = makeDefaultListing();
const events = listing.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]!.eventName).toBe('listing.created');
});
it('should transition DRAFT -> PENDING_REVIEW', () => {
const listing = makeDefaultListing();
listing.submitForReview();
expect(listing.status).toBe('PENDING_REVIEW');
});
it('should transition PENDING_REVIEW -> ACTIVE and emit ListingApprovedEvent', () => {
const listing = makeDefaultListing();
listing.clearDomainEvents();
listing.submitForReview();
listing.approve();
expect(listing.status).toBe('ACTIVE');
expect(listing.publishedAt).toBeTruthy();
const events = listing.domainEvents;
expect(events.some((e) => e.eventName === 'listing.approved')).toBe(true);
});
it('should reject a PENDING_REVIEW listing', () => {
const listing = makeDefaultListing();
listing.submitForReview();
listing.reject('Ảnh không rõ ràng');
expect(listing.status).toBe('REJECTED');
expect(listing.moderationNotes).toBe('Ảnh không rõ ràng');
});
it('should transition ACTIVE -> SOLD and emit ListingSoldEvent', () => {
const listing = makeDefaultListing();
listing.clearDomainEvents();
listing.submitForReview();
listing.approve();
listing.clearDomainEvents();
listing.transitionTo('SOLD');
expect(listing.status).toBe('SOLD');
const events = listing.domainEvents;
expect(events.some((e) => e.eventName === 'listing.sold')).toBe(true);
});
it('should throw on invalid status transition', () => {
const listing = makeDefaultListing();
expect(() => listing.transitionTo('ACTIVE')).toThrow('Không thể chuyển trạng thái');
});
it('should not allow transition from SOLD', () => {
const listing = makeDefaultListing();
listing.submitForReview();
listing.approve();
listing.transitionTo('SOLD');
expect(() => listing.transitionTo('ACTIVE')).toThrow();
});
it('should allow REJECTED -> DRAFT', () => {
const listing = makeDefaultListing();
listing.submitForReview();
listing.reject('Nội dung vi phạm');
listing.transitionTo('DRAFT');
expect(listing.status).toBe('DRAFT');
});
it('should increment view count', () => {
const listing = makeDefaultListing();
listing.incrementViewCount();
listing.incrementViewCount();
expect(listing.viewCount).toBe(2);
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { Address } from '../value-objects/address.vo';
import { GeoPoint } from '../value-objects/geo-point.vo';
import { Price } from '../value-objects/price.vo';
describe('Address', () => {
it('should create a valid address', () => {
const result = Address.create('123 Lê Lợi', 'Phường Bến Nghé', 'Quận 1', 'TP.HCM');
expect(result.isOk).toBe(true);
const addr = result.unwrap();
expect(addr.fullAddress).toBe('123 Lê Lợi, Phường Bến Nghé, Quận 1, TP.HCM');
});
it('should reject empty address', () => {
const result = Address.create('', 'Ward', 'District', 'City');
expect(result.isErr).toBe(true);
});
it('should reject empty ward', () => {
const result = Address.create('123 St', '', 'District', 'City');
expect(result.isErr).toBe(true);
});
});
describe('GeoPoint', () => {
it('should create a valid geo point', () => {
const result = GeoPoint.create(10.7769, 106.7009);
expect(result.isOk).toBe(true);
const point = result.unwrap();
expect(point.latitude).toBe(10.7769);
expect(point.longitude).toBe(106.7009);
});
it('should generate WKT', () => {
const point = GeoPoint.create(10.7769, 106.7009).unwrap();
expect(point.toWKT()).toBe('POINT(106.7009 10.7769)');
});
it('should reject invalid latitude', () => {
expect(GeoPoint.create(91, 0).isErr).toBe(true);
expect(GeoPoint.create(-91, 0).isErr).toBe(true);
});
it('should reject invalid longitude', () => {
expect(GeoPoint.create(0, 181).isErr).toBe(true);
expect(GeoPoint.create(0, -181).isErr).toBe(true);
});
});
describe('Price', () => {
it('should create a valid price', () => {
const result = Price.create(5_000_000_000n);
expect(result.isOk).toBe(true);
expect(result.unwrap().amountVND).toBe(5_000_000_000n);
});
it('should reject zero or negative price', () => {
expect(Price.create(0n).isErr).toBe(true);
expect(Price.create(-1n).isErr).toBe(true);
});
it('should calculate price per m2', () => {
const price = Price.create(5_000_000_000n).unwrap();
expect(price.calculatePerM2(100)).toBe(50_000_000);
});
it('should return 0 for zero area', () => {
const price = Price.create(5_000_000_000n).unwrap();
expect(price.calculatePerM2(0)).toBe(0);
});
});

View File

@@ -0,0 +1,3 @@
export { PropertyEntity, type PropertyProps } from './property.entity';
export { ListingEntity, type ListingProps } from './listing.entity';
export { PropertyMediaEntity, type PropertyMediaProps } from './property-media.entity';

View File

@@ -0,0 +1,188 @@
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
import { type ListingStatus, type TransactionType } from '@prisma/client';
import { type Price } from '../value-objects/price.vo';
import { ListingCreatedEvent } from '../events/listing-created.event';
import { ListingApprovedEvent } from '../events/listing-approved.event';
import { ListingSoldEvent } from '../events/listing-sold.event';
import { ValidationException } from '@modules/shared/domain/domain-exception';
const VALID_TRANSITIONS: Record<ListingStatus, ListingStatus[]> = {
DRAFT: ['PENDING_REVIEW'],
PENDING_REVIEW: ['ACTIVE', 'REJECTED'],
ACTIVE: ['RESERVED', 'SOLD', 'RENTED', 'EXPIRED'],
RESERVED: ['ACTIVE', 'SOLD', 'RENTED'],
SOLD: [],
RENTED: [],
EXPIRED: ['DRAFT'],
REJECTED: ['DRAFT'],
};
export interface ListingProps {
propertyId: string;
agentId: string | null;
sellerId: string;
transactionType: TransactionType;
status: ListingStatus;
price: Price;
pricePerM2: number | null;
rentPriceMonthly: bigint | null;
commissionPct: number | null;
aiPriceEstimate: bigint | null;
aiConfidence: number | null;
moderationScore: number | null;
moderationNotes: string | null;
viewCount: number;
saveCount: number;
inquiryCount: number;
featuredUntil: Date | null;
expiresAt: Date | null;
publishedAt: Date | null;
}
export class ListingEntity extends AggregateRoot<string> {
private _propertyId: string;
private _agentId: string | null;
private _sellerId: string;
private _transactionType: TransactionType;
private _status: ListingStatus;
private _price: Price;
private _pricePerM2: number | null;
private _rentPriceMonthly: bigint | null;
private _commissionPct: number | null;
private _aiPriceEstimate: bigint | null;
private _aiConfidence: number | null;
private _moderationScore: number | null;
private _moderationNotes: string | null;
private _viewCount: number;
private _saveCount: number;
private _inquiryCount: number;
private _featuredUntil: Date | null;
private _expiresAt: Date | null;
private _publishedAt: Date | null;
constructor(id: string, props: ListingProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt, updatedAt);
this._propertyId = props.propertyId;
this._agentId = props.agentId;
this._sellerId = props.sellerId;
this._transactionType = props.transactionType;
this._status = props.status;
this._price = props.price;
this._pricePerM2 = props.pricePerM2;
this._rentPriceMonthly = props.rentPriceMonthly;
this._commissionPct = props.commissionPct;
this._aiPriceEstimate = props.aiPriceEstimate;
this._aiConfidence = props.aiConfidence;
this._moderationScore = props.moderationScore;
this._moderationNotes = props.moderationNotes;
this._viewCount = props.viewCount;
this._saveCount = props.saveCount;
this._inquiryCount = props.inquiryCount;
this._featuredUntil = props.featuredUntil;
this._expiresAt = props.expiresAt;
this._publishedAt = props.publishedAt;
}
get propertyId(): string { return this._propertyId; }
get agentId(): string | null { return this._agentId; }
get sellerId(): string { return this._sellerId; }
get transactionType(): TransactionType { return this._transactionType; }
get status(): ListingStatus { return this._status; }
get price(): Price { return this._price; }
get pricePerM2(): number | null { return this._pricePerM2; }
get rentPriceMonthly(): bigint | null { return this._rentPriceMonthly; }
get commissionPct(): number | null { return this._commissionPct; }
get aiPriceEstimate(): bigint | null { return this._aiPriceEstimate; }
get aiConfidence(): number | null { return this._aiConfidence; }
get moderationScore(): number | null { return this._moderationScore; }
get moderationNotes(): string | null { return this._moderationNotes; }
get viewCount(): number { return this._viewCount; }
get saveCount(): number { return this._saveCount; }
get inquiryCount(): number { return this._inquiryCount; }
get featuredUntil(): Date | null { return this._featuredUntil; }
get expiresAt(): Date | null { return this._expiresAt; }
get publishedAt(): Date | null { return this._publishedAt; }
static createNew(
id: string,
propertyId: string,
sellerId: string,
transactionType: TransactionType,
price: Price,
areaM2: number,
agentId?: string,
rentPriceMonthly?: bigint,
commissionPct?: number,
): ListingEntity {
const listing = new ListingEntity(id, {
propertyId,
agentId: agentId ?? null,
sellerId,
transactionType,
status: 'DRAFT',
price,
pricePerM2: price.calculatePerM2(areaM2),
rentPriceMonthly: rentPriceMonthly ?? null,
commissionPct: commissionPct ?? 2.0,
aiPriceEstimate: null,
aiConfidence: null,
moderationScore: null,
moderationNotes: null,
viewCount: 0,
saveCount: 0,
inquiryCount: 0,
featuredUntil: null,
expiresAt: null,
publishedAt: null,
});
listing.addDomainEvent(new ListingCreatedEvent(id, propertyId, sellerId, transactionType));
return listing;
}
transitionTo(newStatus: ListingStatus): void {
const allowed = VALID_TRANSITIONS[this._status];
if (!allowed?.includes(newStatus)) {
throw new ValidationException(
`Không thể chuyển trạng thái từ ${this._status} sang ${newStatus}`,
{ currentStatus: this._status, targetStatus: newStatus },
);
}
const previousStatus = this._status;
this._status = newStatus;
this.updatedAt = new Date();
if (newStatus === 'ACTIVE' && previousStatus === 'PENDING_REVIEW') {
this._publishedAt = new Date();
this.addDomainEvent(new ListingApprovedEvent(this.id, this._propertyId));
}
if (newStatus === 'SOLD' || newStatus === 'RENTED') {
this.addDomainEvent(new ListingSoldEvent(this.id, this._propertyId, newStatus));
}
}
submitForReview(): void {
this.transitionTo('PENDING_REVIEW');
}
approve(): void {
this.transitionTo('ACTIVE');
}
reject(notes: string): void {
this._moderationNotes = notes;
this.transitionTo('REJECTED');
}
setModerationScore(score: number, notes?: string): void {
this._moderationScore = score;
if (notes) this._moderationNotes = notes;
this.updatedAt = new Date();
}
incrementViewCount(): void {
this._viewCount++;
}
}

View File

@@ -0,0 +1,54 @@
import { BaseEntity } from '@modules/shared/domain/base-entity';
export interface PropertyMediaProps {
propertyId: string;
url: string;
type: 'image' | 'video';
order: number;
caption: string | null;
aiTags: unknown;
}
export class PropertyMediaEntity extends BaseEntity<string> {
private _propertyId: string;
private _url: string;
private _type: 'image' | 'video';
private _order: number;
private _caption: string | null;
private _aiTags: unknown;
constructor(id: string, props: PropertyMediaProps, createdAt?: Date) {
super(id, createdAt);
this._propertyId = props.propertyId;
this._url = props.url;
this._type = props.type;
this._order = props.order;
this._caption = props.caption;
this._aiTags = props.aiTags;
}
get propertyId(): string { return this._propertyId; }
get url(): string { return this._url; }
get type(): 'image' | 'video' { return this._type; }
get order(): number { return this._order; }
get caption(): string | null { return this._caption; }
get aiTags(): unknown { return this._aiTags; }
static createNew(
id: string,
propertyId: string,
url: string,
type: 'image' | 'video',
order: number,
caption?: string,
): PropertyMediaEntity {
return new PropertyMediaEntity(id, {
propertyId,
url,
type,
order,
caption: caption ?? null,
aiTags: null,
});
}
}

View File

@@ -0,0 +1,95 @@
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
import { type PropertyType, type Direction } from '@prisma/client';
import { type Address } from '../value-objects/address.vo';
import { type GeoPoint } from '../value-objects/geo-point.vo';
export interface PropertyProps {
propertyType: PropertyType;
title: string;
description: string;
address: Address;
location: GeoPoint;
areaM2: number;
usableAreaM2: number | null;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
floor: number | null;
totalFloors: number | null;
direction: Direction | null;
yearBuilt: number | null;
legalStatus: string | null;
amenities: unknown;
nearbyPOIs: unknown;
metroDistanceM: number | null;
projectName: string | null;
}
export class PropertyEntity extends AggregateRoot<string> {
private _propertyType: PropertyType;
private _title: string;
private _description: string;
private _address: Address;
private _location: GeoPoint;
private _areaM2: number;
private _usableAreaM2: number | null;
private _bedrooms: number | null;
private _bathrooms: number | null;
private _floors: number | null;
private _floor: number | null;
private _totalFloors: number | null;
private _direction: Direction | null;
private _yearBuilt: number | null;
private _legalStatus: string | null;
private _amenities: unknown;
private _nearbyPOIs: unknown;
private _metroDistanceM: number | null;
private _projectName: string | null;
constructor(id: string, props: PropertyProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt, updatedAt);
this._propertyType = props.propertyType;
this._title = props.title;
this._description = props.description;
this._address = props.address;
this._location = props.location;
this._areaM2 = props.areaM2;
this._usableAreaM2 = props.usableAreaM2;
this._bedrooms = props.bedrooms;
this._bathrooms = props.bathrooms;
this._floors = props.floors;
this._floor = props.floor;
this._totalFloors = props.totalFloors;
this._direction = props.direction;
this._yearBuilt = props.yearBuilt;
this._legalStatus = props.legalStatus;
this._amenities = props.amenities;
this._nearbyPOIs = props.nearbyPOIs;
this._metroDistanceM = props.metroDistanceM;
this._projectName = props.projectName;
}
get propertyType(): PropertyType { return this._propertyType; }
get title(): string { return this._title; }
get description(): string { return this._description; }
get address(): Address { return this._address; }
get location(): GeoPoint { return this._location; }
get areaM2(): number { return this._areaM2; }
get usableAreaM2(): number | null { return this._usableAreaM2; }
get bedrooms(): number | null { return this._bedrooms; }
get bathrooms(): number | null { return this._bathrooms; }
get floors(): number | null { return this._floors; }
get floor(): number | null { return this._floor; }
get totalFloors(): number | null { return this._totalFloors; }
get direction(): Direction | null { return this._direction; }
get yearBuilt(): number | null { return this._yearBuilt; }
get legalStatus(): string | null { return this._legalStatus; }
get amenities(): unknown { return this._amenities; }
get nearbyPOIs(): unknown { return this._nearbyPOIs; }
get metroDistanceM(): number | null { return this._metroDistanceM; }
get projectName(): string | null { return this._projectName; }
static createNew(id: string, props: PropertyProps): PropertyEntity {
return new PropertyEntity(id, props);
}
}

View File

@@ -0,0 +1,3 @@
export { ListingCreatedEvent } from './listing-created.event';
export { ListingApprovedEvent } from './listing-approved.event';
export { ListingSoldEvent } from './listing-sold.event';

View File

@@ -0,0 +1,11 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
export class ListingApprovedEvent implements DomainEvent {
readonly eventName = 'listing.approved';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly propertyId: string,
) {}
}

View File

@@ -0,0 +1,14 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type TransactionType } from '@prisma/client';
export class ListingCreatedEvent implements DomainEvent {
readonly eventName = 'listing.created';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly propertyId: string,
public readonly sellerId: string,
public readonly transactionType: TransactionType,
) {}
}

View File

@@ -0,0 +1,13 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type ListingStatus } from '@prisma/client';
export class ListingSoldEvent implements DomainEvent {
readonly eventName = 'listing.sold';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly propertyId: string,
public readonly finalStatus: ListingStatus,
) {}
}

View File

@@ -0,0 +1,4 @@
export * from './entities';
export * from './events';
export * from './repositories';
export * from './value-objects';

View File

@@ -0,0 +1,2 @@
export { PROPERTY_REPOSITORY, type IPropertyRepository } from './property.repository';
export { LISTING_REPOSITORY, type IListingRepository, type ListingSearchParams, type PaginatedResult } from './listing.repository';

View File

@@ -0,0 +1,37 @@
import { type ListingStatus } from '@prisma/client';
import { type ListingEntity } from '../entities/listing.entity';
export const LISTING_REPOSITORY = Symbol('LISTING_REPOSITORY');
export interface ListingSearchParams {
status?: ListingStatus;
transactionType?: string;
propertyType?: string;
city?: string;
district?: string;
minPrice?: bigint;
maxPrice?: bigint;
minArea?: number;
maxArea?: number;
bedrooms?: number;
page?: number;
limit?: number;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface IListingRepository {
findById(id: string): Promise<ListingEntity | null>;
findByIdWithProperty(id: string): Promise<{ listing: ListingEntity; property: any } | null>;
save(listing: ListingEntity): Promise<void>;
update(listing: ListingEntity): Promise<void>;
search(params: ListingSearchParams): Promise<PaginatedResult<any>>;
findByStatus(status: ListingStatus, page: number, limit: number): Promise<PaginatedResult<any>>;
findBySellerId(sellerId: string, page: number, limit: number): Promise<PaginatedResult<any>>;
}

View File

@@ -0,0 +1,14 @@
import { type PropertyEntity } from '../entities/property.entity';
import { type PropertyMediaEntity } from '../entities/property-media.entity';
export const PROPERTY_REPOSITORY = Symbol('PROPERTY_REPOSITORY');
export interface IPropertyRepository {
findById(id: string): Promise<PropertyEntity | null>;
save(property: PropertyEntity): Promise<void>;
update(property: PropertyEntity): Promise<void>;
addMedia(media: PropertyMediaEntity): Promise<void>;
findMediaByPropertyId(propertyId: string): Promise<PropertyMediaEntity[]>;
deleteMedia(mediaId: string): Promise<void>;
countMediaByPropertyId(propertyId: string): Promise<number>;
}

View File

@@ -0,0 +1,34 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result } from '@modules/shared/domain/result';
interface AddressProps {
address: string;
ward: string;
district: string;
city: string;
}
export class Address extends ValueObject<AddressProps> {
get address(): string { return this.props.address; }
get ward(): string { return this.props.ward; }
get district(): string { return this.props.district; }
get city(): string { return this.props.city; }
get fullAddress(): string {
return `${this.props.address}, ${this.props.ward}, ${this.props.district}, ${this.props.city}`;
}
static create(address: string, ward: string, district: string, city: string): Result<Address, string> {
if (!address?.trim()) return Result.err('Địa chỉ không được để trống');
if (!ward?.trim()) return Result.err('Phường/xã không được để trống');
if (!district?.trim()) return Result.err('Quận/huyện không được để trống');
if (!city?.trim()) return Result.err('Thành phố không được để trống');
return Result.ok(new Address({
address: address.trim(),
ward: ward.trim(),
district: district.trim(),
city: city.trim(),
}));
}
}

View File

@@ -0,0 +1,26 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result } from '@modules/shared/domain/result';
interface GeoPointProps {
latitude: number;
longitude: number;
}
export class GeoPoint extends ValueObject<GeoPointProps> {
get latitude(): number { return this.props.latitude; }
get longitude(): number { return this.props.longitude; }
static create(latitude: number, longitude: number): Result<GeoPoint, string> {
if (latitude < -90 || latitude > 90) {
return Result.err('Vĩ độ phải nằm trong khoảng -90 đến 90');
}
if (longitude < -180 || longitude > 180) {
return Result.err('Kinh độ phải nằm trong khoảng -180 đến 180');
}
return Result.ok(new GeoPoint({ latitude, longitude }));
}
toWKT(): string {
return `POINT(${this.props.longitude} ${this.props.latitude})`;
}
}

View File

@@ -0,0 +1,3 @@
export { Address } from './address.vo';
export { GeoPoint } from './geo-point.vo';
export { Price } from './price.vo';

View File

@@ -0,0 +1,22 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result } from '@modules/shared/domain/result';
interface PriceProps {
amountVND: bigint;
}
export class Price extends ValueObject<PriceProps> {
get amountVND(): bigint { return this.props.amountVND; }
static create(amountVND: bigint): Result<Price, string> {
if (amountVND <= 0n) {
return Result.err('Giá phải lớn hơn 0');
}
return Result.ok(new Price({ amountVND }));
}
calculatePerM2(areaM2: number): number {
if (areaM2 <= 0) return 0;
return Number(this.props.amountVND) / areaM2;
}
}