fix: wire Nhắn tin button with InquiryModal on listing detail page
The messaging button on the listing detail page was inert — clicking it did nothing. This commit completes the inquiry flow: - Add CreateInquiryDto and create() method to inquiries API client - Add useCreateInquiry React Query mutation hook - Wire onClick handler on the Nhắn tin button to open InquiryModal - Add InquiryModal mock in listing-detail-client tests for isolation - InquiryModal component (added in prior commit) provides the full form with phone pre-fill, validation, success/error states All 593 web tests pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<ListingStatus, ListingStatus[]> = {
|
||||
@@ -190,4 +191,69 @@ export class ListingEntity extends AggregateRoot<string> {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,4 +92,37 @@ export class PropertyEntity extends AggregateRoot<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<UpdateListingResult> {
|
||||
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' })
|
||||
|
||||
Reference in New Issue
Block a user