Compare commits
7 Commits
d8b409a9ab
...
bf6a506719
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6a506719 | ||
|
|
588f6e0c19 | ||
|
|
62d737e439 | ||
|
|
5bbddc48c9 | ||
|
|
6a8e75effe | ||
|
|
db8ac9c592 | ||
|
|
13c2a97cbc |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -35,3 +35,8 @@ load-tests/results/*.json
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# personal notes / Obsidian
|
||||
.obsidian/
|
||||
TEC/
|
||||
*.canvas
|
||||
|
||||
1
.obsidian/app.json
vendored
1
.obsidian/app.json
vendored
@@ -1 +0,0 @@
|
||||
{}
|
||||
1
.obsidian/appearance.json
vendored
1
.obsidian/appearance.json
vendored
@@ -1 +0,0 @@
|
||||
{}
|
||||
33
.obsidian/core-plugins.json
vendored
33
.obsidian/core-plugins.json
vendored
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"file-explorer": true,
|
||||
"global-search": true,
|
||||
"switcher": true,
|
||||
"graph": true,
|
||||
"backlink": true,
|
||||
"canvas": true,
|
||||
"outgoing-link": true,
|
||||
"tag-pane": true,
|
||||
"footnotes": false,
|
||||
"properties": true,
|
||||
"page-preview": true,
|
||||
"daily-notes": true,
|
||||
"templates": true,
|
||||
"note-composer": true,
|
||||
"command-palette": true,
|
||||
"slash-command": false,
|
||||
"editor-status": true,
|
||||
"bookmarks": true,
|
||||
"markdown-importer": false,
|
||||
"zk-prefixer": false,
|
||||
"random-note": false,
|
||||
"outline": true,
|
||||
"word-count": true,
|
||||
"slides": false,
|
||||
"audio-recorder": false,
|
||||
"workspaces": false,
|
||||
"file-recovery": true,
|
||||
"publish": false,
|
||||
"sync": true,
|
||||
"bases": true,
|
||||
"webviewer": false
|
||||
}
|
||||
22
.obsidian/graph.json
vendored
22
.obsidian/graph.json
vendored
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"collapse-filter": true,
|
||||
"search": "",
|
||||
"showTags": false,
|
||||
"showAttachments": false,
|
||||
"hideUnresolved": false,
|
||||
"showOrphans": true,
|
||||
"collapse-color-groups": true,
|
||||
"colorGroups": [],
|
||||
"collapse-display": true,
|
||||
"showArrow": false,
|
||||
"textFadeMultiplier": 0,
|
||||
"nodeSizeMultiplier": 1,
|
||||
"lineSizeMultiplier": 1,
|
||||
"collapse-forces": true,
|
||||
"centerStrength": 0.518713248970312,
|
||||
"repelStrength": 10,
|
||||
"linkStrength": 1,
|
||||
"linkDistance": 250,
|
||||
"scale": 2.637251782725985,
|
||||
"close": false
|
||||
}
|
||||
195
.obsidian/workspace.json
vendored
195
.obsidian/workspace.json
vendored
@@ -1,195 +0,0 @@
|
||||
{
|
||||
"main": {
|
||||
"id": "8120c80685758063",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "32d6a24c5a30cd86",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "bcc8519e86215ae1",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "docs/audits/README.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "README"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
},
|
||||
"left": {
|
||||
"id": "1a23335ac90faeeb",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "84af90c3050b8522",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "6b1656f5d8814399",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical",
|
||||
"autoReveal": false
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
"title": "Trình duyệt tệp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eee596e4768da7c7",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical"
|
||||
},
|
||||
"icon": "lucide-search",
|
||||
"title": "Tìm kiếm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "d9f2a6cd9003f026",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "bookmarks",
|
||||
"state": {},
|
||||
"icon": "lucide-bookmark",
|
||||
"title": "Đánh dấu"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300
|
||||
},
|
||||
"right": {
|
||||
"id": "2a72ad31ae778671",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "afbb78cfabc5ffdc",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "b31ea8299b55a296",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"file": "docs/audits/README.md",
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
"showSearch": false,
|
||||
"searchQuery": "",
|
||||
"backlinkCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "Liên kết đến của README"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "4f0abda4204b5457",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"file": "docs/audits/README.md",
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "Liên kết đi ra từ README"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c90ad3bbb8a1356f",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "tag",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"useHierarchy": true,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-tags",
|
||||
"title": "Thẻ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0b43a9e84bf13337",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "all-properties",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-archive",
|
||||
"title": "Tất cả thuộc tính"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "09cab83df267633e",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "docs/audits/README.md",
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Đề cương của README"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300,
|
||||
"collapsed": true
|
||||
},
|
||||
"left-ribbon": {
|
||||
"hiddenItems": {
|
||||
"switcher:Mở bộ chuyển đổi nhanh": false,
|
||||
"graph:Mở xem biểu đồ": false,
|
||||
"canvas:Tạo bảng mới": false,
|
||||
"daily-notes:Mở ghi chú hôm nay": false,
|
||||
"templates:Chèn mẫu": false,
|
||||
"command-palette:Mở khay lệnh": false,
|
||||
"bases:Tạo cơ sở mới": false
|
||||
}
|
||||
},
|
||||
"active": "bcc8519e86215ae1",
|
||||
"lastOpenFiles": [
|
||||
"report/audit-2026-04-19.md",
|
||||
"docs/audits/README.md",
|
||||
"CHANGELOG.md",
|
||||
"CLAUDE.md",
|
||||
"CONTRIBUTING.md",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import {
|
||||
type EmailChangeRequestedEvent,
|
||||
type EmailChangedEvent,
|
||||
type PhoneChangeRequestedEvent,
|
||||
type PhoneChangedEvent,
|
||||
} from '@modules/auth';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.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(
|
||||
action: 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 { GetValuationHandler } from './application/queries/get-valuation/get-valuation.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 { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
|
||||
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository';
|
||||
@@ -46,6 +47,7 @@ const QueryHandlers = [
|
||||
BatchValuationHandler,
|
||||
ValuationHistoryHandler,
|
||||
ValuationComparisonHandler,
|
||||
ValuationExplanationHandler,
|
||||
GetNeighborhoodScoreHandler,
|
||||
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 { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.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 { 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', () => {
|
||||
const industrialDto = {
|
||||
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 { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
|
||||
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 { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
||||
import { AvmCompareQueryDto } from '../dto/avm-compare-query.dto';
|
||||
import { AvmExplainQueryDto } from '../dto/avm-explain-query.dto';
|
||||
import { BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||
import { IndustrialValuationDto } from '../dto/industrial-valuation.dto';
|
||||
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||
@@ -104,6 +107,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')
|
||||
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
|
||||
@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 { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||
import { type HashedPassword } from '../../domain/value-objects/hashed-password.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 { GenerateKycUploadUrlsHandler } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('GenerateKycUploadUrlsHandler', () => {
|
||||
getPresignedUploadUrl: vi.fn(),
|
||||
generatePresignedUpload: vi.fn(),
|
||||
getPublicUrl: vi.fn(),
|
||||
isTrustedUrl: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
mockLogger = {
|
||||
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 { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||
import { type HashedPassword } from '../../domain/value-objects/hashed-password.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 { SubmitKycHandler } from '../commands/submit-kyc/submit-kyc.handler';
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('SubmitKycHandler', () => {
|
||||
getPresignedUploadUrl: vi.fn(),
|
||||
generatePresignedUpload: vi.fn(),
|
||||
getPublicUrl: vi.fn(),
|
||||
isTrustedUrl: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
mockCache = {
|
||||
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -137,6 +138,115 @@ describe('SubmitKycHandler', () => {
|
||||
expect(result.message).toBeTruthy();
|
||||
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', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserEntity } from '../../domain/entities/user.entity';
|
||||
import { EmailChangedEvent } from '../../domain/events/email-changed.event';
|
||||
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||
import { Email } from '../../domain/value-objects/email.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 mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserRepo = {
|
||||
@@ -51,11 +53,13 @@ describe('VerifyEmailChangeHandler', () => {
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new VerifyEmailChangeHandler(
|
||||
mockUserRepo as any,
|
||||
mockRedis as any,
|
||||
mockCache as any,
|
||||
mockEventBus as any,
|
||||
{ error: vi.fn() } as any,
|
||||
);
|
||||
});
|
||||
@@ -78,6 +82,11 @@ describe('VerifyEmailChangeHandler', () => {
|
||||
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
||||
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 () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 HashedPassword } from '../../domain/value-objects/hashed-password.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 mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserRepo = {
|
||||
@@ -49,11 +51,13 @@ describe('VerifyPhoneChangeHandler', () => {
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new VerifyPhoneChangeHandler(
|
||||
mockUserRepo as any,
|
||||
mockRedis as any,
|
||||
mockCache as any,
|
||||
mockEventBus as any,
|
||||
{ error: vi.fn() } as any,
|
||||
);
|
||||
});
|
||||
@@ -76,6 +80,11 @@ describe('VerifyPhoneChangeHandler', () => {
|
||||
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
||||
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 () => {
|
||||
|
||||
@@ -49,6 +49,34 @@ export class SubmitKycHandler implements ICommandHandler<SubmitKycCommand> {
|
||||
frontImageUrl = command.imageUrls.frontImageUrl;
|
||||
backImageUrl = command.imageUrls.backImageUrl ?? 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) {
|
||||
// Legacy file upload flow: upload buffers server-side
|
||||
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(
|
||||
file: KycFileData,
|
||||
folder: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CachePrefix,
|
||||
CacheService,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RedisService,
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { EmailChangedEvent } from '../../../domain/events/email-changed.event';
|
||||
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
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,
|
||||
private readonly redis: RedisService,
|
||||
private readonly cache: CacheService,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -60,6 +62,7 @@ export class VerifyEmailChangeHandler implements ICommandHandler<VerifyEmailChan
|
||||
}
|
||||
|
||||
const emailVo = Email.create(newEmail).unwrap();
|
||||
const oldEmail = user.email?.value ?? null;
|
||||
user.updateProfile(undefined, undefined, emailVo);
|
||||
await this.userRepo.update(user);
|
||||
|
||||
@@ -69,6 +72,9 @@ export class VerifyEmailChangeHandler implements ICommandHandler<VerifyEmailChan
|
||||
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
|
||||
);
|
||||
|
||||
// Emit event for audit log
|
||||
this.eventBus.publish(new EmailChangedEvent(command.userId, oldEmail, emailVo.value));
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: emailVo.value,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CachePrefix,
|
||||
CacheService,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RedisService,
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { PhoneChangedEvent } from '../../../domain/events/phone-changed.event';
|
||||
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||
import { Phone } from '../../../domain/value-objects/phone.vo';
|
||||
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,
|
||||
private readonly redis: RedisService,
|
||||
private readonly cache: CacheService,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -60,6 +62,7 @@ export class VerifyPhoneChangeHandler implements ICommandHandler<VerifyPhoneChan
|
||||
}
|
||||
|
||||
const phoneVo = Phone.create(newPhone).unwrap();
|
||||
const oldPhone = user.phone.value;
|
||||
user.updatePhone(phoneVo);
|
||||
await this.userRepo.update(user);
|
||||
|
||||
@@ -69,6 +72,9 @@ export class VerifyPhoneChangeHandler implements ICommandHandler<VerifyPhoneChan
|
||||
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
|
||||
);
|
||||
|
||||
// Emit event for audit log
|
||||
this.eventBus.publish(new PhoneChangedEvent(command.userId, oldPhone, phoneVo.value));
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
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 { EmailChangeRequestedEvent } from './email-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 { EmailChangeRequestedEvent } from './domain/events/email-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';
|
||||
|
||||
@@ -260,7 +260,9 @@ export class AuthController {
|
||||
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')
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Update current user profile' })
|
||||
@@ -268,6 +270,7 @@ export class AuthController {
|
||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 409, description: 'Email already in use' })
|
||||
@ApiResponse({ status: 429, description: 'Too many requests' })
|
||||
async updateProfile(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
@@ -278,7 +281,9 @@ export class AuthController {
|
||||
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')
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Verify phone number change with SMS OTP code' })
|
||||
@@ -286,6 +291,7 @@ export class AuthController {
|
||||
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 409, description: 'Phone number already in use' })
|
||||
@ApiResponse({ status: 429, description: 'Too many requests' })
|
||||
async verifyPhoneChange(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: VerifyPhoneChangeDto,
|
||||
@@ -296,7 +302,9 @@ export class AuthController {
|
||||
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')
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Verify email change with OTP code' })
|
||||
@@ -304,6 +312,7 @@ export class AuthController {
|
||||
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 409, description: 'Email already in use' })
|
||||
@ApiResponse({ status: 429, description: 'Too many requests' })
|
||||
async verifyEmailChange(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: VerifyEmailChangeDto,
|
||||
|
||||
@@ -137,7 +137,30 @@ describe('UpdateListingHandler', () => {
|
||||
|
||||
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 amenities?: string[],
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 isAgent = listing.agentId !== null && listing.agentId === command.userId;
|
||||
if (!isOwner && !isAgent) {
|
||||
const isAdmin = command.userRole === 'ADMIN';
|
||||
if (!isOwner && !isAgent && !isAdmin) {
|
||||
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,
|
||||
): Promise<PresignedUploadResult>;
|
||||
getPublicUrl(objectKey: string): string;
|
||||
isTrustedUrl(url: string): boolean;
|
||||
}
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
@@ -151,6 +152,45 @@ export class MinioMediaStorageService implements IMediaStorageService, OnModuleI
|
||||
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> {
|
||||
try {
|
||||
const urlObj = new URL(fileUrl);
|
||||
|
||||
@@ -235,6 +235,7 @@ export class ListingsController {
|
||||
dto.rentPriceMonthly,
|
||||
dto.amenities,
|
||||
dto.mediaOrder,
|
||||
user.role,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ describe('TemplateService', () => {
|
||||
expect(result.body).toContain('/listings/2');
|
||||
});
|
||||
|
||||
it('getTemplateKeys returns all 13 template keys', () => {
|
||||
it('getTemplateKeys returns all 17 template keys', () => {
|
||||
const keys = service.getTemplateKeys();
|
||||
|
||||
expect(keys).toHaveLength(13);
|
||||
expect(keys).toHaveLength(17);
|
||||
expect(keys).toContain('user.registered');
|
||||
expect(keys).toContain('agent.verified');
|
||||
expect(keys).toContain('listing.approved');
|
||||
@@ -98,5 +98,9 @@ describe('TemplateService', () => {
|
||||
expect(keys).toContain('saved_search_digest');
|
||||
expect(keys).toContain('user.email_change_otp');
|
||||
expect(keys).toContain('user.phone_change_otp');
|
||||
expect(keys).toContain('inquiry.reply');
|
||||
expect(keys).toContain('listing.price_drop');
|
||||
expect(keys).toContain('subscription.renewal');
|
||||
expect(keys).toContain('subscription.renewed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getZaloZnsTemplates } from '../services/zalo-zns-templates';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'ZALO_ZNS_TEMPLATE_INQUIRY',
|
||||
'ZALO_ZNS_TEMPLATE_INQUIRY_REPLY',
|
||||
'ZALO_ZNS_TEMPLATE_PAYMENT',
|
||||
'ZALO_ZNS_TEMPLATE_LISTING_APPROVED',
|
||||
'ZALO_ZNS_TEMPLATE_LISTING_REJECTED',
|
||||
'ZALO_ZNS_TEMPLATE_LISTING_SOLD',
|
||||
'ZALO_ZNS_TEMPLATE_PRICE_DROP',
|
||||
'ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWAL',
|
||||
];
|
||||
|
||||
describe('getZaloZnsTemplates', () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an empty map when no template env vars are configured', () => {
|
||||
expect(getZaloZnsTemplates()).toEqual({});
|
||||
});
|
||||
|
||||
it('registers R8.2 templates when their env vars are present', () => {
|
||||
process.env['ZALO_ZNS_TEMPLATE_INQUIRY_REPLY'] = 'tpl-inquiry-reply';
|
||||
process.env['ZALO_ZNS_TEMPLATE_PRICE_DROP'] = 'tpl-price-drop';
|
||||
process.env['ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWAL'] = 'tpl-sub-renewal';
|
||||
|
||||
const tpls = getZaloZnsTemplates();
|
||||
|
||||
expect(tpls['inquiry.reply']?.templateId).toBe('tpl-inquiry-reply');
|
||||
expect(tpls['listing.price_drop']?.templateId).toBe('tpl-price-drop');
|
||||
expect(tpls['subscription.renewal']?.templateId).toBe('tpl-sub-renewal');
|
||||
});
|
||||
|
||||
it('maps inquiry.reply params correctly', () => {
|
||||
process.env['ZALO_ZNS_TEMPLATE_INQUIRY_REPLY'] = 'tpl-1';
|
||||
const tpl = getZaloZnsTemplates()['inquiry.reply']!;
|
||||
|
||||
expect(
|
||||
tpl.mapParams({
|
||||
agentName: 'Nguyễn Văn A',
|
||||
listingTitle: 'Căn hộ Q.1',
|
||||
message: 'Chào bạn',
|
||||
}),
|
||||
).toEqual({
|
||||
agent_name: 'Nguyễn Văn A',
|
||||
property_name: 'Căn hộ Q.1',
|
||||
message: 'Chào bạn',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps listing.price_drop params correctly', () => {
|
||||
process.env['ZALO_ZNS_TEMPLATE_PRICE_DROP'] = 'tpl-2';
|
||||
const tpl = getZaloZnsTemplates()['listing.price_drop']!;
|
||||
|
||||
expect(
|
||||
tpl.mapParams({
|
||||
listingTitle: 'Nhà phố Q.7',
|
||||
oldPriceVND: 5_000_000_000,
|
||||
newPriceVND: 4_500_000_000,
|
||||
}),
|
||||
).toEqual({
|
||||
listing_title: 'Nhà phố Q.7',
|
||||
old_price: '5000000000',
|
||||
new_price: '4500000000',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps subscription.renewal params correctly', () => {
|
||||
process.env['ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWAL'] = 'tpl-3';
|
||||
const tpl = getZaloZnsTemplates()['subscription.renewal']!;
|
||||
|
||||
expect(
|
||||
tpl.mapParams({
|
||||
planTier: 'PRO',
|
||||
renewalDate: '2026-05-01',
|
||||
amountVND: 299000,
|
||||
}),
|
||||
).toEqual({
|
||||
plan_tier: 'PRO',
|
||||
renewal_date: '2026-05-01',
|
||||
amount: '299000',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to empty strings for missing data keys', () => {
|
||||
process.env['ZALO_ZNS_TEMPLATE_PRICE_DROP'] = 'tpl-2';
|
||||
const tpl = getZaloZnsTemplates()['listing.price_drop']!;
|
||||
|
||||
expect(tpl.mapParams({})).toEqual({
|
||||
listing_title: '',
|
||||
old_price: '',
|
||||
new_price: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,34 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
|
||||
</table>
|
||||
<p><a href="{{listingUrl}}" style="display:inline-block;padding:10px 20px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;">Xem chi tiết</a></p>
|
||||
<p style="color:#6b7280;font-size:14px;">Bạn có thể tắt thông báo cho tìm kiếm này trong phần <a href="/saved-searches">Tìm kiếm đã lưu</a>.</p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'inquiry.reply': {
|
||||
subject: 'Phản hồi mới cho yêu cầu tư vấn của bạn',
|
||||
body: `<h1>Phản hồi mới từ {{agentName}}</h1>
|
||||
<p>Bạn nhận được phản hồi cho yêu cầu tư vấn về tin đăng <strong>{{listingTitle}}</strong>.</p>
|
||||
<p>Nội dung: {{message}}</p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'listing.price_drop': {
|
||||
subject: 'Tin đăng đã giảm giá: {{listingTitle}}',
|
||||
body: `<h1>Giá đã giảm!</h1>
|
||||
<p>Tin đăng <strong>{{listingTitle}}</strong> mà bạn đang theo dõi vừa giảm giá.</p>
|
||||
<p>Giá cũ: <strong>{{oldPriceVND}} VNĐ</strong></p>
|
||||
<p>Giá mới: <strong>{{newPriceVND}} VNĐ</strong></p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'subscription.renewal': {
|
||||
subject: 'Nhắc nhở gia hạn gói {{planTier}}',
|
||||
body: `<h1>Gói đăng ký sắp gia hạn</h1>
|
||||
<p>Gói <strong>{{planTier}}</strong> của bạn sẽ được gia hạn vào <strong>{{renewalDate}}</strong>.</p>
|
||||
<p>Số tiền dự kiến: <strong>{{amountVND}} VNĐ</strong></p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'subscription.renewed': {
|
||||
subject: 'Gói {{planTier}} đã được gia hạn',
|
||||
body: `<h1>Gia hạn thành công</h1>
|
||||
<p>Gói <strong>{{planTier}}</strong> của bạn đã được gia hạn đến <strong>{{periodEnd}}</strong>.</p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'saved_search_digest': {
|
||||
|
||||
@@ -83,5 +83,56 @@ export function getZaloZnsTemplates(): Record<string, ZaloZnsTemplateConfig> {
|
||||
};
|
||||
}
|
||||
|
||||
// R8.2 — Inquiry reply: notify the original inquirer that the agent/owner replied
|
||||
const inquiryReplyTplId = process.env['ZALO_ZNS_TEMPLATE_INQUIRY_REPLY'] ?? '';
|
||||
if (inquiryReplyTplId) {
|
||||
templates['inquiry.reply'] = {
|
||||
templateId: inquiryReplyTplId,
|
||||
mapParams: (data) => ({
|
||||
agent_name: String(data['agentName'] ?? ''),
|
||||
property_name: String(data['listingTitle'] ?? ''),
|
||||
message: String(data['message'] ?? ''),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// R8.2 — Listing price drop: notify watchers/saved-search subscribers
|
||||
const priceDropTplId = process.env['ZALO_ZNS_TEMPLATE_PRICE_DROP'] ?? '';
|
||||
if (priceDropTplId) {
|
||||
templates['listing.price_drop'] = {
|
||||
templateId: priceDropTplId,
|
||||
mapParams: (data) => ({
|
||||
listing_title: String(data['listingTitle'] ?? ''),
|
||||
old_price: String(data['oldPriceVND'] ?? ''),
|
||||
new_price: String(data['newPriceVND'] ?? ''),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// R8.2 — Subscription renewal reminder (pre-renewal heads-up)
|
||||
const subscriptionRenewalTplId = process.env['ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWAL'] ?? '';
|
||||
if (subscriptionRenewalTplId) {
|
||||
templates['subscription.renewal'] = {
|
||||
templateId: subscriptionRenewalTplId,
|
||||
mapParams: (data) => ({
|
||||
plan_tier: String(data['planTier'] ?? ''),
|
||||
renewal_date: String(data['renewalDate'] ?? ''),
|
||||
amount: String(data['amountVND'] ?? ''),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// R8.2 — Subscription renewed (post-renewal confirmation; matches SubscriptionRenewedListener)
|
||||
const subscriptionRenewedTplId = process.env['ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWED'] ?? '';
|
||||
if (subscriptionRenewedTplId) {
|
||||
templates['subscription.renewed'] = {
|
||||
templateId: subscriptionRenewedTplId,
|
||||
mapParams: (data) => ({
|
||||
plan_tier: String(data['planTier'] ?? ''),
|
||||
period_end: String(data['periodEnd'] ?? ''),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user