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

View File

@@ -8,6 +8,7 @@ describe('listing-read.queries', () => {
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
$queryRaw: ReturnType<typeof vi.fn>;
};
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);
});

View File

@@ -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<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 {
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,