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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
3
apps/api/src/modules/listings/domain/entities/index.ts
Normal file
3
apps/api/src/modules/listings/domain/entities/index.ts
Normal 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';
|
||||
188
apps/api/src/modules/listings/domain/entities/listing.entity.ts
Normal file
188
apps/api/src/modules/listings/domain/entities/listing.entity.ts
Normal 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++;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
3
apps/api/src/modules/listings/domain/events/index.ts
Normal file
3
apps/api/src/modules/listings/domain/events/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ListingCreatedEvent } from './listing-created.event';
|
||||
export { ListingApprovedEvent } from './listing-approved.event';
|
||||
export { ListingSoldEvent } from './listing-sold.event';
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
4
apps/api/src/modules/listings/domain/index.ts
Normal file
4
apps/api/src/modules/listings/domain/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './entities';
|
||||
export * from './events';
|
||||
export * from './repositories';
|
||||
export * from './value-objects';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PROPERTY_REPOSITORY, type IPropertyRepository } from './property.repository';
|
||||
export { LISTING_REPOSITORY, type IListingRepository, type ListingSearchParams, type PaginatedResult } from './listing.repository';
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Address } from './address.vo';
|
||||
export { GeoPoint } from './geo-point.vo';
|
||||
export { Price } from './price.vo';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user