06/19 Update inspections/issues sent via email, and duplicated issue photos issues
This commit is contained in:
@@ -187,6 +187,27 @@ class SyncManager: ObservableObject {
|
||||
// Do not sync unless authenticated — avoids 401 loops before
|
||||
// restoreSession() completes on first launch.
|
||||
guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return }
|
||||
|
||||
// Re-entrancy guard. triggerSync() has many independent call sites
|
||||
// (issue submit, inspection submit, manual "Sync Now", NWPathMonitor
|
||||
// reconnect, the 60s poll timer, app-foreground). Although this class
|
||||
// is @MainActor, the `await` points inside processPhotoQueue/etc.
|
||||
// yield the actor, so a second triggerSync() call can interleave
|
||||
// between those awaits and run concurrently with the first.
|
||||
//
|
||||
// Without this guard, two overlapping passes both fetch the same
|
||||
// "pending" PendingPhoto records (neither has flipped uploadStatus
|
||||
// yet), both upload the same local file, and each appends its own
|
||||
// distinct server-generated filename to LocalIssue.photoServerPaths.
|
||||
// The `!paths.contains(serverPath)` dedup check in processPhotoQueue
|
||||
// never catches this because the two server paths are different
|
||||
// strings for the same photo content — producing duplicated photos
|
||||
// in the issue's evidence (and therefore in the exported PDF).
|
||||
//
|
||||
// Guarding re-entrancy here closes the race at its source rather
|
||||
// than trying to dedupe by content downstream.
|
||||
guard !isSyncing else { return }
|
||||
|
||||
isSyncing = true
|
||||
syncError = nil
|
||||
defer { isSyncing = false }
|
||||
@@ -216,10 +237,32 @@ class SyncManager: ObservableObject {
|
||||
// string literals against PendingPhoto.uploadStatus reliably
|
||||
// when the predicate type is inferred across model boundaries.
|
||||
guard let allPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) else { return }
|
||||
let pending = allPhotos
|
||||
var pending = allPhotos
|
||||
.filter { $0.uploadStatus == "pending" }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
|
||||
// Defense-in-depth: if two PendingPhoto rows somehow reference the
|
||||
// exact same local file (e.g. a future call site re-submitting the
|
||||
// same photo array), only upload it once. The primary fix for photo
|
||||
// duplication is the re-entrancy guard in triggerSync(), but this
|
||||
// keeps processPhotoQueue itself safe even if it's ever invoked
|
||||
// outside that guard.
|
||||
var seenPaths = Set<String>()
|
||||
var duplicates: [PendingPhoto] = []
|
||||
pending = pending.filter { photo in
|
||||
if seenPaths.contains(photo.localFilePath) {
|
||||
duplicates.append(photo)
|
||||
return false
|
||||
}
|
||||
seenPaths.insert(photo.localFilePath)
|
||||
return true
|
||||
}
|
||||
for dup in duplicates {
|
||||
// Mark the duplicate row as uploaded without re-uploading — the
|
||||
// first row for this file will populate serverPath/photoServerPaths.
|
||||
dup.uploadStatus = "uploaded"
|
||||
}
|
||||
|
||||
for photo in pending {
|
||||
do {
|
||||
let serverPath = try await APIClient.shared.uploadPhoto(
|
||||
|
||||
Reference in New Issue
Block a user