feat(analytics): add GET /analytics/market-history endpoint

Time-series endpoint returning monthly/weekly market data points
for the analytics page. Queries MarketIndex aggregated by period
with 6-hour Redis cache. Includes unit tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 02:37:10 +07:00
parent 0651074319
commit f7b0fe6f5d
9 changed files with 297 additions and 0 deletions

View File

@@ -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,

View File

@@ -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<typeof vi.fn> };
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
let mockLogger: { error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRepo = { getMarketHistory: vi.fn() };
mockCache = {
getOrSet: vi.fn((_key: string, fn: () => Promise<unknown>) => 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();
});
});

View File

@@ -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<GetMarketHistoryQuery> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(query: GetMarketHistoryQuery): Promise<MarketHistoryDto> {
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);
}
}

View File

@@ -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,
) {}
}

View File

@@ -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<MarketIndexEntity | null>;
findByKey(district: string, city: string, propertyType: PropertyType, period: string): Promise<MarketIndexEntity | null>;
@@ -54,4 +63,5 @@ export interface IMarketIndexRepository {
getHeatmap(city: string, period: string): Promise<HeatmapDataPoint[]>;
getPriceTrend(district: string, city: string, propertyType: PropertyType, periods: string[]): Promise<PriceTrendPoint[]>;
getDistrictStats(city: string, period: string): Promise<DistrictStatsResult[]>;
getMarketHistory(city: string, periods: string[]): Promise<MarketHistoryPoint[]>;
}

View File

@@ -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<MarketHistoryPoint[]> {
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<string, {
totalAvgPrice: number;
totalMedian: bigint;
totalListings: number;
totalDaysOnMarket: number;
count: number;
}>();
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,

View File

@@ -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<MarketHistoryDto> {
return this.queryBus.execute(
new GetMarketHistoryQuery(dto.city, dto.period, dto.granularity, dto.propertyType),
);
}
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')

View File

@@ -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;
}

View File

@@ -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()