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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 02:32:30 +07:00
parent cc584239b0
commit ce781df76d
3 changed files with 76 additions and 29 deletions

View File

@@ -23,6 +23,8 @@ export interface ListingDetailData {
ward: string; ward: string;
district: string; district: string;
city: string; city: string;
latitude: number;
longitude: number;
areaM2: number; areaM2: number;
bedrooms: number | null; bedrooms: number | null;
bathrooms: number | null; bathrooms: number | null;
@@ -70,6 +72,8 @@ export interface ListingSearchItem {
address: string; address: string;
district: string; district: string;
city: string; city: string;
latitude: number;
longitude: number;
areaM2: number; areaM2: number;
bedrooms: number | null; bedrooms: number | null;
bathrooms: number | null; bathrooms: number | null;

View File

@@ -8,6 +8,7 @@ describe('listing-read.queries', () => {
findMany: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>; count: ReturnType<typeof vi.fn>;
}; };
$queryRaw: ReturnType<typeof vi.fn>;
}; };
beforeEach(() => { beforeEach(() => {
@@ -17,6 +18,7 @@ describe('listing-read.queries', () => {
findMany: vi.fn().mockResolvedValue([]), findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0), 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' }, seller: { id: 'seller-1', fullName: 'Nguyễn Văn A', phone: '0901234567' },
agent: { id: 'agent-1', userId: 'user-a', agency: 'Đất Xanh' }, 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'); 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!.seller.fullName).toBe('Nguyễn Văn A');
expect(result!.agent!.agency).toBe('Đất Xanh'); expect(result!.agent!.agency).toBe('Đất Xanh');
expect(result!.publishedAt).toBe(now.toISOString()); 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.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 }); 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]!.id).toBe('listing-1');
expect(result.data[0]!.priceVND).toBe('5000000000'); 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.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.total).toBe(1);
expect(result.totalPages).toBe(1); expect(result.totalPages).toBe(1);
}); });

View File

@@ -22,6 +22,17 @@ export async function findByIdWithProperty(
if (!listing) return null; 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 { return {
id: listing.id, id: listing.id,
status: listing.status, status: listing.status,
@@ -44,6 +55,8 @@ export async function findByIdWithProperty(
ward: listing.property.ward, ward: listing.property.ward,
district: listing.property.district, district: listing.property.district,
city: listing.property.city, city: listing.property.city,
latitude: geo.latitude,
longitude: geo.longitude,
areaM2: listing.property.areaM2, areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms, bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms, bathrooms: listing.property.bathrooms,
@@ -115,36 +128,58 @@ export async function searchListings(
prisma.listing.count({ where }), 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<string, { latitude: number; longitude: number }>();
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 { return {
data: data.map((listing) => ({ data: data.map((listing) => {
id: listing.id, const geo = geoMap.get(listing.property.id) ?? { latitude: 0, longitude: 0 };
status: listing.status, return {
transactionType: listing.transactionType, id: listing.id,
priceVND: listing.priceVND.toString(), status: listing.status,
pricePerM2: listing.pricePerM2, transactionType: listing.transactionType,
viewCount: listing.viewCount, priceVND: listing.priceVND.toString(),
publishedAt: listing.publishedAt?.toISOString() ?? null, pricePerM2: listing.pricePerM2,
property: { viewCount: listing.viewCount,
id: listing.property.id, publishedAt: listing.publishedAt?.toISOString() ?? null,
propertyType: listing.property.propertyType, property: {
title: listing.property.title, id: listing.property.id,
address: listing.property.address, propertyType: listing.property.propertyType,
district: listing.property.district, title: listing.property.title,
city: listing.property.city, address: listing.property.address,
areaM2: listing.property.areaM2, district: listing.property.district,
bedrooms: listing.property.bedrooms, city: listing.property.city,
bathrooms: listing.property.bathrooms, latitude: geo.latitude,
thumbnail: listing.property.media[0]?.url ?? null, longitude: geo.longitude,
media: listing.property.media.map((m) => ({ areaM2: listing.property.areaM2,
id: m.id, bedrooms: listing.property.bedrooms,
url: m.url, bathrooms: listing.property.bathrooms,
type: m.type, thumbnail: listing.property.media[0]?.url ?? null,
order: m.order, media: listing.property.media.map((m) => ({
caption: m.caption, id: m.id,
})), url: m.url,
}, type: m.type,
seller: listing.seller, order: m.order,
})), caption: m.caption,
})),
},
seller: listing.seller,
};
}),
total, total,
page, page,
limit, limit,