From 08c8b5e027c98f54b7f0076d26221efe2b4494bc Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 19 Apr 2026 14:48:09 +0700 Subject: [PATCH] =?UTF-8?q?feat(listings):=20phase=20C=20=E2=80=94=20nearb?= =?UTF-8?q?y=20POIs=20on=20listing=20detail=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 for a single pin — it now renders 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) --- .../src/modules/analytics/analytics.module.ts | 2 + .../get-nearby-pois.handler.ts | 112 ++++++++++++++++++ .../get-nearby-pois/get-nearby-pois.query.ts | 8 ++ .../controllers/analytics.controller.ts | 17 +++ .../presentation/dto/get-nearby-pois.dto.ts | 35 ++++++ .../__tests__/listing-detail-client.spec.tsx | 19 +++ .../listings/listing-detail-client.tsx | 51 ++++++-- apps/web/lib/analytics-api.ts | 29 +++++ 8 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/modules/analytics/application/queries/get-nearby-pois/get-nearby-pois.handler.ts create mode 100644 apps/api/src/modules/analytics/application/queries/get-nearby-pois/get-nearby-pois.query.ts create mode 100644 apps/api/src/modules/analytics/presentation/dto/get-nearby-pois.dto.ts diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index b19b8d3..0f716e1 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -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, ]; diff --git a/apps/api/src/modules/analytics/application/queries/get-nearby-pois/get-nearby-pois.handler.ts b/apps/api/src/modules/analytics/application/queries/get-nearby-pois/get-nearby-pois.handler.ts new file mode 100644 index 0000000..f517fee --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-nearby-pois/get-nearby-pois.handler.ts @@ -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 { + constructor(private readonly prisma: PrismaService) {} + + async execute(query: GetNearbyPOIsQuery): Promise { + const { lat, lng, radiusM, limit } = query; + + const rows = await this.prisma.$queryRawUnsafe( + ` + 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 }, + }; + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-nearby-pois/get-nearby-pois.query.ts b/apps/api/src/modules/analytics/application/queries/get-nearby-pois/get-nearby-pois.query.ts new file mode 100644 index 0000000..993ec98 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-nearby-pois/get-nearby-pois.query.ts @@ -0,0 +1,8 @@ +export class GetNearbyPOIsQuery { + constructor( + public readonly lat: number, + public readonly lng: number, + public readonly radiusM: number, + public readonly limit: number, + ) {} +} diff --git a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts index a436b82..b09ca37 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -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 { + return this.queryBus.execute( + new GetNearbyPOIsQuery( + dto.lat, + dto.lng, + dto.radius ?? 2000, + dto.limit ?? 30, + ), + ); + } } diff --git a/apps/api/src/modules/analytics/presentation/dto/get-nearby-pois.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-nearby-pois.dto.ts new file mode 100644 index 0000000..73e22e7 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-nearby-pois.dto.ts @@ -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; +} diff --git a/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx b/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx index 83defde..d29b98d 100644 --- a/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx +++ b/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx @@ -48,6 +48,25 @@ vi.mock('@/components/listings/inquiry-modal', () => ({ InquiryModal: () => null, })); +// Mock analytics API (used for nearby POIs fetch) +vi.mock('@/lib/analytics-api', () => ({ + analyticsApi: { + getNearbyPOIs: vi.fn().mockResolvedValue({ pois: [], center: { lat: 0, lng: 0 } }), + }, +})); + +// Mock listings API (used for neighborhood score + price history) +vi.mock('@/lib/listings-api', async () => { + const actual = await vi.importActual('@/lib/listings-api'); + return { + ...actual, + listingsApi: { + getNeighborhoodScore: vi.fn().mockResolvedValue(null), + getPriceHistory: vi.fn().mockResolvedValue([]), + }, + }; +}); + // Mock currency vi.mock('@/lib/currency', () => ({ formatPrice: (price: string) => { diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx index d54c337..6646ac6 100644 --- a/apps/web/components/listings/listing-detail-client.tsx +++ b/apps/web/components/listings/listing-detail-client.tsx @@ -12,22 +12,25 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { AiEstimateButton } from '@/components/valuation/ai-estimate-button'; +import { analyticsApi } from '@/lib/analytics-api'; +import type { NearbyPOI } from '@/lib/analytics-api'; import { formatPrice, formatPricePerM2 } from '@/lib/currency'; import type { ListingDetail, NeighborhoodScoreResult, PriceHistoryItem } from '@/lib/listings-api'; import { listingsApi } from '@/lib/listings-api'; import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings'; +import type { POIItem } from '@/components/neighborhood'; const NeighborhoodRadarChart = dynamic( () => import('@/components/neighborhood').then((m) => m.NeighborhoodRadarChart), { ssr: false }, ); -const ListingMap = dynamic( - () => import('@/components/map/listing-map').then((mod) => mod.ListingMap), +const NeighborhoodPOIMap = dynamic( + () => import('@/components/neighborhood').then((m) => m.NeighborhoodPOIMap), { ssr: false, loading: () => ( -
+

{'\u0110ang t\u1ea3i b\u1ea3n \u0111\u1ed3...'}

), @@ -61,6 +64,7 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { const [inquiryOpen, setInquiryOpen] = React.useState(false); const [neighborhoodScore, setNeighborhoodScore] = React.useState(null); const [priceHistory, setPriceHistory] = React.useState([]); + const [nearbyPois, setNearbyPois] = React.useState([]); React.useEffect(() => { if (!property.district || !property.city) return; @@ -70,6 +74,25 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { .catch(() => {/* silently ignore — section simply won't render */}); }, [property.district, property.city]); + React.useEffect(() => { + const { latitude, longitude } = property; + if (latitude == null || longitude == null) return; + analyticsApi + .getNearbyPOIs(latitude, longitude) + .then((res) => { + const mapped: POIItem[] = res.pois.map((p: NearbyPOI) => ({ + id: p.id, + name: p.name, + category: p.category, + lat: p.lat, + lng: p.lng, + distance: p.distance, + })); + setNearbyPois(mapped); + }) + .catch(() => {/* silently ignore — map still renders without POIs */}); + }, [property.latitude, property.longitude]); + React.useEffect(() => { listingsApi .getPriceHistory(listing.id) @@ -245,10 +268,24 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { Vị trí trên bản đồ - + {property.latitude != null && property.longitude != null ? ( + <> + +

+ Tìm thấy {nearbyPois.length} điểm quan tâm trong bán kính 2 km +

+ + ) : ( +
+

+ Chưa có tọa độ cho tin đăng này +

+
+ )}
diff --git a/apps/web/lib/analytics-api.ts b/apps/web/lib/analytics-api.ts index 2eff3e7..223f9c8 100644 --- a/apps/web/lib/analytics-api.ts +++ b/apps/web/lib/analytics-api.ts @@ -67,6 +67,30 @@ export interface DistrictStatsResponse { districts: DistrictStats[]; } +export type NearbyPOICategory = + | 'school' + | 'hospital' + | 'transit' + | 'shopping' + | 'restaurant' + | 'park'; + +export interface NearbyPOI { + id: string; + name: string; + type: string; + category: NearbyPOICategory; + lat: number; + lng: number; + distance: number; + address: string | null; +} + +export interface NearbyPOIsResponse { + pois: NearbyPOI[]; + center: { lat: number; lng: number }; +} + export const analyticsApi = { getMarketReport: (city: string, period: string, propertyType?: string) => { const params = new URLSearchParams({ city, period }); @@ -88,4 +112,9 @@ export const analyticsApi = { const params = new URLSearchParams({ city, period }); return apiClient.get(`/analytics/district-stats?${params}`); }, + + getNearbyPOIs: (lat: number, lng: number, radius = 2000, limit = 30) => + apiClient.get( + `/analytics/pois/nearby?lat=${lat}&lng=${lng}&radius=${radius}&limit=${limit}`, + ), };