package main import ( "context" "errors" "flag" "fmt" "io/fs" "log/slog" "net/http" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strconv" "syscall" "time" qfassets "git.mchus.pro/mchus/quoteforge" "git.mchus.pro/mchus/quoteforge/internal/appstate" "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/db" "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" "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" func main() { configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)") localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_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("qfs version %s\n", Version) os.Exit(0) } exePath, _ := os.Executable() slog.Info("starting qfs", "version", Version, "executable", exePath) resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath) if err != nil { slog.Error("failed to resolve config path", "error", err) os.Exit(1) } resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath) if err != nil { slog.Error("failed to resolve local database 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) } } // Migrate legacy project-local DB path to the user state directory when using defaults. if *localDBPath == "" && os.Getenv("QFS_DB_PATH") == "" { legacyPaths := []string{ filepath.Join("data", "settings.db"), filepath.Join("data", "qfs.db"), } migratedFrom, migrateErr := appstate.MigrateLegacyDB(resolvedLocalDBPath, legacyPaths) if migrateErr != nil { slog.Warn("failed to migrate legacy local database", "error", migrateErr) } else if migratedFrom != "" { slog.Info("migrated legacy local database", "from", migratedFrom, "to", resolvedLocalDBPath) } } // Initialize local SQLite database (always used) local, err := localdb.New(resolvedLocalDBPath) 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(resolvedConfigPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { // Use defaults if config file doesn't exist slog.Info("config file not found, using defaults", "path", resolvedConfigPath) cfg = &config.Config{} } else { slog.Error("failed to load config", "path", resolvedConfigPath, "error", err) os.Exit(1) } } setConfigDefaults(cfg) slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath) setupLogger(cfg.Logging) // Create connection manager and try to connect immediately if settings exist connMgr := db.NewConnectionManager(local) dbUser := local.GetDBUser() // Try to connect to MariaDB on startup mariaDB, err := connMgr.GetDB() if err != nil { slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err) mariaDB = nil } else { slog.Info("successfully connected to MariaDB on startup") } slog.Info("starting QuoteForge server", "version", Version, "host", cfg.Server.Host, "port", cfg.Server.Port, "db_user", dbUser, "online", mariaDB != nil, ) if *migrate { if mariaDB == nil { slog.Error("cannot run migrations: database not available") os.Exit(1) } 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") } gin.SetMode(cfg.Server.Mode) router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser) if err != nil { slog.Error("failed to setup router", "error", err) os.Exit(1) } // Start background sync worker (will auto-skip when offline) workerCtx, workerCancel := context.WithCancel(context.Background()) defer workerCancel() syncWorker := sync.NewWorker(syncService, connMgr, 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) } }() // 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...") // 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 = "127.0.0.1" } 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) // In setup mode, we don't have a connection manager yet (will restart after setup) templatesPath := filepath.Join("web", "templates") setupHandler, err := handlers.NewSetupHandler(local, nil, templatesPath, 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()) 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)) } // 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 := "127.0.0.1:8080" slog.Info("starting setup mode server", "address", addr, "version", Version) 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) } }() // Open browser to setup page go func() { time.Sleep(1 * time.Second) browserURL := "http://127.0.0.1:8080/setup" slog.Info("Opening browser to setup page", "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) 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(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string) (*gin.Engine, *sync.Service, error) { // mariaDB may be nil if we're in offline mode // Repositories var componentRepo *repository.ComponentRepository var categoryRepo *repository.CategoryRepository var priceRepo *repository.PriceRepository var alertRepo *repository.AlertRepository var statsRepo *repository.StatsRepository var pricelistRepo *repository.PricelistRepository // Only initialize repositories if we have a database connection 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) } else { // In offline mode, we'll use nil repositories or handle them differently // This is handled in the sync service and other components } // Services var pricingService *pricing.Service var componentService *services.ComponentService var quoteService *services.QuoteService var exportService *services.ExportService var alertService *alerts.Service var pricelistService *pricelist.Service var syncService *sync.Service // Sync service always uses ConnectionManager (works offline and online) syncService = sync.NewService(connMgr, local) if mariaDB != nil { 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(mariaDB, pricelistRepo, componentRepo) } else { // In offline mode, we still need to create services that don't require DB pricingService = pricing.NewService(nil, nil, cfg.Pricing) componentService = services.NewComponentService(nil, nil, nil) quoteService = services.NewQuoteService(nil, nil, pricingService) exportService = services.NewExportService(cfg.Export, nil) alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing) pricelistService = pricelist.NewService(nil, nil, nil) } // isOnline function for local-first architecture isOnline := func() bool { return connMgr.IsOnline() } // Local-first configuration service (replaces old ConfigurationService) configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline) // Use filepath.Join for cross-platform path compatibility templatesPath := filepath.Join("web", "templates") // Handlers componentHandler := handlers.NewComponentHandler(componentService, local) quoteHandler := handlers.NewQuoteHandler(quoteService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService) pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo) pricelistHandler := handlers.NewPricelistHandler(pricelistService, local) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath) 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, connMgr, templatesPath, nil) if err != nil { return nil, nil, fmt.Errorf("creating setup handler: %w", err) } // Web handler (templates) webHandler, err := handlers.NewWebHandler(templatesPath, 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(connMgr, local)) // Static files (use filepath.Join for Windows compatibility) 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)) } // Health check router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "time": time.Now().UTC().Format(time.RFC3339), }) }) // Restart endpoint (for development purposes) router.POST("/api/restart", func(c *gin.Context) { // This will cause the server to restart by exiting // The restartProcess function will be called to restart the process slog.Info("Restart requested via API") go func() { time.Sleep(100 * time.Millisecond) restartProcess() }() c.JSON(http.StatusOK, gin.H{"message": "restarting..."}) }) // DB status endpoint router.GET("/api/db-status", func(c *gin.Context) { var lotCount, lotLogCount, metadataCount int64 var dbOK bool = false var dbError string // Check if connection exists (fast check, no reconnect attempt) status := connMgr.GetStatus() if status.IsConnected { // Already connected, safe to use if db, err := connMgr.GetDB(); err == nil && db != nil { dbOK = true db.Table("lot").Count(&lotCount) db.Table("lot_log").Count(&lotLogCount) db.Table("qt_lot_metadata").Count(&metadataCount) } } else { // Not connected - don't try to reconnect on status check // This prevents 3s timeout on every request dbError = "Database not connected (offline mode)" if status.LastError != "" { dbError = status.LastError } } 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")) status := c.DefaultQuery("status", "active") if status != "active" && status != "archived" && status != "all" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) return } cfgs, total, err := configService.ListAllWithStatus(page, perPage, status) 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, "status": status, }) }) configs.POST("/import", func(c *gin.Context) { result, err := configService.ImportFromServer() if err != nil { if errors.Is(err, sync.ErrOffline) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, result) }) 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(dbUsername, &req) 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": "archived"}) }) configs.POST("/:uuid/reactivate", func(c *gin.Context) { uuid := c.Param("uuid") config, err := configService.ReactivateNoAuth(uuid) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "message": "reactivated", "config": config, }) }) 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, dbUsername) 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) }) configs.GET("/:uuid/versions", func(c *gin.Context) { uuid := c.Param("uuid") limit, err := strconv.Atoi(c.DefaultQuery("limit", "20")) if err != nil || limit <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"}) return } offset, err := strconv.Atoi(c.DefaultQuery("offset", "0")) if err != nil || offset < 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"}) return } versions, err := configService.ListVersions(uuid, limit, offset) if err != nil { switch { case errors.Is(err, services.ErrConfigNotFound): c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) case errors.Is(err, services.ErrInvalidVersionNumber): c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusOK, gin.H{ "versions": versions, "limit": limit, "offset": offset, }) }) configs.GET("/:uuid/versions/:version", func(c *gin.Context) { uuid := c.Param("uuid") versionNo, err := strconv.Atoi(c.Param("version")) if err != nil || versionNo <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"}) return } version, err := configService.GetVersion(uuid, versionNo) if err != nil { switch { case errors.Is(err, services.ErrInvalidVersionNumber): c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"}) case errors.Is(err, services.ErrConfigVersionNotFound): c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusOK, version) }) configs.POST("/:uuid/rollback", func(c *gin.Context) { uuid := c.Param("uuid") var req struct { TargetVersion int `json:"target_version"` Note string `json:"note"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.TargetVersion <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"}) return } config, err := configService.RollbackToVersionWithNote(uuid, req.TargetVersion, dbUsername, req.Note) if err != nil { switch { case errors.Is(err, services.ErrInvalidVersionNumber): c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"}) case errors.Is(err, services.ErrConfigNotFound), errors.Is(err, services.ErrConfigVersionNotFound): c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) case errors.Is(err, services.ErrVersionConflict): c.JSON(http.StatusConflict, gin.H{"error": "version conflict"}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } currentVersion, err := configService.GetCurrentVersion(uuid) if err != nil { c.JSON(http.StatusOK, gin.H{ "message": "rollback applied", "config": config, }) return } c.JSON(http.StatusOK, gin.H{ "message": "rollback applied", "config": config, "current_version": currentVersion, }) }) } // 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.GET("/info", syncHandler.GetInfo) 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 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(), ) } }