From b809fabd4135ec891bdae3a166988885b2dcef7d Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 15 Apr 2026 09:41:10 +0700 Subject: [PATCH] fix: extract actual lat/lng from PostGIS instead of hardcoded (0,0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Property toDomain() was hardcoding GeoPoint.create(0, 0) because Prisma returns PostGIS geometry(Point, 4326) as an opaque Unsupported type. Changed findById to use raw SQL with ST_Y/ST_X extraction, ensuring actual coordinates round-trip correctly through save → query. Co-Authored-By: Paperclip --- .../prisma-property.repository.spec.ts | 112 +++++++++++++----- .../prisma-property.repository.ts | 57 +++++++-- 2 files changed, 130 insertions(+), 39 deletions(-) diff --git a/apps/api/src/modules/listings/infrastructure/__tests__/prisma-property.repository.spec.ts b/apps/api/src/modules/listings/infrastructure/__tests__/prisma-property.repository.spec.ts index 1e704bf..eec12ac 100644 --- a/apps/api/src/modules/listings/infrastructure/__tests__/prisma-property.repository.spec.ts +++ b/apps/api/src/modules/listings/infrastructure/__tests__/prisma-property.repository.spec.ts @@ -16,6 +16,7 @@ describe('PrismaPropertyRepository', () => { count: ReturnType; }; $executeRaw: ReturnType; + $queryRaw: ReturnType; }; beforeEach(() => { @@ -30,51 +31,53 @@ describe('PrismaPropertyRepository', () => { count: vi.fn().mockResolvedValue(0), }, $executeRaw: vi.fn().mockResolvedValue(1), + $queryRaw: vi.fn().mockResolvedValue([]), }; repository = new PrismaPropertyRepository(mockPrisma as any); }); describe('findById', () => { it('should return null when property not found', async () => { - mockPrisma.property.findUnique.mockResolvedValue(null); + mockPrisma.$queryRaw.mockResolvedValue([]); const result = await repository.findById('non-existent'); expect(result).toBeNull(); - expect(mockPrisma.property.findUnique).toHaveBeenCalledWith({ - where: { id: 'non-existent' }, - }); }); - it('should return a PropertyEntity when property is found', async () => { + it('should return a PropertyEntity with actual lat/lng from PostGIS', async () => { const now = new Date(); - mockPrisma.property.findUnique.mockResolvedValue({ - id: 'prop-1', - propertyType: 'APARTMENT', - title: 'Căn hộ đẹp', - description: 'Mô tả chi tiết', - address: '123 Nguyễn Huệ', - ward: 'Bến Nghé', - district: 'Quận 1', - city: 'Hồ Chí Minh', - location: null, // PostGIS geometry placeholder - areaM2: 80, - usableAreaM2: 70, - bedrooms: 2, - bathrooms: 2, - floors: null, - floor: 10, - totalFloors: 25, - direction: 'EAST', - yearBuilt: 2022, - legalStatus: 'Sổ hồng', - amenities: null, - nearbyPOIs: null, - metroDistanceM: 300, - projectName: 'Vinhomes', - createdAt: now, - updatedAt: now, - }); + // ST_Y/ST_X extract actual coordinates — no more hardcoded (0,0) + mockPrisma.$queryRaw.mockResolvedValue([ + { + id: 'prop-1', + propertyType: 'APARTMENT', + title: 'Căn hộ đẹp', + description: 'Mô tả chi tiết', + address: '123 Nguyễn Huệ', + ward: 'Bến Nghé', + district: 'Quận 1', + city: 'Hồ Chí Minh', + latitude: 10.7769, + longitude: 106.7009, + areaM2: 80, + usableAreaM2: 70, + bedrooms: 2, + bathrooms: 2, + floors: null, + floor: 10, + totalFloors: 25, + direction: 'EAST', + yearBuilt: 2022, + legalStatus: 'Sổ hồng', + amenities: null, + nearbyPOIs: null, + metroDistanceM: 300, + projectName: 'Vinhomes', + createdAt: now, + updatedAt: now, + }, + ]); const result = await repository.findById('prop-1'); @@ -82,6 +85,51 @@ describe('PrismaPropertyRepository', () => { expect(result!.id).toBe('prop-1'); expect(result!.title).toBe('Căn hộ đẹp'); expect(result!.propertyType).toBe('APARTMENT'); + // Verify actual lat/lng are returned — not hardcoded (0, 0) + expect(result!.location.latitude).toBe(10.7769); + expect(result!.location.longitude).toBe(106.7009); + }); + + it('should correctly map Ho Chi Minh City coordinates', async () => { + const now = new Date(); + mockPrisma.$queryRaw.mockResolvedValue([ + { + id: 'prop-hcm', + propertyType: 'TOWNHOUSE', + title: 'Nhà phố Thủ Đức', + description: 'Nhà phố đẹp', + address: '456 Võ Văn Ngân', + ward: 'Linh Chiểu', + district: 'Thủ Đức', + city: 'Hồ Chí Minh', + latitude: 10.8514, + longitude: 106.7583, + areaM2: 120, + usableAreaM2: 100, + bedrooms: 3, + bathrooms: 2, + floors: 3, + floor: null, + totalFloors: null, + direction: 'SOUTH', + yearBuilt: 2021, + legalStatus: 'Sổ hồng', + amenities: { parking: true }, + nearbyPOIs: [], + metroDistanceM: 500, + projectName: null, + createdAt: now, + updatedAt: now, + }, + ]); + + const result = await repository.findById('prop-hcm'); + + expect(result).toBeInstanceOf(PropertyEntity); + expect(result!.location.latitude).toBe(10.8514); + expect(result!.location.longitude).toBe(106.7583); + // Verify WKT generation works with the correct coordinates + expect(result!.location.toWKT()).toBe('POINT(106.7583 10.8514)'); }); }); diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts index 3b81424..13b7465 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Prisma, Property as PrismaProperty, PropertyMedia as PrismaMedia } from '@prisma/client'; +import { Prisma, PropertyMedia as PrismaMedia, PropertyType, Direction } from '@prisma/client'; import { PrismaService } from '@modules/shared'; import { PropertyMediaEntity, PropertyMediaProps } from '../../domain/entities/property-media.entity'; import { PropertyEntity, PropertyProps } from '../../domain/entities/property.entity'; @@ -7,13 +7,58 @@ import { IPropertyRepository } from '../../domain/repositories/property.reposito import { Address } from '../../domain/value-objects/address.vo'; import { GeoPoint } from '../../domain/value-objects/geo-point.vo'; +/** Shape returned by findById raw SQL query with ST_X/ST_Y extracted coordinates */ +interface PropertyWithGeo { + id: string; + propertyType: PropertyType; + title: string; + description: string; + address: string; + ward: string; + district: string; + city: string; + latitude: number; + longitude: number; + areaM2: number; + usableAreaM2: number | null; + bedrooms: number | null; + bathrooms: number | null; + floors: number | null; + floor: number | null; + totalFloors: number | null; + direction: Direction | null; + yearBuilt: number | null; + legalStatus: string | null; + amenities: unknown; + nearbyPOIs: unknown; + metroDistanceM: number | null; + projectName: string | null; + createdAt: Date; + updatedAt: Date; +} + @Injectable() export class PrismaPropertyRepository implements IPropertyRepository { constructor(private readonly prisma: PrismaService) {} async findById(id: string): Promise { - const property = await this.prisma.property.findUnique({ where: { id } }); - return property ? this.toDomain(property) : null; + // Use raw SQL to extract lat/lng from PostGIS geometry(Point, 4326) + const rows = await this.prisma.$queryRaw` + SELECT + "id", "propertyType", "title", "description", + "address", "ward", "district", "city", + ST_Y("location"::geometry) AS latitude, + ST_X("location"::geometry) AS longitude, + "areaM2", "usableAreaM2", "bedrooms", "bathrooms", + "floors", "floor", "totalFloors", "direction", + "yearBuilt", "legalStatus", "amenities", "nearbyPOIs", + "metroDistanceM", "projectName", "createdAt", "updatedAt" + FROM "Property" + WHERE "id" = ${id} + LIMIT 1 + `; + const row = rows[0]; + return row ? this.toDomainWithGeo(row) : null; } async save(entity: PropertyEntity): Promise { @@ -91,10 +136,8 @@ export class PrismaPropertyRepository implements IPropertyRepository { return this.prisma.propertyMedia.count({ where: { propertyId } }); } - private toDomain(raw: PrismaProperty): PropertyEntity { - // PostGIS geometry is returned as a raw object — extract lat/lng - // For raw SQL results we'd get WKB, but Prisma returns Unsupported as-is - const geoPoint = GeoPoint.create(0, 0).unwrap(); // placeholder — location read via raw SQL + private toDomainWithGeo(raw: PropertyWithGeo): PropertyEntity { + const geoPoint = GeoPoint.create(raw.latitude, raw.longitude).unwrap(); const address = Address.create(raw.address, raw.ward, raw.district, raw.city).unwrap(); const props: PropertyProps = {