Implements complete offline-first architecture with SQLite caching and MariaDB synchronization. Key features: - Local SQLite database for offline operation (data/quoteforge.db) - Connection settings with encrypted credentials - Component and pricelist caching with auto-sync - Sync API endpoints (/api/sync/status, /components, /pricelists, /all) - Real-time sync status indicator in UI with auto-refresh - Offline mode detection middleware - Migration tool for database initialization - Setup wizard for initial configuration New components: - internal/localdb: SQLite repository layer (components, pricelists, sync) - internal/services/sync: Synchronization service - internal/handlers/sync: Sync API handlers - internal/handlers/setup: Setup wizard handlers - internal/middleware/offline: Offline detection - cmd/migrate: Database migration tool UI improvements: - Setup page for database configuration - Sync status indicator with online/offline detection - Warning icons for pending synchronization - Auto-refresh every 30 seconds Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
216 lines
6.2 KiB
Go
216 lines
6.2 KiB
Go
package sync
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
)
|
|
|
|
// Service handles synchronization between MariaDB and local SQLite
|
|
type Service struct {
|
|
pricelistRepo *repository.PricelistRepository
|
|
localDB *localdb.LocalDB
|
|
}
|
|
|
|
// NewService creates a new sync service
|
|
func NewService(pricelistRepo *repository.PricelistRepository, localDB *localdb.LocalDB) *Service {
|
|
return &Service{
|
|
pricelistRepo: pricelistRepo,
|
|
localDB: localDB,
|
|
}
|
|
}
|
|
|
|
// SyncStatus represents the current sync status
|
|
type SyncStatus struct {
|
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
|
ServerPricelists int `json:"server_pricelists"`
|
|
LocalPricelists int `json:"local_pricelists"`
|
|
NeedsSync bool `json:"needs_sync"`
|
|
}
|
|
|
|
// GetStatus returns the current sync status
|
|
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 local pricelists
|
|
localCount := s.localDB.CountLocalPricelists()
|
|
|
|
needsSync, _ := s.NeedSync()
|
|
|
|
return &SyncStatus{
|
|
LastSyncAt: lastSync,
|
|
ServerPricelists: len(serverPricelists),
|
|
LocalPricelists: int(localCount),
|
|
NeedsSync: needsSync,
|
|
}, nil
|
|
}
|
|
|
|
// NeedSync checks if synchronization is needed
|
|
// Returns true if there are new pricelists on server or last sync was >1 hour ago
|
|
func (s *Service) NeedSync() (bool, error) {
|
|
lastSync := s.localDB.GetLastSyncTime()
|
|
|
|
// If never synced, need sync
|
|
if lastSync == nil {
|
|
return true, nil
|
|
}
|
|
|
|
// If last sync was more than 1 hour ago, suggest sync
|
|
if time.Since(*lastSync) > time.Hour {
|
|
return true, nil
|
|
}
|
|
|
|
// Check if there are new pricelists on server
|
|
latestServer, err := s.pricelistRepo.GetLatestActive()
|
|
if err != nil {
|
|
// If no pricelists on server, no need to sync
|
|
return false, nil
|
|
}
|
|
|
|
latestLocal, err := s.localDB.GetLatestLocalPricelist()
|
|
if err != nil {
|
|
// No local pricelists, need to sync
|
|
return true, nil
|
|
}
|
|
|
|
// If server has newer pricelist, need sync
|
|
if latestServer.ID != latestLocal.ServerID {
|
|
return true, nil
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
|
func (s *Service) SyncPricelists() (int, error) {
|
|
slog.Info("starting pricelist sync")
|
|
|
|
// Get all active pricelists from server (up to 100)
|
|
serverPricelists, _, err := s.pricelistRepo.List(0, 100)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("getting server pricelists: %w", err)
|
|
}
|
|
|
|
synced := 0
|
|
for _, pl := range serverPricelists {
|
|
// Check if pricelist already exists locally
|
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
|
if existing != nil {
|
|
// Already synced, skip
|
|
continue
|
|
}
|
|
|
|
// Create local pricelist
|
|
localPL := &localdb.LocalPricelist{
|
|
ServerID: pl.ID,
|
|
Version: pl.Version,
|
|
Name: pl.Notification, // Using notification as name
|
|
CreatedAt: pl.CreatedAt,
|
|
SyncedAt: time.Now(),
|
|
IsUsed: false,
|
|
}
|
|
|
|
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
|
|
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
|
|
continue
|
|
}
|
|
|
|
synced++
|
|
slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID)
|
|
}
|
|
|
|
// Update last sync time
|
|
s.localDB.SetLastSyncTime(time.Now())
|
|
|
|
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
|
return synced, nil
|
|
}
|
|
|
|
// SyncPricelistItems synchronizes items for a specific pricelist
|
|
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|
// Get local pricelist
|
|
localPL, err := s.localDB.GetLocalPricelistByID(localPricelistID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("getting local pricelist: %w", err)
|
|
}
|
|
|
|
// Check if items already exist
|
|
existingCount := s.localDB.CountLocalPricelistItems(localPricelistID)
|
|
if existingCount > 0 {
|
|
slog.Debug("pricelist items already synced", "pricelist_id", localPricelistID, "count", existingCount)
|
|
return int(existingCount), nil
|
|
}
|
|
|
|
// Get items from server
|
|
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
|
}
|
|
|
|
// Convert and save locally
|
|
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
|
for i, item := range serverItems {
|
|
localItems[i] = localdb.LocalPricelistItem{
|
|
PricelistID: localPricelistID,
|
|
LotName: item.LotName,
|
|
Price: item.Price,
|
|
}
|
|
}
|
|
|
|
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
|
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
|
}
|
|
|
|
slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems))
|
|
return len(localItems), nil
|
|
}
|
|
|
|
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
|
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("local pricelist not found for server ID %d", serverPricelistID)
|
|
}
|
|
return s.SyncPricelistItems(localPL.ID)
|
|
}
|
|
|
|
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
|
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
|
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
|
}
|
|
|
|
// GetPricelistForOffline returns a pricelist suitable for offline use
|
|
// If items are not synced, it will sync them first
|
|
func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.LocalPricelist, error) {
|
|
// Ensure pricelist is synced
|
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
|
if err != nil {
|
|
// Try to sync pricelists first
|
|
if _, err := s.SyncPricelists(); err != nil {
|
|
return nil, fmt.Errorf("syncing pricelists: %w", err)
|
|
}
|
|
|
|
// Try again
|
|
localPL, err = s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pricelist not found on server: %w", err)
|
|
}
|
|
}
|
|
|
|
// Ensure items are synced
|
|
if _, err := s.SyncPricelistItems(localPL.ID); err != nil {
|
|
return nil, fmt.Errorf("syncing pricelist items: %w", err)
|
|
}
|
|
|
|
return localPL, nil
|
|
}
|