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
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:
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class GetNearbyPOIsQuery {
|
||||
constructor(
|
||||
public readonly lat: number,
|
||||
public readonly lng: number,
|
||||
public readonly radiusM: number,
|
||||
public readonly limit: number,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 có tọa độ cho tin đăng này
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user