package main import ( "context" "flag" "fmt" "log/slog" "net" "net/http" "os" "os/exec" "os/signal" "path/filepath" "runtime" "syscall" "time" qfassets "git.mchus.pro/mchus/priceforge" "git.mchus.pro/mchus/priceforge/internal/appmeta" "git.mchus.pro/mchus/priceforge/internal/appstate" "git.mchus.pro/mchus/priceforge/internal/config" "git.mchus.pro/mchus/priceforge/internal/db" "git.mchus.pro/mchus/priceforge/internal/handlers" "git.mchus.pro/mchus/priceforge/internal/middleware" "git.mchus.pro/mchus/priceforge/internal/models" "git.mchus.pro/mchus/priceforge/internal/repository" "git.mchus.pro/mchus/priceforge/internal/services" "git.mchus.pro/mchus/priceforge/internal/services/alerts" "git.mchus.pro/mchus/priceforge/internal/services/pricelist" "git.mchus.pro/mchus/priceforge/internal/services/pricing" "github.com/gin-gonic/gin" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) // Version is set via ldflags during build var Version = "dev" const backgroundSyncInterval = 5 * time.Minute func main() { configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)") migrate := flag.Bool("migrate", false, "run database migrations") version := flag.Bool("version", false, "show version information") flag.Parse() // Show version if requested if *version { fmt.Printf("pfs version %s\n", Version) os.Exit(0) } exePath, _ := os.Executable() slog.Info("starting pfs", "version", Version, "executable", exePath) appmeta.SetVersion(Version) resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath) if err != nil { slog.Error("failed to resolve config path", "error", err) os.Exit(1) } // Migrate legacy project-local config path to the user state directory when using defaults. if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" { migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"}) if migrateErr != nil { slog.Warn("failed to migrate legacy config file", "error", migrateErr) } else if migratedFrom != "" { slog.Info("migrated legacy config file", "from", migratedFrom, "to", resolvedConfigPath) } } // Load config for server settings cfg, err := config.Load(resolvedConfigPath) if err != nil { slog.Error("failed to load config", "path", resolvedConfigPath, "error", err) os.Exit(1) } setConfigDefaults(cfg) slog.Info("resolved runtime files", "config_path", resolvedConfigPath) setupLogger(cfg.Logging) dsn := cfg.Database.DSN() dsnHost := net.JoinHostPort(cfg.Database.Host, fmt.Sprintf("%d", cfg.Database.Port)) connMgr := db.NewConnectionManager(dsn, dsnHost) dbUser := cfg.Database.User // Fail-fast mode: MariaDB must be available on startup. mariaDB, err := connMgr.GetDB() if err != nil { slog.Error("failed to connect to MariaDB on startup", "error", err) os.Exit(1) } slog.Info("successfully connected to MariaDB on startup") slog.Info("starting PriceForge server", "version", Version, "host", cfg.Server.Host, "port", cfg.Server.Port, "db_user", dbUser, "online", true, ) if *migrate { slog.Info("running database migrations...") if err := models.Migrate(mariaDB); err != nil { slog.Error("migration failed", "error", err) os.Exit(1) } if err := models.SeedCategories(mariaDB); err != nil { slog.Error("seeding categories failed", "error", err) os.Exit(1) } slog.Info("migrations completed") } // Always apply SQL migrations on startup when database is available. // This keeps schema in sync for long-running installations without manual steps. // If current DB user does not have enough privileges, continue startup in normal mode. sqlMigrationsPath := filepath.Join("migrations") needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath) if err != nil { if models.IsMigrationPermissionError(err) { slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err) } else if needsMigrations { slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err) os.Exit(1) } } else if needsMigrations { if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil { if models.IsMigrationPermissionError(err) { slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err) } else { slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err) os.Exit(1) } } else { slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath) } } else { slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath) } gin.SetMode(cfg.Server.Mode) router, err := setupRouter(cfg, connMgr, mariaDB, dbUser) if err != nil { slog.Error("failed to setup router", "error", err) os.Exit(1) } 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) } }() // Automatically open browser after server starts (with a small delay) go func() { time.Sleep(1 * time.Second) // Always use localhost for browser, even if server binds to 0.0.0.0 browserURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port) slog.Info("Opening browser to", "url", browserURL) err := openBrowser(browserURL) if err != nil { slog.Warn("Failed to open browser", "error", err) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit slog.Info("shutting down server...") // 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 = "127.0.0.1" } if cfg.Server.Port == 0 { cfg.Server.Port = 8084 } 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 } } 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(cfg *config.Config, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUser string) (*gin.Engine, error) { var componentRepo *repository.ComponentRepository var categoryRepo *repository.CategoryRepository var priceRepo *repository.PriceRepository var alertRepo *repository.AlertRepository var statsRepo *repository.StatsRepository var pricelistRepo *repository.PricelistRepository if mariaDB != nil { componentRepo = repository.NewComponentRepository(mariaDB) categoryRepo = repository.NewCategoryRepository(mariaDB) priceRepo = repository.NewPriceRepository(mariaDB) alertRepo = repository.NewAlertRepository(mariaDB) statsRepo = repository.NewStatsRepository(mariaDB) pricelistRepo = repository.NewPricelistRepository(mariaDB) } pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing) componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo) alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) pricelistService := pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService) stockImportService := services.NewStockImportService(mariaDB, pricelistService) templatesPath := filepath.Join("web", "templates") componentHandler := handlers.NewComponentHandler(componentService) pricingHandler := handlers.NewPricingHandler( mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo, stockImportService, dbUser, ) pricelistHandler := handlers.NewPricelistHandler(pricelistService, dbUser) webHandler, err := handlers.NewWebHandler(templatesPath, componentService) if err != nil { return nil, err } router := gin.New() router.Use(gin.Recovery()) router.Use(requestLogger()) router.Use(middleware.CORS()) router.Use(middleware.OfflineDetector(connMgr)) staticPath := filepath.Join("web", "static") if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() { router.Static("/static", staticPath) } else if staticFS, err := qfassets.StaticFS(); err == nil { router.StaticFS("/static", http.FS(staticFS)) } router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "time": time.Now().UTC().Format(time.RFC3339), }) }) router.POST("/api/restart", func(c *gin.Context) { slog.Info("Restart requested via API") go func() { time.Sleep(100 * time.Millisecond) restartProcess() }() c.JSON(http.StatusOK, gin.H{"message": "restarting..."}) }) router.GET("/api/db-status", func(c *gin.Context) { var lotCount, lotLogCount, metadataCount int64 var dbOK bool var dbError string includeCounts := c.Query("include_counts") == "true" status := connMgr.GetStatus() dbOK = status.IsConnected if !status.IsConnected { dbError = "Database not connected (offline mode)" if status.LastError != "" { dbError = status.LastError } } if includeCounts && status.IsConnected { if db, err := connMgr.GetDB(); err == nil && db != nil { _ = db.Table("lot").Count(&lotCount) _ = db.Table("lot_log").Count(&lotLogCount) _ = db.Table("qt_lot_metadata").Count(&metadataCount) } else if err != nil { dbOK = false dbError = err.Error() } else { dbOK = false dbError = "Database not connected (offline mode)" } } c.JSON(http.StatusOK, gin.H{ "connected": dbOK, "error": dbError, "lot_count": lotCount, "lot_log_count": lotLogCount, "metadata_count": metadataCount, "db_user": dbUser, "db_host": status.DSNHost, }) }) router.GET("/api/current-user", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "username": dbUser, "role": "db_user", }) }) router.GET("/", webHandler.Index) router.GET("/pricelists", webHandler.Pricelists) router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/admin/pricing", webHandler.AdminPricing) partials := router.Group("/partials") { partials.GET("/components", webHandler.ComponentsPartial) } api := router.Group("/api") { api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong"}) }) components := api.Group("/components") { components.GET("", componentHandler.List) components.GET("/:lot_name", componentHandler.Get) } api.GET("/categories", componentHandler.GetCategories) 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.GET("/:id/lots", pricelistHandler.GetLotNames) pricelists.POST("", pricelistHandler.Create) pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress) pricelists.PATCH("/:id/active", pricelistHandler.SetActive) pricelists.DELETE("/:id", pricelistHandler.Delete) } 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("/lots", pricingHandler.ListLots) pricingAdmin.GET("/lots-table", pricingHandler.ListLotsTable) pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog) pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings) pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping) pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping) pricingAdmin.GET("/stock/ignore-rules", pricingHandler.ListStockIgnoreRules) pricingAdmin.POST("/stock/ignore-rules", pricingHandler.UpsertStockIgnoreRule) pricingAdmin.DELETE("/stock/ignore-rules/:id", pricingHandler.DeleteStockIgnoreRule) 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) } } return router, 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 openBrowser(url string) error { var cmd string var args []string switch runtime.GOOS { case "windows": cmd = "cmd" args = []string{"/c", "start", url} case "darwin": cmd = "open" args = []string{url} default: // "linux", "freebsd", "openbsd", "netbsd" cmd = "xdg-open" args = []string{url} } return exec.Command(cmd, args...).Start() } 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(), ) } }