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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user