Compare commits
7 Commits
master
...
f5da1d9f01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5da1d9f01 | ||
|
|
41e855e11e | ||
|
|
0cd3dc82fd | ||
|
|
632efbe2c6 | ||
|
|
4ee01294a9 | ||
|
|
b6a5a2c1f5 | ||
|
|
99385d8263 |
@@ -1,5 +1,11 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import {
|
||||||
|
type EmailChangeRequestedEvent,
|
||||||
|
type EmailChangedEvent,
|
||||||
|
type PhoneChangeRequestedEvent,
|
||||||
|
type PhoneChangedEvent,
|
||||||
|
} from '@modules/auth';
|
||||||
import { type LoggerService } from '@modules/shared';
|
import { type LoggerService } from '@modules/shared';
|
||||||
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
||||||
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
||||||
@@ -68,6 +74,54 @@ export class AdminAuditListener {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sensitive user profile field changes (OTP-gated) ─────────────────
|
||||||
|
|
||||||
|
@OnEvent('user.email_change_requested', { async: true })
|
||||||
|
async onEmailChangeRequested(event: EmailChangeRequestedEvent): Promise<void> {
|
||||||
|
// Actor is the user themselves — they initiated the change.
|
||||||
|
// Do NOT include the OTP code in the audit metadata.
|
||||||
|
await this.log(
|
||||||
|
'EMAIL_CHANGE_REQUESTED',
|
||||||
|
event.aggregateId,
|
||||||
|
event.aggregateId,
|
||||||
|
'USER',
|
||||||
|
{ newEmail: event.newEmail },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('user.phone_change_requested', { async: true })
|
||||||
|
async onPhoneChangeRequested(event: PhoneChangeRequestedEvent): Promise<void> {
|
||||||
|
await this.log(
|
||||||
|
'PHONE_CHANGE_REQUESTED',
|
||||||
|
event.aggregateId,
|
||||||
|
event.aggregateId,
|
||||||
|
'USER',
|
||||||
|
{ newPhone: event.newPhone },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('user.email_changed', { async: true })
|
||||||
|
async onEmailChanged(event: EmailChangedEvent): Promise<void> {
|
||||||
|
await this.log(
|
||||||
|
'EMAIL_CHANGED',
|
||||||
|
event.aggregateId,
|
||||||
|
event.aggregateId,
|
||||||
|
'USER',
|
||||||
|
{ oldEmail: event.oldEmail, newEmail: event.newEmail },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('user.phone_changed', { async: true })
|
||||||
|
async onPhoneChanged(event: PhoneChangedEvent): Promise<void> {
|
||||||
|
await this.log(
|
||||||
|
'PHONE_CHANGED',
|
||||||
|
event.aggregateId,
|
||||||
|
event.aggregateId,
|
||||||
|
'USER',
|
||||||
|
{ oldPhone: event.oldPhone, newPhone: event.newPhone },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async log(
|
private async log(
|
||||||
action: string,
|
action: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborh
|
|||||||
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
||||||
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
||||||
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
|
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
|
||||||
|
import { ValuationExplanationHandler } from './application/queries/valuation-explanation/valuation-explanation.handler';
|
||||||
import { ValuationHistoryHandler } from './application/queries/valuation-history/valuation-history.handler';
|
import { ValuationHistoryHandler } from './application/queries/valuation-history/valuation-history.handler';
|
||||||
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
|
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
|
||||||
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository';
|
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository';
|
||||||
@@ -43,6 +44,7 @@ const QueryHandlers = [
|
|||||||
BatchValuationHandler,
|
BatchValuationHandler,
|
||||||
ValuationHistoryHandler,
|
ValuationHistoryHandler,
|
||||||
ValuationComparisonHandler,
|
ValuationComparisonHandler,
|
||||||
|
ValuationExplanationHandler,
|
||||||
GetNeighborhoodScoreHandler,
|
GetNeighborhoodScoreHandler,
|
||||||
IndustrialValuationHandler,
|
IndustrialValuationHandler,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
|
import { DomainException, NotFoundException } from '@modules/shared';
|
||||||
|
import { ValuationEntity } from '../../domain/entities/valuation.entity';
|
||||||
|
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
|
||||||
|
import { ValuationExplanationHandler } from '../queries/valuation-explanation/valuation-explanation.handler';
|
||||||
|
import { ValuationExplanationQuery } from '../queries/valuation-explanation/valuation-explanation.query';
|
||||||
|
|
||||||
|
describe('ValuationExplanationHandler', () => {
|
||||||
|
let handler: ValuationExplanationHandler;
|
||||||
|
let mockRepo: { [K in keyof IValuationRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByPropertyId: vi.fn(),
|
||||||
|
findLatestByPropertyId: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
};
|
||||||
|
mockLogger = { error: vi.fn() };
|
||||||
|
const mockCache = {
|
||||||
|
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
|
||||||
|
} as unknown as CacheService;
|
||||||
|
handler = new ValuationExplanationHandler(
|
||||||
|
mockRepo as any,
|
||||||
|
mockCache,
|
||||||
|
mockLogger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns explanation with top drivers from drivers array', async () => {
|
||||||
|
const entity = new ValuationEntity(
|
||||||
|
'val-1',
|
||||||
|
{
|
||||||
|
propertyId: 'prop-1',
|
||||||
|
estimatedPrice: 5_000_000_000n,
|
||||||
|
confidence: 0.87,
|
||||||
|
pricePerM2: 75_000_000,
|
||||||
|
comparables: [
|
||||||
|
{ propertyId: 'c1', address: 'A', district: 'D1', priceVND: '5000000000', pricePerM2: 75000000, areaM2: 60, propertyType: 'APARTMENT', distanceMeters: 100, soldAt: '2026-01-01' },
|
||||||
|
],
|
||||||
|
features: {
|
||||||
|
drivers: [
|
||||||
|
{ feature: 'location', importance: 0.45 },
|
||||||
|
{ feature: 'area', importance: -0.22 },
|
||||||
|
{ feature: 'year_built', importance: 0.12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
modelVersion: 'avm-v2.0',
|
||||||
|
},
|
||||||
|
new Date('2026-04-15T10:00:00Z'),
|
||||||
|
);
|
||||||
|
mockRepo.findById.mockResolvedValue(entity);
|
||||||
|
|
||||||
|
const result = await handler.execute(new ValuationExplanationQuery('val-1'));
|
||||||
|
|
||||||
|
expect(result.valuationId).toBe('val-1');
|
||||||
|
expect(result.propertyId).toBe('prop-1');
|
||||||
|
expect(result.modelVersion).toBe('avm-v2.0');
|
||||||
|
expect(result.estimatedPrice).toBe('5000000000');
|
||||||
|
expect(result.topDrivers).toHaveLength(3);
|
||||||
|
// Sorted by |importance| descending
|
||||||
|
expect(result.topDrivers[0]!.feature).toBe('location');
|
||||||
|
expect(result.topDrivers[1]!.feature).toBe('area');
|
||||||
|
expect(result.comparables).toHaveLength(1);
|
||||||
|
expect(result.confidenceExplanation).toContain('cao');
|
||||||
|
expect(result.valuedAt).toBe('2026-04-15T10:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to object-of-numbers feature importances', async () => {
|
||||||
|
const entity = new ValuationEntity(
|
||||||
|
'val-2',
|
||||||
|
{
|
||||||
|
propertyId: 'prop-2',
|
||||||
|
estimatedPrice: 3_000_000_000n,
|
||||||
|
confidence: 0.55,
|
||||||
|
pricePerM2: 50_000_000,
|
||||||
|
comparables: [],
|
||||||
|
features: { location: 0.6, area: 0.2, foo: 'not-number' },
|
||||||
|
modelVersion: 'avm-v1.0',
|
||||||
|
},
|
||||||
|
new Date('2026-03-01T00:00:00Z'),
|
||||||
|
);
|
||||||
|
mockRepo.findById.mockResolvedValue(entity);
|
||||||
|
|
||||||
|
const result = await handler.execute(new ValuationExplanationQuery('val-2'));
|
||||||
|
|
||||||
|
expect(result.topDrivers.map((d) => d.feature)).toEqual(['location', 'area']);
|
||||||
|
expect(result.comparables).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when valuation does not exist', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handler.execute(new ValuationExplanationQuery('missing')),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-throws DomainException directly', async () => {
|
||||||
|
const domainError = new DomainException('NOT_FOUND' as any, 'Valuation not found');
|
||||||
|
mockRepo.findById.mockRejectedValue(domainError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handler.execute(new ValuationExplanationQuery('v-err')),
|
||||||
|
).rejects.toThrow(DomainException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps unexpected errors in InternalServerErrorException', async () => {
|
||||||
|
mockRepo.findById.mockRejectedValue(new Error('DB down'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handler.execute(new ValuationExplanationQuery('v-err')),
|
||||||
|
).rejects.toThrow(InternalServerErrorException);
|
||||||
|
expect(mockLogger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
CacheService,
|
||||||
|
CachePrefix,
|
||||||
|
CacheTTL,
|
||||||
|
DomainException,
|
||||||
|
NotFoundException,
|
||||||
|
type LoggerService,
|
||||||
|
} from '@modules/shared';
|
||||||
|
import {
|
||||||
|
VALUATION_REPOSITORY,
|
||||||
|
type IValuationRepository,
|
||||||
|
} from '../../../domain/repositories/valuation.repository';
|
||||||
|
import { type Comparable } from '../../../domain/services/avm-service';
|
||||||
|
import { generateConfidenceExplanation } from '../../../infrastructure/services/confidence-explanation.helper';
|
||||||
|
import { ValuationExplanationQuery } from './valuation-explanation.query';
|
||||||
|
|
||||||
|
/** A single feature contribution in the explanation. */
|
||||||
|
export interface FeatureContribution {
|
||||||
|
feature: string;
|
||||||
|
importance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationExplanationDto {
|
||||||
|
valuationId: string;
|
||||||
|
propertyId: string;
|
||||||
|
modelVersion: string;
|
||||||
|
confidence: number;
|
||||||
|
confidenceExplanation: string;
|
||||||
|
estimatedPrice: string;
|
||||||
|
pricePerM2: number;
|
||||||
|
topDrivers: FeatureContribution[];
|
||||||
|
comparables: Comparable[];
|
||||||
|
valuedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts feature importances from the stored features payload. The payload
|
||||||
|
* shape varies by model (residential v1 comparable-weighted, residential v2
|
||||||
|
* ensemble, industrial). We defensively inspect the known keys and fall back
|
||||||
|
* to deriving importance from `features.features` (key => weight) objects.
|
||||||
|
*/
|
||||||
|
function extractTopDrivers(features: unknown, limit = 5): FeatureContribution[] {
|
||||||
|
if (features == null || typeof features !== 'object') return [];
|
||||||
|
const asRecord = features as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Preferred: pre-computed drivers array (industrial AVM, AI service output).
|
||||||
|
const drivers = asRecord['drivers'] ?? asRecord['top_drivers'] ?? asRecord['topDrivers'];
|
||||||
|
if (Array.isArray(drivers)) {
|
||||||
|
return drivers
|
||||||
|
.map((d) => {
|
||||||
|
if (!d || typeof d !== 'object') return null;
|
||||||
|
const rec = d as Record<string, unknown>;
|
||||||
|
const feature = rec['feature'] ?? rec['name'];
|
||||||
|
const importance = rec['importance'] ?? rec['contribution'] ?? rec['weight'];
|
||||||
|
if (typeof feature !== 'string' || typeof importance !== 'number') return null;
|
||||||
|
return { feature, importance } as FeatureContribution;
|
||||||
|
})
|
||||||
|
.filter((d): d is FeatureContribution => d !== null)
|
||||||
|
.sort((a, b) => Math.abs(b.importance) - Math.abs(a.importance))
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: features is an object of {feature: importance}.
|
||||||
|
const entries = Object.entries(asRecord).filter(
|
||||||
|
([, v]) => typeof v === 'number',
|
||||||
|
) as Array<[string, number]>;
|
||||||
|
return entries
|
||||||
|
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([feature, importance]) => ({ feature, importance }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractComparables(comparables: unknown): Comparable[] {
|
||||||
|
if (!Array.isArray(comparables)) return [];
|
||||||
|
return comparables.filter(
|
||||||
|
(c): c is Comparable => c != null && typeof c === 'object',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(ValuationExplanationQuery)
|
||||||
|
export class ValuationExplanationHandler
|
||||||
|
implements IQueryHandler<ValuationExplanationQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(VALUATION_REPOSITORY)
|
||||||
|
private readonly valuationRepo: IValuationRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: ValuationExplanationQuery): Promise<ValuationExplanationDto> {
|
||||||
|
try {
|
||||||
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.VALUATION,
|
||||||
|
'explain',
|
||||||
|
query.valuationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this.cache.getOrSet(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const entity = await this.valuationRepo.findById(query.valuationId);
|
||||||
|
if (!entity) {
|
||||||
|
throw new NotFoundException('Valuation', query.valuationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparables = extractComparables(entity.comparables);
|
||||||
|
const topDrivers = extractTopDrivers(entity.features);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valuationId: entity.id,
|
||||||
|
propertyId: entity.propertyId,
|
||||||
|
modelVersion: entity.modelVersion,
|
||||||
|
confidence: entity.confidence,
|
||||||
|
confidenceExplanation: generateConfidenceExplanation(
|
||||||
|
entity.confidence,
|
||||||
|
comparables.length,
|
||||||
|
),
|
||||||
|
estimatedPrice: entity.estimatedPrice.toString(),
|
||||||
|
pricePerM2: entity.pricePerM2,
|
||||||
|
topDrivers,
|
||||||
|
comparables,
|
||||||
|
valuedAt: entity.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
CacheTTL.MARKET_DATA,
|
||||||
|
'valuation_explanation',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Valuation explanation failed for ${query.valuationId}: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'Không thể lấy giải thích định giá. Vui lòng thử lại sau.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class ValuationExplanationQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly valuationId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { type QueryBus } from '@nestjs/cqrs';
|
|||||||
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
|
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
|
||||||
import { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.query';
|
import { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.query';
|
||||||
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
|
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
|
||||||
|
import { ValuationExplanationQuery } from '../../application/queries/valuation-explanation/valuation-explanation.query';
|
||||||
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
||||||
import { AvmController } from '../controllers/avm.controller';
|
import { AvmController } from '../controllers/avm.controller';
|
||||||
|
|
||||||
@@ -94,6 +95,31 @@ describe('AvmController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /avm/explain', () => {
|
||||||
|
it('dispatches ValuationExplanationQuery with valuationId', async () => {
|
||||||
|
const expected = {
|
||||||
|
valuationId: 'val-1',
|
||||||
|
propertyId: 'prop-1',
|
||||||
|
modelVersion: 'avm-v2.0',
|
||||||
|
confidence: 0.85,
|
||||||
|
confidenceExplanation: 'Mức độ tin cậy cao (85%).',
|
||||||
|
estimatedPrice: '5000000000',
|
||||||
|
pricePerM2: 75000000,
|
||||||
|
topDrivers: [{ feature: 'location', importance: 0.45 }],
|
||||||
|
comparables: [],
|
||||||
|
valuedAt: '2026-04-15T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
mockQueryBus.execute.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await controller.explain({ valuationId: 'val-1' } as any);
|
||||||
|
|
||||||
|
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||||
|
new ValuationExplanationQuery('val-1'),
|
||||||
|
);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /avm/industrial', () => {
|
describe('POST /avm/industrial', () => {
|
||||||
const industrialDto = {
|
const industrialDto = {
|
||||||
province: 'Bình Dương',
|
province: 'Bình Dương',
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ import { type IndustrialValuationDto as IndustrialValuationResultDto } from '../
|
|||||||
import { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.query';
|
import { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.query';
|
||||||
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
|
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
|
||||||
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
|
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
|
||||||
|
import { type ValuationExplanationDto as ValuationExplanationResultDto } from '../../application/queries/valuation-explanation/valuation-explanation.handler';
|
||||||
|
import { ValuationExplanationQuery } from '../../application/queries/valuation-explanation/valuation-explanation.query';
|
||||||
import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
|
import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
|
||||||
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
||||||
import { type AvmCompareQueryDto } from '../dto/avm-compare-query.dto';
|
import { type AvmCompareQueryDto } from '../dto/avm-compare-query.dto';
|
||||||
|
import { type AvmExplainQueryDto } from '../dto/avm-explain-query.dto';
|
||||||
import { type BatchValuationDto } from '../dto/batch-valuation.dto';
|
import { type BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||||
import { type IndustrialValuationDto } from '../dto/industrial-valuation.dto';
|
import { type IndustrialValuationDto } from '../dto/industrial-valuation.dto';
|
||||||
import { type ValuationHistoryDto } from '../dto/valuation-history.dto';
|
import { type ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||||
@@ -87,6 +90,27 @@ export class AvmController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
|
@Get('explain')
|
||||||
|
@ApiOperation({ summary: 'Explain a stored valuation — top drivers, comparables, confidence' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'valuationId',
|
||||||
|
description: 'Stored valuation ID to explain',
|
||||||
|
example: 'val-abc123',
|
||||||
|
type: String,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'Valuation explanation with drivers and comparables' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid parameters' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Valuation not found' })
|
||||||
|
async explain(@Query() dto: AvmExplainQueryDto): Promise<ValuationExplanationResultDto> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new ValuationExplanationQuery(dto.valuationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
|
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
|
||||||
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
|
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class AvmExplainQueryDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'ID of the stored valuation to explain',
|
||||||
|
example: 'val-abc123',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
valuationId!: string;
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service';
|
||||||
import { UserEntity } from '../../domain/entities/user.entity';
|
import { UserEntity } from '../../domain/entities/user.entity';
|
||||||
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||||
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
||||||
import { Phone } from '../../domain/value-objects/phone.vo';
|
import { Phone } from '../../domain/value-objects/phone.vo';
|
||||||
import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service';
|
|
||||||
import { GenerateKycUploadUrlsCommand } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
|
import { GenerateKycUploadUrlsCommand } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
|
||||||
import { GenerateKycUploadUrlsHandler } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
|
import { GenerateKycUploadUrlsHandler } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ describe('GenerateKycUploadUrlsHandler', () => {
|
|||||||
getPresignedUploadUrl: vi.fn(),
|
getPresignedUploadUrl: vi.fn(),
|
||||||
generatePresignedUpload: vi.fn(),
|
generatePresignedUpload: vi.fn(),
|
||||||
getPublicUrl: vi.fn(),
|
getPublicUrl: vi.fn(),
|
||||||
|
isTrustedUrl: vi.fn().mockReturnValue(true),
|
||||||
};
|
};
|
||||||
mockLogger = {
|
mockLogger = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service';
|
||||||
import { UserEntity } from '../../domain/entities/user.entity';
|
import { UserEntity } from '../../domain/entities/user.entity';
|
||||||
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||||
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
||||||
import { Phone } from '../../domain/value-objects/phone.vo';
|
import { Phone } from '../../domain/value-objects/phone.vo';
|
||||||
import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service';
|
|
||||||
import { SubmitKycCommand } from '../commands/submit-kyc/submit-kyc.command';
|
import { SubmitKycCommand } from '../commands/submit-kyc/submit-kyc.command';
|
||||||
import { SubmitKycHandler } from '../commands/submit-kyc/submit-kyc.handler';
|
import { SubmitKycHandler } from '../commands/submit-kyc/submit-kyc.handler';
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ describe('SubmitKycHandler', () => {
|
|||||||
getPresignedUploadUrl: vi.fn(),
|
getPresignedUploadUrl: vi.fn(),
|
||||||
generatePresignedUpload: vi.fn(),
|
generatePresignedUpload: vi.fn(),
|
||||||
getPublicUrl: vi.fn(),
|
getPublicUrl: vi.fn(),
|
||||||
|
isTrustedUrl: vi.fn().mockReturnValue(true),
|
||||||
};
|
};
|
||||||
mockCache = {
|
mockCache = {
|
||||||
invalidate: vi.fn().mockResolvedValue(undefined),
|
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -137,6 +138,115 @@ describe('SubmitKycHandler', () => {
|
|||||||
expect(result.message).toBeTruthy();
|
expect(result.message).toBeTruthy();
|
||||||
expect(user.kycStatus).toBe('PENDING');
|
expect(user.kycStatus).toBe('PENDING');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects untrusted image URL hosts', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockMediaStorage.isTrustedUrl.mockImplementation((url: string) =>
|
||||||
|
url.startsWith('https://minio/'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new SubmitKycCommand(
|
||||||
|
'user-1',
|
||||||
|
'CCCD',
|
||||||
|
'012345678901',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ frontImageUrl: 'https://evil.example.com/kyc/front.jpg' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow();
|
||||||
|
expect(mockUserRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects image URL that belongs to a different user namespace', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
// host is trusted but path is /bucket/kyc/user-2/...
|
||||||
|
mockMediaStorage.isTrustedUrl.mockReturnValue(true);
|
||||||
|
|
||||||
|
const command = new SubmitKycCommand(
|
||||||
|
'user-1',
|
||||||
|
'CCCD',
|
||||||
|
'012345678901',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ frontImageUrl: 'https://minio/bucket/kyc/user-2/front.jpg' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow(
|
||||||
|
/khong thuoc ve nguoi dung/i,
|
||||||
|
);
|
||||||
|
expect(mockUserRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects image URL outside the kyc folder', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockMediaStorage.isTrustedUrl.mockReturnValue(true);
|
||||||
|
|
||||||
|
const command = new SubmitKycCommand(
|
||||||
|
'user-1',
|
||||||
|
'CCCD',
|
||||||
|
'012345678901',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ frontImageUrl: 'https://minio/bucket/listings/user-1/front.jpg' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow();
|
||||||
|
expect(mockUserRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts URL inside the caller kyc namespace across all three images', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockUserRepo.update.mockResolvedValue(undefined);
|
||||||
|
mockMediaStorage.isTrustedUrl.mockReturnValue(true);
|
||||||
|
|
||||||
|
const command = new SubmitKycCommand(
|
||||||
|
'user-1',
|
||||||
|
'CCCD',
|
||||||
|
'012345678901',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
frontImageUrl: 'https://minio/bucket/kyc/user-1/1-front.jpg',
|
||||||
|
backImageUrl: 'https://minio/bucket/kyc/user-1/1-back.jpg',
|
||||||
|
selfieUrl: 'https://minio/bucket/kyc/user-1/1-selfie.jpg',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
expect(result.message).toBeTruthy();
|
||||||
|
expect(user.kycStatus).toBe('PENDING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when any of the back/selfie URLs escapes the namespace', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockMediaStorage.isTrustedUrl.mockReturnValue(true);
|
||||||
|
|
||||||
|
const command = new SubmitKycCommand(
|
||||||
|
'user-1',
|
||||||
|
'CCCD',
|
||||||
|
'012345678901',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
frontImageUrl: 'https://minio/bucket/kyc/user-1/front.jpg',
|
||||||
|
backImageUrl: 'https://minio/bucket/kyc/user-2/back.jpg',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow();
|
||||||
|
expect(mockUserRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('legacy file upload flow', () => {
|
describe('legacy file upload flow', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { UserEntity } from '../../domain/entities/user.entity';
|
import { UserEntity } from '../../domain/entities/user.entity';
|
||||||
|
import { EmailChangedEvent } from '../../domain/events/email-changed.event';
|
||||||
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||||
import { Email } from '../../domain/value-objects/email.vo';
|
import { Email } from '../../domain/value-objects/email.vo';
|
||||||
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
||||||
@@ -32,6 +33,7 @@ describe('VerifyEmailChangeHandler', () => {
|
|||||||
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
||||||
let mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
|
let mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
|
||||||
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockUserRepo = {
|
mockUserRepo = {
|
||||||
@@ -51,11 +53,13 @@ describe('VerifyEmailChangeHandler', () => {
|
|||||||
set: vi.fn().mockResolvedValue(undefined),
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
|
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
|
||||||
handler = new VerifyEmailChangeHandler(
|
handler = new VerifyEmailChangeHandler(
|
||||||
mockUserRepo as any,
|
mockUserRepo as any,
|
||||||
mockRedis as any,
|
mockRedis as any,
|
||||||
mockCache as any,
|
mockCache as any,
|
||||||
|
mockEventBus as any,
|
||||||
{ error: vi.fn() } as any,
|
{ error: vi.fn() } as any,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -78,6 +82,11 @@ describe('VerifyEmailChangeHandler', () => {
|
|||||||
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('user-1'),
|
expect.stringContaining('user-1'),
|
||||||
);
|
);
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalledWith(expect.any(EmailChangedEvent));
|
||||||
|
const published = mockEventBus.publish.mock.calls[0][0] as EmailChangedEvent;
|
||||||
|
expect(published.aggregateId).toBe('user-1');
|
||||||
|
expect(published.newEmail).toBe('new@example.com');
|
||||||
|
expect(published.oldEmail).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws ValidationException when OTP has expired', async () => {
|
it('throws ValidationException when OTP has expired', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { UserEntity } from '../../domain/entities/user.entity';
|
import { UserEntity } from '../../domain/entities/user.entity';
|
||||||
|
import { PhoneChangedEvent } from '../../domain/events/phone-changed.event';
|
||||||
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||||
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
||||||
import { Phone } from '../../domain/value-objects/phone.vo';
|
import { Phone } from '../../domain/value-objects/phone.vo';
|
||||||
@@ -30,6 +31,7 @@ describe('VerifyPhoneChangeHandler', () => {
|
|||||||
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
||||||
let mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
|
let mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
|
||||||
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockUserRepo = {
|
mockUserRepo = {
|
||||||
@@ -49,11 +51,13 @@ describe('VerifyPhoneChangeHandler', () => {
|
|||||||
set: vi.fn().mockResolvedValue(undefined),
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
|
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
|
||||||
handler = new VerifyPhoneChangeHandler(
|
handler = new VerifyPhoneChangeHandler(
|
||||||
mockUserRepo as any,
|
mockUserRepo as any,
|
||||||
mockRedis as any,
|
mockRedis as any,
|
||||||
mockCache as any,
|
mockCache as any,
|
||||||
|
mockEventBus as any,
|
||||||
{ error: vi.fn() } as any,
|
{ error: vi.fn() } as any,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -76,6 +80,11 @@ describe('VerifyPhoneChangeHandler', () => {
|
|||||||
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('user-1'),
|
expect.stringContaining('user-1'),
|
||||||
);
|
);
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalledWith(expect.any(PhoneChangedEvent));
|
||||||
|
const published = mockEventBus.publish.mock.calls[0][0] as PhoneChangedEvent;
|
||||||
|
expect(published.aggregateId).toBe('user-1');
|
||||||
|
expect(published.newPhone).toBe('+84987654321');
|
||||||
|
expect(published.oldPhone).toBe('+84912345678');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws ValidationException when OTP has expired', async () => {
|
it('throws ValidationException when OTP has expired', async () => {
|
||||||
|
|||||||
@@ -49,6 +49,34 @@ export class SubmitKycHandler implements ICommandHandler<SubmitKycCommand> {
|
|||||||
frontImageUrl = command.imageUrls.frontImageUrl;
|
frontImageUrl = command.imageUrls.frontImageUrl;
|
||||||
backImageUrl = command.imageUrls.backImageUrl ?? null;
|
backImageUrl = command.imageUrls.backImageUrl ?? null;
|
||||||
selfieUrl = command.imageUrls.selfieUrl ?? null;
|
selfieUrl = command.imageUrls.selfieUrl ?? null;
|
||||||
|
|
||||||
|
// Validate URL hosts match our MinIO bucket (reject SSRF / tampering)
|
||||||
|
const untrusted: string[] = [];
|
||||||
|
if (!this.mediaStorage.isTrustedUrl(frontImageUrl)) untrusted.push('frontImageUrl');
|
||||||
|
if (backImageUrl && !this.mediaStorage.isTrustedUrl(backImageUrl)) untrusted.push('backImageUrl');
|
||||||
|
if (selfieUrl && !this.mediaStorage.isTrustedUrl(selfieUrl)) untrusted.push('selfieUrl');
|
||||||
|
if (untrusted.length > 0) {
|
||||||
|
throw new ValidationException(
|
||||||
|
`URL khong hop le (${untrusted.join(', ')}): chi chap nhan URL tu MinIO bucket cua he thong`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL belongs to this user's KYC namespace (reject cross-user tampering)
|
||||||
|
const outsideNamespace: string[] = [];
|
||||||
|
if (!this.isInUserKycNamespace(frontImageUrl, command.userId)) {
|
||||||
|
outsideNamespace.push('frontImageUrl');
|
||||||
|
}
|
||||||
|
if (backImageUrl && !this.isInUserKycNamespace(backImageUrl, command.userId)) {
|
||||||
|
outsideNamespace.push('backImageUrl');
|
||||||
|
}
|
||||||
|
if (selfieUrl && !this.isInUserKycNamespace(selfieUrl, command.userId)) {
|
||||||
|
outsideNamespace.push('selfieUrl');
|
||||||
|
}
|
||||||
|
if (outsideNamespace.length > 0) {
|
||||||
|
throw new ValidationException(
|
||||||
|
`URL khong thuoc ve nguoi dung (${outsideNamespace.join(', ')}): chi chap nhan URL trong thu muc KYC cua ban`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (command.frontImage) {
|
} else if (command.frontImage) {
|
||||||
// Legacy file upload flow: upload buffers server-side
|
// Legacy file upload flow: upload buffers server-side
|
||||||
const folder = `${KYC_FOLDER}/${command.userId}`;
|
const folder = `${KYC_FOLDER}/${command.userId}`;
|
||||||
@@ -94,6 +122,29 @@ export class SubmitKycHandler implements ICommandHandler<SubmitKycCommand> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirms the given URL's object key lies within `kyc/{userId}/` for this user.
|
||||||
|
* This prevents a caller from submitting a URL that belongs to a different user's
|
||||||
|
* KYC namespace even when the host is trusted (bucket).
|
||||||
|
*
|
||||||
|
* Accepts pathname of either `/<bucket>/kyc/{userId}/<object>` (production format)
|
||||||
|
* or `/kyc/{userId}/<object>` (bucket-less URL).
|
||||||
|
*/
|
||||||
|
private isInUserKycNamespace(url: string, userId: string): boolean {
|
||||||
|
if (!userId) return false;
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Require a real object after /kyc/{userId}/
|
||||||
|
const marker = `/${KYC_FOLDER}/${userId}/`;
|
||||||
|
const idx = parsed.pathname.indexOf(marker);
|
||||||
|
if (idx < 0) return false;
|
||||||
|
return parsed.pathname.length > idx + marker.length;
|
||||||
|
}
|
||||||
|
|
||||||
private async uploadFile(
|
private async uploadFile(
|
||||||
file: KycFileData,
|
file: KycFileData,
|
||||||
folder: string,
|
folder: string,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
CachePrefix,
|
CachePrefix,
|
||||||
CacheService,
|
CacheService,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type RedisService,
|
type RedisService,
|
||||||
ValidationException,
|
ValidationException,
|
||||||
} from '@modules/shared';
|
} from '@modules/shared';
|
||||||
|
import { EmailChangedEvent } from '../../../domain/events/email-changed.event';
|
||||||
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||||
import { Email } from '../../../domain/value-objects/email.vo';
|
import { Email } from '../../../domain/value-objects/email.vo';
|
||||||
import { EMAIL_CHANGE_OTP_PREFIX } from '../update-profile/update-profile.handler';
|
import { EMAIL_CHANGE_OTP_PREFIX } from '../update-profile/update-profile.handler';
|
||||||
@@ -27,6 +28,7 @@ export class VerifyEmailChangeHandler implements ICommandHandler<VerifyEmailChan
|
|||||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
|
private readonly eventBus: EventBus,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ export class VerifyEmailChangeHandler implements ICommandHandler<VerifyEmailChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
const emailVo = Email.create(newEmail).unwrap();
|
const emailVo = Email.create(newEmail).unwrap();
|
||||||
|
const oldEmail = user.email?.value ?? null;
|
||||||
user.updateProfile(undefined, undefined, emailVo);
|
user.updateProfile(undefined, undefined, emailVo);
|
||||||
await this.userRepo.update(user);
|
await this.userRepo.update(user);
|
||||||
|
|
||||||
@@ -69,6 +72,9 @@ export class VerifyEmailChangeHandler implements ICommandHandler<VerifyEmailChan
|
|||||||
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
|
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Emit event for audit log
|
||||||
|
this.eventBus.publish(new EmailChangedEvent(command.userId, oldEmail, emailVo.value));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: emailVo.value,
|
email: emailVo.value,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
CachePrefix,
|
CachePrefix,
|
||||||
CacheService,
|
CacheService,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type RedisService,
|
type RedisService,
|
||||||
ValidationException,
|
ValidationException,
|
||||||
} from '@modules/shared';
|
} from '@modules/shared';
|
||||||
|
import { PhoneChangedEvent } from '../../../domain/events/phone-changed.event';
|
||||||
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||||
import { Phone } from '../../../domain/value-objects/phone.vo';
|
import { Phone } from '../../../domain/value-objects/phone.vo';
|
||||||
import { PHONE_CHANGE_OTP_PREFIX } from '../update-profile/update-profile.handler';
|
import { PHONE_CHANGE_OTP_PREFIX } from '../update-profile/update-profile.handler';
|
||||||
@@ -27,6 +28,7 @@ export class VerifyPhoneChangeHandler implements ICommandHandler<VerifyPhoneChan
|
|||||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
|
private readonly eventBus: EventBus,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ export class VerifyPhoneChangeHandler implements ICommandHandler<VerifyPhoneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
const phoneVo = Phone.create(newPhone).unwrap();
|
const phoneVo = Phone.create(newPhone).unwrap();
|
||||||
|
const oldPhone = user.phone.value;
|
||||||
user.updatePhone(phoneVo);
|
user.updatePhone(phoneVo);
|
||||||
await this.userRepo.update(user);
|
await this.userRepo.update(user);
|
||||||
|
|
||||||
@@ -69,6 +72,9 @@ export class VerifyPhoneChangeHandler implements ICommandHandler<VerifyPhoneChan
|
|||||||
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
|
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Emit event for audit log
|
||||||
|
this.eventBus.publish(new PhoneChangedEvent(command.userId, oldPhone, phoneVo.value));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
phoneNumber: phoneVo.value,
|
phoneNumber: phoneVo.value,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired after a user successfully confirms an email change via OTP.
|
||||||
|
* Consumed by the audit listener to record sensitive-field changes.
|
||||||
|
*/
|
||||||
|
export class EmailChangedEvent implements DomainEvent {
|
||||||
|
readonly eventName = 'user.email_changed';
|
||||||
|
readonly occurredAt = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly aggregateId: string,
|
||||||
|
public readonly oldEmail: string | null,
|
||||||
|
public readonly newEmail: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -2,3 +2,5 @@ export { UserRegisteredEvent } from './user-registered.event';
|
|||||||
export { AgentVerifiedEvent } from './agent-verified.event';
|
export { AgentVerifiedEvent } from './agent-verified.event';
|
||||||
export { EmailChangeRequestedEvent } from './email-change-requested.event';
|
export { EmailChangeRequestedEvent } from './email-change-requested.event';
|
||||||
export { PhoneChangeRequestedEvent } from './phone-change-requested.event';
|
export { PhoneChangeRequestedEvent } from './phone-change-requested.event';
|
||||||
|
export { EmailChangedEvent } from './email-changed.event';
|
||||||
|
export { PhoneChangedEvent } from './phone-changed.event';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired after a user successfully confirms a phone number change via SMS OTP.
|
||||||
|
* Consumed by the audit listener to record sensitive-field changes.
|
||||||
|
*/
|
||||||
|
export class PhoneChangedEvent implements DomainEvent {
|
||||||
|
readonly eventName = 'user.phone_changed';
|
||||||
|
readonly occurredAt = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly aggregateId: string,
|
||||||
|
public readonly oldPhone: string,
|
||||||
|
public readonly newPhone: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -13,4 +13,6 @@ export { UserKycUpdatedEvent } from './domain/events/user-kyc-updated.event';
|
|||||||
export { UserRegisteredEvent } from './domain/events/user-registered.event';
|
export { UserRegisteredEvent } from './domain/events/user-registered.event';
|
||||||
export { EmailChangeRequestedEvent } from './domain/events/email-change-requested.event';
|
export { EmailChangeRequestedEvent } from './domain/events/email-change-requested.event';
|
||||||
export { PhoneChangeRequestedEvent } from './domain/events/phone-change-requested.event';
|
export { PhoneChangeRequestedEvent } from './domain/events/phone-change-requested.event';
|
||||||
|
export { EmailChangedEvent } from './domain/events/email-changed.event';
|
||||||
|
export { PhoneChangedEvent } from './domain/events/phone-changed.event';
|
||||||
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';
|
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';
|
||||||
|
|||||||
@@ -218,7 +218,9 @@ export class AuthController {
|
|||||||
return this.queryBus.execute(new GetProfileQuery(user.sub));
|
return this.queryBus.execute(new GetProfileQuery(user.sub));
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||||
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 10, windowSeconds: 60, keyStrategy: 'user' })
|
||||||
|
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
||||||
@Patch('profile')
|
@Patch('profile')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@ApiOperation({ summary: 'Update current user profile' })
|
@ApiOperation({ summary: 'Update current user profile' })
|
||||||
@@ -226,6 +228,7 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@ApiResponse({ status: 409, description: 'Email already in use' })
|
@ApiResponse({ status: 409, description: 'Email already in use' })
|
||||||
|
@ApiResponse({ status: 429, description: 'Too many requests' })
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Body() dto: UpdateProfileDto,
|
@Body() dto: UpdateProfileDto,
|
||||||
@@ -236,7 +239,9 @@ export class AuthController {
|
|||||||
return { message: 'Cập nhật hồ sơ thành công', data: result };
|
return { message: 'Cập nhật hồ sơ thành công', data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||||
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
|
||||||
|
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
||||||
@Post('profile/verify-phone')
|
@Post('profile/verify-phone')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@ApiOperation({ summary: 'Verify phone number change with SMS OTP code' })
|
@ApiOperation({ summary: 'Verify phone number change with SMS OTP code' })
|
||||||
@@ -244,6 +249,7 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@ApiResponse({ status: 409, description: 'Phone number already in use' })
|
@ApiResponse({ status: 409, description: 'Phone number already in use' })
|
||||||
|
@ApiResponse({ status: 429, description: 'Too many requests' })
|
||||||
async verifyPhoneChange(
|
async verifyPhoneChange(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Body() dto: VerifyPhoneChangeDto,
|
@Body() dto: VerifyPhoneChangeDto,
|
||||||
@@ -254,7 +260,9 @@ export class AuthController {
|
|||||||
return { message: 'Số điện thoại đã được cập nhật thành công', data: result };
|
return { message: 'Số điện thoại đã được cập nhật thành công', data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||||
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
|
||||||
|
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
||||||
@Post('profile/verify-email')
|
@Post('profile/verify-email')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@ApiOperation({ summary: 'Verify email change with OTP code' })
|
@ApiOperation({ summary: 'Verify email change with OTP code' })
|
||||||
@@ -262,6 +270,7 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@ApiResponse({ status: 409, description: 'Email already in use' })
|
@ApiResponse({ status: 409, description: 'Email already in use' })
|
||||||
|
@ApiResponse({ status: 429, description: 'Too many requests' })
|
||||||
async verifyEmailChange(
|
async verifyEmailChange(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Body() dto: VerifyEmailChangeDto,
|
@Body() dto: VerifyEmailChangeDto,
|
||||||
|
|||||||
@@ -137,7 +137,30 @@ describe('UpdateListingHandler', () => {
|
|||||||
|
|
||||||
const command = new UpdateListingCommand('listing-1', 'stranger', 'Tiêu đề mới');
|
const command = new UpdateListingCommand('listing-1', 'stranger', 'Tiêu đề mới');
|
||||||
|
|
||||||
await expect(handler.execute(command)).rejects.toThrow(/người bán|môi giới/);
|
await expect(handler.execute(command)).rejects.toThrow(/người bán|môi giới|quản trị/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an admin to update any listing', async () => {
|
||||||
|
const listing = createListing('listing-1', 'seller-1');
|
||||||
|
const property = createProperty('prop-1');
|
||||||
|
mockListingRepo.findById.mockResolvedValue(listing);
|
||||||
|
mockPropertyRepo.findById.mockResolvedValue(property);
|
||||||
|
|
||||||
|
const command = new UpdateListingCommand(
|
||||||
|
'listing-1',
|
||||||
|
'admin-user',
|
||||||
|
'Tiêu đề do admin sửa',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'ADMIN',
|
||||||
|
);
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.listingId).toBe('listing-1');
|
||||||
|
expect(result.updatedFields).toContain('title');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export class UpdateListingCommand {
|
|||||||
public readonly rentPriceMonthly?: bigint,
|
public readonly rentPriceMonthly?: bigint,
|
||||||
public readonly amenities?: string[],
|
public readonly amenities?: string[],
|
||||||
public readonly mediaOrder?: { mediaId: string; order: number }[],
|
public readonly mediaOrder?: { mediaId: string; order: number }[],
|
||||||
|
public readonly userRole?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ export class UpdateListingHandler implements ICommandHandler<UpdateListingComman
|
|||||||
throw new NotFoundException('Listing', command.listingId);
|
throw new NotFoundException('Listing', command.listingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Ownership check: only the seller or assigned agent can edit
|
// 2. Ownership check: only the seller, assigned agent, or admin can edit
|
||||||
const isOwner = listing.sellerId === command.userId;
|
const isOwner = listing.sellerId === command.userId;
|
||||||
const isAgent = listing.agentId !== null && listing.agentId === command.userId;
|
const isAgent = listing.agentId !== null && listing.agentId === command.userId;
|
||||||
if (!isOwner && !isAgent) {
|
const isAdmin = command.userRole === 'ADMIN';
|
||||||
|
if (!isOwner && !isAgent && !isAdmin) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
'Chỉ người bán hoặc môi giới được giao mới có thể chỉnh sửa tin đăng',
|
'Chỉ người bán, môi giới được giao hoặc quản trị viên mới có thể chỉnh sửa tin đăng',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface IMediaStorageService {
|
|||||||
expiresInSeconds?: number,
|
expiresInSeconds?: number,
|
||||||
): Promise<PresignedUploadResult>;
|
): Promise<PresignedUploadResult>;
|
||||||
getPublicUrl(objectKey: string): string;
|
getPublicUrl(objectKey: string): string;
|
||||||
|
isTrustedUrl(url: string): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireEnv(key: string): string {
|
function requireEnv(key: string): string {
|
||||||
@@ -151,6 +152,45 @@ export class MinioMediaStorageService implements IMediaStorageService, OnModuleI
|
|||||||
return `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectKey}`;
|
return `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a URL points to our configured MinIO bucket.
|
||||||
|
* Accepts the primary endpoint, plus an optional comma-separated list of
|
||||||
|
* additional trusted hosts via `MINIO_TRUSTED_HOSTS` (e.g. public CDN domains).
|
||||||
|
* Also enforces the bucket is the first path segment.
|
||||||
|
*/
|
||||||
|
isTrustedUrl(url: string): boolean {
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedHosts = new Set<string>();
|
||||||
|
allowedHosts.add(this.endpoint.toLowerCase());
|
||||||
|
allowedHosts.add(`${this.endpoint.toLowerCase()}:${this.port}`);
|
||||||
|
|
||||||
|
const extra = process.env['MINIO_TRUSTED_HOSTS'];
|
||||||
|
if (extra) {
|
||||||
|
for (const h of extra.split(',')) {
|
||||||
|
const trimmed = h.trim().toLowerCase();
|
||||||
|
if (trimmed) allowedHosts.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.host.toLowerCase();
|
||||||
|
if (!allowedHosts.has(host) && !allowedHosts.has(parsed.hostname.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path must start with /<bucket>/
|
||||||
|
const expectedPrefix = `/${this.bucket}/`;
|
||||||
|
return parsed.pathname.startsWith(expectedPrefix) && parsed.pathname.length > expectedPrefix.length;
|
||||||
|
}
|
||||||
|
|
||||||
async delete(fileUrl: string): Promise<void> {
|
async delete(fileUrl: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(fileUrl);
|
const urlObj = new URL(fileUrl);
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export class ListingsController {
|
|||||||
dto.rentPriceMonthly,
|
dto.rentPriceMonthly,
|
||||||
dto.amenities,
|
dto.amenities,
|
||||||
dto.mediaOrder,
|
dto.mediaOrder,
|
||||||
|
user.role,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { useState } from 'react';
|
|||||||
import { ComparablesTable } from '@/components/valuation/comparables-table';
|
import { ComparablesTable } from '@/components/valuation/comparables-table';
|
||||||
import { ExportPdfButton } from '@/components/valuation/export-pdf-button';
|
import { ExportPdfButton } from '@/components/valuation/export-pdf-button';
|
||||||
import { MarketContextCard } from '@/components/valuation/market-context-card';
|
import { MarketContextCard } from '@/components/valuation/market-context-card';
|
||||||
|
import { ValuationCompare } from '@/components/valuation/valuation-compare';
|
||||||
import { ValuationForm } from '@/components/valuation/valuation-form';
|
import { ValuationForm } from '@/components/valuation/valuation-form';
|
||||||
import { ValuationHistory } from '@/components/valuation/valuation-history';
|
import { ValuationHistory } from '@/components/valuation/valuation-history';
|
||||||
import { ValuationResults } from '@/components/valuation/valuation-results';
|
import { ValuationResults } from '@/components/valuation/valuation-results';
|
||||||
|
import { ApiError } from '@/lib/api-client';
|
||||||
import {
|
import {
|
||||||
useValuationPredict,
|
useValuationPredict,
|
||||||
useValuationHistory,
|
useValuationHistory,
|
||||||
@@ -15,23 +17,65 @@ import {
|
|||||||
} from '@/lib/hooks/use-valuation';
|
} from '@/lib/hooks/use-valuation';
|
||||||
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
|
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
|
||||||
|
|
||||||
// Lazy-load chart component (uses Recharts, no SSR)
|
function getValuationErrorMessage(error: unknown): { title: string; detail: string } {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.status === 429) {
|
||||||
|
return {
|
||||||
|
title: 'Quá nhiều yêu cầu',
|
||||||
|
detail: 'Bạn đã gửi quá nhiều yêu cầu định giá. Vui lòng đợi một lát rồi thử lại.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error.status === 402 || /quota|subscription/i.test(error.message)) {
|
||||||
|
return {
|
||||||
|
title: 'Đã hết hạn mức',
|
||||||
|
detail:
|
||||||
|
'Gói đăng ký hiện tại đã hết lượt định giá AI. Hãy nâng cấp hoặc thử lại vào chu kỳ sau.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error.status === 503 || /model|unavailable/i.test(error.message)) {
|
||||||
|
return {
|
||||||
|
title: 'Dịch vụ AI tạm thời không khả dụng',
|
||||||
|
detail: 'Mô hình định giá đang bận hoặc bảo trì. Vui lòng thử lại sau vài phút.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: 'Không thể định giá',
|
||||||
|
detail: error.message || 'Đã xảy ra lỗi không xác định. Vui lòng thử lại sau.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: 'Không thể định giá',
|
||||||
|
detail: 'Vui lòng kiểm tra kết nối mạng và thử lại.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-load chart components (uses Recharts, no SSR)
|
||||||
|
const chartLoading = () => (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
|
Đang tải...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const ValuationHistoryChart = dynamic(
|
const ValuationHistoryChart = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import('@/components/valuation/valuation-history-chart').then(
|
import('@/components/valuation/valuation-history-chart').then(
|
||||||
(m) => m.ValuationHistoryChart,
|
(m) => m.ValuationHistoryChart,
|
||||||
),
|
),
|
||||||
{
|
{ ssr: false, loading: chartLoading },
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
|
||||||
Đang tải...
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ValueDriversChart = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/components/valuation/value-drivers-chart').then(
|
||||||
|
(m) => m.ValueDriversChart,
|
||||||
|
),
|
||||||
|
{ ssr: false, loading: chartLoading },
|
||||||
|
);
|
||||||
|
|
||||||
|
type TabKey = 'single' | 'compare';
|
||||||
|
|
||||||
export default function ValuationPage() {
|
export default function ValuationPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>('single');
|
||||||
const [historyPage, setHistoryPage] = useState(1);
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -49,6 +93,7 @@ export default function ValuationPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectHistory = (id: string) => {
|
const handleSelectHistory = (id: string) => {
|
||||||
|
setActiveTab('single');
|
||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,7 +107,7 @@ export default function ValuationPage() {
|
|||||||
Sử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu thị trường
|
Sử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu thị trường
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{currentResult && (
|
{activeTab === 'single' && currentResult && (
|
||||||
<ExportPdfButton
|
<ExportPdfButton
|
||||||
targetSelector="#valuation-results"
|
targetSelector="#valuation-results"
|
||||||
filename={`dinh-gia-${currentResult.id}`}
|
filename={`dinh-gia-${currentResult.id}`}
|
||||||
@@ -70,56 +115,99 @@ export default function ValuationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
{/* Tab switcher */}
|
||||||
{/* Form + Results (left 2 cols) */}
|
<div className="flex gap-1 rounded-lg border bg-muted p-1">
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<button
|
||||||
<ValuationForm
|
type="button"
|
||||||
onSubmit={handleSubmit}
|
className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
isLoading={predictMutation.isPending}
|
activeTab === 'single'
|
||||||
/>
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
{predictMutation.isError && (
|
}`}
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
onClick={() => setActiveTab('single')}
|
||||||
Không thể định giá. Vui lòng thử lại sau.
|
>
|
||||||
</div>
|
Định giá đơn
|
||||||
)}
|
</button>
|
||||||
|
<button
|
||||||
{currentResult && (
|
type="button"
|
||||||
<>
|
className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
{/* Main results with confidence badge + driver charts */}
|
activeTab === 'compare'
|
||||||
<ValuationResults result={currentResult} />
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
{/* Comparables table (TanStack Table) */}
|
}`}
|
||||||
{currentResult.comparables.length > 0 && (
|
onClick={() => setActiveTab('compare')}
|
||||||
<ComparablesTable comparables={currentResult.comparables} />
|
>
|
||||||
)}
|
So sánh (2-5 BĐS)
|
||||||
|
</button>
|
||||||
{/* Market context card */}
|
|
||||||
{currentResult.marketContext && (
|
|
||||||
<MarketContextCard context={currentResult.marketContext} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Valuation history chart */}
|
|
||||||
{currentResult.valuationHistory &&
|
|
||||||
currentResult.valuationHistory.length >= 2 && (
|
|
||||||
<ValuationHistoryChart data={currentResult.valuationHistory} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* History sidebar (right col) */}
|
|
||||||
<div>
|
|
||||||
<ValuationHistory
|
|
||||||
items={historyData?.data ?? []}
|
|
||||||
total={historyData?.total ?? 0}
|
|
||||||
page={historyPage}
|
|
||||||
onPageChange={setHistoryPage}
|
|
||||||
onSelect={handleSelectHistory}
|
|
||||||
isLoading={historyLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'single' ? (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Form + Results (left 2 cols) */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
<ValuationForm
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={predictMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{predictMutation.isError && (() => {
|
||||||
|
const { title, detail } = getValuationErrorMessage(predictMutation.error);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="valuation-error"
|
||||||
|
className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
<p className="font-semibold">{title}</p>
|
||||||
|
<p className="mt-1 text-destructive/90">{detail}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{currentResult && (
|
||||||
|
<>
|
||||||
|
{/* Main results with confidence badge */}
|
||||||
|
<ValuationResults result={currentResult} />
|
||||||
|
|
||||||
|
{/* Value drivers waterfall chart */}
|
||||||
|
{currentResult.priceDrivers.length > 0 && (
|
||||||
|
<ValueDriversChart drivers={currentResult.priceDrivers} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comparables table (TanStack Table) */}
|
||||||
|
{currentResult.comparables.length > 0 && (
|
||||||
|
<ComparablesTable comparables={currentResult.comparables} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Market context card */}
|
||||||
|
{currentResult.marketContext && (
|
||||||
|
<MarketContextCard context={currentResult.marketContext} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Valuation history chart */}
|
||||||
|
{currentResult.valuationHistory &&
|
||||||
|
currentResult.valuationHistory.length >= 2 && (
|
||||||
|
<ValuationHistoryChart data={currentResult.valuationHistory} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History sidebar (right col) */}
|
||||||
|
<div>
|
||||||
|
<ValuationHistory
|
||||||
|
items={historyData?.data ?? []}
|
||||||
|
total={historyData?.total ?? 0}
|
||||||
|
page={historyPage}
|
||||||
|
onPageChange={setHistoryPage}
|
||||||
|
onSelect={handleSelectHistory}
|
||||||
|
isLoading={historyLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ValuationCompare />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ describe('ValuationResults', () => {
|
|||||||
|
|
||||||
it('renders price drivers section', () => {
|
it('renders price drivers section', () => {
|
||||||
render(<ValuationResults result={mockResult} />);
|
render(<ValuationResults result={mockResult} />);
|
||||||
expect(screen.getByText('Yếu tố chính')).toBeInTheDocument();
|
expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Vị trí trung tâm/)).toBeInTheDocument();
|
expect(screen.getByText(/Vị trí trung tâm/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Tầng thấp/)).toBeInTheDocument();
|
expect(screen.getByText(/Tầng thấp/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -82,7 +82,7 @@ describe('ValuationResults', () => {
|
|||||||
it('hides drivers section when empty', () => {
|
it('hides drivers section when empty', () => {
|
||||||
const noDrivers = { ...mockResult, priceDrivers: [] };
|
const noDrivers = { ...mockResult, priceDrivers: [] };
|
||||||
render(<ValuationResults result={noDrivers} />);
|
render(<ValuationResults result={noDrivers} />);
|
||||||
expect(screen.queryByText('Yếu tố chính')).not.toBeInTheDocument();
|
expect(screen.queryByText('Yếu tố ảnh hưởng giá')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Bot, ImagePlus, Search, X } from 'lucide-react';
|
import {
|
||||||
|
Bot,
|
||||||
|
ChevronDown,
|
||||||
|
ImagePlus,
|
||||||
|
MapPin,
|
||||||
|
Search,
|
||||||
|
Sparkles,
|
||||||
|
Star,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -21,6 +30,8 @@ import {
|
|||||||
type ValuationFormData,
|
type ValuationFormData,
|
||||||
VALUATION_PROPERTY_TYPES,
|
VALUATION_PROPERTY_TYPES,
|
||||||
CITIES,
|
CITIES,
|
||||||
|
FLOOD_RISK_OPTIONS,
|
||||||
|
QUALITY_LABELS,
|
||||||
} from '@/lib/validations/valuation';
|
} from '@/lib/validations/valuation';
|
||||||
import type { ValuationRequest } from '@/lib/valuation-api';
|
import type { ValuationRequest } from '@/lib/valuation-api';
|
||||||
|
|
||||||
@@ -29,6 +40,85 @@ interface ValuationFormProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CollapsibleSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
description: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/50"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && <div className="border-t px-4 pb-4 pt-4">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QualitySlider({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
register,
|
||||||
|
isInverted,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
register: ReturnType<typeof useForm<ValuationFormData>>['register'];
|
||||||
|
isInverted?: boolean;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(50);
|
||||||
|
const displayValue = Math.round(value * 100) / 100;
|
||||||
|
const normalizedForApi = value / 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{displayValue}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id={id}
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(Number(e.target.value))}
|
||||||
|
className={`h-2 w-full cursor-pointer appearance-none rounded-full ${
|
||||||
|
isInverted
|
||||||
|
? 'bg-gradient-to-r from-green-200 via-yellow-200 to-red-200'
|
||||||
|
: 'bg-gradient-to-r from-red-200 via-yellow-200 to-green-200'
|
||||||
|
} accent-primary`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register(id as keyof ValuationFormData)}
|
||||||
|
value={normalizedForApi}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function toNum(val: string | undefined): number | undefined {
|
function toNum(val: string | undefined): number | undefined {
|
||||||
if (!val || val === '') return undefined;
|
if (!val || val === '') return undefined;
|
||||||
const n = Number(val);
|
const n = Number(val);
|
||||||
@@ -60,8 +150,12 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
// Image upload state
|
// Image upload state
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
const handleProjectSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleProjectSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setProjectQuery(value);
|
setProjectQuery(value);
|
||||||
@@ -87,6 +181,17 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploadError(null);
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
setUploadError('Định dạng không hợp lệ. Vui lòng chọn ảnh JPG hoặc PNG.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_SIZE_BYTES) {
|
||||||
|
setUploadError('Ảnh vượt quá giới hạn 5MB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Show local preview
|
// Show local preview
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (ev) => {
|
reader.onload = (ev) => {
|
||||||
@@ -94,8 +199,25 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// Simulated upload progress for local preview flow — the valuation
|
||||||
|
// endpoint accepts a public imageUrl, so the real upload is a no-op
|
||||||
|
// here, but users still get feedback for files being processed.
|
||||||
|
setUploadProgress(0);
|
||||||
|
const start = Date.now();
|
||||||
|
const tick = () => {
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
const pct = Math.min(100, Math.round((elapsed / 400) * 100));
|
||||||
|
setUploadProgress(pct);
|
||||||
|
if (pct < 100) {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => setUploadProgress(null), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
|
||||||
// In production, upload to server and get URL
|
// In production, upload to server and get URL
|
||||||
// For now we store as data URL for preview purposes
|
// For now we store as object URL for preview purposes
|
||||||
setImageUrl(URL.createObjectURL(file));
|
setImageUrl(URL.createObjectURL(file));
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -104,6 +226,8 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
const handleClearImage = useCallback(() => {
|
const handleClearImage = useCallback(() => {
|
||||||
setImagePreview(null);
|
setImagePreview(null);
|
||||||
setImageUrl(null);
|
setImageUrl(null);
|
||||||
|
setUploadProgress(null);
|
||||||
|
setUploadError(null);
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
@@ -126,6 +250,23 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
deepAnalysis: data.deepAnalysis,
|
deepAnalysis: data.deepAnalysis,
|
||||||
imageUrl: imageUrl || undefined,
|
imageUrl: imageUrl || undefined,
|
||||||
|
// v2 fields
|
||||||
|
useV2: data.useV2,
|
||||||
|
distanceToCbdKm: toNum(data.distanceToCbdKm),
|
||||||
|
distanceToMetroKm: toNum(data.distanceToMetroKm),
|
||||||
|
distanceToSchoolKm: toNum(data.distanceToSchoolKm),
|
||||||
|
distanceToHospitalKm: toNum(data.distanceToHospitalKm),
|
||||||
|
distanceToParkKm: toNum(data.distanceToParkKm),
|
||||||
|
distanceToMallKm: toNum(data.distanceToMallKm),
|
||||||
|
floodZoneRisk: toNum(data.floodZoneRisk),
|
||||||
|
hasElevator: data.hasElevator,
|
||||||
|
hasParking: data.hasParking,
|
||||||
|
hasPool: data.hasPool,
|
||||||
|
renovationScore: toNum(data.renovationScore),
|
||||||
|
viewQuality: toNum(data.viewQuality),
|
||||||
|
interiorQuality: toNum(data.interiorQuality),
|
||||||
|
noiseLevel: toNum(data.noiseLevel),
|
||||||
|
naturalLight: toNum(data.naturalLight),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -354,9 +495,41 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleImageChange}
|
onChange={handleImageChange}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="flex-1 space-y-1.5">
|
||||||
Tải ảnh bất động sản để AI phân tích trực quan (JPG, PNG, tối đa 5MB)
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
Tải ảnh bất động sản để AI phân tích trực quan (JPG, PNG, tối đa 5MB)
|
||||||
|
</p>
|
||||||
|
{uploadProgress !== null && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={uploadProgress}
|
||||||
|
aria-label="Tiến trình tải ảnh"
|
||||||
|
data-testid="image-upload-progress"
|
||||||
|
>
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{uploadProgress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uploadError && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
data-testid="image-upload-error"
|
||||||
|
className="text-xs text-destructive"
|
||||||
|
>
|
||||||
|
{uploadError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -396,8 +569,101 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
Phân tích chuyên sâu
|
Phân tích chuyên sâu
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="useV2"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
|
{...register('useV2')}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="useV2" className="flex items-center gap-1.5">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
Mô hình v2 (Ensemble)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ─── v2 Advanced: Infrastructure Proximity ─── */}
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Khoảng cách hạ tầng"
|
||||||
|
icon={<MapPin className="h-4 w-4" />}
|
||||||
|
description="Khoảng cách đến các tiện ích xung quanh (km)"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="distanceToCbdKm">Đến trung tâm (km)</Label>
|
||||||
|
<Input id="distanceToCbdKm" type="number" step="0.1" placeholder="VD: 5" {...register('distanceToCbdKm')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="distanceToMetroKm">Đến metro (km)</Label>
|
||||||
|
<Input id="distanceToMetroKm" type="number" step="0.1" placeholder="VD: 1.5" {...register('distanceToMetroKm')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="distanceToSchoolKm">Đến trường học (km)</Label>
|
||||||
|
<Input id="distanceToSchoolKm" type="number" step="0.1" placeholder="VD: 0.5" {...register('distanceToSchoolKm')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="distanceToHospitalKm">Đến bệnh viện (km)</Label>
|
||||||
|
<Input id="distanceToHospitalKm" type="number" step="0.1" placeholder="VD: 2" {...register('distanceToHospitalKm')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="distanceToParkKm">Đến công viên (km)</Label>
|
||||||
|
<Input id="distanceToParkKm" type="number" step="0.1" placeholder="VD: 0.3" {...register('distanceToParkKm')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="distanceToMallKm">Đến TTTM (km)</Label>
|
||||||
|
<Input id="distanceToMallKm" type="number" step="0.1" placeholder="VD: 1" {...register('distanceToMallKm')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="floodZoneRisk">Nguy cơ ngập</Label>
|
||||||
|
<Select id="floodZoneRisk" {...register('floodZoneRisk')}>
|
||||||
|
<option value="">-- Không rõ --</option>
|
||||||
|
{FLOOD_RISK_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input id="hasElevator" type="checkbox" className="h-4 w-4 rounded border-input" {...register('hasElevator')} />
|
||||||
|
<Label htmlFor="hasElevator">Thang máy</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input id="hasParking" type="checkbox" className="h-4 w-4 rounded border-input" {...register('hasParking')} />
|
||||||
|
<Label htmlFor="hasParking">Bãi đậu xe</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input id="hasPool" type="checkbox" className="h-4 w-4 rounded border-input" {...register('hasPool')} />
|
||||||
|
<Label htmlFor="hasPool">Hồ bơi</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* ─── v2 Advanced: Quality Scores ─── */}
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Đánh giá chất lượng"
|
||||||
|
icon={<Star className="h-4 w-4" />}
|
||||||
|
description="Đánh giá chủ quan về chất lượng bất động sản (0-100%)"
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{(Object.entries(QUALITY_LABELS) as [keyof typeof QUALITY_LABELS, string][]).map(
|
||||||
|
([field, label]) => (
|
||||||
|
<QualitySlider
|
||||||
|
key={field}
|
||||||
|
id={field}
|
||||||
|
label={label}
|
||||||
|
register={register}
|
||||||
|
isInverted={field === 'noiseLevel'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
|
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
|
||||||
{isLoading ? 'Đang định giá...' : 'Định giá ngay'}
|
{isLoading ? 'Đang định giá...' : 'Định giá ngay'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
95
e2e/api/auth-kyc-upload.spec.ts
Normal file
95
e2e/api/auth-kyc-upload.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KYC presigned-upload flow (TEC-2750).
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - POST /auth/kyc/upload-urls — presigned URL generation (happy + validation errors)
|
||||||
|
* - POST /auth/kyc/submit — accepts URL body; rejects invalid/untrusted URLs
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('POST /auth/kyc/upload-urls', () => {
|
||||||
|
test('rejects unauthenticated requests', async ({ request }) => {
|
||||||
|
const res = await request.post('auth/kyc/upload-urls', {
|
||||||
|
data: { files: [{ field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' }] },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty files array', async ({ authedRequest }) => {
|
||||||
|
const res = await authedRequest.post('auth/kyc/upload-urls', { data: { files: [] } });
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects more than 3 files', async ({ authedRequest }) => {
|
||||||
|
const res = await authedRequest.post('auth/kyc/upload-urls', {
|
||||||
|
data: {
|
||||||
|
files: [
|
||||||
|
{ field: 'frontImage', mimeType: 'image/jpeg', fileName: 'a.jpg' },
|
||||||
|
{ field: 'backImage', mimeType: 'image/jpeg', fileName: 'b.jpg' },
|
||||||
|
{ field: 'selfieImage', mimeType: 'image/jpeg', fileName: 'c.jpg' },
|
||||||
|
{ field: 'selfieImage', mimeType: 'image/jpeg', fileName: 'd.jpg' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unsupported field name', async ({ authedRequest }) => {
|
||||||
|
const res = await authedRequest.post('auth/kyc/upload-urls', {
|
||||||
|
data: {
|
||||||
|
files: [{ field: 'not-a-field', mimeType: 'image/jpeg', fileName: 'front.jpg' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('POST /auth/kyc/submit', () => {
|
||||||
|
test('rejects unauthenticated submit', async ({ request }) => {
|
||||||
|
const res = await request.post('auth/kyc/submit', {
|
||||||
|
data: {
|
||||||
|
documentType: 'CCCD',
|
||||||
|
documentNumber: '001234567890',
|
||||||
|
frontImageUrl: 'https://cdn.goodgo.vn/kyc/front.jpg',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects submit missing required fields', async ({ authedRequest }) => {
|
||||||
|
const res = await authedRequest.post('auth/kyc/submit', {
|
||||||
|
data: { documentType: 'CCCD' },
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects submit with malformed front image URL', async ({ authedRequest }) => {
|
||||||
|
const res = await authedRequest.post('auth/kyc/submit', {
|
||||||
|
data: {
|
||||||
|
documentType: 'CCCD',
|
||||||
|
documentNumber: '001234567890',
|
||||||
|
frontImageUrl: 'not-a-url',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects submit with URL from untrusted host', async ({ authedRequest }) => {
|
||||||
|
const res = await authedRequest.post('auth/kyc/submit', {
|
||||||
|
data: {
|
||||||
|
documentType: 'CCCD',
|
||||||
|
documentNumber: '001234567890',
|
||||||
|
frontImageUrl: 'https://evil.example.com/kyc/front.jpg',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
// URL host validation returns 400; never 5xx / 201.
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
133
e2e/api/auth-profile-otp.spec.ts
Normal file
133
e2e/api/auth-profile-otp.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
import { test, expect, createTestUser, registerUser } from '../fixtures';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E coverage for PATCH /auth/profile OTP-gated email/phone changes.
|
||||||
|
*
|
||||||
|
* Flow: PATCH /auth/profile → OTP stored in Redis (via notifications bus) →
|
||||||
|
* POST /auth/profile/verify-email|verify-phone → persisted user state.
|
||||||
|
*
|
||||||
|
* We read the OTP code directly from Redis because the notifications transport
|
||||||
|
* is asynchronous in dev/test. This is acceptable for an e2e that is already
|
||||||
|
* exercising the same infra the API uses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EMAIL_OTP_PREFIX = 'auth:email_change_otp';
|
||||||
|
const PHONE_OTP_PREFIX = 'auth:phone_change_otp';
|
||||||
|
|
||||||
|
function redisClient(): Redis {
|
||||||
|
return new Redis({
|
||||||
|
host: process.env.REDIS_HOST ?? 'localhost',
|
||||||
|
port: Number(process.env.REDIS_PORT ?? 6379),
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOtp(userId: string, prefix: string): Promise<string | null> {
|
||||||
|
const redis = redisClient();
|
||||||
|
try {
|
||||||
|
await redis.connect();
|
||||||
|
const raw = await redis.get(`${prefix}:${userId}`);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as { code: string };
|
||||||
|
return parsed.code;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await redis.quit().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('PATCH /auth/profile — OTP-gated email change', () => {
|
||||||
|
test('request → OTP → confirm → persisted', async ({ request, authedRequest, testTokens }) => {
|
||||||
|
// Decode JWT to get userId without a DB round-trip.
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(testTokens.accessToken.split('.')[1] ?? '', 'base64url').toString('utf8'),
|
||||||
|
) as { sub: string };
|
||||||
|
const userId = payload.sub;
|
||||||
|
const newEmail = `updated${Date.now()}@goodgo.test`;
|
||||||
|
|
||||||
|
const patchRes = await authedRequest.patch('auth/profile', { data: { email: newEmail } });
|
||||||
|
expect(patchRes.status()).toBe(200);
|
||||||
|
const patchBody = await patchRes.json();
|
||||||
|
expect(patchBody.data.emailChangePending).toBe(true);
|
||||||
|
// Email should NOT be persisted yet.
|
||||||
|
expect(patchBody.data.email).not.toBe(newEmail);
|
||||||
|
|
||||||
|
const code = await readOtp(userId, EMAIL_OTP_PREFIX);
|
||||||
|
expect(code, 'OTP code should be stored in Redis').toMatch(/^\d{6}$/);
|
||||||
|
|
||||||
|
// Wrong code is rejected.
|
||||||
|
const badRes = await authedRequest.post('auth/profile/verify-email', {
|
||||||
|
data: { code: '000000' },
|
||||||
|
});
|
||||||
|
expect([400, 422]).toContain(badRes.status());
|
||||||
|
|
||||||
|
// Correct code commits the change.
|
||||||
|
const okRes = await authedRequest.post('auth/profile/verify-email', {
|
||||||
|
data: { code: code! },
|
||||||
|
});
|
||||||
|
expect(okRes.status()).toBe(201);
|
||||||
|
const okBody = await okRes.json();
|
||||||
|
expect(okBody.data.email).toBe(newEmail);
|
||||||
|
|
||||||
|
// GET /auth/profile now shows the new email.
|
||||||
|
const profileRes = await authedRequest.get('auth/profile');
|
||||||
|
expect(profileRes.status()).toBe(200);
|
||||||
|
const profile = await profileRes.json();
|
||||||
|
expect(profile.email).toBe(newEmail);
|
||||||
|
|
||||||
|
// OTP is consumed — replaying fails.
|
||||||
|
const replayRes = await authedRequest.post('auth/profile/verify-email', {
|
||||||
|
data: { code: code! },
|
||||||
|
});
|
||||||
|
expect([400, 422]).toContain(replayRes.status());
|
||||||
|
|
||||||
|
// Unauthenticated request is rejected.
|
||||||
|
const unauthRes = await request.post('auth/profile/verify-email', { data: { code: '123456' } });
|
||||||
|
expect(unauthRes.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expired / missing OTP returns validation error', async ({ authedRequest }) => {
|
||||||
|
const res = await authedRequest.post('auth/profile/verify-email', {
|
||||||
|
data: { code: '123456' },
|
||||||
|
});
|
||||||
|
expect([400, 422]).toContain(res.status());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PATCH /auth/profile — OTP-gated phone change', () => {
|
||||||
|
test('request → OTP → confirm → persisted', async ({ request }) => {
|
||||||
|
// Fresh user so we can change phone without colliding with fixtures.
|
||||||
|
const user = createTestUser();
|
||||||
|
const { accessToken } = await registerUser(request, user);
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(accessToken.split('.')[1] ?? '', 'base64url').toString('utf8'),
|
||||||
|
) as { sub: string };
|
||||||
|
const userId = payload.sub;
|
||||||
|
|
||||||
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||||
|
const newPhone = `09${Date.now().toString().slice(-8)}`;
|
||||||
|
|
||||||
|
const patchRes = await request.patch('auth/profile', {
|
||||||
|
headers,
|
||||||
|
data: { phoneNumber: newPhone },
|
||||||
|
});
|
||||||
|
expect(patchRes.status()).toBe(200);
|
||||||
|
const patchBody = await patchRes.json();
|
||||||
|
expect(patchBody.data.phoneChangePending).toBe(true);
|
||||||
|
|
||||||
|
const code = await readOtp(userId, PHONE_OTP_PREFIX);
|
||||||
|
expect(code, 'SMS OTP code should be stored in Redis').toMatch(/^\d{6}$/);
|
||||||
|
|
||||||
|
const okRes = await request.post('auth/profile/verify-phone', {
|
||||||
|
headers,
|
||||||
|
data: { code: code! },
|
||||||
|
});
|
||||||
|
expect(okRes.status()).toBe(201);
|
||||||
|
const okBody = await okRes.json();
|
||||||
|
// Phone is normalised server-side (+84...)
|
||||||
|
expect(okBody.data.phoneNumber).toContain(newPhone.slice(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
127
e2e/api/avm.spec.ts
Normal file
127
e2e/api/avm.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { test, expect, registerUser } from '../fixtures';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke E2E for R5.3 AVM API upgrades:
|
||||||
|
* POST /avm/batch — batch valuation, max 50 items
|
||||||
|
* GET /avm/history/:id — stored historical valuations
|
||||||
|
* GET /avm/compare — 2-5 property side-by-side
|
||||||
|
* GET /avm/explain — confidence explanation for a valuationId
|
||||||
|
*
|
||||||
|
* These tests exercise the surface shape (validation, auth, error codes).
|
||||||
|
* Deeper value-level assertions are covered in the unit test suite.
|
||||||
|
*/
|
||||||
|
test.describe('AVM API (R5.3)', () => {
|
||||||
|
let accessToken: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const { accessToken: token } = await registerUser(request);
|
||||||
|
accessToken = token;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('POST /avm/batch', () => {
|
||||||
|
test('requires authentication', async ({ request }) => {
|
||||||
|
const res = await request.post('avm/batch', {
|
||||||
|
data: { propertyIds: ['prop-1'] },
|
||||||
|
});
|
||||||
|
expect([401, 403]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects batches over 50 items', async ({ request }) => {
|
||||||
|
const propertyIds = Array.from({ length: 51 }, (_, i) => `prop-${i}`);
|
||||||
|
const res = await request.post('avm/batch', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
data: { propertyIds },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty batch', async ({ request }) => {
|
||||||
|
const res = await request.post('avm/batch', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
data: { propertyIds: [] },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts valid batch of valid IDs', async ({ request }) => {
|
||||||
|
const res = await request.post('avm/batch', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
data: { propertyIds: ['prop-seed-1', 'prop-seed-2'] },
|
||||||
|
});
|
||||||
|
// 200 on success path; 429 if rate-limited by earlier tests. Both are acceptable.
|
||||||
|
expect([200, 429]).toContain(res.status());
|
||||||
|
if (res.status() === 200) {
|
||||||
|
const body = await res.json();
|
||||||
|
expect(Array.isArray(body)).toBeTruthy();
|
||||||
|
expect(body.length).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('GET /avm/history/:propertyId', () => {
|
||||||
|
test('requires authentication', async ({ request }) => {
|
||||||
|
const res = await request.get('avm/history/prop-1');
|
||||||
|
expect([401, 403]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns chronologically ordered history shape', async ({ request }) => {
|
||||||
|
const res = await request.get('avm/history/prop-seed-1?limit=10', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
expect([200, 403]).toContain(res.status());
|
||||||
|
if (res.status() === 200) {
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty('propertyId', 'prop-seed-1');
|
||||||
|
expect(Array.isArray(body.history)).toBeTruthy();
|
||||||
|
// Each point includes model_version + timestamp
|
||||||
|
for (const point of body.history) {
|
||||||
|
expect(point).toHaveProperty('modelVersion');
|
||||||
|
expect(point).toHaveProperty('valuedAt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('GET /avm/compare', () => {
|
||||||
|
test('requires authentication', async ({ request }) => {
|
||||||
|
const res = await request.get('avm/compare?ids=prop-1,prop-2');
|
||||||
|
expect([401, 403]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects fewer than 2 IDs', async ({ request }) => {
|
||||||
|
const res = await request.get('avm/compare?ids=prop-1', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects more than 5 IDs', async ({ request }) => {
|
||||||
|
const ids = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'].join(',');
|
||||||
|
const res = await request.get(`avm/compare?ids=${ids}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('GET /avm/explain', () => {
|
||||||
|
test('requires authentication', async ({ request }) => {
|
||||||
|
const res = await request.get('avm/explain?valuationId=val-xxx');
|
||||||
|
expect([401, 403]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing valuationId', async ({ request }) => {
|
||||||
|
const res = await request.get('avm/explain', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 for unknown valuationId', async ({ request }) => {
|
||||||
|
const res = await request.get('avm/explain?valuationId=val-does-not-exist', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
expect([404, 403]).toContain(res.status());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
129
e2e/web/valuation.spec.ts
Normal file
129
e2e/web/valuation.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const mockValuationResult = {
|
||||||
|
id: 'val-e2e-1',
|
||||||
|
propertyType: 'APARTMENT',
|
||||||
|
area: 75,
|
||||||
|
district: 'Quận 1',
|
||||||
|
city: 'Ho Chi Minh',
|
||||||
|
estimatedPriceVND: 5_500_000_000,
|
||||||
|
priceRangeLow: 5_100_000_000,
|
||||||
|
priceRangeHigh: 5_900_000_000,
|
||||||
|
pricePerM2: 73_333_333,
|
||||||
|
confidence: 0.82,
|
||||||
|
modelVersion: 'avm-v2.0',
|
||||||
|
priceDrivers: [
|
||||||
|
{ feature: 'area_m2', impact: 24.5, direction: 'positive' as const },
|
||||||
|
{ feature: 'distance_to_cbd_km', impact: 12.3, direction: 'negative' as const },
|
||||||
|
],
|
||||||
|
comparables: [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
listingId: 'l1',
|
||||||
|
propertyType: 'APARTMENT',
|
||||||
|
area: 72,
|
||||||
|
district: 'Quận 1',
|
||||||
|
pricePerM2: 74_500_000,
|
||||||
|
priceVnd: 5_364_000_000,
|
||||||
|
distanceKm: 0.8,
|
||||||
|
publishedAt: '2026-03-12T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modelPredictions: [
|
||||||
|
{ modelName: 'xgboost', weight: 0.6, predictedPriceVnd: 5_500_000_000, predictedPricePerM2Vnd: 73_333_333 },
|
||||||
|
{ modelName: 'lightgbm', weight: 0.4, predictedPriceVnd: 5_480_000_000, predictedPricePerM2Vnd: 73_066_666 },
|
||||||
|
],
|
||||||
|
ensembleMethod: 'weighted_average',
|
||||||
|
createdAt: '2026-04-18T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockHistory = { data: [], total: 0, page: 1, totalPages: 1, limit: 10 };
|
||||||
|
|
||||||
|
async function setupMocks(page: import('@playwright/test').Page) {
|
||||||
|
await page.route('**/auth/me', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ id: 'u1', email: 'e2e@test.vn', fullName: 'E2E User', role: 'USER' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/analytics/valuation/history**', (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHistory) }),
|
||||||
|
);
|
||||||
|
await page.route('**/analytics/valuation', (route) => {
|
||||||
|
if (route.request().method() === 'POST') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(mockValuationResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('AVM v2 Valuation Page', () => {
|
||||||
|
test('submit form -> render result card with confidence + price range', async ({ page }) => {
|
||||||
|
await setupMocks(page);
|
||||||
|
await page.goto('/vi/dashboard/valuation');
|
||||||
|
|
||||||
|
await page.locator('#propertyType').selectOption('APARTMENT');
|
||||||
|
await page.locator('#district').fill('Quận 1');
|
||||||
|
await page.locator('#area').fill('75');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Định giá ngay/i }).click();
|
||||||
|
|
||||||
|
const results = page.locator('#valuation-results');
|
||||||
|
await expect(results).toBeVisible();
|
||||||
|
await expect(results).toContainText('5.500.000.000');
|
||||||
|
await expect(results).toContainText('Độ tin cậy cao');
|
||||||
|
await expect(results).toContainText('avm-v2.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders rate-limit error state on HTTP 429', async ({ page }) => {
|
||||||
|
await page.route('**/auth/me', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ id: 'u1', email: 'e2e@test.vn', fullName: 'E2E User', role: 'USER' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/analytics/valuation/history**', (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHistory) }),
|
||||||
|
);
|
||||||
|
await page.route('**/analytics/valuation', (route) => {
|
||||||
|
if (route.request().method() === 'POST') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 429,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ message: 'Too many requests' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/vi/dashboard/valuation');
|
||||||
|
await page.locator('#propertyType').selectOption('APARTMENT');
|
||||||
|
await page.locator('#district').fill('Quận 1');
|
||||||
|
await page.locator('#area').fill('75');
|
||||||
|
await page.getByRole('button', { name: /Định giá ngay/i }).click();
|
||||||
|
|
||||||
|
const alert = page.getByTestId('valuation-error');
|
||||||
|
await expect(alert).toBeVisible();
|
||||||
|
await expect(alert).toContainText('Quá nhiều yêu cầu');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('export PDF button is visible after a successful valuation', async ({ page }) => {
|
||||||
|
await setupMocks(page);
|
||||||
|
await page.goto('/vi/dashboard/valuation');
|
||||||
|
|
||||||
|
await page.locator('#propertyType').selectOption('APARTMENT');
|
||||||
|
await page.locator('#district').fill('Quận 1');
|
||||||
|
await page.locator('#area').fill('75');
|
||||||
|
await page.getByRole('button', { name: /Định giá ngay/i }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('#valuation-results')).toBeVisible();
|
||||||
|
// Export PDF button (uses Download icon + label)
|
||||||
|
await expect(page.getByRole('button', { name: /Xuất PDF|Export PDF|Tải PDF/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user