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 {
|
||||
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<AgentEntity | null> {
|
||||
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<AgentEntity | null> {
|
||||
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<void> {
|
||||
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<AgentPublicProfileData | null> {
|
||||
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<QualityScoreInputData> {
|
||||
@@ -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<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: {
|
||||
id: 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 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<ValuationResult> {
|
||||
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<Comparable[]> {
|
||||
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<PropertyLocation> {
|
||||
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<PropertyLocation[]>`
|
||||
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<RawComparable[]> {
|
||||
const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : '';
|
||||
|
||||
return this.prisma.$queryRawUnsafe<RawComparable[]>(
|
||||
`
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<SearchResult> {
|
||||
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<RawListingRow[]>(
|
||||
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<void> {
|
||||
// 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