Add background sync worker and complete local-first architecture

Implements automatic background synchronization every 5 minutes:
- Worker pushes pending changes to server (PushPendingChanges)
- Worker pulls new pricelists (SyncPricelistsIfNeeded)
- Graceful shutdown with context cancellation
- Automatic online/offline detection via DB ping

New files:
- internal/services/sync/worker.go - Background sync worker
- internal/services/local_configuration.go - Local-first CRUD
- internal/localdb/converters.go - MariaDB ↔ SQLite converters

Extended sync infrastructure:
- Pending changes queue (pending_changes table)
- Push/pull sync endpoints (/api/sync/push, /pending)
- ConfigurationGetter interface for handler compatibility
- LocalConfigurationService replaces ConfigurationService

All configuration operations now run through SQLite with automatic
background sync to MariaDB when online. Phase 2.5 nearly complete.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 22:17:00 +03:00
parent 143d217397
commit be77256d4e
11 changed files with 1131 additions and 27 deletions

View File

@@ -9,26 +9,33 @@
### Phase 2: Local SQLite Database ✅ DONE ### Phase 2: Local SQLite Database ✅ DONE
### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS ### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS
Приложение должно полностью работать без MariaDB, синхронизация при восстановлении связи. **Local-first architecture:** приложение ВСЕГДА работает с SQLite, MariaDB только для синхронизации.
**Architecture:** **Принцип работы:**
- Dual-source pattern: все операции идут через unified service layer - ВСЕ операции (CRUD) выполняются в SQLite
- Online: read/write MariaDB, async cache to SQLite - При создании конфигурации:
- Offline: read/write SQLite, queue changes for sync 1. Если online → проверить новые прайслисты на сервере → скачать если есть
2. Далее работаем с local_pricelists (и online, и offline одинаково)
- Background sync: push pending_changes → pull updates
**DONE:**
- ✅ Sync queue table (pending_changes) - `internal/localdb/models.go`
- ✅ Model converters: MariaDB ↔ SQLite - `internal/localdb/converters.go`
- ✅ LocalConfigurationService: все CRUD через SQLite - `internal/services/local_configuration.go`
- ✅ Pre-create pricelist check: `SyncPricelistsIfNeeded()` - `internal/services/sync/service.go`
- ✅ Push pending changes: `PushPendingChanges()` - sync service + handlers
- ✅ Sync API endpoints: `/api/sync/push`, `/pending/count`, `/pending`
- ✅ Integrate LocalConfigurationService in main.go (replace ConfigurationService)
- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`)
- ✅ ConfigurationGetter interface for handler compatibility
- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go`
**TODO:** **TODO:**
-Unified repository interface (online/offline transparent switching) -Conflict resolution (last-write-wins or manual)
-Sync queue table (pending_changes: entity_type, entity_uuid, operation, payload, created_at) -UI: pending counter in header
-Background sync worker (push local changes when online) -UI: manual sync button
-Conflict resolution (last-write-wins by updated_at, or manual) -UI: offline indicator (middleware already exists)
-Initial data bootstrap (first sync downloads all needed data) -RefreshPrices for local mode (via local_components)
- ❌ Handlers use context.IsOffline to choose data source
- ❌ UI: pending changes counter, manual sync button, conflict alerts
**Sync flow:**
1. Online → Offline: continue work, changes saved locally with sync_status='pending'
2. Offline → Online: background worker pushes pending_changes, pulls updates
3. Conflict: if server version newer, mark as 'conflict' for manual resolution
### Phase 3: Projects and Specifications ### Phase 3: Projects and Specifications
- qt_projects, qt_specifications tables (MariaDB) - qt_projects, qt_specifications tables (MariaDB)

View File

@@ -108,12 +108,19 @@ func main() {
} }
gin.SetMode(cfg.Server.Mode) gin.SetMode(cfg.Server.Mode)
router, err := setupRouter(db, cfg, local, dbUserID) router, syncService, err := setupRouter(db, cfg, local, dbUserID)
if err != nil { if err != nil {
slog.Error("failed to setup router", "error", err) slog.Error("failed to setup router", "error", err)
os.Exit(1) os.Exit(1)
} }
// Start background sync worker
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
syncWorker := sync.NewWorker(syncService, db, 5*time.Minute)
go syncWorker.Start(workerCtx)
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Address(), Addr: cfg.Address(),
Handler: router, Handler: router,
@@ -135,6 +142,11 @@ func main() {
slog.Info("shutting down server...") slog.Info("shutting down server...")
// Stop background sync worker first
syncWorker.Stop()
workerCancel()
// Then shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@@ -282,7 +294,7 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
return db, nil return db, nil
} }
func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, error) { func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
// Repositories // Repositories
componentRepo := repository.NewComponentRepository(db) componentRepo := repository.NewComponentRepository(db)
categoryRepo := repository.NewCategoryRepository(db) categoryRepo := repository.NewCategoryRepository(db)
@@ -299,8 +311,19 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
exportService := services.NewExportService(cfg.Export, categoryRepo) exportService := services.NewExportService(cfg.Export, categoryRepo)
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo) pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo)
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService) syncService := sync.NewService(pricelistRepo, configRepo, local)
syncService := sync.NewService(pricelistRepo, local)
// isOnline function for local-first architecture
isOnline := func() bool {
sqlDB, err := db.DB()
if err != nil {
return false
}
return sqlDB.Ping() == nil
}
// Local-first configuration service (replaces old ConfigurationService)
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
// Handlers // Handlers
componentHandler := handlers.NewComponentHandler(componentService) componentHandler := handlers.NewComponentHandler(componentService)
@@ -313,13 +336,13 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
// Setup handler (for reconfiguration) // Setup handler (for reconfiguration)
setupHandler, err := handlers.NewSetupHandler(local, "web/templates") setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
if err != nil { if err != nil {
return nil, fmt.Errorf("creating setup handler: %w", err) return nil, nil, fmt.Errorf("creating setup handler: %w", err)
} }
// Web handler (templates) // Web handler (templates)
webHandler, err := handlers.NewWebHandler("web/templates", componentService) webHandler, err := handlers.NewWebHandler("web/templates", componentService)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
// Router // Router
@@ -584,10 +607,13 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
syncAPI.POST("/components", syncHandler.SyncComponents) syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists) syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/all", syncHandler.SyncAll) syncAPI.POST("/all", syncHandler.SyncAll)
syncAPI.POST("/push", syncHandler.PushPendingChanges)
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
} }
} }
return router, nil return router, syncService, nil
} }
func requestLogger() gin.HandlerFunc { func requestLogger() gin.HandlerFunc {

View File

@@ -12,13 +12,13 @@ import (
type ExportHandler struct { type ExportHandler struct {
exportService *services.ExportService exportService *services.ExportService
configService *services.ConfigurationService configService services.ConfigurationGetter
componentService *services.ComponentService componentService *services.ComponentService
} }
func NewExportHandler( func NewExportHandler(
exportService *services.ExportService, exportService *services.ExportService,
configService *services.ConfigurationService, configService services.ConfigurationGetter,
componentService *services.ComponentService, componentService *services.ComponentService,
) *ExportHandler { ) *ExportHandler {
return &ExportHandler{ return &ExportHandler{

View File

@@ -215,3 +215,58 @@ func (h *SyncHandler) checkOnline() bool {
return true return true
} }
// PushPendingChanges pushes all pending changes to the server
// POST /api/sync/push
func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return
}
startTime := time.Now()
pushed, err := h.syncService.PushPendingChanges()
if err != nil {
slog.Error("push pending changes failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
Message: "Pending changes pushed successfully",
Synced: pushed,
Duration: time.Since(startTime).String(),
})
}
// GetPendingCount returns the number of pending changes
// GET /api/sync/pending/count
func (h *SyncHandler) GetPendingCount(c *gin.Context) {
count := h.localDB.GetPendingCount()
c.JSON(http.StatusOK, gin.H{
"count": count,
})
}
// GetPendingChanges returns all pending changes
// GET /api/sync/pending
func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
changes, err := h.localDB.GetPendingChanges()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"changes": changes,
})
}

View File

@@ -0,0 +1,161 @@
package localdb
import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
// ConfigurationToLocal converts models.Configuration to LocalConfiguration
func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
items := make(LocalConfigItems, len(cfg.Items))
for i, item := range cfg.Items {
items[i] = LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
local := &LocalConfiguration{
UUID: cfg.UUID,
Name: cfg.Name,
Items: items,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUserID: cfg.UserID,
}
if cfg.ID > 0 {
serverID := cfg.ID
local.ServerID = &serverID
}
return local
}
// LocalToConfiguration converts LocalConfiguration to models.Configuration
func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
items := make(models.ConfigItems, len(local.Items))
for i, item := range local.Items {
items[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
cfg := &models.Configuration{
UUID: local.UUID,
UserID: local.OriginalUserID,
Name: local.Name,
Items: items,
TotalPrice: local.TotalPrice,
CustomPrice: local.CustomPrice,
Notes: local.Notes,
IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount,
CreatedAt: local.CreatedAt,
}
if local.ServerID != nil {
cfg.ID = *local.ServerID
}
return cfg
}
// PricelistToLocal converts models.Pricelist to LocalPricelist
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
name := pl.Notification
if name == "" {
name = pl.Version
}
return &LocalPricelist{
ServerID: pl.ID,
Version: pl.Version,
Name: name,
CreatedAt: pl.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}
}
// LocalToPricelist converts LocalPricelist to models.Pricelist
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
return &models.Pricelist{
ID: local.ServerID,
Version: local.Version,
Notification: local.Name,
CreatedAt: local.CreatedAt,
IsActive: true,
}
}
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
return &LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
}
}
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
return &models.PricelistItem{
ID: local.ID,
PricelistID: serverPricelistID,
LotName: local.LotName,
Price: local.Price,
}
}
// ComponentToLocal converts models.LotMetadata to LocalComponent
func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
var lotDesc string
var category string
if meta.Lot != nil {
lotDesc = meta.Lot.LotDescription
}
// Extract category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
if len(meta.LotName) > 0 {
for i, ch := range meta.LotName {
if ch == '_' {
category = meta.LotName[:i]
break
}
}
}
return &LocalComponent{
LotName: meta.LotName,
LotDescription: lotDesc,
Category: category,
Model: meta.Model,
CurrentPrice: meta.CurrentPrice,
SyncedAt: time.Now(),
}
}
// LocalToComponent converts LocalComponent to models.LotMetadata
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
return &models.LotMetadata{
LotName: local.LotName,
Model: local.Model,
CurrentPrice: local.CurrentPrice,
Lot: &models.Lot{
LotName: local.LotName,
LotDescription: local.LotDescription,
},
}
}

View File

@@ -56,6 +56,7 @@ func New(dbPath string) (*LocalDB, error) {
&LocalPricelistItem{}, &LocalPricelistItem{},
&LocalComponent{}, &LocalComponent{},
&AppSetting{}, &AppSetting{},
&PendingChange{},
); err != nil { ); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err) return nil, fmt.Errorf("migrating sqlite database: %w", err)
} }
@@ -337,3 +338,73 @@ func (l *LocalDB) DeleteLocalPricelist(id uint) error {
// Delete pricelist // Delete pricelist
return l.db.Delete(&LocalPricelist{}, id).Error return l.db.Delete(&LocalPricelist{}, id).Error
} }
// PendingChange methods
// AddPendingChange adds a change to the sync queue
func (l *LocalDB) AddPendingChange(entityType, entityUUID, operation, payload string) error {
change := PendingChange{
EntityType: entityType,
EntityUUID: entityUUID,
Operation: operation,
Payload: payload,
CreatedAt: time.Now(),
Attempts: 0,
}
return l.db.Create(&change).Error
}
// GetPendingChanges returns all pending changes ordered by creation time
func (l *LocalDB) GetPendingChanges() ([]PendingChange, error) {
var changes []PendingChange
err := l.db.Order("created_at ASC").Find(&changes).Error
return changes, err
}
// GetPendingChangesByEntity returns pending changes for a specific entity
func (l *LocalDB) GetPendingChangesByEntity(entityType, entityUUID string) ([]PendingChange, error) {
var changes []PendingChange
err := l.db.Where("entity_type = ? AND entity_uuid = ?", entityType, entityUUID).
Order("created_at ASC").Find(&changes).Error
return changes, err
}
// DeletePendingChange removes a change from the sync queue after successful sync
func (l *LocalDB) DeletePendingChange(id int64) error {
return l.db.Delete(&PendingChange{}, id).Error
}
// IncrementPendingChangeAttempts updates the attempt counter and last error
func (l *LocalDB) IncrementPendingChangeAttempts(id int64, errorMsg string) error {
return l.db.Model(&PendingChange{}).Where("id = ?", id).Updates(map[string]interface{}{
"attempts": gorm.Expr("attempts + 1"),
"last_error": errorMsg,
}).Error
}
// CountPendingChanges returns the total number of pending changes
func (l *LocalDB) CountPendingChanges() int64 {
var count int64
l.db.Model(&PendingChange{}).Count(&count)
return count
}
// CountPendingChangesByType returns the number of pending changes by entity type
func (l *LocalDB) CountPendingChangesByType(entityType string) int64 {
var count int64
l.db.Model(&PendingChange{}).Where("entity_type = ?", entityType).Count(&count)
return count
}
// MarkChangesSynced marks multiple pending changes as synced by deleting them
func (l *LocalDB) MarkChangesSynced(ids []int64) error {
if len(ids) == 0 {
return nil
}
return l.db.Where("id IN ?", ids).Delete(&PendingChange{}).Error
}
// GetPendingCount returns the total number of pending changes (alias for CountPendingChanges)
func (l *LocalDB) GetPendingCount() int64 {
return l.CountPendingChanges()
}

View File

@@ -120,3 +120,19 @@ type LocalComponent struct {
func (LocalComponent) TableName() string { func (LocalComponent) TableName() string {
return "local_components" return "local_components"
} }
// PendingChange stores changes that need to be synced to the server
type PendingChange struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification"
EntityUUID string `gorm:"not null;index" json:"entity_uuid"`
Operation string `gorm:"not null" json:"operation"` // "create", "update", "delete"
Payload string `gorm:"type:text" json:"payload"` // JSON snapshot of the entity
CreatedAt time.Time `gorm:"not null" json:"created_at"`
Attempts int `gorm:"default:0" json:"attempts"` // Retry count for sync
LastError string `gorm:"type:text" json:"last_error,omitempty"`
}
func (PendingChange) TableName() string {
return "pending_changes"
}

View File

@@ -14,6 +14,12 @@ var (
ErrConfigForbidden = errors.New("access to configuration forbidden") ErrConfigForbidden = errors.New("access to configuration forbidden")
) )
// ConfigurationGetter is an interface for services that can retrieve configurations
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
type ConfigurationGetter interface {
GetByUUID(uuid string, userID uint) (*models.Configuration, error)
}
type ConfigurationService struct { type ConfigurationService struct {
configRepo *repository.ConfigurationRepository configRepo *repository.ConfigurationRepository
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository

View File

@@ -0,0 +1,509 @@
package services
import (
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
)
// LocalConfigurationService handles configurations in local-first mode
// All operations go through SQLite, MariaDB is used only for sync
type LocalConfigurationService struct {
localDB *localdb.LocalDB
syncService *sync.Service
quoteService *QuoteService
isOnline func() bool // Function to check if we're online
}
// NewLocalConfigurationService creates a new local-first configuration service
func NewLocalConfigurationService(
localDB *localdb.LocalDB,
syncService *sync.Service,
quoteService *QuoteService,
isOnline func() bool,
) *LocalConfigurationService {
return &LocalConfigurationService{
localDB: localDB,
syncService: syncService,
quoteService: quoteService,
isOnline: isOnline,
}
}
// Create creates a new configuration in local SQLite and queues it for sync
func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
// If online, check for new pricelists first
if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
// Log but don't fail - we can still use local pricelists
}
}
total := req.Items.Total()
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
cfg := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
CreatedAt: time.Now(),
}
// Convert to local model
localCfg := localdb.ConfigurationToLocal(cfg)
// Save to local SQLite
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
payload, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload)); err != nil {
return nil, err
}
// Record usage stats
_ = s.quoteService.RecordUsage(req.Items)
return cfg, nil
}
// GetByUUID returns a configuration from local SQLite
func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
// Convert to models.Configuration
cfg := localdb.LocalToConfiguration(localCfg)
// Allow access if user owns config or it's a template
if cfg.UserID != userID && !cfg.IsTemplate {
return nil, ErrConfigForbidden
}
return cfg, nil
}
// Update updates a configuration in local SQLite and queues it for sync
func (s *LocalConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if localCfg.OriginalUserID != userID {
return nil, ErrConfigForbidden
}
total := req.Items.Total()
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
// Update fields
localCfg.Name = req.Name
localCfg.Items = localdb.LocalConfigItems{}
for _, item := range req.Items {
localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
})
}
localCfg.TotalPrice = &total
localCfg.CustomPrice = req.CustomPrice
localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
// Save to local SQLite
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
}
// Delete deletes a configuration from local SQLite and queues it for sync
func (s *LocalConfigurationService) Delete(uuid string, userID uint) error {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
if localCfg.OriginalUserID != userID {
return ErrConfigForbidden
}
// Delete from local SQLite
if err := s.localDB.DeleteConfiguration(uuid); err != nil {
return err
}
// Add to pending sync queue
if err := s.localDB.AddPendingChange("configuration", uuid, "delete", ""); err != nil {
return err
}
return nil
}
// Rename renames a configuration
func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if localCfg.OriginalUserID != userID {
return nil, ErrConfigForbidden
}
localCfg.Name = newName
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
}
// Clone clones a configuration
func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, userID)
if err != nil {
return nil, err
}
total := original.Items.Total()
if original.ServerCount > 1 {
total *= float64(original.ServerCount)
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
CreatedAt: time.Now(),
}
localCfg := localdb.ConfigurationToLocal(clone)
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
payload, err := json.Marshal(clone)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", clone.UUID, "create", string(payload)); err != nil {
return nil, err
}
return clone, nil
}
// ListByUser returns all configurations for a user from local SQLite
func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
// Get all local configurations
localConfigs, err := s.localDB.GetConfigurations()
if err != nil {
return nil, 0, err
}
// Filter by user
var userConfigs []models.Configuration
for _, lc := range localConfigs {
if lc.OriginalUserID == userID || lc.IsTemplate {
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
}
}
total := int64(len(userConfigs))
// Apply pagination
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
start := offset
if start > len(userConfigs) {
start = len(userConfigs)
}
end := start + perPage
if end > len(userConfigs) {
end = len(userConfigs)
}
return userConfigs[start:end], total, nil
}
// RefreshPrices updates all component prices in the configuration
func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
// This requires access to component prices from local cache
// For now, return error as we need to implement component price lookup from local cache
return nil, errors.New("refresh prices not yet implemented for local-first mode")
}
// GetByUUIDNoAuth returns configuration without ownership check
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
return localdb.LocalToConfiguration(localCfg), nil
}
// UpdateNoAuth updates configuration without ownership check
func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
total := req.Items.Total()
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
localCfg.Name = req.Name
localCfg.Items = localdb.LocalConfigItems{}
for _, item := range req.Items {
localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
})
}
localCfg.TotalPrice = &total
localCfg.CustomPrice = req.CustomPrice
localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
}
// DeleteNoAuth deletes configuration without ownership check
func (s *LocalConfigurationService) DeleteNoAuth(uuid string) error {
if err := s.localDB.DeleteConfiguration(uuid); err != nil {
return err
}
return s.localDB.AddPendingChange("configuration", uuid, "delete", "")
}
// RenameNoAuth renames configuration without ownership check
func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
localCfg.Name = newName
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
}
// CloneNoAuth clones configuration without ownership check
func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
original, err := s.GetByUUIDNoAuth(configUUID)
if err != nil {
return nil, err
}
total := original.Items.Total()
if original.ServerCount > 1 {
total *= float64(original.ServerCount)
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
CreatedAt: time.Now(),
}
localCfg := localdb.ConfigurationToLocal(clone)
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
payload, err := json.Marshal(clone)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", clone.UUID, "create", string(payload)); err != nil {
return nil, err
}
return clone, nil
}
// ListAll returns all configurations without user filter
func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
localConfigs, err := s.localDB.GetConfigurations()
if err != nil {
return nil, 0, err
}
configs := make([]models.Configuration, len(localConfigs))
for i, lc := range localConfigs {
configs[i] = *localdb.LocalToConfiguration(&lc)
}
total := int64(len(configs))
// Apply pagination
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
start := offset
if start > len(configs) {
start = len(configs)
}
end := start + perPage
if end > len(configs) {
end = len(configs)
}
return configs[start:end], total, nil
}
// ListTemplates returns all template configurations
func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
localConfigs, err := s.localDB.GetConfigurations()
if err != nil {
return nil, 0, err
}
var templates []models.Configuration
for _, lc := range localConfigs {
if lc.IsTemplate {
templates = append(templates, *localdb.LocalToConfiguration(&lc))
}
}
total := int64(len(templates))
// Apply pagination
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
start := offset
if start > len(templates) {
start = len(templates)
}
end := start + perPage
if end > len(templates) {
end = len(templates)
}
return templates[start:end], total, nil
}
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
// This requires access to component prices from local cache
// For now, return error as we need to implement component price lookup from local cache
return nil, errors.New("refresh prices not yet implemented for local-first mode")
}

View File

@@ -1,24 +1,28 @@
package sync package sync
import ( import (
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
"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/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
) )
// 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 pricelistRepo *repository.PricelistRepository
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, localDB *localdb.LocalDB) *Service { func NewService(pricelistRepo *repository.PricelistRepository, configRepo *repository.ConfigurationRepository, localDB *localdb.LocalDB) *Service {
return &Service{ return &Service{
pricelistRepo: pricelistRepo, pricelistRepo: pricelistRepo,
configRepo: configRepo,
localDB: localDB, localDB: localDB,
} }
} }
@@ -213,3 +217,157 @@ func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.Local
return localPL, nil return localPL, nil
} }
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed
// This should be called before creating a new configuration when online
func (s *Service) SyncPricelistsIfNeeded() error {
needSync, err := s.NeedSync()
if err != nil {
slog.Warn("failed to check if sync needed", "error", err)
return nil // Don't fail on check error
}
if !needSync {
slog.Debug("pricelists are up to date, no sync needed")
return nil
}
slog.Info("new pricelists detected, syncing...")
_, err = s.SyncPricelists()
if err != nil {
return fmt.Errorf("syncing pricelists: %w", err)
}
return nil
}
// PushPendingChanges pushes all pending changes to the server
func (s *Service) PushPendingChanges() (int, error) {
changes, err := s.localDB.GetPendingChanges()
if err != nil {
return 0, fmt.Errorf("getting pending changes: %w", err)
}
if len(changes) == 0 {
slog.Debug("no pending changes to push")
return 0, nil
}
slog.Info("pushing pending changes", "count", len(changes))
pushed := 0
var syncedIDs []int64
for _, change := range changes {
err := s.pushSingleChange(&change)
if err != nil {
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
// Increment attempts
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
continue
}
syncedIDs = append(syncedIDs, change.ID)
pushed++
}
// Mark synced changes as complete by deleting them
if len(syncedIDs) > 0 {
if err := s.localDB.MarkChangesSynced(syncedIDs); err != nil {
slog.Error("failed to mark changes as synced", "error", err)
}
}
slog.Info("pending changes pushed", "pushed", pushed, "failed", len(changes)-pushed)
return pushed, nil
}
// pushSingleChange pushes a single pending change to the server
func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
switch change.EntityType {
case "configuration":
return s.pushConfigurationChange(change)
default:
return fmt.Errorf("unknown entity type: %s", change.EntityType)
}
}
// pushConfigurationChange pushes a configuration change to the server
func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
switch change.Operation {
case "create":
return s.pushConfigurationCreate(change)
case "update":
return s.pushConfigurationUpdate(change)
case "delete":
return s.pushConfigurationDelete(change)
default:
return fmt.Errorf("unknown operation: %s", change.Operation)
}
}
// pushConfigurationCreate creates a configuration on the server
func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
var cfg models.Configuration
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
return fmt.Errorf("unmarshaling configuration: %w", err)
}
// Create on server
if err := s.configRepo.Create(&cfg); err != nil {
return fmt.Errorf("creating configuration on server: %w", err)
}
// Update local configuration with server ID
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
if err == nil {
serverID := cfg.ID
localCfg.ServerID = &serverID
localCfg.SyncStatus = "synced"
s.localDB.SaveConfiguration(localCfg)
}
slog.Info("configuration created on server", "uuid", cfg.UUID, "server_id", cfg.ID)
return nil
}
// pushConfigurationUpdate updates a configuration on the server
func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
var cfg models.Configuration
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
return fmt.Errorf("unmarshaling configuration: %w", err)
}
// Update on server
if err := s.configRepo.Update(&cfg); err != nil {
return fmt.Errorf("updating configuration on server: %w", err)
}
// Update local sync status
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
if err == nil {
localCfg.SyncStatus = "synced"
s.localDB.SaveConfiguration(localCfg)
}
slog.Info("configuration updated on server", "uuid", cfg.UUID)
return nil
}
// pushConfigurationDelete deletes a configuration from the server
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
// Get the configuration from server by UUID to get the ID
cfg, err := s.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)
return nil
}
// Delete from server
if err := s.configRepo.Delete(cfg.ID); err != nil {
return fmt.Errorf("deleting configuration from server: %w", err)
}
slog.Info("configuration deleted from server", "uuid", change.EntityUUID)
return nil
}

View File

@@ -0,0 +1,95 @@
package sync
import (
"context"
"log/slog"
"time"
"gorm.io/gorm"
)
// Worker performs background synchronization at regular intervals
type Worker struct {
service *Service
db *gorm.DB
interval time.Duration
logger *slog.Logger
stopCh chan struct{}
}
// NewWorker creates a new background sync worker
func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker {
return &Worker{
service: service,
db: db,
interval: interval,
logger: slog.Default(),
stopCh: make(chan struct{}),
}
}
// isOnline checks if the database connection is available
func (w *Worker) isOnline() bool {
sqlDB, err := w.db.DB()
if err != nil {
return false
}
return sqlDB.Ping() == nil
}
// Start begins the background sync loop in a goroutine
func (w *Worker) Start(ctx context.Context) {
w.logger.Info("starting background sync worker", "interval", w.interval)
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
// Run once immediately
w.runSync()
for {
select {
case <-ctx.Done():
w.logger.Info("background sync worker stopped by context")
return
case <-w.stopCh:
w.logger.Info("background sync worker stopped")
return
case <-ticker.C:
w.runSync()
}
}
}
// Stop gracefully stops the worker
func (w *Worker) Stop() {
w.logger.Info("stopping background sync worker")
close(w.stopCh)
}
// runSync performs a single sync iteration
func (w *Worker) runSync() {
// Check if online
if !w.isOnline() {
w.logger.Debug("offline, skipping background sync")
return
}
w.logger.Debug("running background sync")
// Push pending changes first
pushed, err := w.service.PushPendingChanges()
if err != nil {
w.logger.Warn("failed to push pending changes", "error", err)
} else if pushed > 0 {
w.logger.Info("pushed pending changes", "count", pushed)
}
// Then check for new pricelists
err = w.service.SyncPricelistsIfNeeded()
if err != nil {
w.logger.Warn("failed to sync pricelists", "error", err)
}
w.logger.Debug("background sync completed")
}