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:
Ho Ngoc Hai
2026-04-12 21:12:56 +07:00
parent 97a9541fde
commit aca4fd37cb
9 changed files with 511 additions and 545 deletions

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -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(),
};
}
}

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 !== '*');
}

View File

@@ -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[]) : [],
};
}