fix: apply consistent-type-imports across API codebase (728 lint errors)
- Convert `import type { X }` to `import { type X }` (inline-type-imports style)
- Suppress consistent-type-imports for `typeof import()` in instrument.ts
- Includes uncommitted agent work: metrics module, redis caching, audit logs,
saved searches, circuit breaker, rate limiting, and admin enhancements
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
export { TypesenseClientService } from './typesense-client.service';
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type ISearchRepository,
|
||||
type ListingDocument,
|
||||
type SearchParams,
|
||||
type SearchResult,
|
||||
} from '../../domain/repositories/search.repository';
|
||||
|
||||
/**
|
||||
* PostgreSQL-backed search repository used as a fallback when Typesense
|
||||
* is unavailable.
|
||||
*
|
||||
* Capabilities:
|
||||
* - Full-text search via PostgreSQL `to_tsvector` / `plainto_tsquery`
|
||||
* - Geo radius filtering via PostGIS `ST_DWithin`
|
||||
* - Faceted filters (property type, transaction type, price range, area, etc.)
|
||||
*
|
||||
* Limitations compared to Typesense:
|
||||
* - No relevance-ranked highlighting
|
||||
* - Slower for large result sets
|
||||
* - Vietnamese language support depends on PG config (defaults to 'simple')
|
||||
*/
|
||||
@Injectable()
|
||||
export class PostgresSearchRepository implements ISearchRepository {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
const page = params.page ?? 1;
|
||||
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 whereClause = Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`;
|
||||
|
||||
// ── 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"
|
||||
${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",
|
||||
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"
|
||||
${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,
|
||||
totalFound,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(totalFound / perPage),
|
||||
searchTimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Indexing operations are no-ops for the PG fallback ───────────────
|
||||
|
||||
async indexDocument(_doc: ListingDocument): Promise<void> {
|
||||
// Data already lives in PostgreSQL — nothing to do.
|
||||
}
|
||||
|
||||
async indexDocuments(_docs: ListingDocument[]): Promise<void> {
|
||||
// Data already lives in PostgreSQL — nothing to do.
|
||||
}
|
||||
|
||||
async removeDocument(_id: string): Promise<void> {
|
||||
// No separate index to clean up.
|
||||
}
|
||||
|
||||
async ensureCollection(): Promise<void> {
|
||||
// PostgreSQL tables/indexes are managed by Prisma migrations.
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type Client as TypesenseClient } from 'typesense';
|
||||
import type { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { type CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
type ISearchRepository,
|
||||
|
||||
Reference in New Issue
Block a user