Files
honeyDueAPI/internal/config/config.go
Trey t 5a6bad3ec3 Remove Gorush, use direct APNs/FCM, fix worker queries
- Remove Gorush push server dependency (now using direct APNs/FCM)
- Update docker-compose.yml to remove gorush service
- Update config.go to remove GORUSH_URL
- Fix worker queries:
  - Use auth_user instead of user_user table
  - Use completed_at instead of completion_date column
- Add NotificationService to worker handler for actionable notifications
- Add docs/PUSH_NOTIFICATIONS.md with architecture documentation
- Update README.md, DOKKU_SETUP.md, and dev.sh

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 00:59:42 -06:00

332 lines
9.1 KiB
Go

package config
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/spf13/viper"
)
// Config holds all configuration for the application
type Config struct {
Server ServerConfig
Database DatabaseConfig
Redis RedisConfig
Email EmailConfig
Push PushConfig
Worker WorkerConfig
Security SecurityConfig
Storage StorageConfig
AppleAuth AppleAuthConfig
}
type ServerConfig struct {
Port int
Debug bool
AllowedHosts []string
Timezone string
StaticDir string // Directory for static landing page files
}
type DatabaseConfig struct {
Host string
Port int
User string
Password string
Database string
SSLMode string
MaxOpenConns int
MaxIdleConns int
MaxLifetime time.Duration
}
type RedisConfig struct {
URL string
Password string
DB int
}
type EmailConfig struct {
Host string
Port int
User string
Password string
From string
UseTLS bool
}
type PushConfig struct {
// APNs (iOS) - uses github.com/sideshow/apns2 for direct APNs communication
APNSKeyPath string
APNSKeyID string
APNSTeamID string
APNSTopic string
APNSSandbox bool
APNSProduction bool // If true, use production APNs; if false, use sandbox
// FCM (Android) - uses direct HTTP to FCM legacy API
FCMServerKey string
}
type AppleAuthConfig struct {
ClientID string // Bundle ID (e.g., com.tt.casera.CaseraDev)
TeamID string // Apple Developer Team ID
}
type WorkerConfig struct {
// Scheduled job times (UTC)
TaskReminderHour int
TaskReminderMinute int
OverdueReminderHour int
DailyNotifHour int
}
type SecurityConfig struct {
SecretKey string
TokenCacheTTL time.Duration
PasswordResetExpiry time.Duration
ConfirmationExpiry time.Duration
MaxPasswordResetRate int // per hour
}
// StorageConfig holds file storage settings
type StorageConfig struct {
UploadDir string // Directory to store uploaded files
BaseURL string // Public URL prefix for serving files (e.g., "/uploads")
MaxFileSize int64 // Max file size in bytes (default 10MB)
AllowedTypes string // Comma-separated MIME types
}
var cfg *Config
// Load reads configuration from environment variables
func Load() (*Config, error) {
viper.SetEnvPrefix("")
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// Set defaults
setDefaults()
// Parse DATABASE_URL if set (Dokku-style)
dbConfig := DatabaseConfig{
Host: viper.GetString("DB_HOST"),
Port: viper.GetInt("DB_PORT"),
User: viper.GetString("POSTGRES_USER"),
Password: viper.GetString("POSTGRES_PASSWORD"),
Database: viper.GetString("POSTGRES_DB"),
SSLMode: viper.GetString("DB_SSLMODE"),
MaxOpenConns: viper.GetInt("DB_MAX_OPEN_CONNS"),
MaxIdleConns: viper.GetInt("DB_MAX_IDLE_CONNS"),
MaxLifetime: viper.GetDuration("DB_MAX_LIFETIME"),
}
// Override with DATABASE_URL if present
if databaseURL := viper.GetString("DATABASE_URL"); databaseURL != "" {
parsed, err := parseDatabaseURL(databaseURL)
if err == nil {
dbConfig.Host = parsed.Host
dbConfig.Port = parsed.Port
dbConfig.User = parsed.User
dbConfig.Password = parsed.Password
dbConfig.Database = parsed.Database
if parsed.SSLMode != "" {
dbConfig.SSLMode = parsed.SSLMode
}
}
}
cfg = &Config{
Server: ServerConfig{
Port: viper.GetInt("PORT"),
Debug: viper.GetBool("DEBUG"),
AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","),
Timezone: viper.GetString("TIMEZONE"),
StaticDir: viper.GetString("STATIC_DIR"),
},
Database: dbConfig,
Redis: RedisConfig{
URL: viper.GetString("REDIS_URL"),
Password: viper.GetString("REDIS_PASSWORD"),
DB: viper.GetInt("REDIS_DB"),
},
Email: EmailConfig{
Host: viper.GetString("EMAIL_HOST"),
Port: viper.GetInt("EMAIL_PORT"),
User: viper.GetString("EMAIL_HOST_USER"),
Password: viper.GetString("EMAIL_HOST_PASSWORD"),
From: viper.GetString("DEFAULT_FROM_EMAIL"),
UseTLS: viper.GetBool("EMAIL_USE_TLS"),
},
Push: PushConfig{
APNSKeyPath: viper.GetString("APNS_AUTH_KEY_PATH"),
APNSKeyID: viper.GetString("APNS_AUTH_KEY_ID"),
APNSTeamID: viper.GetString("APNS_TEAM_ID"),
APNSTopic: viper.GetString("APNS_TOPIC"),
APNSSandbox: viper.GetBool("APNS_USE_SANDBOX"),
APNSProduction: viper.GetBool("APNS_PRODUCTION"),
FCMServerKey: viper.GetString("FCM_SERVER_KEY"),
},
Worker: WorkerConfig{
TaskReminderHour: viper.GetInt("TASK_REMINDER_HOUR"),
TaskReminderMinute: viper.GetInt("TASK_REMINDER_MINUTE"),
OverdueReminderHour: viper.GetInt("OVERDUE_REMINDER_HOUR"),
DailyNotifHour: viper.GetInt("DAILY_DIGEST_HOUR"),
},
Security: SecurityConfig{
SecretKey: viper.GetString("SECRET_KEY"),
TokenCacheTTL: 5 * time.Minute,
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
},
Storage: StorageConfig{
UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"),
BaseURL: viper.GetString("STORAGE_BASE_URL"),
MaxFileSize: viper.GetInt64("STORAGE_MAX_FILE_SIZE"),
AllowedTypes: viper.GetString("STORAGE_ALLOWED_TYPES"),
},
AppleAuth: AppleAuthConfig{
ClientID: viper.GetString("APPLE_CLIENT_ID"),
TeamID: viper.GetString("APPLE_TEAM_ID"),
},
}
// Validate required fields
if err := validate(cfg); err != nil {
return nil, err
}
return cfg, nil
}
// Get returns the current configuration
func Get() *Config {
return cfg
}
func setDefaults() {
// Server defaults
viper.SetDefault("PORT", 8000)
viper.SetDefault("DEBUG", false)
viper.SetDefault("ALLOWED_HOSTS", "localhost,127.0.0.1")
viper.SetDefault("TIMEZONE", "UTC")
viper.SetDefault("STATIC_DIR", "/app/static")
// Database defaults
viper.SetDefault("DB_HOST", "localhost")
viper.SetDefault("DB_PORT", 5432)
viper.SetDefault("POSTGRES_USER", "postgres")
viper.SetDefault("POSTGRES_DB", "casera")
viper.SetDefault("DB_SSLMODE", "disable")
viper.SetDefault("DB_MAX_OPEN_CONNS", 25)
viper.SetDefault("DB_MAX_IDLE_CONNS", 10)
viper.SetDefault("DB_MAX_LIFETIME", 600*time.Second)
// Redis defaults
viper.SetDefault("REDIS_URL", "redis://localhost:6379/0")
viper.SetDefault("REDIS_DB", 0)
// Email defaults
viper.SetDefault("EMAIL_HOST", "smtp.gmail.com")
viper.SetDefault("EMAIL_PORT", 587)
viper.SetDefault("EMAIL_USE_TLS", true)
viper.SetDefault("EMAIL_HOST_USER", "")
viper.SetDefault("EMAIL_HOST_PASSWORD", "")
viper.SetDefault("DEFAULT_FROM_EMAIL", "Casera <noreply@casera.com>")
// Push notification defaults
viper.SetDefault("APNS_TOPIC", "com.example.casera")
viper.SetDefault("APNS_USE_SANDBOX", true)
viper.SetDefault("APNS_PRODUCTION", false)
// Worker defaults (all times in UTC)
viper.SetDefault("TASK_REMINDER_HOUR", 20) // 8:00 PM UTC
viper.SetDefault("TASK_REMINDER_MINUTE", 0)
viper.SetDefault("OVERDUE_REMINDER_HOUR", 9) // 9:00 AM UTC
viper.SetDefault("DAILY_DIGEST_HOUR", 11) // 11:00 AM UTC
// Storage defaults
viper.SetDefault("STORAGE_UPLOAD_DIR", "./uploads")
viper.SetDefault("STORAGE_BASE_URL", "/uploads")
viper.SetDefault("STORAGE_MAX_FILE_SIZE", 10*1024*1024) // 10MB
viper.SetDefault("STORAGE_ALLOWED_TYPES", "image/jpeg,image/png,image/gif,image/webp,application/pdf")
}
func validate(cfg *Config) error {
if cfg.Security.SecretKey == "" {
// Use a default key but log a warning in production
cfg.Security.SecretKey = "change-me-in-production-secret-key-12345"
if !cfg.Server.Debug {
fmt.Println("WARNING: SECRET_KEY not set, using default (insecure)")
}
}
// Database password might come from DATABASE_URL, don't require it separately
// The actual connection will fail if credentials are wrong
return nil
}
// DSN returns the database connection string
func (d *DatabaseConfig) DSN() string {
return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
d.Host, d.Port, d.User, d.Password, d.Database, d.SSLMode,
)
}
// ReadAPNSKey reads the APNs key from file if path is provided
func (p *PushConfig) ReadAPNSKey() (string, error) {
if p.APNSKeyPath == "" {
return "", nil
}
content, err := os.ReadFile(p.APNSKeyPath)
if err != nil {
return "", fmt.Errorf("failed to read APNs key: %w", err)
}
return string(content), nil
}
// parseDatabaseURL parses a PostgreSQL URL into DatabaseConfig
// Format: postgres://user:password@host:port/database?sslmode=disable
func parseDatabaseURL(databaseURL string) (*DatabaseConfig, error) {
u, err := url.Parse(databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse DATABASE_URL: %w", err)
}
// Default port
port := 5432
if u.Port() != "" {
port, err = strconv.Atoi(u.Port())
if err != nil {
return nil, fmt.Errorf("invalid port in DATABASE_URL: %w", err)
}
}
// Get password
password, _ := u.User.Password()
// Get database name (remove leading slash)
database := strings.TrimPrefix(u.Path, "/")
// Get sslmode from query params
sslMode := u.Query().Get("sslmode")
return &DatabaseConfig{
Host: u.Hostname(),
Port: port,
User: u.User.Username(),
Password: password,
Database: database,
SSLMode: sslMode,
}, nil
}