Files
flyer-crawler.projectium.com/src/config/env.ts
Torben Sorensen 4a04e478c4
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 16m58s
integration test fixes - claude for the win? try 4 - i have a good feeling
2026-01-09 05:56:19 -08:00

304 lines
8.4 KiB
TypeScript

// src/config/env.ts
/**
* @file Centralized, schema-validated configuration service.
* Implements ADR-007: Configuration and Secrets Management.
*
* This module parses and validates all environment variables at application startup.
* If any required configuration is missing or invalid, the application will fail fast
* with a clear error message.
*
* Usage:
* import { config } from './config/env';
* console.log(config.database.host);
*/
import { z } from 'zod';
// --- Schema Definitions ---
/**
* Helper to parse string to integer with default.
* Handles empty strings by treating them as undefined.
*/
const intWithDefault = (defaultValue: number) =>
z
.string()
.optional()
.transform((val) => (val && val.trim() !== '' ? parseInt(val, 10) : defaultValue))
.pipe(z.number().int());
/**
* Helper to parse string to float with default.
*/
const floatWithDefault = (defaultValue: number) =>
z
.string()
.optional()
.transform((val) => (val && val.trim() !== '' ? parseFloat(val) : defaultValue))
.pipe(z.number());
/**
* Helper to parse string 'true'/'false' to boolean.
*/
const booleanString = (defaultValue: boolean) =>
z
.string()
.optional()
.transform((val) => (val === undefined ? defaultValue : val === 'true'));
/**
* Database configuration schema.
*/
const databaseSchema = z.object({
host: z.string().min(1, 'DB_HOST is required'),
port: intWithDefault(5432),
user: z.string().min(1, 'DB_USER is required'),
password: z.string().min(1, 'DB_PASSWORD is required'),
name: z.string().min(1, 'DB_NAME is required'),
});
/**
* Redis configuration schema.
*/
const redisSchema = z.object({
url: z.string().url('REDIS_URL must be a valid URL'),
password: z.string().optional(),
});
/**
* Authentication configuration schema.
*/
const authSchema = z.object({
jwtSecret: z.string().min(32, 'JWT_SECRET must be at least 32 characters for security'),
jwtSecretPrevious: z.string().optional(), // For secret rotation (ADR-029)
});
/**
* SMTP/Email configuration schema.
* All fields are optional - email service degrades gracefully if not configured.
*/
const smtpSchema = z.object({
host: z.string().optional(),
port: intWithDefault(587),
user: z.string().optional(),
pass: z.string().optional(),
secure: booleanString(false),
fromEmail: z.string().email().optional(),
});
/**
* AI/Gemini configuration schema.
*/
const aiSchema = z.object({
geminiApiKey: z.string().optional(),
geminiRpm: intWithDefault(5),
priceQualityThreshold: floatWithDefault(0.5),
});
/**
* Google services configuration schema.
*/
const googleSchema = z.object({
mapsApiKey: z.string().optional(),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
});
/**
* Worker concurrency configuration schema.
*/
const workerSchema = z.object({
concurrency: intWithDefault(1),
lockDuration: intWithDefault(30000),
emailConcurrency: intWithDefault(10),
analyticsConcurrency: intWithDefault(1),
cleanupConcurrency: intWithDefault(10),
weeklyAnalyticsConcurrency: intWithDefault(1),
});
/**
* Server configuration schema.
*/
const serverSchema = z.object({
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
port: intWithDefault(3001),
frontendUrl: z.string().url().optional(),
baseUrl: z.string().optional(),
storagePath: z.string().default('/var/www/flyer-crawler.projectium.com/flyer-images'),
});
/**
* Complete environment configuration schema.
*/
const envSchema = z.object({
database: databaseSchema,
redis: redisSchema,
auth: authSchema,
smtp: smtpSchema,
ai: aiSchema,
google: googleSchema,
worker: workerSchema,
server: serverSchema,
});
export type EnvConfig = z.infer<typeof envSchema>;
// --- Configuration Loading ---
/**
* Maps environment variables to the configuration structure.
* This is the single source of truth for which env vars map to which config keys.
*/
function loadEnvVars(): unknown {
return {
database: {
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
name: process.env.DB_NAME,
},
redis: {
url: process.env.REDIS_URL,
password: process.env.REDIS_PASSWORD,
},
auth: {
jwtSecret: process.env.JWT_SECRET,
jwtSecretPrevious: process.env.JWT_SECRET_PREVIOUS,
},
smtp: {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
secure: process.env.SMTP_SECURE,
fromEmail: process.env.SMTP_FROM_EMAIL,
},
ai: {
geminiApiKey: process.env.GEMINI_API_KEY,
geminiRpm: process.env.GEMINI_RPM,
priceQualityThreshold: process.env.AI_PRICE_QUALITY_THRESHOLD,
},
google: {
mapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
worker: {
concurrency: process.env.WORKER_CONCURRENCY,
lockDuration: process.env.WORKER_LOCK_DURATION,
emailConcurrency: process.env.EMAIL_WORKER_CONCURRENCY,
analyticsConcurrency: process.env.ANALYTICS_WORKER_CONCURRENCY,
cleanupConcurrency: process.env.CLEANUP_WORKER_CONCURRENCY,
weeklyAnalyticsConcurrency: process.env.WEEKLY_ANALYTICS_WORKER_CONCURRENCY,
},
server: {
nodeEnv: process.env.NODE_ENV,
port: process.env.PORT,
frontendUrl: process.env.FRONTEND_URL,
baseUrl: process.env.BASE_URL,
storagePath: process.env.STORAGE_PATH,
},
};
}
/**
* Validates and parses environment configuration.
* Throws a descriptive error if validation fails.
*/
function parseConfig(): EnvConfig {
const rawConfig = loadEnvVars();
const result = envSchema.safeParse(rawConfig);
if (!result.success) {
const errors = result.error.issues.map((issue) => {
const path = issue.path.join('.');
return ` - ${path}: ${issue.message}`;
});
const errorMessage = [
'',
'╔════════════════════════════════════════════════════════════════╗',
'║ CONFIGURATION ERROR - APPLICATION STARTUP ║',
'╚════════════════════════════════════════════════════════════════╝',
'',
'The following environment variables are missing or invalid:',
'',
...errors,
'',
'Please check your .env file or environment configuration.',
'See ADR-007 for the complete list of required environment variables.',
'',
].join('\n');
// In test environment, throw instead of exiting to allow test frameworks to catch
if (process.env.NODE_ENV === 'test') {
throw new Error(errorMessage);
}
console.error(errorMessage);
process.exit(1);
}
return result.data;
}
// --- Exported Configuration ---
/**
* The validated application configuration.
* This is a singleton that is parsed once at module load time.
*
* @example
* ```typescript
* import { config } from './config/env';
*
* // Access database config
* const pool = new Pool({
* host: config.database.host,
* port: config.database.port,
* user: config.database.user,
* password: config.database.password,
* database: config.database.name,
* });
*
* // Check environment
* if (config.server.isProduction) {
* // production-only logic
* }
* ```
*/
export const config: EnvConfig = parseConfig();
// --- Convenience Helpers ---
/**
* Returns true if running in production environment.
*/
export const isProduction = config.server.nodeEnv === 'production';
/**
* Returns true if running in test environment.
*/
export const isTest = config.server.nodeEnv === 'test';
/**
* Returns true if running in development environment.
*/
export const isDevelopment = config.server.nodeEnv === 'development';
/**
* Returns true if SMTP is configured (all required fields present).
*/
export const isSmtpConfigured =
!!config.smtp.host && !!config.smtp.user && !!config.smtp.pass && !!config.smtp.fromEmail;
/**
* Returns true if AI services are configured.
*/
export const isAiConfigured = !!config.ai.geminiApiKey;
/**
* Returns true if Google Maps is configured.
*/
export const isGoogleMapsConfigured = !!config.google.mapsApiKey;