import { Injectable } from '@nestjs/common'; export interface DatabaseConnectionOptions { host: string; port: number; user: string; password: string; database: string; allowPublicKeyRetrieval?: boolean; ssl?: boolean; } function requiredEnvironmentValue(name: string): string { const value = process.env[name]?.trim(); if (!value) { throw new Error(`Missing required environment variable: ${name}`); } return value; } function positiveInteger(name: string, fallback: number): number { const raw = process.env[name]?.trim(); if (!raw) return fallback; const value = Number(raw); if (!Number.isSafeInteger(value) || value <= 0) { throw new Error(`${name} must be a positive integer`); } return value; } function parseBoolean(name: string, fallback: boolean): boolean { const raw = process.env[name]?.trim().toLowerCase(); if (!raw) return fallback; if (raw === 'true') return true; if (raw === 'false') return false; throw new Error(`${name} must be true or false`); } @Injectable() export class AppConfigService { readonly appEnvironment = process.env.APP_ENV?.trim() || 'development'; readonly apiPort = positiveInteger('API_PORT', 3000); readonly databaseUrl = requiredEnvironmentValue('DATABASE_URL'); readonly sessionTokenPepper = requiredEnvironmentValue('SESSION_TOKEN_PEPPER'); readonly auditIpHashKey = requiredEnvironmentValue('AUDIT_IP_HASH_KEY'); readonly adminWebOrigin = requiredEnvironmentValue('ADMIN_WEB_ORIGIN'); readonly adminCookieSecure = parseBoolean('ADMIN_COOKIE_SECURE', true); readonly adminSessionTtlSeconds = positiveInteger('AUTH_SESSION_TTL_SECONDS', 28_800); readonly wechatSessionTtlSeconds = positiveInteger('WECHAT_SESSION_TTL_SECONDS', 7_200); readonly wechatAppId = process.env.WECHAT_APP_ID?.trim() || null; readonly wechatAppSecret = process.env.WECHAT_APP_SECRET?.trim() || null; readonly wechatNotificationTemplates = { APPLICATION_SUBMITTED: process.env.WECHAT_TEMPLATE_APPLICATION_SUBMITTED?.trim() || null, APPLICATION_ACCEPTED: process.env.WECHAT_TEMPLATE_APPLICATION_ACCEPTED?.trim() || null, SERVICE_ARRANGED: process.env.WECHAT_TEMPLATE_SERVICE_ARRANGED?.trim() || null, SERVICE_COMPLETED: process.env.WECHAT_TEMPLATE_SERVICE_COMPLETED?.trim() || null, FOLLOW_UP_PENDING: process.env.WECHAT_TEMPLATE_FOLLOW_UP_PENDING?.trim() || null, ACTIVITY_REGISTRATION_RESULT: process.env.WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT?.trim() || null, } as const; readonly notificationWorkerEnabled = parseBoolean('NOTIFICATION_WORKER_ENABLED', false); readonly objectStorageEndpoint = process.env.OBJECT_STORAGE_ENDPOINT?.trim() || null; readonly objectStorageRegion = process.env.OBJECT_STORAGE_REGION?.trim() || 'auto'; readonly objectStorageBucket = process.env.OBJECT_STORAGE_BUCKET?.trim() || null; readonly objectStorageAccessKey = process.env.OBJECT_STORAGE_ACCESS_KEY?.trim() || null; readonly objectStorageSecretKey = process.env.OBJECT_STORAGE_SECRET_KEY?.trim() || null; readonly objectStorageForcePathStyle = parseBoolean('OBJECT_STORAGE_FORCE_PATH_STYLE', true); readonly fieldEncryptionKey = this.parseEncryptionKey(); readonly database = this.parseDatabaseUrl(); get isProduction(): boolean { return this.appEnvironment === 'production'; } get objectStorageConfigured(): boolean { return Boolean( this.objectStorageEndpoint && this.objectStorageBucket && this.objectStorageAccessKey && this.objectStorageSecretKey, ); } get wechatNotificationConfigured(): boolean { return Boolean( this.wechatAppId && this.wechatAppSecret && Object.values(this.wechatNotificationTemplates).every(Boolean), ); } private parseEncryptionKey(): Buffer { const encoded = requiredEnvironmentValue('FIELD_ENCRYPTION_KEY'); const key = Buffer.from(encoded, 'base64'); if (key.length !== 32) { throw new Error('FIELD_ENCRYPTION_KEY must be a base64-encoded 32-byte key'); } return key; } private parseDatabaseUrl(): DatabaseConnectionOptions { const url = new URL(this.databaseUrl); if (url.protocol !== 'mysql:') { throw new Error('DATABASE_URL must use the mysql protocol'); } const database = url.pathname.replace(/^\//, ''); if (!url.hostname || !url.username || !database) { throw new Error('DATABASE_URL must include host, user, and database'); } return { host: url.hostname, port: url.port ? Number(url.port) : 3306, user: decodeURIComponent(url.username), password: decodeURIComponent(url.password), database, allowPublicKeyRetrieval: url.searchParams.get('allowPublicKeyRetrieval') === 'true', ssl: url.searchParams.get('useSSL') === 'true', }; } }