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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 02:06:57 +07:00
parent d91e3f6fe2
commit bcd8b6685a
7 changed files with 366 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 { 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 = [

View File

@@ -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<string, any>;
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
listing: {
aggregate: vi.fn(),
count: vi.fn(),
},
$queryRaw: vi.fn(),
};
mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => 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);
});
});

View File

@@ -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<GetMarketSnapshotQuery> {
constructor(
private readonly prisma: PrismaService,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(query: GetMarketSnapshotQuery): Promise<MarketSnapshotDto> {
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<MarketSnapshotDto> {
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<number> {
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
}
}

View File

@@ -0,0 +1,8 @@
import { type PropertyType } from '@prisma/client';
export class GetMarketSnapshotQuery {
constructor(
public readonly city: string,
public readonly propertyType?: PropertyType,
) {}
}

View File

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

View File

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

View File

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