diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index fc90a5e..2024688 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 { GetMarketHistoryHandler } from './application/queries/get-market-history/get-market-history.handler'; import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler'; import { GetPriceMoversHandler } from './application/queries/get-price-movers/get-price-movers.handler'; import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler'; @@ -49,6 +50,7 @@ const CommandHandlers = [ const QueryHandlers = [ GetMarketReportHandler, + GetMarketHistoryHandler, GetHeatmapHandler, GetPriceTrendHandler, GetDistrictStatsHandler, diff --git a/apps/api/src/modules/analytics/application/queries/get-market-history/__tests__/get-market-history.handler.spec.ts b/apps/api/src/modules/analytics/application/queries/get-market-history/__tests__/get-market-history.handler.spec.ts new file mode 100644 index 0000000..a38a164 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-market-history/__tests__/get-market-history.handler.spec.ts @@ -0,0 +1,78 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { type IMarketIndexRepository } from '../../../../domain/repositories/market-index.repository'; +import { GetMarketHistoryHandler } from '../get-market-history.handler'; +import { GetMarketHistoryQuery } from '../get-market-history.query'; + +describe('GetMarketHistoryHandler', () => { + let handler: GetMarketHistoryHandler; + let mockRepo: { getMarketHistory: ReturnType }; + let mockCache: { getOrSet: ReturnType }; + let mockLogger: { error: ReturnType }; + + beforeEach(() => { + mockRepo = { getMarketHistory: vi.fn() }; + mockCache = { + getOrSet: vi.fn((_key: string, fn: () => Promise) => fn()), + }; + mockLogger = { error: vi.fn() }; + + handler = new GetMarketHistoryHandler( + mockRepo as unknown as IMarketIndexRepository, + mockCache as any, + mockLogger as any, + ); + }); + + it('should return market history points for 12m monthly', async () => { + const points = [ + { date: '2025-05', avgPrice: 50000000, medianPrice: '45000000', listingsCount: 120, inquiriesCount: 0, daysOnMarket: 35 }, + { date: '2025-06', avgPrice: 51000000, medianPrice: '46000000', listingsCount: 130, inquiriesCount: 0, daysOnMarket: 33 }, + ]; + mockRepo.getMarketHistory.mockResolvedValue(points); + + const query = new GetMarketHistoryQuery('HCMC', '12m', 'monthly'); + const result = await handler.execute(query); + + expect(result.city).toBe('HCMC'); + expect(result.points).toEqual(points); + expect(mockRepo.getMarketHistory).toHaveBeenCalledWith('HCMC', expect.any(Array)); + // Should generate 12 monthly periods + const calledPeriods = mockRepo.getMarketHistory.mock.calls[0][1] as string[]; + expect(calledPeriods).toHaveLength(12); + }); + + it('should return market history for 6m period', async () => { + mockRepo.getMarketHistory.mockResolvedValue([]); + + const query = new GetMarketHistoryQuery('HCMC', '6m', 'monthly'); + const result = await handler.execute(query); + + expect(result.city).toBe('HCMC'); + expect(result.points).toEqual([]); + const calledPeriods = mockRepo.getMarketHistory.mock.calls[0][1] as string[]; + expect(calledPeriods).toHaveLength(6); + }); + + it('should use cache with 6h TTL', async () => { + mockRepo.getMarketHistory.mockResolvedValue([]); + const query = new GetMarketHistoryQuery('HCMC', '12m', 'monthly'); + + await handler.execute(query); + + expect(mockCache.getOrSet).toHaveBeenCalledWith( + expect.stringContaining('market_history'), + expect.any(Function), + 21600, + 'market_history', + ); + }); + + it('should throw InternalServerErrorException on unexpected errors', async () => { + mockRepo.getMarketHistory.mockRejectedValue(new Error('DB connection lost')); + + const query = new GetMarketHistoryQuery('HCMC', '12m', 'monthly'); + + await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/analytics/application/queries/get-market-history/get-market-history.handler.ts b/apps/api/src/modules/analytics/application/queries/get-market-history/get-market-history.handler.ts new file mode 100644 index 0000000..1eb7133 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-market-history/get-market-history.handler.ts @@ -0,0 +1,97 @@ +import { Inject, 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 MarketHistoryPoint, +} from '../../../domain/repositories/market-index.repository'; +import { GetMarketHistoryQuery } from './get-market-history.query'; + +export interface MarketHistoryDto { + city: string; + points: MarketHistoryPoint[]; +} + +@QueryHandler(GetMarketHistoryQuery) +export class GetMarketHistoryHandler implements IQueryHandler { + constructor( + @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(query: GetMarketHistoryQuery): Promise { + try { + const cacheKey = CacheService.buildKey( + CachePrefix.MARKET_HISTORY, + query.city, + query.period, + query.granularity, + query.propertyType ?? 'all', + ); + + return await this.cache.getOrSet( + cacheKey, + async () => { + const periods = this.generatePeriods(query.period, query.granularity); + const points = await this.marketIndexRepo.getMarketHistory(query.city, periods); + return { city: query.city, points }; + }, + CacheTTL.MARKET_HISTORY, + 'market_history', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get market history: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException( + 'Không thể truy vấn lịch sử thị trường. Vui lòng thử lại sau.', + ); + } + } + + /** + * Generate period strings based on the requested look-back and granularity. + * E.g. "12m" with "monthly" → ["2025-05", "2025-06", ..., "2026-04"] + */ + private generatePeriods(period: string, granularity: 'monthly' | 'weekly'): string[] { + const match = period.match(/^(\d+)m$/); + const months = match?.[1] ? parseInt(match[1], 10) : 12; + + const now = new Date(); + const periods: string[] = []; + + if (granularity === 'monthly') { + for (let i = months - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + periods.push(`${yyyy}-${mm}`); + } + } else { + // weekly: generate ISO week strings for the past N months + const startDate = new Date(now.getFullYear(), now.getMonth() - months, now.getDate()); + const cursor = new Date(startDate); + while (cursor <= now) { + const yyyy = cursor.getFullYear(); + const week = this.getISOWeek(cursor); + periods.push(`${yyyy}-W${String(week).padStart(2, '0')}`); + cursor.setDate(cursor.getDate() + 7); + } + } + + return periods; + } + + private getISOWeek(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-market-history/get-market-history.query.ts b/apps/api/src/modules/analytics/application/queries/get-market-history/get-market-history.query.ts new file mode 100644 index 0000000..f5b69c8 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-market-history/get-market-history.query.ts @@ -0,0 +1,8 @@ +export class GetMarketHistoryQuery { + constructor( + public readonly city: string, + public readonly period: string, + public readonly granularity: 'monthly' | 'weekly', + public readonly propertyType?: 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 e2c219e..8d43b1a 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 @@ -45,6 +45,15 @@ export interface DistrictStatsResult { yoyChange: number | null; } +export interface MarketHistoryPoint { + date: string; + avgPrice: number; + medianPrice: string; + listingsCount: number; + inquiriesCount: number; + daysOnMarket: number; +} + export interface IMarketIndexRepository { findById(id: string): Promise; findByKey(district: string, city: string, propertyType: PropertyType, period: string): Promise; @@ -54,4 +63,5 @@ export interface IMarketIndexRepository { getHeatmap(city: 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 1dee441..a318ed7 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 @@ -8,6 +8,7 @@ import { type HeatmapDataPoint, type PriceTrendPoint, type DistrictStatsResult, + type MarketHistoryPoint, } from '../../domain/repositories/market-index.repository'; @Injectable() @@ -173,6 +174,53 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository { })); } + async getMarketHistory(city: string, periods: string[]): Promise { + const records = await this.prisma.marketIndex.findMany({ + where: { + city: { equals: city, mode: 'insensitive' }, + period: { in: periods }, + }, + orderBy: { period: 'asc' }, + }); + + // Aggregate across all districts/property types per period + const periodMap = new Map(); + + for (const r of records) { + const existing = periodMap.get(r.period); + if (existing) { + existing.totalAvgPrice += r.avgPriceM2; + existing.totalMedian += r.medianPrice; + existing.totalListings += r.totalListings; + existing.totalDaysOnMarket += r.daysOnMarket; + existing.count++; + } else { + periodMap.set(r.period, { + totalAvgPrice: r.avgPriceM2, + totalMedian: r.medianPrice, + totalListings: r.totalListings, + totalDaysOnMarket: r.daysOnMarket, + count: 1, + }); + } + } + + return Array.from(periodMap.entries()).map(([period, data]) => ({ + date: period, + avgPrice: Math.round(data.totalAvgPrice / data.count), + medianPrice: (data.totalMedian / BigInt(data.count)).toString(), + listingsCount: data.totalListings, + inquiriesCount: 0, // inquiries not tracked in MarketIndex + daysOnMarket: Math.round(data.totalDaysOnMarket / data.count), + })); + } + 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 c149226..ccd68b0 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -30,6 +30,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 MarketHistoryDto } from '../../application/queries/get-market-history/get-market-history.handler'; +import { GetMarketHistoryQuery } from '../../application/queries/get-market-history/get-market-history.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 PriceMoversDto } from '../../application/queries/get-price-movers/get-price-movers.handler'; @@ -52,6 +54,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 { GetMarketHistoryDto } from '../dto/get-market-history.dto'; import { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto'; import { GetPriceMoversDto } from '../dto/get-price-movers.dto'; import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto'; @@ -82,6 +85,23 @@ export class AnalyticsController { ); } + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Get('market-history') + @ApiOperation({ + summary: 'Lịch sử thị trường BĐS theo chuỗi thời gian', + description: + 'Trả về time-series dữ liệu thị trường (giá trung bình, giá trung vị, số tin đăng, thời gian rao) cho trang analytics. Cache 6 giờ.', + }) + @ApiResponse({ status: 200, description: 'Market history time-series retrieved' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + async getMarketHistory(@Query() dto: GetMarketHistoryDto): Promise { + return this.queryBus.execute( + new GetMarketHistoryQuery(dto.city, dto.period, dto.granularity, dto.propertyType), + ); + } + @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard, QuotaGuard) @RequireQuota('analytics_queries') diff --git a/apps/api/src/modules/analytics/presentation/dto/get-market-history.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-market-history.dto.ts new file mode 100644 index 0000000..fd5584b --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-market-history.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PropertyType } from '@prisma/client'; +import { IsEnum, IsIn, IsOptional, IsString } from 'class-validator'; + +export class GetMarketHistoryDto { + @ApiProperty({ description: 'City name', example: 'HCMC' }) + @IsString() + city!: string; + + @ApiProperty({ + description: 'Look-back period (e.g. 12m, 6m, 24m)', + example: '12m', + }) + @IsString() + period!: string; + + @ApiProperty({ + description: 'Time granularity', + enum: ['monthly', 'weekly'], + default: 'monthly', + }) + @IsIn(['monthly', 'weekly']) + granularity!: 'monthly' | 'weekly'; + + @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 70a09d8..4284fa4 100644 --- a/apps/api/src/modules/shared/infrastructure/cache.service.ts +++ b/apps/api/src/modules/shared/infrastructure/cache.service.ts @@ -39,6 +39,10 @@ export const CacheTTL = { TRENDING_AREAS: 1800, // 30 min /** Price movers — 30 min TTL, aggregation over two time windows */ PRICE_MOVERS: 1800, // 30 min + /** Market history — 6 hour TTL, time-series data recomputed nightly */ + MARKET_HISTORY: 21600, // 6 hours + /** AVM valuation estimate per listing — long TTL, model outputs are stable within a day */ + VALUATION_LISTING: 86400, // 24 h } as const; export enum CachePrefix { @@ -58,6 +62,7 @@ export enum CachePrefix { MARKET_SNAPSHOT = 'cache:analytics:market_snapshot', TRENDING_AREAS = 'cache:analytics:trending_areas', PRICE_MOVERS = 'cache:analytics:price_movers', + MARKET_HISTORY = 'cache:analytics:market_history', } @Injectable()