Files
QuoteForge/internal/services/local_configuration.go
Michael Chus be77256d4e 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>
2026-02-01 22:17:00 +03:00

510 lines
13 KiB
Go

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")
}