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

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