diff --git a/src/app.module.ts b/src/app.module.ts index 3b3654f6..f94d3368 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -50,6 +50,8 @@ import { AuthRateLimitMiddleware } from './auth/middleware/auth.middleware'; isGlobal: true, load: [configuration, valuationConfig], envFilePath: ['.env.local', '.env.development', '.env'], + cache: true, // Enable configuration caching + expandVariables: true, // Allow environment variable expansion }), ConfigurationModule, diff --git a/src/config/config.loader.ts b/src/config/config.loader.ts new file mode 100644 index 00000000..b8d0d6bd --- /dev/null +++ b/src/config/config.loader.ts @@ -0,0 +1,129 @@ +import * as Joi from 'joi'; +import { ConfigEncryptionUtil } from './utils/config.encryption'; +import { configValidationSchema } from './validation/config.validation'; + +/** + * Configuration loader with validation and environment-specific management + */ +export class ConfigLoader { + /** + * Load and validate configuration from environment variables + * @param env Environment variables object (defaults to process.env) + * @returns Validated configuration object + */ + static load(env: NodeJS.ProcessEnv = process.env): any { + // Process the environment variables for encryption + const processedEnv = this.processEncryptedValues(env); + + // Validate the configuration + const { error, value: validatedConfig } = configValidationSchema.validate(processedEnv, { + abortEarly: false, // Return all validation errors + stripUnknown: true, // Remove unknown keys + convert: true, // Convert values to appropriate types + }); + + if (error) { + const validationErrors = error.details.map(detail => detail.message); + throw new Error(`Configuration validation failed:\n${validationErrors.join('\n')}`); + } + + // Add helper methods to the configuration + return { + ...validatedConfig, + // Helper to check environment + isDevelopment: validatedConfig.NODE_ENV === 'development', + isProduction: validatedConfig.NODE_ENV === 'production', + isStaging: validatedConfig.NODE_ENV === 'staging', + isTest: validatedConfig.NODE_ENV === 'test', + + // Helper to get environment-specific values + getEnvValue: (key: string, defaultValue?: any) => { + return validatedConfig[key] || defaultValue; + }, + + // Parse allowed file types from string to array + ALLOWED_FILE_TYPES: validatedConfig.ALLOWED_FILE_TYPES?.split(',').map((type: string) => type.trim()) || [], + }; + } + + /** + * Process environment variables for encrypted values + * @param env Environment variables object + * @returns Processed environment variables with decrypted values + */ + private static processEncryptedValues(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const processedEnv = { ...env }; + + // Define which environment variables should be treated as sensitive + const sensitiveKeys = [ + 'JWT_SECRET', + 'JWT_REFRESH_SECRET', + 'ENCRYPTION_KEY', + 'PRIVATE_KEY', + 'REDIS_PASSWORD', + 'SESSION_SECRET', + 'STORAGE_SIGNING_SECRET', + 'S3_ACCESS_KEY_ID', + 'S3_SECRET_ACCESS_KEY', + 'SMTP_PASS', + 'ZILLOW_API_KEY', + 'REDFIN_API_KEY', + 'CORE_LOGIC_API_KEY', + 'MAXMIND_LICENSE_KEY', + 'MARKET_TRENDS_API_KEY', + 'COINGECKO_API_KEY', + 'OPENSEA_API_KEY', + 'ETHERSCAN_API_KEY', + 'WEB3_STORAGE_TOKEN', + 'IPFS_PROJECT_ID', + 'IPFS_PROJECT_SECRET', + ]; + + const encryptionKey = env.ENCRYPTION_KEY; + + if (!encryptionKey) { + console.warn('ENCRYPTION_KEY not found in environment. Values will not be decrypted.'); + return processedEnv; + } + + if (encryptionKey.length !== 32) { + console.warn('ENCRYPTION_KEY must be exactly 32 characters. Values will not be decrypted.'); + return processedEnv; + } + + // Process each sensitive key + for (const key of sensitiveKeys) { + const value = processedEnv[key]; + if (value && ConfigEncryptionUtil.isEncrypted(value)) { + try { + processedEnv[key] = ConfigEncryptionUtil.decrypt(value, encryptionKey); + } catch (error) { + console.error(`Failed to decrypt ${key}:`, error.message); + throw new Error(`Failed to decrypt ${key}: ${error.message}`); + } + } + } + + return processedEnv; + } + + /** + * Get environment-specific configuration file path + * @param env Environment name + * @returns Path to environment-specific config file + */ + static getEnvironmentConfigPath(env: string): string { + const baseDir = process.cwd(); + switch (env.toLowerCase()) { + case 'production': + return `${baseDir}/config/production.env`; + case 'staging': + return `${baseDir}/config/staging.env`; + case 'test': + return `${baseDir}/config/test.env`; + case 'development': + default: + return `${baseDir}/.env.local`; + } + } +} diff --git a/src/config/configuration.service.ts b/src/config/configuration.service.ts index 8426a946..347c8e4d 100644 --- a/src/config/configuration.service.ts +++ b/src/config/configuration.service.ts @@ -125,6 +125,10 @@ export class ConfigurationService { return this.configService.get('THROTTLE_LIMIT'); } + get apiKeyRateLimitPerMinute(): number { + return this.configService.get('API_KEY_RATE_LIMIT_PER_MINUTE'); + } + // File Upload get maxFileSize(): number { return this.configService.get('MAX_FILE_SIZE'); @@ -204,6 +208,133 @@ export class ConfigurationService { return this.configService.get('ENABLE_SEED_DATA'); } + // Storage Configuration + get storageProvider(): 's3' | 'memory' { + return this.configService.get<'s3' | 'memory'>('STORAGE_PROVIDER'); + } + + get storageSignedUrlExpiresIn(): number { + return this.configService.get('STORAGE_SIGNED_URL_EXPIRES_IN'); + } + + get storageSigningSecret(): string { + return this.configService.get('STORAGE_SIGNING_SECRET'); + } + + get thumbnailWidth(): number { + return this.configService.get('THUMBNAIL_WIDTH'); + } + + get thumbnailHeight(): number { + return this.configService.get('THUMBNAIL_HEIGHT'); + } + + get thumbnailFormat(): 'jpeg' | 'png' | 'webp' { + return this.configService.get<'jpeg' | 'png' | 'webp'>('THUMBNAIL_FORMAT'); + } + + get thumbnailQuality(): number { + return this.configService.get('THUMBNAIL_QUALITY'); + } + + // S3 Configuration + get s3Bucket(): string { + return this.configService.get('S3_BUCKET'); + } + + get s3Region(): string { + return this.configService.get('S3_REGION'); + } + + get s3AccessKeyId(): string | undefined { + return this.configService.get('S3_ACCESS_KEY_ID'); + } + + get s3SecretAccessKey(): string | undefined { + return this.configService.get('S3_SECRET_ACCESS_KEY'); + } + + get s3Endpoint(): string | undefined { + return this.configService.get('S3_ENDPOINT'); + } + + get s3ForcePathStyle(): boolean { + return this.configService.get('S3_FORCE_PATH_STYLE'); + } + + // Valuation Configuration + get zillowApiKey(): string | undefined { + return this.configService.get('ZILLOW_API_KEY'); + } + + get redfinApiKey(): string | undefined { + return this.configService.get('REDFIN_API_KEY'); + } + + get coreLogicApiKey(): string | undefined { + return this.configService.get('CORE_LOGIC_API_KEY'); + } + + get maxmindLicenseKey(): string | undefined { + return this.configService.get('MAXMIND_LICENSE_KEY'); + } + + get valuationConfidenceThreshold(): number { + return this.configService.get('VALUATION_CONFIDENCE_THRESHOLD'); + } + + get valuationCacheTtl(): number { + return this.configService.get('VALUATION_CACHE_TTL'); + } + + get valuationMaxRetries(): number { + return this.configService.get('VALUATION_MAX_RETRIES'); + } + + get valuationTimeout(): number { + return this.configService.get('VALUATION_TIMEOUT'); + } + + get marketTrendsApiEndpoint(): string | undefined { + return this.configService.get('MARKET_TRENDS_API_ENDPOINT'); + } + + get marketTrendsApiKey(): string | undefined { + return this.configService.get('MARKET_TRENDS_API_KEY'); + } + + get marketTrendsUpdateFreq(): number { + return this.configService.get('MARKET_TRENDS_UPDATE_FREQ'); + } + + get valuationRateLimitPerMinute(): number { + return this.configService.get('VALUATION_RATE_LIMIT_PER_MINUTE'); + } + + get valuationRateLimitPerHour(): number { + return this.configService.get('VALUATION_RATE_LIMIT_PER_HOUR'); + } + + get locationWeight(): number { + return this.configService.get('LOCATION_WEIGHT'); + } + + get sizeWeight(): number { + return this.configService.get('SIZE_WEIGHT'); + } + + get ageWeight(): number { + return this.configService.get('AGE_WEIGHT'); + } + + get amenitiesWeight(): number { + return this.configService.get('AMENITIES_WEIGHT'); + } + + get marketConditionsWeight(): number { + return this.configService.get('MARKET_CONDITIONS_WEIGHT'); + } + // Helper method to check if we're in development get isDevelopment(): boolean { return this.nodeEnv === 'development'; @@ -218,4 +349,9 @@ export class ConfigurationService { get isTest(): boolean { return this.nodeEnv === 'test'; } + + // Helper method to check if we're in staging + get isStaging(): boolean { + return this.nodeEnv === 'staging'; + } } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 4f3650fa..4493bae8 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,87 +1,6 @@ -import { JoiSchemaConfig } from './interfaces/joi-schema-config.interface'; +import { ConfigLoader } from './config.loader'; -export default (): JoiSchemaConfig => ({ - // Application - NODE_ENV: process.env.NODE_ENV || 'development', - PORT: parseInt(process.env.PORT, 10) || 3000, - HOST: process.env.HOST || '0.0.0.0', - API_PREFIX: process.env.API_PREFIX || 'api', - CORS_ORIGIN: process.env.CORS_ORIGIN || '*', - SWAGGER_ENABLED: process.env.SWAGGER_ENABLED !== 'false', - - // Database - DATABASE_URL: process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:5432/propchain', - - // Redis - REDIS_HOST: process.env.REDIS_HOST || 'localhost', - REDIS_PORT: parseInt(process.env.REDIS_PORT, 10) || 6379, - REDIS_PASSWORD: process.env.REDIS_PASSWORD, - REDIS_DB: parseInt(process.env.REDIS_DB, 10) || 0, - - // JWT - JWT_SECRET: process.env.JWT_SECRET || 'your-super-secret-jwt-key', - JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '24h', - JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || 'your-super-secret-refresh-key', - JWT_REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || '7d', - - // API Keys - API_KEY: process.env.API_KEY, - ENCRYPTION_KEY: process.env.ENCRYPTION_KEY || 'your-32-char-encryption-key-here', - - // Blockchain/Web3 - BLOCKCHAIN_NETWORK: process.env.BLOCKCHAIN_NETWORK || 'sepolia', - RPC_URL: process.env.RPC_URL || 'https://sepolia.infura.io/v3/YOUR_PROJECT_ID', - PRIVATE_KEY: process.env.PRIVATE_KEY, - ETHERSCAN_API_KEY: process.env.ETHERSCAN_API_KEY, - WEB3_STORAGE_TOKEN: process.env.WEB3_STORAGE_TOKEN, - - // IPFS - IPFS_GATEWAY_URL: process.env.IPFS_GATEWAY_URL || 'https://ipfs.io/ipfs/', - IPFS_API_URL: process.env.IPFS_API_URL || 'https://ipfs.infura.io:5001', - IPFS_PROJECT_ID: process.env.IPFS_PROJECT_ID, - IPFS_PROJECT_SECRET: process.env.IPFS_PROJECT_SECRET, - - // Rate Limiting - THROTTLE_TTL: parseInt(process.env.THROTTLE_TTL, 10) || 60, - THROTTLE_LIMIT: parseInt(process.env.THROTTLE_LIMIT, 10) || 10, - - // API Key Rate Limiting - API_KEY_RATE_LIMIT_PER_MINUTE: parseInt(process.env.API_KEY_RATE_LIMIT_PER_MINUTE, 10) || 60, - - // File Upload - MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE, 10) || 10 * 1024 * 1024, // 10MB - ALLOWED_FILE_TYPES: process.env.ALLOWED_FILE_TYPES?.split(',') || [ - 'image/jpeg', - 'image/png', - 'image/webp', - 'application/pdf', - ], - - // Email (for notifications) - SMTP_HOST: process.env.SMTP_HOST, - SMTP_PORT: parseInt(process.env.SMTP_PORT, 10) || 587, - SMTP_USER: process.env.SMTP_USER, - SMTP_PASS: process.env.SMTP_PASS, - EMAIL_FROM: process.env.EMAIL_FROM || 'noreply@propchain.io', - - // Monitoring - SENTRY_DSN: process.env.SENTRY_DSN, - LOG_LEVEL: process.env.LOG_LEVEL || 'info', - - // External Services - COINGECKO_API_KEY: process.env.COINGECKO_API_KEY, - OPENSEA_API_KEY: process.env.OPENSEA_API_KEY, - - // Smart Contracts - PROPERTY_NFT_ADDRESS: process.env.PROPERTY_NFT_ADDRESS, - ESCROW_CONTRACT_ADDRESS: process.env.ESCROW_CONTRACT_ADDRESS, - GOVERNANCE_CONTRACT_ADDRESS: process.env.GOVERNANCE_CONTRACT_ADDRESS, - - // Security - BCRYPT_ROUNDS: parseInt(process.env.BCRYPT_ROUNDS, 10) || 12, - SESSION_SECRET: process.env.SESSION_SECRET || 'your-session-secret-key', - - // Development - MOCK_BLOCKCHAIN: process.env.MOCK_BLOCKCHAIN === 'true', - ENABLE_SEED_DATA: process.env.ENABLE_SEED_DATA === 'true', -}); +// Export the validated configuration using our new loader +export default () => { + return ConfigLoader.load(); +}; diff --git a/src/config/utils/config.encryption.ts b/src/config/utils/config.encryption.ts new file mode 100644 index 00000000..4a455dac --- /dev/null +++ b/src/config/utils/config.encryption.ts @@ -0,0 +1,123 @@ +import * as crypto from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; // Initialization vector length for GCM +const KEY_LENGTH = 32; // 256 bits for AES-256 + +/** + * Utility class for encrypting/decrypting sensitive configuration values + */ +export class ConfigEncryptionUtil { + /** + * Encrypts a sensitive value using AES-256-GCM + * @param value - The value to encrypt + * @param secretKey - The encryption key (must be 32 bytes for AES-256) + * @returns Encrypted value as base64 string + */ + static encrypt(value: string, secretKey: string): string { + if (!secretKey || secretKey.length !== 32) { + throw new Error('Encryption key must be exactly 32 characters for AES-256'); + } + + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(secretKey, 'utf8'), iv); + + let encrypted = cipher.update(value, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + // Combine IV, auth tag, and encrypted data + const encryptedData = `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; + + return Buffer.from(encryptedData).toString('base64'); + } + + /** + * Decrypts an encrypted value using AES-256-GCM + * @param encryptedValue - The encrypted value as base64 string + * @param secretKey - The decryption key (must be 32 bytes for AES-256) + * @returns Decrypted value + */ + static decrypt(encryptedValue: string, secretKey: string): string { + if (!secretKey || secretKey.length !== 32) { + throw new Error('Encryption key must be exactly 32 characters for AES-256'); + } + + const decoded = Buffer.from(encryptedValue, 'base64').toString('utf8'); + const [ivHex, authTagHex, encryptedData] = decoded.split(':'); + + if (!ivHex || !authTagHex || !encryptedData) { + throw new Error('Invalid encrypted value format'); + } + + const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(secretKey, 'utf8'), Buffer.from(ivHex, 'hex')); + + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); + + let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } + + /** + * Checks if a value is encrypted (base64 encoded with our format) + * @param value - The value to check + * @returns True if the value appears to be encrypted + */ + static isEncrypted(value: string): boolean { + try { + if (!value || typeof value !== 'string') { + return false; + } + + // Check if it's a valid base64 + const buffer = Buffer.from(value, 'base64'); + if (buffer.toString('base64') !== value) { + return false; + } + + // Check if it has our IV:AUTH_TAG:ENCRYPTED_DATA format after decoding + const decoded = buffer.toString('utf8'); + const parts = decoded.split(':'); + + return ( + parts.length === 3 && + parts[0].length === IV_LENGTH * 2 && // IV hex length + parts[1].length === 32 && // Auth tag hex length (16 bytes) + parts[2].length > 0 + ); // Encrypted data exists + } catch (error) { + return false; + } + } + + /** + * Conditionally encrypts a value if it's not already encrypted + * @param value - The value to encrypt + * @param secretKey - The encryption key + * @returns Encrypted value or original if already encrypted + */ + static maybeEncrypt(value: string, secretKey: string): string { + if (!value || ConfigEncryptionUtil.isEncrypted(value)) { + return value; + } + + return ConfigEncryptionUtil.encrypt(value, secretKey); + } + + /** + * Conditionally decrypts a value if it's encrypted + * @param value - The value to decrypt + * @param secretKey - The decryption key + * @returns Decrypted value or original if not encrypted + */ + static maybeDecrypt(value: string, secretKey: string): string { + if (!value || !ConfigEncryptionUtil.isEncrypted(value)) { + return value; + } + + return ConfigEncryptionUtil.decrypt(value, secretKey); + } +} diff --git a/src/config/validation/config.validation.ts b/src/config/validation/config.validation.ts new file mode 100644 index 00000000..968b508d --- /dev/null +++ b/src/config/validation/config.validation.ts @@ -0,0 +1,128 @@ +import * as Joi from 'joi'; + +/** + * Joi validation schema for application configuration + */ +export const configValidationSchema = Joi.object({ + // Application + NODE_ENV: Joi.string().valid('development', 'production', 'test', 'staging').default('development'), + PORT: Joi.number().default(3000), + HOST: Joi.string().default('0.0.0.0'), + API_PREFIX: Joi.string().default('api'), + CORS_ORIGIN: Joi.string().default('*'), + SWAGGER_ENABLED: Joi.boolean().default(true), + + // Database + DATABASE_URL: Joi.string().uri().required(), + + // Redis + REDIS_HOST: Joi.string().default('localhost'), + REDIS_PORT: Joi.number().default(6379), + REDIS_PASSWORD: Joi.string().optional(), + REDIS_DB: Joi.number().default(0), + + // JWT + JWT_SECRET: Joi.string().min(32).required(), + JWT_EXPIRES_IN: Joi.string().default('24h'), + JWT_REFRESH_SECRET: Joi.string().min(32).required(), + JWT_REFRESH_EXPIRES_IN: Joi.string().default('7d'), + + // API Keys + API_KEY: Joi.string().optional(), + ENCRYPTION_KEY: Joi.string().length(32).required(), // 32 characters for AES-256 + + // Blockchain/Web3 + BLOCKCHAIN_NETWORK: Joi.string().default('sepolia'), + RPC_URL: Joi.string().uri().required(), + PRIVATE_KEY: Joi.string() + .pattern(/^0x[a-fA-F0-9]{64}$/) + .required(), // Ethereum private key format + ETHERSCAN_API_KEY: Joi.string().optional(), + WEB3_STORAGE_TOKEN: Joi.string().optional(), + + // IPFS + IPFS_GATEWAY_URL: Joi.string().uri().default('https://ipfs.io/ipfs/'), + IPFS_API_URL: Joi.string().uri().default('https://ipfs.infura.io:5001'), + IPFS_PROJECT_ID: Joi.string().optional(), + IPFS_PROJECT_SECRET: Joi.string().optional(), + + // Rate Limiting + THROTTLE_TTL: Joi.number().default(60), + THROTTLE_LIMIT: Joi.number().default(10), + API_KEY_RATE_LIMIT_PER_MINUTE: Joi.number().default(60), + + // File Upload + MAX_FILE_SIZE: Joi.number().default(10 * 1024 * 1024), // 10MB + ALLOWED_FILE_TYPES: Joi.string().default('image/jpeg,image/png,image/webp,application/pdf'), + + // Email + SMTP_HOST: Joi.string().optional(), + SMTP_PORT: Joi.number().default(587), + SMTP_USER: Joi.string().optional(), + SMTP_PASS: Joi.string().optional(), + EMAIL_FROM: Joi.string().email().default('noreply@propchain.io'), + + // Monitoring + SENTRY_DSN: Joi.string().uri().optional(), + LOG_LEVEL: Joi.string().valid('error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly').default('info'), + + // External Services + COINGECKO_API_KEY: Joi.string().optional(), + OPENSEA_API_KEY: Joi.string().optional(), + + // Smart Contracts + PROPERTY_NFT_ADDRESS: Joi.string() + .pattern(/^0x[a-fA-F0-9]{40}$/) + .optional(), // Ethereum address format + ESCROW_CONTRACT_ADDRESS: Joi.string() + .pattern(/^0x[a-fA-F0-9]{40}$/) + .optional(), + GOVERNANCE_CONTRACT_ADDRESS: Joi.string() + .pattern(/^0x[a-fA-F0-9]{40}$/) + .optional(), + + // Security + BCRYPT_ROUNDS: Joi.number().min(10).max(15).default(12), + SESSION_SECRET: Joi.string().min(32).required(), + + // Development + MOCK_BLOCKCHAIN: Joi.boolean().default(false), + ENABLE_SEED_DATA: Joi.boolean().default(false), + + // Storage Configuration + STORAGE_PROVIDER: Joi.string().valid('s3', 'memory').default('s3'), + STORAGE_SIGNED_URL_EXPIRES_IN: Joi.number().default(900), + STORAGE_SIGNING_SECRET: Joi.string().min(16).default('local-storage-signing-secret'), + THUMBNAIL_WIDTH: Joi.number().default(320), + THUMBNAIL_HEIGHT: Joi.number().default(320), + THUMBNAIL_FORMAT: Joi.string().valid('jpeg', 'png', 'webp').default('webp'), + THUMBNAIL_QUALITY: Joi.number().min(1).max(100).default(80), + + // S3 Configuration + S3_BUCKET: Joi.string().default('propchain-documents'), + S3_REGION: Joi.string().default('us-east-1'), + S3_ACCESS_KEY_ID: Joi.string().optional(), + S3_SECRET_ACCESS_KEY: Joi.string().optional(), + S3_ENDPOINT: Joi.string().uri().optional(), + S3_FORCE_PATH_STYLE: Joi.boolean().default(false), + + // Valuation Configuration + ZILLOW_API_KEY: Joi.string().optional(), + REDFIN_API_KEY: Joi.string().optional(), + CORE_LOGIC_API_KEY: Joi.string().optional(), + MAXMIND_LICENSE_KEY: Joi.string().optional(), + VALUATION_CONFIDENCE_THRESHOLD: Joi.number().min(0).max(1).default(0.7), + VALUATION_CACHE_TTL: Joi.number().default(86400), // 24 hours + VALUATION_MAX_RETRIES: Joi.number().default(3), + VALUATION_TIMEOUT: Joi.number().default(10000), // 10 seconds + MARKET_TRENDS_API_ENDPOINT: Joi.string().uri().optional(), + MARKET_TRENDS_API_KEY: Joi.string().optional(), + MARKET_TRENDS_UPDATE_FREQ: Joi.number().default(3600), // 1 hour + VALUATION_RATE_LIMIT_PER_MINUTE: Joi.number().default(10), + VALUATION_RATE_LIMIT_PER_HOUR: Joi.number().default(100), + LOCATION_WEIGHT: Joi.number().min(0).max(1).default(0.3), + SIZE_WEIGHT: Joi.number().min(0).max(1).default(0.25), + AGE_WEIGHT: Joi.number().min(0).max(1).default(0.15), + AMENITIES_WEIGHT: Joi.number().min(0).max(1).default(0.2), + MARKET_CONDITIONS_WEIGHT: Joi.number().min(0).max(1).default(0.1), +}); diff --git a/src/valuation/valuation.service.ts b/src/valuation/valuation.service.ts index 2877cd3a..b735a337 100644 --- a/src/valuation/valuation.service.ts +++ b/src/valuation/valuation.service.ts @@ -286,19 +286,15 @@ export class ValuationService { private normalizeFeatures(features: PropertyFeatures): PropertyFeatures { // Normalize location: trim and lowercase const location = features.location ? features.location.trim().toLowerCase() : ''; - + // Convert string values to numbers where appropriate - const bedrooms = typeof features.bedrooms === 'string' ? - parseInt(features.bedrooms, 10) : features.bedrooms; - const bathrooms = typeof features.bathrooms === 'string' ? - parseFloat(features.bathrooms) : features.bathrooms; - const squareFootage = typeof features.squareFootage === 'string' ? - parseInt(features.squareFootage, 10) : features.squareFootage; - const yearBuilt = typeof features.yearBuilt === 'string' ? - parseInt(features.yearBuilt, 10) : features.yearBuilt; - const lotSize = typeof features.lotSize === 'string' ? - parseFloat(features.lotSize) : features.lotSize; - + const bedrooms = typeof features.bedrooms === 'string' ? parseInt(features.bedrooms, 10) : features.bedrooms; + const bathrooms = typeof features.bathrooms === 'string' ? parseFloat(features.bathrooms) : features.bathrooms; + const squareFootage = + typeof features.squareFootage === 'string' ? parseInt(features.squareFootage, 10) : features.squareFootage; + const yearBuilt = typeof features.yearBuilt === 'string' ? parseInt(features.yearBuilt, 10) : features.yearBuilt; + const lotSize = typeof features.lotSize === 'string' ? parseFloat(features.lotSize) : features.lotSize; + return { ...features, location,