feat(listings): phase C — nearby POIs on listing detail map
Some checks failed
CI / E2E Tests (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 25s
Security Scanning / Trivy Scan — Web Image (push) Failing after 26s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 23s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 56s
Deploy / Build API Image (push) Failing after 18s
Deploy / Build Web Image (push) Failing after 10s
Deploy / Build AI Services Image (push) Failing after 9s
E2E Tests / Playwright E2E (push) Failing after 7s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 2s
Security Scanning / Trivy Filesystem Scan (push) Failing after 26s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped

Backend
-------
- New endpoint GET /analytics/pois/nearby?lat&lng&radius&limit (public,
  no guard). Mirrors the neighborhoods/:district/score shape.
- Prisma $queryRawUnsafe with PostGIS ST_DWithin on POI.location::geography
  and ST_Distance for the ordered-by-distance result. Default radius 2km,
  max 10km; default limit 30, max 100.
- Response maps POIType enum → frontend POICategory so the existing pill
  filter in NeighborhoodPOIMap works out of the box:
    SCHOOL/UNIVERSITY → school
    HOSPITAL/CLINIC/PHARMACY → hospital
    METRO_STATION/BUS_STOP → transit
    MALL/MARKET/SUPERMARKET/BANK/ATM → shopping
    RESTAURANT/CAFE → restaurant
    PARK → park
    else → shopping (fallback, still filterable)
- New files: application/queries/get-nearby-pois/{query,handler}.ts +
  presentation/dto/get-nearby-pois.dto.ts. Registered in analytics.module.ts.

Frontend
--------
- analytics-api.ts: exports NearbyPOI, NearbyPOIsResponse, NearbyPOICategory
  and analyticsApi.getNearbyPOIs(lat, lng, radius?, limit?).
- listing-detail-client.tsx: the "Vị trí trên bản đồ" card no longer
  renders <ListingMap> for a single pin — it now renders
  <NeighborhoodPOIMap> with the property's coords as center, the nearby
  POIs as markers, and the existing category-filter pills. A small
  "Tìm thấy N điểm quan tâm trong bán kính 2 km" summary sits below.
- The neighborhood score radar card remains below, untouched.
- The spec fixture + mocks extended for the new analyticsApi dependency.

No schema change, no migration. Phase C of 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 14:48:09 +07:00
parent 6067adc095
commit 08c8b5e027
8 changed files with 266 additions and 7 deletions

View File

@@ -9,6 +9,7 @@ import { IndustrialValuationHandler } from './application/queries/industrial-val
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
import { GetNearbyPOIsHandler } from './application/queries/get-nearby-pois/get-nearby-pois.handler';
import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
@@ -51,6 +52,7 @@ const QueryHandlers = [
ValuationComparisonHandler,
ValuationExplanationHandler,
GetNeighborhoodScoreHandler,
GetNearbyPOIsHandler,
IndustrialValuationHandler,
];

View File

@@ -0,0 +1,112 @@
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
import { POIType } from '@prisma/client';
import { PrismaService } from '@modules/shared';
import { GetNearbyPOIsQuery } from './get-nearby-pois.query';
export type POICategory =
| 'school'
| 'hospital'
| 'transit'
| 'shopping'
| 'restaurant'
| 'park';
export interface NearbyPOIDto {
id: string;
name: string;
type: POIType;
category: POICategory;
lat: number;
lng: number;
distance: number;
address: string | null;
}
export interface NearbyPOIsResultDto {
pois: NearbyPOIDto[];
center: { lat: number; lng: number };
}
interface PoiRow {
id: string;
name: string;
type: POIType;
lat: number;
lng: number;
distance: number;
address: string | null;
}
function mapTypeToCategory(type: POIType): POICategory {
switch (type) {
case 'SCHOOL':
case 'UNIVERSITY':
return 'school';
case 'HOSPITAL':
case 'CLINIC':
case 'PHARMACY':
return 'hospital';
case 'METRO_STATION':
case 'BUS_STOP':
return 'transit';
case 'MALL':
case 'MARKET':
case 'SUPERMARKET':
case 'BANK':
case 'ATM':
return 'shopping';
case 'RESTAURANT':
case 'CAFE':
return 'restaurant';
case 'PARK':
return 'park';
default:
return 'shopping';
}
}
@QueryHandler(GetNearbyPOIsQuery)
export class GetNearbyPOIsHandler implements IQueryHandler<GetNearbyPOIsQuery> {
constructor(private readonly prisma: PrismaService) {}
async execute(query: GetNearbyPOIsQuery): Promise<NearbyPOIsResultDto> {
const { lat, lng, radiusM, limit } = query;
const rows = await this.prisma.$queryRawUnsafe<PoiRow[]>(
`
SELECT
"id",
"name",
"type",
ST_Y("location"::geometry) AS lat,
ST_X("location"::geometry) AS lng,
ST_Distance("location"::geography, ST_MakePoint($1, $2)::geography) AS distance,
"address"
FROM "POI"
WHERE ST_DWithin("location"::geography, ST_MakePoint($1, $2)::geography, $3)
ORDER BY distance ASC
LIMIT $4
`,
lng,
lat,
radiusM,
limit,
);
const pois: NearbyPOIDto[] = rows.map((row) => ({
id: row.id,
name: row.name,
type: row.type,
category: mapTypeToCategory(row.type),
lat: Number(row.lat),
lng: Number(row.lng),
distance: Number(row.distance),
address: row.address,
}));
return {
pois,
center: { lat, lng },
};
}
}

View File

@@ -0,0 +1,8 @@
export class GetNearbyPOIsQuery {
constructor(
public readonly lat: number,
public readonly lng: number,
public readonly radiusM: number,
public readonly limit: number,
) {}
}

View File

@@ -20,6 +20,8 @@ import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatm
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
import { type NearbyPOIsResultDto } from '../../application/queries/get-nearby-pois/get-nearby-pois.handler';
import { GetNearbyPOIsQuery } from '../../application/queries/get-nearby-pois/get-nearby-pois.query';
import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query';
import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
@@ -36,6 +38,7 @@ import { BatchValuationDto } from '../dto/batch-valuation.dto';
import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
import { GetMarketReportDto } from '../dto/get-market-report.dto';
import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto';
import { GetPriceTrendDto } from '../dto/get-price-trend.dto';
import { GetValuationDto } from '../dto/get-valuation.dto';
import { PredictValuationDto as PredictValuationBodyDto } from '../dto/predict-valuation.dto';
@@ -242,4 +245,18 @@ export class AnalyticsController {
new GetNeighborhoodScoreQuery(district, city),
);
}
@ApiOperation({ summary: 'Get nearby POIs around a coordinate (public)' })
@ApiResponse({ status: 200, description: 'Nearby POIs retrieved' })
@Get('pois/nearby')
async getNearbyPOIs(@Query() dto: GetNearbyPOIsDto): Promise<NearbyPOIsResultDto> {
return this.queryBus.execute(
new GetNearbyPOIsQuery(
dto.lat,
dto.lng,
dto.radius ?? 2000,
dto.limit ?? 30,
),
);
}
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNumber, IsOptional, Max, Min } from 'class-validator';
export class GetNearbyPOIsDto {
@ApiProperty({ description: 'Latitude (-90..90)', example: 10.7975 })
@Transform(({ value }) => Number(value))
@IsNumber()
@Min(-90)
@Max(90)
lat!: number;
@ApiProperty({ description: 'Longitude (-180..180)', example: 106.721 })
@Transform(({ value }) => Number(value))
@IsNumber()
@Min(-180)
@Max(180)
lng!: number;
@ApiProperty({ description: 'Search radius in meters', default: 2000, required: false })
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : Number(value)))
@IsNumber()
@Min(1)
@Max(10_000)
radius?: number;
@ApiProperty({ description: 'Maximum POIs to return', default: 30, required: false })
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : Number(value)))
@IsNumber()
@Min(1)
@Max(100)
limit?: number;
}