Add Phase 2: Local SQLite database with sync functionality

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>
This commit is contained in:
2026-02-01 11:00:32 +03:00
parent 8b8d2f18f9
commit 143d217397
30 changed files with 3697 additions and 887 deletions

View File

@@ -0,0 +1,215 @@
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
}