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:
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type PropertyType } from '@prisma/client';
|
||||
|
||||
export class GetMarketSnapshotQuery {
|
||||
constructor(
|
||||
public readonly city: string,
|
||||
public readonly propertyType?: PropertyType,
|
||||
) {}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user