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;
}

View File

@@ -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<typeof import('@/lib/listings-api')>('@/lib/listings-api');
return {
...actual,
listingsApi: {
getNeighborhoodScore: vi.fn().mockResolvedValue(null),
getPriceHistory: vi.fn().mockResolvedValue([]),
},
};
});
// Mock currency
vi.mock('@/lib/currency', () => ({
formatPrice: (price: string) => {

View File

@@ -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: () => (
<div className="flex h-[300px] items-center justify-center rounded-lg bg-muted">
<div className="flex h-[400px] items-center justify-center rounded-lg bg-muted">
<p className="text-sm text-muted-foreground">{'\u0110ang t\u1ea3i b\u1ea3n \u0111\u1ed3...'}</p>
</div>
),
@@ -61,6 +64,7 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
const [inquiryOpen, setInquiryOpen] = React.useState(false);
const [neighborhoodScore, setNeighborhoodScore] = React.useState<NeighborhoodScoreResult | null>(null);
const [priceHistory, setPriceHistory] = React.useState<PriceHistoryItem[]>([]);
const [nearbyPois, setNearbyPois] = React.useState<POIItem[]>([]);
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) {
<CardTitle>Vị trí trên bản đ</CardTitle>
</CardHeader>
<CardContent>
<ListingMap
listings={[listing]}
className="h-[300px]"
/>
{property.latitude != null && property.longitude != null ? (
<>
<NeighborhoodPOIMap
center={{ lat: property.latitude, lng: property.longitude }}
pois={nearbyPois}
height="400px"
/>
<p className="mt-3 text-sm text-muted-foreground">
Tìm thấy {nearbyPois.length} điểm quan tâm trong bán kính 2 km
</p>
</>
) : (
<div className="flex h-[300px] items-center justify-center rounded-lg bg-muted">
<p className="text-sm text-muted-foreground">
Chưa tọa đ cho tin đăng này
</p>
</div>
)}
</CardContent>
</Card>

View File

@@ -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<DistrictStatsResponse>(`/analytics/district-stats?${params}`);
},
getNearbyPOIs: (lat: number, lng: number, radius = 2000, limit = 30) =>
apiClient.get<NearbyPOIsResponse>(
`/analytics/pois/nearby?lat=${lat}&lng=${lng}&radius=${radius}&limit=${limit}`,
),
};