package config import ( "fmt" "net/url" "strconv" "strings" "time" ) // Validate checks the configuration for required fields and valid values. // Similar to Chatwoot's config validation in config/initializers. func Validate(cfg *Config) error { // Server validation if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { return fmt.Errorf("invalid server port: %d", cfg.Server.Port) } if cfg.Server.Mode != "debug" && cfg.Server.Mode != "release" && cfg.Server.Mode != "test" { return fmt.Errorf("invalid server mode: %s (must be debug, release, or test)", cfg.Server.Mode) } // Database validation (ref: Chatwoot requires POSTGRES_* env vars) if cfg.Database.Host == "" { return fmt.Errorf("database host is required") } if cfg.Database.Name == "" { return fmt.Errorf("database name is required") } if cfg.Database.User == "" { return fmt.Errorf("database user is required") } // Redis validation (ref: Chatwoot config/cable.yml requires REDIS_URL) if cfg.Redis.URL == "" { return fmt.Errorf("redis url is required") } redisURL, err := url.Parse(cfg.Redis.URL) if err != nil { return fmt.Errorf("invalid redis URL: %w", err) } if redisURL.Scheme != "redis" && redisURL.Scheme != "rediss" { return fmt.Errorf("invalid redis URL: scheme must be redis or rediss, got %s", redisURL.Scheme) } if redisURL.Host == "" { return fmt.Errorf("invalid redis URL: host is required") } // JWT validation if cfg.JWT.Secret == "" || cfg.JWT.Secret == "change-me-in-production" { if cfg.Server.Mode == "release" { return fmt.Errorf("JWT secret must be changed in production mode") } } // Log validation validLogLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} if !validLogLevels[cfg.Log.Level] { return fmt.Errorf("invalid log level: %s", cfg.Log.Level) } // Worker validation (ref: Chatwoot sidekiq.yml concurrency check) if cfg.Worker.Concurrency < 1 { return fmt.Errorf("worker concurrency must be >= 1") } // Rate limit validation if cfg.RateLimit.RequestsPerMinute < 1 { return fmt.Errorf("rate_limit.requests_per_minute must be >= 1") } if cfg.RateLimit.WindowSeconds < 1 { return fmt.Errorf("rate_limit.window_seconds must be >= 1") } // Search validation. Meilisearch is the production parity engine; db remains // available only as an explicit development fallback. engine := strings.ToLower(cfg.Search.Engine) if engine == "" { engine = "meilisearch" } if engine != "meilisearch" && engine != "db" { return fmt.Errorf("invalid search engine: %s (must be meilisearch or db)", cfg.Search.Engine) } if engine == "db" && cfg.Server.Mode == "release" { return fmt.Errorf("search.engine=db is only allowed for local development; release mode requires meilisearch") } if engine == "meilisearch" { if cfg.Search.Host == "" { return fmt.Errorf("search.host is required when search.engine=meilisearch") } searchURL, err := url.Parse(cfg.Search.Host) if err != nil || searchURL.Scheme == "" || searchURL.Host == "" { return fmt.Errorf("invalid search.host: %s", cfg.Search.Host) } } if cfg.Search.TimeoutSeconds < 0 { return fmt.Errorf("search.timeout_seconds must be >= 0") } return nil } // ParseStatementTimeout converts the string timeout to time.Duration. func ParseStatementTimeout(timeout string) (time.Duration, error) { if timeout == "" { return 14 * time.Second, nil } // Handle plain seconds (e.g., "14s") if secs, err := strconv.Atoi(timeout); err == nil { return time.Duration(secs) * time.Second, nil } // Handle Go duration format (e.g., "14s", "500ms") d, err := time.ParseDuration(timeout) if err != nil { return 0, fmt.Errorf("invalid statement_timeout format: %s", timeout) } return d, nil }