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

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