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 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 23:29:36 +03:00
parent 3d222b7f14
commit 7f030e7db7
3 changed files with 267 additions and 39 deletions

View File

@@ -8,21 +8,21 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "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/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"gorm.io/gorm"
) )
// SyncHandler handles sync API endpoints // SyncHandler handles sync API endpoints
type SyncHandler struct { type SyncHandler struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
syncService *sync.Service syncService *sync.Service
mariaDB *gorm.DB connMgr *db.ConnectionManager
tmpl *template.Template tmpl *template.Template
} }
// NewSyncHandler creates a new sync handler // 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 // Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html") partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
tmpl, err := template.ParseFiles(partialPath) tmpl, err := template.ParseFiles(partialPath)
@@ -33,7 +33,7 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB
return &SyncHandler{ return &SyncHandler{
localDB: localDB, localDB: localDB,
syncService: syncService, syncService: syncService,
mariaDB: mariaDB, connMgr: connMgr,
tmpl: tmpl, tmpl: tmpl,
}, nil }, nil
} }
@@ -109,7 +109,17 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
return 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 { if err != nil {
slog.Error("component sync failed", "error", err) slog.Error("component sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -181,7 +191,16 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
var componentsSynced, pricelistsSynced int var componentsSynced, pricelistsSynced int
// Sync components // 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 { if err != nil {
slog.Error("component sync failed during full sync", "error", err) slog.Error("component sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -215,16 +234,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
// checkOnline checks if MariaDB is accessible // checkOnline checks if MariaDB is accessible
func (h *SyncHandler) checkOnline() bool { func (h *SyncHandler) checkOnline() bool {
sqlDB, err := h.mariaDB.DB() return h.connMgr.IsOnline()
if err != nil {
return false
}
if err := sqlDB.Ping(); err != nil {
return false
}
return true
} }
// PushPendingChanges pushes all pending changes to the server // PushPendingChanges pushes all pending changes to the server

View File

@@ -9,6 +9,13 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// ComponentFilter for searching with filters
type ComponentFilter struct {
Category string
Search string
HasPrice bool
}
// ComponentSyncResult contains statistics from component sync // ComponentSyncResult contains statistics from component sync
type ComponentSyncResult struct { type ComponentSyncResult struct {
TotalSynced int TotalSynced int
@@ -196,6 +203,44 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string,
return components, err 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 // GetLocalComponent returns a single component by lot_name
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
var component LocalComponent var component LocalComponent
@@ -266,3 +311,100 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
} }
return time.Since(*syncTime).Hours() > float64(maxAgeHours) 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
}

View File

@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
@@ -13,17 +14,15 @@ import (
// Service handles synchronization between MariaDB and local SQLite // Service handles synchronization between MariaDB and local SQLite
type Service struct { type Service struct {
pricelistRepo *repository.PricelistRepository connMgr *db.ConnectionManager
configRepo *repository.ConfigurationRepository localDB *localdb.LocalDB
localDB *localdb.LocalDB
} }
// NewService creates a new sync service // 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{ return &Service{
pricelistRepo: pricelistRepo, connMgr: connMgr,
configRepo: configRepo, localDB: localDB,
localDB: localDB,
} }
} }
@@ -39,10 +38,14 @@ type SyncStatus struct {
func (s *Service) GetStatus() (*SyncStatus, error) { func (s *Service) GetStatus() (*SyncStatus, error) {
lastSync := s.localDB.GetLastSyncTime() lastSync := s.localDB.GetLastSyncTime()
// Count server pricelists // Count server pricelists (requires connection)
serverPricelists, _, err := s.pricelistRepo.List(0, 1) serverCount := 0
if err != nil { if mariaDB, err := s.connMgr.GetDB(); err == nil && mariaDB != nil {
return nil, fmt.Errorf("counting server pricelists: %w", err) pricelistRepo := repository.NewPricelistRepository(mariaDB)
serverPricelists, _, err := pricelistRepo.List(0, 1)
if err == nil {
serverCount = len(serverPricelists)
}
} }
// Count local pricelists // Count local pricelists
@@ -52,7 +55,7 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
return &SyncStatus{ return &SyncStatus{
LastSyncAt: lastSync, LastSyncAt: lastSync,
ServerPricelists: len(serverPricelists), ServerPricelists: serverCount,
LocalPricelists: int(localCount), LocalPricelists: int(localCount),
NeedsSync: needsSync, NeedsSync: needsSync,
}, nil }, nil
@@ -73,8 +76,15 @@ func (s *Service) NeedSync() (bool, error) {
return true, nil return true, nil
} }
// Check if there are new pricelists on server // Check if there are new pricelists on server (requires connection)
latestServer, err := s.pricelistRepo.GetLatestActive() 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 err != nil {
// If no pricelists on server, no need to sync // If no pricelists on server, no need to sync
return false, nil return false, nil
@@ -98,18 +108,29 @@ func (s *Service) NeedSync() (bool, error) {
func (s *Service) SyncPricelists() (int, error) { func (s *Service) SyncPricelists() (int, error) {
slog.Info("starting pricelist sync") 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) // 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 { if err != nil {
return 0, fmt.Errorf("getting server pricelists: %w", err) return 0, fmt.Errorf("getting server pricelists: %w", err)
} }
synced := 0 synced := 0
var latestLocalID uint
for _, pl := range serverPricelists { for _, pl := range serverPricelists {
// Check if pricelist already exists locally // Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil { if existing != nil {
// Already synced, skip // Already synced, track latest
latestLocalID = existing.ID
continue continue
} }
@@ -128,8 +149,27 @@ func (s *Service) SyncPricelists() (int, error) {
continue 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++ 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 // Update last sync time
@@ -154,8 +194,17 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
return int(existingCount), nil 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 // Get items from server
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "") serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
if err != nil { if err != nil {
return 0, fmt.Errorf("getting server pricelist items: %w", err) 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) 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 // 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) 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) 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 // Ensure we have a server ID before updating
// If the payload doesn't have ID, get it from local configuration // If the payload doesn't have ID, get it from local configuration
if cfg.ID == 0 { if cfg.ID == 0 {
@@ -347,7 +414,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
if localCfg.ServerID == nil { if localCfg.ServerID == nil {
// Configuration hasn't been synced yet, try to find it on server by UUID // 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 { if err != nil {
return fmt.Errorf("configuration not yet synced to server: %w", err) 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 // 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) 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 // pushConfigurationDelete deletes a configuration from the server
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error { 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 // 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 { if err != nil {
// Already deleted or not found, consider it successful // Already deleted or not found, consider it successful
slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID) 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 // 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) return fmt.Errorf("deleting configuration from server: %w", err)
} }