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>
218 lines
5.7 KiB
Go
218 lines
5.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SyncHandler handles sync API endpoints
|
|
type SyncHandler struct {
|
|
localDB *localdb.LocalDB
|
|
syncService *sync.Service
|
|
mariaDB *gorm.DB
|
|
}
|
|
|
|
// NewSyncHandler creates a new sync handler
|
|
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB) *SyncHandler {
|
|
return &SyncHandler{
|
|
localDB: localDB,
|
|
syncService: syncService,
|
|
mariaDB: mariaDB,
|
|
}
|
|
}
|
|
|
|
// SyncStatusResponse represents the sync status
|
|
type SyncStatusResponse struct {
|
|
LastComponentSync *time.Time `json:"last_component_sync"`
|
|
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
|
IsOnline bool `json:"is_online"`
|
|
ComponentsCount int64 `json:"components_count"`
|
|
PricelistsCount int64 `json:"pricelists_count"`
|
|
ServerPricelists int `json:"server_pricelists"`
|
|
NeedComponentSync bool `json:"need_component_sync"`
|
|
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
|
}
|
|
|
|
// GetStatus returns current sync status
|
|
// GET /api/sync/status
|
|
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
|
// Check online status by pinging MariaDB
|
|
isOnline := h.checkOnline()
|
|
|
|
// Get sync times
|
|
lastComponentSync := h.localDB.GetComponentSyncTime()
|
|
lastPricelistSync := h.localDB.GetLastSyncTime()
|
|
|
|
// Get counts
|
|
componentsCount := h.localDB.CountLocalComponents()
|
|
pricelistsCount := h.localDB.CountLocalPricelists()
|
|
|
|
// Get server pricelist count if online
|
|
serverPricelists := 0
|
|
needPricelistSync := false
|
|
if isOnline {
|
|
status, err := h.syncService.GetStatus()
|
|
if err == nil {
|
|
serverPricelists = status.ServerPricelists
|
|
needPricelistSync = status.NeedsSync
|
|
}
|
|
}
|
|
|
|
// Check if component sync is needed (older than 24 hours)
|
|
needComponentSync := h.localDB.NeedComponentSync(24)
|
|
|
|
c.JSON(http.StatusOK, SyncStatusResponse{
|
|
LastComponentSync: lastComponentSync,
|
|
LastPricelistSync: lastPricelistSync,
|
|
IsOnline: isOnline,
|
|
ComponentsCount: componentsCount,
|
|
PricelistsCount: pricelistsCount,
|
|
ServerPricelists: serverPricelists,
|
|
NeedComponentSync: needComponentSync,
|
|
NeedPricelistSync: needPricelistSync,
|
|
})
|
|
}
|
|
|
|
// SyncResultResponse represents sync operation result
|
|
type SyncResultResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
Synced int `json:"synced"`
|
|
Duration string `json:"duration"`
|
|
}
|
|
|
|
// SyncComponents syncs components from MariaDB to local SQLite
|
|
// POST /api/sync/components
|
|
func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
|
if !h.checkOnline() {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"success": false,
|
|
"error": "Database is offline",
|
|
})
|
|
return
|
|
}
|
|
|
|
result, err := h.localDB.SyncComponents(h.mariaDB)
|
|
if err != nil {
|
|
slog.Error("component sync failed", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"success": false,
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SyncResultResponse{
|
|
Success: true,
|
|
Message: "Components synced successfully",
|
|
Synced: result.TotalSynced,
|
|
Duration: result.Duration.String(),
|
|
})
|
|
}
|
|
|
|
// SyncPricelists syncs pricelists from MariaDB to local SQLite
|
|
// POST /api/sync/pricelists
|
|
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
|
if !h.checkOnline() {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"success": false,
|
|
"error": "Database is offline",
|
|
})
|
|
return
|
|
}
|
|
|
|
startTime := time.Now()
|
|
synced, err := h.syncService.SyncPricelists()
|
|
if err != nil {
|
|
slog.Error("pricelist sync failed", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"success": false,
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SyncResultResponse{
|
|
Success: true,
|
|
Message: "Pricelists synced successfully",
|
|
Synced: synced,
|
|
Duration: time.Since(startTime).String(),
|
|
})
|
|
}
|
|
|
|
// SyncAllResponse represents result of full sync
|
|
type SyncAllResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
ComponentsSynced int `json:"components_synced"`
|
|
PricelistsSynced int `json:"pricelists_synced"`
|
|
Duration string `json:"duration"`
|
|
}
|
|
|
|
// SyncAll syncs both components and pricelists
|
|
// POST /api/sync/all
|
|
func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|
if !h.checkOnline() {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"success": false,
|
|
"error": "Database is offline",
|
|
})
|
|
return
|
|
}
|
|
|
|
startTime := time.Now()
|
|
var componentsSynced, pricelistsSynced int
|
|
|
|
// Sync components
|
|
compResult, err := h.localDB.SyncComponents(h.mariaDB)
|
|
if err != nil {
|
|
slog.Error("component sync failed during full sync", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"success": false,
|
|
"error": "Component sync failed: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
componentsSynced = compResult.TotalSynced
|
|
|
|
// Sync pricelists
|
|
pricelistsSynced, err = h.syncService.SyncPricelists()
|
|
if err != nil {
|
|
slog.Error("pricelist sync failed during full sync", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"success": false,
|
|
"error": "Pricelist sync failed: " + err.Error(),
|
|
"components_synced": componentsSynced,
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SyncAllResponse{
|
|
Success: true,
|
|
Message: "Full sync completed successfully",
|
|
ComponentsSynced: componentsSynced,
|
|
PricelistsSynced: pricelistsSynced,
|
|
Duration: time.Since(startTime).String(),
|
|
})
|
|
}
|
|
|
|
// checkOnline checks if MariaDB is accessible
|
|
func (h *SyncHandler) checkOnline() bool {
|
|
sqlDB, err := h.mariaDB.DB()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
if err := sqlDB.Ping(); err != nil {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|