diff --git a/apps/api/src/modules/listings/application/__tests__/get-similar-listings.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/get-similar-listings.handler.spec.ts new file mode 100644 index 0000000..eff2bf3 --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/get-similar-listings.handler.spec.ts @@ -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 }; + + 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); + }); +}); diff --git a/apps/api/src/modules/listings/application/queries/get-similar-listings/get-similar-listings.handler.ts b/apps/api/src/modules/listings/application/queries/get-similar-listings/get-similar-listings.handler.ts new file mode 100644 index 0000000..a508b71 --- /dev/null +++ b/apps/api/src/modules/listings/application/queries/get-similar-listings/get-similar-listings.handler.ts @@ -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 { + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + ) {} + + async execute(query: GetSimilarListingsQuery): Promise { + return this.listingRepo.findSimilar(query.listingId, query.limit); + } +} diff --git a/apps/api/src/modules/listings/application/queries/get-similar-listings/get-similar-listings.query.ts b/apps/api/src/modules/listings/application/queries/get-similar-listings/get-similar-listings.query.ts new file mode 100644 index 0000000..1e7fd19 --- /dev/null +++ b/apps/api/src/modules/listings/application/queries/get-similar-listings/get-similar-listings.query.ts @@ -0,0 +1,6 @@ +export class GetSimilarListingsQuery { + constructor( + public readonly listingId: string, + public readonly limit: number, + ) {} +} diff --git a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts index 946a57c..850bb16 100644 --- a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts +++ b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts @@ -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; diff --git a/apps/api/src/modules/listings/domain/repositories/listing.repository.ts b/apps/api/src/modules/listings/domain/repositories/listing.repository.ts index 022a043..5e680fb 100644 --- a/apps/api/src/modules/listings/domain/repositories/listing.repository.ts +++ b/apps/api/src/modules/listings/domain/repositories/listing.repository.ts @@ -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 { @@ -30,6 +36,7 @@ export interface PaginatedResult { export interface IListingRepository { findById(id: string): Promise; findByIdWithProperty(id: string): Promise; + findSimilar(id: string, limit: number): Promise; save(listing: ListingEntity): Promise; update(listing: ListingEntity): Promise; delete(id: string): Promise; diff --git a/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts b/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts index b620e3b..a334c8d 100644 --- a/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts +++ b/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts @@ -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; + findMany: ReturnType; + }; + }; + + 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); + }); +}); diff --git a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts index a93bdb3..a256f8b 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts @@ -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 { + 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, + })); +} diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts index 78eed50..bffce33 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts @@ -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 { + return findSimilarListingsQuery(this.prisma, id, limit); + } + private toDomain(raw: PrismaListing): ListingEntity { const price = Price.create(raw.priceVND).unwrap(); diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index 05c5fd8..b044a36 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -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 = [ diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 9b419e1..76a5386 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -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 { + 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, ), ); }