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:
@@ -24,7 +24,7 @@ describe('PrismaAVMService', () => {
|
|||||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(service.estimateValue({ propertyId: 'non-existent' })).rejects.toThrow(
|
await expect(service.estimateValue({ propertyId: 'non-existent' })).rejects.toThrow(
|
||||||
'Property not found: non-existent',
|
"Property with id 'non-existent' not found",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
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 { 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;
|
* The DTO names below are aliases over `@goodgo/ai-contract` (auto-generated
|
||||||
district: string;
|
* from the FastAPI OpenAPI schema at libs/ai-services). They are kept to
|
||||||
city: string;
|
* preserve the existing public surface for callers under apps/api; the
|
||||||
property_type: string;
|
* underlying shapes come from the contract package so a schema change in
|
||||||
bedrooms?: number;
|
* Python surfaces as a TypeScript compile error here.
|
||||||
bathrooms?: number;
|
*
|
||||||
floors?: number;
|
* Do not hand-edit these aliases to diverge from the generated types. If
|
||||||
frontage?: number;
|
* the FastAPI schema changes, refresh the contract:
|
||||||
road_width?: number;
|
*
|
||||||
year_built?: number | null;
|
* pnpm --filter @goodgo/ai-contract export:openapi
|
||||||
has_legal_paper?: boolean;
|
* pnpm --filter @goodgo/ai-contract generate
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
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 {
|
// --- AVM v1 (legacy) ------------------------------------------------------
|
||||||
feature: string;
|
export type AiPredictRequest = AVMPredictRequest;
|
||||||
importance: number;
|
export type AiPredictResponse = AVMPredictResponse;
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiPredictV2Comparable {
|
// --- AVM v2 (residential ensemble) ---------------------------------------
|
||||||
district: string;
|
export type AiPredictV2Request = AVMv2PredictRequest;
|
||||||
property_type: string;
|
export type AiPredictV2Response = AVMv2PredictResponse;
|
||||||
area_m2: number;
|
export type AiPredictV2FeatureImportance = AVMv2FeatureImportance;
|
||||||
price_vnd: number;
|
export type AiPredictV2Comparable = AVMv2Comparable;
|
||||||
price_per_m2_vnd: number;
|
|
||||||
similarity_score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiPredictV2Response {
|
// --- Industrial AVM -------------------------------------------------------
|
||||||
estimated_price_vnd: number;
|
export type AiIndustrialPredictRequest = IndustrialAVMRequest;
|
||||||
confidence: number;
|
export type AiIndustrialPredictResponse = IndustrialAVMResponse;
|
||||||
price_per_m2_vnd: number;
|
export type AiIndustrialComparable = IndustrialComparable;
|
||||||
price_range_low_vnd: number;
|
export type AiIndustrialFeatureImportance = IndustrialFeatureImportance;
|
||||||
price_range_high_vnd: number;
|
|
||||||
drivers?: AiPredictV2FeatureImportance[];
|
|
||||||
comparables?: AiPredictV2Comparable[];
|
|
||||||
model_version?: string;
|
|
||||||
ensemble_method?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiIndustrialPredictRequest {
|
// --- Moderation -----------------------------------------------------------
|
||||||
province: string;
|
export type AiModerationRequest = ModerationRequest;
|
||||||
region: string;
|
export type AiModerationResponse = ModerationResponse;
|
||||||
park_occupancy_rate: number;
|
export type AiModerationFlag = ModerationFlag;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiIndustrialComparable {
|
// --- Neighborhood scoring -------------------------------------------------
|
||||||
park_name: string;
|
export type AiNeighborhoodPOICounts = NeighborhoodPOICounts;
|
||||||
province: string;
|
export type AiNeighborhoodScoreRequest = NeighborhoodScoreRequest;
|
||||||
property_type: string;
|
export type AiNeighborhoodScoreResponse = NeighborhoodScoreResponse;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
|
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
|
||||||
|
|
||||||
@@ -214,30 +87,30 @@ export class AiServiceClient implements IAiServiceClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async predict(req: AiPredictRequest): Promise<AiPredictResponse> {
|
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> {
|
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> {
|
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> {
|
async moderate(req: AiModerationRequest): Promise<AiModerationResponse> {
|
||||||
return this.post<AiModerationResponse>('/moderation/check', req);
|
return this.post<AiModerationResponse>(AiRoutes.moderationCheck, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
async scoreNeighborhood(
|
async scoreNeighborhood(
|
||||||
req: AiNeighborhoodScoreRequest,
|
req: AiNeighborhoodScoreRequest,
|
||||||
): Promise<AiNeighborhoodScoreResponse> {
|
): Promise<AiNeighborhoodScoreResponse> {
|
||||||
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req);
|
return this.post<AiNeighborhoodScoreResponse>(AiRoutes.neighborhoodScore, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/health`, {
|
const response = await fetch(`${this.baseUrl}${AiRoutes.health}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal: AbortSignal.timeout(2000),
|
signal: AbortSignal.timeout(2000),
|
||||||
});
|
});
|
||||||
@@ -265,7 +138,10 @@ export class AiServiceClient implements IAiServiceClient {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
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>;
|
return response.json() as Promise<T>;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { PrismaService, LoggerService } from '@modules/shared';
|
import { PrismaService, LoggerService } from '@modules/shared';
|
||||||
|
import { DomainException } from '@modules/shared/domain/domain-exception';
|
||||||
|
import { ErrorCode } from '@modules/shared/domain/error-codes';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AvmRetrainCronService {
|
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 '%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.amenities::text ILIKE '%pool%' THEN 1.0 ELSE 0.0 END AS has_pool,
|
||||||
CASE
|
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
|
ELSE 0.0
|
||||||
END AS has_legal_paper,
|
END AS has_legal_paper,
|
||||||
0.5 AS developer_reputation,
|
0.5 AS developer_reputation,
|
||||||
@@ -206,7 +208,10 @@ export class AvmRetrainCronService {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
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(
|
this.logger.log(
|
||||||
@@ -235,7 +240,10 @@ export class AvmRetrainCronService {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
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>;
|
return response.json() as Promise<RetrainResult>;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { type PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { PrismaService } from '@modules/shared';
|
||||||
|
import { DomainException, NotFoundException } from '@modules/shared/domain/domain-exception';
|
||||||
|
import { ErrorCode } from '@modules/shared/domain/error-codes';
|
||||||
import {
|
import {
|
||||||
type IAVMService,
|
type IAVMService,
|
||||||
type AVMParams,
|
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> {
|
private async getPropertyLocation(propertyId: string): Promise<PropertyLocation> {
|
||||||
@@ -127,7 +132,7 @@ export class PrismaAVMService implements IAVMService {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) throw new Error(`Property not found: ${propertyId}`);
|
if (!row) throw new NotFoundException('Property', propertyId);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||||
import { LoggerService } from '@modules/shared';
|
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');
|
export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE');
|
||||||
|
|
||||||
@@ -36,7 +38,10 @@ export interface IMediaStorageService {
|
|||||||
function requireEnv(key: string): string {
|
function requireEnv(key: string): string {
|
||||||
const value = process.env[key];
|
const value = process.env[key];
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error(`Missing required environment variable: ${key}`);
|
throw new DomainException(
|
||||||
|
ErrorCode.INTERNAL_ERROR,
|
||||||
|
`Missing required environment variable: ${key}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { type PaymentProvider } from '@prisma/client';
|
import { type PaymentProvider } from '@prisma/client';
|
||||||
import { LoggerService } from '@modules/shared';
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { DomainException } from '@modules/shared/domain/domain-exception';
|
||||||
|
import { ErrorCode } from '@modules/shared/domain/error-codes';
|
||||||
import {
|
import {
|
||||||
type IPaymentGateway,
|
type IPaymentGateway,
|
||||||
type CreatePaymentUrlParams,
|
type CreatePaymentUrlParams,
|
||||||
@@ -89,7 +91,10 @@ export class MomoService implements IPaymentGateway {
|
|||||||
const result = await response.json() as { resultCode: number; payUrl: string };
|
const result = await response.json() as { resultCode: number; payUrl: string };
|
||||||
|
|
||||||
if (result.resultCode !== 0) {
|
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');
|
this.logger.log(`MoMo payment URL created for order ${params.orderId}`, 'MomoService');
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { type PaymentProvider } from '@prisma/client';
|
import { type PaymentProvider } from '@prisma/client';
|
||||||
import { LoggerService } from '@modules/shared';
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { DomainException } from '@modules/shared/domain/domain-exception';
|
||||||
|
import { ErrorCode } from '@modules/shared/domain/error-codes';
|
||||||
import {
|
import {
|
||||||
type IPaymentGateway,
|
type IPaymentGateway,
|
||||||
type CreatePaymentUrlParams,
|
type CreatePaymentUrlParams,
|
||||||
@@ -85,7 +87,10 @@ export class ZalopayService implements IPaymentGateway {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (result.return_code !== 1) {
|
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');
|
this.logger.log(`ZaloPay payment URL created for order ${params.orderId}`, 'ZalopayService');
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { DomainException } from './domain-exception';
|
||||||
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
export class Result<T, E = Error> {
|
export class Result<T, E = Error> {
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly _isOk: boolean,
|
private readonly _isOk: boolean,
|
||||||
@@ -28,7 +31,7 @@ export class Result<T, E = Error> {
|
|||||||
|
|
||||||
unwrapErr(): E {
|
unwrapErr(): E {
|
||||||
if (!this._isOk) return this._error as 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> {
|
map<U>(fn: (value: T) => U): Result<U, E> {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
* are required only in production.
|
* are required only in production.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DomainException } from '../domain/domain-exception';
|
||||||
|
import { ErrorCode } from '../domain/error-codes';
|
||||||
|
|
||||||
const ALWAYS_REQUIRED: readonly string[] = [
|
const ALWAYS_REQUIRED: readonly string[] = [
|
||||||
'JWT_SECRET',
|
'JWT_SECRET',
|
||||||
'JWT_REFRESH_SECRET',
|
'JWT_REFRESH_SECRET',
|
||||||
@@ -104,9 +107,9 @@ export function validateEnv(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new Error(
|
throw new DomainException(
|
||||||
`Missing required environment variables:\n ${missing.join('\n ')}\n` +
|
ErrorCode.INTERNAL_ERROR,
|
||||||
'JWT_SECRET and JWT_REFRESH_SECRET must always be set. See .env.example.',
|
`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) {
|
if (secretErrors.length > 0) {
|
||||||
throw new Error(
|
throw new DomainException(
|
||||||
`Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\n` +
|
ErrorCode.INTERNAL_ERROR,
|
||||||
'Generate secure secrets with: openssl rand -base64 48',
|
`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) {
|
if (missingProd.length > 0) {
|
||||||
throw new Error(
|
throw new DomainException(
|
||||||
`Missing required environment variables in production:\n ${missingProd.join('\n ')}\n` +
|
ErrorCode.INTERNAL_ERROR,
|
||||||
'See .env.example for the full list of variables.',
|
`Missing required environment variables in production:\n ${missingProd.join('\n ')}\nSee .env.example for the full list of variables.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { DomainException } from '../domain/domain-exception';
|
||||||
|
import { ErrorCode } from '../domain/error-codes';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
|
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
|
||||||
@@ -23,7 +25,8 @@ export interface FieldEncryptionConfig {
|
|||||||
function deriveKeyBuffer(hexKey: string): Buffer {
|
function deriveKeyBuffer(hexKey: string): Buffer {
|
||||||
const buf = Buffer.from(hexKey, 'hex');
|
const buf = Buffer.from(hexKey, 'hex');
|
||||||
if (buf.length !== 32) {
|
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`,
|
`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}
|
// Format: enc:v{version}:{iv}:{authTag}:{ciphertext}
|
||||||
const parts = stored.slice(PREFIX.length).split(':');
|
const parts = stored.slice(PREFIX.length).split(':');
|
||||||
if (parts.length !== 4) {
|
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;
|
const [_versionTag, ivHex, authTagHex, ciphertextHex] = parts;
|
||||||
|
|||||||
Reference in New Issue
Block a user