From ce781df76dfb9108b3939d7192bb5ebda1a4c8cb Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 02:32:30 +0700 Subject: [PATCH] fix(listings): extract PostGIS coordinates in read queries instead of returning 0,0 findByIdWithProperty and searchListings used Prisma include which cannot extract PostGIS geometry(Point,4326) columns. Added raw SQL with ST_Y/ST_X to return actual lat/lng. Search uses batch extraction via ANY() for efficiency. Co-Authored-By: Paperclip --- .../domain/repositories/listing-read.dto.ts | 4 + .../__tests__/listing-read.queries.spec.ts | 8 ++ .../repositories/listing-read.queries.ts | 93 +++++++++++++------ 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts index eb87177..a25a5ad 100644 --- a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts +++ b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts @@ -23,6 +23,8 @@ export interface ListingDetailData { ward: string; district: string; city: string; + latitude: number; + longitude: number; areaM2: number; bedrooms: number | null; bathrooms: number | null; @@ -70,6 +72,8 @@ export interface ListingSearchItem { address: string; district: string; city: string; + latitude: number; + longitude: number; areaM2: number; bedrooms: number | null; bathrooms: number | null; diff --git a/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts b/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts index c0d6a95..b620e3b 100644 --- a/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts +++ b/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts @@ -8,6 +8,7 @@ describe('listing-read.queries', () => { findMany: ReturnType; count: ReturnType; }; + $queryRaw: ReturnType; }; beforeEach(() => { @@ -17,6 +18,7 @@ describe('listing-read.queries', () => { findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), }, + $queryRaw: vi.fn().mockResolvedValue([]), }; }); @@ -69,6 +71,7 @@ describe('listing-read.queries', () => { seller: { id: 'seller-1', fullName: 'Nguyễn Văn A', phone: '0901234567' }, agent: { id: 'agent-1', userId: 'user-a', agency: 'Đất Xanh' }, }); + mockPrisma.$queryRaw.mockResolvedValue([{ latitude: 10.7769, longitude: 106.7009 }]); const result = await findByIdWithProperty(mockPrisma as any, 'listing-1'); @@ -81,6 +84,8 @@ describe('listing-read.queries', () => { expect(result!.seller.fullName).toBe('Nguyễn Văn A'); expect(result!.agent!.agency).toBe('Đất Xanh'); expect(result!.publishedAt).toBe(now.toISOString()); + expect(result!.property.latitude).toBe(10.7769); + expect(result!.property.longitude).toBe(106.7009); }); }); @@ -125,6 +130,7 @@ describe('listing-read.queries', () => { }, ]); mockPrisma.listing.count.mockResolvedValue(1); + mockPrisma.$queryRaw.mockResolvedValue([{ id: 'prop-1', latitude: 10.7769, longitude: 106.7009 }]); const result = await searchListings(mockPrisma as any, { status: 'ACTIVE', page: 1, limit: 20 }); @@ -132,6 +138,8 @@ describe('listing-read.queries', () => { expect(result.data[0]!.id).toBe('listing-1'); expect(result.data[0]!.priceVND).toBe('5000000000'); expect(result.data[0]!.property.thumbnail).toBe('https://cdn.example.com/thumb.jpg'); + expect(result.data[0]!.property.latitude).toBe(10.7769); + expect(result.data[0]!.property.longitude).toBe(106.7009); expect(result.total).toBe(1); expect(result.totalPages).toBe(1); }); diff --git a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts index 55e0774..c12e512 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts @@ -22,6 +22,17 @@ export async function findByIdWithProperty( if (!listing) return null; + // Extract lat/lng from PostGIS geometry via raw SQL + const geoRows = await prisma.$queryRaw<{ latitude: number; longitude: number }[]>` + SELECT + ST_Y("location"::geometry) AS latitude, + ST_X("location"::geometry) AS longitude + FROM "Property" + WHERE "id" = ${listing.property.id} + LIMIT 1 + `; + const geo = geoRows[0] ?? { latitude: 0, longitude: 0 }; + return { id: listing.id, status: listing.status, @@ -44,6 +55,8 @@ export async function findByIdWithProperty( ward: listing.property.ward, district: listing.property.district, city: listing.property.city, + latitude: geo.latitude, + longitude: geo.longitude, areaM2: listing.property.areaM2, bedrooms: listing.property.bedrooms, bathrooms: listing.property.bathrooms, @@ -115,36 +128,58 @@ export async function searchListings( prisma.listing.count({ where }), ]); + // Batch-extract lat/lng for all properties in the result set + const propertyIds = data.map((l) => l.property.id); + const geoMap = new Map(); + if (propertyIds.length > 0) { + const geoRows = await prisma.$queryRaw<{ id: string; latitude: number; longitude: number }[]>` + SELECT + "id", + ST_Y("location"::geometry) AS latitude, + ST_X("location"::geometry) AS longitude + FROM "Property" + WHERE "id" = ANY(${propertyIds}) + `; + for (const row of geoRows) { + geoMap.set(row.id, { latitude: row.latitude, longitude: row.longitude }); + } + } + return { - data: data.map((listing) => ({ - id: listing.id, - status: listing.status, - transactionType: listing.transactionType, - priceVND: listing.priceVND.toString(), - pricePerM2: listing.pricePerM2, - viewCount: listing.viewCount, - publishedAt: listing.publishedAt?.toISOString() ?? null, - property: { - id: listing.property.id, - propertyType: listing.property.propertyType, - title: listing.property.title, - address: listing.property.address, - district: listing.property.district, - city: listing.property.city, - areaM2: listing.property.areaM2, - bedrooms: listing.property.bedrooms, - bathrooms: listing.property.bathrooms, - thumbnail: listing.property.media[0]?.url ?? null, - media: listing.property.media.map((m) => ({ - id: m.id, - url: m.url, - type: m.type, - order: m.order, - caption: m.caption, - })), - }, - seller: listing.seller, - })), + data: data.map((listing) => { + const geo = geoMap.get(listing.property.id) ?? { latitude: 0, longitude: 0 }; + return { + id: listing.id, + status: listing.status, + transactionType: listing.transactionType, + priceVND: listing.priceVND.toString(), + pricePerM2: listing.pricePerM2, + viewCount: listing.viewCount, + publishedAt: listing.publishedAt?.toISOString() ?? null, + property: { + id: listing.property.id, + propertyType: listing.property.propertyType, + title: listing.property.title, + address: listing.property.address, + district: listing.property.district, + city: listing.property.city, + latitude: geo.latitude, + longitude: geo.longitude, + areaM2: listing.property.areaM2, + bedrooms: listing.property.bedrooms, + bathrooms: listing.property.bathrooms, + thumbnail: listing.property.media[0]?.url ?? null, + media: listing.property.media.map((m) => ({ + id: m.id, + url: m.url, + type: m.type, + order: m.order, + caption: m.caption, + })), + }, + seller: listing.seller, + }; + }), total, page, limit,