feat(analytics): add valuation handler, AVM service, and market index improvements

Add property valuation query handler with AVM (Automated Valuation Model)
service integration. Improve market index, heatmap, and price trend handlers
with proper dependency injection and error handling.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 09:41:46 +07:00
parent 1e0436e95f
commit cd25d4df2e
25 changed files with 587 additions and 14 deletions

View File

@@ -6,9 +6,8 @@ import {
} from '@nestjs/common';
import { type QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { RequireQuota } from '@modules/subscriptions/presentation/decorators/require-quota.decorator';
import { QuotaGuard } from '@modules/subscriptions/presentation/guards/quota.guard';
import { JwtAuthGuard } from '@modules/auth';
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
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 { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
@@ -17,10 +16,13 @@ import { type MarketReportDto } from '../../application/queries/get-market-repor
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
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 { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
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';
@ApiTags('analytics')
@Controller('analytics')
@@ -80,4 +82,18 @@ export class AnalyticsController {
new GetDistrictStatsQuery(dto.city, dto.period),
);
}
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Get('valuation')
@ApiOperation({ summary: 'Get automated property valuation (AVM)' })
@ApiResponse({ status: 200, description: 'Valuation estimate retrieved' })
@ApiResponse({ status: 400, description: 'Invalid parameters — provide propertyId or (lat, lng, areaM2)' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
async getValuation(@Query() dto: GetValuationDto): Promise<ValuationDto> {
return this.queryBus.execute(
new GetValuationQuery(dto.propertyId, dto.latitude, dto.longitude, dto.areaM2, dto.propertyType),
);
}
}

View File

@@ -0,0 +1,34 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { PropertyType } from '@prisma/client';
import { Transform } from 'class-transformer';
import { IsEnum, IsNumber, IsOptional, IsString, ValidateIf } from 'class-validator';
export class GetValuationDto {
@ApiPropertyOptional({ description: 'Property ID for valuation' })
@IsOptional()
@IsString()
propertyId?: string;
@ApiPropertyOptional({ description: 'Latitude (required if no propertyId)' })
@ValidateIf((o) => !o.propertyId)
@IsNumber()
@Transform(({ value }) => (value != null ? parseFloat(value) : undefined))
latitude?: number;
@ApiPropertyOptional({ description: 'Longitude (required if no propertyId)' })
@ValidateIf((o) => !o.propertyId)
@IsNumber()
@Transform(({ value }) => (value != null ? parseFloat(value) : undefined))
longitude?: number;
@ApiPropertyOptional({ description: 'Area in square meters (required if no propertyId)' })
@ValidateIf((o) => !o.propertyId)
@IsNumber()
@Transform(({ value }) => (value != null ? parseFloat(value) : undefined))
areaM2?: number;
@ApiPropertyOptional({ enum: PropertyType, description: 'Property type filter' })
@IsOptional()
@IsEnum(PropertyType)
propertyType?: PropertyType;
}

View File

@@ -2,3 +2,4 @@ export { GetMarketReportDto } from './get-market-report.dto';
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';