package main import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/scottyah/paragliding/internal/client" "github.com/scottyah/paragliding/internal/config" "github.com/scottyah/paragliding/internal/database" "github.com/scottyah/paragliding/internal/model" "github.com/scottyah/paragliding/internal/repository" "github.com/scottyah/paragliding/internal/server" "github.com/scottyah/paragliding/internal/service" ) func main() { // Setup structured logging logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, })) slog.SetDefault(logger) // Load configuration cfg, err := config.Load() if err != nil { logger.Error("failed to load configuration", "error", err) os.Exit(1) } logger.Info("configuration loaded", "port", cfg.Port, "location", cfg.LocationName, "fetch_interval", cfg.FetchInterval, ) // Create context for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Connect to PostgreSQL with retry dbpool, err := connectDatabaseWithRetry(ctx, cfg.DatabaseURL, logger, 30*time.Second) if err != nil { logger.Error("failed to connect to database", "error", err) os.Exit(1) } defer dbpool.Close() logger.Info("connected to database") // Run database migrations if err := database.RunMigrations(cfg.DatabaseURL, logger); err != nil { logger.Error("failed to run migrations", "error", err) os.Exit(1) } // Initialize services weatherClient := client.NewOpenMeteoClient(client.OpenMeteoConfig{ Latitude: cfg.LocationLat, Longitude: cfg.LocationLon, Timezone: cfg.Timezone, }) weatherRepo := repository.NewWeatherRepository(dbpool) assessmentSvc := service.NewAssessmentService() // Create weather service (handles caching and API rate limiting) weatherSvc := service.NewWeatherService(service.WeatherServiceConfig{ Client: weatherClient, Repo: weatherRepo, Logger: logger, }) // Create HTTP server srv := server.New(cfg.Addr(), logger) // Setup routes with handler handler := &Handler{ logger: logger, db: dbpool, config: cfg, weatherSvc: weatherSvc, assessmentSvc: assessmentSvc, } srv.SetupRoutes(handler) // Start background weather fetcher fetcher := &WeatherFetcher{ logger: logger, config: cfg, weatherSvc: weatherSvc, stopChan: make(chan struct{}), } go fetcher.Start(ctx) // Start HTTP server in a goroutine serverErrors := make(chan error, 1) go func() { serverErrors <- srv.Start() }() // Wait for interrupt signal or server error shutdown := make(chan os.Signal, 1) signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) select { case err := <-serverErrors: logger.Error("server error", "error", err) case sig := <-shutdown: logger.Info("shutdown signal received", "signal", sig) // Stop background fetcher close(fetcher.stopChan) // Give outstanding requests time to complete ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { logger.Error("graceful shutdown failed", "error", err) os.Exit(1) } logger.Info("server stopped gracefully") } } // connectDatabaseWithRetry establishes a connection pool to PostgreSQL with exponential backoff func connectDatabaseWithRetry(ctx context.Context, databaseURL string, logger *slog.Logger, maxWait time.Duration) (*pgxpool.Pool, error) { pgConfig, err := pgxpool.ParseConfig(databaseURL) if err != nil { return nil, fmt.Errorf("failed to parse database URL: %w", err) } // Set connection pool settings pgConfig.MaxConns = 25 pgConfig.MinConns = 5 pgConfig.MaxConnLifetime = time.Hour pgConfig.MaxConnIdleTime = 30 * time.Minute pgConfig.HealthCheckPeriod = time.Minute // Exponential backoff retry var pool *pgxpool.Pool backoff := 1 * time.Second maxBackoff := 8 * time.Second deadline := time.Now().Add(maxWait) for { pool, err = pgxpool.NewWithConfig(ctx, pgConfig) if err == nil { // Verify connection if pingErr := pool.Ping(ctx); pingErr == nil { return pool, nil } else { pool.Close() err = pingErr } } if time.Now().After(deadline) { return nil, fmt.Errorf("failed to connect to database after %v: %w", maxWait, err) } logger.Warn("database connection failed, retrying", "error", err, "retry_in", backoff, ) select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(backoff): } // Exponential backoff with cap backoff *= 2 if backoff > maxBackoff { backoff = maxBackoff } } } // Handler implements the RouteHandler interface type Handler struct { logger *slog.Logger db *pgxpool.Pool config *config.Config weatherSvc *service.WeatherService assessmentSvc *service.AssessmentService } // Health handles health check requests func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() // Check database connectivity if err := h.db.Ping(ctx); err != nil { h.logger.Error("health check failed", "error", err) server.RespondError(w, 503, "database unavailable") return } server.RespondJSON(w, 200, map[string]interface{}{ "status": "healthy", "timestamp": time.Now().UTC(), "location": h.config.LocationName, }) } // GetCurrentWeather handles current weather requests func (h *Handler) GetCurrentWeather(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Get current weather from service (handles caching, DB, and rate-limited API fallback) weatherData, err := h.weatherSvc.GetCurrentWeather(ctx) if err != nil { h.logger.Error("failed to get current weather", "error", err) server.RespondError(w, 500, "failed to fetch weather data") return } if len(weatherData.Points) == 0 { server.RespondError(w, 500, "no current weather data available") return } current := weatherData.Points[0] // Get all points for assessment allPoints, err := h.weatherSvc.GetAllPoints(ctx) if err != nil { h.logger.Warn("failed to get points for assessment", "error", err) allPoints = weatherData.Points } // Get default thresholds and assess conditions thresholds := model.DefaultThresholds() assessment := h.assessmentSvc.Evaluate(allPoints, thresholds) response := map[string]interface{}{ "timestamp": weatherData.FetchedAt.UTC(), "location": map[string]interface{}{ "name": h.config.LocationName, "lat": h.config.LocationLat, "lon": h.config.LocationLon, }, "current": map[string]interface{}{ "windSpeed": current.WindSpeedMPH, "windDirection": current.WindDirection, "windGust": current.WindGustMPH, "time": current.Time, }, "assessment": assessment, "source": weatherData.Source, } server.RespondJSON(w, 200, response) } // GetForecast handles weather forecast requests func (h *Handler) GetForecast(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Get forecast from service (handles caching, DB, and rate-limited API fallback) weatherData, err := h.weatherSvc.GetForecast(ctx) if err != nil { h.logger.Error("failed to get forecast", "error", err) server.RespondError(w, 500, "failed to fetch forecast data") return } // Get default thresholds thresholds := model.DefaultThresholds() // Find flyable windows windows := h.assessmentSvc.FindFlyableWindows(weatherData.Points, thresholds) response := map[string]interface{}{ "generated": weatherData.FetchedAt.UTC(), "location": map[string]interface{}{ "name": h.config.LocationName, "lat": h.config.LocationLat, "lon": h.config.LocationLon, }, "forecast": weatherData.Points, "flyableWindows": windows, "defaultThresholds": thresholds, "source": weatherData.Source, } server.RespondJSON(w, 200, response) } // GetHistorical handles historical weather requests func (h *Handler) GetHistorical(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Parse date from query param (default: yesterday) dateStr := r.URL.Query().Get("date") var date time.Time if dateStr == "" { date = time.Now().AddDate(0, 0, -1) } else { var err error date, err = time.Parse("2006-01-02", dateStr) if err != nil { server.RespondError(w, 400, "invalid date format, use YYYY-MM-DD") return } } // Get historical data from service (handles caching and DB lookup) weatherData, err := h.weatherSvc.GetHistorical(ctx, date) if err != nil { h.logger.Error("failed to get historical data", "error", err) server.RespondError(w, 500, "failed to fetch historical data") return } response := map[string]interface{}{ "date": date.Format("2006-01-02"), "data": weatherData.Points, "source": weatherData.Source, } server.RespondJSON(w, 200, response) } // AssessConditions handles paragliding condition assessment requests func (h *Handler) AssessConditions(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Limit request body size r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB max // Parse thresholds from request body var req struct { Thresholds model.Thresholds `json:"thresholds"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // Use default thresholds if not provided or invalid req.Thresholds = model.DefaultThresholds() } // Get weather data from service (DB-first, no API call triggered here) points, err := h.weatherSvc.GetAllPoints(ctx) if err != nil { h.logger.Error("failed to get weather for assessment", "error", err) server.RespondError(w, 500, "failed to fetch weather data") return } // Run assessment with provided thresholds assessment := h.assessmentSvc.Evaluate(points, req.Thresholds) windows := h.assessmentSvc.FindFlyableWindows(points, req.Thresholds) response := map[string]interface{}{ "assessment": assessment, "flyableWindows": windows, "thresholds": req.Thresholds, } server.RespondJSON(w, 200, response) } // WeatherFetcher runs background weather fetching type WeatherFetcher struct { logger *slog.Logger config *config.Config weatherSvc *service.WeatherService stopChan chan struct{} } // Start begins the background weather fetching loop func (f *WeatherFetcher) Start(ctx context.Context) { f.logger.Info("starting weather fetcher", "interval", f.config.FetchInterval) ticker := time.NewTicker(f.config.FetchInterval) defer ticker.Stop() // Fetch immediately on startup f.fetch(ctx) for { select { case <-ticker.C: f.fetch(ctx) case <-f.stopChan: f.logger.Info("stopping weather fetcher") return case <-ctx.Done(): f.logger.Info("weather fetcher context cancelled") return } } } // fetch performs the actual weather data fetching via the weather service func (f *WeatherFetcher) fetch(ctx context.Context) { f.logger.Info("fetching weather data", "lat", f.config.LocationLat, "lon", f.config.LocationLon, ) // Use weather service's FetchFromAPI (bypasses rate limiting for scheduled fetches) points, err := f.weatherSvc.FetchFromAPI(ctx) if err != nil { f.logger.Error("failed to fetch weather data", "error", err) return } f.logger.Info("weather data updated successfully", "points", len(points)) }