749 lines
24 KiB
Go
749 lines
24 KiB
Go
package handlers
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"html/template"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
stdsync "sync"
|
||
"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
|
||
readinessMu stdsync.Mutex
|
||
readinessCached *sync.SyncReadiness
|
||
readinessCachedAt time.Time
|
||
}
|
||
|
||
// NewSyncHandler creates a new sync handler
|
||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
||
// Load sync_status partial template
|
||
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"`
|
||
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
|
||
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"`
|
||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||
}
|
||
|
||
type SyncReadinessResponse struct {
|
||
Status string `json:"status"`
|
||
Blocked bool `json:"blocked"`
|
||
ReasonCode string `json:"reason_code,omitempty"`
|
||
ReasonText string `json:"reason_text,omitempty"`
|
||
RequiredMinAppVersion *string `json:"required_min_app_version,omitempty"`
|
||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||
}
|
||
|
||
// GetStatus returns current sync status
|
||
// GET /api/sync/status
|
||
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||
connStatus := h.connMgr.GetStatus()
|
||
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||
lastComponentSync := h.localDB.GetComponentSyncTime()
|
||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||
componentsCount := h.localDB.CountLocalComponents()
|
||
pricelistsCount := h.localDB.CountLocalPricelists()
|
||
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||
needComponentSync := h.localDB.NeedComponentSync(24)
|
||
readiness := h.getReadinessLocal()
|
||
|
||
c.JSON(http.StatusOK, SyncStatusResponse{
|
||
LastComponentSync: lastComponentSync,
|
||
LastPricelistSync: lastPricelistSync,
|
||
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||
LastPricelistSyncError: lastPricelistSyncError,
|
||
HasIncompleteServerSync: hasFailedSync,
|
||
KnownServerChangesMiss: hasFailedSync,
|
||
IsOnline: isOnline,
|
||
ComponentsCount: componentsCount,
|
||
PricelistsCount: pricelistsCount,
|
||
ServerPricelists: 0,
|
||
NeedComponentSync: needComponentSync,
|
||
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
|
||
Readiness: readiness,
|
||
})
|
||
}
|
||
|
||
// GetReadiness returns sync readiness guard status.
|
||
// GET /api/sync/readiness
|
||
func (h *SyncHandler) GetReadiness(c *gin.Context) {
|
||
readiness, err := h.syncService.GetReadiness()
|
||
if err != nil && readiness == nil {
|
||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||
return
|
||
}
|
||
if readiness == nil {
|
||
c.JSON(http.StatusOK, SyncReadinessResponse{Status: sync.ReadinessUnknown, Blocked: false})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, SyncReadinessResponse{
|
||
Status: readiness.Status,
|
||
Blocked: readiness.Blocked,
|
||
ReasonCode: readiness.ReasonCode,
|
||
ReasonText: readiness.ReasonText,
|
||
RequiredMinAppVersion: readiness.RequiredMinAppVersion,
|
||
LastCheckedAt: readiness.LastCheckedAt,
|
||
})
|
||
}
|
||
|
||
func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
|
||
readiness, err := h.syncService.EnsureReadinessForSync()
|
||
if err == nil {
|
||
return true
|
||
}
|
||
|
||
blocked := &sync.SyncBlockedError{}
|
||
if errors.As(err, &blocked) {
|
||
c.JSON(http.StatusLocked, gin.H{
|
||
"success": false,
|
||
"error": blocked.Error(),
|
||
"reason_code": blocked.Readiness.ReasonCode,
|
||
"reason_text": blocked.Readiness.ReasonText,
|
||
"required_min_app_version": blocked.Readiness.RequiredMinAppVersion,
|
||
"status": blocked.Readiness.Status,
|
||
"blocked": true,
|
||
"last_checked_at": blocked.Readiness.LastCheckedAt,
|
||
})
|
||
return false
|
||
}
|
||
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"error": "internal server error",
|
||
})
|
||
_ = c.Error(err)
|
||
_ = readiness
|
||
return false
|
||
}
|
||
|
||
// 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.ensureSyncReadiness(c) {
|
||
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",
|
||
})
|
||
_ = c.Error(err)
|
||
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": "component sync failed",
|
||
})
|
||
_ = c.Error(err)
|
||
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.ensureSyncReadiness(c) {
|
||
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": "pricelist sync failed",
|
||
})
|
||
_ = c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, SyncResultResponse{
|
||
Success: true,
|
||
Message: "Pricelists synced successfully",
|
||
Synced: synced,
|
||
Duration: time.Since(startTime).String(),
|
||
})
|
||
h.syncService.RecordSyncHeartbeat()
|
||
}
|
||
|
||
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
|
||
// POST /api/sync/partnumber-books
|
||
func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
|
||
if !h.ensureSyncReadiness(c) {
|
||
return
|
||
}
|
||
|
||
startTime := time.Now()
|
||
pulled, err := h.syncService.PullPartnumberBooks()
|
||
if err != nil {
|
||
slog.Error("partnumber books pull failed", "error", err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"error": "partnumber books sync failed",
|
||
})
|
||
_ = c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, SyncResultResponse{
|
||
Success: true,
|
||
Message: "Partnumber books synced successfully",
|
||
Synced: pulled,
|
||
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.ensureSyncReadiness(c) {
|
||
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",
|
||
})
|
||
_ = c.Error(err)
|
||
return
|
||
}
|
||
|
||
// Sync components
|
||
mariaDB, err := h.connMgr.GetDB()
|
||
if err != nil {
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||
"success": false,
|
||
"error": "database connection failed",
|
||
})
|
||
_ = c.Error(err)
|
||
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",
|
||
})
|
||
_ = c.Error(err)
|
||
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",
|
||
"pending_pushed": pendingPushed,
|
||
"components_synced": componentsSynced,
|
||
})
|
||
_ = c.Error(err)
|
||
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",
|
||
"pending_pushed": pendingPushed,
|
||
"components_synced": componentsSynced,
|
||
"pricelists_synced": pricelistsSynced,
|
||
})
|
||
_ = c.Error(err)
|
||
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",
|
||
"pending_pushed": pendingPushed,
|
||
"components_synced": componentsSynced,
|
||
"pricelists_synced": pricelistsSynced,
|
||
"projects_imported": projectsResult.Imported,
|
||
"projects_updated": projectsResult.Updated,
|
||
"projects_skipped": projectsResult.Skipped,
|
||
})
|
||
_ = c.Error(err)
|
||
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.ensureSyncReadiness(c) {
|
||
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": "pending changes push failed",
|
||
})
|
||
_ = c.Error(err)
|
||
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 {
|
||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"changes": changes,
|
||
})
|
||
}
|
||
|
||
// RepairPendingChanges attempts to repair errored pending changes
|
||
// POST /api/sync/repair
|
||
func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
|
||
repaired, remainingErrors, err := h.localDB.RepairPendingChanges()
|
||
if err != nil {
|
||
slog.Error("repair pending changes failed", "error", err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"error": "pending changes repair failed",
|
||
})
|
||
_ = c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"repaired": repaired,
|
||
"remaining_errors": remainingErrors,
|
||
})
|
||
}
|
||
|
||
// SyncInfoResponse represents sync information for the modal
|
||
type SyncInfoResponse struct {
|
||
// Connection
|
||
DBHost string `json:"db_host"`
|
||
DBUser string `json:"db_user"`
|
||
DBName string `json:"db_name"`
|
||
|
||
// Status
|
||
IsOnline bool `json:"is_online"`
|
||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||
|
||
// Statistics
|
||
LotCount int64 `json:"lot_count"`
|
||
LotLogCount int64 `json:"lot_log_count"`
|
||
ConfigCount int64 `json:"config_count"`
|
||
ProjectCount int64 `json:"project_count"`
|
||
|
||
// Pending changes
|
||
PendingChanges []localdb.PendingChange `json:"pending_changes"`
|
||
|
||
// Errors
|
||
ErrorCount int `json:"error_count"`
|
||
Errors []SyncError `json:"errors,omitempty"`
|
||
|
||
// Readiness guard
|
||
Readiness *sync.SyncReadiness `json:"readiness,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) {
|
||
connStatus := h.connMgr.GetStatus()
|
||
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||
|
||
// Get DB connection info
|
||
var dbHost, dbUser, dbName string
|
||
if settings, err := h.localDB.GetSettings(); err == nil {
|
||
dbHost = settings.Host + ":" + fmt.Sprintf("%d", settings.Port)
|
||
dbUser = settings.User
|
||
dbName = settings.Database
|
||
}
|
||
|
||
// Get sync times
|
||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||
needPricelistSync := lastPricelistSync == nil || hasFailedSync
|
||
hasIncompleteServerSync := hasFailedSync
|
||
|
||
// Get local counts
|
||
configCount := h.localDB.CountConfigurations()
|
||
projectCount := h.localDB.CountProjects()
|
||
componentCount := h.localDB.CountLocalComponents()
|
||
pricelistCount := h.localDB.CountLocalPricelists()
|
||
|
||
// Get error count (only changes with LastError != "")
|
||
errorCount := int(h.localDB.CountErroredChanges())
|
||
|
||
// Get pending changes
|
||
changes, err := h.localDB.GetPendingChanges()
|
||
if err != nil {
|
||
slog.Error("failed to get pending changes for sync info", "error", err)
|
||
changes = []localdb.PendingChange{}
|
||
}
|
||
|
||
var syncErrors []SyncError
|
||
for _, change := range changes {
|
||
if change.LastError != "" {
|
||
syncErrors = append(syncErrors, SyncError{
|
||
Timestamp: change.CreatedAt,
|
||
Message: change.LastError,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Limit to last 10 errors
|
||
if len(syncErrors) > 10 {
|
||
syncErrors = syncErrors[:10]
|
||
}
|
||
|
||
readiness := h.getReadinessLocal()
|
||
|
||
c.JSON(http.StatusOK, SyncInfoResponse{
|
||
DBHost: dbHost,
|
||
DBUser: dbUser,
|
||
DBName: dbName,
|
||
IsOnline: isOnline,
|
||
LastSyncAt: lastPricelistSync,
|
||
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||
LastPricelistSyncError: lastPricelistSyncError,
|
||
NeedPricelistSync: needPricelistSync,
|
||
HasIncompleteServerSync: hasIncompleteServerSync,
|
||
LotCount: componentCount,
|
||
LotLogCount: pricelistCount,
|
||
ConfigCount: configCount,
|
||
ProjectCount: projectCount,
|
||
PendingChanges: changes,
|
||
ErrorCount: errorCount,
|
||
Errors: syncErrors,
|
||
Readiness: readiness,
|
||
})
|
||
}
|
||
|
||
// 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 {
|
||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||
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()
|
||
readiness := h.getReadinessLocal()
|
||
isBlocked := readiness != nil && readiness.Blocked
|
||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||
hasIncompleteServerSync := hasFailedSync
|
||
|
||
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
|
||
|
||
data := gin.H{
|
||
"IsOffline": isOffline,
|
||
"PendingCount": pendingCount,
|
||
"IsBlocked": isBlocked,
|
||
"HasFailedSync": hasFailedSync,
|
||
"HasIncompleteServerSync": hasIncompleteServerSync,
|
||
"SyncIssueTitle": func() string {
|
||
if hasIncompleteServerSync {
|
||
return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально."
|
||
}
|
||
if hasFailedSync {
|
||
if lastPricelistSyncError != "" {
|
||
return lastPricelistSyncError
|
||
}
|
||
return "Последняя синхронизация прайслистов завершилась ошибкой."
|
||
}
|
||
return ""
|
||
}(),
|
||
"BlockedReason": func() string {
|
||
if readiness == nil {
|
||
return ""
|
||
}
|
||
return readiness.ReasonText
|
||
}(),
|
||
}
|
||
|
||
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.Error(err)
|
||
c.String(http.StatusInternalServerError, "Template error")
|
||
}
|
||
}
|
||
|
||
func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness {
|
||
h.readinessMu.Lock()
|
||
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < 10*time.Second {
|
||
cached := *h.readinessCached
|
||
h.readinessMu.Unlock()
|
||
return &cached
|
||
}
|
||
h.readinessMu.Unlock()
|
||
|
||
state, err := h.localDB.GetSyncGuardState()
|
||
if err != nil || state == nil {
|
||
return nil
|
||
}
|
||
|
||
readiness := &sync.SyncReadiness{
|
||
Status: state.Status,
|
||
Blocked: state.Status == sync.ReadinessBlocked,
|
||
ReasonCode: state.ReasonCode,
|
||
ReasonText: state.ReasonText,
|
||
RequiredMinAppVersion: state.RequiredMinAppVersion,
|
||
LastCheckedAt: state.LastCheckedAt,
|
||
}
|
||
|
||
h.readinessMu.Lock()
|
||
h.readinessCached = readiness
|
||
h.readinessCachedAt = time.Now()
|
||
h.readinessMu.Unlock()
|
||
return readiness
|
||
}
|
||
|
||
// ReportPartnumberSeen pushes unresolved vendor partnumbers to qt_vendor_partnumber_seen on MariaDB.
|
||
// POST /api/sync/partnumber-seen
|
||
func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
|
||
var body struct {
|
||
Items []struct {
|
||
Partnumber string `json:"partnumber"`
|
||
Description string `json:"description"`
|
||
Ignored bool `json:"ignored"`
|
||
} `json:"items"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||
return
|
||
}
|
||
|
||
items := make([]sync.SeenPartnumber, 0, len(body.Items))
|
||
for _, it := range body.Items {
|
||
if it.Partnumber != "" {
|
||
items = append(items, sync.SeenPartnumber{
|
||
Partnumber: it.Partnumber,
|
||
Description: it.Description,
|
||
Ignored: it.Ignored,
|
||
})
|
||
}
|
||
}
|
||
|
||
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
||
RespondError(c, http.StatusServiceUnavailable, "service unavailable", err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"reported": len(items)})
|
||
}
|