diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index bd0c46c..b22f986 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1653,6 +1653,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect syncAPI.POST("/push", syncHandler.PushPendingChanges) syncAPI.GET("/pending/count", syncHandler.GetPendingCount) syncAPI.GET("/pending", syncHandler.GetPendingChanges) + syncAPI.POST("/repair", syncHandler.RepairPendingChanges) } } diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 26f897b..4116f9f 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -419,6 +419,26 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) { }) } +// 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": err.Error(), + }) + 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 diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 0d1b611..11b2c87 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1041,6 +1041,129 @@ func (l *LocalDB) GetPendingCount() int64 { return l.CountPendingChanges() } +// RepairPendingChanges attempts to fix errored pending changes by validating and correcting data. +// Returns the number of changes repaired and a list of errors that couldn't be fixed. +func (l *LocalDB) RepairPendingChanges() (int, []string, error) { + var erroredChanges []PendingChange + if err := l.db.Where("last_error != ?", "").Find(&erroredChanges).Error; err != nil { + return 0, nil, fmt.Errorf("fetching errored changes: %w", err) + } + + if len(erroredChanges) == 0 { + return 0, nil, nil + } + + repaired := 0 + var remainingErrors []string + + for _, change := range erroredChanges { + var repairErr error + switch change.EntityType { + case "project": + repairErr = l.repairProjectChange(&change) + case "configuration": + repairErr = l.repairConfigurationChange(&change) + default: + repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType) + } + + if repairErr != nil { + remainingErrors = append(remainingErrors, fmt.Sprintf("%s %s %s: %v", + change.Operation, change.EntityType, change.EntityUUID[:8], repairErr)) + continue + } + + // Clear error and reset attempts + if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{ + "last_error": "", + "attempts": 0, + }).Error; err != nil { + remainingErrors = append(remainingErrors, fmt.Sprintf("clearing error for %s: %v", change.EntityUUID[:8], err)) + continue + } + + repaired++ + } + + return repaired, remainingErrors, nil +} + +// repairProjectChange validates and fixes project data +func (l *LocalDB) repairProjectChange(change *PendingChange) error { + project, err := l.GetProjectByUUID(change.EntityUUID) + if err != nil { + return fmt.Errorf("project not found locally: %w", err) + } + + modified := false + + // Fix Code: must be non-empty + if strings.TrimSpace(project.Code) == "" { + if project.Name != nil && strings.TrimSpace(*project.Name) != "" { + project.Code = strings.TrimSpace(*project.Name) + } else { + project.Code = project.UUID[:8] + } + modified = true + } + + // Fix Name: use Code if empty + if project.Name == nil || strings.TrimSpace(*project.Name) == "" { + name := project.Code + project.Name = &name + modified = true + } + + // Fix OwnerUsername: must be non-empty + if strings.TrimSpace(project.OwnerUsername) == "" { + project.OwnerUsername = l.GetDBUser() + if project.OwnerUsername == "" { + return fmt.Errorf("cannot determine owner username") + } + modified = true + } + + if modified { + if err := l.SaveProject(project); err != nil { + return fmt.Errorf("saving repaired project: %w", err) + } + } + + return nil +} + +// repairConfigurationChange validates and fixes configuration data +func (l *LocalDB) repairConfigurationChange(change *PendingChange) error { + config, err := l.GetConfigurationByUUID(change.EntityUUID) + if err != nil { + return fmt.Errorf("configuration not found locally: %w", err) + } + + modified := false + + // Check if referenced project exists + if config.ProjectUUID != nil && *config.ProjectUUID != "" { + _, err := l.GetProjectByUUID(*config.ProjectUUID) + if err != nil { + // Project doesn't exist locally - use default system project + systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername) + if sysErr != nil { + return fmt.Errorf("getting system project: %w", sysErr) + } + config.ProjectUUID = &systemProject.UUID + modified = true + } + } + + if modified { + if err := l.SaveConfiguration(config); err != nil { + return fmt.Errorf("saving repaired configuration: %w", err) + } + } + + return nil +} + // GetRemoteMigrationApplied returns a locally applied remote migration by ID. func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) { var migration LocalRemoteMigrationApplied diff --git a/web/templates/base.html b/web/templates/base.html index 9946ae2..2140686 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -123,6 +123,26 @@
+ Система может исправить данные и очистить ошибки синхронизации: +
+