package main import ( "bytes" "context" "errors" "flag" "fmt" "io" "io/fs" "log/slog" "math" "net/http" "os" "os/exec" "os/signal" "path/filepath" "runtime" "sort" "strconv" "strings" syncpkg "sync" "syscall" "time" qfassets "git.mchus.pro/mchus/quoteforge" "git.mchus.pro/mchus/quoteforge/internal/appmeta" "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/services" "git.mchus.pro/mchus/quoteforge/internal/services/sync" "github.com/gin-gonic/gin" "gopkg.in/yaml.v3" "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 const onDemandPullCooldown = 30 * time.Second const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать" func main() { showStartupConsoleWarning() 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)") resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)") 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) appmeta.SetVersion(Version) resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath) if err != nil { slog.Error("failed to resolve local database path", "error", err) os.Exit(1) } resolvedConfigPath, err := appstate.ResolveConfigPathNearDB(*configPath, resolvedLocalDBPath) 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) } } // 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) } } if shouldResetLocalDB(*resetLocalDB) { if err := localdb.ResetData(resolvedLocalDBPath); err != nil { slog.Error("failed to reset local database", "error", err) os.Exit(1) } } // 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) if err := ensureDefaultConfigFile(resolvedConfigPath); err != nil { slog.Error("failed to ensure default config file", "path", resolvedConfigPath, "error", err) os.Exit(1) } 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) if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil { slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err) os.Exit(1) } slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath) setupLogger(cfg.Logging) // Create connection manager. Runtime stays local-first; MariaDB is used on demand by sync/setup only. connMgr := db.NewConnectionManager(local) dbUser := local.GetDBUser() slog.Info("starting QuoteForge server", "version", Version, "host", cfg.Server.Host, "port", cfg.Server.Port, "db_user", dbUser, "online", false, ) if *migrate { mariaDB, err := connMgr.GetDB() if err != nil { slog.Error("cannot run migrations: database not available", "error", err) os.Exit(1) } 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) restartSig := make(chan struct{}, 1) router, syncService, err := setupRouter(cfg, local, connMgr, dbUser, restartSig) if err != nil { slog.Error("failed to setup router", "error", err) os.Exit(1) } if readiness, readinessErr := syncService.GetReadiness(); readinessErr != nil { slog.Warn("sync readiness check failed on startup", "error", readinessErr) } else if readiness != nil && readiness.Blocked { slog.Warn("sync readiness blocked on startup", "reason_code", readiness.ReasonCode, "reason_text", readiness.ReasonText, ) } // Start background sync worker (will auto-skip when offline) workerCtx, workerCancel := context.WithCancel(context.Background()) defer workerCancel() syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval) go syncWorker.Start(workerCtx) backupCtx, backupCancel := context.WithCancel(context.Background()) defer backupCancel() go startBackupScheduler(backupCtx, cfg, resolvedLocalDBPath, resolvedConfigPath) 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) shouldRestart := false select { case <-quit: slog.Info("shutting down server...") case <-restartSig: shouldRestart = true slog.Info("restarting application after connection settings update...") } // Stop background sync worker first syncWorker.Stop() workerCancel() backupCancel() // 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") if shouldRestart { restartProcess() } } func showStartupConsoleWarning() { // Visible in console output. fmt.Println(startupConsoleWarning) // Keep the warning always visible in the console window title when supported. fmt.Printf("\033]0;%s\007", startupConsoleWarning) } func shouldResetLocalDB(flagValue bool) bool { if flagValue { return true } value := strings.TrimSpace(os.Getenv("QFS_RESET_LOCAL_DB")) if value == "" { return false } switch strings.ToLower(value) { case "1", "true", "yes", "y": return true default: return false } } func derefString(value *string) string { if value == nil { return "" } return *value } 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 } if cfg.Backup.Time == "" { cfg.Backup.Time = "00:00" } } func ensureDefaultConfigFile(configPath string) error { if strings.TrimSpace(configPath) == "" { return fmt.Errorf("config path is empty") } if _, err := os.Stat(configPath); err == nil { return nil } else if !errors.Is(err, os.ErrNotExist) { return err } if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { return err } const defaultConfigYAML = `server: host: "127.0.0.1" port: 8080 mode: "release" read_timeout: 30s write_timeout: 30s backup: time: "00:00" logging: level: "info" format: "json" output: "stdout" ` if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0644); err != nil { return err } slog.Info("created default config file", "path", configPath) return nil } type runtimeServerConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` Mode string `yaml:"mode"` ReadTimeout time.Duration `yaml:"read_timeout"` WriteTimeout time.Duration `yaml:"write_timeout"` } type runtimeLoggingConfig struct { Level string `yaml:"level"` Format string `yaml:"format"` Output string `yaml:"output"` } type runtimeBackupConfig struct { Time string `yaml:"time"` } type runtimeConfigFile struct { Server runtimeServerConfig `yaml:"server"` Logging runtimeLoggingConfig `yaml:"logging"` Backup runtimeBackupConfig `yaml:"backup"` } // migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format. // Deprecated sections from legacy configs are intentionally dropped. func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) error { if cfg == nil { return fmt.Errorf("config is nil") } runtimeCfg := runtimeConfigFile{ Server: runtimeServerConfig{ Host: cfg.Server.Host, Port: cfg.Server.Port, Mode: cfg.Server.Mode, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, }, Logging: runtimeLoggingConfig{ Level: cfg.Logging.Level, Format: cfg.Logging.Format, Output: cfg.Logging.Output, }, Backup: runtimeBackupConfig{ Time: cfg.Backup.Time, }, } rendered, err := yaml.Marshal(&runtimeCfg) if err != nil { return fmt.Errorf("marshal runtime config: %w", err) } current, err := os.ReadFile(configPath) if err == nil && bytes.Equal(bytes.TrimSpace(current), bytes.TrimSpace(rendered)) { return nil } if err := os.WriteFile(configPath, rendered, 0644); err != nil { return fmt.Errorf("write runtime config: %w", err) } slog.Info("migrated config.yaml to runtime format", "path", configPath) return nil } func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) { if cfg == nil { return } hour, minute, err := parseBackupTime(cfg.Backup.Time) if err != nil { slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err) hour = 0 minute = 0 } if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil { slog.Error("local backup failed", "error", backupErr) } else if len(created) > 0 { for _, path := range created { slog.Info("local backup completed", "archive", path) } } for { next := nextBackupTime(time.Now(), hour, minute) timer := time.NewTimer(time.Until(next)) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: start := time.Now() created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath) duration := time.Since(start) if backupErr != nil { slog.Error("local backup failed", "error", backupErr, "duration", duration) } else { for _, path := range created { slog.Info("local backup completed", "archive", path, "duration", duration) } } } } } func parseBackupTime(value string) (int, int, error) { if strings.TrimSpace(value) == "" { return 0, 0, fmt.Errorf("empty backup time") } parsed, err := time.Parse("15:04", value) if err != nil { return 0, 0, err } return parsed.Hour(), parsed.Minute(), nil } func nextBackupTime(now time.Time, hour, minute int) time.Time { location := now.Location() target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location) if !now.Before(target) { target = target.Add(24 * time.Hour) } return target } // 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()) if staticFS, err := qfassets.StaticFS(); err == nil { router.StaticFS("/static", http.FS(staticFS)) } else { slog.Error("failed to load embedded static assets", "error", err) os.Exit(1) } // 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, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) { var syncService *sync.Service var projectService *services.ProjectService syncService = sync.NewService(connMgr, local) componentService := services.NewComponentService(nil, nil, nil) quoteService := services.NewQuoteService(nil, nil, nil, local, nil) exportService := services.NewExportService(cfg.Export, nil, local) // isOnline function for local-first architecture isOnline := func() bool { return connMgr.IsOnline() } // Local-first configuration service (replaces old ConfigurationService) projectService = services.NewProjectService(local) configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline) // Data hygiene: remove empty nameless projects and ensure every configuration is attached to a project. if removed, err := local.ConsolidateSystemProjects(); err == nil && removed > 0 { slog.Info("consolidated duplicate local system projects", "removed", removed) } if removed, err := local.PurgeEmptyNamelessProjects(); err == nil && removed > 0 { slog.Info("purged empty nameless local projects", "removed", removed) } if err := local.BackfillConfigurationProjects(dbUsername); err != nil { slog.Warn("failed to backfill local configuration projects", "error", err) } type pullState struct { mu syncpkg.Mutex running bool lastStarted time.Time } triggerPull := func(label string, state *pullState, pullFn func() error) { state.mu.Lock() if state.running { state.mu.Unlock() return } if !state.lastStarted.IsZero() && time.Since(state.lastStarted) < onDemandPullCooldown { state.mu.Unlock() return } state.running = true state.lastStarted = time.Now() state.mu.Unlock() go func() { defer func() { state.mu.Lock() state.running = false state.mu.Unlock() }() if err := pullFn(); err != nil { slog.Warn("on-demand pull failed", "scope", label, "error", err) } }() } var projectsPullState pullState var configsPullState pullState syncProjectsFromServer := func() error { if !connMgr.IsOnline() { return nil } if readiness, err := syncService.EnsureReadinessForSync(); err != nil { slog.Warn("skipping project pull: sync readiness blocked", "error", err, "reason_code", readiness.ReasonCode, "reason_text", readiness.ReasonText, ) return nil } if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) { return err } return nil } syncConfigurationsFromServer := func() error { if !connMgr.IsOnline() { return nil } if readiness, err := syncService.EnsureReadinessForSync(); err != nil { slog.Warn("skipping configuration pull: sync readiness blocked", "error", err, "reason_code", readiness.ReasonCode, "reason_text", readiness.ReasonText, ) return nil } _, err := configService.ImportFromServer() if err != nil && !errors.Is(err, sync.ErrOffline) { return err } return nil } // 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, projectService, dbUsername) pricelistHandler := handlers.NewPricelistHandler(local) vendorSpecHandler := handlers.NewVendorSpecHandler(local) partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) if err != nil { return nil, nil, fmt.Errorf("creating sync handler: %w", err) } // Setup handler (for reconfiguration) setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig) if err != nil { return nil, nil, fmt.Errorf("creating setup handler: %w", err) } // Web handler (templates) webHandler, err := handlers.NewWebHandler(templatesPath, local) 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) if staticFS, err := qfassets.StaticFS(); err == nil { router.StaticFS("/static", http.FS(staticFS)) } else { return nil, nil, fmt.Errorf("load embedded static assets: %w", err) } // 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 var dbError string includeCounts := c.Query("include_counts") == "true" // Fast status path: do not execute heavy COUNT queries unless requested. status := connMgr.GetStatus() dbOK = status.IsConnected if !status.IsConnected { dbError = "Database not connected (offline mode)" if status.LastError != "" { dbError = status.LastError } } // Runtime diagnostics stay local-only. Server table counts are intentionally unavailable here. if !includeCounts || !status.IsConnected { lotCount = 0 lotLogCount = 0 metadataCount = 0 } 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 (local DB username) router.GET("/api/current-user", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "username": local.GetDBUser(), }) }) // 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("/projects", webHandler.Projects) router.GET("/projects/:uuid", webHandler.ProjectDetail) router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions) router.GET("/pricelists", webHandler.Pricelists) router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/partnumber-books", webHandler.PartnumberBooks) // 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) quote.POST("/price-levels", quoteHandler.PriceLevels) } // 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("/latest", pricelistHandler.GetLatest) pricelists.GET("/:id", pricelistHandler.Get) pricelists.GET("/:id/items", pricelistHandler.GetItems) pricelists.GET("/:id/lots", pricelistHandler.GetLotNames) } // Partnumber books (read-only) pnBooks := api.Group("/partnumber-books") { pnBooks.GET("", partnumberBooksHandler.List) pnBooks.GET("/:id", partnumberBooksHandler.GetItems) } // Configurations (public - RBAC disabled) configs := api.Group("/configs") { configs.GET("", func(c *gin.Context) { triggerPull("configs", &configsPullState, syncConfigurationsFromServer) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) status := c.DefaultQuery("status", "active") search := c.Query("search") 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, search) 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, "search": search, }) }) 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.POST("/preview-article", func(c *gin.Context) { var req services.ArticlePreviewRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } result, err := configService.BuildArticlePreview(&req) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "article": result.Article, "warnings": result.Warnings, }) }) 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 { switch { case errors.Is(err, services.ErrConfigNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) default: 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"` FromVersion int `json:"from_version"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } config, err := configService.CloneNoAuthToProjectFromVersion(uuid, req.Name, dbUsername, nil, req.FromVersion) if err != nil { if errors.Is(err, services.ErrConfigVersionNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) return } 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.PATCH("/:uuid/project", func(c *gin.Context) { uuid := c.Param("uuid") var req struct { ProjectUUID string `json:"project_uuid"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID) if err != nil { switch { case errors.Is(err, services.ErrConfigNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusOK, updated) }) 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, }) }) configs.GET("/:uuid/export", exportHandler.ExportConfigCSV) // Vendor spec (BOM) endpoints configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec) configs.PUT("/:uuid/vendor-spec", vendorSpecHandler.PutVendorSpec) configs.POST("/:uuid/vendor-spec/resolve", vendorSpecHandler.ResolveVendorSpec) configs.POST("/:uuid/vendor-spec/apply", vendorSpecHandler.ApplyVendorSpec) configs.PATCH("/:uuid/server-count", func(c *gin.Context) { uuid := c.Param("uuid") var req struct { ServerCount int `json:"server_count" binding:"required,min=1"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } config, err := configService.UpdateServerCount(uuid, req.ServerCount) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, config) }) } projects := api.Group("/projects") { projects.GET("", func(c *gin.Context) { triggerPull("projects", &projectsPullState, syncProjectsFromServer) triggerPull("configs", &configsPullState, syncConfigurationsFromServer) status := c.DefaultQuery("status", "active") search := strings.ToLower(strings.TrimSpace(c.Query("search"))) author := strings.ToLower(strings.TrimSpace(c.Query("author"))) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) // Return all projects by default (set high limit for configs to reference) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "1000")) sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at"))) sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc"))) if status != "active" && status != "archived" && status != "all" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) return } if page < 1 { page = 1 } if perPage < 1 { perPage = 10 } if perPage > 100 { perPage = 100 } if sortField != "name" && sortField != "created_at" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort field"}) return } if sortDir != "asc" && sortDir != "desc" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort direction"}) return } allProjects, err := projectService.ListByUser(dbUsername, true) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } filtered := make([]models.Project, 0, len(allProjects)) for i := range allProjects { p := allProjects[i] if status == "active" && !p.IsActive { continue } if status == "archived" && p.IsActive { continue } if search != "" && !strings.Contains(strings.ToLower(derefString(p.Name)), search) && !strings.Contains(strings.ToLower(p.Code), search) && !strings.Contains(strings.ToLower(p.Variant), search) { continue } if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) { continue } filtered = append(filtered, p) } sort.Slice(filtered, func(i, j int) bool { left := filtered[i] right := filtered[j] if sortField == "name" { leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name))) rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name))) if leftName == rightName { if sortDir == "asc" { return left.CreatedAt.Before(right.CreatedAt) } return left.CreatedAt.After(right.CreatedAt) } if sortDir == "asc" { return leftName < rightName } return leftName > rightName } if left.CreatedAt.Equal(right.CreatedAt) { leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name))) rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name))) if sortDir == "asc" { return leftName < rightName } return leftName > rightName } if sortDir == "asc" { return left.CreatedAt.Before(right.CreatedAt) } return left.CreatedAt.After(right.CreatedAt) }) total := len(filtered) totalPages := 0 if total > 0 { totalPages = int(math.Ceil(float64(total) / float64(perPage))) } if totalPages > 0 && page > totalPages { page = totalPages } start := (page - 1) * perPage if start < 0 { start = 0 } end := start + perPage if end > total { end = total } paged := []models.Project{} if start < total { paged = filtered[start:end] } // Build per-project active config stats in one pass (avoid N+1 scans). projectConfigCount := map[string]int{} projectConfigTotal := map[string]float64{} if localConfigs, cfgErr := local.GetConfigurations(); cfgErr == nil { for i := range localConfigs { cfg := localConfigs[i] if !cfg.IsActive || cfg.ProjectUUID == nil || *cfg.ProjectUUID == "" { continue } projectUUID := *cfg.ProjectUUID projectConfigCount[projectUUID]++ if cfg.TotalPrice != nil { projectConfigTotal[projectUUID] += *cfg.TotalPrice } } } projectRows := make([]gin.H, 0, len(paged)) for i := range paged { p := paged[i] projectRows = append(projectRows, gin.H{ "id": p.ID, "uuid": p.UUID, "owner_username": p.OwnerUsername, "code": p.Code, "variant": p.Variant, "name": p.Name, "tracker_url": p.TrackerURL, "is_active": p.IsActive, "is_system": p.IsSystem, "created_at": p.CreatedAt, "updated_at": p.UpdatedAt, "config_count": projectConfigCount[p.UUID], "total": projectConfigTotal[p.UUID], }) } c.JSON(http.StatusOK, gin.H{ "projects": projectRows, "status": status, "search": search, "author": author, "sort": sortField, "dir": sortDir, "page": page, "per_page": perPage, "total": total, "total_pages": totalPages, }) }) // GET /api/projects/all - Returns all projects without pagination for UI dropdowns projects.GET("/all", func(c *gin.Context) { allProjects, err := projectService.ListByUser(dbUsername, true) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Return simplified list of all projects (UUID + Name only) type ProjectSimple struct { UUID string `json:"uuid"` Code string `json:"code"` Variant string `json:"variant"` Name string `json:"name"` IsActive bool `json:"is_active"` } simplified := make([]ProjectSimple, 0, len(allProjects)) for _, p := range allProjects { simplified = append(simplified, ProjectSimple{ UUID: p.UUID, Code: p.Code, Variant: p.Variant, Name: derefString(p.Name), IsActive: p.IsActive, }) } c.JSON(http.StatusOK, simplified) }) projects.POST("", func(c *gin.Context) { var req services.CreateProjectRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if strings.TrimSpace(req.Code) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"}) return } project, err := projectService.Create(dbUsername, &req) if err != nil { switch { case errors.Is(err, services.ErrProjectCodeExists): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusCreated, project) }) projects.GET("/:uuid", func(c *gin.Context) { project, err := projectService.GetByUUID(c.Param("uuid"), dbUsername) if err != nil { switch { case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusOK, project) }) projects.PUT("/:uuid", func(c *gin.Context) { var req services.UpdateProjectRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } project, err := projectService.Update(c.Param("uuid"), dbUsername, &req) if err != nil { switch { case errors.Is(err, services.ErrProjectCodeExists): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusOK, project) }) projects.POST("/:uuid/archive", func(c *gin.Context) { if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil { switch { case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusOK, gin.H{"message": "project archived"}) }) projects.POST("/:uuid/reactivate", func(c *gin.Context) { if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil { switch { case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusOK, gin.H{"message": "project reactivated"}) }) projects.DELETE("/:uuid", func(c *gin.Context) { if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil { switch { case errors.Is(err, services.ErrCannotDeleteMainVariant): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusOK, gin.H{"message": "variant deleted"}) }) projects.GET("/:uuid/configs", func(c *gin.Context) { triggerPull("configs", &configsPullState, syncConfigurationsFromServer) status := c.DefaultQuery("status", "active") if status != "active" && status != "archived" && status != "all" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) return } result, err := projectService.ListConfigurations(c.Param("uuid"), dbUsername, status) if err != nil { switch { case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.Header("X-Config-Status", status) c.JSON(http.StatusOK, result) }) projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) { var req struct { OrderedUUIDs []string `json:"ordered_uuids"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(req.OrderedUUIDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_uuids is required"}) return } configs, err := configService.ReorderProjectConfigurationsNoAuth(c.Param("uuid"), req.OrderedUUIDs) if err != nil { switch { case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) default: c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } return } total := 0.0 for i := range configs { if configs[i].TotalPrice != nil { total += *configs[i].TotalPrice } } c.JSON(http.StatusOK, gin.H{ "project_uuid": c.Param("uuid"), "configurations": configs, "total": total, }) }) projects.POST("/:uuid/configs", 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 } projectUUID := c.Param("uuid") req.ProjectUUID = &projectUUID config, err := configService.Create(dbUsername, &req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, config) }) projects.POST("/:uuid/vendor-import", func(c *gin.Context) { fileHeader, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) return } file, err := fileHeader.Open() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"}) return } defer file.Close() data, err := io.ReadAll(file) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"}) return } if !services.IsCFXMLWorkspace(data) { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"}) return } result, err := configService.ImportVendorWorkspaceToProject(c.Param("uuid"), fileHeader.Filename, data, dbUsername) if err != nil { switch { case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) default: c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusCreated, result) }) projects.GET("/:uuid/export", exportHandler.ExportProjectCSV) projects.POST("/:uuid/export", exportHandler.ExportProjectPricingCSV) projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) { var req struct { Name string `json:"name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } projectUUID := c.Param("uuid") config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, config) }) } // Sync API (for offline mode) syncAPI := api.Group("/sync") { syncAPI.GET("/status", syncHandler.GetStatus) syncAPI.GET("/readiness", syncHandler.GetReadiness) syncAPI.GET("/info", syncHandler.GetInfo) syncAPI.GET("/users-status", syncHandler.GetUsersStatus) syncAPI.POST("/components", syncHandler.SyncComponents) syncAPI.POST("/pricelists", syncHandler.SyncPricelists) syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks) syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen) syncAPI.POST("/all", syncHandler.SyncAll) syncAPI.POST("/push", syncHandler.PushPendingChanges) syncAPI.GET("/pending/count", syncHandler.GetPendingCount) syncAPI.GET("/pending", syncHandler.GetPendingChanges) syncAPI.POST("/repair", syncHandler.RepairPendingChanges) } } 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 blw := &captureResponseWriter{ ResponseWriter: c.Writer, body: bytes.NewBuffer(nil), } c.Writer = blw c.Next() latency := time.Since(start) status := c.Writer.Status() if status >= http.StatusBadRequest { responseBody := strings.TrimSpace(blw.body.String()) if len(responseBody) > 2048 { responseBody = responseBody[:2048] + "...(truncated)" } errText := strings.TrimSpace(c.Errors.String()) slog.Error("request failed", "method", c.Request.Method, "path", path, "query", query, "status", status, "latency", latency, "ip", c.ClientIP(), "errors", errText, "response", responseBody, ) return } slog.Info("request", "method", c.Request.Method, "path", path, "query", query, "status", status, "latency", latency, "ip", c.ClientIP(), ) } } type captureResponseWriter struct { gin.ResponseWriter body *bytes.Buffer } func (w *captureResponseWriter) Write(b []byte) (int, error) { if len(b) > 0 { _, _ = w.body.Write(b) } return w.ResponseWriter.Write(b) } func (w *captureResponseWriter) WriteString(s string) (int, error) { if s != "" { _, _ = w.body.WriteString(s) } return w.ResponseWriter.WriteString(s) }