From bcd8b6685a78a9598b6472f909be8cff4f9d5276 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 21 Apr 2026 02:06:57 +0700 Subject: [PATCH] feat(analytics): add GET /analytics/market-snapshot endpoint Dashboard tile endpoint returning activeCount, avgPrice, medianPrice, priceChangePct (1d/7d/30d), avgPricePerM2, daysOnMarket, newListings24h. Redis cache-aside with 5min TTL. CQRS query handler with parallel Prisma queries for p95 <200ms on cache hit. Refs: TEC-3049 Co-Authored-By: Paperclip --- .../src/modules/analytics/analytics.module.ts | 2 + .../get-market-snapshot.handler.spec.ts | 136 +++++++++++++ .../get-market-snapshot.handler.ts | 183 ++++++++++++++++++ .../get-market-snapshot.query.ts | 8 + .../controllers/analytics.controller.ts | 20 ++ .../dto/get-market-snapshot.dto.ts | 14 ++ .../shared/infrastructure/cache.service.ts | 3 + 7 files changed, 366 insertions(+) create mode 100644 apps/api/src/modules/analytics/application/__tests__/get-market-snapshot.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/queries/get-market-snapshot/get-market-snapshot.handler.ts create mode 100644 apps/api/src/modules/analytics/application/queries/get-market-snapshot/get-market-snapshot.query.ts create mode 100644 apps/api/src/modules/analytics/presentation/dto/get-market-snapshot.dto.ts diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 2142920..9dfb6eb 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -13,6 +13,7 @@ import { GetDistrictStatsHandler } from './application/queries/get-district-stat 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 { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler'; +import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler'; import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.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'; @@ -61,6 +62,7 @@ const QueryHandlers = [ IndustrialValuationHandler, GetListingAiAdviceHandler, GetProjectAiAdviceHandler, + GetMarketSnapshotHandler, ]; const EventHandlers = [ diff --git a/apps/api/src/modules/analytics/application/__tests__/get-market-snapshot.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-market-snapshot.handler.spec.ts new file mode 100644 index 0000000..511f34f --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/get-market-snapshot.handler.spec.ts @@ -0,0 +1,136 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; +import { type PrismaService } from '@modules/shared'; +import { GetMarketSnapshotHandler } from '../queries/get-market-snapshot/get-market-snapshot.handler'; +import { GetMarketSnapshotQuery } from '../queries/get-market-snapshot/get-market-snapshot.query'; + +describe('GetMarketSnapshotHandler', () => { + let handler: GetMarketSnapshotHandler; + let mockPrisma: Record; + let mockCache: { getOrSet: ReturnType }; + + beforeEach(() => { + mockPrisma = { + listing: { + aggregate: vi.fn(), + count: vi.fn(), + }, + $queryRaw: vi.fn(), + }; + mockCache = { + getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()), + }; + const mockLogger = { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }; + handler = new GetMarketSnapshotHandler( + mockPrisma as unknown as PrismaService, + mockCache as unknown as CacheService, + mockLogger as any, + ); + }); + + it('returns market snapshot with all fields', async () => { + mockPrisma.listing.aggregate.mockResolvedValue({ + _count: 12345, + _avg: { priceVND: 4500000000n, pricePerM2: 65000000 }, + }); + mockPrisma.$queryRaw + .mockResolvedValueOnce([{ median: 3800000000n }]) // median + .mockResolvedValueOnce([{ avg_days: 42.3 }]) // days on market + .mockResolvedValueOnce([{ avg_price: 4400000000 }]) // 1d ago avg + .mockResolvedValueOnce([{ avg_price: 4550000000 }]) // 7d ago avg + .mockResolvedValueOnce([{ avg_price: 4380000000 }]); // 30d ago avg + mockPrisma.listing.count.mockResolvedValue(178); + + const query = new GetMarketSnapshotQuery('HCMC', 'APARTMENT'); + const result = await handler.execute(query); + + expect(result.city).toBe('HCMC'); + expect(result.propertyType).toBe('APARTMENT'); + expect(result.activeCount).toBe(12345); + expect(result.avgPrice).toBe(4500000000); + expect(result.medianPrice).toBe(3800000000); + expect(result.avgPricePerM2).toBe(65000000); + expect(result.daysOnMarket).toBe(42); + expect(result.newListings24h).toBe(178); + expect(result.priceChangePct).toBeDefined(); + expect(typeof result.priceChangePct.d1).toBe('number'); + expect(typeof result.priceChangePct.d7).toBe('number'); + expect(typeof result.priceChangePct.d30).toBe('number'); + }); + + it('returns snapshot without propertyType filter', async () => { + mockPrisma.listing.aggregate.mockResolvedValue({ + _count: 500, + _avg: { priceVND: 3000000000n, pricePerM2: 50000000 }, + }); + mockPrisma.$queryRaw + .mockResolvedValueOnce([{ median: 2500000000n }]) + .mockResolvedValueOnce([{ avg_days: 30 }]) + .mockResolvedValueOnce([{ avg_price: 2900000000 }]) + .mockResolvedValueOnce([{ avg_price: 3100000000 }]) + .mockResolvedValueOnce([{ avg_price: 2800000000 }]); + mockPrisma.listing.count.mockResolvedValue(50); + + const query = new GetMarketSnapshotQuery('HCMC'); + const result = await handler.execute(query); + + expect(result.city).toBe('HCMC'); + expect(result.propertyType).toBeUndefined(); + expect(result.activeCount).toBe(500); + }); + + it('handles empty data gracefully', async () => { + mockPrisma.listing.aggregate.mockResolvedValue({ + _count: 0, + _avg: { priceVND: null, pricePerM2: null }, + }); + mockPrisma.$queryRaw + .mockResolvedValueOnce([{ median: null }]) + .mockResolvedValueOnce([{ avg_days: null }]) + .mockResolvedValueOnce([{ avg_price: null }]) + .mockResolvedValueOnce([{ avg_price: null }]) + .mockResolvedValueOnce([{ avg_price: null }]); + mockPrisma.listing.count.mockResolvedValue(0); + + const query = new GetMarketSnapshotQuery('Hà Nội'); + const result = await handler.execute(query); + + expect(result.activeCount).toBe(0); + expect(result.avgPrice).toBe(0); + expect(result.medianPrice).toBe(0); + expect(result.avgPricePerM2).toBe(0); + expect(result.daysOnMarket).toBe(0); + expect(result.newListings24h).toBe(0); + expect(result.priceChangePct).toEqual({ d1: 0, d7: 0, d30: 0 }); + }); + + it('uses cache with correct key', async () => { + mockPrisma.listing.aggregate.mockResolvedValue({ + _count: 1, + _avg: { priceVND: 1000000000n, pricePerM2: 50000000 }, + }); + mockPrisma.$queryRaw.mockResolvedValue([{ median: null, avg_days: null, avg_price: null }]); + mockPrisma.listing.count.mockResolvedValue(0); + + const query = new GetMarketSnapshotQuery('HCMC', 'APARTMENT'); + await handler.execute(query); + + expect(mockCache.getOrSet).toHaveBeenCalledWith( + expect.stringContaining('market_snapshot'), + expect.any(Function), + 300, + 'market_snapshot', + ); + }); + + it('throws InternalServerErrorException on unexpected error', async () => { + mockCache.getOrSet.mockRejectedValue(new Error('DB down')); + + const query = new GetMarketSnapshotQuery('HCMC'); + await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException); + }); +}); diff --git a/apps/api/src/modules/analytics/application/queries/get-market-snapshot/get-market-snapshot.handler.ts b/apps/api/src/modules/analytics/application/queries/get-market-snapshot/get-market-snapshot.handler.ts new file mode 100644 index 0000000..4566814 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-market-snapshot/get-market-snapshot.handler.ts @@ -0,0 +1,183 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService, PrismaService } from '@modules/shared'; +import { type PropertyType, ListingStatus, Prisma } from '@prisma/client'; +import { GetMarketSnapshotQuery } from './get-market-snapshot.query'; + +export interface PriceChangePct { + d1: number; + d7: number; + d30: number; +} + +export interface MarketSnapshotDto { + city: string; + propertyType?: PropertyType; + activeCount: number; + avgPrice: number; + medianPrice: number; + priceChangePct: PriceChangePct; + avgPricePerM2: number; + daysOnMarket: number; + newListings24h: number; + cachedAt: string | null; + nextRefreshAt: string | null; +} + +@QueryHandler(GetMarketSnapshotQuery) +export class GetMarketSnapshotHandler implements IQueryHandler { + constructor( + private readonly prisma: PrismaService, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(query: GetMarketSnapshotQuery): Promise { + try { + const cacheKey = CacheService.buildKey( + CachePrefix.MARKET_SNAPSHOT, + query.city, + query.propertyType, + ); + + return await this.cache.getOrSet( + cacheKey, + () => this.computeSnapshot(query.city, query.propertyType), + CacheTTL.MARKET_SNAPSHOT, + 'market_snapshot', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get market snapshot: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException( + 'Không thể truy vấn tổng quan thị trường. Vui lòng thử lại sau.', + ); + } + } + + private async computeSnapshot( + city: string, + propertyType?: PropertyType, + ): Promise { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const propertyWhere: Prisma.PropertyWhereInput = { + city: { equals: city, mode: 'insensitive' }, + ...(propertyType ? { propertyType } : {}), + }; + + const baseListingWhere: Prisma.ListingWhereInput = { + status: ListingStatus.ACTIVE, + property: propertyWhere, + }; + + // Run queries in parallel for performance + const [ + activeAgg, + medianResult, + newListings24h, + avgDaysOnMarket, + priceChange1d, + priceChange7d, + priceChange30d, + ] = await Promise.all([ + // Active listings count + avg price + avg price/m2 + this.prisma.listing.aggregate({ + where: baseListingWhere, + _count: true, + _avg: { + priceVND: true, + pricePerM2: true, + }, + }), + + // Median price via raw SQL for efficiency + this.prisma.$queryRaw<{ median: bigint | null }[]>` + SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median + FROM "Listing" l + JOIN "Property" p ON p.id = l."propertyId" + WHERE l.status = 'ACTIVE' + AND LOWER(p.city) = LOWER(${city}) + ${propertyType ? Prisma.sql`AND p."propertyType" = ${propertyType}::"PropertyType"` : Prisma.empty} + `, + + // New listings in last 24h + this.prisma.listing.count({ + where: { + ...baseListingWhere, + publishedAt: { gte: oneDayAgo }, + }, + }), + + // Average days on market + this.prisma.$queryRaw<{ avg_days: number | null }[]>` + SELECT AVG(EXTRACT(EPOCH FROM (NOW() - l."publishedAt")) / 86400)::float AS avg_days + FROM "Listing" l + JOIN "Property" p ON p.id = l."propertyId" + WHERE l.status = 'ACTIVE' + AND l."publishedAt" IS NOT NULL + AND LOWER(p.city) = LOWER(${city}) + ${propertyType ? Prisma.sql`AND p."propertyType" = ${propertyType}::"PropertyType"` : Prisma.empty} + `, + + // Price change %: compare current avg vs avg of listings from 1d/7d/30d ago + this.computePriceChangePct(city, propertyType, oneDayAgo, now), + this.computePriceChangePct(city, propertyType, sevenDaysAgo, oneDayAgo), + this.computePriceChangePct(city, propertyType, thirtyDaysAgo, sevenDaysAgo), + ]); + + const currentAvg = Number(activeAgg._avg.priceVND ?? 0); + const median = medianResult[0]?.median ? Number(medianResult[0].median) : 0; + const avgPricePerM2 = activeAgg._avg.pricePerM2 ?? 0; + const daysOnMarket = Math.round(avgDaysOnMarket[0]?.avg_days ?? 0); + + return { + city, + propertyType, + activeCount: activeAgg._count, + avgPrice: currentAvg, + medianPrice: median, + priceChangePct: { + d1: this.calcChangePct(currentAvg, priceChange1d), + d7: this.calcChangePct(currentAvg, priceChange7d), + d30: this.calcChangePct(currentAvg, priceChange30d), + }, + avgPricePerM2: Math.round(avgPricePerM2), + daysOnMarket, + newListings24h, + cachedAt: null, // Filled by CacheMetaInterceptor + nextRefreshAt: null, // Filled by CacheMetaInterceptor + }; + } + + private async computePriceChangePct( + city: string, + propertyType: PropertyType | undefined, + from: Date, + to: Date, + ): Promise { + const result = await this.prisma.$queryRaw<{ avg_price: number | null }[]>` + SELECT AVG(l."priceVND")::float AS avg_price + FROM "Listing" l + JOIN "Property" p ON p.id = l."propertyId" + WHERE l.status = 'ACTIVE' + AND l."publishedAt" >= ${from} + AND l."publishedAt" < ${to} + AND LOWER(p.city) = LOWER(${city}) + ${propertyType ? Prisma.sql`AND p."propertyType" = ${propertyType}::"PropertyType"` : Prisma.empty} + `; + return result[0]?.avg_price ?? 0; + } + + private calcChangePct(current: number, previous: number): number { + if (!previous || previous === 0) return 0; + return Math.round(((current - previous) / previous) * 1000) / 10; // 1 decimal + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-market-snapshot/get-market-snapshot.query.ts b/apps/api/src/modules/analytics/application/queries/get-market-snapshot/get-market-snapshot.query.ts new file mode 100644 index 0000000..a24e13b --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-market-snapshot/get-market-snapshot.query.ts @@ -0,0 +1,8 @@ +import { type PropertyType } from '@prisma/client'; + +export class GetMarketSnapshotQuery { + constructor( + public readonly city: string, + public readonly propertyType?: PropertyType, + ) {} +} 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 66ced76..1f868cf 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -28,6 +28,8 @@ import { import { GetProjectAiAdviceQuery } from '../../application/queries/get-project-ai-advice/get-project-ai-advice.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 MarketSnapshotDto } from '../../application/queries/get-market-snapshot/get-market-snapshot.handler'; +import { GetMarketSnapshotQuery } from '../../application/queries/get-market-snapshot/get-market-snapshot.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'; @@ -46,6 +48,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 { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto'; import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto'; import { GetPriceTrendDto } from '../dto/get-price-trend.dto'; import { GetValuationDto } from '../dto/get-valuation.dto'; @@ -73,6 +76,23 @@ export class AnalyticsController { ); } + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Get('market-snapshot') + @ApiOperation({ + summary: 'Tổng quan thị trường cho dashboard tiles', + description: + 'Trả về snapshot thị trường BĐS: số tin đang hoạt động, giá trung bình, giá trung vị, biến động giá 1d/7d/30d, giá/m², thời gian rao trung bình, tin mới 24h. Cache Redis 5 phút.', + }) + @ApiResponse({ status: 200, description: 'Market snapshot retrieved' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + async getMarketSnapshot(@Query() dto: GetMarketSnapshotDto): Promise { + return this.queryBus.execute( + new GetMarketSnapshotQuery(dto.city, dto.propertyType), + ); + } + @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard, QuotaGuard) @RequireQuota('analytics_queries') diff --git a/apps/api/src/modules/analytics/presentation/dto/get-market-snapshot.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-market-snapshot.dto.ts new file mode 100644 index 0000000..c29b89a --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-market-snapshot.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PropertyType } from '@prisma/client'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +export class GetMarketSnapshotDto { + @ApiProperty({ description: 'City name', example: 'HCMC' }) + @IsString() + city!: string; + + @ApiPropertyOptional({ enum: PropertyType, description: 'Property type filter' }) + @IsOptional() + @IsEnum(PropertyType) + propertyType?: PropertyType; +} diff --git a/apps/api/src/modules/shared/infrastructure/cache.service.ts b/apps/api/src/modules/shared/infrastructure/cache.service.ts index f6f4a5e..ccbd2a9 100644 --- a/apps/api/src/modules/shared/infrastructure/cache.service.ts +++ b/apps/api/src/modules/shared/infrastructure/cache.service.ts @@ -32,6 +32,8 @@ export const CacheTTL = { PLAN_LIST: 3600, // 1 hour /** Reference data (districts, wards) — very long TTL, static data */ REFERENCE_DATA: 86400, // 24 hours + /** Market snapshot — 5 min TTL, dashboard tile data */ + MARKET_SNAPSHOT: 300, // 5 min } as const; export enum CachePrefix { @@ -48,6 +50,7 @@ export enum CachePrefix { PLAN_LIST = 'cache:plan:list', REFERENCE = 'cache:reference', AGENT_LISTINGS = 'cache:agent:listings', + MARKET_SNAPSHOT = 'cache:analytics:market_snapshot', } @Injectable()