06/19 Update inspections/issues sent via email, and duplicated issue photos issues

This commit is contained in:
Nguyen Ngo
2026-06-19 17:28:09 -04:00
parent 95c8238c00
commit a57d6f02ba
7 changed files with 1366 additions and 12 deletions
+44 -1
View File
@@ -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(