fix: extract actual lat/lng from PostGIS instead of hardcoded (0,0)

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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-15 09:41:10 +07:00
parent 92e708f17f
commit b809fabd41
2 changed files with 130 additions and 39 deletions

View File

@@ -16,6 +16,7 @@ describe('PrismaPropertyRepository', () => {
count: ReturnType<typeof vi.fn>;
};
$executeRaw: ReturnType<typeof vi.fn>;
$queryRaw: ReturnType<typeof vi.fn>;
};
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)');
});
});

View File

@@ -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<PropertyEntity | null> {
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<PropertyWithGeo[]>`
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<void> {
@@ -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 = {