package main import ( "context" "encoding/json" "errors" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/redis/go-redis/v9" "github.com/alchemistkay/guestguard/internal/api" "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/billing" "github.com/alchemistkay/guestguard/internal/config" "github.com/alchemistkay/guestguard/internal/fraud" "github.com/alchemistkay/guestguard/internal/natspub" "github.com/alchemistkay/guestguard/internal/notification" "github.com/alchemistkay/guestguard/internal/storage" ) func main() { if err := run(); err != nil { slog.Error("fatal", "err", err) os.Exit(1) } } func run() error { cfg, err := config.Load() if err != nil { return err } logger := newLogger(cfg.Env) slog.SetDefault(logger) rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() logger.Info("connecting to database") db, err := storage.NewDB(rootCtx, cfg.DatabaseURL) if err != nil { return err } defer db.Close() logger.Info("running migrations") if err := db.Migrate(rootCtx); err != nil { return err } logger.Info("connecting to nats", "url", cfg.NATSURL) natsClient, err := natspub.Connect(rootCtx, cfg.NATSURL, logger) if err != nil { return err } defer natsClient.Close() logger.Info("connecting to redis", "addr", cfg.RedisAddr) rdb := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr}) if err := rdb.Ping(rootCtx).Err(); err != nil { logger.Warn("redis ping failed — rate limits + lockout disabled", "err", err) _ = rdb.Close() rdb = nil } else { defer rdb.Close() } logger.Info("dialing fraud engine", "addr", cfg.FraudGRPCAddr) fraudClient, err := fraud.Dial(rootCtx, cfg.FraudGRPCAddr, cfg.FraudGRPCTimeout, logger) if err != nil { return err } defer fraudClient.Close() hub := api.NewHub(logger) accessLogs := storage.NewAccessLogRepo(db) fraudSub, err := natspub.NewFraudScoredSubscriber( rootCtx, natsClient, "core-api-fraud-scored", func(ctx context.Context, evt natspub.FraudScored) error { if err := accessLogs.ApplyScore(ctx, storage.ApplyScoreParams{ AccessLogID: evt.AccessLogID, Score: evt.Score, Reasons: evt.Reasons, Flagged: evt.Score >= 60, }); err != nil { return err } payload, _ := json.Marshal(evt) hub.Broadcast(api.WSEvent{ Type: "fraud.scored", EventID: evt.EventID, Payload: payload, }) return nil }, logger, ) if err != nil { return err } fraudConsumeCtx, err := fraudSub.Start(rootCtx) if err != nil { return err } defer fraudConsumeCtx.Stop() rsvpSub, err := natspub.NewRSVPConfirmedSubscriber( rootCtx, natsClient, "core-api-rsvp-confirmed-ws", func(ctx context.Context, evt natspub.RSVPConfirmed) error { payload, _ := json.Marshal(evt) hub.Broadcast(api.WSEvent{ Type: "rsvp.confirmed", EventID: evt.EventID, Payload: payload, }) return nil }, logger, ) if err != nil { return err } rsvpConsumeCtx, err := rsvpSub.Start(rootCtx) if err != nil { return err } defer rsvpConsumeCtx.Stop() // Notification senders. If SES creds are configured, route auth + // guest emails through SES. Otherwise the log stub keeps the dev flow // (verification link in API logs) intact. tpls, err := notification.NewTemplates() if err != nil { return err } suppressions := notification.NewSuppressionRepo(db) notifRepo := notification.NewRepo(db) unsubSigner := notification.NewUnsubscribeSigner(cfg.UnsubscribeSecret) emailSenderCombined, backend, err := notification.PickEmailSender(rootCtx, notification.EmailSenderConfig{ Resend: notification.ResendConfig{ APIKey: cfg.ResendAPIKey, FromEmail: cfg.ResendFromEmail, FromName: cfg.ResendFromName, }, SMTP: notification.SMTPConfig{ Host: cfg.SMTPHost, Port: cfg.SMTPPort, Username: cfg.SMTPUsername, Password: cfg.SMTPPassword, FromEmail: cfg.SMTPFromEmail, FromName: cfg.SMTPFromName, TLS: cfg.SMTPTLS, }, SES: notification.SESConfig{ Region: cfg.SESRegion, FromEmail: cfg.SESFromEmail, FromName: cfg.SESFromName, ConfigurationSet: cfg.SESConfigurationSet, PublicBaseURL: cfg.PublicBaseURL, }, }, tpls, logger) if err != nil { return err } logger.Info("email backend selected", "backend", backend) var emailSender auth.EmailSender = emailSenderCombined stripeClient, err := billing.NewClient(billing.Config{ SecretKey: cfg.StripeSecretKey, WebhookSecret: cfg.StripeWebhookSecret, PriceProMonthly: cfg.StripePricePro, PriceBusiness: cfg.StripePriceBusiness, }) if err != nil { return err } if stripeClient != nil && stripeClient.Enabled() { logger.Info("billing enabled via stripe") } else { logger.Info("billing disabled — free tier limits apply to all users") } apiSrv, err := api.NewServer(api.ServerDeps{ Logger: logger, DB: db, Hub: hub, AccessPublisher: natsClient, RSVPPublisher: natsClient, InvitationPublisher: natsClient, FraudScorer: fraudClient, TokenTTL: cfg.TokenTTL, JWTSecret: cfg.JWTSecret, JWTIssuer: cfg.JWTIssuer, AccessTokenTTL: cfg.AccessTokenTTL, RefreshTokenTTL: cfg.RefreshTokenTTL, EmailVerificationTTL: cfg.EmailVerificationTTL, PasswordResetTTL: cfg.PasswordResetTTL, PublicBaseURL: cfg.PublicBaseURL, RefreshCookieDomain: cfg.RefreshCookieDomain, RefreshCookieSecure: cfg.RefreshCookieSecure, Redis: rdb, EmailSender: emailSender, NotificationRepo: notifRepo, SuppressionRepo: suppressions, UnsubscribeSigner: unsubSigner, StripeClient: stripeClient, }) if err != nil { return err } srv := &http.Server{ Addr: cfg.HTTPAddr, Handler: apiSrv.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 0, // 0 lets WS connections live; per-request handlers still bound by their own ctx IdleTimeout: 60 * time.Second, } errCh := make(chan error, 1) go func() { logger.Info("http server starting", "addr", cfg.HTTPAddr, "env", cfg.Env) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } close(errCh) }() select { case <-rootCtx.Done(): logger.Info("shutdown signal received") case err := <-errCh: if err != nil { return err } } shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("graceful shutdown failed", "err", err) return err } logger.Info("shutdown complete") return nil } func newLogger(env string) *slog.Logger { level := slog.LevelInfo if env == "development" { level = slog.LevelDebug } return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})) }