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 */
|
/** Returned by findBySellerId — compact listing for seller dashboard */
|
||||||
export interface ListingSellerItem {
|
export interface ListingSellerItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { type ListingStatus, type TransactionType, type PropertyType } from '@prisma/client';
|
import { type ListingStatus, type TransactionType, type PropertyType } from '@prisma/client';
|
||||||
import { type ListingEntity } from '../entities/listing.entity';
|
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 const LISTING_REPOSITORY = Symbol('LISTING_REPOSITORY');
|
||||||
|
|
||||||
|
export type ListingSortBy = 'publishedAt' | 'priceAsc' | 'priceDesc' | 'createdAt';
|
||||||
|
|
||||||
export interface ListingSearchParams {
|
export interface ListingSearchParams {
|
||||||
status?: ListingStatus;
|
status?: ListingStatus;
|
||||||
transactionType?: TransactionType;
|
transactionType?: TransactionType;
|
||||||
@@ -17,6 +19,10 @@ export interface ListingSearchParams {
|
|||||||
bedrooms?: number;
|
bedrooms?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: 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> {
|
export interface PaginatedResult<T> {
|
||||||
@@ -30,6 +36,7 @@ export interface PaginatedResult<T> {
|
|||||||
export interface IListingRepository {
|
export interface IListingRepository {
|
||||||
findById(id: string): Promise<ListingEntity | null>;
|
findById(id: string): Promise<ListingEntity | null>;
|
||||||
findByIdWithProperty(id: string): Promise<ListingDetailData | null>;
|
findByIdWithProperty(id: string): Promise<ListingDetailData | null>;
|
||||||
|
findSimilar(id: string, limit: number): Promise<ListingSimilarItem[]>;
|
||||||
save(listing: ListingEntity): Promise<void>;
|
save(listing: ListingEntity): Promise<void>;
|
||||||
update(listing: ListingEntity): Promise<void>;
|
update(listing: ListingEntity): Promise<void>;
|
||||||
delete(id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
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', () => {
|
describe('listing-read.queries', () => {
|
||||||
let mockPrisma: {
|
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 Prisma } from '@prisma/client';
|
||||||
import { type PrismaService } from '@modules/shared';
|
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';
|
import { type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||||
|
|
||||||
export async function findByIdWithProperty(
|
export async function findByIdWithProperty(
|
||||||
@@ -128,15 +128,40 @@ export async function searchListings(
|
|||||||
if (params.bedrooms) where.property.bedrooms = { gte: params.bedrooms };
|
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([
|
const [data, total] = await Promise.all([
|
||||||
prisma.listing.findMany({
|
prisma.listing.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: [
|
orderBy: sortClauses,
|
||||||
{ featuredUntil: { sort: 'desc', nulls: 'last' } },
|
|
||||||
{ createdAt: 'desc' },
|
|
||||||
],
|
|
||||||
include: {
|
include: {
|
||||||
property: {
|
property: {
|
||||||
include: {
|
include: {
|
||||||
@@ -267,3 +292,78 @@ export async function findBySellerIdQuery(
|
|||||||
totalPages: Math.ceil(total / limit),
|
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 { type Listing as PrismaListing, type ListingStatus } from '@prisma/client';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { PrismaService } from '@modules/shared';
|
||||||
import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
|
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 { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||||
import { Price } from '../../domain/value-objects/price.vo';
|
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()
|
@Injectable()
|
||||||
export class PrismaListingRepository implements IListingRepository {
|
export class PrismaListingRepository implements IListingRepository {
|
||||||
@@ -97,6 +97,10 @@ export class PrismaListingRepository implements IListingRepository {
|
|||||||
return findBySellerIdQuery(this.prisma, sellerId, page, limit);
|
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 {
|
private toDomain(raw: PrismaListing): ListingEntity {
|
||||||
const price = Price.create(raw.priceVND).unwrap();
|
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 { GetPendingModerationHandler } from './application/queries/get-pending-moderation/get-pending-moderation.handler';
|
||||||
import { GetPriceHistoryHandler } from './application/queries/get-price-history/get-price-history.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 { 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 { SearchListingsHandler } from './application/queries/search-listings/search-listings.handler';
|
||||||
import { LISTING_REPOSITORY } from './domain/repositories/listing.repository';
|
import { LISTING_REPOSITORY } from './domain/repositories/listing.repository';
|
||||||
import { PROPERTY_REPOSITORY } from './domain/repositories/property.repository';
|
import { PROPERTY_REPOSITORY } from './domain/repositories/property.repository';
|
||||||
@@ -51,6 +52,7 @@ const QueryHandlers = [
|
|||||||
GetPendingModerationHandler,
|
GetPendingModerationHandler,
|
||||||
GetPriceHistoryHandler,
|
GetPriceHistoryHandler,
|
||||||
GetPropertyDuplicatesHandler,
|
GetPropertyDuplicatesHandler,
|
||||||
|
GetSimilarListingsHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
const EventHandlers = [
|
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 { 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 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 { 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 { 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 type { PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||||
import { BulkUpdateListingsDto } from '../dto/bulk-update-listings.dto';
|
import { BulkUpdateListingsDto } from '../dto/bulk-update-listings.dto';
|
||||||
import { CreateListingDto } from '../dto/create-listing.dto';
|
import { CreateListingDto } from '../dto/create-listing.dto';
|
||||||
@@ -218,6 +219,20 @@ export class ListingsController {
|
|||||||
return this.queryBus.execute(new GetPriceHistoryQuery(id));
|
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' })
|
@ApiOperation({ summary: 'Get listing details by ID' })
|
||||||
@ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
|
@ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
|
||||||
@ApiResponse({ status: 200, description: 'Listing details returned' })
|
@ApiResponse({ status: 200, description: 'Listing details returned' })
|
||||||
@@ -249,6 +264,8 @@ export class ListingsController {
|
|||||||
dto.bedrooms,
|
dto.bedrooms,
|
||||||
dto.page,
|
dto.page,
|
||||||
dto.limit,
|
dto.limit,
|
||||||
|
dto.sortBy,
|
||||||
|
dto.newSince != null ? new Date(dto.newSince) : undefined,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user