From 6774914b4c50b468ac89d9dbe6a6771322c56e42 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 24 Apr 2026 12:33:31 +0700 Subject: [PATCH] fix(analytics): parameterize raw SQL queries in AVM and market-index Replace $queryRawUnsafe with Prisma $queryRaw tagged template literals to eliminate SQL injection in findComparables() and getHeatmapWard(). Update tests to match the new parameterized query approach. Co-Authored-By: Paperclip --- .../__tests__/prisma-avm.service.spec.ts | 51 ++++++++++--------- .../prisma-market-index.repository.ts | 45 ++++++++++------ .../services/prisma-avm.service.ts | 30 +++++++---- 3 files changed, 77 insertions(+), 49 deletions(-) diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts index e73c582..16c6ee7 100644 --- a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts @@ -3,12 +3,11 @@ import { PrismaAVMService } from '../services/prisma-avm.service'; describe('PrismaAVMService', () => { let service: PrismaAVMService; - let mockPrisma: { $queryRaw: ReturnType; $queryRawUnsafe: ReturnType }; + let mockPrisma: { $queryRaw: ReturnType }; beforeEach(() => { mockPrisma = { $queryRaw: vi.fn(), - $queryRawUnsafe: vi.fn(), }; service = new PrismaAVMService(mockPrisma as unknown as PrismaService); }); @@ -29,12 +28,13 @@ describe('PrismaAVMService', () => { }); it('returns zero confidence when fewer than 3 comparables', async () => { - mockPrisma.$queryRaw.mockResolvedValue([ - { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, - ]); - mockPrisma.$queryRawUnsafe.mockResolvedValue([ - { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, - ]); + mockPrisma.$queryRaw + .mockResolvedValueOnce([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]) + .mockResolvedValueOnce([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, + ]); const result = await service.estimateValue({ propertyId: 'prop-1' }); @@ -44,14 +44,15 @@ describe('PrismaAVMService', () => { }); it('calculates weighted valuation with sufficient comparables', async () => { - mockPrisma.$queryRaw.mockResolvedValue([ - { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, - ]); - mockPrisma.$queryRawUnsafe.mockResolvedValue([ - { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, - { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, - { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, - ]); + mockPrisma.$queryRaw + .mockResolvedValueOnce([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]) + .mockResolvedValueOnce([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, + { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, + { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, + ]); const result = await service.estimateValue({ propertyId: 'prop-1' }); @@ -63,7 +64,7 @@ describe('PrismaAVMService', () => { }); it('uses coordinates directly when no propertyId', async () => { - mockPrisma.$queryRawUnsafe.mockResolvedValue([ + mockPrisma.$queryRaw.mockResolvedValueOnce([ { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, @@ -78,18 +79,20 @@ describe('PrismaAVMService', () => { expect(result.confidence).toBeGreaterThan(0); expect(Number(result.estimatedPrice)).toBeGreaterThan(0); - expect(mockPrisma.$queryRaw).not.toHaveBeenCalled(); + // Only one $queryRaw call (findComparables) — no getPropertyLocation needed + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); }); }); describe('getComparables', () => { it('returns comparables for a property', async () => { - mockPrisma.$queryRaw.mockResolvedValue([ - { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, - ]); - mockPrisma.$queryRawUnsafe.mockResolvedValue([ - { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() }, - ]); + mockPrisma.$queryRaw + .mockResolvedValueOnce([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]) + .mockResolvedValueOnce([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() }, + ]); const result = await service.getComparables('prop-1', 3000); diff --git a/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts index 0b231e4..efb67f0 100644 --- a/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts +++ b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts @@ -146,22 +146,35 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository { async getHeatmapWard(city: string, _period: string, district?: string): Promise { type WardRow = { ward: string; district: string; avg_price_m2: number; total_listings: bigint; median_price: bigint }; - const districtFilter = district ? `AND p."district" = ${JSON.stringify(district)}` : ''; - - const rows = await this.prisma.$queryRawUnsafe(` - SELECT - p."ward", - p."district", - AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2, - COUNT(l."id")::bigint AS total_listings, - PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price - FROM "Property" p - JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE' - WHERE p."city" = $1 ${districtFilter} - AND p."ward" IS NOT NULL AND p."ward" != '' - GROUP BY p."ward", p."district" - ORDER BY p."ward" ASC - `, city); + const rows = district + ? await this.prisma.$queryRaw` + SELECT + p."ward", + p."district", + AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2, + COUNT(l."id")::bigint AS total_listings, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price + FROM "Property" p + JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE' + WHERE p."city" = ${city} AND p."district" = ${district} + AND p."ward" IS NOT NULL AND p."ward" != '' + GROUP BY p."ward", p."district" + ORDER BY p."ward" ASC + ` + : await this.prisma.$queryRaw` + SELECT + p."ward", + p."district", + AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2, + COUNT(l."id")::bigint AS total_listings, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price + FROM "Property" p + JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE' + WHERE p."city" = ${city} + AND p."ward" IS NOT NULL AND p."ward" != '' + GROUP BY p."ward", p."district" + ORDER BY p."ward" ASC + `; return rows.map((r) => ({ ward: r.ward, diff --git a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts index 4d66a08..039431c 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts @@ -136,23 +136,35 @@ export class PrismaAVMService implements IAVMService { propertyType: PropertyType | undefined, radiusMeters: number, ): Promise { - const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : ''; - return this.prisma.$queryRawUnsafe( - ` + if (propertyType) { + return this.prisma.$queryRaw` + SELECT + p.id AS property_id, p.address, p.district, + l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2, + p."areaM2" AS area_m2, p."propertyType" AS property_type, + ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) AS distance_meters, + l."publishedAt" AS published_at + FROM "Property" p + JOIN "Listing" l ON l."propertyId" = p.id + WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL + AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters}) + AND p."propertyType" = ${propertyType}::"PropertyType" + ORDER BY distance_meters ASC LIMIT 20 + `; + } + + return this.prisma.$queryRaw` SELECT p.id AS property_id, p.address, p.district, l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2, p."areaM2" AS area_m2, p."propertyType" AS property_type, - ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) AS distance_meters, + ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) AS distance_meters, l."publishedAt" AS published_at FROM "Property" p JOIN "Listing" l ON l."propertyId" = p.id WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL - AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3) - ${typeFilter} + AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters}) ORDER BY distance_meters ASC LIMIT 20 - `, - lng, lat, radiusMeters, - ); + `; } }