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:
Ho Ngoc Hai
2026-04-15 11:25:06 +07:00
parent eebe24e1ae
commit 50a0d739a7
6 changed files with 262 additions and 6 deletions

View File

@@ -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');
});
});
});

View File

@@ -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', () => {

View File

@@ -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,
),
);
}
}

View File

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

View File

@@ -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,

View File

@@ -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' })