package handlers import ( "errors" "fmt" "html/template" "log/slog" "net/http" "os" "path/filepath" stdsync "sync" "time" qfassets "git.mchus.pro/mchus/priceforge" "git.mchus.pro/mchus/priceforge/internal/db" "git.mchus.pro/mchus/priceforge/internal/localdb" "git.mchus.pro/mchus/priceforge/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, 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"` 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) { // 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) readiness := h.getReadinessCached(10 * time.Second) c.JSON(http.StatusOK, SyncStatusResponse{ LastComponentSync: lastComponentSync, LastPricelistSync: lastPricelistSync, IsOnline: isOnline, ComponentsCount: componentsCount, PricelistsCount: pricelistsCount, ServerPricelists: serverPricelists, NeedComponentSync: needComponentSync, NeedPricelistSync: needPricelistSync, 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 { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) 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": err.Error(), }) _ = 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: " + 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.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": 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.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: " + 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.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": 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 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"` // 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) { // Check online status by pinging MariaDB isOnline := h.checkOnline() // 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() // Get MariaDB counts (if online) var lotCount, lotLogCount int64 if isOnline { if mariaDB, err := h.connMgr.GetDB(); err == nil { mariaDB.Table("lot").Count(&lotCount) mariaDB.Table("lot_log").Count(&lotLogCount) } } // Get local counts configCount := h.localDB.CountConfigurations() projectCount := h.localDB.CountProjects() // 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.getReadinessCached(10 * time.Second) c.JSON(http.StatusOK, SyncInfoResponse{ DBHost: dbHost, DBUser: dbUser, DBName: dbName, IsOnline: isOnline, LastSyncAt: lastPricelistSync, LotCount: lotCount, LotLogCount: lotLogCount, 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 { 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() readiness := h.getReadinessCached(10 * time.Second) isBlocked := readiness != nil && readiness.Blocked slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked) data := gin.H{ "IsOffline": isOffline, "PendingCount": pendingCount, "IsBlocked": isBlocked, "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.String(http.StatusInternalServerError, "Template error: "+err.Error()) } } func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness { h.readinessMu.Lock() if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge { cached := *h.readinessCached h.readinessMu.Unlock() return &cached } h.readinessMu.Unlock() readiness, err := h.syncService.GetReadiness() if err != nil && readiness == nil { return nil } h.readinessMu.Lock() h.readinessCached = readiness h.readinessCachedAt = time.Now() h.readinessMu.Unlock() return readiness }