From 7f030e7db77d372910c8e166a62595e3df267152 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Mon, 2 Feb 2026 23:29:36 +0300 Subject: [PATCH] refactor: migrate sync service and handlers to use ConnectionManager Updated sync-related code to use ConnectionManager instead of direct database references: - SyncService now creates repositories on-demand when connection available - SyncHandler uses ConnectionManager for lazy DB access - Added ComponentFilter and ListComponents to localdb for offline queries - All sync operations check connection status before attempting MariaDB access This completes the transition to offline-first architecture where all database access goes through ConnectionManager. Part of Phase 2.5: Full Offline Mode Co-Authored-By: Claude Sonnet 4.5 --- internal/handlers/sync.go | 42 +++++---- internal/localdb/components.go | 142 ++++++++++++++++++++++++++++++ internal/services/sync/service.go | 122 ++++++++++++++++++++----- 3 files changed, 267 insertions(+), 39 deletions(-) diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index d5937aa..597483f 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -8,21 +8,21 @@ import ( "time" "github.com/gin-gonic/gin" + "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/services/sync" - "gorm.io/gorm" ) // SyncHandler handles sync API endpoints type SyncHandler struct { localDB *localdb.LocalDB syncService *sync.Service - mariaDB *gorm.DB + connMgr *db.ConnectionManager tmpl *template.Template } // NewSyncHandler creates a new sync handler -func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB, templatesPath string) (*SyncHandler, error) { +func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) { // Load sync_status partial template partialPath := filepath.Join(templatesPath, "partials", "sync_status.html") tmpl, err := template.ParseFiles(partialPath) @@ -33,7 +33,7 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB return &SyncHandler{ localDB: localDB, syncService: syncService, - mariaDB: mariaDB, + connMgr: connMgr, tmpl: tmpl, }, nil } @@ -109,7 +109,17 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) { return } - result, err := h.localDB.SyncComponents(h.mariaDB) + // Get database connection from ConnectionManager + mariaDB, err := h.connMgr.GetDB() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "success": false, + "error": "Database connection failed: " + err.Error(), + }) + return + } + + result, err := h.localDB.SyncComponents(mariaDB) if err != nil { slog.Error("component sync failed", "error", err) c.JSON(http.StatusInternalServerError, gin.H{ @@ -181,7 +191,16 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { var componentsSynced, pricelistsSynced int // Sync components - compResult, err := h.localDB.SyncComponents(h.mariaDB) + mariaDB, err := h.connMgr.GetDB() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "success": false, + "error": "Database connection failed: " + err.Error(), + }) + return + } + + compResult, err := h.localDB.SyncComponents(mariaDB) if err != nil { slog.Error("component sync failed during full sync", "error", err) c.JSON(http.StatusInternalServerError, gin.H{ @@ -215,16 +234,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { // checkOnline checks if MariaDB is accessible func (h *SyncHandler) checkOnline() bool { - sqlDB, err := h.mariaDB.DB() - if err != nil { - return false - } - - if err := sqlDB.Ping(); err != nil { - return false - } - - return true + return h.connMgr.IsOnline() } // PushPendingChanges pushes all pending changes to the server diff --git a/internal/localdb/components.go b/internal/localdb/components.go index 666cf96..0158241 100644 --- a/internal/localdb/components.go +++ b/internal/localdb/components.go @@ -9,6 +9,13 @@ import ( "gorm.io/gorm" ) +// ComponentFilter for searching with filters +type ComponentFilter struct { + Category string + Search string + HasPrice bool +} + // ComponentSyncResult contains statistics from component sync type ComponentSyncResult struct { TotalSynced int @@ -196,6 +203,44 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, return components, err } +// ListComponents returns components with filtering and pagination +func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) { + db := l.db + + // Apply category filter + if filter.Category != "" { + db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category)) + } + + // Apply search filter + if filter.Search != "" { + searchPattern := "%" + strings.ToLower(filter.Search) + "%" + db = db.Where( + "LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern, + ) + } + + // Apply price filter + if filter.HasPrice { + db = db.Where("current_price IS NOT NULL") + } + + // Get total count + var total int64 + if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Apply pagination and get results + var components []LocalComponent + if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil { + return nil, 0, err + } + + return components, total, nil +} + // GetLocalComponent returns a single component by lot_name func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { var component LocalComponent @@ -266,3 +311,100 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool { } return time.Since(*syncTime).Hours() > float64(maxAgeHours) } + +// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items +// This allows offline price updates using synced pricelists without MariaDB connection +func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) { + // Get all items from the specified pricelist + var items []LocalPricelistItem + if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil { + return 0, fmt.Errorf("fetching pricelist items: %w", err) + } + + if len(items) == 0 { + slog.Warn("no items found in pricelist", "pricelist_id", pricelistID) + return 0, nil + } + + // Update current_price for each component + updated := 0 + err := l.db.Transaction(func(tx *gorm.DB) error { + for _, item := range items { + result := tx.Model(&LocalComponent{}). + Where("lot_name = ?", item.LotName). + Update("current_price", item.Price) + + if result.Error != nil { + return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error) + } + + if result.RowsAffected > 0 { + updated++ + } + } + return nil + }) + + if err != nil { + return 0, err + } + + slog.Info("updated component prices from pricelist", + "pricelist_id", pricelistID, + "total_items", len(items), + "updated_components", updated) + + return updated, nil +} + +// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components +// if no components exist or all current prices are NULL +func (l *LocalDB) EnsureComponentPricesFromPricelists() error { + // Check if we have any components with prices + var count int64 + if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil { + return fmt.Errorf("checking component prices: %w", err) + } + + // If we have components with prices, don't load from pricelists + if count > 0 { + return nil + } + + // Check if we have any components at all + var totalComponents int64 + if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil { + return fmt.Errorf("counting components: %w", err) + } + + // If we have no components, we need to load them from pricelists + if totalComponents == 0 { + slog.Info("no components found in local database, loading from latest pricelist") + // This would typically be called from the sync service or setup process + // For now, we'll just return nil to indicate no action needed + return nil + } + + // If we have components but no prices, we should load prices from pricelists + // Find the latest pricelist + var latestPricelist LocalPricelist + if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil { + if err == gorm.ErrRecordNotFound { + slog.Warn("no pricelists found in local database") + return nil + } + return fmt.Errorf("finding latest pricelist: %w", err) + } + + // Update prices from the latest pricelist + updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID) + if err != nil { + return fmt.Errorf("updating component prices from pricelist: %w", err) + } + + slog.Info("loaded component prices from latest pricelist", + "pricelist_id", latestPricelist.ID, + "updated_components", updated) + + return nil +} diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 91146a8..b1f431f 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -6,6 +6,7 @@ import ( "log/slog" "time" + "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" @@ -13,17 +14,15 @@ import ( // Service handles synchronization between MariaDB and local SQLite type Service struct { - pricelistRepo *repository.PricelistRepository - configRepo *repository.ConfigurationRepository - localDB *localdb.LocalDB + connMgr *db.ConnectionManager + localDB *localdb.LocalDB } // NewService creates a new sync service -func NewService(pricelistRepo *repository.PricelistRepository, configRepo *repository.ConfigurationRepository, localDB *localdb.LocalDB) *Service { +func NewService(connMgr *db.ConnectionManager, localDB *localdb.LocalDB) *Service { return &Service{ - pricelistRepo: pricelistRepo, - configRepo: configRepo, - localDB: localDB, + connMgr: connMgr, + localDB: localDB, } } @@ -39,10 +38,14 @@ type SyncStatus struct { func (s *Service) GetStatus() (*SyncStatus, error) { lastSync := s.localDB.GetLastSyncTime() - // Count server pricelists - serverPricelists, _, err := s.pricelistRepo.List(0, 1) - if err != nil { - return nil, fmt.Errorf("counting server pricelists: %w", err) + // Count server pricelists (requires connection) + serverCount := 0 + if mariaDB, err := s.connMgr.GetDB(); err == nil && mariaDB != nil { + pricelistRepo := repository.NewPricelistRepository(mariaDB) + serverPricelists, _, err := pricelistRepo.List(0, 1) + if err == nil { + serverCount = len(serverPricelists) + } } // Count local pricelists @@ -52,7 +55,7 @@ func (s *Service) GetStatus() (*SyncStatus, error) { return &SyncStatus{ LastSyncAt: lastSync, - ServerPricelists: len(serverPricelists), + ServerPricelists: serverCount, LocalPricelists: int(localCount), NeedsSync: needsSync, }, nil @@ -73,8 +76,15 @@ func (s *Service) NeedSync() (bool, error) { return true, nil } - // Check if there are new pricelists on server - latestServer, err := s.pricelistRepo.GetLatestActive() + // Check if there are new pricelists on server (requires connection) + mariaDB, err := s.connMgr.GetDB() + if err != nil { + // If offline, can't check server, no need to sync + return false, nil + } + + pricelistRepo := repository.NewPricelistRepository(mariaDB) + latestServer, err := pricelistRepo.GetLatestActive() if err != nil { // If no pricelists on server, no need to sync return false, nil @@ -98,18 +108,29 @@ func (s *Service) NeedSync() (bool, error) { func (s *Service) SyncPricelists() (int, error) { slog.Info("starting pricelist sync") + // Get database connection + mariaDB, err := s.connMgr.GetDB() + if err != nil { + return 0, fmt.Errorf("database not available: %w", err) + } + + // Create repository + pricelistRepo := repository.NewPricelistRepository(mariaDB) + // Get all active pricelists from server (up to 100) - serverPricelists, _, err := s.pricelistRepo.List(0, 100) + serverPricelists, _, err := pricelistRepo.List(0, 100) if err != nil { return 0, fmt.Errorf("getting server pricelists: %w", err) } synced := 0 + var latestLocalID uint for _, pl := range serverPricelists { // Check if pricelist already exists locally existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) if existing != nil { - // Already synced, skip + // Already synced, track latest + latestLocalID = existing.ID continue } @@ -128,8 +149,27 @@ func (s *Service) SyncPricelists() (int, error) { continue } + // Sync items for the newly created pricelist + itemCount, err := s.SyncPricelistItems(localPL.ID) + if err != nil { + slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err) + // Continue even if items sync fails - we have the pricelist metadata + } else { + slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount) + } + + latestLocalID = localPL.ID synced++ - slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID) + } + + // Update component prices from latest pricelist + if latestLocalID > 0 { + updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID) + if err != nil { + slog.Warn("failed to update component prices from pricelist", "error", err) + } else { + slog.Info("updated component prices from latest pricelist", "updated", updated) + } } // Update last sync time @@ -154,8 +194,17 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) { return int(existingCount), nil } + // Get database connection + mariaDB, err := s.connMgr.GetDB() + if err != nil { + return 0, fmt.Errorf("database not available: %w", err) + } + + // Create repository + pricelistRepo := repository.NewPricelistRepository(mariaDB) + // Get items from server - serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "") + serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "") if err != nil { return 0, fmt.Errorf("getting server pricelist items: %w", err) } @@ -312,8 +361,17 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error { return fmt.Errorf("unmarshaling configuration: %w", err) } + // Get database connection + mariaDB, err := s.connMgr.GetDB() + if err != nil { + return fmt.Errorf("database not available: %w", err) + } + + // Create repository + configRepo := repository.NewConfigurationRepository(mariaDB) + // Create on server - if err := s.configRepo.Create(&cfg); err != nil { + if err := configRepo.Create(&cfg); err != nil { return fmt.Errorf("creating configuration on server: %w", err) } @@ -337,6 +395,15 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error { return fmt.Errorf("unmarshaling configuration: %w", err) } + // Get database connection + mariaDB, err := s.connMgr.GetDB() + if err != nil { + return fmt.Errorf("database not available: %w", err) + } + + // Create repository + configRepo := repository.NewConfigurationRepository(mariaDB) + // Ensure we have a server ID before updating // If the payload doesn't have ID, get it from local configuration if cfg.ID == 0 { @@ -347,7 +414,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error { if localCfg.ServerID == nil { // Configuration hasn't been synced yet, try to find it on server by UUID - serverCfg, err := s.configRepo.GetByUUID(cfg.UUID) + serverCfg, err := configRepo.GetByUUID(cfg.UUID) if err != nil { return fmt.Errorf("configuration not yet synced to server: %w", err) } @@ -363,7 +430,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error { } // Update on server - if err := s.configRepo.Update(&cfg); err != nil { + if err := configRepo.Update(&cfg); err != nil { return fmt.Errorf("updating configuration on server: %w", err) } @@ -380,8 +447,17 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error { // pushConfigurationDelete deletes a configuration from the server func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error { + // Get database connection + mariaDB, err := s.connMgr.GetDB() + if err != nil { + return fmt.Errorf("database not available: %w", err) + } + + // Create repository + configRepo := repository.NewConfigurationRepository(mariaDB) + // Get the configuration from server by UUID to get the ID - cfg, err := s.configRepo.GetByUUID(change.EntityUUID) + cfg, err := configRepo.GetByUUID(change.EntityUUID) if err != nil { // Already deleted or not found, consider it successful slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID) @@ -389,7 +465,7 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error { } // Delete from server - if err := s.configRepo.Delete(cfg.ID); err != nil { + if err := configRepo.Delete(cfg.ID); err != nil { return fmt.Errorf("deleting configuration from server: %w", err) }