package main import ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" "time" discordadapter "mixmaker/backend/internal/adapter/discord" "mixmaker/backend/internal/adapter/httpapi" "mixmaker/backend/internal/adapter/postgres" "mixmaker/backend/internal/application" "mixmaker/backend/internal/realtime" ) func main() { ctx := context.Background() databaseURL := required("DATABASE_URL") if len(os.Args) > 1 && os.Args[1] == "migrate" { dir := env("MIGRATIONS_DIR", "migrations") if err := postgres.Migrate(ctx, databaseURL, dir); err != nil { slog.Error("migration failed", "error", err) os.Exit(1) } slog.Info("migrations applied") return } store, err := postgres.Open(ctx, databaseURL) if err != nil { slog.Error("database connection failed", "error", err) os.Exit(1) } defer store.Close() hub := realtime.New() var eventAnnouncer application.EventAnnouncer discordBotToken := os.Getenv("DISCORD_BOT_TOKEN") discordAnnouncementChannelID := os.Getenv("DISCORD_ANNOUNCEMENT_CHANNEL_ID") if discordBotToken != "" || discordAnnouncementChannelID != "" { eventAnnouncer, err = discordadapter.New(discordadapter.Config{ BotToken: discordBotToken, ChannelID: discordAnnouncementChannelID, PublicURL: env("PUBLIC_URL", env("FRONTEND_URL", "")), Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"), }) if err != nil { slog.Error("Discord announcer configuration failed", "error", err) os.Exit(2) } } service := application.New(store, hub, eventAnnouncer) runCtx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) defer cancel() discordGuildID := os.Getenv("DISCORD_GUILD_ID") if discordGuildID != "" { roleWorker, roleErr := discordadapter.NewRoleWorker(store, discordadapter.RoleSyncConfig{ BotToken: discordBotToken, GuildID: discordGuildID, Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"), GlobalSyncInterval: durationEnv("DISCORD_GLOBAL_SYNC_INTERVAL", 5*time.Minute), }) if roleErr != nil { slog.Error("Discord role sync configuration failed", "error", roleErr) os.Exit(2) } go roleWorker.Run(runCtx) } cfg := httpapi.Config{ DiscordClientID: required("DISCORD_CLIENT_ID"), DiscordClientSecret: required("DISCORD_CLIENT_SECRET"), DiscordRedirectURL: required("DISCORD_REDIRECT_URL"), FrontendURL: env("FRONTEND_URL", "/"), CookieName: env("SESSION_COOKIE_NAME", "mixmaker_session"), SecureCookies: env("COOKIE_SECURE", "true") == "true", SessionTTL: durationEnv("SESSION_TTL", 7*24*time.Hour), AdminDiscordIDs: csvSet(os.Getenv("ADMIN_DISCORD_IDS")), } server := &http.Server{ Addr: env("HTTP_ADDR", ":8080"), Handler: httpapi.New(service, store, hub, cfg), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, } go func() { slog.Info("API listening", "address", server.Addr) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server failed", "error", err) os.Exit(1) } }() <-runCtx.Done() shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() if err := server.Shutdown(shutdownCtx); err != nil { slog.Error("graceful shutdown failed", "error", err) } } func required(key string) string { value := os.Getenv(key) if value == "" { slog.Error("required environment variable missing", "key", key) os.Exit(2) } return value } func env(key, fallback string) string { if value := os.Getenv(key); value != "" { return value } return fallback } func csvSet(value string) map[string]bool { out := make(map[string]bool) for _, item := range strings.Split(value, ",") { if item = strings.TrimSpace(item); item != "" { out[item] = true } } return out } func durationEnv(key string, fallback time.Duration) time.Duration { value := os.Getenv(key) if value == "" { return fallback } parsed, err := time.ParseDuration(value) if err != nil || parsed <= 0 { slog.Error("invalid duration environment variable", "key", key, "value", value) os.Exit(2) } return parsed }