diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 9dfb6eb..fc90a5e 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -14,6 +14,7 @@ import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap 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 { 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'; import { GetNearbyPOIsHandler } from './application/queries/get-nearby-pois/get-nearby-pois.handler'; import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler'; @@ -63,6 +64,7 @@ const QueryHandlers = [ GetListingAiAdviceHandler, GetProjectAiAdviceHandler, GetMarketSnapshotHandler, + GetPriceMoversHandler, ]; const EventHandlers = [ diff --git a/apps/api/src/modules/analytics/application/__tests__/get-price-movers.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-price-movers.handler.spec.ts new file mode 100644 index 0000000..0d4c670 --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/get-price-movers.handler.spec.ts @@ -0,0 +1,107 @@ +import { type CacheService, type LoggerService } from '@modules/shared'; +import { GetPriceMoversHandler } from '../queries/get-price-movers/get-price-movers.handler'; +import { GetPriceMoversQuery } from '../queries/get-price-movers/get-price-movers.query'; + +describe('GetPriceMoversHandler', () => { + let handler: GetPriceMoversHandler; + let mockPrisma: { $queryRaw: ReturnType }; + let mockCache: Partial; + let mockLogger: Partial; + + beforeEach(() => { + mockPrisma = { + $queryRaw: vi.fn(), + }; + mockCache = { + getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()), + } as unknown as Partial; + mockLogger = { error: vi.fn(), warn: vi.fn(), log: vi.fn() } as unknown as Partial; + + handler = new GetPriceMoversHandler( + mockPrisma as any, + mockCache as CacheService, + mockLogger as LoggerService, + ); + }); + + it('returns top price gainers sorted by changePct descending', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { district: 'Quận 1', current_avg: 5_000_000_000, previous_avg: 4_000_000_000, sample_size: BigInt(15) }, + { district: 'Quận 7', current_avg: 3_000_000_000, previous_avg: 2_500_000_000, sample_size: BigInt(20) }, + { district: 'Bình Thạnh', current_avg: 2_000_000_000, previous_avg: 2_200_000_000, sample_size: BigInt(12) }, + ]); + + const query = new GetPriceMoversQuery('up', '7d', 5, 'district'); + const result = await handler.execute(query); + + expect(result.direction).toBe('up'); + expect(result.period).toBe('7d'); + expect(result.movers.length).toBe(2); // Only positive changes + // Quận 1: +25%, Quận 7: +20% + expect(result.movers[0].districtId).toBe('Quận 1'); + expect(result.movers[0].changePct).toBe(25); + expect(result.movers[1].districtId).toBe('Quận 7'); + expect(result.movers[1].changePct).toBe(20); + }); + + it('returns top price losers sorted by changePct ascending', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { district: 'Quận 1', current_avg: 5_000_000_000, previous_avg: 4_000_000_000, sample_size: BigInt(15) }, + { district: 'Bình Thạnh', current_avg: 2_000_000_000, previous_avg: 2_200_000_000, sample_size: BigInt(12) }, + { district: 'Thủ Đức', current_avg: 1_800_000_000, previous_avg: 2_100_000_000, sample_size: BigInt(18) }, + ]); + + const query = new GetPriceMoversQuery('down', '7d', 5, 'district'); + const result = await handler.execute(query); + + expect(result.direction).toBe('down'); + expect(result.movers.length).toBe(2); // Only negative changes + // Thủ Đức: -14.29%, Bình Thạnh: -9.09% + expect(result.movers[0].districtId).toBe('Thủ Đức'); + expect(result.movers[1].districtId).toBe('Bình Thạnh'); + expect(result.movers[0].changePct).toBeLessThan(result.movers[1].changePct); + }); + + it('respects the limit parameter', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { district: 'A', current_avg: 200, previous_avg: 100, sample_size: BigInt(10) }, + { district: 'B', current_avg: 180, previous_avg: 100, sample_size: BigInt(10) }, + { district: 'C', current_avg: 160, previous_avg: 100, sample_size: BigInt(10) }, + ]); + + const query = new GetPriceMoversQuery('up', '7d', 2, 'district'); + const result = await handler.execute(query); + + expect(result.movers.length).toBe(2); + expect(result.limit).toBe(2); + }); + + it('returns empty movers when no data', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + const query = new GetPriceMoversQuery('up', '7d', 5, 'district'); + const result = await handler.execute(query); + + expect(result.movers).toEqual([]); + }); + + it('rounds changePct to two decimal places', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { district: 'Quận 1', current_avg: 3_333_333, previous_avg: 3_000_000, sample_size: BigInt(15) }, + ]); + + const query = new GetPriceMoversQuery('up', '7d', 5, 'district'); + const result = await handler.execute(query); + + expect(result.movers[0].changePct).toBe(11.11); + }); + + it('throws InternalServerErrorException on unexpected errors', async () => { + mockPrisma.$queryRaw.mockRejectedValue(new Error('DB connection lost')); + + const query = new GetPriceMoversQuery('up', '7d', 5, 'district'); + await expect(handler.execute(query)).rejects.toThrow( + 'Không thể truy vấn biến động giá. Vui lòng thử lại sau.', + ); + }); +}); diff --git a/apps/api/src/modules/analytics/application/queries/get-price-movers/get-price-movers.handler.ts b/apps/api/src/modules/analytics/application/queries/get-price-movers/get-price-movers.handler.ts new file mode 100644 index 0000000..e3aa0a2 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-price-movers/get-price-movers.handler.ts @@ -0,0 +1,144 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { DomainException, CacheService, CachePrefix, CacheTTL, Cacheable, LoggerService, PrismaService } from '@modules/shared'; +import { GetPriceMoversQuery } from './get-price-movers.query'; + +export interface PriceMoverItem { + districtId: string; + name: string; + currentAvgPrice: number; + previousAvgPrice: number; + changePct: number; + sampleSize: number; +} + +export interface PriceMoversDto { + direction: 'up' | 'down'; + period: string; + level: string; + limit: number; + movers: PriceMoverItem[]; +} + +/** Days extracted from period string, e.g. '7d' → 7 */ +function periodToDays(period: string): number { + return parseInt(period.replace('d', ''), 10); +} + +interface RawPriceMoverRow { + district: string; + current_avg: number | null; + previous_avg: number | null; + sample_size: bigint; +} + +@QueryHandler(GetPriceMoversQuery) +export class GetPriceMoversHandler implements IQueryHandler { + constructor( + private readonly prisma: PrismaService, + private readonly cacheService: CacheService, + private readonly logger: LoggerService, + ) {} + + @Cacheable({ + prefix: CachePrefix.PRICE_MOVERS, + ttl: CacheTTL.PRICE_MOVERS, + resource: 'price_movers', + keyFrom: (query: unknown) => { + const q = query as GetPriceMoversQuery; + return [q.direction, q.period, String(q.limit), q.level]; + }, + }) + async execute(query: GetPriceMoversQuery): Promise { + const { direction, period, limit, level } = query; + + try { + const days = periodToDays(period); + const now = new Date(); + const currentStart = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + const previousStart = new Date(currentStart.getTime() - days * 24 * 60 * 60 * 1000); + + // Compare average listing price per district between current window and previous window. + // Only include districts with at least 10 listings in the current window (min sample size). + const rows = await this.prisma.$queryRaw` + WITH current_window AS ( + SELECT + p.district, + AVG(l.price) AS avg_price, + COUNT(l.id) AS sample_size + FROM "Listing" l + INNER JOIN "Property" p ON p.id = l."propertyId" + WHERE l."createdAt" >= ${currentStart} + AND l.status = 'ACTIVE' + AND l.price > 0 + GROUP BY p.district + HAVING COUNT(l.id) >= 10 + ), + previous_window AS ( + SELECT + p.district, + AVG(l.price) AS avg_price + FROM "Listing" l + INNER JOIN "Property" p ON p.id = l."propertyId" + WHERE l."createdAt" >= ${previousStart} + AND l."createdAt" < ${currentStart} + AND l.status = 'ACTIVE' + AND l.price > 0 + GROUP BY p.district + ) + SELECT + c.district, + c.avg_price AS current_avg, + pr.avg_price AS previous_avg, + c.sample_size + FROM current_window c + INNER JOIN previous_window pr ON pr.district = c.district + WHERE pr.avg_price > 0 + `; + + // Compute changePct and sort by direction + const computed = rows + .map((r) => { + const currentAvg = Number(r.current_avg); + const previousAvg = Number(r.previous_avg); + const changePct = ((currentAvg - previousAvg) / previousAvg) * 100; + return { + district: r.district, + currentAvgPrice: Math.round(currentAvg), + previousAvgPrice: Math.round(previousAvg), + changePct: Math.round(changePct * 100) / 100, + sampleSize: Number(r.sample_size), + }; + }) + .filter((r) => (direction === 'up' ? r.changePct > 0 : r.changePct < 0)); + + // Sort: 'up' → descending changePct, 'down' → ascending changePct + computed.sort((a, b) => + direction === 'up' ? b.changePct - a.changePct : a.changePct - b.changePct, + ); + + const top = computed.slice(0, limit); + + const movers: PriceMoverItem[] = top.map((r) => ({ + districtId: r.district, + name: r.district, + currentAvgPrice: r.currentAvgPrice, + previousAvgPrice: r.previousAvgPrice, + changePct: r.changePct, + sampleSize: r.sampleSize, + })); + + return { direction, period, level, limit, movers }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to query price movers: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException( + 'Không thể truy vấn biến động giá. Vui lòng thử lại sau.', + ); + } + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-price-movers/get-price-movers.query.ts b/apps/api/src/modules/analytics/application/queries/get-price-movers/get-price-movers.query.ts new file mode 100644 index 0000000..bcabb71 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-price-movers/get-price-movers.query.ts @@ -0,0 +1,12 @@ +export class GetPriceMoversQuery { + constructor( + /** Price movement direction: 'up' for gainers, 'down' for losers */ + public readonly direction: 'up' | 'down', + /** Look-back period string, e.g. '7d', '14d', '30d' */ + public readonly period: string, + /** Maximum number of results to return */ + public readonly limit: number, + /** Geographic aggregation level — currently only 'district' */ + public readonly level: 'district', + ) {} +} diff --git a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts index 375f4f9..c149226 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -32,6 +32,8 @@ import { type MarketReportDto } from '../../application/queries/get-market-repor 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 PriceMoversDto } from '../../application/queries/get-price-movers/get-price-movers.handler'; +import { GetPriceMoversQuery } from '../../application/queries/get-price-movers/get-price-movers.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'; @@ -51,6 +53,7 @@ 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 { GetPriceMoversDto } from '../dto/get-price-movers.dto'; import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto'; import { GetPriceTrendDto } from '../dto/get-price-trend.dto'; import { GetValuationDto } from '../dto/get-valuation.dto'; @@ -96,6 +99,23 @@ export class AnalyticsController { ); } + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Get('price-movers') + @ApiOperation({ + summary: 'Top tăng/giảm giá theo quận cho Home dashboard', + description: + 'Trả về danh sách quận có biến động giá lớn nhất (tăng hoặc giảm) trong khoảng thời gian chỉ định. Chỉ hiển thị quận có ≥ 10 tin đăng. Cache Redis 30 phút.', + }) + @ApiResponse({ status: 200, description: 'Price movers retrieved' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + async getPriceMovers(@Query() dto: GetPriceMoversDto): Promise { + return this.queryBus.execute( + new GetPriceMoversQuery(dto.direction, dto.period, dto.limit, dto.level), + ); + } + @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard, QuotaGuard) @RequireQuota('analytics_queries') diff --git a/apps/api/src/modules/analytics/presentation/dto/get-price-movers.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-price-movers.dto.ts new file mode 100644 index 0000000..3a647ed --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-price-movers.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class GetPriceMoversDto { + @ApiProperty({ + description: 'Price movement direction', + enum: ['up', 'down'], + example: 'up', + }) + @IsIn(['up', 'down']) + direction: 'up' | 'down' = 'up'; + + @ApiPropertyOptional({ + description: 'Look-back period', + enum: ['7d', '14d', '30d'], + default: '7d', + example: '7d', + }) + @IsOptional() + @IsIn(['7d', '14d', '30d']) + period: string = '7d'; + + @ApiPropertyOptional({ + description: 'Maximum number of results to return', + minimum: 1, + maximum: 20, + default: 5, + example: 5, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(20) + limit: number = 5; + + @ApiPropertyOptional({ + description: 'Geographic aggregation level (currently only "district" is supported)', + enum: ['district'], + default: 'district', + example: 'district', + }) + @IsOptional() + @IsIn(['district']) + level: 'district' = 'district'; +} diff --git a/apps/api/src/modules/analytics/presentation/dto/index.ts b/apps/api/src/modules/analytics/presentation/dto/index.ts index 2aa905f..bd060ed 100644 --- a/apps/api/src/modules/analytics/presentation/dto/index.ts +++ b/apps/api/src/modules/analytics/presentation/dto/index.ts @@ -8,3 +8,5 @@ export { ValuationHistoryDto } from './valuation-history.dto'; export { ValuationComparisonDto } from './valuation-comparison.dto'; export { AvmCompareQueryDto } from './avm-compare-query.dto'; export { IndustrialValuationDto } from './industrial-valuation.dto'; +export { GetTrendingAreasDto } from './get-trending-areas.dto'; +export { GetPriceMoversDto } from './get-price-movers.dto'; diff --git a/apps/api/src/modules/shared/infrastructure/cache.service.ts b/apps/api/src/modules/shared/infrastructure/cache.service.ts index 0711a5c..70a09d8 100644 --- a/apps/api/src/modules/shared/infrastructure/cache.service.ts +++ b/apps/api/src/modules/shared/infrastructure/cache.service.ts @@ -37,6 +37,8 @@ export const CacheTTL = { MARKET_SNAPSHOT: 300, // 5 min /** Trending areas — 30 min TTL, aggregation is expensive */ TRENDING_AREAS: 1800, // 30 min + /** Price movers — 30 min TTL, aggregation over two time windows */ + PRICE_MOVERS: 1800, // 30 min } as const; export enum CachePrefix { @@ -55,6 +57,7 @@ export enum CachePrefix { AGENT_LISTINGS = 'cache:agent:listings', MARKET_SNAPSHOT = 'cache:analytics:market_snapshot', TRENDING_AREAS = 'cache:analytics:trending_areas', + PRICE_MOVERS = 'cache:analytics:price_movers', } @Injectable()