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:
41
CLAUDE.md
41
CLAUDE.md
@@ -9,26 +9,33 @@
|
||||
### Phase 2: Local SQLite Database ✅ DONE
|
||||
|
||||
### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS
|
||||
Приложение должно полностью работать без MariaDB, синхронизация при восстановлении связи.
|
||||
**Local-first architecture:** приложение ВСЕГДА работает с SQLite, MariaDB только для синхронизации.
|
||||
|
||||
**Architecture:**
|
||||
- Dual-source pattern: все операции идут через unified service layer
|
||||
- Online: read/write MariaDB, async cache to SQLite
|
||||
- Offline: read/write SQLite, queue changes for sync
|
||||
**Принцип работы:**
|
||||
- ВСЕ операции (CRUD) выполняются в SQLite
|
||||
- При создании конфигурации:
|
||||
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:**
|
||||
- ❌ Unified repository interface (online/offline transparent switching)
|
||||
- ❌ Sync queue table (pending_changes: entity_type, entity_uuid, operation, payload, created_at)
|
||||
- ❌ Background sync worker (push local changes when online)
|
||||
- ❌ Conflict resolution (last-write-wins by updated_at, or manual)
|
||||
- ❌ Initial data bootstrap (first sync downloads all needed data)
|
||||
- ❌ 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
|
||||
- ❌ Conflict resolution (last-write-wins or manual)
|
||||
- ❌ UI: pending counter in header
|
||||
- ❌ UI: manual sync button
|
||||
- ❌ UI: offline indicator (middleware already exists)
|
||||
- ❌ RefreshPrices for local mode (via local_components)
|
||||
|
||||
### Phase 3: Projects and Specifications
|
||||
- qt_projects, qt_specifications tables (MariaDB)
|
||||
|
||||
@@ -108,12 +108,19 @@ func main() {
|
||||
}
|
||||
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
router, err := setupRouter(db, cfg, local, dbUserID)
|
||||
router, syncService, err := setupRouter(db, cfg, local, dbUserID)
|
||||
if err != nil {
|
||||
slog.Error("failed to setup router", "error", err)
|
||||
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{
|
||||
Addr: cfg.Address(),
|
||||
Handler: router,
|
||||
@@ -135,6 +142,11 @@ func main() {
|
||||
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
@@ -282,7 +294,7 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
||||
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
|
||||
componentRepo := repository.NewComponentRepository(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)
|
||||
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo)
|
||||
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
|
||||
syncService := sync.NewService(pricelistRepo, local)
|
||||
syncService := sync.NewService(pricelistRepo, configRepo, 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
|
||||
componentHandler := handlers.NewComponentHandler(componentService)
|
||||
@@ -313,13 +336,13 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
// Setup handler (for reconfiguration)
|
||||
setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
|
||||
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)
|
||||
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Router
|
||||
@@ -584,10 +607,13 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
syncAPI.POST("/components", syncHandler.SyncComponents)
|
||||
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||
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 {
|
||||
|
||||
@@ -12,13 +12,13 @@ import (
|
||||
|
||||
type ExportHandler struct {
|
||||
exportService *services.ExportService
|
||||
configService *services.ConfigurationService
|
||||
configService services.ConfigurationGetter
|
||||
componentService *services.ComponentService
|
||||
}
|
||||
|
||||
func NewExportHandler(
|
||||
exportService *services.ExportService,
|
||||
configService *services.ConfigurationService,
|
||||
configService services.ConfigurationGetter,
|
||||
componentService *services.ComponentService,
|
||||
) *ExportHandler {
|
||||
return &ExportHandler{
|
||||
|
||||
@@ -215,3 +215,58 @@ func (h *SyncHandler) checkOnline() bool {
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
161
internal/localdb/converters.go
Normal file
161
internal/localdb/converters.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
&LocalPricelistItem{},
|
||||
&LocalComponent{},
|
||||
&AppSetting{},
|
||||
&PendingChange{},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
||||
}
|
||||
@@ -337,3 +338,73 @@ func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
||||
// Delete pricelist
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -120,3 +120,19 @@ type LocalComponent struct {
|
||||
func (LocalComponent) TableName() string {
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ var (
|
||||
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 {
|
||||
configRepo *repository.ConfigurationRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
|
||||
509
internal/services/local_configuration.go
Normal file
509
internal/services/local_configuration.go
Normal 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")
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
// Service handles synchronization between MariaDB and local SQLite
|
||||
type Service struct {
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
configRepo *repository.ConfigurationRepository
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
// 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{
|
||||
pricelistRepo: pricelistRepo,
|
||||
configRepo: configRepo,
|
||||
localDB: localDB,
|
||||
}
|
||||
}
|
||||
@@ -213,3 +217,157 @@ func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.Local
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
95
internal/services/sync/worker.go
Normal file
95
internal/services/sync/worker.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user