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

@@ -7,10 +7,13 @@ import { GetDistrictStatsHandler } from './application/queries/get-district-stat
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository';
import { AVM_SERVICE } from './domain/services/avm-service';
import { PrismaMarketIndexRepository } from './infrastructure/repositories/prisma-market-index.repository';
import { PrismaValuationRepository } from './infrastructure/repositories/prisma-valuation.repository';
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
import { AnalyticsController } from './presentation/controllers/analytics.controller';
const CommandHandlers = [
@@ -24,6 +27,7 @@ const QueryHandlers = [
GetHeatmapHandler,
GetPriceTrendHandler,
GetDistrictStatsHandler,
GetValuationHandler,
];
@Module({
@@ -33,11 +37,12 @@ const QueryHandlers = [
// Repositories
{ provide: MARKET_INDEX_REPOSITORY, useClass: PrismaMarketIndexRepository },
{ provide: VALUATION_REPOSITORY, useClass: PrismaValuationRepository },
{ provide: AVM_SERVICE, useClass: PrismaAVMService },
// CQRS
...CommandHandlers,
...QueryHandlers,
],
exports: [MARKET_INDEX_REPOSITORY, VALUATION_REPOSITORY],
exports: [MARKET_INDEX_REPOSITORY, VALUATION_REPOSITORY, AVM_SERVICE],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,91 @@
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { type IAVMService, type ValuationResult } from '../../domain/services/avm-service';
import { GetValuationHandler } from '../queries/get-valuation/get-valuation.handler';
import { GetValuationQuery } from '../queries/get-valuation/get-valuation.query';
describe('GetValuationHandler', () => {
let handler: GetValuationHandler;
let mockAvm: { [K in keyof IAVMService]: ReturnType<typeof vi.fn> };
const sampleResult: ValuationResult = {
estimatedPrice: '5000000000',
confidence: 0.85,
pricePerM2: 75000000,
comparables: [
{
propertyId: 'prop-1',
address: '123 Nguyễn Huệ',
district: 'Quận 1',
priceVND: '4800000000',
pricePerM2: 72000000,
areaM2: 66.7,
propertyType: 'APARTMENT',
distanceMeters: 350,
soldAt: '2026-03-01T00:00:00.000Z',
},
],
modelVersion: 'avm-v1.0',
};
beforeEach(() => {
mockAvm = {
estimateValue: vi.fn(),
getComparables: vi.fn(),
};
const mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as CacheService;
handler = new GetValuationHandler(mockAvm as any, mockCache);
});
it('returns valuation by propertyId', async () => {
mockAvm.estimateValue.mockResolvedValue(sampleResult);
const query = new GetValuationQuery('prop-123');
const result = await handler.execute(query);
expect(result.estimatedPrice).toBe('5000000000');
expect(result.confidence).toBe(0.85);
expect(result.comparables).toHaveLength(1);
expect(mockAvm.estimateValue).toHaveBeenCalledWith({
propertyId: 'prop-123',
latitude: undefined,
longitude: undefined,
areaM2: undefined,
propertyType: undefined,
});
});
it('returns valuation by coordinates', async () => {
mockAvm.estimateValue.mockResolvedValue(sampleResult);
const query = new GetValuationQuery(undefined, 10.762, 106.66, 80, 'APARTMENT');
const result = await handler.execute(query);
expect(result.estimatedPrice).toBe('5000000000');
expect(mockAvm.estimateValue).toHaveBeenCalledWith({
propertyId: undefined,
latitude: 10.762,
longitude: 106.66,
areaM2: 80,
propertyType: 'APARTMENT',
});
});
it('returns zero confidence when insufficient comparables', async () => {
const lowConfidence: ValuationResult = {
estimatedPrice: '0',
confidence: 0,
pricePerM2: 0,
comparables: [],
modelVersion: 'avm-v1.0',
};
mockAvm.estimateValue.mockResolvedValue(lowConfidence);
const query = new GetValuationQuery('prop-remote');
const result = await handler.execute(query);
expect(result.estimatedPrice).toBe('0');
expect(result.confidence).toBe(0);
});
});

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { type CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { type CacheService, CachePrefix } from '@modules/shared';
import { MarketIndexEntity } from '../../../domain/entities/market-index.entity';
import {
MARKET_INDEX_REPOSITORY,

View File

@@ -15,3 +15,5 @@ export { GetPriceTrendQuery } from './queries/get-price-trend/get-price-trend.qu
export { GetPriceTrendHandler, type PriceTrendDto } from './queries/get-price-trend/get-price-trend.handler';
export { GetDistrictStatsQuery } from './queries/get-district-stats/get-district-stats.query';
export { GetDistrictStatsHandler, type DistrictStatsDto } from './queries/get-district-stats/get-district-stats.handler';
export { GetValuationQuery } from './queries/get-valuation/get-valuation.query';
export { GetValuationHandler, type ValuationDto } from './queries/get-valuation/get-valuation.handler';

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import {
MARKET_INDEX_REPOSITORY,
type IMarketIndexRepository,

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import {
MARKET_INDEX_REPOSITORY,
type IMarketIndexRepository,

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import {
MARKET_INDEX_REPOSITORY,
type IMarketIndexRepository,

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import {
MARKET_INDEX_REPOSITORY,
type IMarketIndexRepository,

View File

@@ -0,0 +1,45 @@
import { Inject } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import {
AVM_SERVICE,
type IAVMService,
type ValuationResult,
} from '../../../domain/services/avm-service';
import { GetValuationQuery } from './get-valuation.query';
export type ValuationDto = ValuationResult;
@QueryHandler(GetValuationQuery)
export class GetValuationHandler implements IQueryHandler<GetValuationQuery> {
constructor(
@Inject(AVM_SERVICE) private readonly avmService: IAVMService,
private readonly cache: CacheService,
) {}
async execute(query: GetValuationQuery): Promise<ValuationDto> {
const cacheKey = CacheService.buildKey(
CachePrefix.VALUATION,
query.propertyId ?? '',
query.latitude?.toString(),
query.longitude?.toString(),
query.areaM2?.toString(),
query.propertyType,
);
return this.cache.getOrSet(
cacheKey,
async () => {
return this.avmService.estimateValue({
propertyId: query.propertyId,
latitude: query.latitude,
longitude: query.longitude,
areaM2: query.areaM2,
propertyType: query.propertyType,
});
},
CacheTTL.MARKET_DATA,
'valuation',
);
}
}

View File

@@ -0,0 +1,11 @@
import { type PropertyType } from '@prisma/client';
export class GetValuationQuery {
constructor(
public readonly propertyId?: string,
public readonly latitude?: number,
public readonly longitude?: number,
public readonly areaM2?: number,
public readonly propertyType?: PropertyType,
) {}
}

View File

@@ -1,5 +1,5 @@
import { type PropertyType } from '@prisma/client';
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
import { AggregateRoot } from '@modules/shared';
import { MarketIndexUpdatedEvent } from '../events/market-index-updated.event';
export interface MarketIndexProps {

View File

@@ -1,4 +1,4 @@
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
import { AggregateRoot } from '@modules/shared';
export interface ValuationProps {
propertyId: string;

View File

@@ -1,4 +1,4 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
export class MarketIndexUpdatedEvent implements DomainEvent {
readonly eventName = 'market-index.updated';

View File

@@ -1,3 +1,4 @@
export * from './entities';
export * from './events';
export * from './repositories';
export * from './services';

View File

@@ -0,0 +1,39 @@
import { type PropertyType } from '@prisma/client';
export const AVM_SERVICE = Symbol('AVM_SERVICE');
export interface AVMParams {
propertyId?: string;
latitude?: number;
longitude?: number;
areaM2?: number;
propertyType?: PropertyType;
yearBuilt?: number;
floor?: number;
totalFloors?: number;
}
export interface Comparable {
propertyId: string;
address: string;
district: string;
priceVND: string;
pricePerM2: number;
areaM2: number;
propertyType: PropertyType;
distanceMeters: number;
soldAt: string;
}
export interface ValuationResult {
estimatedPrice: string;
confidence: number;
pricePerM2: number;
comparables: Comparable[];
modelVersion: string;
}
export interface IAVMService {
estimateValue(params: AVMParams): Promise<ValuationResult>;
getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]>;
}

View File

@@ -0,0 +1 @@
export { AVM_SERVICE, type IAVMService, type AVMParams, type ValuationResult, type Comparable } from './avm-service';

View File

@@ -0,0 +1,101 @@
import { type PrismaService } from '@modules/shared';
import { PrismaAVMService } from '../services/prisma-avm.service';
describe('PrismaAVMService', () => {
let service: PrismaAVMService;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn>; $queryRawUnsafe: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
$queryRaw: vi.fn(),
$queryRawUnsafe: vi.fn(),
};
service = new PrismaAVMService(mockPrisma as unknown as PrismaService);
});
describe('estimateValue', () => {
it('throws when neither propertyId nor coordinates provided', async () => {
await expect(service.estimateValue({})).rejects.toThrow(
'Either propertyId or (latitude, longitude, areaM2) must be provided',
);
});
it('throws when property not found', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
await expect(service.estimateValue({ propertyId: 'non-existent' })).rejects.toThrow(
'Property not found: non-existent',
);
});
it('returns zero confidence when fewer than 3 comparables', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
]);
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
]);
const result = await service.estimateValue({ propertyId: 'prop-1' });
expect(result.confidence).toBe(0);
expect(result.estimatedPrice).toBe('0');
expect(result.comparables).toHaveLength(1);
});
it('calculates weighted valuation with sufficient comparables', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
]);
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
]);
const result = await service.estimateValue({ propertyId: 'prop-1' });
expect(result.confidence).toBeGreaterThan(0);
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
expect(result.pricePerM2).toBeGreaterThan(0);
expect(result.comparables).toHaveLength(3);
expect(result.modelVersion).toBe('avm-v1.0');
});
it('uses coordinates directly when no propertyId', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
]);
const result = await service.estimateValue({
latitude: 10.762,
longitude: 106.66,
areaM2: 80,
propertyType: 'APARTMENT',
});
expect(result.confidence).toBeGreaterThan(0);
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
expect(mockPrisma.$queryRaw).not.toHaveBeenCalled();
});
});
describe('getComparables', () => {
it('returns comparables for a property', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
]);
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
]);
const result = await service.getComparables('prop-1', 3000);
expect(result).toHaveLength(1);
expect(result[0].propertyId).toBe('p1');
expect(result[0].distanceMeters).toBe(200);
});
});
});

View File

@@ -1 +1,2 @@
export * from './repositories';
export * from './services';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type MarketIndex as PrismaMarketIndex, type PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import { MarketIndexEntity, type MarketIndexProps } from '../../domain/entities/market-index.entity';
import {
type IMarketIndexRepository,

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Prisma, type Valuation as PrismaValuation } from '@prisma/client';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import { ValuationEntity, type ValuationProps } from '../../domain/entities/valuation.entity';
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';

View File

@@ -0,0 +1 @@
export { PrismaAVMService } from './prisma-avm.service';

View File

@@ -0,0 +1,224 @@
import { Injectable } from '@nestjs/common';
import { type PropertyType } from '@prisma/client';
import type { PrismaService } from '@modules/shared';
import {
type IAVMService,
type AVMParams,
type ValuationResult,
type Comparable,
} from '../../domain/services/avm-service';
const MODEL_VERSION = 'avm-v1.0';
const DEFAULT_RADIUS_METERS = 2000;
const MIN_COMPARABLES = 3;
interface RawComparable {
property_id: string;
address: string;
district: string;
price_vnd: bigint;
price_per_m2: number;
area_m2: number;
property_type: PropertyType;
distance_meters: number;
published_at: Date;
}
interface PropertyLocation {
latitude: number;
longitude: number;
areaM2: number;
propertyType: PropertyType;
yearBuilt: number | null;
floor: number | null;
totalFloors: number | null;
}
@Injectable()
export class PrismaAVMService implements IAVMService {
constructor(private readonly prisma: PrismaService) {}
async estimateValue(params: AVMParams): Promise<ValuationResult> {
let lat: number;
let lng: number;
let areaM2: number;
let propertyType: PropertyType | undefined = params.propertyType;
let yearBuilt: number | null = params.yearBuilt ?? null;
let floor: number | null = params.floor ?? null;
let totalFloors: number | null = params.totalFloors ?? null;
if (params.propertyId) {
const loc = await this.getPropertyLocation(params.propertyId);
lat = loc.latitude;
lng = loc.longitude;
areaM2 = params.areaM2 ?? loc.areaM2;
propertyType = propertyType ?? loc.propertyType;
yearBuilt = yearBuilt ?? loc.yearBuilt;
floor = floor ?? loc.floor;
totalFloors = totalFloors ?? loc.totalFloors;
} else if (params.latitude != null && params.longitude != null && params.areaM2 != null) {
lat = params.latitude;
lng = params.longitude;
areaM2 = params.areaM2;
} else {
throw new Error('Either propertyId or (latitude, longitude, areaM2) must be provided');
}
const comparables = await this.findComparables(lat, lng, propertyType, DEFAULT_RADIUS_METERS);
if (comparables.length < MIN_COMPARABLES) {
return {
estimatedPrice: '0',
confidence: 0,
pricePerM2: 0,
comparables: comparables.map((c) => this.toComparableDto(c)),
modelVersion: MODEL_VERSION,
};
}
const { pricePerM2, confidence } = this.calculateWeightedPrice(
comparables,
areaM2,
propertyType,
yearBuilt,
floor,
totalFloors,
);
const estimatedPrice = BigInt(Math.round(pricePerM2 * areaM2));
return {
estimatedPrice: estimatedPrice.toString(),
confidence,
pricePerM2,
comparables: comparables.map((c) => this.toComparableDto(c)),
modelVersion: MODEL_VERSION,
};
}
async getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]> {
const loc = await this.getPropertyLocation(propertyId);
const raws = await this.findComparables(loc.latitude, loc.longitude, loc.propertyType, radiusMeters);
return raws.map((c) => this.toComparableDto(c));
}
private async getPropertyLocation(propertyId: string): Promise<PropertyLocation> {
const rows = await this.prisma.$queryRaw<
Array<{
latitude: number;
longitude: number;
areaM2: number;
propertyType: PropertyType;
yearBuilt: number | null;
floor: number | null;
totalFloors: number | null;
}>
>`
SELECT
ST_Y(location::geometry) AS "latitude",
ST_X(location::geometry) AS "longitude",
"areaM2",
"propertyType",
"yearBuilt",
"floor",
"totalFloors"
FROM "Property"
WHERE id = ${propertyId}
LIMIT 1
`;
const row = rows[0];
if (!row) {
throw new Error(`Property not found: ${propertyId}`);
}
return row;
}
private async findComparables(
lat: number,
lng: number,
propertyType: PropertyType | undefined,
radiusMeters: number,
): Promise<RawComparable[]> {
const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : '';
return this.prisma.$queryRawUnsafe<RawComparable[]>(
`
SELECT
p.id AS property_id,
p.address,
p.district,
l."priceVND" AS price_vnd,
l."pricePerM2" AS price_per_m2,
p."areaM2" AS area_m2,
p."propertyType" AS property_type,
ST_Distance(
p.location::geography,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography
) AS distance_meters,
l."publishedAt" AS published_at
FROM "Property" p
JOIN "Listing" l ON l."propertyId" = p.id
WHERE l.status = 'ACTIVE'
AND l."publishedAt" IS NOT NULL
AND ST_DWithin(
p.location::geography,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
$3
)
${typeFilter}
ORDER BY distance_meters ASC
LIMIT 20
`,
lng,
lat,
radiusMeters,
);
}
private calculateWeightedPrice(
comparables: RawComparable[],
_areaM2: number,
_propertyType: PropertyType | undefined,
_yearBuilt: number | null,
_floor: number | null,
_totalFloors: number | null,
): { pricePerM2: number; confidence: number } {
// Distance-weighted average: closer properties have more weight
let totalWeight = 0;
let weightedSum = 0;
for (const comp of comparables) {
const distance = Math.max(comp.distance_meters, 1);
const weight = 1 / distance;
weightedSum += comp.price_per_m2 * weight;
totalWeight += weight;
}
const pricePerM2 = totalWeight > 0 ? weightedSum / totalWeight : 0;
// Confidence based on number of comparables and distance spread
const maxComparables = 15;
const countFactor = Math.min(comparables.length / maxComparables, 1);
const avgDistance =
comparables.reduce((sum, c) => sum + c.distance_meters, 0) / comparables.length;
const distanceFactor = Math.max(0, 1 - avgDistance / DEFAULT_RADIUS_METERS);
const confidence = Math.round((countFactor * 0.6 + distanceFactor * 0.4) * 100) / 100;
return { pricePerM2: Math.round(pricePerM2), confidence };
}
private toComparableDto(raw: RawComparable): Comparable {
return {
propertyId: raw.property_id,
address: raw.address,
district: raw.district,
priceVND: raw.price_vnd.toString(),
pricePerM2: raw.price_per_m2,
areaM2: raw.area_m2,
propertyType: raw.property_type,
distanceMeters: Math.round(raw.distance_meters),
soldAt: raw.published_at.toISOString(),
};
}
}

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';