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 <noreply@paperclip.ing>
This commit is contained in:
@@ -3,12 +3,11 @@ import { PrismaAVMService } from '../services/prisma-avm.service';
|
|||||||
|
|
||||||
describe('PrismaAVMService', () => {
|
describe('PrismaAVMService', () => {
|
||||||
let service: PrismaAVMService;
|
let service: PrismaAVMService;
|
||||||
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn>; $queryRawUnsafe: ReturnType<typeof vi.fn> };
|
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockPrisma = {
|
mockPrisma = {
|
||||||
$queryRaw: vi.fn(),
|
$queryRaw: vi.fn(),
|
||||||
$queryRawUnsafe: vi.fn(),
|
|
||||||
};
|
};
|
||||||
service = new PrismaAVMService(mockPrisma as unknown as PrismaService);
|
service = new PrismaAVMService(mockPrisma as unknown as PrismaService);
|
||||||
});
|
});
|
||||||
@@ -29,12 +28,13 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns zero confidence when fewer than 3 comparables', async () => {
|
it('returns zero confidence when fewer than 3 comparables', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
mockPrisma.$queryRaw
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ 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() },
|
.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' });
|
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
||||||
|
|
||||||
@@ -44,14 +44,15 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calculates weighted valuation with sufficient comparables', async () => {
|
it('calculates weighted valuation with sufficient comparables', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
mockPrisma.$queryRaw
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ 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() },
|
.mockResolvedValueOnce([
|
||||||
{ 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: '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: '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() },
|
{ 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' });
|
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses coordinates directly when no propertyId', async () => {
|
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: '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: '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() },
|
{ 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(result.confidence).toBeGreaterThan(0);
|
||||||
expect(Number(result.estimatedPrice)).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', () => {
|
describe('getComparables', () => {
|
||||||
it('returns comparables for a property', async () => {
|
it('returns comparables for a property', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
mockPrisma.$queryRaw
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ 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() },
|
.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);
|
const result = await service.getComparables('prop-1', 3000);
|
||||||
|
|
||||||
|
|||||||
@@ -146,22 +146,35 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository {
|
|||||||
async getHeatmapWard(city: string, _period: string, district?: string): Promise<WardHeatmapDataPoint[]> {
|
async getHeatmapWard(city: string, _period: string, district?: string): Promise<WardHeatmapDataPoint[]> {
|
||||||
type WardRow = { ward: string; district: string; avg_price_m2: number; total_listings: bigint; median_price: bigint };
|
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 = district
|
||||||
|
? await this.prisma.$queryRaw<WardRow[]>`
|
||||||
const rows = await this.prisma.$queryRawUnsafe<WardRow[]>(`
|
SELECT
|
||||||
SELECT
|
p."ward",
|
||||||
p."ward",
|
p."district",
|
||||||
p."district",
|
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
||||||
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
COUNT(l."id")::bigint AS total_listings,
|
||||||
COUNT(l."id")::bigint AS total_listings,
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
||||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
FROM "Property" p
|
||||||
FROM "Property" p
|
JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
|
||||||
JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
|
WHERE p."city" = ${city} AND p."district" = ${district}
|
||||||
WHERE p."city" = $1 ${districtFilter}
|
AND p."ward" IS NOT NULL AND p."ward" != ''
|
||||||
AND p."ward" IS NOT NULL AND p."ward" != ''
|
GROUP BY p."ward", p."district"
|
||||||
GROUP BY p."ward", p."district"
|
ORDER BY p."ward" ASC
|
||||||
ORDER BY p."ward" ASC
|
`
|
||||||
`, city);
|
: await this.prisma.$queryRaw<WardRow[]>`
|
||||||
|
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) => ({
|
return rows.map((r) => ({
|
||||||
ward: r.ward,
|
ward: r.ward,
|
||||||
|
|||||||
@@ -136,23 +136,35 @@ export class PrismaAVMService implements IAVMService {
|
|||||||
propertyType: PropertyType | undefined,
|
propertyType: PropertyType | undefined,
|
||||||
radiusMeters: number,
|
radiusMeters: number,
|
||||||
): Promise<RawComparable[]> {
|
): Promise<RawComparable[]> {
|
||||||
const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : '';
|
if (propertyType) {
|
||||||
return this.prisma.$queryRawUnsafe<RawComparable[]>(
|
return this.prisma.$queryRaw<RawComparable[]>`
|
||||||
`
|
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<RawComparable[]>`
|
||||||
SELECT
|
SELECT
|
||||||
p.id AS property_id, p.address, p.district,
|
p.id AS property_id, p.address, p.district,
|
||||||
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
||||||
p."areaM2" AS area_m2, p."propertyType" AS property_type,
|
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
|
l."publishedAt" AS published_at
|
||||||
FROM "Property" p
|
FROM "Property" p
|
||||||
JOIN "Listing" l ON l."propertyId" = p.id
|
JOIN "Listing" l ON l."propertyId" = p.id
|
||||||
WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL
|
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)
|
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters})
|
||||||
${typeFilter}
|
|
||||||
ORDER BY distance_meters ASC LIMIT 20
|
ORDER BY distance_meters ASC LIMIT 20
|
||||||
`,
|
`;
|
||||||
lng, lat, radiusMeters,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user