package main import ( "context" "flag" "fmt" "log/slog" "net/http" "os" "os/signal" "strconv" "syscall" "time" "github.com/gin-gonic/gin" "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/handlers" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services/alerts" "git.mchus.pro/mchus/quoteforge/internal/services/pricelist" "git.mchus.pro/mchus/quoteforge/internal/services/pricing" "git.mchus.pro/mchus/quoteforge/internal/services/sync" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) const ( localDBPath = "./data/settings.db" ) func main() { configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)") migrate := flag.Bool("migrate", false, "run database migrations") flag.Parse() // Initialize local SQLite database (always used) local, err := localdb.New(localDBPath) if err != nil { slog.Error("failed to initialize local database", "error", err) os.Exit(1) } // Check if running in setup mode (no connection settings) if !local.HasSettings() { slog.Info("no database settings found, starting setup mode") runSetupMode(local) return } // Load config for server settings (optional) cfg, err := config.Load(*configPath) if err != nil { // Use defaults if config file doesn't exist slog.Info("config file not found, using defaults", "path", *configPath) cfg = &config.Config{} } setConfigDefaults(cfg) setupLogger(cfg.Logging) // Get DSN from local SQLite dsn, err := local.GetDSN() if err != nil { slog.Error("failed to get database settings", "error", err) os.Exit(1) } // Connect to MariaDB db, err := setupDatabaseFromDSN(dsn) if err != nil { slog.Error("failed to connect to database", "error", err) slog.Info("you may need to reconfigure connection at /setup") os.Exit(1) } dbUser := local.GetDBUser() // Ensure DB user exists in qt_users table (for foreign key constraint) dbUserID, err := models.EnsureDBUser(db, dbUser) if err != nil { slog.Error("failed to ensure DB user exists", "error", err) os.Exit(1) } slog.Info("starting QuoteForge server", "host", cfg.Server.Host, "port", cfg.Server.Port, "db_user", dbUser, "db_user_id", dbUserID, ) if *migrate { slog.Info("running database migrations...") if err := models.Migrate(db); err != nil { slog.Error("migration failed", "error", err) os.Exit(1) } if err := models.SeedCategories(db); err != nil { slog.Error("seeding categories failed", "error", err) os.Exit(1) } slog.Info("migrations completed") } gin.SetMode(cfg.Server.Mode) router, syncService, err := setupRouter(db, cfg, local, dbUserID) if err != nil { slog.Error("failed to setup router", "error", err) os.Exit(1) } // Start background sync worker workerCtx, workerCancel := context.WithCancel(context.Background()) defer workerCancel() syncWorker := sync.NewWorker(syncService, db, 5*time.Minute) go syncWorker.Start(workerCtx) srv := &http.Server{ Addr: cfg.Address(), Handler: router, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, } go func() { slog.Info("server listening", "address", cfg.Address()) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server error", "error", err) os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit slog.Info("shutting down server...") // Stop background sync worker first syncWorker.Stop() workerCancel() // Then shutdown HTTP server ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { slog.Error("server forced to shutdown", "error", err) } slog.Info("server stopped") } func setConfigDefaults(cfg *config.Config) { if cfg.Server.Host == "" { cfg.Server.Host = "0.0.0.0" } if cfg.Server.Port == 0 { cfg.Server.Port = 8080 } if cfg.Server.Mode == "" { cfg.Server.Mode = "release" } if cfg.Server.ReadTimeout == 0 { cfg.Server.ReadTimeout = 30 * time.Second } if cfg.Server.WriteTimeout == 0 { cfg.Server.WriteTimeout = 30 * time.Second } if cfg.Pricing.DefaultMethod == "" { cfg.Pricing.DefaultMethod = "weighted_median" } if cfg.Pricing.DefaultPeriodDays == 0 { cfg.Pricing.DefaultPeriodDays = 90 } if cfg.Pricing.FreshnessGreenDays == 0 { cfg.Pricing.FreshnessGreenDays = 30 } if cfg.Pricing.FreshnessYellowDays == 0 { cfg.Pricing.FreshnessYellowDays = 60 } if cfg.Pricing.FreshnessRedDays == 0 { cfg.Pricing.FreshnessRedDays = 90 } if cfg.Pricing.MinQuotesForMedian == 0 { cfg.Pricing.MinQuotesForMedian = 3 } } // runSetupMode starts a minimal server that only serves the setup page func runSetupMode(local *localdb.LocalDB) { restartSig := make(chan struct{}, 1) setupHandler, err := handlers.NewSetupHandler(local, "web/templates", restartSig) if err != nil { slog.Error("failed to create setup handler", "error", err) os.Exit(1) } gin.SetMode(gin.ReleaseMode) router := gin.New() router.Use(gin.Recovery()) router.Static("/static", "web/static") // Setup routes only router.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/setup") }) router.GET("/setup", setupHandler.ShowSetup) router.POST("/setup", setupHandler.SaveConnection) router.POST("/setup/test", setupHandler.TestConnection) router.GET("/setup/status", setupHandler.GetStatus) // Health check router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "setup_required", "time": time.Now().UTC().Format(time.RFC3339), }) }) addr := ":8080" slog.Info("starting setup mode server", "address", addr) slog.Info("open http://localhost:8080/setup to configure database connection") srv := &http.Server{ Addr: addr, Handler: router, } go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server error", "error", err) os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) select { case <-quit: slog.Info("setup mode server stopped") case <-restartSig: slog.Info("restarting application with saved settings...") // Graceful shutdown ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() srv.Shutdown(ctx) // Restart process with same arguments restartProcess() } } func setupLogger(cfg config.LoggingConfig) { var level slog.Level switch cfg.Level { case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: level = slog.LevelInfo } opts := &slog.HandlerOptions{Level: level} var handler slog.Handler if cfg.Format == "json" { handler = slog.NewJSONHandler(os.Stdout, opts) } else { handler = slog.NewTextHandler(os.Stdout, opts) } slog.SetDefault(slog.New(handler)) } func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) { gormLogger := logger.Default.LogMode(logger.Silent) db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: gormLogger, }) if err != nil { return nil, err } sqlDB, err := db.DB() if err != nil { return nil, err } sqlDB.SetMaxOpenConns(25) sqlDB.SetMaxIdleConns(5) sqlDB.SetConnMaxLifetime(5 * time.Minute) return db, nil } func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, *sync.Service, error) { // Repositories componentRepo := repository.NewComponentRepository(db) categoryRepo := repository.NewCategoryRepository(db) priceRepo := repository.NewPriceRepository(db) alertRepo := repository.NewAlertRepository(db) statsRepo := repository.NewStatsRepository(db) pricelistRepo := repository.NewPricelistRepository(db) configRepo := repository.NewConfigurationRepository(db) // Services pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing) componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo) quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService) exportService := services.NewExportService(cfg.Export, categoryRepo) alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo) syncService := sync.NewService(pricelistRepo, configRepo, local) // isOnline function for local-first architecture isOnline := func() bool { sqlDB, err := db.DB() if err != nil { return false } return sqlDB.Ping() == nil } // Local-first configuration service (replaces old ConfigurationService) configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline) // Handlers componentHandler := handlers.NewComponentHandler(componentService) quoteHandler := handlers.NewQuoteHandler(quoteService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService) pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo) pricelistHandler := handlers.NewPricelistHandler(pricelistService, local) syncHandler, err := handlers.NewSyncHandler(local, syncService, db, "web/templates") if err != nil { return nil, nil, fmt.Errorf("creating sync handler: %w", err) } // Setup handler (for reconfiguration) - no restart signal in normal mode setupHandler, err := handlers.NewSetupHandler(local, "web/templates", nil) if err != nil { return nil, nil, fmt.Errorf("creating setup handler: %w", err) } // Web handler (templates) webHandler, err := handlers.NewWebHandler("web/templates", componentService) if err != nil { return nil, nil, err } // Router router := gin.New() router.Use(gin.Recovery()) router.Use(requestLogger()) router.Use(middleware.CORS()) router.Use(middleware.OfflineDetector(db, local)) // Static files router.Static("/static", "web/static") // Health check router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "time": time.Now().UTC().Format(time.RFC3339), }) }) // DB status endpoint router.GET("/api/db-status", func(c *gin.Context) { var lotCount, lotLogCount, metadataCount int64 var dbOK bool = true var dbError string sqlDB, err := db.DB() if err != nil { dbOK = false dbError = err.Error() } else if err := sqlDB.Ping(); err != nil { dbOK = false dbError = err.Error() } db.Table("lot").Count(&lotCount) db.Table("lot_log").Count(&lotLogCount) db.Table("qt_lot_metadata").Count(&metadataCount) c.JSON(http.StatusOK, gin.H{ "connected": dbOK, "error": dbError, "lot_count": lotCount, "lot_log_count": lotLogCount, "metadata_count": metadataCount, "db_user": local.GetDBUser(), }) }) // Current user info (DB user, not app user) router.GET("/api/current-user", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "username": local.GetDBUser(), "role": "db_user", }) }) // Setup routes (for reconfiguration) router.GET("/setup", setupHandler.ShowSetup) router.POST("/setup", setupHandler.SaveConnection) router.POST("/setup/test", setupHandler.TestConnection) router.GET("/setup/status", setupHandler.GetStatus) // Web pages router.GET("/", webHandler.Index) router.GET("/configs", webHandler.Configs) router.GET("/configurator", webHandler.Configurator) router.GET("/pricelists", func(c *gin.Context) { // Redirect to admin/pricing with pricelists tab c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists") }) router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/admin/pricing", webHandler.AdminPricing) // htmx partials partials := router.Group("/partials") { partials.GET("/components", webHandler.ComponentsPartial) partials.GET("/sync-status", syncHandler.SyncStatusPartial) } // API routes api := router.Group("/api") { api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong"}) }) // Components (public read) components := api.Group("/components") { components.GET("", componentHandler.List) components.GET("/:lot_name", componentHandler.Get) } // Categories (public) api.GET("/categories", componentHandler.GetCategories) // Quote (public) quote := api.Group("/quote") { quote.POST("/validate", quoteHandler.Validate) quote.POST("/calculate", quoteHandler.Calculate) } // Export (public) export := api.Group("/export") { export.POST("/csv", exportHandler.ExportCSV) } // Pricelists (public - RBAC disabled in Phase 1-3) pricelists := api.Group("/pricelists") { pricelists.GET("", pricelistHandler.List) pricelists.GET("/can-write", pricelistHandler.CanWrite) pricelists.GET("/latest", pricelistHandler.GetLatest) pricelists.GET("/:id", pricelistHandler.Get) pricelists.GET("/:id/items", pricelistHandler.GetItems) pricelists.POST("", pricelistHandler.Create) pricelists.DELETE("/:id", pricelistHandler.Delete) } // Configurations (public - RBAC disabled) configs := api.Group("/configs") { configs.GET("", func(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) cfgs, total, err := configService.ListAll(page, perPage) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "configurations": cfgs, "total": total, "page": page, "per_page": perPage, }) }) configs.POST("", func(c *gin.Context) { var req services.CreateConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } config, err := configService.Create(dbUserID, &req) // use DB user ID if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, config) }) configs.GET("/:uuid", func(c *gin.Context) { uuid := c.Param("uuid") config, err := configService.GetByUUIDNoAuth(uuid) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) return } c.JSON(http.StatusOK, config) }) configs.PUT("/:uuid", func(c *gin.Context) { uuid := c.Param("uuid") var req services.CreateConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } config, err := configService.UpdateNoAuth(uuid, &req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, config) }) configs.DELETE("/:uuid", func(c *gin.Context) { uuid := c.Param("uuid") if err := configService.DeleteNoAuth(uuid); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "deleted"}) }) configs.PATCH("/:uuid/rename", func(c *gin.Context) { uuid := c.Param("uuid") var req struct { Name string `json:"name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } config, err := configService.RenameNoAuth(uuid, req.Name) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, config) }) configs.POST("/:uuid/clone", func(c *gin.Context) { uuid := c.Param("uuid") var req struct { Name string `json:"name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, config) }) configs.POST("/:uuid/refresh-prices", func(c *gin.Context) { uuid := c.Param("uuid") config, err := configService.RefreshPricesNoAuth(uuid) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, config) }) } // Pricing admin (public - RBAC disabled) pricingAdmin := api.Group("/admin/pricing") { pricingAdmin.GET("/stats", pricingHandler.GetStats) pricingAdmin.GET("/components", pricingHandler.ListComponents) pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing) pricingAdmin.POST("/update", pricingHandler.UpdatePrice) pricingAdmin.POST("/preview", pricingHandler.PreviewPrice) pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll) pricingAdmin.GET("/alerts", pricingHandler.ListAlerts) pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert) pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert) pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert) } // Sync API (for offline mode) syncAPI := api.Group("/sync") { syncAPI.GET("/status", syncHandler.GetStatus) syncAPI.POST("/components", syncHandler.SyncComponents) syncAPI.POST("/pricelists", syncHandler.SyncPricelists) syncAPI.POST("/all", syncHandler.SyncAll) syncAPI.POST("/push", syncHandler.PushPendingChanges) syncAPI.GET("/pending/count", syncHandler.GetPendingCount) syncAPI.GET("/pending", syncHandler.GetPendingChanges) } } return router, syncService, nil } // restartProcess restarts the current process with the same arguments func restartProcess() { executable, err := os.Executable() if err != nil { slog.Error("failed to get executable path", "error", err) os.Exit(1) } args := os.Args env := os.Environ() slog.Info("executing restart", "executable", executable, "args", args) err = syscall.Exec(executable, args, env) if err != nil { slog.Error("failed to restart process", "error", err) os.Exit(1) } } func requestLogger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path query := c.Request.URL.RawQuery c.Next() latency := time.Since(start) status := c.Writer.Status() slog.Info("request", "method", c.Request.Method, "path", path, "query", query, "status", status, "latency", latency, "ip", c.ClientIP(), ) } }