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,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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/modules/listings/application/index.ts
Normal file
17
apps/api/src/modules/listings/application/index.ts
Normal 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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export class GetListingQuery {
|
||||||
|
constructor(public readonly listingId: string) {}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class GetPendingModerationQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly page: number = 1,
|
||||||
|
public readonly limit: number = 20,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/api/src/modules/listings/index.ts
Normal file
1
apps/api/src/modules/listings/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ListingsModule } from './listings.module';
|
||||||
2
apps/api/src/modules/listings/infrastructure/index.ts
Normal file
2
apps/api/src/modules/listings/infrastructure/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './repositories';
|
||||||
|
export * from './services';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { PrismaPropertyRepository } from './prisma-property.repository';
|
||||||
|
export { PrismaListingRepository } from './prisma-listing.repository';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { MEDIA_STORAGE_SERVICE, type IMediaStorageService, MinioMediaStorageService } from './media-storage.service';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
apps/api/src/modules/listings/listings.module.ts
Normal file
63
apps/api/src/modules/listings/listings.module.ts
Normal 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 {}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ListingsController } from './listings.controller';
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
4
apps/api/src/modules/listings/presentation/dto/index.ts
Normal file
4
apps/api/src/modules/listings/presentation/dto/index.ts
Normal 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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
2
apps/api/src/modules/listings/presentation/index.ts
Normal file
2
apps/api/src/modules/listings/presentation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
@@ -29,6 +29,19 @@ export enum ErrorCode {
|
|||||||
COURSE_ALREADY_PUBLISHED = 'COURSE_ALREADY_PUBLISHED',
|
COURSE_ALREADY_PUBLISHED = 'COURSE_ALREADY_PUBLISHED',
|
||||||
COURSE_ENROLLMENT_CLOSED = 'COURSE_ENROLLMENT_CLOSED',
|
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
|
||||||
PAYMENT_FAILED = 'PAYMENT_FAILED',
|
PAYMENT_FAILED = 'PAYMENT_FAILED',
|
||||||
PAYMENT_ALREADY_PROCESSED = 'PAYMENT_ALREADY_PROCESSED',
|
PAYMENT_ALREADY_PROCESSED = 'PAYMENT_ALREADY_PROCESSED',
|
||||||
|
|||||||
Reference in New Issue
Block a user