From 9e70074403a7bb7cd0fdc71658838c2bf3d994a2 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 23 Apr 2026 20:12:48 +0700 Subject: [PATCH] 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 --- .../__tests__/prisma-avm.service.spec.ts | 2 +- .../services/ai-service.client.ts | 254 +++++------------- .../services/avm-retrain-cron.service.ts | 14 +- .../services/prisma-avm.service.ts | 9 +- .../services/media-storage.service.ts | 7 +- .../infrastructure/services/momo.service.ts | 7 +- .../services/zalopay.service.ts | 7 +- apps/api/src/modules/shared/domain/result.ts | 5 +- .../shared/infrastructure/env-validation.ts | 21 +- .../shared/infrastructure/field-encryption.ts | 10 +- 10 files changed, 126 insertions(+), 210 deletions(-) diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts index e73c582..743fc60 100644 --- a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts @@ -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", ); }); diff --git a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts index 9934b81..ad8ebff 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts @@ -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; - 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 { - return this.post('/avm/predict', req); + return this.post(AiRoutes.avmPredict, req); } async predictV2(req: AiPredictV2Request): Promise { - return this.post('/avm/v2/predict', req); + return this.post(AiRoutes.avmV2Predict, req); } async predictIndustrial(req: AiIndustrialPredictRequest): Promise { - return this.post('/avm/industrial/predict', req); + return this.post(AiRoutes.avmIndustrialPredict, req); } async moderate(req: AiModerationRequest): Promise { - return this.post('/moderation/check', req); + return this.post(AiRoutes.moderationCheck, req); } async scoreNeighborhood( req: AiNeighborhoodScoreRequest, ): Promise { - return this.post('/neighborhood/score', req); + return this.post(AiRoutes.neighborhoodScore, req); } async isAvailable(): Promise { 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; diff --git a/apps/api/src/modules/analytics/infrastructure/services/avm-retrain-cron.service.ts b/apps/api/src/modules/analytics/infrastructure/services/avm-retrain-cron.service.ts index 299c5e8..160b8f3 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/avm-retrain-cron.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/avm-retrain-cron.service.ts @@ -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; diff --git a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts index 4d66a08..92147ee 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts @@ -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 { @@ -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; } diff --git a/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts b/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts index 6a753d2..2859e9e 100644 --- a/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts +++ b/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts @@ -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; } diff --git a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts index 0797c9c..7aa73fc 100644 --- a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts @@ -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'); diff --git a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts index 967fb74..0f951cb 100644 --- a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts @@ -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'); diff --git a/apps/api/src/modules/shared/domain/result.ts b/apps/api/src/modules/shared/domain/result.ts index 45a177f..fe2c98c 100644 --- a/apps/api/src/modules/shared/domain/result.ts +++ b/apps/api/src/modules/shared/domain/result.ts @@ -1,3 +1,6 @@ +import { DomainException } from './domain-exception'; +import { ErrorCode } from './error-codes'; + export class Result { private constructor( private readonly _isOk: boolean, @@ -28,7 +31,7 @@ export class Result { 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(fn: (value: T) => U): Result { diff --git a/apps/api/src/modules/shared/infrastructure/env-validation.ts b/apps/api/src/modules/shared/infrastructure/env-validation.ts index e25077a..6c852b3 100644 --- a/apps/api/src/modules/shared/infrastructure/env-validation.ts +++ b/apps/api/src/modules/shared/infrastructure/env-validation.ts @@ -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.`, ); } diff --git a/apps/api/src/modules/shared/infrastructure/field-encryption.ts b/apps/api/src/modules/shared/infrastructure/field-encryption.ts index 64e7f0d..8dc3890 100644 --- a/apps/api/src/modules/shared/infrastructure/field-encryption.ts +++ b/apps/api/src/modules/shared/infrastructure/field-encryption.ts @@ -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;