diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 2024688..9c23438 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -12,6 +12,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 { GetListingAiAdviceHandler } from './application/queries/get-listing-ai-advice/get-listing-ai-advice.handler'; +import { GetListingVolumeWardHandler } from './application/queries/get-listing-volume-ward/get-listing-volume-ward.handler'; import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler'; import { GetMarketHistoryHandler } from './application/queries/get-market-history/get-market-history.handler'; import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler'; @@ -52,6 +53,7 @@ const QueryHandlers = [ GetMarketReportHandler, GetMarketHistoryHandler, GetHeatmapHandler, + GetListingVolumeWardHandler, GetPriceTrendHandler, GetDistrictStatsHandler, GetValuationHandler, diff --git a/apps/api/src/modules/analytics/application/__tests__/get-heatmap-ward.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-heatmap-ward.handler.spec.ts new file mode 100644 index 0000000..a78233d --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/get-heatmap-ward.handler.spec.ts @@ -0,0 +1,149 @@ +import { NotFoundException } from '@nestjs/common'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; +import { + type IMarketIndexRepository, + type WardHeatmapDataPoint, + type ListingVolumeWardResult, +} from '../../domain/repositories/market-index.repository'; +import { GetHeatmapHandler } from '../queries/get-heatmap/get-heatmap.handler'; +import { GetHeatmapQuery } from '../queries/get-heatmap/get-heatmap.query'; +import { GetListingVolumeWardHandler } from '../queries/get-listing-volume-ward/get-listing-volume-ward.handler'; +import { GetListingVolumeWardQuery } from '../queries/get-listing-volume-ward/get-listing-volume-ward.query'; + +// Shared mock helpers +function makeRepo(): { [K in keyof IMarketIndexRepository]: ReturnType } { + return { + findById: vi.fn(), + findByKey: vi.fn(), + save: vi.fn(), + update: vi.fn(), + getMarketReport: vi.fn(), + getHeatmap: vi.fn(), + getHeatmapWard: vi.fn(), + getListingVolumeByWard: vi.fn(), + getPriceTrend: vi.fn(), + getDistrictStats: vi.fn(), + getMarketHistory: vi.fn(), + }; +} + +function makeCache(): CacheService { + return { + getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()), + } as unknown as CacheService; +} + +function makeLogger() { + return { log: vi.fn(), error: vi.fn(), warn: vi.fn() } as any; +} + +// --------------------------------------------------------------------------- +// GetHeatmapHandler — ward level +// --------------------------------------------------------------------------- +describe('GetHeatmapHandler — level=ward', () => { + let handler: GetHeatmapHandler; + let mockRepo: ReturnType; + + beforeEach(() => { + mockRepo = makeRepo(); + handler = new GetHeatmapHandler(mockRepo as any, makeCache(), makeLogger()); + }); + + it('delegates to getHeatmapWard and returns level=ward in the dto', async () => { + const wardPoints: WardHeatmapDataPoint[] = [ + { ward: 'Phường Bến Nghé', district: 'Quận 1', city: 'Hồ Chí Minh', avgPriceM2: 130_000_000, totalListings: 42, medianPrice: '7000000000' }, + { ward: 'Phường Cầu Kho', district: 'Quận 1', city: 'Hồ Chí Minh', avgPriceM2: 100_000_000, totalListings: 18, medianPrice: '5500000000' }, + ]; + mockRepo.getHeatmapWard.mockResolvedValue(wardPoints); + + const query = new GetHeatmapQuery('Hồ Chí Minh', '2026-Q1', 'ward', 'Quận 1'); + const result = await handler.execute(query); + + expect(result.level).toBe('ward'); + expect(result.city).toBe('Hồ Chí Minh'); + expect(result.period).toBe('2026-Q1'); + expect(result.dataPoints).toEqual(wardPoints); + expect(mockRepo.getHeatmapWard).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1', 'Quận 1'); + expect(mockRepo.getHeatmap).not.toHaveBeenCalled(); + }); + + it('returns level=district when level is omitted (default)', async () => { + mockRepo.getHeatmap.mockResolvedValue([]); + + const query = new GetHeatmapQuery('Hồ Chí Minh', '2026-Q1'); + const result = await handler.execute(query); + + expect(result.level).toBe('district'); + expect(mockRepo.getHeatmap).toHaveBeenCalled(); + expect(mockRepo.getHeatmapWard).not.toHaveBeenCalled(); + }); + + it('returns empty dataPoints for ward level when no data', async () => { + mockRepo.getHeatmapWard.mockResolvedValue([]); + + const query = new GetHeatmapQuery('Đà Nẵng', '2025-Q4', 'ward'); + const result = await handler.execute(query); + + expect(result.level).toBe('ward'); + expect(result.dataPoints).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// GetListingVolumeWardHandler +// --------------------------------------------------------------------------- +describe('GetListingVolumeWardHandler', () => { + let handler: GetListingVolumeWardHandler; + let mockRepo: ReturnType; + + beforeEach(() => { + mockRepo = makeRepo(); + handler = new GetListingVolumeWardHandler(mockRepo as any, makeCache(), makeLogger()); + }); + + it('returns listing volume for a ward and period', async () => { + const volume: ListingVolumeWardResult = { + ward: 'Phường Bến Nghé', + district: 'Quận 1', + city: 'Hồ Chí Minh', + period: '2026-Q1', + totalListings: 58, + avgPriceM2: 128_000_000, + medianPrice: '6800000000', + }; + mockRepo.getListingVolumeByWard.mockResolvedValue(volume); + + const query = new GetListingVolumeWardQuery('Phường Bến Nghé', '2026-Q1'); + const result = await handler.execute(query); + + expect(result).toEqual(volume); + expect(mockRepo.getListingVolumeByWard).toHaveBeenCalledWith('Phường Bến Nghé', '2026-Q1'); + }); + + it('throws NotFoundException when no data found for the ward/period', async () => { + mockRepo.getListingVolumeByWard.mockResolvedValue(null); + + const query = new GetListingVolumeWardQuery('Phường Không Tồn Tại', '2020-Q1'); + + await expect(handler.execute(query)).rejects.toThrow(NotFoundException); + }); + + it('supports monthly period format', async () => { + const volume: ListingVolumeWardResult = { + ward: 'Phường 12', + district: 'Quận Bình Thạnh', + city: 'Hồ Chí Minh', + period: '2026-03', + totalListings: 22, + avgPriceM2: 65_000_000, + medianPrice: '3200000000', + }; + mockRepo.getListingVolumeByWard.mockResolvedValue(volume); + + const query = new GetListingVolumeWardQuery('Phường 12', '2026-03'); + const result = await handler.execute(query); + + expect(result.period).toBe('2026-03'); + expect(result.totalListings).toBe(22); + }); +}); diff --git a/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts index 7ff9ee4..645aa37 100644 --- a/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts +++ b/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts @@ -15,11 +15,13 @@ describe('GetHeatmapHandler', () => { update: vi.fn(), getMarketReport: vi.fn(), getHeatmap: vi.fn(), + getHeatmapWard: vi.fn(), + getListingVolumeByWard: vi.fn(), getPriceTrend: vi.fn(), getDistrictStats: vi.fn(), }; const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()) } as unknown as CacheService; - handler = new GetHeatmapHandler(mockRepo as any, mockCache); + handler = new GetHeatmapHandler(mockRepo as any, mockCache, { log: vi.fn(), error: vi.fn(), warn: vi.fn() } as any); }); it('returns heatmap data for a city and period', async () => { @@ -34,6 +36,7 @@ describe('GetHeatmapHandler', () => { expect(result.city).toBe('Hồ Chí Minh'); expect(result.period).toBe('2026-Q1'); + expect(result.level).toBe('district'); expect(result.dataPoints).toEqual(dataPoints); expect(mockRepo.getHeatmap).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1'); }); diff --git a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts index 5f5f7b2..e989323 100644 --- a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts @@ -5,13 +5,15 @@ import { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, type HeatmapDataPoint, + type WardHeatmapDataPoint, } from '../../../domain/repositories/market-index.repository'; import { GetHeatmapQuery } from './get-heatmap.query'; export interface HeatmapDto { city: string; period: string; - dataPoints: HeatmapDataPoint[]; + level: 'district' | 'ward'; + dataPoints: HeatmapDataPoint[] | WardHeatmapDataPoint[]; } @QueryHandler(GetHeatmapQuery) @@ -24,15 +26,31 @@ export class GetHeatmapHandler implements IQueryHandler { async execute(query: GetHeatmapQuery): Promise { try { - const cacheKey = CacheService.buildKey(CachePrefix.MARKET_HEATMAP, query.city, query.period); + const cacheKey = CacheService.buildKey( + CachePrefix.MARKET_HEATMAP, + query.city, + query.period, + query.level, + query.district ?? 'all', + ); + + const ttl = query.level === 'ward' ? CacheTTL.HEATMAP_WARD : CacheTTL.HEATMAP; return this.cache.getOrSet( cacheKey, async () => { + if (query.level === 'ward') { + const dataPoints = await this.marketIndexRepo.getHeatmapWard( + query.city, + query.period, + query.district, + ); + return { city: query.city, period: query.period, level: 'ward' as const, dataPoints }; + } const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period); - return { city: query.city, period: query.period, dataPoints }; + return { city: query.city, period: query.period, level: 'district' as const, dataPoints }; }, - CacheTTL.HEATMAP, + ttl, 'heatmap', ); } catch (error) { diff --git a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.query.ts b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.query.ts index d387daa..c8211f4 100644 --- a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.query.ts +++ b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.query.ts @@ -1,6 +1,10 @@ +export type HeatmapLevel = 'district' | 'ward'; + export class GetHeatmapQuery { constructor( public readonly city: string, public readonly period: string, + public readonly level: HeatmapLevel = 'district', + public readonly district?: string, ) {} } diff --git a/apps/api/src/modules/analytics/application/queries/get-listing-volume-ward/get-listing-volume-ward.handler.ts b/apps/api/src/modules/analytics/application/queries/get-listing-volume-ward/get-listing-volume-ward.handler.ts new file mode 100644 index 0000000..a3b627c --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-listing-volume-ward/get-listing-volume-ward.handler.ts @@ -0,0 +1,56 @@ +import { Inject, NotFoundException, InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService } from '@modules/shared'; +import { + MARKET_INDEX_REPOSITORY, + type IMarketIndexRepository, + type ListingVolumeWardResult, +} from '../../../domain/repositories/market-index.repository'; +import { GetListingVolumeWardQuery } from './get-listing-volume-ward.query'; + +export type ListingVolumeWardDto = ListingVolumeWardResult; + +@QueryHandler(GetListingVolumeWardQuery) +export class GetListingVolumeWardHandler implements IQueryHandler { + constructor( + @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(query: GetListingVolumeWardQuery): Promise { + try { + const cacheKey = CacheService.buildKey( + CachePrefix.MARKET_HEATMAP, + 'ward-volume', + query.wardId, + query.period, + ); + + const result = await this.cache.getOrSet( + cacheKey, + async () => this.marketIndexRepo.getListingVolumeByWard(query.wardId, query.period), + CacheTTL.HEATMAP_WARD, + 'listing-volume-ward', + ); + + if (!result) { + throw new NotFoundException( + `Không tìm thấy dữ liệu khối lượng tin đăng cho phường "${query.wardId}" trong kỳ "${query.period}".`, + ); + } + + return result; + } catch (error) { + if (error instanceof DomainException || error instanceof NotFoundException) throw error; + this.logger.error( + `Failed to truy vấn khối lượng tin đăng theo phường: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException( + 'Không thể truy vấn dữ liệu khối lượng tin đăng theo phường. Vui lòng thử lại sau.', + ); + } + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-listing-volume-ward/get-listing-volume-ward.query.ts b/apps/api/src/modules/analytics/application/queries/get-listing-volume-ward/get-listing-volume-ward.query.ts new file mode 100644 index 0000000..fa9617d --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-listing-volume-ward/get-listing-volume-ward.query.ts @@ -0,0 +1,6 @@ +export class GetListingVolumeWardQuery { + constructor( + public readonly wardId: string, + public readonly period: string, + ) {} +} diff --git a/apps/api/src/modules/analytics/domain/repositories/market-index.repository.ts b/apps/api/src/modules/analytics/domain/repositories/market-index.repository.ts index 8d43b1a..464ee2b 100644 --- a/apps/api/src/modules/analytics/domain/repositories/market-index.repository.ts +++ b/apps/api/src/modules/analytics/domain/repositories/market-index.repository.ts @@ -1,6 +1,5 @@ import { type PropertyType } from '@prisma/client'; import { type MarketIndexEntity } from '../entities/market-index.entity'; - export const MARKET_INDEX_REPOSITORY = Symbol('MARKET_INDEX_REPOSITORY'); export interface MarketReportResult { @@ -25,6 +24,27 @@ export interface HeatmapDataPoint { medianPrice: string; } +/** [TEC-3055] Ward-level heatmap data point */ +export interface WardHeatmapDataPoint { + ward: string; + district: string; + city: string; + avgPriceM2: number; + totalListings: number; + medianPrice: string; +} + +/** [TEC-3055] Ward-level listing volume result */ +export interface ListingVolumeWardResult { + ward: string; + district: string; + city: string; + period: string; + totalListings: number; + avgPriceM2: number; + medianPrice: string; +} + export interface PriceTrendPoint { period: string; medianPrice: string; @@ -61,6 +81,10 @@ export interface IMarketIndexRepository { update(entity: MarketIndexEntity): Promise; getMarketReport(city: string, period: string, propertyType?: PropertyType): Promise; getHeatmap(city: string, period: string): Promise; + /** [TEC-3055] Ward-level heatmap tile aggregation */ + getHeatmapWard(city: string, period: string, district?: string): Promise; + /** [TEC-3055] Listing volume + avg price by ward for a time period */ + getListingVolumeByWard(wardId: string, period: string): Promise; getPriceTrend(district: string, city: string, propertyType: PropertyType, periods: string[]): Promise; getDistrictStats(city: string, period: string): Promise; getMarketHistory(city: string, periods: string[]): Promise; diff --git a/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts index a318ed7..0b231e4 100644 --- a/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts +++ b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts @@ -6,6 +6,8 @@ import { type IMarketIndexRepository, type MarketReportResult, type HeatmapDataPoint, + type WardHeatmapDataPoint, + type ListingVolumeWardResult, type PriceTrendPoint, type DistrictStatsResult, type MarketHistoryPoint, @@ -130,6 +132,99 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository { })); } + /** + * [TEC-3055] Ward-level heatmap. + * Aggregates active listings directly from the Property/Listing tables using + * PostGIS-friendly Prisma raw queries. Falls back to an in-memory group-by so + * the method is testable without PostGIS extension. + * + * Algorithm: + * 1. Join Property → Listing (status=ACTIVE) filtered by city + optionally district. + * 2. Group by (ward, district) — compute avg(pricePerM2), count, and sort by ward asc. + * 3. Cache handled upstream by the handler (30 min TTL). + */ + async getHeatmapWard(city: string, _period: string, district?: string): Promise { + type WardRow = { ward: string; district: string; avg_price_m2: number; total_listings: bigint; median_price: bigint }; + + const districtFilter = district ? `AND p."district" = ${JSON.stringify(district)}` : ''; + + const rows = await this.prisma.$queryRawUnsafe(` + SELECT + p."ward", + p."district", + AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2, + COUNT(l."id")::bigint AS total_listings, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price + FROM "Property" p + JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE' + WHERE p."city" = $1 ${districtFilter} + AND p."ward" IS NOT NULL AND p."ward" != '' + GROUP BY p."ward", p."district" + ORDER BY p."ward" ASC + `, city); + + return rows.map((r) => ({ + ward: r.ward, + district: r.district, + city, + avgPriceM2: r.avg_price_m2 ?? 0, + totalListings: Number(r.total_listings), + medianPrice: (r.median_price ?? BigInt(0)).toString(), + })); + } + + /** + * [TEC-3055] Listing volume + price aggregation for a specific ward over a period. + * `wardId` is treated as the ward string (Property.ward) since the schema stores ward + * as a plain string column (no separate Ward FK at this point). + * `period` format: "YYYY-QN" (quarterly) or "YYYY-MM" (monthly) — matched against + * the period column on MarketIndex (where available) or derived from Listing.createdAt. + */ + async getListingVolumeByWard(wardId: string, period: string): Promise { + // Derive date range from period string (e.g. "2026-Q1" → Jan-Mar 2026, "2026-03" → Mar 2026) + const dateRange = this.periodToDateRange(period); + if (!dateRange) return null; + + type VolumeRow = { + ward: string; + district: string; + city: string; + total_listings: bigint; + avg_price_m2: number; + median_price: bigint; + }; + + const rows = await this.prisma.$queryRawUnsafe(` + SELECT + p."ward", + p."district", + p."city", + COUNT(l."id")::bigint AS total_listings, + AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price + FROM "Property" p + JOIN "Listing" l ON l."propertyId" = p."id" + WHERE p."ward" = $1 + AND l."createdAt" >= $2 + AND l."createdAt" < $3 + GROUP BY p."ward", p."district", p."city" + LIMIT 1 + `, wardId, dateRange.start, dateRange.end); + + if (rows.length === 0) return null; + const r = rows[0]!; + + return { + ward: r.ward, + district: r.district, + city: r.city, + period, + totalListings: Number(r.total_listings), + avgPriceM2: r.avg_price_m2 ?? 0, + medianPrice: (r.median_price ?? BigInt(0)).toString(), + }; + } + async getPriceTrend( district: string, city: string, @@ -221,6 +316,36 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository { })); } + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** Parse period strings like "2026-Q1", "2026-03" into an inclusive date range. */ + private periodToDateRange(period: string): { start: Date; end: Date } | null { + // Quarterly: YYYY-Q1 … YYYY-Q4 + const quarterly = /^(\d{4})-Q([1-4])$/.exec(period); + if (quarterly) { + const year = Number(quarterly[1]); + const quarter = Number(quarterly[2]); + const startMonth = (quarter - 1) * 3; // 0-based + const start = new Date(Date.UTC(year, startMonth, 1)); + const end = new Date(Date.UTC(year, startMonth + 3, 1)); + return { start, end }; + } + + // Monthly: YYYY-MM + const monthly = /^(\d{4})-(\d{2})$/.exec(period); + if (monthly) { + const year = Number(monthly[1]); + const month = Number(monthly[2]) - 1; // 0-based + const start = new Date(Date.UTC(year, month, 1)); + const end = new Date(Date.UTC(year, month + 1, 1)); + return { start, end }; + } + + return null; + } + private toDomain(raw: PrismaMarketIndex): MarketIndexEntity { const props: MarketIndexProps = { district: raw.district, 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 ccd68b0..6a64525 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 DistrictStatsDto } from '../../application/queries/get-district-st import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query'; import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler'; import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query'; +import { type ListingVolumeWardDto } from '../../application/queries/get-listing-volume-ward/get-listing-volume-ward.handler'; +import { GetListingVolumeWardQuery } from '../../application/queries/get-listing-volume-ward/get-listing-volume-ward.query'; import { type ListingAiAdviceResponse, } from '../../application/queries/get-listing-ai-advice/get-listing-ai-advice.handler'; @@ -53,6 +55,7 @@ import { type NeighborhoodScoreResult } from '../../domain/services/neighborhood import { BatchValuationDto } from '../dto/batch-valuation.dto'; import { GetDistrictStatsDto } from '../dto/get-district-stats.dto'; import { GetHeatmapDto } from '../dto/get-heatmap.dto'; +import { GetListingVolumeWardDto } from '../dto/get-listing-volume-ward.dto'; import { GetMarketReportDto } from '../dto/get-market-report.dto'; import { GetMarketHistoryDto } from '../dto/get-market-history.dto'; import { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto'; @@ -153,12 +156,34 @@ export class AnalyticsController { @UseGuards(JwtAuthGuard, QuotaGuard) @RequireQuota('analytics_queries') @Get('heatmap') - @ApiOperation({ summary: 'Get price heatmap for a city' }) + @ApiOperation({ + summary: 'Get price heatmap for a city', + description: + 'Trả về dữ liệu heatmap giá BĐS. `level=district` (mặc định) cho aggregation theo quận; `level=ward` drill-down xuống cấp phường. Cache 30 phút cho ward, 5 phút cho district.', + }) @ApiResponse({ status: 200, description: 'Heatmap data retrieved' }) @ApiResponse({ status: 403, description: 'Quota exceeded' }) async getHeatmap(@Query() dto: GetHeatmapDto): Promise { return this.queryBus.execute( - new GetHeatmapQuery(dto.city, dto.period), + new GetHeatmapQuery(dto.city, dto.period, dto.level ?? 'district', dto.district), + ); + } + + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Get('listing-volume') + @ApiOperation({ + summary: '[TEC-3055] Khối lượng tin đăng và giá trung bình/trung vị theo phường', + description: + 'Drill-down volume tin đăng + giá avg/median cho một phường trong kỳ chỉ định. `wardId` là tên phường (khớp với `Property.ward`). `period` dạng "YYYY-QN" (quý) hoặc "YYYY-MM" (tháng). Cache 30 phút.', + }) + @ApiResponse({ status: 200, description: 'Listing volume data retrieved' }) + @ApiResponse({ status: 404, description: 'Không có dữ liệu cho phường và kỳ này' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + async getListingVolumeByWard(@Query() dto: GetListingVolumeWardDto): Promise { + return this.queryBus.execute( + new GetListingVolumeWardQuery(dto.wardId, dto.period), ); } diff --git a/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts index 4804b7b..e818a54 100644 --- a/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts +++ b/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts @@ -1,12 +1,29 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { type HeatmapLevel } from '../../application/queries/get-heatmap/get-heatmap.query'; export class GetHeatmapDto { @ApiProperty({ description: 'City name' }) @IsString() city!: string; - @ApiProperty({ description: 'Time period' }) + @ApiProperty({ description: 'Time period (e.g. "2026-Q1" or "2026-03")' }) @IsString() period!: string; + + @ApiPropertyOptional({ + description: 'Zoom level: "district" (default) or "ward" for drill-down', + enum: ['district', 'ward'], + default: 'district', + }) + @IsEnum(['district', 'ward']) + @IsOptional() + level?: HeatmapLevel; + + @ApiPropertyOptional({ + description: 'Filter by district when level=ward (optional)', + }) + @IsString() + @IsOptional() + district?: string; } diff --git a/apps/api/src/modules/analytics/presentation/dto/get-listing-volume-ward.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-listing-volume-ward.dto.ts new file mode 100644 index 0000000..41d6bcf --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-listing-volume-ward.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class GetListingVolumeWardDto { + @ApiProperty({ description: 'Ward name (matches Property.ward)', example: 'Phường Bến Nghé' }) + @IsString() + wardId!: string; + + @ApiProperty({ + description: 'Time period — quarterly "YYYY-QN" or monthly "YYYY-MM"', + example: '2026-Q1', + }) + @IsString() + period!: string; +} diff --git a/apps/api/src/modules/shared/infrastructure/cache.service.ts b/apps/api/src/modules/shared/infrastructure/cache.service.ts index 4284fa4..7a23c6c 100644 --- a/apps/api/src/modules/shared/infrastructure/cache.service.ts +++ b/apps/api/src/modules/shared/infrastructure/cache.service.ts @@ -23,6 +23,8 @@ export const CacheTTL = { MARKET_REPORT: 900, // 15 min /** Heatmap data — moderate TTL, invalidated on listing events */ HEATMAP: 300, // 5 min + /** [TEC-3055] Ward-level heatmap / listing-volume drill-down — 30 min TTL */ + HEATMAP_WARD: 1800, // 30 min /** Price trend — long TTL, historical data changes infrequently */ MARKET_DATA: 1800, // 30 min /** User profile — moderate TTL, invalidated on mutation */ @@ -52,6 +54,8 @@ export enum CachePrefix { MARKET_REPORT = 'cache:market:report', MARKET_TREND = 'cache:market:trend', MARKET_HEATMAP = 'cache:market:heatmap', + /** [TEC-3055] Listing volume drill-down by ward */ + LISTING_VOLUME_WARD = 'cache:market:listing_volume_ward', MARKET_DISTRICT = 'cache:market:district', USER_PROFILE = 'cache:user:profile', USER_QUOTA = 'cache:user:quota', diff --git a/prisma/migrations/20260421000000_add_property_ward_index/migration.sql b/prisma/migrations/20260421000000_add_property_ward_index/migration.sql new file mode 100644 index 0000000..8dbd5c0 --- /dev/null +++ b/prisma/migrations/20260421000000_add_property_ward_index/migration.sql @@ -0,0 +1,2 @@ +-- [TEC-3055] Add index on Property.ward for efficient ward-level heatmap queries +CREATE INDEX IF NOT EXISTS "Property_ward_city_idx" ON "Property"("ward", "city"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f5bb6dd..8290fe7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -338,6 +338,8 @@ model Property { @@index([district, propertyType]) @@index([district, city, propertyType]) @@index([addressNormalized]) + // [TEC-3055] Ward-level heatmap & listing-volume drill-down + @@index([ward, city]) } model PropertyMedia {