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 index 3b20f26..8a7fdb2 100644 --- a/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts +++ b/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts @@ -108,4 +108,67 @@ describe('ListingEntity', () => { listing.incrementViewCount(); expect(listing.viewCount).toBe(2); }); + + describe('updateContent', () => { + it('should update priceVND and recalculate pricePerM2', () => { + const listing = makeDefaultListing(); + listing.clearDomainEvents(); + + const fields = listing.updateContent({ priceVND: 6_000_000_000n, areaM2: 100 }); + + expect(fields).toContain('priceVND'); + expect(listing.price.amountVND).toBe(6_000_000_000n); + expect(listing.pricePerM2).toBe(60_000_000); + }); + + it('should update rentPriceMonthly', () => { + const listing = makeDefaultListing(); + listing.clearDomainEvents(); + + const fields = listing.updateContent({ rentPriceMonthly: 30_000_000n }); + + expect(fields).toContain('rentPriceMonthly'); + expect(listing.rentPriceMonthly).toBe(30_000_000n); + }); + + it('should throw ValidationException for invalid price', () => { + const listing = makeDefaultListing(); + + expect(() => listing.updateContent({ priceVND: -1n })).toThrow(); + }); + + it('should return empty array when no fields changed', () => { + const listing = makeDefaultListing(); + const fields = listing.updateContent({}); + expect(fields).toEqual([]); + }); + }); + + describe('markEditedForReModeration', () => { + it('should transition ACTIVE -> PENDING_REVIEW and emit events', () => { + const listing = makeDefaultListing(); + listing.submitForReview(); + listing.approve(); + listing.clearDomainEvents(); + + listing.markEditedForReModeration('property-1', ['title']); + + expect(listing.status).toBe('PENDING_REVIEW'); + const events = listing.domainEvents; + expect(events.some((e) => e.eventName === 'listing.status_changed')).toBe(true); + expect(events.some((e) => e.eventName === 'listing.updated')).toBe(true); + }); + + it('should NOT change DRAFT status but still emit ListingUpdatedEvent', () => { + const listing = makeDefaultListing(); + listing.clearDomainEvents(); + + listing.markEditedForReModeration('property-1', ['title']); + + expect(listing.status).toBe('DRAFT'); + const events = listing.domainEvents; + expect(events).toHaveLength(1); + expect(events[0]!.eventName).toBe('listing.updated'); + }); + }); }); diff --git a/apps/api/src/modules/listings/domain/__tests__/property.entity.spec.ts b/apps/api/src/modules/listings/domain/__tests__/property.entity.spec.ts index 8bf3ea5..3aefab8 100644 --- a/apps/api/src/modules/listings/domain/__tests__/property.entity.spec.ts +++ b/apps/api/src/modules/listings/domain/__tests__/property.entity.spec.ts @@ -78,6 +78,68 @@ describe('PropertyEntity', () => { expect(property.totalFloors).toBe(25); expect(property.projectName).toBe('Vinhomes Grand Park'); }); + + describe('updateContent', () => { + const makeProperty = () => + PropertyEntity.createNew('prop-1', { + propertyType: 'TOWNHOUSE', + title: 'Nhà phố đẹp Quận 1', + description: 'Nhà phố 3 tầng, mặt tiền rộng', + address: makeAddress(), + location: makeGeoPoint(), + areaM2: 120, + usableAreaM2: 100, + bedrooms: 3, + bathrooms: 2, + floors: 3, + floor: null, + totalFloors: null, + direction: 'EAST', + yearBuilt: 2020, + legalStatus: 'Sổ hồng', + amenities: { parking: true }, + nearbyPOIs: [], + metroDistanceM: 500, + projectName: null, + }); + + it('updates title', () => { + const property = makeProperty(); + const fields = property.updateContent({ title: 'Tiêu đề mới' }); + expect(fields).toContain('title'); + expect(property.title).toBe('Tiêu đề mới'); + }); + + it('updates description', () => { + const property = makeProperty(); + const fields = property.updateContent({ description: 'Mô tả mới chi tiết hơn' }); + expect(fields).toContain('description'); + expect(property.description).toBe('Mô tả mới chi tiết hơn'); + }); + + it('updates amenities', () => { + const property = makeProperty(); + const fields = property.updateContent({ amenities: ['Hồ bơi', 'Gym'] }); + expect(fields).toContain('amenities'); + expect(property.amenities).toEqual(['Hồ bơi', 'Gym']); + }); + + it('returns empty array when no fields provided', () => { + const property = makeProperty(); + const fields = property.updateContent({}); + expect(fields).toEqual([]); + }); + + it('updates multiple fields at once', () => { + const property = makeProperty(); + const fields = property.updateContent({ + title: 'Tiêu đề mới', + description: 'Mô tả mới', + amenities: ['Gym'], + }); + expect(fields).toEqual(['title', 'description', 'amenities']); + }); + }); }); describe('PropertyMediaEntity', () => { diff --git a/apps/api/src/modules/listings/domain/entities/listing.entity.ts b/apps/api/src/modules/listings/domain/entities/listing.entity.ts index 65ff81a..42603a2 100644 --- a/apps/api/src/modules/listings/domain/entities/listing.entity.ts +++ b/apps/api/src/modules/listings/domain/entities/listing.entity.ts @@ -4,6 +4,7 @@ import { ListingApprovedEvent } from '../events/listing-approved.event'; import { ListingCreatedEvent } from '../events/listing-created.event'; import { ListingSoldEvent } from '../events/listing-sold.event'; import { ListingStatusChangedEvent } from '../events/listing-status-changed.event'; +import { ListingUpdatedEvent } from '../events/listing-updated.event'; import { Price } from '../value-objects/price.vo'; const VALID_TRANSITIONS: Record = { @@ -190,4 +191,69 @@ export class ListingEntity extends AggregateRoot { incrementViewCount(): void { this._viewCount++; } + + /** + * Updates listing content fields (price, rent price). + * If the listing is ACTIVE, transitions it to PENDING_REVIEW for re-moderation. + * Returns list of changed field names. + */ + updateContent(fields: { + priceVND?: bigint; + rentPriceMonthly?: bigint; + areaM2?: number; + }): string[] { + // Immutable fields after ACTIVE: propertyType, address, location are on Property entity + // and are blocked at the handler level. + + const updatedFields: string[] = []; + + if (fields.priceVND !== undefined) { + const priceResult = Price.create(fields.priceVND); + if (priceResult.isErr) { + throw new ValidationException(priceResult.unwrapErr(), { field: 'priceVND' }); + } + this._price = priceResult.unwrap(); + if (fields.areaM2 !== undefined) { + this._pricePerM2 = this._price.calculatePerM2(fields.areaM2); + } + updatedFields.push('priceVND'); + } + + if (fields.rentPriceMonthly !== undefined) { + this._rentPriceMonthly = fields.rentPriceMonthly; + updatedFields.push('rentPriceMonthly'); + } + + if (updatedFields.length > 0) { + this.updatedAt = new Date(); + } + + return updatedFields; + } + + /** + * If listing is ACTIVE, transitions to PENDING_REVIEW for re-moderation after content edit. + * Emits ListingUpdatedEvent with changed fields. + */ + markEditedForReModeration(propertyId: string, updatedFields: string[]): void { + const previousStatus = this._status; + + if (this._status === 'ACTIVE') { + this._status = 'PENDING_REVIEW'; + this.addDomainEvent( + new ListingStatusChangedEvent(this.id, propertyId, previousStatus, 'PENDING_REVIEW'), + ); + } + + this.addDomainEvent( + new ListingUpdatedEvent( + this.id, + propertyId, + this._sellerId, + previousStatus, + this._status, + updatedFields, + ), + ); + } } diff --git a/apps/api/src/modules/listings/domain/entities/property.entity.ts b/apps/api/src/modules/listings/domain/entities/property.entity.ts index 68b1edb..2e07b86 100644 --- a/apps/api/src/modules/listings/domain/entities/property.entity.ts +++ b/apps/api/src/modules/listings/domain/entities/property.entity.ts @@ -92,4 +92,37 @@ export class PropertyEntity extends AggregateRoot { static createNew(id: string, props: PropertyProps): PropertyEntity { return new PropertyEntity(id, props); } + + /** + * Updates mutable property content fields. + * Returns list of changed field names. + */ + updateContent(fields: { + title?: string; + description?: string; + amenities?: unknown; + }): string[] { + const updatedFields: string[] = []; + + if (fields.title !== undefined) { + this._title = fields.title; + updatedFields.push('title'); + } + + if (fields.description !== undefined) { + this._description = fields.description; + updatedFields.push('description'); + } + + if (fields.amenities !== undefined) { + this._amenities = fields.amenities; + updatedFields.push('amenities'); + } + + if (updatedFields.length > 0) { + this.updatedAt = new Date(); + } + + return updatedFields; + } } diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index 45f0a10..4eff876 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs'; import { MulterModule } from '@nestjs/platform-express'; import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler'; import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler'; +import { UpdateListingHandler } from './application/commands/update-listing/update-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 { GetListingHandler } from './application/queries/get-listing/get-listing.handler'; @@ -22,6 +23,7 @@ import { ListingsController } from './presentation/controllers/listings.controll const CommandHandlers = [ CreateListingHandler, + UpdateListingHandler, UpdateListingStatusHandler, UploadMediaHandler, ModerateListingHandler, diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 88c33e3..b3388fb 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -10,7 +10,6 @@ import { UseGuards, UseInterceptors, } from '@nestjs/common'; -import { NotFoundException } from '@modules/shared'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { FileInterceptor } from '@nestjs/platform-express'; import { @@ -22,23 +21,26 @@ import { ApiQuery, ApiParam, } from '@nestjs/swagger'; -import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; -import { EndpointRateLimit, EndpointRateLimitGuard, FileValidationPipe, UploadedFile as ValidatedFile } from '@modules/shared'; +import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; +import { NotFoundException, EndpointRateLimit, EndpointRateLimitGuard, FileValidationPipe, UploadedFile as ValidatedFile } from '@modules/shared'; import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command'; -import { CreateListingResult } from '../../application/commands/create-listing/create-listing.handler'; +import type { CreateListingResult } from '../../application/commands/create-listing/create-listing.handler'; import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command'; +import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command'; +import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler'; import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command'; import { UploadMediaCommand } from '../../application/commands/upload-media/upload-media.command'; import { GetListingQuery } from '../../application/queries/get-listing/get-listing.query'; import { GetPendingModerationQuery } from '../../application/queries/get-pending-moderation/get-pending-moderation.query'; import { SearchListingsQuery } from '../../application/queries/search-listings/search-listings.query'; -import { ListingDetailData, ListingSearchItem } from '../../domain/repositories/listing-read.dto'; -import { PaginatedResult } from '../../domain/repositories/listing.repository'; +import type { ListingDetailData, ListingSearchItem } from '../../domain/repositories/listing-read.dto'; +import type { PaginatedResult } from '../../domain/repositories/listing.repository'; import { CreateListingDto } from '../dto/create-listing.dto'; import { ModerateListingDto } from '../dto/moderate-listing.dto'; import { SearchListingsDto } from '../dto/search-listings.dto'; import { UpdateListingStatusDto } from '../dto/update-listing-status.dto'; +import { UpdateListingDto } from '../dto/update-listing.dto'; @ApiTags('listings') @Controller('listings') @@ -151,6 +153,34 @@ export class ListingsController { ); } + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Update listing content (title, description, price, amenities)' }) + @ApiParam({ name: 'id', description: 'Listing UUID' }) + @ApiResponse({ status: 200, description: 'Listing updated successfully' }) + @ApiResponse({ status: 400, description: 'Validation error or no fields provided' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden — not the seller or assigned agent' }) + @ApiResponse({ status: 404, description: 'Listing not found' }) + @UseGuards(JwtAuthGuard) + @Patch(':id') + async updateListing( + @Param('id') id: string, + @Body() dto: UpdateListingDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new UpdateListingCommand( + id, + user.sub, + dto.title, + dto.description, + dto.priceVND, + dto.rentPriceMonthly, + dto.amenities, + ), + ); + } + @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Update listing status' }) @ApiParam({ name: 'id', description: 'Listing UUID' })