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([]);
|
||||
|
||||
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';
|
||||
// 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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user