diff --git a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts new file mode 100644 index 0000000..7b2e9e1 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts new file mode 100644 index 0000000..1b3ed26 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts @@ -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 { + constructor( + @Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository, + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: CreateListingCommand): Promise { + // 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, + }; + } +} diff --git a/apps/api/src/modules/listings/application/commands/moderate-listing/moderate-listing.command.ts b/apps/api/src/modules/listings/application/commands/moderate-listing/moderate-listing.command.ts new file mode 100644 index 0000000..d9197af --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/moderate-listing/moderate-listing.command.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/listings/application/commands/moderate-listing/moderate-listing.handler.ts b/apps/api/src/modules/listings/application/commands/moderate-listing/moderate-listing.handler.ts new file mode 100644 index 0000000..1bfb062 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/moderate-listing/moderate-listing.handler.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.command.ts b/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.command.ts new file mode 100644 index 0000000..afd124f --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.command.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts b/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts new file mode 100644 index 0000000..a54225b --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/listings/application/commands/upload-media/upload-media.command.ts b/apps/api/src/modules/listings/application/commands/upload-media/upload-media.command.ts new file mode 100644 index 0000000..7c9dd16 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/upload-media/upload-media.command.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts b/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts new file mode 100644 index 0000000..6c3e867 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/listings/application/index.ts b/apps/api/src/modules/listings/application/index.ts new file mode 100644 index 0000000..cdc9a52 --- /dev/null +++ b/apps/api/src/modules/listings/application/index.ts @@ -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'; diff --git a/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts new file mode 100644 index 0000000..6a8bf33 --- /dev/null +++ b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts @@ -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 { + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + ) {} + + async execute(query: GetListingQuery): Promise { + const result = await this.listingRepo.findByIdWithProperty(query.listingId); + if (!result) { + throw new NotFoundException('Listing', query.listingId); + } + return result as unknown as ListingDetailDto; + } +} diff --git a/apps/api/src/modules/listings/application/queries/get-listing/get-listing.query.ts b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.query.ts new file mode 100644 index 0000000..9d64498 --- /dev/null +++ b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.query.ts @@ -0,0 +1,3 @@ +export class GetListingQuery { + constructor(public readonly listingId: string) {} +} diff --git a/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts b/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts new file mode 100644 index 0000000..0339366 --- /dev/null +++ b/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts @@ -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 { + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + ) {} + + async execute(query: GetPendingModerationQuery): Promise> { + return this.listingRepo.findByStatus('PENDING_REVIEW', query.page, query.limit); + } +} diff --git a/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.query.ts b/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.query.ts new file mode 100644 index 0000000..53a3f99 --- /dev/null +++ b/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.query.ts @@ -0,0 +1,6 @@ +export class GetPendingModerationQuery { + constructor( + public readonly page: number = 1, + public readonly limit: number = 20, + ) {} +} diff --git a/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts b/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts new file mode 100644 index 0000000..fa35e55 --- /dev/null +++ b/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts @@ -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 { + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + ) {} + + async execute(query: SearchListingsQuery): Promise> { + 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, + }); + } +} diff --git a/apps/api/src/modules/listings/application/queries/search-listings/search-listings.query.ts b/apps/api/src/modules/listings/application/queries/search-listings/search-listings.query.ts new file mode 100644 index 0000000..f3e581a --- /dev/null +++ b/apps/api/src/modules/listings/application/queries/search-listings/search-listings.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts b/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts new file mode 100644 index 0000000..01838a4 --- /dev/null +++ b/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/listings/domain/__tests__/value-objects.spec.ts b/apps/api/src/modules/listings/domain/__tests__/value-objects.spec.ts new file mode 100644 index 0000000..32efb82 --- /dev/null +++ b/apps/api/src/modules/listings/domain/__tests__/value-objects.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/listings/domain/entities/index.ts b/apps/api/src/modules/listings/domain/entities/index.ts new file mode 100644 index 0000000..8d3b492 --- /dev/null +++ b/apps/api/src/modules/listings/domain/entities/index.ts @@ -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'; diff --git a/apps/api/src/modules/listings/domain/entities/listing.entity.ts b/apps/api/src/modules/listings/domain/entities/listing.entity.ts new file mode 100644 index 0000000..93b2371 --- /dev/null +++ b/apps/api/src/modules/listings/domain/entities/listing.entity.ts @@ -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 = { + 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 { + 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++; + } +} diff --git a/apps/api/src/modules/listings/domain/entities/property-media.entity.ts b/apps/api/src/modules/listings/domain/entities/property-media.entity.ts new file mode 100644 index 0000000..8834dbb --- /dev/null +++ b/apps/api/src/modules/listings/domain/entities/property-media.entity.ts @@ -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 { + 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, + }); + } +} diff --git a/apps/api/src/modules/listings/domain/entities/property.entity.ts b/apps/api/src/modules/listings/domain/entities/property.entity.ts new file mode 100644 index 0000000..655ff8d --- /dev/null +++ b/apps/api/src/modules/listings/domain/entities/property.entity.ts @@ -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 { + 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); + } +} diff --git a/apps/api/src/modules/listings/domain/events/index.ts b/apps/api/src/modules/listings/domain/events/index.ts new file mode 100644 index 0000000..986909e --- /dev/null +++ b/apps/api/src/modules/listings/domain/events/index.ts @@ -0,0 +1,3 @@ +export { ListingCreatedEvent } from './listing-created.event'; +export { ListingApprovedEvent } from './listing-approved.event'; +export { ListingSoldEvent } from './listing-sold.event'; diff --git a/apps/api/src/modules/listings/domain/events/listing-approved.event.ts b/apps/api/src/modules/listings/domain/events/listing-approved.event.ts new file mode 100644 index 0000000..cbfc9f4 --- /dev/null +++ b/apps/api/src/modules/listings/domain/events/listing-approved.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/listings/domain/events/listing-created.event.ts b/apps/api/src/modules/listings/domain/events/listing-created.event.ts new file mode 100644 index 0000000..525edf6 --- /dev/null +++ b/apps/api/src/modules/listings/domain/events/listing-created.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/listings/domain/events/listing-sold.event.ts b/apps/api/src/modules/listings/domain/events/listing-sold.event.ts new file mode 100644 index 0000000..bfd884d --- /dev/null +++ b/apps/api/src/modules/listings/domain/events/listing-sold.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/listings/domain/index.ts b/apps/api/src/modules/listings/domain/index.ts new file mode 100644 index 0000000..82fbdd4 --- /dev/null +++ b/apps/api/src/modules/listings/domain/index.ts @@ -0,0 +1,4 @@ +export * from './entities'; +export * from './events'; +export * from './repositories'; +export * from './value-objects'; diff --git a/apps/api/src/modules/listings/domain/repositories/index.ts b/apps/api/src/modules/listings/domain/repositories/index.ts new file mode 100644 index 0000000..c408ee6 --- /dev/null +++ b/apps/api/src/modules/listings/domain/repositories/index.ts @@ -0,0 +1,2 @@ +export { PROPERTY_REPOSITORY, type IPropertyRepository } from './property.repository'; +export { LISTING_REPOSITORY, type IListingRepository, type ListingSearchParams, type PaginatedResult } from './listing.repository'; diff --git a/apps/api/src/modules/listings/domain/repositories/listing.repository.ts b/apps/api/src/modules/listings/domain/repositories/listing.repository.ts new file mode 100644 index 0000000..f6c7aa6 --- /dev/null +++ b/apps/api/src/modules/listings/domain/repositories/listing.repository.ts @@ -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 { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface IListingRepository { + findById(id: string): Promise; + findByIdWithProperty(id: string): Promise<{ listing: ListingEntity; property: any } | null>; + save(listing: ListingEntity): Promise; + update(listing: ListingEntity): Promise; + search(params: ListingSearchParams): Promise>; + findByStatus(status: ListingStatus, page: number, limit: number): Promise>; + findBySellerId(sellerId: string, page: number, limit: number): Promise>; +} diff --git a/apps/api/src/modules/listings/domain/repositories/property.repository.ts b/apps/api/src/modules/listings/domain/repositories/property.repository.ts new file mode 100644 index 0000000..a382cf3 --- /dev/null +++ b/apps/api/src/modules/listings/domain/repositories/property.repository.ts @@ -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; + save(property: PropertyEntity): Promise; + update(property: PropertyEntity): Promise; + addMedia(media: PropertyMediaEntity): Promise; + findMediaByPropertyId(propertyId: string): Promise; + deleteMedia(mediaId: string): Promise; + countMediaByPropertyId(propertyId: string): Promise; +} diff --git a/apps/api/src/modules/listings/domain/value-objects/address.vo.ts b/apps/api/src/modules/listings/domain/value-objects/address.vo.ts new file mode 100644 index 0000000..fbe14df --- /dev/null +++ b/apps/api/src/modules/listings/domain/value-objects/address.vo.ts @@ -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 { + 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 { + 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(), + })); + } +} diff --git a/apps/api/src/modules/listings/domain/value-objects/geo-point.vo.ts b/apps/api/src/modules/listings/domain/value-objects/geo-point.vo.ts new file mode 100644 index 0000000..5d8f6a1 --- /dev/null +++ b/apps/api/src/modules/listings/domain/value-objects/geo-point.vo.ts @@ -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 { + get latitude(): number { return this.props.latitude; } + get longitude(): number { return this.props.longitude; } + + static create(latitude: number, longitude: number): Result { + 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})`; + } +} diff --git a/apps/api/src/modules/listings/domain/value-objects/index.ts b/apps/api/src/modules/listings/domain/value-objects/index.ts new file mode 100644 index 0000000..074eb10 --- /dev/null +++ b/apps/api/src/modules/listings/domain/value-objects/index.ts @@ -0,0 +1,3 @@ +export { Address } from './address.vo'; +export { GeoPoint } from './geo-point.vo'; +export { Price } from './price.vo'; diff --git a/apps/api/src/modules/listings/domain/value-objects/price.vo.ts b/apps/api/src/modules/listings/domain/value-objects/price.vo.ts new file mode 100644 index 0000000..3f17764 --- /dev/null +++ b/apps/api/src/modules/listings/domain/value-objects/price.vo.ts @@ -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 { + get amountVND(): bigint { return this.props.amountVND; } + + static create(amountVND: bigint): Result { + 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; + } +} diff --git a/apps/api/src/modules/listings/index.ts b/apps/api/src/modules/listings/index.ts new file mode 100644 index 0000000..d3bd191 --- /dev/null +++ b/apps/api/src/modules/listings/index.ts @@ -0,0 +1 @@ +export { ListingsModule } from './listings.module'; diff --git a/apps/api/src/modules/listings/infrastructure/index.ts b/apps/api/src/modules/listings/infrastructure/index.ts new file mode 100644 index 0000000..5b9f2aa --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/index.ts @@ -0,0 +1,2 @@ +export * from './repositories'; +export * from './services'; diff --git a/apps/api/src/modules/listings/infrastructure/repositories/index.ts b/apps/api/src/modules/listings/infrastructure/repositories/index.ts new file mode 100644 index 0000000..4537bd3 --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/repositories/index.ts @@ -0,0 +1,2 @@ +export { PrismaPropertyRepository } from './prisma-property.repository'; +export { PrismaListingRepository } from './prisma-listing.repository'; diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts new file mode 100644 index 0000000..1667705 --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts @@ -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 { + const listing = await this.prisma.listing.findUnique({ where: { id } }); + return listing ? this.toDomain(listing) : null; + } + + async findByIdWithProperty(id: string): Promise { + 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 { + 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 { + 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> { + 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> { + return this.search({ status, page, limit }); + } + + async findBySellerId(sellerId: string, page: number, limit: number): Promise> { + 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); + } +} diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts new file mode 100644 index 0000000..8342977 --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts @@ -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 { + const property = await this.prisma.property.findUnique({ where: { id } }); + return property ? this.toDomain(property) : null; + } + + async save(entity: PropertyEntity): Promise { + 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 { + 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 { + 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 { + const mediaList = await this.prisma.propertyMedia.findMany({ + where: { propertyId }, + orderBy: { order: 'asc' }, + }); + return mediaList.map((m) => this.toMediaDomain(m)); + } + + async deleteMedia(mediaId: string): Promise { + await this.prisma.propertyMedia.delete({ where: { id: mediaId } }); + } + + async countMediaByPropertyId(propertyId: string): Promise { + 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); + } +} diff --git a/apps/api/src/modules/listings/infrastructure/services/index.ts b/apps/api/src/modules/listings/infrastructure/services/index.ts new file mode 100644 index 0000000..8233203 --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/services/index.ts @@ -0,0 +1 @@ +export { MEDIA_STORAGE_SERVICE, type IMediaStorageService, MinioMediaStorageService } from './media-storage.service'; diff --git a/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts b/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts new file mode 100644 index 0000000..21b163d --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts @@ -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; + delete(fileUrl: string): Promise; +} + +@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 { + 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 { + 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; + } + } +} diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts new file mode 100644 index 0000000..14c1761 --- /dev/null +++ b/apps/api/src/modules/listings/listings.module.ts @@ -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 {} diff --git a/apps/api/src/modules/listings/presentation/controllers/index.ts b/apps/api/src/modules/listings/presentation/controllers/index.ts new file mode 100644 index 0000000..1fdf765 --- /dev/null +++ b/apps/api/src/modules/listings/presentation/controllers/index.ts @@ -0,0 +1 @@ +export { ListingsController } from './listings.controller'; diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts new file mode 100644 index 0000000..6a8c122 --- /dev/null +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -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 { + 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> { + return this.queryBus.execute( + new GetPendingModerationQuery(page ?? 1, limit ?? 20), + ); + } + + @Get(':id') + async getListing(@Param('id') id: string): Promise { + return this.queryBus.execute(new GetListingQuery(id)); + } + + @Get() + async searchListings(@Query() dto: SearchListingsDto): Promise> { + 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), + ); + } +} diff --git a/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts new file mode 100644 index 0000000..8cc5328 --- /dev/null +++ b/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/listings/presentation/dto/index.ts b/apps/api/src/modules/listings/presentation/dto/index.ts new file mode 100644 index 0000000..77ae32b --- /dev/null +++ b/apps/api/src/modules/listings/presentation/dto/index.ts @@ -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'; diff --git a/apps/api/src/modules/listings/presentation/dto/moderate-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/moderate-listing.dto.ts new file mode 100644 index 0000000..6a37baa --- /dev/null +++ b/apps/api/src/modules/listings/presentation/dto/moderate-listing.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts b/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts new file mode 100644 index 0000000..5f59ffc --- /dev/null +++ b/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/listings/presentation/dto/update-listing-status.dto.ts b/apps/api/src/modules/listings/presentation/dto/update-listing-status.dto.ts new file mode 100644 index 0000000..99ee4e5 --- /dev/null +++ b/apps/api/src/modules/listings/presentation/dto/update-listing-status.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/listings/presentation/index.ts b/apps/api/src/modules/listings/presentation/index.ts new file mode 100644 index 0000000..5f229e9 --- /dev/null +++ b/apps/api/src/modules/listings/presentation/index.ts @@ -0,0 +1,2 @@ +export * from './controllers'; +export * from './dto'; diff --git a/apps/api/src/modules/shared/domain/error-codes.ts b/apps/api/src/modules/shared/domain/error-codes.ts index 68d250e..40197e8 100644 --- a/apps/api/src/modules/shared/domain/error-codes.ts +++ b/apps/api/src/modules/shared/domain/error-codes.ts @@ -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',