feat(listings): complete PATCH /api/v1/listings/:id endpoint

- Add mediaOrder field to UpdateListingDto, Command, and Handler for
  reordering media items
- Add updateMediaOrder method to IPropertyRepository and Prisma impl
- Fix PrismaPropertyRepository.update() to persist amenities, nearbyPOIs,
  floors, floor, totalFloors, and metroDistanceM columns
- Add unit tests for media order updates in handler spec
- Add DTO validation tests for mediaOrder with nested validation
- Add e2e integration tests covering content updates, auth, ownership
  guard, and forbidden field rejection

Existing guards enforced:
- Only seller or assigned agent can update (403 for others)
- ACTIVE listings transition to PENDING_REVIEW on edit
- propertyType, address, location blocked via DTO whitelist

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 06:10:27 +07:00
parent a48abf23b5
commit 62a8842193
9 changed files with 200 additions and 3 deletions

View File

@@ -82,6 +82,7 @@ describe('UpdateListingHandler', () => {
findMediaByPropertyId: vi.fn(),
deleteMedia: vi.fn(),
countMediaByPropertyId: vi.fn(),
updateMediaOrder: vi.fn().mockResolvedValue(undefined),
};
mockEventBus = { publish: vi.fn() };
@@ -204,6 +205,27 @@ describe('UpdateListingHandler', () => {
expect(mockPropertyRepo.update).toHaveBeenCalledTimes(1);
});
it('updates media order via property repository', async () => {
const listing = createListing('listing-1', 'seller-1');
const property = createProperty('prop-1');
mockListingRepo.findById.mockResolvedValue(listing);
mockPropertyRepo.findById.mockResolvedValue(property);
mockPropertyRepo.updateMediaOrder.mockResolvedValue(undefined);
const command = new UpdateListingCommand(
'listing-1', 'seller-1', undefined, undefined,
undefined, undefined, undefined,
[{ mediaId: 'media-1', order: 1 }, { mediaId: 'media-2', order: 0 }],
);
const result = await handler.execute(command);
expect(result.updatedFields).toContain('mediaOrder');
expect(mockPropertyRepo.updateMediaOrder).toHaveBeenCalledWith('prop-1', [
{ mediaId: 'media-1', order: 1 },
{ mediaId: 'media-2', order: 0 },
]);
});
it('rejects update with no fields provided', async () => {
const listing = createListing('listing-1', 'seller-1');
mockListingRepo.findById.mockResolvedValue(listing);

View File

@@ -7,5 +7,6 @@ export class UpdateListingCommand {
public readonly priceVND?: bigint,
public readonly rentPriceMonthly?: bigint,
public readonly amenities?: string[],
public readonly mediaOrder?: { mediaId: string; order: number }[],
) {}
}

View File

@@ -54,8 +54,10 @@ export class UpdateListingHandler implements ICommandHandler<UpdateListingComman
command.title !== undefined ||
command.description !== undefined ||
command.amenities !== undefined;
const hasMediaOrderUpdate =
command.mediaOrder !== undefined && command.mediaOrder.length > 0;
if (!hasListingUpdates && !hasPropertyUpdates) {
if (!hasListingUpdates && !hasPropertyUpdates && !hasMediaOrderUpdate) {
throw new ValidationException('Không có trường nào được cập nhật', {});
}
@@ -88,6 +90,12 @@ export class UpdateListingHandler implements ICommandHandler<UpdateListingComman
allUpdatedFields.push(...propertyUpdated);
}
// Update media order
if (hasMediaOrderUpdate) {
await this.propertyRepo.updateMediaOrder(listing.propertyId, command.mediaOrder!);
allUpdatedFields.push('mediaOrder');
}
// 6. If listing was ACTIVE, transition to PENDING_REVIEW for re-moderation
const previousStatus = listing.status;
listing.markEditedForReModeration(property.id, allUpdatedFields);

View File

@@ -11,4 +11,5 @@ export interface IPropertyRepository {
findMediaByPropertyId(propertyId: string): Promise<PropertyMediaEntity[]>;
deleteMedia(mediaId: string): Promise<void>;
countMediaByPropertyId(propertyId: string): Promise<number>;
updateMediaOrder(propertyId: string, mediaOrder: { mediaId: string; order: number }[]): Promise<void>;
}

View File

@@ -97,9 +97,15 @@ export class PrismaPropertyRepository implements IPropertyRepository {
"usableAreaM2" = ${entity.usableAreaM2},
"bedrooms" = ${entity.bedrooms},
"bathrooms" = ${entity.bathrooms},
"floors" = ${entity.floors},
"floor" = ${entity.floor},
"totalFloors" = ${entity.totalFloors},
"direction" = ${entity.direction}::"Direction",
"yearBuilt" = ${entity.yearBuilt},
"legalStatus" = ${entity.legalStatus},
"amenities" = ${entity.amenities ? JSON.stringify(entity.amenities) : null}::jsonb,
"nearbyPOIs" = ${entity.nearbyPOIs ? JSON.stringify(entity.nearbyPOIs) : null}::jsonb,
"metroDistanceM" = ${entity.metroDistanceM},
"projectName" = ${entity.projectName},
"updatedAt" = NOW()
WHERE "id" = ${entity.id}`;
@@ -136,6 +142,17 @@ export class PrismaPropertyRepository implements IPropertyRepository {
return this.prisma.propertyMedia.count({ where: { propertyId } });
}
async updateMediaOrder(propertyId: string, mediaOrder: { mediaId: string; order: number }[]): Promise<void> {
await this.prisma.$transaction(
mediaOrder.map((item) =>
this.prisma.propertyMedia.updateMany({
where: { id: item.mediaId, propertyId },
data: { order: item.order },
}),
),
);
}
private toDomainWithGeo(raw: PropertyWithGeo): PropertyEntity {
const geoPoint = GeoPoint.create(raw.latitude, raw.longitude).unwrap();
const address = Address.create(raw.address, raw.ward, raw.district, raw.city).unwrap();

View File

@@ -47,12 +47,31 @@ describe('UpdateListingDto', () => {
priceVND: '5500000000',
rentPriceMonthly: '25000000',
amenities: ['Hồ bơi', 'Gym'],
mediaOrder: [{ mediaId: 'media-1', order: 0 }, { mediaId: 'media-2', order: 1 }],
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('should pass validation with mediaOrder only', async () => {
const dto = plainToInstance(UpdateListingDto, {
mediaOrder: [{ mediaId: 'media-1', order: 0 }, { mediaId: 'media-2', order: 1 }],
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('should fail validation when mediaOrder items have invalid order', async () => {
const dto = plainToInstance(UpdateListingDto, {
mediaOrder: [{ mediaId: 'media-1', order: -1 }],
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('should fail validation when title is too short', async () => {
const dto = plainToInstance(UpdateListingDto, {
title: 'AB',

View File

@@ -230,6 +230,7 @@ export class ListingsController {
dto.priceVND,
dto.rentPriceMonthly,
dto.amenities,
dto.mediaOrder,
),
);
}

View File

@@ -1,12 +1,25 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
IsString,
IsOptional,
MinLength,
IsArray,
ValidateNested,
IsNumber,
IsInt,
Min,
} from 'class-validator';
export class MediaOrderItemDto {
@IsString()
mediaId!: string;
@IsInt()
@Min(0)
order!: number;
}
export class UpdateListingDto {
@ApiPropertyOptional({ example: 'Căn hộ 3PN view sông - Vinhomes Central Park', description: 'Listing title (min 5 chars)' })
@IsOptional()
@@ -35,6 +48,15 @@ export class UpdateListingDto {
@IsArray()
amenities?: string[];
// Note: media order changes are handled via separate media endpoints.
@ApiPropertyOptional({
example: [{ mediaId: 'media-1', order: 0 }, { mediaId: 'media-2', order: 1 }],
description: 'Reorder media items by specifying mediaId and new order',
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MediaOrderItemDto)
mediaOrder?: MediaOrderItemDto[];
// propertyType, address, location CANNOT be changed after ACTIVE status.
}