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,38 @@
import { type PropertyType, type TransactionType, type Direction } from '@prisma/client';
export class CreateListingCommand {
constructor(
public readonly sellerId: string,
public readonly transactionType: TransactionType,
public readonly priceVND: bigint,
// Property details
public readonly propertyType: PropertyType,
public readonly title: string,
public readonly description: string,
public readonly address: string,
public readonly ward: string,
public readonly district: string,
public readonly city: string,
public readonly latitude: number,
public readonly longitude: number,
public readonly areaM2: number,
// Optional property fields
public readonly usableAreaM2?: number,
public readonly bedrooms?: number,
public readonly bathrooms?: number,
public readonly floors?: number,
public readonly floor?: number,
public readonly totalFloors?: number,
public readonly direction?: Direction,
public readonly yearBuilt?: number,
public readonly legalStatus?: string,
public readonly amenities?: unknown,
public readonly nearbyPOIs?: unknown,
public readonly metroDistanceM?: number,
public readonly projectName?: string,
// Optional listing fields
public readonly agentId?: string,
public readonly rentPriceMonthly?: bigint,
public readonly commissionPct?: number,
) {}
}

View File

@@ -0,0 +1,96 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject, BadRequestException } from '@nestjs/common';
import { createId } from '@paralleldrive/cuid2';
import { CreateListingCommand } from './create-listing.command';
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import { PropertyEntity } from '../../../domain/entities/property.entity';
import { ListingEntity } from '../../../domain/entities/listing.entity';
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';
export interface CreateListingResult {
listingId: string;
propertyId: string;
status: string;
}
@CommandHandler(CreateListingCommand)
export class CreateListingHandler implements ICommandHandler<CreateListingCommand> {
constructor(
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: CreateListingCommand): Promise<CreateListingResult> {
// Validate value objects
const addressResult = Address.create(command.address, command.ward, command.district, command.city);
if (addressResult.isErr) throw new BadRequestException(addressResult.unwrapErr());
const geoPointResult = GeoPoint.create(command.latitude, command.longitude);
if (geoPointResult.isErr) throw new BadRequestException(geoPointResult.unwrapErr());
const priceResult = Price.create(command.priceVND);
if (priceResult.isErr) throw new BadRequestException(priceResult.unwrapErr());
const address = addressResult.unwrap();
const geoPoint = geoPointResult.unwrap();
const price = priceResult.unwrap();
// Create property
const propertyId = createId();
const property = PropertyEntity.createNew(propertyId, {
propertyType: command.propertyType,
title: command.title,
description: command.description,
address,
location: geoPoint,
areaM2: command.areaM2,
usableAreaM2: command.usableAreaM2 ?? null,
bedrooms: command.bedrooms ?? null,
bathrooms: command.bathrooms ?? null,
floors: command.floors ?? null,
floor: command.floor ?? null,
totalFloors: command.totalFloors ?? null,
direction: command.direction ?? null,
yearBuilt: command.yearBuilt ?? null,
legalStatus: command.legalStatus ?? null,
amenities: command.amenities ?? null,
nearbyPOIs: command.nearbyPOIs ?? null,
metroDistanceM: command.metroDistanceM ?? null,
projectName: command.projectName ?? null,
});
await this.propertyRepo.save(property);
// Create listing
const listingId = createId();
const listing = ListingEntity.createNew(
listingId,
propertyId,
command.sellerId,
command.transactionType,
price,
command.areaM2,
command.agentId,
command.rentPriceMonthly,
command.commissionPct,
);
await this.listingRepo.save(listing);
// Publish domain events
const events = [...property.clearDomainEvents(), ...listing.clearDomainEvents()];
for (const event of events) {
this.eventBus.publish(event);
}
return {
listingId,
propertyId,
status: listing.status,
};
}
}

View File

@@ -0,0 +1,9 @@
export class ModerateListingCommand {
constructor(
public readonly listingId: string,
public readonly moderatorId: string,
public readonly action: 'approve' | 'reject',
public readonly moderationScore?: number,
public readonly notes?: string,
) {}
}

View File

@@ -0,0 +1,39 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { ModerateListingCommand } from './moderate-listing.command';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
@CommandHandler(ModerateListingCommand)
export class ModerateListingHandler implements ICommandHandler<ModerateListingCommand> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: ModerateListingCommand): Promise<{ status: string }> {
const listing = await this.listingRepo.findById(command.listingId);
if (!listing) {
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');
}
await this.listingRepo.update(listing);
const events = listing.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
return { status: listing.status };
}
}

View File

@@ -0,0 +1,10 @@
import { type ListingStatus } from '@prisma/client';
export class UpdateListingStatusCommand {
constructor(
public readonly listingId: string,
public readonly newStatus: ListingStatus,
public readonly userId: string,
public readonly moderationNotes?: string,
) {}
}

View File

@@ -0,0 +1,37 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { UpdateListingStatusCommand } from './update-listing-status.command';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
@CommandHandler(UpdateListingStatusCommand)
export class UpdateListingStatusHandler implements ICommandHandler<UpdateListingStatusCommand> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: UpdateListingStatusCommand): Promise<{ status: string }> {
const listing = await this.listingRepo.findById(command.listingId);
if (!listing) {
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);
}
await this.listingRepo.update(listing);
const events = listing.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
return { status: listing.status };
}
}

View File

@@ -0,0 +1,13 @@
export class UploadMediaCommand {
constructor(
public readonly propertyId: string,
public readonly userId: string,
public readonly file: {
buffer: Buffer;
mimetype: string;
originalname: string;
size: number;
},
public readonly caption?: string,
) {}
}

View File

@@ -0,0 +1,53 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { Inject, BadRequestException } from '@nestjs/common';
import { createId } from '@paralleldrive/cuid2';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { UploadMediaCommand } from './upload-media.command';
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
import { PropertyMediaEntity } from '../../../domain/entities/property-media.entity';
import { MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '../../../infrastructure/services/media-storage.service';
const MAX_MEDIA_PER_PROPERTY = 20;
@CommandHandler(UploadMediaCommand)
export class UploadMediaHandler implements ICommandHandler<UploadMediaCommand> {
constructor(
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
@Inject(MEDIA_STORAGE_SERVICE) private readonly mediaStorage: IMediaStorageService,
) {}
async execute(command: UploadMediaCommand): Promise<{ mediaId: string; url: string }> {
const property = await this.propertyRepo.findById(command.propertyId);
if (!property) {
throw new NotFoundException('Property', command.propertyId);
}
const mediaCount = await this.propertyRepo.countMediaByPropertyId(command.propertyId);
if (mediaCount >= MAX_MEDIA_PER_PROPERTY) {
throw new BadRequestException(`Tối đa ${MAX_MEDIA_PER_PROPERTY} ảnh/video cho mỗi bất động sản`);
}
const mediaType = command.file.mimetype.startsWith('video/') ? 'video' as const : 'image' as const;
const url = await this.mediaStorage.upload(
command.file.buffer,
command.file.originalname,
command.file.mimetype,
`properties/${command.propertyId}`,
);
const mediaId = createId();
const media = PropertyMediaEntity.createNew(
mediaId,
command.propertyId,
url,
mediaType,
mediaCount, // next order index
command.caption,
);
await this.propertyRepo.addMedia(media);
return { mediaId, url };
}
}

View File

@@ -0,0 +1,17 @@
// Commands
export { CreateListingCommand } from './commands/create-listing/create-listing.command';
export { CreateListingHandler, type CreateListingResult } from './commands/create-listing/create-listing.handler';
export { UpdateListingStatusCommand } from './commands/update-listing-status/update-listing-status.command';
export { UpdateListingStatusHandler } from './commands/update-listing-status/update-listing-status.handler';
export { UploadMediaCommand } from './commands/upload-media/upload-media.command';
export { UploadMediaHandler } from './commands/upload-media/upload-media.handler';
export { ModerateListingCommand } from './commands/moderate-listing/moderate-listing.command';
export { ModerateListingHandler } from './commands/moderate-listing/moderate-listing.handler';
// Queries
export { GetListingQuery } from './queries/get-listing/get-listing.query';
export { GetListingHandler, type ListingDetailDto } from './queries/get-listing/get-listing.handler';
export { SearchListingsQuery } from './queries/search-listings/search-listings.query';
export { SearchListingsHandler } from './queries/search-listings/search-listings.handler';
export { GetPendingModerationQuery } from './queries/get-pending-moderation/get-pending-moderation.query';
export { GetPendingModerationHandler } from './queries/get-pending-moderation/get-pending-moderation.handler';

View File

@@ -0,0 +1,71 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { GetListingQuery } from './get-listing.query';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
export interface ListingDetailDto {
id: string;
status: string;
transactionType: string;
priceVND: string;
pricePerM2: number | null;
rentPriceMonthly: string | null;
commissionPct: number | null;
viewCount: number;
saveCount: number;
inquiryCount: number;
publishedAt: string | null;
createdAt: string;
property: {
id: string;
propertyType: string;
title: string;
description: string;
address: string;
ward: string;
district: string;
city: string;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
direction: string | null;
yearBuilt: number | null;
legalStatus: string | null;
amenities: unknown;
projectName: string | null;
media: Array<{
id: string;
url: string;
type: string;
order: number;
caption: string | null;
}>;
};
seller: {
id: string;
fullName: string;
phone: string;
};
agent: {
id: string;
userId: string;
agency: string | null;
} | null;
}
@QueryHandler(GetListingQuery)
export class GetListingHandler implements IQueryHandler<GetListingQuery> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
) {}
async execute(query: GetListingQuery): Promise<ListingDetailDto> {
const result = await this.listingRepo.findByIdWithProperty(query.listingId);
if (!result) {
throw new NotFoundException('Listing', query.listingId);
}
return result as unknown as ListingDetailDto;
}
}

View File

@@ -0,0 +1,3 @@
export class GetListingQuery {
constructor(public readonly listingId: string) {}
}

View File

@@ -0,0 +1,15 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { GetPendingModerationQuery } from './get-pending-moderation.query';
import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository';
@QueryHandler(GetPendingModerationQuery)
export class GetPendingModerationHandler implements IQueryHandler<GetPendingModerationQuery> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
) {}
async execute(query: GetPendingModerationQuery): Promise<PaginatedResult<any>> {
return this.listingRepo.findByStatus('PENDING_REVIEW', query.page, query.limit);
}
}

View File

@@ -0,0 +1,6 @@
export class GetPendingModerationQuery {
constructor(
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -0,0 +1,28 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { SearchListingsQuery } from './search-listings.query';
import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository';
@QueryHandler(SearchListingsQuery)
export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
) {}
async execute(query: SearchListingsQuery): Promise<PaginatedResult<any>> {
return this.listingRepo.search({
status: query.status,
transactionType: query.transactionType,
propertyType: query.propertyType,
city: query.city,
district: query.district,
minPrice: query.minPrice,
maxPrice: query.maxPrice,
minArea: query.minArea,
maxArea: query.maxArea,
bedrooms: query.bedrooms,
page: query.page,
limit: query.limit,
});
}
}

View File

@@ -0,0 +1,18 @@
import { type ListingStatus } from '@prisma/client';
export class SearchListingsQuery {
constructor(
public readonly status?: ListingStatus,
public readonly transactionType?: string,
public readonly propertyType?: string,
public readonly city?: string,
public readonly district?: string,
public readonly minPrice?: bigint,
public readonly maxPrice?: bigint,
public readonly minArea?: number,
public readonly maxArea?: number,
public readonly bedrooms?: number,
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

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

View File

@@ -0,0 +1 @@
export { ListingsModule } from './listings.module';

View File

@@ -0,0 +1,2 @@
export * from './repositories';
export * from './services';

View File

@@ -0,0 +1,2 @@
export { PrismaPropertyRepository } from './prisma-property.repository';
export { PrismaListingRepository } from './prisma-listing.repository';

View File

@@ -0,0 +1,273 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type Listing as PrismaListing, type Prisma, type ListingStatus } from '@prisma/client';
import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
import { Price } from '../../domain/value-objects/price.vo';
@Injectable()
export class PrismaListingRepository implements IListingRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<ListingEntity | null> {
const listing = await this.prisma.listing.findUnique({ where: { id } });
return listing ? this.toDomain(listing) : null;
}
async findByIdWithProperty(id: string): Promise<any | null> {
const listing = await this.prisma.listing.findUnique({
where: { id },
include: {
property: {
include: {
media: { orderBy: { order: 'asc' } },
},
},
seller: { select: { id: true, fullName: true, phone: true } },
agent: { select: { id: true, userId: true, agency: true } },
},
});
if (!listing) return null;
return {
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
pricePerM2: listing.pricePerM2,
rentPriceMonthly: listing.rentPriceMonthly?.toString() ?? null,
commissionPct: listing.commissionPct,
viewCount: listing.viewCount,
saveCount: listing.saveCount,
inquiryCount: listing.inquiryCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
createdAt: listing.createdAt.toISOString(),
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
title: listing.property.title,
description: listing.property.description,
address: listing.property.address,
ward: listing.property.ward,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
floors: listing.property.floors,
direction: listing.property.direction,
yearBuilt: listing.property.yearBuilt,
legalStatus: listing.property.legalStatus,
amenities: listing.property.amenities,
projectName: listing.property.projectName,
media: listing.property.media.map((m) => ({
id: m.id,
url: m.url,
type: m.type,
order: m.order,
caption: m.caption,
})),
},
seller: listing.seller,
agent: listing.agent,
};
}
async save(entity: ListingEntity): Promise<void> {
await this.prisma.listing.create({
data: {
id: entity.id,
propertyId: entity.propertyId,
agentId: entity.agentId,
sellerId: entity.sellerId,
transactionType: entity.transactionType,
status: entity.status,
priceVND: entity.price.amountVND,
pricePerM2: entity.pricePerM2,
rentPriceMonthly: entity.rentPriceMonthly,
commissionPct: entity.commissionPct,
aiPriceEstimate: entity.aiPriceEstimate,
aiConfidence: entity.aiConfidence,
moderationScore: entity.moderationScore,
moderationNotes: entity.moderationNotes,
viewCount: entity.viewCount,
saveCount: entity.saveCount,
inquiryCount: entity.inquiryCount,
featuredUntil: entity.featuredUntil,
expiresAt: entity.expiresAt,
publishedAt: entity.publishedAt,
},
});
}
async update(entity: ListingEntity): Promise<void> {
await this.prisma.listing.update({
where: { id: entity.id },
data: {
status: entity.status,
priceVND: entity.price.amountVND,
pricePerM2: entity.pricePerM2,
rentPriceMonthly: entity.rentPriceMonthly,
commissionPct: entity.commissionPct,
moderationScore: entity.moderationScore,
moderationNotes: entity.moderationNotes,
viewCount: entity.viewCount,
saveCount: entity.saveCount,
inquiryCount: entity.inquiryCount,
featuredUntil: entity.featuredUntil,
expiresAt: entity.expiresAt,
publishedAt: entity.publishedAt,
},
});
}
async search(params: ListingSearchParams): Promise<PaginatedResult<any>> {
const page = params.page ?? 1;
const limit = Math.min(params.limit ?? 20, 100);
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = {};
if (params.status) where.status = params.status;
if (params.transactionType) where.transactionType = params.transactionType as any;
if (params.minPrice || params.maxPrice) {
where.priceVND = {};
if (params.minPrice) where.priceVND.gte = params.minPrice;
if (params.maxPrice) where.priceVND.lte = params.maxPrice;
}
if (params.propertyType || params.city || params.district || params.minArea || params.maxArea || params.bedrooms) {
where.property = {};
if (params.propertyType) where.property.propertyType = params.propertyType as any;
if (params.city) where.property.city = params.city;
if (params.district) where.property.district = params.district;
if (params.minArea || params.maxArea) {
where.property.areaM2 = {};
if (params.minArea) where.property.areaM2.gte = params.minArea;
if (params.maxArea) where.property.areaM2.lte = params.maxArea;
}
if (params.bedrooms) where.property.bedrooms = { gte: params.bedrooms };
}
const [data, total] = await Promise.all([
this.prisma.listing.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
property: {
include: {
media: { orderBy: { order: 'asc' }, take: 1 },
},
},
seller: { select: { id: true, fullName: true } },
},
}),
this.prisma.listing.count({ where }),
]);
return {
data: data.map((listing) => ({
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
pricePerM2: listing.pricePerM2,
viewCount: listing.viewCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
title: listing.property.title,
address: listing.property.address,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
thumbnail: listing.property.media[0]?.url ?? null,
},
seller: listing.seller,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findByStatus(status: ListingStatus, page: number, limit: number): Promise<PaginatedResult<any>> {
return this.search({ status, page, limit });
}
async findBySellerId(sellerId: string, page: number, limit: number): Promise<PaginatedResult<any>> {
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = { sellerId };
const [data, total] = await Promise.all([
this.prisma.listing.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
property: {
include: { media: { orderBy: { order: 'asc' }, take: 1 } },
},
},
}),
this.prisma.listing.count({ where }),
]);
return {
data: data.map((listing) => ({
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
property: {
id: listing.property.id,
title: listing.property.title,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
thumbnail: listing.property.media[0]?.url ?? null,
},
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
private toDomain(raw: PrismaListing): ListingEntity {
const price = Price.create(raw.priceVND).unwrap();
const props: ListingProps = {
propertyId: raw.propertyId,
agentId: raw.agentId,
sellerId: raw.sellerId,
transactionType: raw.transactionType,
status: raw.status,
price,
pricePerM2: raw.pricePerM2,
rentPriceMonthly: raw.rentPriceMonthly,
commissionPct: raw.commissionPct,
aiPriceEstimate: raw.aiPriceEstimate,
aiConfidence: raw.aiConfidence,
moderationScore: raw.moderationScore,
moderationNotes: raw.moderationNotes,
viewCount: raw.viewCount,
saveCount: raw.saveCount,
inquiryCount: raw.inquiryCount,
featuredUntil: raw.featuredUntil,
expiresAt: raw.expiresAt,
publishedAt: raw.publishedAt,
};
return new ListingEntity(raw.id, props, raw.createdAt, raw.updatedAt);
}
}

View File

@@ -0,0 +1,135 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type Property as PrismaProperty, type PropertyMedia as PrismaMedia } from '@prisma/client';
import { type IPropertyRepository } from '../../domain/repositories/property.repository';
import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity';
import { PropertyMediaEntity, type PropertyMediaProps } from '../../domain/entities/property-media.entity';
import { Address } from '../../domain/value-objects/address.vo';
import { GeoPoint } from '../../domain/value-objects/geo-point.vo';
@Injectable()
export class PrismaPropertyRepository implements IPropertyRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<PropertyEntity | null> {
const property = await this.prisma.property.findUnique({ where: { id } });
return property ? this.toDomain(property) : null;
}
async save(entity: PropertyEntity): Promise<void> {
await this.prisma.$executeRaw`
INSERT INTO "Property" (
"id", "propertyType", "title", "description", "address", "ward", "district", "city",
"location", "areaM2", "usableAreaM2", "bedrooms", "bathrooms", "floors", "floor",
"totalFloors", "direction", "yearBuilt", "legalStatus", "amenities", "nearbyPOIs",
"metroDistanceM", "projectName", "createdAt", "updatedAt"
) VALUES (
${entity.id}, ${entity.propertyType}::"PropertyType", ${entity.title}, ${entity.description},
${entity.address.address}, ${entity.address.ward}, ${entity.address.district}, ${entity.address.city},
ST_SetSRID(ST_MakePoint(${entity.location.longitude}, ${entity.location.latitude}), 4326),
${entity.areaM2}, ${entity.usableAreaM2}, ${entity.bedrooms}, ${entity.bathrooms},
${entity.floors}, ${entity.floor}, ${entity.totalFloors},
${entity.direction}::"Direction", ${entity.yearBuilt}, ${entity.legalStatus},
${entity.amenities ? JSON.stringify(entity.amenities) : null}::jsonb,
${entity.nearbyPOIs ? JSON.stringify(entity.nearbyPOIs) : null}::jsonb,
${entity.metroDistanceM}, ${entity.projectName},
${entity.createdAt}, ${entity.updatedAt}
)`;
}
async update(entity: PropertyEntity): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "Property" SET
"propertyType" = ${entity.propertyType}::"PropertyType",
"title" = ${entity.title},
"description" = ${entity.description},
"address" = ${entity.address.address},
"ward" = ${entity.address.ward},
"district" = ${entity.address.district},
"city" = ${entity.address.city},
"location" = ST_SetSRID(ST_MakePoint(${entity.location.longitude}, ${entity.location.latitude}), 4326),
"areaM2" = ${entity.areaM2},
"usableAreaM2" = ${entity.usableAreaM2},
"bedrooms" = ${entity.bedrooms},
"bathrooms" = ${entity.bathrooms},
"direction" = ${entity.direction}::"Direction",
"yearBuilt" = ${entity.yearBuilt},
"legalStatus" = ${entity.legalStatus},
"projectName" = ${entity.projectName},
"updatedAt" = NOW()
WHERE "id" = ${entity.id}`;
}
async addMedia(media: PropertyMediaEntity): Promise<void> {
await this.prisma.propertyMedia.create({
data: {
id: media.id,
propertyId: media.propertyId,
url: media.url,
type: media.type,
order: media.order,
caption: media.caption,
aiTags: media.aiTags as any,
},
});
}
async findMediaByPropertyId(propertyId: string): Promise<PropertyMediaEntity[]> {
const mediaList = await this.prisma.propertyMedia.findMany({
where: { propertyId },
orderBy: { order: 'asc' },
});
return mediaList.map((m) => this.toMediaDomain(m));
}
async deleteMedia(mediaId: string): Promise<void> {
await this.prisma.propertyMedia.delete({ where: { id: mediaId } });
}
async countMediaByPropertyId(propertyId: string): Promise<number> {
return this.prisma.propertyMedia.count({ where: { propertyId } });
}
private toDomain(raw: PrismaProperty): PropertyEntity {
// PostGIS geometry is returned as a raw object — extract lat/lng
// For raw SQL results we'd get WKB, but Prisma returns Unsupported as-is
const geoPoint = GeoPoint.create(0, 0).unwrap(); // placeholder — location read via raw SQL
const address = Address.create(raw.address, raw.ward, raw.district, raw.city).unwrap();
const props: PropertyProps = {
propertyType: raw.propertyType,
title: raw.title,
description: raw.description,
address,
location: geoPoint,
areaM2: raw.areaM2,
usableAreaM2: raw.usableAreaM2,
bedrooms: raw.bedrooms,
bathrooms: raw.bathrooms,
floors: raw.floors,
floor: raw.floor,
totalFloors: raw.totalFloors,
direction: raw.direction,
yearBuilt: raw.yearBuilt,
legalStatus: raw.legalStatus,
amenities: raw.amenities,
nearbyPOIs: raw.nearbyPOIs,
metroDistanceM: raw.metroDistanceM,
projectName: raw.projectName,
};
return new PropertyEntity(raw.id, props, raw.createdAt, raw.updatedAt);
}
private toMediaDomain(raw: PrismaMedia): PropertyMediaEntity {
const props: PropertyMediaProps = {
propertyId: raw.propertyId,
url: raw.url,
type: raw.type as 'image' | 'video',
order: raw.order,
caption: raw.caption,
aiTags: raw.aiTags,
};
return new PropertyMediaEntity(raw.id, props, raw.createdAt);
}
}

View File

@@ -0,0 +1 @@
export { MEDIA_STORAGE_SERVICE, type IMediaStorageService, MinioMediaStorageService } from './media-storage.service';

View File

@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
import * as crypto from 'crypto';
import * as path from 'path';
export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE');
export interface IMediaStorageService {
upload(buffer: Buffer, originalName: string, mimeType: string, folder: string): Promise<string>;
delete(fileUrl: string): Promise<void>;
}
@Injectable()
export class MinioMediaStorageService implements IMediaStorageService {
private readonly endpoint: string;
private readonly port: number;
private readonly accessKey: string;
private readonly secretKey: string;
private readonly bucket: string;
private readonly useSSL: boolean;
constructor(private readonly logger: LoggerService) {
this.endpoint = process.env['MINIO_ENDPOINT'] || 'localhost';
this.port = parseInt(process.env['MINIO_PORT'] || '9000', 10);
this.accessKey = process.env['MINIO_ACCESS_KEY'] || 'minioadmin';
this.secretKey = process.env['MINIO_SECRET_KEY'] || 'minioadmin';
this.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media';
this.useSSL = process.env['MINIO_USE_SSL'] === 'true';
}
async upload(buffer: Buffer, originalName: string, mimeType: string, folder: string): Promise<string> {
const ext = path.extname(originalName);
const hash = crypto.createHash('md5').update(buffer).digest('hex').slice(0, 12);
const objectName = `${folder}/${Date.now()}-${hash}${ext}`;
const protocol = this.useSSL ? 'https' : 'http';
const url = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectName}`;
try {
// PUT object via MinIO S3-compatible API
const putUrl = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectName}`;
const response = await fetch(putUrl, {
method: 'PUT',
headers: {
'Content-Type': mimeType,
'Content-Length': buffer.length.toString(),
},
body: buffer,
});
if (!response.ok) {
throw new Error(`MinIO upload failed: ${response.status} ${response.statusText}`);
}
this.logger.log(`Media uploaded: ${objectName}`, 'MinioMediaStorageService');
return url;
} catch (error) {
this.logger.error(`Media upload failed: ${objectName}`, String(error), 'MinioMediaStorageService');
throw error;
}
}
async delete(fileUrl: string): Promise<void> {
try {
const urlObj = new URL(fileUrl);
const objectPath = urlObj.pathname.replace(`/${this.bucket}/`, '');
const protocol = this.useSSL ? 'https' : 'http';
const deleteUrl = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectPath}`;
await fetch(deleteUrl, { method: 'DELETE' });
this.logger.log(`Media deleted: ${objectPath}`, 'MinioMediaStorageService');
} catch (error) {
this.logger.error(`Media delete failed: ${fileUrl}`, String(error), 'MinioMediaStorageService');
throw error;
}
}
}

View File

@@ -0,0 +1,63 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { MulterModule } from '@nestjs/platform-express';
// Domain
import { PROPERTY_REPOSITORY } from './domain/repositories/property.repository';
import { LISTING_REPOSITORY } from './domain/repositories/listing.repository';
// Infrastructure
import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository';
import { PrismaListingRepository } from './infrastructure/repositories/prisma-listing.repository';
import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service';
// Application — Commands
import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler';
// Application — Queries
import { GetListingHandler } from './application/queries/get-listing/get-listing.handler';
import { SearchListingsHandler } from './application/queries/search-listings/search-listings.handler';
import { GetPendingModerationHandler } from './application/queries/get-pending-moderation/get-pending-moderation.handler';
// Presentation
import { ListingsController } from './presentation/controllers/listings.controller';
const CommandHandlers = [
CreateListingHandler,
UpdateListingStatusHandler,
UploadMediaHandler,
ModerateListingHandler,
];
const QueryHandlers = [
GetListingHandler,
SearchListingsHandler,
GetPendingModerationHandler,
];
@Module({
imports: [
CqrsModule,
MulterModule.register({
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
}),
],
controllers: [ListingsController],
providers: [
// Repositories
{ provide: PROPERTY_REPOSITORY, useClass: PrismaPropertyRepository },
{ provide: LISTING_REPOSITORY, useClass: PrismaListingRepository },
// Services
{ provide: MEDIA_STORAGE_SERVICE, useClass: MinioMediaStorageService },
// CQRS
...CommandHandlers,
...QueryHandlers,
],
exports: [LISTING_REPOSITORY, PROPERTY_REPOSITORY],
})
export class ListingsModule {}

View File

@@ -0,0 +1 @@
export { ListingsController } from './listings.controller';

View File

@@ -0,0 +1,163 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service';
import { FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared/infrastructure/pipes/file-validation.pipe';
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command';
import { UploadMediaCommand } from '../../application/commands/upload-media/upload-media.command';
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
import { GetListingQuery } from '../../application/queries/get-listing/get-listing.query';
import { SearchListingsQuery } from '../../application/queries/search-listings/search-listings.query';
import { GetPendingModerationQuery } from '../../application/queries/get-pending-moderation/get-pending-moderation.query';
import { CreateListingDto } from '../dto/create-listing.dto';
import { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
import { ModerateListingDto } from '../dto/moderate-listing.dto';
import { SearchListingsDto } from '../dto/search-listings.dto';
import { type CreateListingResult } from '../../application/commands/create-listing/create-listing.handler';
import { type ListingDetailDto } from '../../application/queries/get-listing/get-listing.handler';
import { type PaginatedResult } from '../../domain/repositories/listing.repository';
@Controller('listings')
export class ListingsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@UseGuards(JwtAuthGuard)
@Post()
async createListing(
@Body() dto: CreateListingDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateListingResult> {
return this.commandBus.execute(
new CreateListingCommand(
user.sub,
dto.transactionType,
dto.priceVND,
dto.propertyType,
dto.title,
dto.description,
dto.address,
dto.ward,
dto.district,
dto.city,
dto.latitude,
dto.longitude,
dto.areaM2,
dto.usableAreaM2,
dto.bedrooms,
dto.bathrooms,
dto.floors,
dto.floor,
dto.totalFloors,
dto.direction,
dto.yearBuilt,
dto.legalStatus,
dto.amenities,
dto.nearbyPOIs,
dto.metroDistanceM,
dto.projectName,
dto.agentId,
dto.rentPriceMonthly,
dto.commissionPct,
),
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Get('pending')
async getPendingModeration(
@Query('page') page?: number,
@Query('limit') limit?: number,
): Promise<PaginatedResult<any>> {
return this.queryBus.execute(
new GetPendingModerationQuery(page ?? 1, limit ?? 20),
);
}
@Get(':id')
async getListing(@Param('id') id: string): Promise<ListingDetailDto> {
return this.queryBus.execute(new GetListingQuery(id));
}
@Get()
async searchListings(@Query() dto: SearchListingsDto): Promise<PaginatedResult<any>> {
return this.queryBus.execute(
new SearchListingsQuery(
dto.status,
dto.transactionType,
dto.propertyType,
dto.city,
dto.district,
dto.minPrice,
dto.maxPrice,
dto.minArea,
dto.maxArea,
dto.bedrooms,
dto.page,
dto.limit,
),
);
}
@UseGuards(JwtAuthGuard)
@Patch(':id/status')
async updateStatus(
@Param('id') id: string,
@Body() dto: UpdateListingStatusDto,
@CurrentUser() user: JwtPayload,
): Promise<{ status: string }> {
return this.commandBus.execute(
new UpdateListingStatusCommand(id, dto.status, user.sub, dto.moderationNotes),
);
}
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileInterceptor('file'))
@Post(':id/media')
async uploadMedia(
@Param('id') id: string,
@UploadedFile(new FileValidationPipe({
maxSizeBytes: 10 * 1024 * 1024, // 10 MB
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'],
}))
file: ValidatedFile,
@CurrentUser() user: JwtPayload,
@Body('caption') caption?: string,
): Promise<{ mediaId: string; url: string }> {
return this.commandBus.execute(
new UploadMediaCommand(id, user.sub, file, caption),
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Patch(':id/moderate')
async moderateListing(
@Param('id') id: string,
@Body() dto: ModerateListingDto,
@CurrentUser() user: JwtPayload,
): Promise<{ status: string }> {
return this.commandBus.execute(
new ModerateListingCommand(id, user.sub, dto.action, dto.moderationScore, dto.notes),
);
}
}

View File

@@ -0,0 +1,132 @@
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
MinLength,
Min,
Max,
IsArray,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { PropertyType, TransactionType, Direction } from '@prisma/client';
export class CreateListingDto {
@IsEnum(TransactionType)
transactionType!: TransactionType;
@Transform(({ value }) => BigInt(value))
priceVND!: bigint;
@IsEnum(PropertyType)
propertyType!: PropertyType;
@IsString()
@MinLength(5)
title!: string;
@IsString()
@MinLength(10)
description!: string;
@IsString()
address!: string;
@IsString()
ward!: string;
@IsString()
district!: string;
@IsString()
city!: string;
@IsNumber()
@Type(() => Number)
@Min(-90)
@Max(90)
latitude!: number;
@IsNumber()
@Type(() => Number)
@Min(-180)
@Max(180)
longitude!: number;
@IsNumber()
@Type(() => Number)
@Min(1)
areaM2!: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
usableAreaM2?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
bedrooms?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
bathrooms?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
floors?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
floor?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
totalFloors?: number;
@IsOptional()
@IsEnum(Direction)
direction?: Direction;
@IsOptional()
@IsNumber()
@Type(() => Number)
yearBuilt?: number;
@IsOptional()
@IsString()
legalStatus?: string;
@IsOptional()
@IsArray()
amenities?: string[];
@IsOptional()
nearbyPOIs?: unknown;
@IsOptional()
@IsNumber()
@Type(() => Number)
metroDistanceM?: number;
@IsOptional()
@IsString()
projectName?: string;
@IsOptional()
@IsString()
agentId?: string;
@IsOptional()
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
rentPriceMonthly?: bigint;
@IsOptional()
@IsNumber()
@Type(() => Number)
commissionPct?: number;
}

View File

@@ -0,0 +1,4 @@
export { CreateListingDto } from './create-listing.dto';
export { UpdateListingStatusDto } from './update-listing-status.dto';
export { ModerateListingDto } from './moderate-listing.dto';
export { SearchListingsDto } from './search-listings.dto';

View File

@@ -0,0 +1,18 @@
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class ModerateListingDto {
@IsEnum(['approve', 'reject'] as const)
action!: 'approve' | 'reject';
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
@Max(100)
moderationScore?: number;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,61 @@
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ListingStatus, PropertyType, TransactionType } from '@prisma/client';
export class SearchListingsDto {
@IsOptional()
@IsEnum(ListingStatus)
status?: ListingStatus;
@IsOptional()
@IsEnum(TransactionType)
transactionType?: TransactionType;
@IsOptional()
@IsEnum(PropertyType)
propertyType?: PropertyType;
@IsOptional()
@IsString()
city?: string;
@IsOptional()
@IsString()
district?: string;
@IsOptional()
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
minPrice?: bigint;
@IsOptional()
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
maxPrice?: bigint;
@IsOptional()
@IsNumber()
@Type(() => Number)
minArea?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
maxArea?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
bedrooms?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
page?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
@Max(100)
limit?: number;
}

View File

@@ -0,0 +1,11 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ListingStatus } from '@prisma/client';
export class UpdateListingStatusDto {
@IsEnum(ListingStatus)
status!: ListingStatus;
@IsOptional()
@IsString()
moderationNotes?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './controllers';
export * from './dto';

View File

@@ -29,6 +29,19 @@ export enum ErrorCode {
COURSE_ALREADY_PUBLISHED = 'COURSE_ALREADY_PUBLISHED',
COURSE_ENROLLMENT_CLOSED = 'COURSE_ENROLLMENT_CLOSED',
// Listing
LISTING_NOT_FOUND = 'LISTING_NOT_FOUND',
LISTING_INVALID_STATUS_TRANSITION = 'LISTING_INVALID_STATUS_TRANSITION',
LISTING_ALREADY_ACTIVE = 'LISTING_ALREADY_ACTIVE',
LISTING_EXPIRED = 'LISTING_EXPIRED',
// Property
PROPERTY_NOT_FOUND = 'PROPERTY_NOT_FOUND',
// Media
MEDIA_UPLOAD_FAILED = 'MEDIA_UPLOAD_FAILED',
MEDIA_LIMIT_EXCEEDED = 'MEDIA_LIMIT_EXCEEDED',
// Payment
PAYMENT_FAILED = 'PAYMENT_FAILED',
PAYMENT_ALREADY_PROCESSED = 'PAYMENT_ALREADY_PROCESSED',