refactor(api): split 3 oversized files to comply with 200 LOC convention
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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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<AgentPublicListingItem[]> {
|
||||||
|
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<AgentPublicProfileData | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,28 +4,24 @@ import { AgentEntity } from '../../domain/entities/agent.entity';
|
|||||||
import {
|
import {
|
||||||
type AgentDashboardData,
|
type AgentDashboardData,
|
||||||
type AgentPublicProfileData,
|
type AgentPublicProfileData,
|
||||||
type AgentPublicListingItem,
|
|
||||||
type IAgentRepository,
|
type IAgentRepository,
|
||||||
type QualityScoreInputData,
|
type QualityScoreInputData,
|
||||||
} from '../../domain/repositories/agent.repository';
|
} from '../../domain/repositories/agent.repository';
|
||||||
import { QualityScore } from '../../domain/value-objects/quality-score.vo';
|
import { QualityScore } from '../../domain/value-objects/quality-score.vo';
|
||||||
|
import { buildPublicProfile } from './agent-profile.queries';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaAgentRepository implements IAgentRepository {
|
export class PrismaAgentRepository implements IAgentRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<AgentEntity | null> {
|
async findByUserId(userId: string): Promise<AgentEntity | null> {
|
||||||
const row = await this.prisma.agent.findUnique({
|
const row = await this.prisma.agent.findUnique({ where: { userId } });
|
||||||
where: { userId },
|
|
||||||
});
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return this.toDomain(row);
|
return this.toDomain(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(agentId: string): Promise<AgentEntity | null> {
|
async findById(agentId: string): Promise<AgentEntity | null> {
|
||||||
const row = await this.prisma.agent.findUnique({
|
const row = await this.prisma.agent.findUnique({ where: { id: agentId } });
|
||||||
where: { id: agentId },
|
|
||||||
});
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return this.toDomain(row);
|
return this.toDomain(row);
|
||||||
}
|
}
|
||||||
@@ -33,9 +29,7 @@ export class PrismaAgentRepository implements IAgentRepository {
|
|||||||
async save(agent: AgentEntity): Promise<void> {
|
async save(agent: AgentEntity): Promise<void> {
|
||||||
await this.prisma.agent.update({
|
await this.prisma.agent.update({
|
||||||
where: { id: agent.id },
|
where: { id: agent.id },
|
||||||
data: {
|
data: { qualityScore: agent.qualityScore.value },
|
||||||
qualityScore: agent.qualityScore.value,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,11 +39,8 @@ export class PrismaAgentRepository implements IAgentRepository {
|
|||||||
this.prisma.agent.findUniqueOrThrow({
|
this.prisma.agent.findUniqueOrThrow({
|
||||||
where: { id: agentId },
|
where: { id: agentId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true, qualityScore: true, totalDeals: true,
|
||||||
qualityScore: true,
|
responseTimeAvg: true, isVerified: true,
|
||||||
totalDeals: true,
|
|
||||||
responseTimeAvg: true,
|
|
||||||
isVerified: true,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.lead.groupBy({
|
this.prisma.lead.groupBy({
|
||||||
@@ -73,9 +64,7 @@ export class PrismaAgentRepository implements IAgentRepository {
|
|||||||
for (const group of leads) {
|
for (const group of leads) {
|
||||||
leadsByStatus[group.status] = group._count.id;
|
leadsByStatus[group.status] = group._count.id;
|
||||||
totalLeads += group._count.id;
|
totalLeads += group._count.id;
|
||||||
if (group.status === 'CONVERTED') {
|
if (group.status === 'CONVERTED') convertedLeads = group._count.id;
|
||||||
convertedLeads = group._count.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0;
|
const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0;
|
||||||
@@ -88,62 +77,18 @@ export class PrismaAgentRepository implements IAgentRepository {
|
|||||||
isVerified: agent.isVerified,
|
isVerified: agent.isVerified,
|
||||||
totalLeads,
|
totalLeads,
|
||||||
leadsByStatus,
|
leadsByStatus,
|
||||||
conversionRate: Math.round(conversionRate * 1000) / 1000, // 3 decimals
|
conversionRate: Math.round(conversionRate * 1000) / 1000,
|
||||||
totalInquiries: inquiryStats.total,
|
totalInquiries: inquiryStats.total,
|
||||||
unreadInquiries: inquiryStats.unread,
|
unreadInquiries: inquiryStats.unread,
|
||||||
totalListings: listingStats.total,
|
totalListings: listingStats.total,
|
||||||
activeListings: listingStats.active,
|
activeListings: listingStats.active,
|
||||||
avgReviewRating:
|
avgReviewRating: Math.round((reviewStats._avg.rating ?? 0) * 10) / 10,
|
||||||
Math.round((reviewStats._avg.rating ?? 0) * 10) / 10,
|
|
||||||
totalReviews: reviewStats._count.rating,
|
totalReviews: reviewStats._count.rating,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPublicProfile(agentId: string): Promise<AgentPublicProfileData | null> {
|
async getPublicProfile(agentId: string): Promise<AgentPublicProfileData | null> {
|
||||||
const agent = await this.prisma.agent.findUnique({
|
return buildPublicProfile(this.prisma, agentId);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getQualityScoreInputs(agentId: string): Promise<QualityScoreInputData> {
|
async getQualityScoreInputs(agentId: string): Promise<QualityScoreInputData> {
|
||||||
@@ -156,15 +101,11 @@ export class PrismaAgentRepository implements IAgentRepository {
|
|||||||
}),
|
}),
|
||||||
Promise.all([
|
Promise.all([
|
||||||
this.prisma.lead.count({ where: { agentId } }),
|
this.prisma.lead.count({ where: { agentId } }),
|
||||||
this.prisma.lead.count({
|
this.prisma.lead.count({ where: { agentId, status: 'CONVERTED' } }),
|
||||||
where: { agentId, status: 'CONVERTED' },
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
Promise.all([
|
Promise.all([
|
||||||
this.prisma.listing.count({ where: { agentId } }),
|
this.prisma.listing.count({ where: { agentId } }),
|
||||||
this.prisma.listing.count({
|
this.prisma.listing.count({ where: { agentId, status: 'ACTIVE' } }),
|
||||||
where: { agentId, status: 'ACTIVE' },
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
this.prisma.agent.findUnique({
|
this.prisma.agent.findUnique({
|
||||||
where: { id: agentId },
|
where: { id: agentId },
|
||||||
@@ -188,12 +129,8 @@ export class PrismaAgentRepository implements IAgentRepository {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
): Promise<{ total: number; unread: number }> {
|
): Promise<{ total: number; unread: number }> {
|
||||||
const [total, unread] = await Promise.all([
|
const [total, unread] = await Promise.all([
|
||||||
this.prisma.inquiry.count({
|
this.prisma.inquiry.count({ where: { listing: { agentId } } }),
|
||||||
where: { listing: { agentId } },
|
this.prisma.inquiry.count({ where: { listing: { agentId }, isRead: false } }),
|
||||||
}),
|
|
||||||
this.prisma.inquiry.count({
|
|
||||||
where: { listing: { agentId }, isRead: false },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
return { total, unread };
|
return { total, unread };
|
||||||
}
|
}
|
||||||
@@ -203,67 +140,11 @@ export class PrismaAgentRepository implements IAgentRepository {
|
|||||||
): Promise<{ total: number; active: number }> {
|
): Promise<{ total: number; active: number }> {
|
||||||
const [total, active] = await Promise.all([
|
const [total, active] = await Promise.all([
|
||||||
this.prisma.listing.count({ where: { agentId } }),
|
this.prisma.listing.count({ where: { agentId } }),
|
||||||
this.prisma.listing.count({
|
this.prisma.listing.count({ where: { agentId, status: 'ACTIVE' } }),
|
||||||
where: { agentId, status: 'ACTIVE' },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
return { total, active };
|
return { total, active };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getActiveListingsForAgent(
|
|
||||||
agentId: string,
|
|
||||||
): Promise<AgentPublicListingItem[]> {
|
|
||||||
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: {
|
private toDomain(row: {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -7,23 +7,16 @@ import {
|
|||||||
type ValuationResult,
|
type ValuationResult,
|
||||||
type Comparable,
|
type Comparable,
|
||||||
} from '../../domain/services/avm-service';
|
} from '../../domain/services/avm-service';
|
||||||
|
import {
|
||||||
|
type RawComparable,
|
||||||
|
toComparableDto,
|
||||||
|
calculateWeightedPrice,
|
||||||
|
} from './avm-calculation.helper';
|
||||||
|
|
||||||
const MODEL_VERSION = 'avm-v1.0';
|
const MODEL_VERSION = 'avm-v1.0';
|
||||||
const DEFAULT_RADIUS_METERS = 2000;
|
const DEFAULT_RADIUS_METERS = 2000;
|
||||||
const MIN_COMPARABLES = 3;
|
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 {
|
interface PropertyLocation {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -39,59 +32,32 @@ export class PrismaAVMService implements IAVMService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async estimateValue(params: AVMParams): Promise<ValuationResult> {
|
async estimateValue(params: AVMParams): Promise<ValuationResult> {
|
||||||
let lat: number;
|
const resolved = await this.resolveParams(params);
|
||||||
let lng: number;
|
const comparables = await this.findComparables(
|
||||||
let areaM2: number;
|
resolved.lat, resolved.lng, resolved.propertyType, DEFAULT_RADIUS_METERS,
|
||||||
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);
|
|
||||||
|
|
||||||
if (comparables.length < MIN_COMPARABLES) {
|
if (comparables.length < MIN_COMPARABLES) {
|
||||||
return {
|
return {
|
||||||
estimatedPrice: '0',
|
estimatedPrice: '0',
|
||||||
confidence: 0,
|
confidence: 0,
|
||||||
pricePerM2: 0,
|
pricePerM2: 0,
|
||||||
comparables: comparables.map((c) => this.toComparableDto(c)),
|
comparables: comparables.map(toComparableDto),
|
||||||
modelVersion: MODEL_VERSION,
|
modelVersion: MODEL_VERSION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { pricePerM2, confidence } = this.calculateWeightedPrice(
|
const { pricePerM2, confidence } = calculateWeightedPrice(
|
||||||
comparables,
|
comparables, resolved.areaM2, resolved.propertyType,
|
||||||
areaM2,
|
resolved.yearBuilt, resolved.floor, resolved.totalFloors,
|
||||||
propertyType,
|
|
||||||
yearBuilt,
|
|
||||||
floor,
|
|
||||||
totalFloors,
|
|
||||||
);
|
);
|
||||||
|
const estimatedPrice = BigInt(Math.round(pricePerM2 * resolved.areaM2));
|
||||||
const estimatedPrice = BigInt(Math.round(pricePerM2 * areaM2));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
estimatedPrice: estimatedPrice.toString(),
|
estimatedPrice: estimatedPrice.toString(),
|
||||||
confidence,
|
confidence,
|
||||||
pricePerM2,
|
pricePerM2,
|
||||||
comparables: comparables.map((c) => this.toComparableDto(c)),
|
comparables: comparables.map(toComparableDto),
|
||||||
modelVersion: MODEL_VERSION,
|
modelVersion: MODEL_VERSION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -99,126 +65,79 @@ export class PrismaAVMService implements IAVMService {
|
|||||||
async getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]> {
|
async getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]> {
|
||||||
const loc = await this.getPropertyLocation(propertyId);
|
const loc = await this.getPropertyLocation(propertyId);
|
||||||
const raws = await this.findComparables(loc.latitude, loc.longitude, loc.propertyType, radiusMeters);
|
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<PropertyLocation> {
|
private async getPropertyLocation(propertyId: string): Promise<PropertyLocation> {
|
||||||
const rows = await this.prisma.$queryRaw<
|
const rows = await this.prisma.$queryRaw<PropertyLocation[]>`
|
||||||
Array<{
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
areaM2: number;
|
|
||||||
propertyType: PropertyType;
|
|
||||||
yearBuilt: number | null;
|
|
||||||
floor: number | null;
|
|
||||||
totalFloors: number | null;
|
|
||||||
}>
|
|
||||||
>`
|
|
||||||
SELECT
|
SELECT
|
||||||
ST_Y(location::geometry) AS "latitude",
|
ST_Y(location::geometry) AS "latitude",
|
||||||
ST_X(location::geometry) AS "longitude",
|
ST_X(location::geometry) AS "longitude",
|
||||||
"areaM2",
|
"areaM2", "propertyType", "yearBuilt", "floor", "totalFloors"
|
||||||
"propertyType",
|
|
||||||
"yearBuilt",
|
|
||||||
"floor",
|
|
||||||
"totalFloors"
|
|
||||||
FROM "Property"
|
FROM "Property"
|
||||||
WHERE id = ${propertyId}
|
WHERE id = ${propertyId}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) {
|
if (!row) throw new Error(`Property not found: ${propertyId}`);
|
||||||
throw new Error(`Property not found: ${propertyId}`);
|
|
||||||
}
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findComparables(
|
private async findComparables(
|
||||||
lat: number,
|
lat: number, lng: number,
|
||||||
lng: number,
|
|
||||||
propertyType: PropertyType | undefined,
|
propertyType: PropertyType | undefined,
|
||||||
radiusMeters: number,
|
radiusMeters: number,
|
||||||
): Promise<RawComparable[]> {
|
): Promise<RawComparable[]> {
|
||||||
const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : '';
|
const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : '';
|
||||||
|
|
||||||
return this.prisma.$queryRawUnsafe<RawComparable[]>(
|
return this.prisma.$queryRawUnsafe<RawComparable[]>(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
p.id AS property_id,
|
p.id AS property_id, p.address, p.district,
|
||||||
p.address,
|
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
||||||
p.district,
|
p."areaM2" AS area_m2, p."propertyType" AS property_type,
|
||||||
l."priceVND" AS price_vnd,
|
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) AS distance_meters,
|
||||||
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
|
l."publishedAt" AS published_at
|
||||||
FROM "Property" p
|
FROM "Property" p
|
||||||
JOIN "Listing" l ON l."propertyId" = p.id
|
JOIN "Listing" l ON l."propertyId" = p.id
|
||||||
WHERE l.status = 'ACTIVE'
|
WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL
|
||||||
AND l."publishedAt" IS NOT NULL
|
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
|
||||||
AND ST_DWithin(
|
|
||||||
p.location::geography,
|
|
||||||
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
|
|
||||||
$3
|
|
||||||
)
|
|
||||||
${typeFilter}
|
${typeFilter}
|
||||||
ORDER BY distance_meters ASC
|
ORDER BY distance_meters ASC LIMIT 20
|
||||||
LIMIT 20
|
|
||||||
`,
|
`,
|
||||||
lng,
|
lng, lat, radiusMeters,
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ export { TypesenseSearchRepository } from './typesense-search.repository';
|
|||||||
export { PostgresSearchRepository } from './postgres-search.repository';
|
export { PostgresSearchRepository } from './postgres-search.repository';
|
||||||
export { ResilientSearchRepository, SEARCH_DEGRADATION_TOTAL } from './resilient-search.repository';
|
export { ResilientSearchRepository, SEARCH_DEGRADATION_TOTAL } from './resilient-search.repository';
|
||||||
export { ListingIndexerService } from './listing-indexer.service';
|
export { ListingIndexerService } from './listing-indexer.service';
|
||||||
|
export { parseFilterBy, type ParsedFilters } from './search-filter-parser';
|
||||||
|
export { mapRowToListingDocument, type RawListingRow } from './search-result-mapper';
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
type SearchParams,
|
type SearchParams,
|
||||||
type SearchResult,
|
type SearchResult,
|
||||||
} from '../../domain/repositories/search.repository';
|
} 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
|
* 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.
|
* Search listings using PostgreSQL full-text search + PostGIS.
|
||||||
* Parses the Typesense-style `filterBy` string to build SQL conditions.
|
|
||||||
*/
|
*/
|
||||||
async search(params: SearchParams): Promise<SearchResult> {
|
async search(params: SearchParams): Promise<SearchResult> {
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
@@ -39,186 +40,44 @@ export class PostgresSearchRepository implements ISearchRepository {
|
|||||||
const perPage = params.perPage ?? 20;
|
const perPage = params.perPage ?? 20;
|
||||||
const offset = (page - 1) * perPage;
|
const offset = (page - 1) * perPage;
|
||||||
|
|
||||||
const conditions: Prisma.Sql[] = [Prisma.sql`l."status" = 'ACTIVE'`];
|
const conditions = buildSearchConditions(params);
|
||||||
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 whereClause = Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`;
|
const whereClause = Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`;
|
||||||
|
const orderClause = buildOrderClause(params);
|
||||||
|
|
||||||
// ── Count total matches ────────────────────────────────────────────
|
|
||||||
const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>(
|
const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>(
|
||||||
Prisma.sql`
|
Prisma.sql`
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM "Listing" l
|
FROM "Listing" l JOIN "Property" p ON l."propertyId" = p."id"
|
||||||
JOIN "Property" p ON l."propertyId" = p."id"
|
|
||||||
${whereClause}
|
${whereClause}
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
const totalFound = Number(countResult[0]?.count ?? 0);
|
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<RawListingRow[]>(
|
const rows = await this.prisma.$queryRaw<RawListingRow[]>(
|
||||||
Prisma.sql`
|
Prisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
l."id" AS "listingId",
|
l."id" AS "listingId", l."propertyId", p."title", p."description",
|
||||||
l."propertyId" AS "propertyId",
|
p."propertyType", l."transactionType", l."priceVND", l."pricePerM2",
|
||||||
p."title" AS "title",
|
p."areaM2", p."bedrooms", p."bathrooms", p."floors", p."direction",
|
||||||
p."description" AS "description",
|
p."address", p."ward", p."district", p."city",
|
||||||
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",
|
|
||||||
ST_Y(p."location"::geometry) AS "lat",
|
ST_Y(p."location"::geometry) AS "lat",
|
||||||
ST_X(p."location"::geometry) AS "lng",
|
ST_X(p."location"::geometry) AS "lng",
|
||||||
l."agentId" AS "agentId",
|
l."agentId", l."sellerId", l."status", l."publishedAt",
|
||||||
l."sellerId" AS "sellerId",
|
l."viewCount", l."saveCount", p."projectName", p."amenities"
|
||||||
l."status" AS "status",
|
FROM "Listing" l JOIN "Property" p ON l."propertyId" = p."id"
|
||||||
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"
|
|
||||||
${whereClause}
|
${whereClause}
|
||||||
${orderClause}
|
${orderClause}
|
||||||
LIMIT ${perPage} OFFSET ${offset}
|
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 {
|
return {
|
||||||
hits,
|
hits: rows.map(mapRowToListingDocument),
|
||||||
totalFound,
|
totalFound,
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
totalPages: Math.ceil(totalFound / perPage),
|
totalPages: Math.ceil(totalFound / perPage),
|
||||||
searchTimeMs,
|
searchTimeMs: Date.now() - startMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,118 +102,4 @@ export class PostgresSearchRepository implements ISearchRepository {
|
|||||||
async dropCollection(): Promise<void> {
|
async dropCollection(): Promise<void> {
|
||||||
// Not applicable for PostgreSQL fallback.
|
// 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 !== '*');
|
||||||
|
}
|
||||||
@@ -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[]) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user