feat(analytics): AVM v2 batch valuation, comparison, history + frontend upgrade

Add batch valuation (POST /analytics/valuation/batch, max 50 properties),
valuation comparison (POST /analytics/valuation/compare, 2-5 properties),
and history endpoint (GET /analytics/valuation/history/:propertyId) with
confidence explanation helper. Frontend: enhanced valuation form with project
autocomplete and deep analysis toggle, results with confidence badges and
price range visualization, comparables table, history chart, market context
card, and PDF export.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 05:08:05 +07:00
parent 93a390efb9
commit 8da488711b
27 changed files with 1715 additions and 162 deletions

View File

@@ -1,28 +1,41 @@
import {
Body,
Controller,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { type QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { JwtAuthGuard } from '@modules/auth';
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
import { DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler';
import { type BatchValuationDto as BatchValuationQueryDto } from '../../application/queries/batch-valuation/batch-valuation.handler';
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
import { type DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler';
import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
import { HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
import { MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
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 { PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
import { ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
import { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
import { GetMarketReportDto } from '../dto/get-market-report.dto';
import { GetPriceTrendDto } from '../dto/get-price-trend.dto';
import { GetValuationDto } from '../dto/get-valuation.dto';
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
import { type BatchValuationDto } from '../dto/batch-valuation.dto';
import { type GetDistrictStatsDto } from '../dto/get-district-stats.dto';
import { type GetHeatmapDto } from '../dto/get-heatmap.dto';
import { type GetMarketReportDto } from '../dto/get-market-report.dto';
import { type GetPriceTrendDto } from '../dto/get-price-trend.dto';
import { type GetValuationDto } from '../dto/get-valuation.dto';
import { type ValuationComparisonDto } from '../dto/valuation-comparison.dto';
import { type ValuationHistoryDto } from '../dto/valuation-history.dto';
@ApiTags('analytics')
@Controller('analytics')
@@ -96,4 +109,53 @@ export class AnalyticsController {
new GetValuationQuery(dto.propertyId, dto.latitude, dto.longitude, dto.areaM2, dto.propertyType),
);
}
@ApiBearerAuth('JWT')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Post('valuation/batch')
@ApiOperation({ summary: 'Batch valuation for multiple properties (max 50)' })
@ApiResponse({ status: 200, description: 'Batch valuation results retrieved' })
@ApiResponse({ status: 400, description: 'Invalid parameters' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
async batchValuation(@Body() dto: BatchValuationDto): Promise<BatchValuationQueryDto> {
return this.queryBus.execute(
new BatchValuationQuery(dto.propertyIds),
);
}
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Get('valuation/history/:propertyId')
@ApiOperation({ summary: 'Get valuation history for a property (chart data)' })
@ApiParam({ name: 'propertyId', description: 'Property ID' })
@ApiResponse({ status: 200, description: 'Valuation history retrieved' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
async getValuationHistory(
@Param('propertyId') propertyId: string,
@Query() dto: ValuationHistoryDto,
): Promise<ValuationHistoryResultDto> {
return this.queryBus.execute(
new ValuationHistoryQuery(propertyId, dto.limit ?? 50),
);
}
@ApiBearerAuth('JWT')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Post('valuation/compare')
@ApiOperation({ summary: 'Compare valuations for 2-5 properties side by side' })
@ApiResponse({ status: 200, description: 'Valuation comparison retrieved' })
@ApiResponse({ status: 400, description: 'Invalid parameters — provide 2-5 property IDs' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
async compareValuations(@Body() dto: ValuationComparisonDto): Promise<ValuationComparisonResultDto> {
return this.queryBus.execute(
new ValuationComparisonQuery(dto.propertyIds),
);
}
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
export class BatchValuationDto {
@ApiProperty({
description: 'Array of property IDs to valuate (max 50)',
example: ['prop-1', 'prop-2'],
type: [String],
})
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(50)
@IsString({ each: true })
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
propertyIds!: string[];
}

View File

@@ -3,3 +3,6 @@ export { GetHeatmapDto } from './get-heatmap.dto';
export { GetPriceTrendDto } from './get-price-trend.dto';
export { GetDistrictStatsDto } from './get-district-stats.dto';
export { GetValuationDto } from './get-valuation.dto';
export { BatchValuationDto } from './batch-valuation.dto';
export { ValuationHistoryDto } from './valuation-history.dto';
export { ValuationComparisonDto } from './valuation-comparison.dto';

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
export class ValuationComparisonDto {
@ApiProperty({
description: 'Array of property IDs to compare (2-5 properties)',
example: ['prop-1', 'prop-2', 'prop-3'],
type: [String],
})
@IsArray()
@ArrayMinSize(2)
@ArrayMaxSize(5)
@IsString({ each: true })
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
propertyIds!: string[];
}

View File

@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ValuationHistoryDto {
@ApiPropertyOptional({ description: 'Maximum number of history records (default: 50, max: 100)', default: 50 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Transform(({ value }) => (value != null ? parseInt(value, 10) : 50))
limit?: number;
}