Add Phase 2: Local SQLite database with sync functionality
Implements complete offline-first architecture with SQLite caching and MariaDB synchronization. Key features: - Local SQLite database for offline operation (data/quoteforge.db) - Connection settings with encrypted credentials - Component and pricelist caching with auto-sync - Sync API endpoints (/api/sync/status, /components, /pricelists, /all) - Real-time sync status indicator in UI with auto-refresh - Offline mode detection middleware - Migration tool for database initialization - Setup wizard for initial configuration New components: - internal/localdb: SQLite repository layer (components, pricelists, sync) - internal/services/sync: Synchronization service - internal/handlers/sync: Sync API handlers - internal/handlers/setup: Setup wizard handlers - internal/middleware/offline: Offline detection - cmd/migrate: Database migration tool UI improvements: - Setup page for database configuration - Sync status indicator with online/offline detection - Warning icons for pending synchronization - Auto-refresh every 30 seconds Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -194,6 +194,149 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
|
||||
return s.configRepo.ListByUser(userID, offset, perPage)
|
||||
}
|
||||
|
||||
// ListAll returns all configurations without user filter (for use when auth is disabled)
|
||||
func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.configRepo.ListAll(offset, perPage)
|
||||
}
|
||||
|
||||
// GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled)
|
||||
func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// UpdateNoAuth updates configuration without ownership check
|
||||
func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config.Name = req.Name
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// DeleteNoAuth deletes configuration without ownership check
|
||||
func (s *ConfigurationService) DeleteNoAuth(uuid string) error {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
return s.configRepo.Delete(config.ID)
|
||||
}
|
||||
|
||||
// RenameNoAuth renames configuration without ownership check
|
||||
func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
config.Name = newName
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// CloneNoAuth clones configuration without ownership check
|
||||
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
|
||||
original, err := s.configRepo.GetByUUID(configUUID)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
total := original.Items.Total()
|
||||
if original.ServerCount > 1 {
|
||||
total *= float64(original.ServerCount)
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
UserID: userID, // Use provided user ID
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
// RefreshPricesNoAuth refreshes prices without ownership check
|
||||
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||
for i, item := range config.Items {
|
||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *metadata.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
config.Items = updatedItems
|
||||
total := updatedItems.Total()
|
||||
if config.ServerCount > 1 {
|
||||
total *= float64(config.ServerCount)
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
now := time.Now()
|
||||
config.PriceUpdatedAt = &now
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
|
||||
156
internal/services/pricelist/service.go
Normal file
156
internal/services/pricelist/service.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package pricelist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *repository.PricelistRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
componentRepo: componentRepo,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
||||
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
||||
version, err := s.repo.GenerateVersion()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating version: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
|
||||
|
||||
pricelist := &models.Pricelist{
|
||||
Version: version,
|
||||
CreatedBy: createdBy,
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiresAt,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(pricelist); err != nil {
|
||||
return nil, fmt.Errorf("creating pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Get all components with prices from qt_lot_metadata
|
||||
var metadata []models.LotMetadata
|
||||
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting lot metadata: %w", err)
|
||||
}
|
||||
|
||||
// Create pricelist items with all price settings
|
||||
items := make([]models.PricelistItem, 0, len(metadata))
|
||||
for _, m := range metadata {
|
||||
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, models.PricelistItem{
|
||||
PricelistID: pricelist.ID,
|
||||
LotName: m.LotName,
|
||||
Price: *m.CurrentPrice,
|
||||
PriceMethod: string(m.PriceMethod),
|
||||
PricePeriodDays: m.PricePeriodDays,
|
||||
PriceCoefficient: m.PriceCoefficient,
|
||||
ManualPrice: m.ManualPrice,
|
||||
MetaPrices: m.MetaPrices,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.repo.CreateItems(items); err != nil {
|
||||
// Clean up the pricelist if items creation fails
|
||||
s.repo.Delete(pricelist.ID)
|
||||
return nil, fmt.Errorf("creating pricelist items: %w", err)
|
||||
}
|
||||
|
||||
pricelist.ItemCount = len(items)
|
||||
|
||||
slog.Info("pricelist created",
|
||||
"id", pricelist.ID,
|
||||
"version", pricelist.Version,
|
||||
"items", len(items),
|
||||
"created_by", createdBy,
|
||||
)
|
||||
|
||||
return pricelist, nil
|
||||
}
|
||||
|
||||
// List returns pricelists with pagination
|
||||
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.List(offset, perPage)
|
||||
}
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
|
||||
return s.repo.GetByID(id)
|
||||
}
|
||||
|
||||
// GetItems returns pricelist items with pagination
|
||||
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.GetItems(pricelistID, offset, perPage, search)
|
||||
}
|
||||
|
||||
// Delete deletes a pricelist by ID
|
||||
func (s *Service) Delete(id uint) error {
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
// CanWrite returns true if the user can create pricelists
|
||||
func (s *Service) CanWrite() bool {
|
||||
return s.repo.CanWrite()
|
||||
}
|
||||
|
||||
// CanWriteDebug returns write permission status with debug info
|
||||
func (s *Service) CanWriteDebug() (bool, string) {
|
||||
return s.repo.CanWriteDebug()
|
||||
}
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
||||
return s.repo.GetLatestActive()
|
||||
}
|
||||
|
||||
// CleanupExpired deletes expired and unused pricelists
|
||||
func (s *Service) CleanupExpired() (int, error) {
|
||||
expired, err := s.repo.GetExpiredUnused()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, pl := range expired {
|
||||
if err := s.repo.Delete(pl.ID); err != nil {
|
||||
slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
slog.Info("cleaned up expired pricelists", "deleted", deleted)
|
||||
return deleted, nil
|
||||
}
|
||||
215
internal/services/sync/service.go
Normal file
215
internal/services/sync/service.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
// Service handles synchronization between MariaDB and local SQLite
|
||||
type Service struct {
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
// NewService creates a new sync service
|
||||
func NewService(pricelistRepo *repository.PricelistRepository, localDB *localdb.LocalDB) *Service {
|
||||
return &Service{
|
||||
pricelistRepo: pricelistRepo,
|
||||
localDB: localDB,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncStatus represents the current sync status
|
||||
type SyncStatus struct {
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
LocalPricelists int `json:"local_pricelists"`
|
||||
NeedsSync bool `json:"needs_sync"`
|
||||
}
|
||||
|
||||
// GetStatus returns the current sync status
|
||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
|
||||
// Count server pricelists
|
||||
serverPricelists, _, err := s.pricelistRepo.List(0, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("counting server pricelists: %w", err)
|
||||
}
|
||||
|
||||
// Count local pricelists
|
||||
localCount := s.localDB.CountLocalPricelists()
|
||||
|
||||
needsSync, _ := s.NeedSync()
|
||||
|
||||
return &SyncStatus{
|
||||
LastSyncAt: lastSync,
|
||||
ServerPricelists: len(serverPricelists),
|
||||
LocalPricelists: int(localCount),
|
||||
NeedsSync: needsSync,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NeedSync checks if synchronization is needed
|
||||
// Returns true if there are new pricelists on server or last sync was >1 hour ago
|
||||
func (s *Service) NeedSync() (bool, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
|
||||
// If never synced, need sync
|
||||
if lastSync == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If last sync was more than 1 hour ago, suggest sync
|
||||
if time.Since(*lastSync) > time.Hour {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check if there are new pricelists on server
|
||||
latestServer, err := s.pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
// If no pricelists on server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
|
||||
latestLocal, err := s.localDB.GetLatestLocalPricelist()
|
||||
if err != nil {
|
||||
// No local pricelists, need to sync
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If server has newer pricelist, need sync
|
||||
if latestServer.ID != latestLocal.ServerID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
||||
func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Info("starting pricelist sync")
|
||||
|
||||
// Get all active pricelists from server (up to 100)
|
||||
serverPricelists, _, err := s.pricelistRepo.List(0, 100)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting server pricelists: %w", err)
|
||||
}
|
||||
|
||||
synced := 0
|
||||
for _, pl := range serverPricelists {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
if existing != nil {
|
||||
// Already synced, skip
|
||||
continue
|
||||
}
|
||||
|
||||
// Create local pricelist
|
||||
localPL := &localdb.LocalPricelist{
|
||||
ServerID: pl.ID,
|
||||
Version: pl.Version,
|
||||
Name: pl.Notification, // Using notification as name
|
||||
CreatedAt: pl.CreatedAt,
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}
|
||||
|
||||
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
|
||||
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
synced++
|
||||
slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID)
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
s.localDB.SetLastSyncTime(time.Now())
|
||||
|
||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||
return synced, nil
|
||||
}
|
||||
|
||||
// SyncPricelistItems synchronizes items for a specific pricelist
|
||||
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
// Get local pricelist
|
||||
localPL, err := s.localDB.GetLocalPricelistByID(localPricelistID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting local pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Check if items already exist
|
||||
existingCount := s.localDB.CountLocalPricelistItems(localPricelistID)
|
||||
if existingCount > 0 {
|
||||
slog.Debug("pricelist items already synced", "pricelist_id", localPricelistID, "count", existingCount)
|
||||
return int(existingCount), nil
|
||||
}
|
||||
|
||||
// Get items from server
|
||||
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
||||
}
|
||||
|
||||
// Convert and save locally
|
||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||
for i, item := range serverItems {
|
||||
localItems[i] = localdb.LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems))
|
||||
return len(localItems), nil
|
||||
}
|
||||
|
||||
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
||||
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("local pricelist not found for server ID %d", serverPricelistID)
|
||||
}
|
||||
return s.SyncPricelistItems(localPL.ID)
|
||||
}
|
||||
|
||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
||||
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
||||
}
|
||||
|
||||
// GetPricelistForOffline returns a pricelist suitable for offline use
|
||||
// If items are not synced, it will sync them first
|
||||
func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.LocalPricelist, error) {
|
||||
// Ensure pricelist is synced
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||
if err != nil {
|
||||
// Try to sync pricelists first
|
||||
if _, err := s.SyncPricelists(); err != nil {
|
||||
return nil, fmt.Errorf("syncing pricelists: %w", err)
|
||||
}
|
||||
|
||||
// Try again
|
||||
localPL, err = s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pricelist not found on server: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure items are synced
|
||||
if _, err := s.SyncPricelistItems(localPL.ID); err != nil {
|
||||
return nil, fmt.Errorf("syncing pricelist items: %w", err)
|
||||
}
|
||||
|
||||
return localPL, nil
|
||||
}
|
||||
Reference in New Issue
Block a user