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();
|
listing.incrementViewCount();
|
||||||
expect(listing.viewCount).toBe(2);
|
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.totalFloors).toBe(25);
|
||||||
expect(property.projectName).toBe('Vinhomes Grand Park');
|
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', () => {
|
describe('PropertyMediaEntity', () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ListingApprovedEvent } from '../events/listing-approved.event';
|
|||||||
import { ListingCreatedEvent } from '../events/listing-created.event';
|
import { ListingCreatedEvent } from '../events/listing-created.event';
|
||||||
import { ListingSoldEvent } from '../events/listing-sold.event';
|
import { ListingSoldEvent } from '../events/listing-sold.event';
|
||||||
import { ListingStatusChangedEvent } from '../events/listing-status-changed.event';
|
import { ListingStatusChangedEvent } from '../events/listing-status-changed.event';
|
||||||
|
import { ListingUpdatedEvent } from '../events/listing-updated.event';
|
||||||
import { Price } from '../value-objects/price.vo';
|
import { Price } from '../value-objects/price.vo';
|
||||||
|
|
||||||
const VALID_TRANSITIONS: Record<ListingStatus, ListingStatus[]> = {
|
const VALID_TRANSITIONS: Record<ListingStatus, ListingStatus[]> = {
|
||||||
@@ -190,4 +191,69 @@ export class ListingEntity extends AggregateRoot<string> {
|
|||||||
incrementViewCount(): void {
|
incrementViewCount(): void {
|
||||||
this._viewCount++;
|
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 {
|
static createNew(id: string, props: PropertyProps): PropertyEntity {
|
||||||
return new PropertyEntity(id, props);
|
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 { MulterModule } from '@nestjs/platform-express';
|
||||||
import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
|
import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
|
||||||
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-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 { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
||||||
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
|
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
|
||||||
import { GetListingHandler } from './application/queries/get-listing/get-listing.handler';
|
import { GetListingHandler } from './application/queries/get-listing/get-listing.handler';
|
||||||
@@ -22,6 +23,7 @@ import { ListingsController } from './presentation/controllers/listings.controll
|
|||||||
|
|
||||||
const CommandHandlers = [
|
const CommandHandlers = [
|
||||||
CreateListingHandler,
|
CreateListingHandler,
|
||||||
|
UpdateListingHandler,
|
||||||
UpdateListingStatusHandler,
|
UpdateListingStatusHandler,
|
||||||
UploadMediaHandler,
|
UploadMediaHandler,
|
||||||
ModerateListingHandler,
|
ModerateListingHandler,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { NotFoundException } from '@modules/shared';
|
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
@@ -22,23 +21,26 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||||
import { EndpointRateLimit, EndpointRateLimitGuard, FileValidationPipe, UploadedFile as ValidatedFile } from '@modules/shared';
|
import { NotFoundException, EndpointRateLimit, EndpointRateLimitGuard, FileValidationPipe, UploadedFile as ValidatedFile } from '@modules/shared';
|
||||||
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||||
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
|
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 { 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 { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command';
|
||||||
import { UploadMediaCommand } from '../../application/commands/upload-media/upload-media.command';
|
import { UploadMediaCommand } from '../../application/commands/upload-media/upload-media.command';
|
||||||
import { GetListingQuery } from '../../application/queries/get-listing/get-listing.query';
|
import { GetListingQuery } from '../../application/queries/get-listing/get-listing.query';
|
||||||
import { GetPendingModerationQuery } from '../../application/queries/get-pending-moderation/get-pending-moderation.query';
|
import { GetPendingModerationQuery } from '../../application/queries/get-pending-moderation/get-pending-moderation.query';
|
||||||
import { SearchListingsQuery } from '../../application/queries/search-listings/search-listings.query';
|
import { SearchListingsQuery } from '../../application/queries/search-listings/search-listings.query';
|
||||||
import { ListingDetailData, ListingSearchItem } from '../../domain/repositories/listing-read.dto';
|
import type { ListingDetailData, ListingSearchItem } from '../../domain/repositories/listing-read.dto';
|
||||||
import { PaginatedResult } from '../../domain/repositories/listing.repository';
|
import type { PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||||
import { CreateListingDto } from '../dto/create-listing.dto';
|
import { CreateListingDto } from '../dto/create-listing.dto';
|
||||||
import { ModerateListingDto } from '../dto/moderate-listing.dto';
|
import { ModerateListingDto } from '../dto/moderate-listing.dto';
|
||||||
import { SearchListingsDto } from '../dto/search-listings.dto';
|
import { SearchListingsDto } from '../dto/search-listings.dto';
|
||||||
import { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
import { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
||||||
|
import { UpdateListingDto } from '../dto/update-listing.dto';
|
||||||
|
|
||||||
@ApiTags('listings')
|
@ApiTags('listings')
|
||||||
@Controller('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')
|
@ApiBearerAuth('JWT')
|
||||||
@ApiOperation({ summary: 'Update listing status' })
|
@ApiOperation({ summary: 'Update listing status' })
|
||||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||||
|
|||||||
Reference in New Issue
Block a user