package app import ( "context" "errors" "fmt" "log" "net/http" "os/signal" "sync" "sync/atomic" "syscall" "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/canned" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/geoip" ws "github.com/gochat/gochat/internal/handler/ws" "github.com/gochat/gochat/internal/lifecycle" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/pubsub" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/internal/worker" wspkg "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" ) // App is the central application container holding all shared services. type App struct { config *config.Config reloader *config.ConfigReloader db *gorm.DB pubsub pubsub.PubSub engine *gin.Engine wsHub *ws.Hub wsRelay *wspkg.BroadcastRelay eventPublisher *wspkg.EventPublisher notificationDeliverySvc *service.NotificationDeliveryService workerPool *worker.WorkerPool visitorGeoReader *geoip.Reader ready *atomic.Bool notificationRunning atomic.Bool handlerGroupOnce sync.Once handlerGroup *lifecycle.HandlerGroup } // New creates and initializes the application. func New(cfg *config.Config) (*App, error) { // Initialize database db, err := initDB(cfg) if err != nil { return nil, fmt.Errorf("failed to init database: %w", err) } // Auto-migrate core models if err := autoMigrate(db); err != nil { return nil, fmt.Errorf("failed to auto-migrate: %w", err) } // Initialize gin engine gin.SetMode(cfg.Server.Mode) engine := gin.New() if err := engine.SetTrustedProxies(cfg.Server.TrustedProxies); err != nil { return nil, fmt.Errorf("trusted proxy configuration failed: %w", err) } engine.Use(gin.Recovery()) // Initialize pub/sub — Redis-backed when Redis is configured, in-memory fallback var ps pubsub.PubSub rdb, redisErr := NewRedisClient(&cfg.Redis) if redisErr != nil { applogger.L().Warnf("Redis unavailable, using in-memory pubsub: %v", redisErr) ps = pubsub.NewInMemoryPubSub() } else { redisPubSub, pubsubErr := pubsub.NewRedisPubSub(rdb) if pubsubErr != nil { applogger.L().Warnf("Redis PubSub init failed, falling back to in-memory: %v", pubsubErr) ps = pubsub.NewInMemoryPubSub() } else { ps = redisPubSub applogger.L().Info("Redis PubSub initialized (watermill-redisstream)") } } application := &App{ config: cfg, db: db, pubsub: ps, engine: engine, } return application, nil } // Run starts only the web process. Background jobs are consumed by RunWorker. func (a *App) Run() error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() return a.runWeb(ctx) } func (a *App) runWeb(ctx context.Context) error { lifecycle, cancel := context.WithCancel(context.Background()) defer cancel() if a.wsHub != nil { go a.wsHub.Run(lifecycle) } if a.wsRelay != nil { if err := a.wsRelay.Start(lifecycle); err != nil { return fmt.Errorf("failed to start WebSocket relay: %w", err) } } server := a.HTTPServer() serveErr := make(chan error, 1) go func() { log.Printf("Starting gochat web server on %s", server.Addr) serveErr <- server.ListenAndServe() }() select { case err := <-serveErr: cancel() _ = a.Shutdown(a.shutdownTimeout()) if errors.Is(err, http.ErrServerClosed) { return nil } return err case <-ctx.Done(): } if a.ready != nil { a.ready.Store(false) } timeout := a.shutdownTimeout() shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), timeout) defer shutdownCancel() httpErr := server.Shutdown(shutdownCtx) cancel() return errors.Join(httpErr, a.shutdown(shutdownCtx)) } // RunWorker consumes background jobs and notifications without opening an HTTP listener. func (a *App) RunWorker() error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() return a.runWorker(ctx) } func (a *App) runWorker(ctx context.Context) error { if a.workerPool != nil { if err := a.workerPool.Start(); err != nil { return fmt.Errorf("failed to start background worker: %w", err) } applogger.L().Info("Background worker started") } notificationErr := make(chan error, 1) if a.notificationDeliverySvc != nil { a.notificationRunning.Store(true) go func() { notificationErr <- a.notificationDeliverySvc.Start(a.handlers().Context()) }() applogger.L().Info("Notification delivery pipeline started") } select { case <-ctx.Done(): case err := <-notificationErr: if err != nil { return errors.Join(err, a.Shutdown(a.shutdownTimeout())) } } return a.Shutdown(a.shutdownTimeout()) } // HTTPServer applies the production request-boundary limits from configuration. func (a *App) HTTPServer() *http.Server { cfg := a.config.Server return &http.Server{ Addr: a.Address(), Handler: a.Handler(), ReadHeaderTimeout: positiveDuration(cfg.ReadHeaderTimeoutS, 5*time.Second), ReadTimeout: positiveDuration(cfg.ReadTimeoutS, 30*time.Second), WriteTimeout: positiveDuration(cfg.WriteTimeoutS, 30*time.Second), IdleTimeout: positiveDuration(cfg.IdleTimeoutS, 120*time.Second), MaxHeaderBytes: positiveInt(cfg.MaxHeaderBytes, 1<<20), } } func (a *App) shutdownTimeout() time.Duration { return positiveDuration(a.config.Server.ShutdownTimeoutS, 30*time.Second) } func positiveDuration(seconds int, fallback time.Duration) time.Duration { if seconds <= 0 { return fallback } return time.Duration(seconds) * time.Second } func positiveInt(value, fallback int) int { if value <= 0 { return fallback } return value } // Address returns the server listen address. func (a *App) Address() string { return fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port) } // Handler returns the gin engine as http.Handler for use with http.Server. func (a *App) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handlers := a.handlers() if !handlers.Begin() { http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) return } defer handlers.Done() a.engine.ServeHTTP(w, r) }) } func (a *App) handlers() *lifecycle.HandlerGroup { a.handlerGroupOnce.Do(func() { if a.handlerGroup == nil { a.handlerGroup = lifecycle.NewHandlerGroup() } }) return a.handlerGroup } // Config returns the application config. func (a *App) Config() *config.Config { return a.config } // DB returns the gorm.DB instance. func (a *App) DB() *gorm.DB { return a.db } // PubSub returns the pub/sub instance. func (a *App) PubSub() pubsub.PubSub { return a.pubsub } // Reloader returns the config hot-reloader (may be nil if not initialized). func (a *App) Reloader() *config.ConfigReloader { return a.reloader } func initDB(cfg *config.Config) (*gorm.DB, error) { db, err := gorm.Open(postgres.Open(cfg.Database.DSN), &gorm.Config{ Logger: newDatabaseLogger(log.Default(), logger.Info), }) if err != nil { return nil, err } sqlDB, err := db.DB() if err != nil { return nil, err } sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns) sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns) return db, nil } func autoMigrate(db *gorm.DB) error { models := []interface{}{ &model.Account{}, &model.User{}, &model.Conversation{}, &model.Message{}, &model.Contact{}, &model.Inbox{}, &model.Attachment{}, &model.ContactInbox{}, &model.InboxMember{}, &model.Team{}, &model.TeamMember{}, &model.ReportingEvent{}, &model.ReportingEventsRollup{}, &model.DashboardApp{}, // G4: Company module models (CRUD + search + nested contacts/conversations/notes) &model.Company{}, &model.CompanyNote{}, &automation.CsatSurveyResponse{}, &canned.CannedResponse{}, &model.PushToken{}, &model.WebhookSubscription{}, &model.WebhookDelivery{}, // M13: OIDC enterprise authentication model &model.AccountOIDCSettings{}, // P9: AgentBot rule engine models (defined in automation package to avoid import cycle) &automation.BotRule{}, &automation.BotTriggerConfig{}, // M6: Automation & Macro models &automation.AutomationRule{}, &automation.AutomationExecution{}, &automation.Macro{}, &automation.MacroExecution{}, // M11: WebWidget channel models — theme config, pre-chat form, file upload, offline messages &model.WidgetThemeConfig{}, &model.PreChatForm{}, &model.WidgetFileUpload{}, &model.WidgetOfflineMessage{}, // M11: SLA Policy models — policy, applied SLA, events, inbox associations &model.SlaPolicy{}, &model.SlaPolicyInbox{}, &model.AppliedSLA{}, &model.SlaEvent{}, // Chatwoot assignment policies + inbox association. &model.AssignmentPolicy{}, &model.InboxAssignmentPolicy{}, // Legacy enhanced assignment policy aliases kept for local compatibility. &model.AssignmentPolicyV2{}, &model.AssignmentPolicyInbox{}, // Enterprise: Audit log model (read-only log of account actions) &model.Audit{}, // Enterprise: Custom role for fine-grained permissions &model.CustomRole{}, // Enterprise: Agent capacity policies + inbox capacity limits &model.AgentCapacityPolicy{}, &model.InboxCapacityLimit{}, // Upload: Direct upload model (account-level + widget direct uploads) &model.DirectUpload{}, // Channel models — existing + new channels (TikTok, LINE, Twilio SMS) &channelmodel.ChannelTelegram{}, &channelmodel.ChannelWebWidget{}, &channelmodel.ChannelFacebook{}, &channelmodel.ChannelWhatsApp{}, &channelmodel.ChannelEmail{}, &channelmodel.ChannelTikTok{}, &channelmodel.ChannelLINE{}, &channelmodel.ChannelTwilioSMS{}, // W0: Additional channel models not previously registered &channelmodel.ChannelAPI{}, &channelmodel.ChannelGoogle{}, &channelmodel.ChannelMicrosoft{}, &channelmodel.ChannelTwitter{}, // Platform: InstallationConfig — global key-value config for super-admin &model.InstallationConfig{}, // Banner — platform announcement banners &model.Banner{}, // WidgetTest — platform read-only, widget/UI integration testing &model.WidgetTest{}, // W0: Activated stub models — previously .go.txt, now compiled &model.AutomationAction{}, &model.Call{}, &model.CaptainAssistantInbox{}, &model.Contactable{}, &model.ContactExport{}, &model.DataImport{}, &model.EmailTemplate{}, &model.MessageReaction{}, &model.Report{}, &model.BackgroundJob{}, &model.UserSession{}, &model.CaptainMessageReport{}, &model.CaptainSkill{}, &model.CaptainSkillReference{}, &model.CaptainAssistantSkill{}, // S6: WorkingHour — out-of-office / business hours per inbox &model.WorkingHour{}, &model.ChannelShangwutongConfig{}, // Notification settings — per-user per-account notification preferences &model.NotificationSetting{}, } for _, m := range models { if err := db.AutoMigrate(m); err != nil { return fmt.Errorf("failed to auto-migrate %T: %w", m, err) } } return nil }