506 lines
15 KiB
Go
506 lines
15 KiB
Go
package handlers
|
|
|
|
import (
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// SyncHandler handles sync API endpoints
|
|
type SyncHandler struct {
|
|
localDB *localdb.LocalDB
|
|
syncService *sync.Service
|
|
connMgr *db.ConnectionManager
|
|
autoSyncInterval time.Duration
|
|
onlineGraceFactor float64
|
|
tmpl *template.Template
|
|
}
|
|
|
|
// NewSyncHandler creates a new sync handler
|
|
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
|
// Load sync_status partial template
|
|
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
|
var tmpl *template.Template
|
|
var err error
|
|
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
|
tmpl, err = template.ParseFiles(partialPath)
|
|
} else {
|
|
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &SyncHandler{
|
|
localDB: localDB,
|
|
syncService: syncService,
|
|
connMgr: connMgr,
|
|
autoSyncInterval: autoSyncInterval,
|
|
onlineGraceFactor: 1.10,
|
|
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
|
|
}
|
|
|
|
// Get database connection from ConnectionManager
|
|
mariaDB, err := h.connMgr.GetDB()
|
|
if err != nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"success": false,
|
|
"error": "Database connection failed: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
result, err := h.localDB.SyncComponents(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(),
|
|
})
|
|
h.syncService.RecordSyncHeartbeat()
|
|
}
|
|
|
|
// SyncAllResponse represents result of full sync
|
|
type SyncAllResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
PendingPushed int `json:"pending_pushed"`
|
|
ComponentsSynced int `json:"components_synced"`
|
|
PricelistsSynced int `json:"pricelists_synced"`
|
|
ProjectsImported int `json:"projects_imported"`
|
|
ProjectsUpdated int `json:"projects_updated"`
|
|
ProjectsSkipped int `json:"projects_skipped"`
|
|
ConfigurationsImported int `json:"configurations_imported"`
|
|
ConfigurationsUpdated int `json:"configurations_updated"`
|
|
ConfigurationsSkipped int `json:"configurations_skipped"`
|
|
Duration string `json:"duration"`
|
|
}
|
|
|
|
// SyncAll performs full bidirectional sync:
|
|
// - push pending local changes (projects/configurations) to server
|
|
// - pull components, pricelists, projects, and configurations from server
|
|
// 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 pendingPushed, componentsSynced, pricelistsSynced int
|
|
|
|
// Push local pending changes first (projects/configurations)
|
|
pendingPushed, err := h.syncService.PushPendingChanges()
|
|
if err != nil {
|
|
slog.Error("pending push failed during full sync", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"success": false,
|
|
"error": "Pending changes push failed: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Sync components
|
|
mariaDB, err := h.connMgr.GetDB()
|
|
if err != nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"success": false,
|
|
"error": "Database connection failed: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
compResult, err := h.localDB.SyncComponents(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(),
|
|
"pending_pushed": pendingPushed,
|
|
"components_synced": componentsSynced,
|
|
})
|
|
return
|
|
}
|
|
|
|
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
|
if err != nil {
|
|
slog.Error("project import failed during full sync", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"success": false,
|
|
"error": "Project import failed: " + err.Error(),
|
|
"pending_pushed": pendingPushed,
|
|
"components_synced": componentsSynced,
|
|
"pricelists_synced": pricelistsSynced,
|
|
})
|
|
return
|
|
}
|
|
|
|
configsResult, err := h.syncService.ImportConfigurationsToLocal()
|
|
if err != nil {
|
|
slog.Error("configuration import failed during full sync", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"success": false,
|
|
"error": "Configuration import failed: " + err.Error(),
|
|
"pending_pushed": pendingPushed,
|
|
"components_synced": componentsSynced,
|
|
"pricelists_synced": pricelistsSynced,
|
|
"projects_imported": projectsResult.Imported,
|
|
"projects_updated": projectsResult.Updated,
|
|
"projects_skipped": projectsResult.Skipped,
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SyncAllResponse{
|
|
Success: true,
|
|
Message: "Full sync completed successfully",
|
|
PendingPushed: pendingPushed,
|
|
ComponentsSynced: componentsSynced,
|
|
PricelistsSynced: pricelistsSynced,
|
|
ProjectsImported: projectsResult.Imported,
|
|
ProjectsUpdated: projectsResult.Updated,
|
|
ProjectsSkipped: projectsResult.Skipped,
|
|
ConfigurationsImported: configsResult.Imported,
|
|
ConfigurationsUpdated: configsResult.Updated,
|
|
ConfigurationsSkipped: configsResult.Skipped,
|
|
Duration: time.Since(startTime).String(),
|
|
})
|
|
h.syncService.RecordSyncHeartbeat()
|
|
}
|
|
|
|
// checkOnline checks if MariaDB is accessible
|
|
func (h *SyncHandler) checkOnline() bool {
|
|
return h.connMgr.IsOnline()
|
|
}
|
|
|
|
// 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(),
|
|
})
|
|
h.syncService.RecordSyncHeartbeat()
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
|
|
// SyncInfoResponse represents sync information
|
|
type SyncInfoResponse struct {
|
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
|
IsOnline bool `json:"is_online"`
|
|
ErrorCount int `json:"error_count"`
|
|
Errors []SyncError `json:"errors,omitempty"`
|
|
}
|
|
|
|
type SyncUsersStatusResponse struct {
|
|
IsOnline bool `json:"is_online"`
|
|
AutoSyncIntervalSeconds int64 `json:"auto_sync_interval_seconds"`
|
|
OnlineThresholdSeconds int64 `json:"online_threshold_seconds"`
|
|
GeneratedAt time.Time `json:"generated_at"`
|
|
Users []sync.UserSyncStatus `json:"users"`
|
|
}
|
|
|
|
// SyncError represents a sync error
|
|
type SyncError struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// GetInfo returns sync information for modal
|
|
// GET /api/sync/info
|
|
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|
// Check online status by pinging MariaDB
|
|
isOnline := h.checkOnline()
|
|
|
|
// Get sync times
|
|
lastPricelistSync := h.localDB.GetLastSyncTime()
|
|
|
|
// Get error count (only changes with LastError != "")
|
|
errorCount := int(h.localDB.CountErroredChanges())
|
|
|
|
// Get recent errors (last 10)
|
|
changes, err := h.localDB.GetPendingChanges()
|
|
if err != nil {
|
|
slog.Error("failed to get pending changes for sync info", "error", err)
|
|
// Even if we can't get changes, we can still return the error count
|
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
|
LastSyncAt: lastPricelistSync,
|
|
IsOnline: isOnline,
|
|
ErrorCount: errorCount,
|
|
Errors: []SyncError{}, // Return empty errors list
|
|
})
|
|
return
|
|
}
|
|
|
|
var errors []SyncError
|
|
for _, change := range changes {
|
|
// Check if there's a last error and it's not empty
|
|
if change.LastError != "" {
|
|
errors = append(errors, SyncError{
|
|
Timestamp: change.CreatedAt,
|
|
Message: change.LastError,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Limit to last 10 errors
|
|
if len(errors) > 10 {
|
|
errors = errors[:10]
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
|
LastSyncAt: lastPricelistSync,
|
|
IsOnline: isOnline,
|
|
ErrorCount: errorCount,
|
|
Errors: errors,
|
|
})
|
|
}
|
|
|
|
// GetUsersStatus returns last sync timestamps for users with sync heartbeats.
|
|
// GET /api/sync/users-status
|
|
func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
|
|
threshold := time.Duration(float64(h.autoSyncInterval) * h.onlineGraceFactor)
|
|
isOnline := h.checkOnline()
|
|
|
|
if !isOnline {
|
|
c.JSON(http.StatusOK, SyncUsersStatusResponse{
|
|
IsOnline: false,
|
|
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
|
|
OnlineThresholdSeconds: int64(threshold.Seconds()),
|
|
GeneratedAt: time.Now().UTC(),
|
|
Users: []sync.UserSyncStatus{},
|
|
})
|
|
return
|
|
}
|
|
|
|
// Keep current client heartbeat fresh so app version is available in the table.
|
|
h.syncService.RecordSyncHeartbeat()
|
|
|
|
users, err := h.syncService.ListUserSyncStatuses(threshold)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SyncUsersStatusResponse{
|
|
IsOnline: true,
|
|
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
|
|
OnlineThresholdSeconds: int64(threshold.Seconds()),
|
|
GeneratedAt: time.Now().UTC(),
|
|
Users: users,
|
|
})
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|