package app import ( "context" "errors" "fmt" "net/http" "os" "strings" "time" "go.uber.org/zap" "productier/apps/backend/internal/authsession" "productier/apps/backend/internal/filestorage" "productier/apps/backend/internal/httpapi" "productier/apps/backend/internal/mailruntime" "productier/apps/backend/internal/store" ) type App struct { server *httpapi.Server port string shutdownTimeout time.Duration stopMailRuntime context.CancelFunc } func New(logger *zap.Logger) (*App, error) { runtimeConfig, err := loadRuntimeConfig() if err != nil { return nil, err } var dataStore store.Store databaseURL := os.Getenv("DATABASE_URL") if databaseURL != "" { persistentStore, err := store.NewPostgresStore(databaseURL, runtimeConfig.mode) if err != nil { return nil, err } dataStore = persistentStore } else { if err := validateStoreRuntimeMode(runtimeConfig.mode, inMemoryStoreAllowed()); err != nil { return nil, err } dataStore = store.NewSeededState(runtimeConfig.mode) } mailService, err := mailruntime.New(dataStore, logger, runtimeConfig.mailSecret) if err != nil { return nil, err } files, err := filestorage.NewFromEnv() if err != nil { return nil, err } probeCtx, cancelProbe := context.WithTimeout(context.Background(), 3*time.Second) defer cancelProbe() if err := files.Probe(probeCtx); err != nil { return nil, fmt.Errorf("file storage startup probe failed: %w", err) } mailRuntimeCtx, stopMailRuntime := context.WithCancel(context.Background()) mailService.Start(mailRuntimeCtx) return &App{ server: httpapi.NewServer( dataStore, authsession.NewClient(runtimeConfig.authServiceURL), mailService, files, runtimeConfig.mode, runtimeConfig.corsAllowOrigins, runtimeConfig.metricsAuthToken, logger, ), port: runtimeConfig.apiPort, shutdownTimeout: runtimeConfig.shutdownTimeout, stopMailRuntime: stopMailRuntime, }, nil } func (a *App) Run() error { return a.RunContext(context.Background()) } func (a *App) RunContext(ctx context.Context) error { httpServer := &http.Server{ Addr: fmt.Sprintf(":%s", a.port), Handler: a.server.Engine(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } serverErr := make(chan error, 1) go func() { err := httpServer.ListenAndServe() if err != nil && !errors.Is(err, http.ErrServerClosed) { serverErr <- err } close(serverErr) }() select { case <-ctx.Done(): if a.stopMailRuntime != nil { a.stopMailRuntime() } shutdownCtx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout) defer cancel() if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("shutdown api server: %w", err) } if err, ok := <-serverErr; ok && err != nil { return fmt.Errorf("run api server: %w", err) } return nil case err, ok := <-serverErr: if a.stopMailRuntime != nil { a.stopMailRuntime() } if !ok || err == nil { return nil } return fmt.Errorf("run api server: %w", err) } } func validateStoreRuntimeMode(mode string, allowInMemory bool) error { if mode == "development" || allowInMemory { return nil } return fmt.Errorf("DATABASE_URL is required when APP_ENV=%q (set ALLOW_INMEMORY_STORE=true only for temporary non-production testing)", mode) } func inMemoryStoreAllowed() bool { raw := strings.TrimSpace(strings.ToLower(os.Getenv("ALLOW_INMEMORY_STORE"))) switch raw { case "1", "true", "yes", "on": return true default: return false } }