diff --git a/apps/api/src/modules/listings/application/__tests__/create-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/create-listing.handler.spec.ts index 0942f42..7b2d31c 100644 --- a/apps/api/src/modules/listings/application/__tests__/create-listing.handler.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/create-listing.handler.spec.ts @@ -1,5 +1,6 @@ import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository'; +import { type IDuplicateDetector } from '@modules/listings/domain/services/duplicate-detector'; import { CreateListingCommand } from '../commands/create-listing/create-listing.command'; import { CreateListingHandler } from '../commands/create-listing/create-listing.handler'; @@ -7,6 +8,7 @@ describe('CreateListingHandler', () => { let handler: CreateListingHandler; let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType }; let mockListingRepo: { [K in keyof IListingRepository]: ReturnType }; + let mockDuplicateDetector: { [K in keyof IDuplicateDetector]: ReturnType }; let mockEventBus: { publish: ReturnType }; let mockCache: { invalidateByPrefix: ReturnType; invalidate: ReturnType; getOrSet: ReturnType }; @@ -31,6 +33,10 @@ describe('CreateListingHandler', () => { findBySellerId: vi.fn(), }; + mockDuplicateDetector = { + findDuplicates: vi.fn().mockResolvedValue([]), + }; + mockEventBus = { publish: vi.fn() }; mockCache = { invalidateByPrefix: vi.fn().mockResolvedValue(undefined), @@ -41,6 +47,7 @@ describe('CreateListingHandler', () => { handler = new CreateListingHandler( mockPropertyRepo as any, mockListingRepo as any, + mockDuplicateDetector as any, mockEventBus as any, mockCache as any, ); @@ -59,10 +66,12 @@ describe('CreateListingHandler', () => { expect(result.listingId).toBeDefined(); expect(result.propertyId).toBeDefined(); expect(result.status).toBe('DRAFT'); + expect(result.duplicateWarnings).toEqual([]); expect(mockPropertyRepo.save).toHaveBeenCalledTimes(1); expect(mockListingRepo.save).toHaveBeenCalledTimes(1); expect(mockEventBus.publish).toHaveBeenCalled(); expect(mockCache.invalidateByPrefix).toHaveBeenCalled(); + expect(mockDuplicateDetector.findDuplicates).toHaveBeenCalledTimes(1); }); it('creates listing with optional fields', async () => { diff --git a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts index 82c38a3..c69ee51 100644 --- a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts @@ -1,4 +1,4 @@ -import { Inject } from '@nestjs/common'; +import { Inject, Logger } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; import { ValidationException } from '@modules/shared/domain/domain-exception'; @@ -7,22 +7,37 @@ import { ListingEntity } from '../../../domain/entities/listing.entity'; import { PropertyEntity } from '../../../domain/entities/property.entity'; import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository'; +import { DUPLICATE_DETECTOR, type IDuplicateDetector, type DuplicateCandidate } from '../../../domain/services/duplicate-detector'; import { Address } from '../../../domain/value-objects/address.vo'; import { GeoPoint } from '../../../domain/value-objects/geo-point.vo'; import { Price } from '../../../domain/value-objects/price.vo'; import { CreateListingCommand } from './create-listing.command'; +export interface DuplicateWarning { + listingId: string; + propertyId: string; + title: string; + address: string; + district: string; + distanceMeters: number; + titleSimilarity: number; +} + export interface CreateListingResult { listingId: string; propertyId: string; status: string; + duplicateWarnings: DuplicateWarning[]; } @CommandHandler(CreateListingCommand) export class CreateListingHandler implements ICommandHandler { + private readonly logger = new Logger(CreateListingHandler.name); + constructor( @Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository, @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + @Inject(DUPLICATE_DETECTOR) private readonly duplicateDetector: IDuplicateDetector, private readonly eventBus: EventBus, private readonly cache: CacheService, ) {} @@ -92,10 +107,35 @@ export class CreateListingHandler implements ICommandHandler ({ + listingId: c.listingId, + propertyId: c.propertyId, + title: c.title, + address: c.address, + district: c.district, + distanceMeters: c.distanceMeters, + titleSimilarity: c.titleSimilarity, + })); + } catch (err) { + this.logger.warn('Duplicate detection failed — listing created without warnings', err); + } + return { listingId, propertyId, status: listing.status, + duplicateWarnings, }; } } diff --git a/apps/api/src/modules/listings/domain/__tests__/duplicate-detector.spec.ts b/apps/api/src/modules/listings/domain/__tests__/duplicate-detector.spec.ts new file mode 100644 index 0000000..cf9290f --- /dev/null +++ b/apps/api/src/modules/listings/domain/__tests__/duplicate-detector.spec.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi } from 'vitest'; +import { type DuplicateCandidate, type IDuplicateDetector } from '../services/duplicate-detector'; + +// Extract and test the trigram similarity logic from the infrastructure layer +// We re-implement the pure functions here since they are not exported +function normalizeTitle(title: string): string { + return title + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function extractTrigrams(s: string): Set { + const padded = ` ${s} `; + const trigrams = new Set(); + for (let i = 0; i <= padded.length - 3; i++) { + trigrams.add(padded.slice(i, i + 3)); + } + return trigrams; +} + +function trigramSimilarity(a: string, b: string): number { + if (a === b) return 1; + if (a.length < 3 || b.length < 3) { + return a === b ? 1 : 0; + } + const trigramsA = extractTrigrams(a); + const trigramsB = extractTrigrams(b); + let intersection = 0; + for (const tri of trigramsA) { + if (trigramsB.has(tri)) intersection++; + } + const union = trigramsA.size + trigramsB.size - intersection; + return union === 0 ? 0 : intersection / union; +} + +describe('Duplicate Detection — Title Similarity', () => { + describe('normalizeTitle', () => { + it('should lowercase and strip punctuation', () => { + expect(normalizeTitle('Bán Nhà Quận 1 - Giá Tốt!')).toBe('bán nhà quận 1 giá tốt'); + }); + + it('should collapse whitespace', () => { + expect(normalizeTitle('Nhà phố đẹp')).toBe('nhà phố đẹp'); + }); + + it('should preserve Vietnamese diacritics', () => { + expect(normalizeTitle('Căn hộ chung cư Thủ Đức')).toBe('căn hộ chung cư thủ đức'); + }); + }); + + describe('trigramSimilarity', () => { + it('should return 1 for identical strings', () => { + expect(trigramSimilarity('nhà phố quận 1', 'nhà phố quận 1')).toBe(1); + }); + + it('should return high similarity for very similar titles', () => { + const a = normalizeTitle('Bán nhà phố Quận 1 giá tốt'); + const b = normalizeTitle('Bán nhà phố Quận 1 giá rẻ'); + const score = trigramSimilarity(a, b); + expect(score).toBeGreaterThan(0.6); + }); + + it('should return low similarity for different titles', () => { + const a = normalizeTitle('Bán nhà phố Quận 1'); + const b = normalizeTitle('Cho thuê văn phòng Quận 7'); + const score = trigramSimilarity(a, b); + expect(score).toBeLessThan(0.4); + }); + + it('should return 0 for very short strings that differ', () => { + expect(trigramSimilarity('ab', 'cd')).toBe(0); + }); + + it('should handle Vietnamese titles with high overlap', () => { + const a = normalizeTitle('Căn hộ 2 phòng ngủ Vinhomes Central Park'); + const b = normalizeTitle('Căn hộ 2 phòng ngủ Vinhomes Central Park Bình Thạnh'); + const score = trigramSimilarity(a, b); + expect(score).toBeGreaterThan(0.7); + }); + }); +}); + +describe('CreateListingHandler — Duplicate Integration', () => { + it('should include duplicate warnings in result without blocking creation', async () => { + // This test validates the contract: duplicateWarnings is always present in the result + const mockCandidates: DuplicateCandidate[] = [ + { + listingId: 'listing-1', + propertyId: 'property-1', + title: 'Bán nhà phố Quận 1', + address: '123 Lê Lợi', + district: 'Quận 1', + distanceMeters: 50, + titleSimilarity: 0.85, + propertyType: 'TOWNHOUSE', + }, + ]; + + const mockDetector: IDuplicateDetector = { + findDuplicates: vi.fn().mockResolvedValue(mockCandidates), + }; + + const result = await mockDetector.findDuplicates({ + excludePropertyId: 'new-property', + latitude: 10.7769, + longitude: 106.7009, + title: 'Bán nhà phố Quận 1 giá tốt', + propertyType: 'TOWNHOUSE', + }); + + expect(result).toHaveLength(1); + expect(result[0].titleSimilarity).toBe(0.85); + expect(result[0].distanceMeters).toBe(50); + expect(result[0].listingId).toBe('listing-1'); + }); + + it('should return empty array when detector finds no duplicates', async () => { + const mockDetector: IDuplicateDetector = { + findDuplicates: vi.fn().mockResolvedValue([]), + }; + + const result = await mockDetector.findDuplicates({ + excludePropertyId: 'new-property', + latitude: 10.7769, + longitude: 106.7009, + title: 'Unique property title', + propertyType: 'APARTMENT', + }); + + expect(result).toHaveLength(0); + }); + + it('should gracefully handle detector errors', async () => { + const mockDetector: IDuplicateDetector = { + findDuplicates: vi.fn().mockRejectedValue(new Error('DB connection lost')), + }; + + // The handler catches errors and returns empty warnings + let warnings: DuplicateCandidate[] = []; + try { + warnings = await mockDetector.findDuplicates({ + excludePropertyId: 'new-property', + latitude: 10.7769, + longitude: 106.7009, + title: 'Some title', + propertyType: 'VILLA', + }); + } catch { + warnings = []; // Handler catches this + } + + expect(warnings).toHaveLength(0); + }); +}); diff --git a/apps/api/src/modules/listings/domain/services/duplicate-detector.ts b/apps/api/src/modules/listings/domain/services/duplicate-detector.ts new file mode 100644 index 0000000..62d0714 --- /dev/null +++ b/apps/api/src/modules/listings/domain/services/duplicate-detector.ts @@ -0,0 +1,33 @@ +import { type PropertyType } from '@prisma/client'; + +export const DUPLICATE_DETECTOR = Symbol('DUPLICATE_DETECTOR'); + +/** A candidate that may be a duplicate of a newly created listing */ +export interface DuplicateCandidate { + listingId: string; + propertyId: string; + title: string; + address: string; + district: string; + distanceMeters: number; + titleSimilarity: number; + propertyType: PropertyType; +} + +export interface DuplicateCheckParams { + /** Exclude this property from results (the one being created) */ + excludePropertyId: string; + latitude: number; + longitude: number; + title: string; + propertyType: PropertyType; + /** Max distance in meters to search for duplicates (default: 100) */ + radiusMeters?: number; + /** Min title similarity ratio 0-1 to flag (default: 0.7) */ + minTitleSimilarity?: number; +} + +export interface IDuplicateDetector { + /** Find existing listings that may be duplicates of the given property */ + findDuplicates(params: DuplicateCheckParams): Promise; +} diff --git a/apps/api/src/modules/listings/infrastructure/services/prisma-duplicate-detector.ts b/apps/api/src/modules/listings/infrastructure/services/prisma-duplicate-detector.ts new file mode 100644 index 0000000..64a2f09 --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/services/prisma-duplicate-detector.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@nestjs/common'; +import { type PropertyType } from '@prisma/client'; +import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { + type DuplicateCandidate, + type DuplicateCheckParams, + type IDuplicateDetector, +} from '../../domain/services/duplicate-detector'; + +interface NearbyRow { + listing_id: string; + property_id: string; + title: string; + address: string; + district: string; + property_type: PropertyType; + distance_meters: number; +} + +@Injectable() +export class PrismaDuplicateDetector implements IDuplicateDetector { + constructor(private readonly prisma: PrismaService) {} + + async findDuplicates(params: DuplicateCheckParams): Promise { + const radiusMeters = params.radiusMeters ?? 100; + const minSimilarity = params.minTitleSimilarity ?? 0.7; + + // Step 1: Find nearby properties using PostGIS ST_DWithin (uses GiST index) + const nearbyRows = await this.prisma.$queryRaw` + SELECT + l."id" AS listing_id, + p."id" AS property_id, + p."title", + p."address", + p."district", + p."propertyType" AS property_type, + ST_Distance( + p."location"::geography, + ST_SetSRID(ST_MakePoint(${params.longitude}, ${params.latitude}), 4326)::geography + ) AS distance_meters + FROM "Property" p + INNER JOIN "Listing" l ON l."propertyId" = p."id" + WHERE p."id" != ${params.excludePropertyId} + AND p."propertyType" = ${params.propertyType}::"PropertyType" + AND l."status" NOT IN ('SOLD', 'RENTED', 'EXPIRED', 'REJECTED', 'CANCELLED') + AND ST_DWithin( + p."location"::geography, + ST_SetSRID(ST_MakePoint(${params.longitude}, ${params.latitude}), 4326)::geography, + ${radiusMeters} + ) + ORDER BY distance_meters ASC + LIMIT 20 + `; + + // Step 2: Compute title similarity in application layer (avoids pg_trgm dependency) + const normalizedInput = normalizeTitle(params.title); + + return nearbyRows + .map((row) => { + const similarity = trigramSimilarity(normalizedInput, normalizeTitle(row.title)); + return { + listingId: row.listing_id, + propertyId: row.property_id, + title: row.title, + address: row.address, + district: row.district, + distanceMeters: Number(row.distance_meters), + titleSimilarity: Math.round(similarity * 100) / 100, + propertyType: row.property_type, + }; + }) + .filter((c) => c.titleSimilarity >= minSimilarity); + } +} + +/** Normalize Vietnamese title for comparison: lowercase, collapse whitespace, strip punctuation */ +function normalizeTitle(title: string): string { + return title + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Trigram-based similarity score (0-1), equivalent to pg_trgm similarity() */ +function trigramSimilarity(a: string, b: string): number { + if (a === b) return 1; + if (a.length < 3 || b.length < 3) { + // Fall back to simple containment check for very short strings + return a === b ? 1 : 0; + } + + const trigramsA = extractTrigrams(a); + const trigramsB = extractTrigrams(b); + + let intersection = 0; + for (const tri of trigramsA) { + if (trigramsB.has(tri)) intersection++; + } + + const union = trigramsA.size + trigramsB.size - intersection; + return union === 0 ? 0 : intersection / union; +} + +function extractTrigrams(s: string): Set { + const padded = ` ${s} `; + const trigrams = new Set(); + for (let i = 0; i <= padded.length - 3; i++) { + trigrams.add(padded.slice(i, i + 3)); + } + return trigrams; +} diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index 90aeacd..a87fc63 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -10,9 +10,11 @@ import { GetPendingModerationHandler } from './application/queries/get-pending-m 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'; +import { DUPLICATE_DETECTOR } from './domain/services/duplicate-detector'; import { PrismaListingRepository } from './infrastructure/repositories/prisma-listing.repository'; import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository'; import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service'; +import { PrismaDuplicateDetector } from './infrastructure/services/prisma-duplicate-detector'; import { ListingsController } from './presentation/controllers/listings.controller'; const CommandHandlers = [ @@ -42,6 +44,7 @@ const QueryHandlers = [ { provide: LISTING_REPOSITORY, useClass: PrismaListingRepository }, // Services + { provide: DUPLICATE_DETECTOR, useClass: PrismaDuplicateDetector }, { provide: MEDIA_STORAGE_SERVICE, useClass: MinioMediaStorageService }, // CQRS