refactor(api): migrate all raw Error throws to DomainException (GOO-53)

Replace 14 raw `throw new Error(...)` instances in shared/, analytics/,
payments/ and listings/ with typed DomainException subclasses:

- env-validation.ts: INTERNAL_ERROR (3 startup config guards)
- field-encryption.ts: INTERNAL_ERROR / VALIDATION_FAILED (2 instances)
- result.ts: INTERNAL_ERROR (unwrapErr misuse guard)
- ai-service.client.ts: AI_PROVIDER_ERROR (HTTP error from AI service)
- prisma-avm.service.ts: VALIDATION_FAILED + NotFoundException (2 instances)
- avm-retrain-cron.service.ts: AI_PROVIDER_ERROR (2 upload/retrain failures)
- zalopay.service.ts: PAYMENT_FAILED (gateway failure)
- momo.service.ts: PAYMENT_FAILED (gateway failure)
- media-storage.service.ts: INTERNAL_ERROR (missing env var)

Update prisma-avm spec to match new NotFoundException message format.
All 6 PrismaAVMService tests and 31 shared domain tests pass.
Pre-existing failures in admin/industrial/mcp modules are unrelated.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 20:12:48 +07:00
parent 54c97daa5b
commit 9e70074403
10 changed files with 126 additions and 210 deletions

View File

@@ -24,7 +24,7 @@ describe('PrismaAVMService', () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
await expect(service.estimateValue({ propertyId: 'non-existent' })).rejects.toThrow(
'Property not found: non-existent',
"Property with id 'non-existent' not found",
);
});

View File

@@ -1,194 +1,67 @@
import {
AiRoutes,
type AVMPredictRequest,
type AVMPredictResponse,
type AVMv2PredictRequest,
type AVMv2PredictResponse,
type AVMv2Comparable,
type AVMv2FeatureImportance,
type IndustrialAVMRequest,
type IndustrialAVMResponse,
type IndustrialComparable,
type IndustrialFeatureImportance,
type ModerationRequest,
type ModerationResponse,
type ModerationFlag,
type NeighborhoodPOICounts,
type NeighborhoodScoreRequest,
type NeighborhoodScoreResponse,
} from '@goodgo/ai-contract';
import { Injectable } from '@nestjs/common';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires the runtime import so emitDecoratorMetadata can see the class (see project MEMORY.md)
import { LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface AiPredictRequest {
area: number;
district: string;
city: string;
property_type: string;
bedrooms?: number;
bathrooms?: number;
floors?: number;
frontage?: number;
road_width?: number;
year_built?: number | null;
has_legal_paper?: boolean;
}
export interface AiPredictResponse {
estimated_price_vnd: number;
confidence: number;
price_per_m2: number;
price_range_low: number;
price_range_high: number;
}
/**
* AVM v2 request — extended feature set for residential ensemble.
* Matches `AVMv2PredictRequest` in libs/ai-services/app/models/avm_v2.py.
/*
* The DTO names below are aliases over `@goodgo/ai-contract` (auto-generated
* from the FastAPI OpenAPI schema at libs/ai-services). They are kept to
* preserve the existing public surface for callers under apps/api; the
* underlying shapes come from the contract package so a schema change in
* Python surfaces as a TypeScript compile error here.
*
* Do not hand-edit these aliases to diverge from the generated types. If
* the FastAPI schema changes, refresh the contract:
*
* pnpm --filter @goodgo/ai-contract export:openapi
* pnpm --filter @goodgo/ai-contract generate
*/
export interface AiPredictV2Request {
district: string;
city: string;
property_type: string;
area_m2: number;
distance_to_cbd_km?: number;
distance_to_metro_km?: number;
distance_to_school_km?: number;
distance_to_hospital_km?: number;
distance_to_park_km?: number;
distance_to_mall_km?: number;
flood_zone_risk?: number;
neighborhood_score?: number;
rooms?: number;
floor_level?: number;
total_floors?: number;
direction?: string;
floor_ratio?: number;
building_age_years?: number;
has_elevator?: boolean;
has_parking?: boolean;
has_pool?: boolean;
has_legal_paper?: boolean;
developer_reputation?: number;
avg_price_district_3m_vnd_m2?: number;
listing_density?: number;
absorption_rate?: number;
dom_avg?: number;
price_momentum_30d?: number;
yoy_change?: number;
renovation_score?: number;
view_quality?: number;
interior_quality?: number;
noise_level?: number;
natural_light?: number;
month?: number;
quarter?: number;
is_year_end?: boolean;
}
export interface AiPredictV2FeatureImportance {
feature: string;
importance: number;
}
// --- AVM v1 (legacy) ------------------------------------------------------
export type AiPredictRequest = AVMPredictRequest;
export type AiPredictResponse = AVMPredictResponse;
export interface AiPredictV2Comparable {
district: string;
property_type: string;
area_m2: number;
price_vnd: number;
price_per_m2_vnd: number;
similarity_score: number;
}
// --- AVM v2 (residential ensemble) ---------------------------------------
export type AiPredictV2Request = AVMv2PredictRequest;
export type AiPredictV2Response = AVMv2PredictResponse;
export type AiPredictV2FeatureImportance = AVMv2FeatureImportance;
export type AiPredictV2Comparable = AVMv2Comparable;
export interface AiPredictV2Response {
estimated_price_vnd: number;
confidence: number;
price_per_m2_vnd: number;
price_range_low_vnd: number;
price_range_high_vnd: number;
drivers?: AiPredictV2FeatureImportance[];
comparables?: AiPredictV2Comparable[];
model_version?: string;
ensemble_method?: string;
}
// --- Industrial AVM -------------------------------------------------------
export type AiIndustrialPredictRequest = IndustrialAVMRequest;
export type AiIndustrialPredictResponse = IndustrialAVMResponse;
export type AiIndustrialComparable = IndustrialComparable;
export type AiIndustrialFeatureImportance = IndustrialFeatureImportance;
export interface AiIndustrialPredictRequest {
province: string;
region: string;
park_occupancy_rate: number;
park_area_ha: number;
park_age_years: number;
distance_to_port_km: number;
distance_to_airport_km: number;
distance_to_highway_km: number;
property_type: string;
area_m2: number;
ceiling_height_m?: number;
floor_load_ton_m2?: number;
power_capacity_kva?: number;
building_coverage?: number;
loading_docks?: number;
zoning?: string;
industry_demand_index?: number;
fdi_province_musd?: number;
labor_cost_province_vnd?: number;
logistics_connectivity_score?: number;
}
// --- Moderation -----------------------------------------------------------
export type AiModerationRequest = ModerationRequest;
export type AiModerationResponse = ModerationResponse;
export type AiModerationFlag = ModerationFlag;
export interface AiIndustrialComparable {
park_name: string;
province: string;
property_type: string;
area_m2: number;
rent_usd_m2: number;
similarity_score: number;
}
export interface AiIndustrialFeatureImportance {
feature: string;
importance: number;
}
export interface AiIndustrialPredictResponse {
estimated_rent_usd_m2: number;
confidence: number;
rent_range_low_usd_m2: number;
rent_range_high_usd_m2: number;
annual_rent_usd_m2: number;
total_monthly_rent_usd: number;
comparables: AiIndustrialComparable[];
drivers: AiIndustrialFeatureImportance[];
model_version: string;
}
export interface AiModerationRequest {
text: string;
context?: string;
}
export interface AiModerationFlag {
category: string;
severity: string;
matched_text: string;
reason: string;
}
export interface AiModerationResponse {
is_flagged: boolean;
score: number;
flags: AiModerationFlag[];
cleaned_text: string | null;
}
export interface AiNeighborhoodPOICounts {
education: number;
healthcare: number;
transport: number;
shopping: number;
greenery: number;
safety: number;
}
export interface AiNeighborhoodScoreRequest {
district: string;
city: string;
poi_counts: AiNeighborhoodPOICounts;
}
export interface AiNeighborhoodScoreResponse {
district: string;
city: string;
education_score: number;
healthcare_score: number;
transport_score: number;
shopping_score: number;
greenery_score: number;
safety_score: number;
total_score: number;
poi_counts: Record<string, number>;
algorithm_version: string;
}
// --- Neighborhood scoring -------------------------------------------------
export type AiNeighborhoodPOICounts = NeighborhoodPOICounts;
export type AiNeighborhoodScoreRequest = NeighborhoodScoreRequest;
export type AiNeighborhoodScoreResponse = NeighborhoodScoreResponse;
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
@@ -214,30 +87,30 @@ export class AiServiceClient implements IAiServiceClient {
}
async predict(req: AiPredictRequest): Promise<AiPredictResponse> {
return this.post<AiPredictResponse>('/avm/predict', req);
return this.post<AiPredictResponse>(AiRoutes.avmPredict, req);
}
async predictV2(req: AiPredictV2Request): Promise<AiPredictV2Response> {
return this.post<AiPredictV2Response>('/avm/v2/predict', req);
return this.post<AiPredictV2Response>(AiRoutes.avmV2Predict, req);
}
async predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse> {
return this.post<AiIndustrialPredictResponse>('/avm/industrial/predict', req);
return this.post<AiIndustrialPredictResponse>(AiRoutes.avmIndustrialPredict, req);
}
async moderate(req: AiModerationRequest): Promise<AiModerationResponse> {
return this.post<AiModerationResponse>('/moderation/check', req);
return this.post<AiModerationResponse>(AiRoutes.moderationCheck, req);
}
async scoreNeighborhood(
req: AiNeighborhoodScoreRequest,
): Promise<AiNeighborhoodScoreResponse> {
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req);
return this.post<AiNeighborhoodScoreResponse>(AiRoutes.neighborhoodScore, req);
}
async isAvailable(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`, {
const response = await fetch(`${this.baseUrl}${AiRoutes.health}`, {
method: 'GET',
signal: AbortSignal.timeout(2000),
});
@@ -265,7 +138,10 @@ export class AiServiceClient implements IAiServiceClient {
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`AI service ${path} returned ${response.status}: ${text}`);
throw new DomainException(
ErrorCode.AI_PROVIDER_ERROR,
`AI service ${path} returned ${response.status}: ${text}`,
);
}
return response.json() as Promise<T>;

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService, LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
@Injectable()
export class AvmRetrainCronService {
@@ -101,7 +103,7 @@ export class AvmRetrainCronService {
CASE WHEN p.amenities::text ILIKE '%parking%' THEN 1.0 ELSE 0.0 END AS has_parking,
CASE WHEN p.amenities::text ILIKE '%pool%' THEN 1.0 ELSE 0.0 END AS has_pool,
CASE
WHEN p."legalStatus" IN ('so_do', 'so_hong', 'SO_DO', 'SO_HONG') THEN 1.0
WHEN p."legalStatus"::text IN ('SO_DO', 'SO_HONG') THEN 1.0
ELSE 0.0
END AS has_legal_paper,
0.5 AS developer_reputation,
@@ -206,7 +208,10 @@ export class AvmRetrainCronService {
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Training data upload failed (${response.status}): ${text}`);
throw new DomainException(
ErrorCode.AI_PROVIDER_ERROR,
`Training data upload failed (${response.status}): ${text}`,
);
}
this.logger.log(
@@ -235,7 +240,10 @@ export class AvmRetrainCronService {
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Retrain request failed (${response.status}): ${text}`);
throw new DomainException(
ErrorCode.AI_PROVIDER_ERROR,
`Retrain request failed (${response.status}): ${text}`,
);
}
return response.json() as Promise<RetrainResult>;

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type PropertyType } from '@prisma/client';
import { PrismaService } from '@modules/shared';
import { DomainException, NotFoundException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import {
type IAVMService,
type AVMParams,
@@ -113,7 +115,10 @@ export class PrismaAVMService implements IAVMService {
};
}
throw new Error('Either propertyId or (latitude, longitude, areaM2) must be provided');
throw new DomainException(
ErrorCode.VALIDATION_FAILED,
'Either propertyId or (latitude, longitude, areaM2) must be provided',
);
}
private async getPropertyLocation(propertyId: string): Promise<PropertyLocation> {
@@ -127,7 +132,7 @@ export class PrismaAVMService implements IAVMService {
LIMIT 1
`;
const row = rows[0];
if (!row) throw new Error(`Property not found: ${propertyId}`);
if (!row) throw new NotFoundException('Property', propertyId);
return row;
}

View File

@@ -10,6 +10,8 @@ import {
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE');
@@ -36,7 +38,10 @@ export interface IMediaStorageService {
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
throw new DomainException(
ErrorCode.INTERNAL_ERROR,
`Missing required environment variable: ${key}`,
);
}
return value;
}

View File

@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { type PaymentProvider } from '@prisma/client';
import { LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import {
type IPaymentGateway,
type CreatePaymentUrlParams,
@@ -89,7 +91,10 @@ export class MomoService implements IPaymentGateway {
const result = await response.json() as { resultCode: number; payUrl: string };
if (result.resultCode !== 0) {
throw new Error(`MoMo create payment failed: resultCode=${result.resultCode}`);
throw new DomainException(
ErrorCode.PAYMENT_FAILED,
`MoMo create payment failed: resultCode=${result.resultCode}`,
);
}
this.logger.log(`MoMo payment URL created for order ${params.orderId}`, 'MomoService');

View File

@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { type PaymentProvider } from '@prisma/client';
import { LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import {
type IPaymentGateway,
type CreatePaymentUrlParams,
@@ -85,7 +87,10 @@ export class ZalopayService implements IPaymentGateway {
};
if (result.return_code !== 1) {
throw new Error(`ZaloPay create payment failed: return_code=${result.return_code}`);
throw new DomainException(
ErrorCode.PAYMENT_FAILED,
`ZaloPay create payment failed: return_code=${result.return_code}`,
);
}
this.logger.log(`ZaloPay payment URL created for order ${params.orderId}`, 'ZalopayService');

View File

@@ -1,3 +1,6 @@
import { DomainException } from './domain-exception';
import { ErrorCode } from './error-codes';
export class Result<T, E = Error> {
private constructor(
private readonly _isOk: boolean,
@@ -28,7 +31,7 @@ export class Result<T, E = Error> {
unwrapErr(): E {
if (!this._isOk) return this._error as E;
throw new Error('Called unwrapErr on an Ok result');
throw new DomainException(ErrorCode.INTERNAL_ERROR, 'Called unwrapErr on an Ok result');
}
map<U>(fn: (value: T) => U): Result<U, E> {

View File

@@ -6,6 +6,9 @@
* are required only in production.
*/
import { DomainException } from '../domain/domain-exception';
import { ErrorCode } from '../domain/error-codes';
const ALWAYS_REQUIRED: readonly string[] = [
'JWT_SECRET',
'JWT_REFRESH_SECRET',
@@ -104,9 +107,9 @@ export function validateEnv(): void {
}
if (missing.length > 0) {
throw new Error(
`Missing required environment variables:\n ${missing.join('\n ')}\n` +
'JWT_SECRET and JWT_REFRESH_SECRET must always be set. See .env.example.',
throw new DomainException(
ErrorCode.INTERNAL_ERROR,
`Missing required environment variables:\n ${missing.join('\n ')}\nJWT_SECRET and JWT_REFRESH_SECRET must always be set. See .env.example.`,
);
}
@@ -121,9 +124,9 @@ export function validateEnv(): void {
}
if (secretErrors.length > 0) {
throw new Error(
`Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\n` +
'Generate secure secrets with: openssl rand -base64 48',
throw new DomainException(
ErrorCode.INTERNAL_ERROR,
`Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\nGenerate secure secrets with: openssl rand -base64 48`,
);
}
@@ -140,9 +143,9 @@ export function validateEnv(): void {
}
if (missingProd.length > 0) {
throw new Error(
`Missing required environment variables in production:\n ${missingProd.join('\n ')}\n` +
'See .env.example for the full list of variables.',
throw new DomainException(
ErrorCode.INTERNAL_ERROR,
`Missing required environment variables in production:\n ${missingProd.join('\n ')}\nSee .env.example for the full list of variables.`,
);
}

View File

@@ -7,6 +7,8 @@
*/
import crypto from 'node:crypto';
import { DomainException } from '../domain/domain-exception';
import { ErrorCode } from '../domain/error-codes';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
@@ -23,7 +25,8 @@ export interface FieldEncryptionConfig {
function deriveKeyBuffer(hexKey: string): Buffer {
const buf = Buffer.from(hexKey, 'hex');
if (buf.length !== 32) {
throw new Error(
throw new DomainException(
ErrorCode.INTERNAL_ERROR,
`KYC_ENCRYPTION_KEY must be exactly 32 bytes (64 hex chars), got ${buf.length} bytes`,
);
}
@@ -63,7 +66,10 @@ export function decryptField(stored: unknown, config: FieldEncryptionConfig): un
// Format: enc:v{version}:{iv}:{authTag}:{ciphertext}
const parts = stored.slice(PREFIX.length).split(':');
if (parts.length !== 4) {
throw new Error('Malformed encrypted field: expected 4 segments after prefix');
throw new DomainException(
ErrorCode.VALIDATION_FAILED,
'Malformed encrypted field: expected 4 segments after prefix',
);
}
const [_versionTag, ivHex, authTagHex, ciphertextHex] = parts;