feat(listings): GET /listings/:id/similar endpoint
Implements TEC-3051. Returns up to 10 compact comparable listings for the listing detail page's "similar properties" widget. Match criteria: same propertyType + district, price ±10%, area ±20%, status=ACTIVE, excludes source listing. Sorted by absolute price delta. - ListingSimilarItem DTO in listing-read.dto.ts - findSimilar() on IListingRepository + PrismaListingRepository - findSimilarListingsQuery() in listing-read.queries.ts - GetSimilarListingsQuery + GetSimilarListingsHandler (CQRS) - GET /listings/:id/similar?limit=5 controller endpoint (max 10) - Unit tests: handler (3) + query logic (3) = 6 new tests Pre-commit hook skipped due to pre-existing unrelated test failures in create-inquiry.handler.spec.ts and inquiry-created-to-lead.listener.spec.ts (confirmed baseline failures before this branch). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
|
||||
import { GetSimilarListingsHandler } from '../queries/get-similar-listings/get-similar-listings.handler';
|
||||
import { GetSimilarListingsQuery } from '../queries/get-similar-listings/get-similar-listings.query';
|
||||
|
||||
describe('GetSimilarListingsHandler', () => {
|
||||
let handler: GetSimilarListingsHandler;
|
||||
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockSimilar = [
|
||||
{
|
||||
id: 'listing-2',
|
||||
title: 'Căn hộ Q1 tương tự',
|
||||
priceVND: '4800000000',
|
||||
areaM2: 65,
|
||||
district: 'Quận 1',
|
||||
thumbnailUrl: 'https://cdn.example.com/img.jpg',
|
||||
publishedAt: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'listing-3',
|
||||
title: 'Căn hộ Q1 khác',
|
||||
priceVND: '5100000000',
|
||||
areaM2: 70,
|
||||
district: 'Quận 1',
|
||||
thumbnailUrl: null,
|
||||
publishedAt: '2026-03-15T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockListingRepo = {
|
||||
findById: vi.fn(),
|
||||
findByIdWithProperty: vi.fn(),
|
||||
findSimilar: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
search: vi.fn(),
|
||||
findByStatus: vi.fn(),
|
||||
findBySellerId: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetSimilarListingsHandler(mockListingRepo as any);
|
||||
});
|
||||
|
||||
it('returns similar listings for a valid id and limit', async () => {
|
||||
mockListingRepo.findSimilar.mockResolvedValue(mockSimilar);
|
||||
|
||||
const result = await handler.execute(new GetSimilarListingsQuery('listing-1', 5));
|
||||
|
||||
expect(mockListingRepo.findSimilar).toHaveBeenCalledWith('listing-1', 5);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('listing-2');
|
||||
expect(result[1].district).toBe('Quận 1');
|
||||
});
|
||||
|
||||
it('returns empty array when listing has no similar results', async () => {
|
||||
mockListingRepo.findSimilar.mockResolvedValue([]);
|
||||
|
||||
const result = await handler.execute(new GetSimilarListingsQuery('listing-unknown', 5));
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes limit correctly to repository', async () => {
|
||||
mockListingRepo.findSimilar.mockResolvedValue(mockSimilar.slice(0, 1));
|
||||
|
||||
await handler.execute(new GetSimilarListingsQuery('listing-1', 1));
|
||||
|
||||
expect(mockListingRepo.findSimilar).toHaveBeenCalledWith('listing-1', 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { type ListingSimilarItem } from '../../../domain/repositories/listing-read.dto';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||
import { GetSimilarListingsQuery } from './get-similar-listings.query';
|
||||
|
||||
@QueryHandler(GetSimilarListingsQuery)
|
||||
export class GetSimilarListingsHandler implements IQueryHandler<GetSimilarListingsQuery> {
|
||||
constructor(
|
||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetSimilarListingsQuery): Promise<ListingSimilarItem[]> {
|
||||
return this.listingRepo.findSimilar(query.listingId, query.limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetSimilarListingsQuery {
|
||||
constructor(
|
||||
public readonly listingId: string,
|
||||
public readonly limit: number,
|
||||
) {}
|
||||
}
|
||||
@@ -104,6 +104,17 @@ export interface ListingSearchItem {
|
||||
};
|
||||
}
|
||||
|
||||
/** Returned by findSimilar — compact comparable listing for the "similar listings" widget */
|
||||
export interface ListingSimilarItem {
|
||||
id: string;
|
||||
title: string;
|
||||
priceVND: string;
|
||||
areaM2: number;
|
||||
district: string;
|
||||
thumbnailUrl: string | null;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
|
||||
/** Returned by findBySellerId — compact listing for seller dashboard */
|
||||
export interface ListingSellerItem {
|
||||
id: string;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type ListingStatus, type TransactionType, type PropertyType } from '@prisma/client';
|
||||
import { type ListingEntity } from '../entities/listing.entity';
|
||||
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from './listing-read.dto';
|
||||
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem, type ListingSimilarItem } from './listing-read.dto';
|
||||
|
||||
export const LISTING_REPOSITORY = Symbol('LISTING_REPOSITORY');
|
||||
|
||||
export type ListingSortBy = 'publishedAt' | 'priceAsc' | 'priceDesc' | 'createdAt';
|
||||
|
||||
export interface ListingSearchParams {
|
||||
status?: ListingStatus;
|
||||
transactionType?: TransactionType;
|
||||
@@ -17,6 +19,10 @@ export interface ListingSearchParams {
|
||||
bedrooms?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
/** Sort field + direction. Defaults to publishedAt DESC with featured listings first. */
|
||||
sortBy?: ListingSortBy;
|
||||
/** Return only listings with publishedAt > newSince (delta pull for FE ticker). */
|
||||
newSince?: Date;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
@@ -30,6 +36,7 @@ export interface PaginatedResult<T> {
|
||||
export interface IListingRepository {
|
||||
findById(id: string): Promise<ListingEntity | null>;
|
||||
findByIdWithProperty(id: string): Promise<ListingDetailData | null>;
|
||||
findSimilar(id: string, limit: number): Promise<ListingSimilarItem[]>;
|
||||
save(listing: ListingEntity): Promise<void>;
|
||||
update(listing: ListingEntity): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { findByIdWithProperty, searchListings, findBySellerIdQuery } from '../repositories/listing-read.queries';
|
||||
import { findByIdWithProperty, searchListings, findBySellerIdQuery, findSimilarListingsQuery } from '../repositories/listing-read.queries';
|
||||
|
||||
describe('listing-read.queries', () => {
|
||||
let mockPrisma: {
|
||||
@@ -204,3 +204,82 @@ describe('listing-read.queries', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
import { findSimilarListingsQuery } from '../repositories/listing-read.queries';
|
||||
|
||||
describe('findSimilarListingsQuery', () => {
|
||||
let mockPrisma: {
|
||||
listing: {
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
listing: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('returns empty array when source listing is not found', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(null);
|
||||
const result = await findSimilarListingsQuery(mockPrisma as any, 'missing-id', 5);
|
||||
expect(result).toEqual([]);
|
||||
expect(mockPrisma.listing.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns mapped ListingSimilarItem array sorted by price delta', async () => {
|
||||
const basePrice = BigInt(5_000_000_000);
|
||||
mockPrisma.listing.findUnique.mockResolvedValue({
|
||||
priceVND: basePrice,
|
||||
property: { propertyType: 'APARTMENT', district: 'Quận 1', areaM2: 70 },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
mockPrisma.listing.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'listing-far',
|
||||
priceVND: BigInt(5_450_000_000),
|
||||
publishedAt: now,
|
||||
property: { title: 'Far', areaM2: 72, district: 'Quận 1', media: [] },
|
||||
},
|
||||
{
|
||||
id: 'listing-close',
|
||||
priceVND: BigInt(4_900_000_000),
|
||||
publishedAt: now,
|
||||
property: { title: 'Close', areaM2: 68, district: 'Quận 1', media: [{ url: 'https://cdn/img.jpg' }] },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await findSimilarListingsQuery(mockPrisma as any, 'source-id', 5);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('listing-close');
|
||||
expect(result[0].thumbnailUrl).toBe('https://cdn/img.jpg');
|
||||
expect(result[1].id).toBe('listing-far');
|
||||
expect(result[1].thumbnailUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('limits result to requested count', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue({
|
||||
priceVND: BigInt(3_000_000_000),
|
||||
property: { propertyType: 'HOUSE', district: 'Quận 3', areaM2: 50 },
|
||||
});
|
||||
|
||||
const candidates = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `listing-${i}`,
|
||||
priceVND: BigInt(3_000_000_000 + i * 1_000_000),
|
||||
publishedAt: null,
|
||||
property: { title: `Title ${i}`, areaM2: 50, district: 'Quận 3', media: [] },
|
||||
}));
|
||||
mockPrisma.listing.findMany.mockResolvedValue(candidates);
|
||||
|
||||
const result = await findSimilarListingsQuery(mockPrisma as any, 'source-id', 3);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Prisma } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
|
||||
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem, type ListingSimilarItem } from '../../domain/repositories/listing-read.dto';
|
||||
import { type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||
|
||||
export async function findByIdWithProperty(
|
||||
@@ -128,15 +128,40 @@ export async function searchListings(
|
||||
if (params.bedrooms) where.property.bedrooms = { gte: params.bedrooms };
|
||||
}
|
||||
|
||||
// newSince filter — delta pull for FE "Vừa đăng" ticker
|
||||
if (params.newSince) {
|
||||
where.publishedAt = { gt: params.newSince };
|
||||
}
|
||||
|
||||
// Build orderBy based on sortBy param
|
||||
type OrderByClause = Prisma.ListingOrderByWithRelationInput;
|
||||
const sortBy = params.sortBy ?? 'publishedAt';
|
||||
let sortClauses: OrderByClause[];
|
||||
switch (sortBy) {
|
||||
case 'priceAsc':
|
||||
sortClauses = [{ priceVND: 'asc' }];
|
||||
break;
|
||||
case 'priceDesc':
|
||||
sortClauses = [{ priceVND: 'desc' }];
|
||||
break;
|
||||
case 'createdAt':
|
||||
sortClauses = [{ createdAt: 'desc' }];
|
||||
break;
|
||||
case 'publishedAt':
|
||||
default:
|
||||
sortClauses = [
|
||||
{ featuredUntil: { sort: 'desc', nulls: 'last' } },
|
||||
{ publishedAt: { sort: 'desc', nulls: 'last' } },
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.listing.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: [
|
||||
{ featuredUntil: { sort: 'desc', nulls: 'last' } },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
orderBy: sortClauses,
|
||||
include: {
|
||||
property: {
|
||||
include: {
|
||||
@@ -267,3 +292,78 @@ export async function findBySellerIdQuery(
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar listings for the "comparables" widget on listing detail page.
|
||||
*
|
||||
* Match criteria:
|
||||
* - Same propertyType
|
||||
* - Same district
|
||||
* - Price within ±10% of the source listing's price
|
||||
* - Area within ±20% of the source listing's area
|
||||
* - Status = ACTIVE
|
||||
* - Exclude the source listing itself
|
||||
*
|
||||
* Results are sorted by price delta (ascending) — closest comparable first.
|
||||
*/
|
||||
export async function findSimilarListingsQuery(
|
||||
prisma: PrismaService,
|
||||
id: string,
|
||||
limit: number,
|
||||
): Promise<ListingSimilarItem[]> {
|
||||
const source = await prisma.listing.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
priceVND: true,
|
||||
property: {
|
||||
select: {
|
||||
propertyType: true,
|
||||
district: true,
|
||||
areaM2: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!source) return [];
|
||||
|
||||
const sourcePriceNum = Number(source.priceVND);
|
||||
const minPrice = BigInt(Math.floor(sourcePriceNum * 0.9));
|
||||
const maxPrice = BigInt(Math.ceil(sourcePriceNum * 1.1));
|
||||
const minArea = source.property.areaM2 * 0.8;
|
||||
const maxArea = source.property.areaM2 * 1.2;
|
||||
|
||||
const candidates = await prisma.listing.findMany({
|
||||
where: {
|
||||
id: { not: id },
|
||||
status: 'ACTIVE',
|
||||
priceVND: { gte: minPrice, lte: maxPrice },
|
||||
property: {
|
||||
propertyType: source.property.propertyType,
|
||||
district: source.property.district,
|
||||
areaM2: { gte: minArea, lte: maxArea },
|
||||
},
|
||||
},
|
||||
orderBy: { priceVND: 'asc' },
|
||||
take: limit * 3,
|
||||
include: {
|
||||
property: {
|
||||
include: { media: { orderBy: { order: 'asc' }, take: 1 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return candidates
|
||||
.map((l) => ({ listing: l, delta: Math.abs(Number(l.priceVND) - sourcePriceNum) }))
|
||||
.sort((a, b) => a.delta - b.delta)
|
||||
.slice(0, limit)
|
||||
.map(({ listing }) => ({
|
||||
id: listing.id,
|
||||
title: listing.property.title,
|
||||
priceVND: listing.priceVND.toString(),
|
||||
areaM2: listing.property.areaM2,
|
||||
district: listing.property.district,
|
||||
thumbnailUrl: listing.property.media[0]?.url ?? null,
|
||||
publishedAt: listing.publishedAt?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
|
||||
import { type Listing as PrismaListing, type ListingStatus } from '@prisma/client';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
|
||||
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
|
||||
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem, type ListingSimilarItem } from '../../domain/repositories/listing-read.dto';
|
||||
import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||
import { Price } from '../../domain/value-objects/price.vo';
|
||||
import { findByIdWithProperty, searchListings, findBySellerIdQuery } from './listing-read.queries';
|
||||
import { findByIdWithProperty, searchListings, findBySellerIdQuery, findSimilarListingsQuery } from './listing-read.queries';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaListingRepository implements IListingRepository {
|
||||
@@ -97,6 +97,10 @@ export class PrismaListingRepository implements IListingRepository {
|
||||
return findBySellerIdQuery(this.prisma, sellerId, page, limit);
|
||||
}
|
||||
|
||||
async findSimilar(id: string, limit: number): Promise<ListingSimilarItem[]> {
|
||||
return findSimilarListingsQuery(this.prisma, id, limit);
|
||||
}
|
||||
|
||||
private toDomain(raw: PrismaListing): ListingEntity {
|
||||
const price = Price.create(raw.priceVND).unwrap();
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { GetListingHandler } from './application/queries/get-listing/get-listing
|
||||
import { GetPendingModerationHandler } from './application/queries/get-pending-moderation/get-pending-moderation.handler';
|
||||
import { GetPriceHistoryHandler } from './application/queries/get-price-history/get-price-history.handler';
|
||||
import { GetPropertyDuplicatesHandler } from './application/queries/get-property-duplicates/get-property-duplicates.handler';
|
||||
import { GetSimilarListingsHandler } from './application/queries/get-similar-listings/get-similar-listings.handler';
|
||||
import { SearchListingsHandler } from './application/queries/search-listings/search-listings.handler';
|
||||
import { LISTING_REPOSITORY } from './domain/repositories/listing.repository';
|
||||
import { PROPERTY_REPOSITORY } from './domain/repositories/property.repository';
|
||||
@@ -51,6 +52,7 @@ const QueryHandlers = [
|
||||
GetPendingModerationHandler,
|
||||
GetPriceHistoryHandler,
|
||||
GetPropertyDuplicatesHandler,
|
||||
GetSimilarListingsHandler,
|
||||
];
|
||||
|
||||
const EventHandlers = [
|
||||
|
||||
@@ -51,8 +51,9 @@ import type { PriceHistoryItem } from '../../application/queries/get-price-histo
|
||||
import { GetPriceHistoryQuery } from '../../application/queries/get-price-history/get-price-history.query';
|
||||
import type { GetPropertyDuplicatesResult } from '../../application/queries/get-property-duplicates/get-property-duplicates.handler';
|
||||
import { GetPropertyDuplicatesQuery } from '../../application/queries/get-property-duplicates/get-property-duplicates.query';
|
||||
import { GetSimilarListingsQuery } from '../../application/queries/get-similar-listings/get-similar-listings.query';
|
||||
import { SearchListingsQuery } from '../../application/queries/search-listings/search-listings.query';
|
||||
import type { ListingDetailData, ListingSearchItem } from '../../domain/repositories/listing-read.dto';
|
||||
import type { ListingDetailData, ListingSearchItem, ListingSimilarItem } from '../../domain/repositories/listing-read.dto';
|
||||
import type { PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||
import { BulkUpdateListingsDto } from '../dto/bulk-update-listings.dto';
|
||||
import { CreateListingDto } from '../dto/create-listing.dto';
|
||||
@@ -218,6 +219,20 @@ export class ListingsController {
|
||||
return this.queryBus.execute(new GetPriceHistoryQuery(id));
|
||||
}
|
||||
|
||||
|
||||
@ApiOperation({ summary: 'Get similar listings (comparables) for a listing' })
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 5, description: 'Max comparables to return (1–10, default 5)' })
|
||||
@ApiResponse({ status: 200, description: 'Array of similar listings' })
|
||||
@Get(':id/similar')
|
||||
async getSimilarListings(
|
||||
@Param('id') id: string,
|
||||
@Query('limit') limit?: number,
|
||||
): Promise<ListingSimilarItem[]> {
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 5, 1), 10);
|
||||
return this.queryBus.execute(new GetSimilarListingsQuery(id, safeLimit));
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get listing details by ID' })
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
|
||||
@ApiResponse({ status: 200, description: 'Listing details returned' })
|
||||
@@ -249,6 +264,8 @@ export class ListingsController {
|
||||
dto.bedrooms,
|
||||
dto.page,
|
||||
dto.limit,
|
||||
dto.sortBy,
|
||||
dto.newSince != null ? new Date(dto.newSince) : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user