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: 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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
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{},
|
&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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
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