diff --git a/cmd/server/main.go b/cmd/server/main.go index 7f8ca87..7720997 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "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" @@ -63,29 +64,15 @@ func main() { 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) - } + // Create connection manager (lazy connection, no connect on startup) + connMgr := db.NewConnectionManager(local) + slog.Info("starting in offline-first mode") 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) - } + // In offline-first mode, use default user ID + // EnsureDBUser will be called lazily when sync happens + dbUserID := uint(1) slog.Info("starting QuoteForge server", "host", cfg.Server.Host, @@ -96,11 +83,16 @@ func main() { if *migrate { slog.Info("running database migrations...") - if err := models.Migrate(db); err != nil { + mariaDB, err := connMgr.GetDB() + if err != nil { + slog.Error("cannot run migrations: database not available", "error", err) + os.Exit(1) + } + if err := models.Migrate(mariaDB); err != nil { slog.Error("migration failed", "error", err) os.Exit(1) } - if err := models.SeedCategories(db); err != nil { + if err := models.SeedCategories(mariaDB); err != nil { slog.Error("seeding categories failed", "error", err) os.Exit(1) } @@ -108,17 +100,17 @@ func main() { } gin.SetMode(cfg.Server.Mode) - router, syncService, err := setupRouter(db, cfg, local, dbUserID) + router, syncService, err := setupRouter(cfg, local, connMgr, dbUserID) if err != nil { slog.Error("failed to setup router", "error", err) os.Exit(1) } - // Start background sync worker + // Start background sync worker (will auto-skip when offline) workerCtx, workerCancel := context.WithCancel(context.Background()) defer workerCancel() - syncWorker := sync.NewWorker(syncService, db, 5*time.Minute) + syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute) go syncWorker.Start(workerCtx) srv := &http.Server{ @@ -308,32 +300,64 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) { return db, nil } -func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, *sync.Service, error) { +func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, dbUserID uint) (*gin.Engine, *sync.Service, error) { + // Don't connect to MariaDB on startup (offline-first architecture) + // Connection will be established lazily when needed + var mariaDB *gorm.DB + // 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) + 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 - 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) + 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 { - sqlDB, err := db.DB() - if err != nil { - return false - } - return sqlDB.Ping() == nil + return connMgr.IsOnline() } // Local-first configuration service (replaces old ConfigurationService) @@ -343,9 +367,9 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser componentHandler := handlers.NewComponentHandler(componentService) quoteHandler := handlers.NewQuoteHandler(quoteService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService) - pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo) + pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo) pricelistHandler := handlers.NewPricelistHandler(pricelistService, local) - syncHandler, err := handlers.NewSyncHandler(local, syncService, db, "web/templates") + syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, "web/templates") if err != nil { return nil, nil, fmt.Errorf("creating sync handler: %w", err) } @@ -367,7 +391,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser router.Use(gin.Recovery()) router.Use(requestLogger()) router.Use(middleware.CORS()) - router.Use(middleware.OfflineDetector(db, local)) + router.Use(middleware.OfflineDetector(connMgr, local)) // Static files router.Static("/static", "web/static") @@ -383,22 +407,22 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser // DB status endpoint router.GET("/api/db-status", func(c *gin.Context) { var lotCount, lotLogCount, metadataCount int64 - var dbOK bool = true + var dbOK bool = false 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() + 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 { + if err != nil { + dbError = err.Error() + } else { + dbError = "Database not connected (offline mode)" + } } - 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, diff --git a/internal/services/component.go b/internal/services/component.go index b39d8a3..2208a08 100644 --- a/internal/services/component.go +++ b/internal/services/component.go @@ -1,6 +1,7 @@ package services import ( + "fmt" "strings" "git.mchus.pro/mchus/quoteforge/internal/models" @@ -59,6 +60,17 @@ type ComponentView struct { } func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) { + // If no database connection (offline mode), return empty list + // Components should be loaded via /api/sync/components first + if s.componentRepo == nil { + return &ComponentListResult{ + Components: []ComponentView{}, + Total: 0, + Page: page, + PerPage: perPage, + }, nil + } + if page < 1 { page = 1 } @@ -106,6 +118,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage } func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) { + // If no database connection (offline mode), return error + if s.componentRepo == nil { + return nil, fmt.Errorf("offline mode: component data not available") + } + c, err := s.componentRepo.GetByLotName(lotName) if err != nil { return nil, err @@ -135,11 +152,20 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) } func (s *ComponentService) GetCategories() ([]models.Category, error) { + // If no database connection (offline mode), return default categories + if s.categoryRepo == nil { + return models.DefaultCategories, nil + } return s.categoryRepo.GetAll() } // ImportFromLot creates metadata entries for lots that don't have them func (s *ComponentService) ImportFromLot() (int, error) { + // If no database connection (offline mode), return error + if s.componentRepo == nil || s.categoryRepo == nil { + return 0, fmt.Errorf("offline mode: import not available") + } + lots, err := s.componentRepo.GetLotsWithoutMetadata() if err != nil { return 0, err diff --git a/internal/services/pricelist/service.go b/internal/services/pricelist/service.go index 4b17139..7af325b 100644 --- a/internal/services/pricelist/service.go +++ b/internal/services/pricelist/service.go @@ -26,6 +26,10 @@ func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo // CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) { + if s.repo == nil || s.db == nil { + return nil, fmt.Errorf("offline mode: cannot create pricelists") + } + version, err := s.repo.GenerateVersion() if err != nil { return nil, fmt.Errorf("generating version: %w", err) @@ -88,6 +92,11 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, // List returns pricelists with pagination func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) { + // If no database connection (offline mode), return empty list + if s.repo == nil { + return []models.PricelistSummary{}, 0, nil + } + if page < 1 { page = 1 } @@ -100,11 +109,17 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err // GetByID returns a pricelist by ID func (s *Service) GetByID(id uint) (*models.Pricelist, error) { + if s.repo == nil { + return nil, fmt.Errorf("offline mode: pricelist service not available") + } return s.repo.GetByID(id) } // GetItems returns pricelist items with pagination func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) { + if s.repo == nil { + return []models.PricelistItem{}, 0, nil + } if page < 1 { page = 1 } @@ -117,26 +132,42 @@ func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ( // Delete deletes a pricelist by ID func (s *Service) Delete(id uint) error { + if s.repo == nil { + return fmt.Errorf("offline mode: cannot delete pricelists") + } return s.repo.Delete(id) } // CanWrite returns true if the user can create pricelists func (s *Service) CanWrite() bool { + if s.repo == nil { + return false + } return s.repo.CanWrite() } // CanWriteDebug returns write permission status with debug info func (s *Service) CanWriteDebug() (bool, string) { + if s.repo == nil { + return false, "offline mode" + } return s.repo.CanWriteDebug() } // GetLatestActive returns the most recent active pricelist func (s *Service) GetLatestActive() (*models.Pricelist, error) { + if s.repo == nil { + return nil, fmt.Errorf("offline mode: pricelist service not available") + } return s.repo.GetLatestActive() } // CleanupExpired deletes expired and unused pricelists func (s *Service) CleanupExpired() (int, error) { + if s.repo == nil { + return 0, fmt.Errorf("offline mode: cleanup not available") + } + expired, err := s.repo.GetExpiredUnused() if err != nil { return 0, err