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;
|
||||
}
|
||||
Reference in New Issue
Block a user