From aca4fd37cba14eb23acb484b8badd20e7f3998d6 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 12 Apr 2026 21:12:56 +0700 Subject: [PATCH] refactor(api): split 3 oversized files to comply with 200 LOC convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared logic from postgres-search.repository.ts (361→105), prisma-agent.repository.ts (298→179), and prisma-avm.service.ts (224→143) into focused helper modules. All existing tests (92/92) pass unchanged. Co-Authored-By: Paperclip --- .../repositories/agent-profile.queries.ts | 112 +++++++ .../repositories/prisma-agent.repository.ts | 149 +-------- .../services/avm-calculation.helper.ts | 66 ++++ .../services/prisma-avm.service.ts | 203 ++++--------- .../search/infrastructure/services/index.ts | 2 + .../services/postgres-search.repository.ts | 283 +----------------- .../services/search-filter-parser.ts | 82 +++++ .../services/search-query-builder.ts | 95 ++++++ .../services/search-result-mapper.ts | 64 ++++ 9 files changed, 511 insertions(+), 545 deletions(-) create mode 100644 apps/api/src/modules/agents/infrastructure/repositories/agent-profile.queries.ts create mode 100644 apps/api/src/modules/analytics/infrastructure/services/avm-calculation.helper.ts create mode 100644 apps/api/src/modules/search/infrastructure/services/search-filter-parser.ts create mode 100644 apps/api/src/modules/search/infrastructure/services/search-query-builder.ts create mode 100644 apps/api/src/modules/search/infrastructure/services/search-result-mapper.ts diff --git a/apps/api/src/modules/agents/infrastructure/repositories/agent-profile.queries.ts b/apps/api/src/modules/agents/infrastructure/repositories/agent-profile.queries.ts new file mode 100644 index 0000000..022fee6 --- /dev/null +++ b/apps/api/src/modules/agents/infrastructure/repositories/agent-profile.queries.ts @@ -0,0 +1,112 @@ +import { type PrismaService } from '@modules/shared'; +import { + type AgentPublicProfileData, + type AgentPublicListingItem, +} from '../../domain/repositories/agent.repository'; + +/** Fetch active listings for an agent's public profile. */ +export async function getActiveListingsForAgent( + prisma: PrismaService, + agentId: string, +): Promise { + const listings = await prisma.listing.findMany({ + where: { agentId, status: 'ACTIVE' }, + take: 12, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + transactionType: true, + priceVND: true, + status: true, + property: { + select: { + id: true, + title: true, + propertyType: true, + address: true, + district: true, + city: true, + areaM2: true, + bedrooms: true, + bathrooms: true, + media: { + where: { type: 'image' }, + take: 1, + orderBy: { order: 'asc' }, + select: { url: true }, + }, + }, + }, + }, + }); + + return listings.map((l) => ({ + id: l.id, + transactionType: l.transactionType, + priceVND: l.priceVND.toString(), + status: l.status, + property: { + id: l.property.id, + title: l.property.title, + propertyType: l.property.propertyType, + address: l.property.address, + district: l.property.district, + city: l.property.city, + areaM2: l.property.areaM2, + bedrooms: l.property.bedrooms, + bathrooms: l.property.bathrooms, + imageUrl: l.property.media[0]?.url ?? null, + }, + })); +} + +/** Build the full public profile data for an agent. */ +export async function buildPublicProfile( + prisma: PrismaService, + agentId: string, +): Promise { + const agent = await prisma.agent.findUnique({ + where: { id: agentId }, + include: { + user: { + select: { + fullName: true, + avatarUrl: true, + phone: true, + email: true, + createdAt: true, + }, + }, + }, + }); + + if (!agent) return null; + + const [listings, reviewStats] = await Promise.all([ + getActiveListingsForAgent(prisma, agentId), + prisma.review.aggregate({ + where: { targetType: 'AGENT', targetId: agentId }, + _avg: { rating: true }, + _count: { rating: true }, + }), + ]); + + return { + id: agent.id, + fullName: agent.user.fullName, + avatarUrl: agent.user.avatarUrl, + phone: agent.user.phone, + email: agent.user.email, + agency: agent.agency, + licenseNumber: agent.licenseNumber, + bio: agent.bio, + qualityScore: agent.qualityScore, + totalDeals: agent.totalDeals, + isVerified: agent.isVerified, + serviceAreas: (agent.serviceAreas as string[]) ?? [], + memberSince: agent.createdAt.toISOString(), + activeListings: listings, + avgReviewRating: Math.round((reviewStats._avg.rating ?? 0) * 10) / 10, + totalReviews: reviewStats._count.rating, + }; +} diff --git a/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts b/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts index 0e0c890..e1a2d1a 100644 --- a/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts +++ b/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts @@ -4,28 +4,24 @@ import { AgentEntity } from '../../domain/entities/agent.entity'; import { type AgentDashboardData, type AgentPublicProfileData, - type AgentPublicListingItem, type IAgentRepository, type QualityScoreInputData, } from '../../domain/repositories/agent.repository'; import { QualityScore } from '../../domain/value-objects/quality-score.vo'; +import { buildPublicProfile } from './agent-profile.queries'; @Injectable() export class PrismaAgentRepository implements IAgentRepository { constructor(private readonly prisma: PrismaService) {} async findByUserId(userId: string): Promise { - const row = await this.prisma.agent.findUnique({ - where: { userId }, - }); + const row = await this.prisma.agent.findUnique({ where: { userId } }); if (!row) return null; return this.toDomain(row); } async findById(agentId: string): Promise { - const row = await this.prisma.agent.findUnique({ - where: { id: agentId }, - }); + const row = await this.prisma.agent.findUnique({ where: { id: agentId } }); if (!row) return null; return this.toDomain(row); } @@ -33,9 +29,7 @@ export class PrismaAgentRepository implements IAgentRepository { async save(agent: AgentEntity): Promise { await this.prisma.agent.update({ where: { id: agent.id }, - data: { - qualityScore: agent.qualityScore.value, - }, + data: { qualityScore: agent.qualityScore.value }, }); } @@ -45,11 +39,8 @@ export class PrismaAgentRepository implements IAgentRepository { this.prisma.agent.findUniqueOrThrow({ where: { id: agentId }, select: { - id: true, - qualityScore: true, - totalDeals: true, - responseTimeAvg: true, - isVerified: true, + id: true, qualityScore: true, totalDeals: true, + responseTimeAvg: true, isVerified: true, }, }), this.prisma.lead.groupBy({ @@ -73,9 +64,7 @@ export class PrismaAgentRepository implements IAgentRepository { for (const group of leads) { leadsByStatus[group.status] = group._count.id; totalLeads += group._count.id; - if (group.status === 'CONVERTED') { - convertedLeads = group._count.id; - } + if (group.status === 'CONVERTED') convertedLeads = group._count.id; } const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0; @@ -88,62 +77,18 @@ export class PrismaAgentRepository implements IAgentRepository { isVerified: agent.isVerified, totalLeads, leadsByStatus, - conversionRate: Math.round(conversionRate * 1000) / 1000, // 3 decimals + conversionRate: Math.round(conversionRate * 1000) / 1000, totalInquiries: inquiryStats.total, unreadInquiries: inquiryStats.unread, totalListings: listingStats.total, activeListings: listingStats.active, - avgReviewRating: - Math.round((reviewStats._avg.rating ?? 0) * 10) / 10, + avgReviewRating: Math.round((reviewStats._avg.rating ?? 0) * 10) / 10, totalReviews: reviewStats._count.rating, }; } async getPublicProfile(agentId: string): Promise { - const agent = await this.prisma.agent.findUnique({ - where: { id: agentId }, - include: { - user: { - select: { - fullName: true, - avatarUrl: true, - phone: true, - email: true, - createdAt: true, - }, - }, - }, - }); - - if (!agent) return null; - - const [listings, reviewStats] = await Promise.all([ - this.getActiveListingsForAgent(agentId), - this.prisma.review.aggregate({ - where: { targetType: 'AGENT', targetId: agentId }, - _avg: { rating: true }, - _count: { rating: true }, - }), - ]); - - return { - id: agent.id, - fullName: agent.user.fullName, - avatarUrl: agent.user.avatarUrl, - phone: agent.user.phone, - email: agent.user.email, - agency: agent.agency, - licenseNumber: agent.licenseNumber, - bio: agent.bio, - qualityScore: agent.qualityScore, - totalDeals: agent.totalDeals, - isVerified: agent.isVerified, - serviceAreas: (agent.serviceAreas as string[]) ?? [], - memberSince: agent.createdAt.toISOString(), - activeListings: listings, - avgReviewRating: Math.round((reviewStats._avg.rating ?? 0) * 10) / 10, - totalReviews: reviewStats._count.rating, - }; + return buildPublicProfile(this.prisma, agentId); } async getQualityScoreInputs(agentId: string): Promise { @@ -156,15 +101,11 @@ export class PrismaAgentRepository implements IAgentRepository { }), Promise.all([ this.prisma.lead.count({ where: { agentId } }), - this.prisma.lead.count({ - where: { agentId, status: 'CONVERTED' }, - }), + this.prisma.lead.count({ where: { agentId, status: 'CONVERTED' } }), ]), Promise.all([ this.prisma.listing.count({ where: { agentId } }), - this.prisma.listing.count({ - where: { agentId, status: 'ACTIVE' }, - }), + this.prisma.listing.count({ where: { agentId, status: 'ACTIVE' } }), ]), this.prisma.agent.findUnique({ where: { id: agentId }, @@ -188,12 +129,8 @@ export class PrismaAgentRepository implements IAgentRepository { agentId: string, ): Promise<{ total: number; unread: number }> { const [total, unread] = await Promise.all([ - this.prisma.inquiry.count({ - where: { listing: { agentId } }, - }), - this.prisma.inquiry.count({ - where: { listing: { agentId }, isRead: false }, - }), + this.prisma.inquiry.count({ where: { listing: { agentId } } }), + this.prisma.inquiry.count({ where: { listing: { agentId }, isRead: false } }), ]); return { total, unread }; } @@ -203,67 +140,11 @@ export class PrismaAgentRepository implements IAgentRepository { ): Promise<{ total: number; active: number }> { const [total, active] = await Promise.all([ this.prisma.listing.count({ where: { agentId } }), - this.prisma.listing.count({ - where: { agentId, status: 'ACTIVE' }, - }), + this.prisma.listing.count({ where: { agentId, status: 'ACTIVE' } }), ]); return { total, active }; } - private async getActiveListingsForAgent( - agentId: string, - ): Promise { - const listings = await this.prisma.listing.findMany({ - where: { agentId, status: 'ACTIVE' }, - take: 12, - orderBy: { createdAt: 'desc' }, - select: { - id: true, - transactionType: true, - priceVND: true, - status: true, - property: { - select: { - id: true, - title: true, - propertyType: true, - address: true, - district: true, - city: true, - areaM2: true, - bedrooms: true, - bathrooms: true, - media: { - where: { type: 'image' }, - take: 1, - orderBy: { order: 'asc' }, - select: { url: true }, - }, - }, - }, - }, - }); - - return listings.map((l) => ({ - id: l.id, - transactionType: l.transactionType, - priceVND: l.priceVND.toString(), - status: l.status, - property: { - id: l.property.id, - title: l.property.title, - propertyType: l.property.propertyType, - address: l.property.address, - district: l.property.district, - city: l.property.city, - areaM2: l.property.areaM2, - bedrooms: l.property.bedrooms, - bathrooms: l.property.bathrooms, - imageUrl: l.property.media[0]?.url ?? null, - }, - })); - } - private toDomain(row: { id: string; userId: string; diff --git a/apps/api/src/modules/analytics/infrastructure/services/avm-calculation.helper.ts b/apps/api/src/modules/analytics/infrastructure/services/avm-calculation.helper.ts new file mode 100644 index 0000000..04bf4b9 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/services/avm-calculation.helper.ts @@ -0,0 +1,66 @@ +import { type PropertyType } from '@prisma/client'; +import { type Comparable } from '../../domain/services/avm-service'; + +const DEFAULT_RADIUS_METERS = 2000; + +export interface RawComparable { + property_id: string; + address: string; + district: string; + price_vnd: bigint; + price_per_m2: number; + area_m2: number; + property_type: PropertyType; + distance_meters: number; + published_at: Date; +} + +/** Map a raw SQL comparable row to the domain Comparable DTO. */ +export function toComparableDto(raw: RawComparable): Comparable { + return { + propertyId: raw.property_id, + address: raw.address, + district: raw.district, + priceVND: raw.price_vnd.toString(), + pricePerM2: raw.price_per_m2, + areaM2: raw.area_m2, + propertyType: raw.property_type, + distanceMeters: Math.round(raw.distance_meters), + soldAt: raw.published_at.toISOString(), + }; +} + +/** + * Distance-weighted average price calculation. + * Closer properties carry more weight in the estimate. + */ +export function calculateWeightedPrice( + comparables: RawComparable[], + _areaM2: number, + _propertyType: PropertyType | undefined, + _yearBuilt: number | null, + _floor: number | null, + _totalFloors: number | null, +): { pricePerM2: number; confidence: number } { + let totalWeight = 0; + let weightedSum = 0; + + for (const comp of comparables) { + const distance = Math.max(comp.distance_meters, 1); + const weight = 1 / distance; + weightedSum += comp.price_per_m2 * weight; + totalWeight += weight; + } + + const pricePerM2 = totalWeight > 0 ? weightedSum / totalWeight : 0; + + // Confidence based on number of comparables and distance spread + const maxComparables = 15; + const countFactor = Math.min(comparables.length / maxComparables, 1); + const avgDistance = + comparables.reduce((sum, c) => sum + c.distance_meters, 0) / comparables.length; + const distanceFactor = Math.max(0, 1 - avgDistance / DEFAULT_RADIUS_METERS); + const confidence = Math.round((countFactor * 0.6 + distanceFactor * 0.4) * 100) / 100; + + return { pricePerM2: Math.round(pricePerM2), confidence }; +} diff --git a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts index 823366b..49ad9c9 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts @@ -7,23 +7,16 @@ import { type ValuationResult, type Comparable, } from '../../domain/services/avm-service'; +import { + type RawComparable, + toComparableDto, + calculateWeightedPrice, +} from './avm-calculation.helper'; const MODEL_VERSION = 'avm-v1.0'; const DEFAULT_RADIUS_METERS = 2000; const MIN_COMPARABLES = 3; -interface RawComparable { - property_id: string; - address: string; - district: string; - price_vnd: bigint; - price_per_m2: number; - area_m2: number; - property_type: PropertyType; - distance_meters: number; - published_at: Date; -} - interface PropertyLocation { latitude: number; longitude: number; @@ -39,59 +32,32 @@ export class PrismaAVMService implements IAVMService { constructor(private readonly prisma: PrismaService) {} async estimateValue(params: AVMParams): Promise { - let lat: number; - let lng: number; - let areaM2: number; - let propertyType: PropertyType | undefined = params.propertyType; - let yearBuilt: number | null = params.yearBuilt ?? null; - let floor: number | null = params.floor ?? null; - let totalFloors: number | null = params.totalFloors ?? null; - - if (params.propertyId) { - const loc = await this.getPropertyLocation(params.propertyId); - lat = loc.latitude; - lng = loc.longitude; - areaM2 = params.areaM2 ?? loc.areaM2; - propertyType = propertyType ?? loc.propertyType; - yearBuilt = yearBuilt ?? loc.yearBuilt; - floor = floor ?? loc.floor; - totalFloors = totalFloors ?? loc.totalFloors; - } else if (params.latitude != null && params.longitude != null && params.areaM2 != null) { - lat = params.latitude; - lng = params.longitude; - areaM2 = params.areaM2; - } else { - throw new Error('Either propertyId or (latitude, longitude, areaM2) must be provided'); - } - - const comparables = await this.findComparables(lat, lng, propertyType, DEFAULT_RADIUS_METERS); + const resolved = await this.resolveParams(params); + const comparables = await this.findComparables( + resolved.lat, resolved.lng, resolved.propertyType, DEFAULT_RADIUS_METERS, + ); if (comparables.length < MIN_COMPARABLES) { return { estimatedPrice: '0', confidence: 0, pricePerM2: 0, - comparables: comparables.map((c) => this.toComparableDto(c)), + comparables: comparables.map(toComparableDto), modelVersion: MODEL_VERSION, }; } - const { pricePerM2, confidence } = this.calculateWeightedPrice( - comparables, - areaM2, - propertyType, - yearBuilt, - floor, - totalFloors, + const { pricePerM2, confidence } = calculateWeightedPrice( + comparables, resolved.areaM2, resolved.propertyType, + resolved.yearBuilt, resolved.floor, resolved.totalFloors, ); - - const estimatedPrice = BigInt(Math.round(pricePerM2 * areaM2)); + const estimatedPrice = BigInt(Math.round(pricePerM2 * resolved.areaM2)); return { estimatedPrice: estimatedPrice.toString(), confidence, pricePerM2, - comparables: comparables.map((c) => this.toComparableDto(c)), + comparables: comparables.map(toComparableDto), modelVersion: MODEL_VERSION, }; } @@ -99,126 +65,79 @@ export class PrismaAVMService implements IAVMService { async getComparables(propertyId: string, radiusMeters: number): Promise { const loc = await this.getPropertyLocation(propertyId); const raws = await this.findComparables(loc.latitude, loc.longitude, loc.propertyType, radiusMeters); - return raws.map((c) => this.toComparableDto(c)); + return raws.map(toComparableDto); + } + + private async resolveParams(params: AVMParams): Promise<{ + lat: number; lng: number; areaM2: number; + propertyType: PropertyType | undefined; + yearBuilt: number | null; floor: number | null; totalFloors: number | null; + }> { + if (params.propertyId) { + const loc = await this.getPropertyLocation(params.propertyId); + return { + lat: loc.latitude, + lng: loc.longitude, + areaM2: params.areaM2 ?? loc.areaM2, + propertyType: params.propertyType ?? loc.propertyType, + yearBuilt: params.yearBuilt ?? loc.yearBuilt, + floor: params.floor ?? loc.floor, + totalFloors: params.totalFloors ?? loc.totalFloors, + }; + } + + if (params.latitude != null && params.longitude != null && params.areaM2 != null) { + return { + lat: params.latitude, + lng: params.longitude, + areaM2: params.areaM2, + propertyType: params.propertyType, + yearBuilt: params.yearBuilt ?? null, + floor: params.floor ?? null, + totalFloors: params.totalFloors ?? null, + }; + } + + throw new Error('Either propertyId or (latitude, longitude, areaM2) must be provided'); } private async getPropertyLocation(propertyId: string): Promise { - const rows = await this.prisma.$queryRaw< - Array<{ - latitude: number; - longitude: number; - areaM2: number; - propertyType: PropertyType; - yearBuilt: number | null; - floor: number | null; - totalFloors: number | null; - }> - >` + const rows = await this.prisma.$queryRaw` SELECT ST_Y(location::geometry) AS "latitude", ST_X(location::geometry) AS "longitude", - "areaM2", - "propertyType", - "yearBuilt", - "floor", - "totalFloors" + "areaM2", "propertyType", "yearBuilt", "floor", "totalFloors" FROM "Property" WHERE id = ${propertyId} LIMIT 1 `; - const row = rows[0]; - if (!row) { - throw new Error(`Property not found: ${propertyId}`); - } + if (!row) throw new Error(`Property not found: ${propertyId}`); return row; } private async findComparables( - lat: number, - lng: number, + lat: number, lng: number, propertyType: PropertyType | undefined, radiusMeters: number, ): Promise { const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : ''; - return this.prisma.$queryRawUnsafe( ` SELECT - p.id AS property_id, - p.address, - p.district, - l."priceVND" AS price_vnd, - l."pricePerM2" AS price_per_m2, - p."areaM2" AS area_m2, - p."propertyType" AS property_type, - ST_Distance( - p.location::geography, - ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography - ) AS distance_meters, + p.id AS property_id, p.address, p.district, + l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2, + p."areaM2" AS area_m2, p."propertyType" AS property_type, + ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) AS distance_meters, l."publishedAt" AS published_at FROM "Property" p JOIN "Listing" l ON l."propertyId" = p.id - WHERE l.status = 'ACTIVE' - AND l."publishedAt" IS NOT NULL - AND ST_DWithin( - p.location::geography, - ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, - $3 - ) + WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL + AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3) ${typeFilter} - ORDER BY distance_meters ASC - LIMIT 20 + ORDER BY distance_meters ASC LIMIT 20 `, - lng, - lat, - radiusMeters, + lng, lat, radiusMeters, ); } - - private calculateWeightedPrice( - comparables: RawComparable[], - _areaM2: number, - _propertyType: PropertyType | undefined, - _yearBuilt: number | null, - _floor: number | null, - _totalFloors: number | null, - ): { pricePerM2: number; confidence: number } { - // Distance-weighted average: closer properties have more weight - let totalWeight = 0; - let weightedSum = 0; - - for (const comp of comparables) { - const distance = Math.max(comp.distance_meters, 1); - const weight = 1 / distance; - weightedSum += comp.price_per_m2 * weight; - totalWeight += weight; - } - - const pricePerM2 = totalWeight > 0 ? weightedSum / totalWeight : 0; - - // Confidence based on number of comparables and distance spread - const maxComparables = 15; - const countFactor = Math.min(comparables.length / maxComparables, 1); - const avgDistance = - comparables.reduce((sum, c) => sum + c.distance_meters, 0) / comparables.length; - const distanceFactor = Math.max(0, 1 - avgDistance / DEFAULT_RADIUS_METERS); - const confidence = Math.round((countFactor * 0.6 + distanceFactor * 0.4) * 100) / 100; - - return { pricePerM2: Math.round(pricePerM2), confidence }; - } - - private toComparableDto(raw: RawComparable): Comparable { - return { - propertyId: raw.property_id, - address: raw.address, - district: raw.district, - priceVND: raw.price_vnd.toString(), - pricePerM2: raw.price_per_m2, - areaM2: raw.area_m2, - propertyType: raw.property_type, - distanceMeters: Math.round(raw.distance_meters), - soldAt: raw.published_at.toISOString(), - }; - } } diff --git a/apps/api/src/modules/search/infrastructure/services/index.ts b/apps/api/src/modules/search/infrastructure/services/index.ts index fe9c7c4..d1f1322 100644 --- a/apps/api/src/modules/search/infrastructure/services/index.ts +++ b/apps/api/src/modules/search/infrastructure/services/index.ts @@ -3,3 +3,5 @@ export { TypesenseSearchRepository } from './typesense-search.repository'; export { PostgresSearchRepository } from './postgres-search.repository'; export { ResilientSearchRepository, SEARCH_DEGRADATION_TOTAL } from './resilient-search.repository'; export { ListingIndexerService } from './listing-indexer.service'; +export { parseFilterBy, type ParsedFilters } from './search-filter-parser'; +export { mapRowToListingDocument, type RawListingRow } from './search-result-mapper'; diff --git a/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts b/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts index 2ce572b..81fda67 100644 --- a/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts +++ b/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts @@ -7,6 +7,8 @@ import { type SearchParams, type SearchResult, } from '../../domain/repositories/search.repository'; +import { type RawListingRow, mapRowToListingDocument } from './search-result-mapper'; +import { buildSearchConditions, buildOrderClause } from './search-query-builder'; /** * PostgreSQL-backed search repository used as a fallback when Typesense @@ -31,7 +33,6 @@ export class PostgresSearchRepository implements ISearchRepository { /** * Search listings using PostgreSQL full-text search + PostGIS. - * Parses the Typesense-style `filterBy` string to build SQL conditions. */ async search(params: SearchParams): Promise { const startMs = Date.now(); @@ -39,186 +40,44 @@ export class PostgresSearchRepository implements ISearchRepository { const perPage = params.perPage ?? 20; const offset = (page - 1) * perPage; - const conditions: Prisma.Sql[] = [Prisma.sql`l."status" = 'ACTIVE'`]; - const parsed = this.parseFilterBy(params.filterBy ?? ''); - - // ── Parsed Typesense-style filters ───────────────────────────────── - if (parsed.propertyType) { - conditions.push(Prisma.sql`p."propertyType" = ${parsed.propertyType}`); - } - if (parsed.transactionType) { - conditions.push(Prisma.sql`l."transactionType" = ${parsed.transactionType}`); - } - if (parsed.priceMin !== undefined && parsed.priceMax !== undefined) { - conditions.push(Prisma.sql`l."priceVND" BETWEEN ${BigInt(parsed.priceMin)} AND ${BigInt(parsed.priceMax)}`); - } else if (parsed.priceMin !== undefined) { - conditions.push(Prisma.sql`l."priceVND" >= ${BigInt(parsed.priceMin)}`); - } else if (parsed.priceMax !== undefined) { - conditions.push(Prisma.sql`l."priceVND" <= ${BigInt(parsed.priceMax)}`); - } - if (parsed.areaMin !== undefined && parsed.areaMax !== undefined) { - conditions.push(Prisma.sql`p."areaM2" BETWEEN ${parsed.areaMin} AND ${parsed.areaMax}`); - } else if (parsed.areaMin !== undefined) { - conditions.push(Prisma.sql`p."areaM2" >= ${parsed.areaMin}`); - } else if (parsed.areaMax !== undefined) { - conditions.push(Prisma.sql`p."areaM2" <= ${parsed.areaMax}`); - } - if (parsed.bedrooms !== undefined) { - conditions.push(Prisma.sql`p."bedrooms" >= ${parsed.bedrooms}`); - } - if (parsed.district) { - conditions.push(Prisma.sql`p."district" = ${parsed.district}`); - } - if (parsed.city) { - conditions.push(Prisma.sql`p."city" = ${parsed.city}`); - } - - // ── Geo radius filter (PostGIS) ──────────────────────────────────── - if (params.geoPoint && params.geoRadiusKm) { - const radiusMeters = params.geoRadiusKm * 1000; - conditions.push( - Prisma.sql`ST_DWithin( - p."location"::geography, - ST_SetSRID(ST_MakePoint(${params.geoPoint.lng}, ${params.geoPoint.lat}), 4326)::geography, - ${radiusMeters} - )`, - ); - } - - // ── Full-text search condition ───────────────────────────────────── - const hasTextQuery = params.query && params.query !== '*'; - if (hasTextQuery) { - conditions.push( - Prisma.sql`( - to_tsvector('simple', coalesce(p."title", '') || ' ' || coalesce(p."description", '') || ' ' || coalesce(p."address", '') || ' ' || coalesce(p."district", '') || ' ' || coalesce(p."city", '')) - @@ plainto_tsquery('simple', ${params.query!}) - )`, - ); - } - + const conditions = buildSearchConditions(params); const whereClause = Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`; + const orderClause = buildOrderClause(params); - // ── Count total matches ──────────────────────────────────────────── const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>( Prisma.sql` SELECT COUNT(*) as count - FROM "Listing" l - JOIN "Property" p ON l."propertyId" = p."id" + FROM "Listing" l JOIN "Property" p ON l."propertyId" = p."id" ${whereClause} `, ); const totalFound = Number(countResult[0]?.count ?? 0); - // ── Sorting ──────────────────────────────────────────────────────── - let orderClause: Prisma.Sql; - if (params.geoPoint && (params.sortBy === 'distance' || (!params.sortBy && params.geoRadiusKm))) { - orderClause = Prisma.sql`ORDER BY ST_Distance( - p."location"::geography, - ST_SetSRID(ST_MakePoint(${params.geoPoint.lng}, ${params.geoPoint.lat}), 4326)::geography - ) ASC`; - } else { - switch (params.sortBy) { - case 'price_asc': - orderClause = Prisma.sql`ORDER BY l."priceVND" ASC`; - break; - case 'price_desc': - orderClause = Prisma.sql`ORDER BY l."priceVND" DESC`; - break; - case 'date_desc': - orderClause = Prisma.sql`ORDER BY l."publishedAt" DESC NULLS LAST`; - break; - case 'relevance': - default: - if (hasTextQuery) { - orderClause = Prisma.sql`ORDER BY ts_rank( - to_tsvector('simple', coalesce(p."title", '') || ' ' || coalesce(p."description", '') || ' ' || coalesce(p."address", '') || ' ' || coalesce(p."district", '') || ' ' || coalesce(p."city", '')), - plainto_tsquery('simple', ${params.query!}) - ) DESC, l."publishedAt" DESC NULLS LAST`; - } else { - orderClause = Prisma.sql`ORDER BY l."publishedAt" DESC NULLS LAST`; - } - break; - } - } - - // ── Fetch rows ───────────────────────────────────────────────────── const rows = await this.prisma.$queryRaw( Prisma.sql` SELECT - l."id" AS "listingId", - l."propertyId" AS "propertyId", - p."title" AS "title", - p."description" AS "description", - p."propertyType" AS "propertyType", - l."transactionType" AS "transactionType", - l."priceVND" AS "priceVND", - l."pricePerM2" AS "pricePerM2", - p."areaM2" AS "areaM2", - p."bedrooms" AS "bedrooms", - p."bathrooms" AS "bathrooms", - p."floors" AS "floors", - p."direction" AS "direction", - p."address" AS "address", - p."ward" AS "ward", - p."district" AS "district", - p."city" AS "city", + l."id" AS "listingId", l."propertyId", p."title", p."description", + p."propertyType", l."transactionType", l."priceVND", l."pricePerM2", + p."areaM2", p."bedrooms", p."bathrooms", p."floors", p."direction", + p."address", p."ward", p."district", p."city", ST_Y(p."location"::geometry) AS "lat", ST_X(p."location"::geometry) AS "lng", - l."agentId" AS "agentId", - l."sellerId" AS "sellerId", - l."status" AS "status", - l."publishedAt" AS "publishedAt", - l."viewCount" AS "viewCount", - l."saveCount" AS "saveCount", - p."projectName" AS "projectName", - p."amenities" AS "amenities" - FROM "Listing" l - JOIN "Property" p ON l."propertyId" = p."id" + l."agentId", l."sellerId", l."status", l."publishedAt", + l."viewCount", l."saveCount", p."projectName", p."amenities" + FROM "Listing" l JOIN "Property" p ON l."propertyId" = p."id" ${whereClause} ${orderClause} LIMIT ${perPage} OFFSET ${offset} `, ); - const hits: ListingDocument[] = rows.map((row) => ({ - id: row.listingId, - listingId: row.listingId, - propertyId: row.propertyId, - title: row.title, - description: row.description, - propertyType: row.propertyType, - transactionType: row.transactionType, - priceVND: Number(row.priceVND), - pricePerM2: row.pricePerM2 ? Number(row.pricePerM2) : null, - areaM2: Number(row.areaM2), - bedrooms: row.bedrooms, - bathrooms: row.bathrooms, - floors: row.floors, - direction: row.direction, - address: row.address, - ward: row.ward, - district: row.district, - city: row.city, - location: [row.lat ?? 0, row.lng ?? 0] as [number, number], - agentId: row.agentId, - sellerId: row.sellerId, - status: row.status, - publishedAt: row.publishedAt ? Math.floor(new Date(row.publishedAt).getTime() / 1000) : 0, - viewCount: row.viewCount ?? 0, - saveCount: row.saveCount ?? 0, - projectName: row.projectName, - amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [], - })); - - const searchTimeMs = Date.now() - startMs; - return { - hits, + hits: rows.map(mapRowToListingDocument), totalFound, page, perPage, totalPages: Math.ceil(totalFound / perPage), - searchTimeMs, + searchTimeMs: Date.now() - startMs, }; } @@ -243,118 +102,4 @@ export class PostgresSearchRepository implements ISearchRepository { async dropCollection(): Promise { // Not applicable for PostgreSQL fallback. } - - // ── Helpers ────────────────────────────────────────────────────────── - - /** - * Minimal parser for the Typesense-style `filterBy` strings produced - * by the query handlers. - * - * Expected format examples: - * "status:=ACTIVE && propertyType:=HOUSE && priceVND:[2000..5000]" - * "status:=ACTIVE && priceVND:>=1000 && bedrooms:>=3" - */ - private parseFilterBy(filterStr: string): ParsedFilters { - const result: ParsedFilters = {}; - if (!filterStr) return result; - - const clauses = filterStr.split('&&').map((c) => c.trim()); - for (const clause of clauses) { - // Range: field:[min..max] - const rangeMatch = clause.match(/^(\w+):\[(\d+)\.\.(\d+)\]$/); - if (rangeMatch) { - const field = rangeMatch[1]!; - const min = Number(rangeMatch[2]); - const max = Number(rangeMatch[3]); - if (field === 'priceVND') { - result.priceMin = min; - result.priceMax = max; - } else if (field === 'areaM2') { - result.areaMin = min; - result.areaMax = max; - } - continue; - } - - // Equality: field:=value - const eqMatch = clause.match(/^(\w+):=(.+)$/); - if (eqMatch) { - const field = eqMatch[1]!; - const val = eqMatch[2]!; - if (field === 'propertyType') result.propertyType = val; - else if (field === 'transactionType') result.transactionType = val; - else if (field === 'district') result.district = val; - else if (field === 'city') result.city = val; - else if (field === 'status') { /* handled separately */ } - continue; - } - - // Gte: field:>=value - const gteMatch = clause.match(/^(\w+):>=(\d+(?:\.\d+)?)$/); - if (gteMatch) { - const field = gteMatch[1]!; - const val = Number(gteMatch[2]); - if (field === 'priceVND') result.priceMin = val; - else if (field === 'areaM2') result.areaMin = val; - else if (field === 'bedrooms') result.bedrooms = val; - continue; - } - - // Lte: field:<=value - const lteMatch = clause.match(/^(\w+):<=(\d+(?:\.\d+)?)$/); - if (lteMatch) { - const field = lteMatch[1]!; - const val = Number(lteMatch[2]); - if (field === 'priceVND') result.priceMax = val; - else if (field === 'areaM2') result.areaMax = val; - continue; - } - - // Geo filter: location:(lat, lng, radius km) — skip, handled via params - } - - return result; - } -} - -interface ParsedFilters { - propertyType?: string; - transactionType?: string; - priceMin?: number; - priceMax?: number; - areaMin?: number; - areaMax?: number; - bedrooms?: number; - district?: string; - city?: string; -} - -interface RawListingRow { - listingId: string; - propertyId: string; - title: string; - description: string; - propertyType: string; - transactionType: string; - priceVND: bigint; - pricePerM2: number | null; - areaM2: number; - bedrooms: number | null; - bathrooms: number | null; - floors: number | null; - direction: string | null; - address: string; - ward: string; - district: string; - city: string; - lat: number | null; - lng: number | null; - agentId: string | null; - sellerId: string; - status: string; - publishedAt: Date | string | null; - viewCount: number; - saveCount: number; - projectName: string | null; - amenities: unknown; } diff --git a/apps/api/src/modules/search/infrastructure/services/search-filter-parser.ts b/apps/api/src/modules/search/infrastructure/services/search-filter-parser.ts new file mode 100644 index 0000000..297adcf --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/services/search-filter-parser.ts @@ -0,0 +1,82 @@ +/** + * Minimal parser for Typesense-style `filterBy` strings produced + * by the search query handlers. + * + * Expected format examples: + * "status:=ACTIVE && propertyType:=HOUSE && priceVND:[2000..5000]" + * "status:=ACTIVE && priceVND:>=1000 && bedrooms:>=3" + */ + +export interface ParsedFilters { + propertyType?: string; + transactionType?: string; + priceMin?: number; + priceMax?: number; + areaMin?: number; + areaMax?: number; + bedrooms?: number; + district?: string; + city?: string; +} + +export function parseFilterBy(filterStr: string): ParsedFilters { + const result: ParsedFilters = {}; + if (!filterStr) return result; + + const clauses = filterStr.split('&&').map((c) => c.trim()); + for (const clause of clauses) { + // Range: field:[min..max] + const rangeMatch = clause.match(/^(\w+):\[(\d+)\.\.(\d+)\]$/); + if (rangeMatch) { + const field = rangeMatch[1]!; + const min = Number(rangeMatch[2]); + const max = Number(rangeMatch[3]); + if (field === 'priceVND') { + result.priceMin = min; + result.priceMax = max; + } else if (field === 'areaM2') { + result.areaMin = min; + result.areaMax = max; + } + continue; + } + + // Equality: field:=value + const eqMatch = clause.match(/^(\w+):=(.+)$/); + if (eqMatch) { + const field = eqMatch[1]!; + const val = eqMatch[2]!; + if (field === 'propertyType') result.propertyType = val; + else if (field === 'transactionType') result.transactionType = val; + else if (field === 'district') result.district = val; + else if (field === 'city') result.city = val; + else if (field === 'status') { /* handled separately */ } + continue; + } + + // Gte: field:>=value + const gteMatch = clause.match(/^(\w+):>=(\d+(?:\.\d+)?)$/); + if (gteMatch) { + const field = gteMatch[1]!; + const val = Number(gteMatch[2]); + if (field === 'priceVND') result.priceMin = val; + else if (field === 'areaM2') result.areaMin = val; + else if (field === 'bedrooms') result.bedrooms = val; + continue; + } + + // Lte: field:<=value + const lteMatch = clause.match(/^(\w+):<=(\d+(?:\.\d+)?)$/); + if (lteMatch) { + const field = lteMatch[1]!; + const val = Number(lteMatch[2]); + if (field === 'priceVND') result.priceMax = val; + else if (field === 'areaM2') result.areaMax = val; + continue; + } + + // Geo filter: location:(lat, lng, radius km) -- skip, handled via params + } + + return result; +} diff --git a/apps/api/src/modules/search/infrastructure/services/search-query-builder.ts b/apps/api/src/modules/search/infrastructure/services/search-query-builder.ts new file mode 100644 index 0000000..2493eda --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/services/search-query-builder.ts @@ -0,0 +1,95 @@ +import { Prisma } from '@prisma/client'; +import { type SearchParams } from '../../domain/repositories/search.repository'; +import { parseFilterBy } from './search-filter-parser'; + +const FTS_COLUMNS = `coalesce(p."title", '') || ' ' || coalesce(p."description", '') || ' ' || coalesce(p."address", '') || ' ' || coalesce(p."district", '') || ' ' || coalesce(p."city", '')`; + +/** Build WHERE conditions from search parameters. */ +export function buildSearchConditions(params: SearchParams): Prisma.Sql[] { + const conditions: Prisma.Sql[] = [Prisma.sql`l."status" = 'ACTIVE'`]; + const parsed = parseFilterBy(params.filterBy ?? ''); + + if (parsed.propertyType) { + conditions.push(Prisma.sql`p."propertyType" = ${parsed.propertyType}`); + } + if (parsed.transactionType) { + conditions.push(Prisma.sql`l."transactionType" = ${parsed.transactionType}`); + } + if (parsed.priceMin !== undefined && parsed.priceMax !== undefined) { + conditions.push(Prisma.sql`l."priceVND" BETWEEN ${BigInt(parsed.priceMin)} AND ${BigInt(parsed.priceMax)}`); + } else if (parsed.priceMin !== undefined) { + conditions.push(Prisma.sql`l."priceVND" >= ${BigInt(parsed.priceMin)}`); + } else if (parsed.priceMax !== undefined) { + conditions.push(Prisma.sql`l."priceVND" <= ${BigInt(parsed.priceMax)}`); + } + if (parsed.areaMin !== undefined && parsed.areaMax !== undefined) { + conditions.push(Prisma.sql`p."areaM2" BETWEEN ${parsed.areaMin} AND ${parsed.areaMax}`); + } else if (parsed.areaMin !== undefined) { + conditions.push(Prisma.sql`p."areaM2" >= ${parsed.areaMin}`); + } else if (parsed.areaMax !== undefined) { + conditions.push(Prisma.sql`p."areaM2" <= ${parsed.areaMax}`); + } + if (parsed.bedrooms !== undefined) { + conditions.push(Prisma.sql`p."bedrooms" >= ${parsed.bedrooms}`); + } + if (parsed.district) { + conditions.push(Prisma.sql`p."district" = ${parsed.district}`); + } + if (parsed.city) { + conditions.push(Prisma.sql`p."city" = ${parsed.city}`); + } + + if (params.geoPoint && params.geoRadiusKm) { + const radiusMeters = params.geoRadiusKm * 1000; + conditions.push( + Prisma.sql`ST_DWithin( + p."location"::geography, + ST_SetSRID(ST_MakePoint(${params.geoPoint.lng}, ${params.geoPoint.lat}), 4326)::geography, + ${radiusMeters} + )`, + ); + } + + if (hasText(params)) { + conditions.push( + Prisma.sql`( + to_tsvector('simple', ${Prisma.raw(FTS_COLUMNS)}) + @@ plainto_tsquery('simple', ${params.query!}) + )`, + ); + } + + return conditions; +} + +/** Build ORDER BY clause from search parameters. */ +export function buildOrderClause(params: SearchParams): Prisma.Sql { + if (params.geoPoint && (params.sortBy === 'distance' || (!params.sortBy && params.geoRadiusKm))) { + return Prisma.sql`ORDER BY ST_Distance( + p."location"::geography, + ST_SetSRID(ST_MakePoint(${params.geoPoint.lng}, ${params.geoPoint.lat}), 4326)::geography + ) ASC`; + } + + switch (params.sortBy) { + case 'price_asc': + return Prisma.sql`ORDER BY l."priceVND" ASC`; + case 'price_desc': + return Prisma.sql`ORDER BY l."priceVND" DESC`; + case 'date_desc': + return Prisma.sql`ORDER BY l."publishedAt" DESC NULLS LAST`; + case 'relevance': + default: + if (hasText(params)) { + return Prisma.sql`ORDER BY ts_rank( + to_tsvector('simple', ${Prisma.raw(FTS_COLUMNS)}), + plainto_tsquery('simple', ${params.query!}) + ) DESC, l."publishedAt" DESC NULLS LAST`; + } + return Prisma.sql`ORDER BY l."publishedAt" DESC NULLS LAST`; + } +} + +function hasText(params: SearchParams): boolean { + return !!(params.query && params.query !== '*'); +} diff --git a/apps/api/src/modules/search/infrastructure/services/search-result-mapper.ts b/apps/api/src/modules/search/infrastructure/services/search-result-mapper.ts new file mode 100644 index 0000000..e46707b --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/services/search-result-mapper.ts @@ -0,0 +1,64 @@ +import { type ListingDocument } from '../../domain/repositories/search.repository'; + +export interface RawListingRow { + listingId: string; + propertyId: string; + title: string; + description: string; + propertyType: string; + transactionType: string; + priceVND: bigint; + pricePerM2: number | null; + areaM2: number; + bedrooms: number | null; + bathrooms: number | null; + floors: number | null; + direction: string | null; + address: string; + ward: string; + district: string; + city: string; + lat: number | null; + lng: number | null; + agentId: string | null; + sellerId: string; + status: string; + publishedAt: Date | string | null; + viewCount: number; + saveCount: number; + projectName: string | null; + amenities: unknown; +} + +/** Map a raw SQL row to the domain ListingDocument shape. */ +export function mapRowToListingDocument(row: RawListingRow): ListingDocument { + return { + id: row.listingId, + listingId: row.listingId, + propertyId: row.propertyId, + title: row.title, + description: row.description, + propertyType: row.propertyType, + transactionType: row.transactionType, + priceVND: Number(row.priceVND), + pricePerM2: row.pricePerM2 ? Number(row.pricePerM2) : null, + areaM2: Number(row.areaM2), + bedrooms: row.bedrooms, + bathrooms: row.bathrooms, + floors: row.floors, + direction: row.direction, + address: row.address, + ward: row.ward, + district: row.district, + city: row.city, + location: [row.lat ?? 0, row.lng ?? 0] as [number, number], + agentId: row.agentId, + sellerId: row.sellerId, + status: row.status, + publishedAt: row.publishedAt ? Math.floor(new Date(row.publishedAt).getTime() / 1000) : 0, + viewCount: row.viewCount ?? 0, + saveCount: row.saveCount ?? 0, + projectName: row.projectName, + amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [], + }; +}