From 30d3039b94316d9f22cc6f75e397275eaf29de36 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 05:21:28 +0700 Subject: [PATCH] feat(analytics): add NeighborhoodScoreService with POI-based scoring and API endpoint - Create INeighborhoodScoreService interface and implementation - Score districts 0-100 across 6 categories: education, healthcare, transport, shopping, greenery, safety - Calculate scores from POI data with configurable weights and max counts - Add GetNeighborhoodScoreQuery handler with lazy calculation - Add GET /analytics/neighborhoods/:district/score endpoint - Wire service and handler into AnalyticsModule Co-Authored-By: Paperclip --- .../src/modules/analytics/analytics.module.ts | 7 + .../get-neighborhood-score.handler.ts | 24 +++ .../get-neighborhood-score.query.ts | 6 + .../services/neighborhood-score.service.ts | 20 +++ .../services/neighborhood-score.service.ts | 142 ++++++++++++++++++ .../controllers/analytics.controller.ts | 15 ++ 6 files changed, 214 insertions(+) create mode 100644 apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.handler.ts create mode 100644 apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.query.ts create mode 100644 apps/api/src/modules/analytics/domain/services/neighborhood-score.service.ts create mode 100644 apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 864829a..ca1a246 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; +import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler'; import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler'; import { TrackEventHandler } from './application/commands/track-event/track-event.handler'; import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler'; @@ -20,6 +21,8 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma- import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client'; import { HttpAVMService } from './infrastructure/services/http-avm.service'; import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service'; +import { NEIGHBORHOOD_SCORE_SERVICE } from './domain/services/neighborhood-score.service'; +import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service'; import { PrismaAVMService } from './infrastructure/services/prisma-avm.service'; import { AnalyticsController } from './presentation/controllers/analytics.controller'; @@ -38,6 +41,7 @@ const QueryHandlers = [ BatchValuationHandler, ValuationHistoryHandler, ValuationComparisonHandler, + GetNeighborhoodScoreHandler, ]; const EventHandlers = [ @@ -59,6 +63,9 @@ const EventHandlers = [ PrismaAVMService, { provide: AVM_SERVICE, useClass: HttpAVMService }, + // Neighborhood scoring + { provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl }, + // Cron MarketIndexCronService, diff --git a/apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.handler.ts b/apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.handler.ts new file mode 100644 index 0000000..3db2811 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.handler.ts @@ -0,0 +1,24 @@ +import { Inject } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { + NEIGHBORHOOD_SCORE_SERVICE, + type INeighborhoodScoreService, + type NeighborhoodScoreResult, +} from '../../../domain/services/neighborhood-score.service'; +import { GetNeighborhoodScoreQuery } from './get-neighborhood-score.query'; + +@QueryHandler(GetNeighborhoodScoreQuery) +export class GetNeighborhoodScoreHandler implements IQueryHandler { + constructor( + @Inject(NEIGHBORHOOD_SCORE_SERVICE) + private readonly scoreService: INeighborhoodScoreService, + ) {} + + async execute(query: GetNeighborhoodScoreQuery): Promise { + // Return cached score if available, otherwise calculate + const existing = await this.scoreService.getScore(query.district, query.city); + if (existing) return existing; + + return this.scoreService.calculateAndSave(query.district, query.city); + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.query.ts b/apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.query.ts new file mode 100644 index 0000000..86fce59 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.query.ts @@ -0,0 +1,6 @@ +export class GetNeighborhoodScoreQuery { + constructor( + public readonly district: string, + public readonly city: string, + ) {} +} diff --git a/apps/api/src/modules/analytics/domain/services/neighborhood-score.service.ts b/apps/api/src/modules/analytics/domain/services/neighborhood-score.service.ts new file mode 100644 index 0000000..0b40625 --- /dev/null +++ b/apps/api/src/modules/analytics/domain/services/neighborhood-score.service.ts @@ -0,0 +1,20 @@ +export const NEIGHBORHOOD_SCORE_SERVICE = Symbol('NEIGHBORHOOD_SCORE_SERVICE'); + +export interface NeighborhoodScoreResult { + district: string; + city: string; + educationScore: number; + healthcareScore: number; + transportScore: number; + shoppingScore: number; + greeneryScore: number; + safetyScore: number; + totalScore: number; + poiCounts: Record; + calculatedAt: Date; +} + +export interface INeighborhoodScoreService { + getScore(district: string, city: string): Promise; + calculateAndSave(district: string, city: string): Promise; +} diff --git a/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts b/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts new file mode 100644 index 0000000..fe64db1 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts @@ -0,0 +1,142 @@ +import { Injectable } from '@nestjs/common'; +import { type PrismaService, type LoggerService } from '@modules/shared'; +import { + type INeighborhoodScoreService, + type NeighborhoodScoreResult, +} from '../../domain/services/neighborhood-score.service'; + +/** + * Scoring weights for each POI category. + * Sum = 100 (total score is 0–100 weighted average). + */ +const CATEGORY_WEIGHTS = { + education: 20, + healthcare: 20, + transport: 20, + shopping: 15, + greenery: 15, + safety: 10, +}; + +/** POI types grouped by scoring category. */ +const CATEGORY_POI_TYPES: Record = { + education: ['SCHOOL', 'UNIVERSITY'], + healthcare: ['HOSPITAL', 'CLINIC'], + transport: ['METRO_STATION', 'BUS_STOP'], + shopping: ['MALL', 'MARKET', 'SUPERMARKET'], + greenery: ['PARK'], + safety: ['POLICE_STATION', 'FIRE_STATION'], +}; + +/** Max count per category that yields a 10/10 score. */ +const MAX_COUNTS: Record = { + education: 15, + healthcare: 8, + transport: 12, + shopping: 10, + greenery: 6, + safety: 4, +}; + +@Injectable() +export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async getScore(district: string, city: string): Promise { + const existing = await this.prisma.neighborhoodScore.findUnique({ + where: { district_city: { district, city } }, + }); + + if (!existing) return null; + + return { + district: existing.district, + city: existing.city, + educationScore: existing.educationScore, + healthcareScore: existing.healthcareScore, + transportScore: existing.transportScore, + shoppingScore: existing.shoppingScore, + greeneryScore: existing.greeneryScore, + safetyScore: existing.safetyScore, + totalScore: existing.totalScore, + poiCounts: existing.poiCounts as Record, + calculatedAt: existing.calculatedAt, + }; + } + + async calculateAndSave(district: string, city: string): Promise { + // Count POIs per category for this district + const poiCounts: Record = {}; + const categoryScores: Record = {}; + + for (const [category, poiTypes] of Object.entries(CATEGORY_POI_TYPES)) { + const count = await this.prisma.pOI.count({ + where: { + district, + city, + type: { in: poiTypes as any }, + }, + }); + + poiCounts[category] = count; + // Score 0–10: linear scale capped at MAX_COUNTS + const maxCount = MAX_COUNTS[category]!; + categoryScores[category] = Math.min(10, (count / maxCount) * 10); + } + + // Weighted total score (0–100) + const totalScore = Object.entries(CATEGORY_WEIGHTS).reduce((sum, [cat, weight]) => { + return sum + (categoryScores[cat]! * weight) / 10; + }, 0); + + const result = await this.prisma.neighborhoodScore.upsert({ + where: { district_city: { district, city } }, + create: { + district, + city, + educationScore: categoryScores['education']!, + healthcareScore: categoryScores['healthcare']!, + transportScore: categoryScores['transport']!, + shoppingScore: categoryScores['shopping']!, + greeneryScore: categoryScores['greenery']!, + safetyScore: categoryScores['safety']!, + totalScore: Math.round(totalScore * 10) / 10, + poiCounts, + calculatedAt: new Date(), + }, + update: { + educationScore: categoryScores['education']!, + healthcareScore: categoryScores['healthcare']!, + transportScore: categoryScores['transport']!, + shoppingScore: categoryScores['shopping']!, + greeneryScore: categoryScores['greenery']!, + safetyScore: categoryScores['safety']!, + totalScore: Math.round(totalScore * 10) / 10, + poiCounts, + calculatedAt: new Date(), + }, + }); + + this.logger.log( + `Neighborhood score calculated: ${district}, ${city} → total=${result.totalScore}`, + 'NeighborhoodScoreService', + ); + + return { + district: result.district, + city: result.city, + educationScore: result.educationScore, + healthcareScore: result.healthcareScore, + transportScore: result.transportScore, + shoppingScore: result.shoppingScore, + greeneryScore: result.greeneryScore, + safetyScore: result.safetyScore, + totalScore: result.totalScore, + poiCounts: result.poiCounts as Record, + calculatedAt: result.calculatedAt, + }; + } +} 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 1d8a30d..9d479c8 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -26,6 +26,8 @@ import { type ValuationDto } from '../../application/queries/get-valuation/get-v import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query'; import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler'; import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query'; +import { type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service'; +import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query'; import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler'; import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query'; import { type BatchValuationDto } from '../dto/batch-valuation.dto'; @@ -158,4 +160,17 @@ export class AnalyticsController { new ValuationComparisonQuery(dto.propertyIds), ); } + + @ApiOperation({ summary: 'Get neighborhood score for a district' }) + @ApiParam({ name: 'district', description: 'District name', example: 'Quận 1' }) + @ApiResponse({ status: 200, description: 'Neighborhood score retrieved' }) + @Get('neighborhoods/:district/score') + async getNeighborhoodScore( + @Param('district') district: string, + @Query('city') city: string = 'Hồ Chí Minh', + ): Promise { + return this.queryBus.execute( + new GetNeighborhoodScoreQuery(district, city), + ); + } }