- Implement RefreshPrices for local-first mode - Update prices from local_components.current_price cache - Graceful degradation when component not found - Add PriceUpdatedAt timestamp to LocalConfiguration model - Support both authenticated and no-auth price refresh - Fix sync duplicate entry bug - pushConfigurationUpdate now ensures server_id exists before update - Fetch from LocalConfiguration.ServerID or search on server if missing - Update local config with server_id after finding - Add application auto-restart after settings save - Implement restartProcess() using syscall.Exec - Setup handler signals restart via channel - Setup page polls /health endpoint and redirects when ready - Add "Back" button on setup page when settings exist - Fix setup handler password handling - Use PasswordEncrypted field consistently - Support empty password by using saved value - Improve sync status handling - Add fallback for is_offline check in SyncStatusPartial - Enhance background sync logging with prefixes - Update CLAUDE.md documentation - Mark Phase 2.5 tasks as complete - Add UI Improvements section with future tasks - Update SQLite tables documentation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
315 lines
8.3 KiB
Go
315 lines
8.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
"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
|
|
tmpl *template.Template
|
|
}
|
|
|
|
// NewSyncHandler creates a new sync handler
|
|
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB, templatesPath string) (*SyncHandler, error) {
|
|
// Load sync_status partial template
|
|
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
|
tmpl, err := template.ParseFiles(partialPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &SyncHandler{
|
|
localDB: localDB,
|
|
syncService: syncService,
|
|
mariaDB: mariaDB,
|
|
tmpl: tmpl,
|
|
}, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
|
|
// SyncStatusPartial renders the sync status partial for htmx
|
|
// GET /partials/sync-status
|
|
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
|
// Check online status from middleware
|
|
isOfflineValue, exists := c.Get("is_offline")
|
|
isOffline := false
|
|
if exists {
|
|
isOffline = isOfflineValue.(bool)
|
|
} else {
|
|
// Fallback: check directly if middleware didn't set it
|
|
isOffline = !h.checkOnline()
|
|
slog.Warn("is_offline not found in context, checking directly")
|
|
}
|
|
|
|
// Get pending count
|
|
pendingCount := h.localDB.GetPendingCount()
|
|
|
|
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount)
|
|
|
|
data := gin.H{
|
|
"IsOffline": isOffline,
|
|
"PendingCount": pendingCount,
|
|
}
|
|
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
|
|
slog.Error("failed to render sync_status template", "error", err)
|
|
c.String(http.StatusInternalServerError, "Template error: "+err.Error())
|
|
}
|
|
}
|