// Sync/SyncManager.swift // ---------------------- // Manages connectivity monitoring, reference data sync (Phase A), // the outbox queue for offline inspection/issue submission (Phase B), // and notification polling (Phase C). import Foundation import Network import SwiftData import SwiftUI import Combine import UserNotifications import UIKit // beginBackgroundTask — see beginSyncBackgroundTask() @MainActor class SyncManager: ObservableObject { // ── Published State ─────────────────────────────────────────────────── @Published var isOnline = false @Published var isSyncing = false @Published var lastSyncAt: Date? @Published var syncError: String? @Published var pendingCount = 0 /// Dashboard KPI stats fetched from the server. Nil until first successful fetch. @Published var dashboardStats: APIDashboardStats? /// Count of notifications received since last resetNotificationPoller(). /// Incremented on each poll that returns new items; reset to 0 on logout. @Published var unreadNotificationCount = 0 /// The most recent batch of notifications (up to 50) for the in-app inbox. /// Replaced entirely on each successful poll; empty until first fetch. @Published var recentNotifications: [APINotification] = [] // ── Dependencies ────────────────────────────────────────────────────── private let monitor = NWPathMonitor() private let monitorQueue = DispatchQueue(label: "com.jqc.networkmonitor") var modelContext: ModelContext? // ── Notification polling state ──────────────────────────────────────── // Tracks the timestamp of the most recently fetched notification so each // poll only retrieves newer records. Nil on first launch → server returns // last 50 unread. Reset to nil on logout. private var lastNotificationFetch: Date? private var pollTask: Task? // replaces Timer — Task.sleep works correctly private let pollInterval: UInt64 = 60_000_000_000 // 60 seconds in nanoseconds // ── Shared date formatters ──────────────────────────────────────────── // DateFormatter init is expensive — allocating one per poll call or per // issue would add measurable overhead at sync time. These are created // once and reused across all calls. Both are nonisolated statics so they // can be read from any context without actor-hopping. // // isoFormatter — parses/formats ISO 8601 strings from the server API // e.g. "2026-05-01T14:30:00" // notifFormatter — same format, used to advance the notification poll cursor nonisolated static let isoFormatter: DateFormatter = { let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" return f }() static let shared = SyncManager() private init() {} // ── Start Monitoring ────────────────────────────────────────────────── func startMonitoring() { monitor.pathUpdateHandler = { [weak self] path in Task { @MainActor [weak self] in guard let self else { return } let wasOffline = !self.isOnline self.isOnline = path.status == .satisfied if self.isOnline { if wasOffline { await self.triggerSync() } self.startPollTask() } else { self.stopPollTask() } } } monitor.start(queue: monitorQueue) } // ── Notification poll task ──────────────────────────────────────────── // Timer.scheduledTimer requires RunLoop.main to be ticking. When called // from inside a Swift Concurrency Task { @MainActor } the current RunLoop // is NOT RunLoop.main — the timer is added to a runloop that never runs, // so it silently fires never. Task + Task.sleep has no such dependency. private func startPollTask() { guard pollTask == nil else { return } // already running pollTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 60_000_000_000) guard !Task.isCancelled else { break } await MainActor.run { [weak self] in guard let self, self.isOnline, AuthManager.shared.isAuthenticated else { return } Task { await self.pollNotifications() } } } } } private func stopPollTask() { pollTask?.cancel() pollTask = nil } // ── Background task assertion ───────────────────────────────────────── // Keeps the process alive across a suspend so an in-flight sync can finish. // Distinct from the BGProcessingTask in JanitorialQCApp: that one asks iOS // to WAKE us later, this one asks it not to suspend us right now. private var syncBackgroundTaskId: UIBackgroundTaskIdentifier = .invalid private func beginSyncBackgroundTask() { // triggerSync() is re-entrancy guarded, but assert defensively anyway: // beginning a second assertion would leak the first identifier. guard syncBackgroundTaskId == .invalid else { return } syncBackgroundTaskId = UIApplication.shared.beginBackgroundTask( withName: "JQC.syncDrain" ) { // Called on the main thread when the grace period runs out. It MUST // end the assertion or iOS terminates the app. assumeIsolated is // required because the handler is a nonisolated closure under // SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. MainActor.assumeIsolated { SyncManager.shared.endSyncBackgroundTask() } } } private func endSyncBackgroundTask() { guard syncBackgroundTaskId != .invalid else { return } UIApplication.shared.endBackgroundTask(syncBackgroundTaskId) syncBackgroundTaskId = .invalid } /// Called on logout so the next login starts a clean fetch. func resetNotificationPoller() { lastNotificationFetch = nil unreadNotificationCount = 0 recentNotifications = [] stopPollTask() } /// Called when the app enters the background (scenePhase == .background). /// Stops the poll loop so it doesn't accumulate suspended sleep cycles. func suspendPolling() { stopPollTask() } /// Called when the app returns to the foreground (scenePhase == .active). /// Restarts the poll loop and immediately syncs so stale data is refreshed /// without waiting up to 60s for the next scheduled tick. func resumePolling() { guard isOnline, AuthManager.shared.isAuthenticated else { return } startPollTask() Task { await triggerSync() } } /// Call when the user opens the NotificationsView to clear the badge. func markNotificationsViewed() { unreadNotificationCount = 0 } // ── Notification polling ────────────────────────────────────────────── func pollNotifications() async { guard isOnline, AuthManager.shared.isAuthenticated else { return } do { let notifications = try await APIClient.shared.fetchNotifications(since: lastNotificationFetch) guard !notifications.isEmpty else { return } // Deliver a local notification for each new item for n in notifications { deliverLocalNotification(n) } // Update in-app inbox state. // Prepend new notifications and cap at 50 — avoids allocating two // arrays and concatenating them on every poll (the old pattern // `notifications + recentNotifications.prefix(50 - count)` always // created a new array even when notifications.count >= 50). recentNotifications.insert(contentsOf: notifications, at: 0) if recentNotifications.count > 50 { recentNotifications = Array(recentNotifications.prefix(50)) } unreadNotificationCount += notifications.count // Update the cursor to the newest notification's timestamp so the // next poll only fetches newer items — do NOT mark notifications as // read on the server. Read state is a deliberate user action managed // via the web app; marking read here would cause the web badge count // to always show zero when the iPad has polled before the user checks. let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) } if let newest = dates.max() { lastNotificationFetch = newest } } catch APIError.notAuthenticated { // Token expired and refresh failed — let AuthManager handle it } catch { // Network errors are silent; next poll will retry } } // ── Local notification delivery ─────────────────────────────────────── private func deliverLocalNotification(_ n: APINotification) { let content = UNMutableNotificationContent() content.title = n.title content.body = n.body content.sound = .default // Use the server notification ID as the identifier so duplicate // deliveries (if the same record is fetched twice) replace rather // than stack. let identifier = "jqc-notif-\(n.id)" let request = UNNotificationRequest( identifier: identifier, content: content, trigger: nil // nil = deliver immediately ) UNUserNotificationCenter.current().add(request) { error in if let error { print("[JQC] Local notification delivery failed: \(error)") } } } // ── Full Sync ───────────────────────────────────────────────────────── func triggerSync() async { // 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 } // Ask iOS to keep the process alive long enough to finish the drain. // The case this covers: an inspector taps Submit and immediately locks // the iPad or swipes to another app. Without an assertion the process // suspends mid-upload and the work waits for the next launch. // Roughly 30 s of grace; the expiration handler ends it cleanly so iOS // never force-kills us. Harmless in the foreground — it simply ends. beginSyncBackgroundTask() defer { endSyncBackgroundTask() } await processPhotoQueue(context: context) await processInspectionQueue(context: context) await processIssueQueue(context: context) await pullReferenceData() await pullAssignedIssues(context: context) await pullScheduledInspections(context: context) await pullFollowUpRequests(context: context) // Poll notifications immediately on every sync rather than waiting // for the 60-second timer — ensures the inspector sees assignments // and follow-up requests as soon as the app goes online. await pollNotifications() // Fetch dashboard KPIs — best-effort, non-fatal on failure. await fetchDashboardStats() // Periodically remove local photo files that are no longer needed. // Runs at most once per hour to avoid repeated FileManager calls on // every 60-second sync cycle. cleanupOrphanedPhotos(context: context) updatePendingCount(context: context) lastSyncAt = Date() } // ── Outbox: Photos ──────────────────────────────────────────────────── /// Upload attempts before a PendingPhoto is given up on. /// /// Until this is reached the row stays "pending", which does two things: /// the next sync retries it, AND processInspectionQueue keeps waiting /// rather than submitting the inspection with a blank photo field. static let maxPhotoUploadAttempts = 5 private func processPhotoQueue(context: ModelContext) async { // Fetch all then filter in Swift — #Predicate cannot reference // string literals against PendingPhoto.uploadStatus reliably // when the predicate type is inferred across model boundaries. guard let allPhotos = try? context.fetch(FetchDescriptor()) else { return } let pending = allPhotos .filter { $0.uploadStatus == "pending" } .sorted { $0.createdAt < $1.createdAt } // Two rows can legitimately point at the same file — the same image // attached to two form fields, or a call site re-submitting a photo // array. Upload it once, then give EVERY row that references it the // same server path. // // The previous version marked the duplicates "uploaded" up front and // never set their serverPath, so the second field was submitted blank. // Settling them from the upload result instead keeps every field // pointing at real evidence. (Uploading once still matters: two uploads // of one file produce two different server filenames and duplicate the // photo in the issue's evidence and its PDF.) var firstByPath: [String: PendingPhoto] = [:] var toUpload: [PendingPhoto] = [] var duplicates: [PendingPhoto] = [] for photo in pending { if firstByPath[photo.localFilePath] == nil { firstByPath[photo.localFilePath] = photo toUpload.append(photo) } else { duplicates.append(photo) } } // Pre-fetch parent records ONCE before the loop. // Without this, every successful photo upload fetched ALL LocalInspection // and ALL LocalIssue records from SwiftData to find the parent — // N photos → 2N full-table fetches. Pre-fetching here reduces that // to 2 fetches regardless of how many photos are in the queue. // Fetch-all + filter in Swift — #Predicate with a captured String variable // causes "LocalInspection is ambiguous" under Xcode 26 (CLAUDE.md rule 3). let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] let allIssues = (try? context.fetch(FetchDescriptor())) ?? [] var uploadedPaths: [String: String] = [:] // localFilePath -> serverPath for photo in toUpload { do { // Capture metadata was recorded at the shutter, not now — the // sync may run hours after an offline capture, and the server // burns these values into the photo as its timestamp/GPS bar. let serverPath = try await APIClient.shared.uploadPhoto( localPath: photo.localFilePath, entityType: photo.entityType, capturedAt: photo.capturedAt, latitude: photo.captureLatitude, longitude: photo.captureLongitude ) photo.serverPath = serverPath photo.uploadStatus = "uploaded" photo.uploadRetryCount = 0 uploadedPaths[photo.localFilePath] = serverPath attachServerPath(serverPath, for: photo, inspections: allInspections, issues: allIssues) await pushLateIssuePhotoIfNeeded(serverPath, for: photo, issues: allIssues, context: context) try? context.save() } catch { // Treat the error as TRANSIENT by default. Leaving the row // "pending" means the next sync retries it and — critically — // processInspectionQueue keeps waiting instead of submitting // the inspection with this field blanked to "" by // APIClient.submitInspection. // // The old code marked "failed" on the very first error, and // nothing anywhere ever moved a row back off "failed". One // dropped connection therefore cost the inspection its evidence // photo permanently, silently, with the inspection still // reported as successfully synced. photo.uploadRetryCount += 1 if photo.uploadRetryCount >= Self.maxPhotoUploadAttempts { photo.uploadStatus = "failed" syncError = "A photo failed to upload after " + "\(Self.maxPhotoUploadAttempts) attempts. " + "Open Pending Sync → Retry Failed Items to try again." } try? context.save() } } // Settle rows that shared a file with one just uploaded. A row whose // twin failed is deliberately left "pending" so both retry together. for dup in duplicates { guard let serverPath = uploadedPaths[dup.localFilePath] else { continue } dup.serverPath = serverPath dup.uploadStatus = "uploaded" dup.uploadRetryCount = 0 attachServerPath(serverPath, for: dup, inspections: allInspections, issues: allIssues) await pushLateIssuePhotoIfNeeded(serverPath, for: dup, issues: allIssues, context: context) } try? context.save() } /// Carry a photo across to an issue that has ALREADY been created on the /// server, which the create request therefore could not have included. /// /// Only reachable via recovery: the photo exhausted /// `maxPhotoUploadAttempts`, the issue was submitted without it (rule 83 /// deliberately lets that happen rather than blocking forever), and the /// inspector later hit Pending Sync → Retry Failed Items and the upload /// succeeded. In the normal path processPhotoQueue runs BEFORE /// processIssueQueue, so `issue.serverId` is still nil here and this does /// nothing — which is the discriminator, and why this is not a rule 85 /// violation. /// /// Must happen now, in this same pass: `pullAssignedIssues` later overwrites /// `photoServerPaths` with the server's copy, so a photo left only in local /// state would be erased before anything else could notice it. /// /// Best-effort. A failure here leaves the photo attached locally but not /// server-side until the next pull overwrites it — the residual limitation /// noted in rule 83. Reaching this at all takes five consecutive upload /// failures, and the PATCH merges idempotently, so a repeat is harmless. private func pushLateIssuePhotoIfNeeded( _ serverPath: String, for photo: PendingPhoto, issues: [LocalIssue], context: ModelContext ) async { guard photo.entityType == "issue" else { return } let entityId = photo.entityLocalId guard let issue = issues.first(where: { $0.localId == entityId }), let issueServerId = issue.serverId else { return } do { try await APIClient.shared.updateIssuePhotos( issueId: issueServerId, resultPhotos: [serverPath] ) issue.syncErrorMessage = nil } catch { issue.syncErrorMessage = "A recovered photo could not be attached: \(error.localizedDescription)" } try? context.save() } /// Write a freshly uploaded server path onto whichever record owns the photo. /// Both collections are pre-fetched by the caller — see processPhotoQueue. private func attachServerPath( _ serverPath: String, for photo: PendingPhoto, inspections: [LocalInspection], issues: [LocalIssue] ) { let entityId = photo.entityLocalId // Inspection form image field. if photo.entityType == "inspection", let fieldId = photo.fieldId { inspections.first(where: { $0.localId == entityId })? .setValue(serverPath, forFieldId: fieldId) } // Issue evidence photo array. if photo.entityType == "issue", let issue = issues.first(where: { $0.localId == entityId }) { var paths = issue.photoServerPaths if !paths.contains(serverPath) { paths.append(serverPath) } issue.photoServerPaths = paths } } // ── Outbox: Inspections ─────────────────────────────────────────────── private func processInspectionQueue(context: ModelContext) async { guard let all = try? context.fetch(FetchDescriptor()) else { return } let pending = all .filter { $0.status == "completed" && $0.syncStatus == "pending" } .sorted { $0.createdAt < $1.createdAt } for inspection in pending { // "failed" is only reachable after maxPhotoUploadAttempts, so this // now means "uploaded, or genuinely unrecoverable" rather than // "uploaded, or hit one network error". A still-retrying photo // keeps its row "pending" and holds the inspection back — which is // the point: submitting first is what blanked the field for good, // since APIClient.submitInspection rewrites a surviving local:// // value to "" and the inspection is then marked synced forever. let photosReady = inspection.pendingPhotos.allSatisfy { $0.uploadStatus == "uploaded" || $0.uploadStatus == "failed" } guard photosReady else { continue } do { let inspectionId = try await APIClient.shared.submitInspection(inspection) inspection.serverId = inspectionId inspection.syncStatus = "synced" inspection.status = "synced" // Clear follow-up flag on parent. // Reuses the `all` array already fetched at the top of this // function — avoids a redundant full-table fetch per inspection. if let parentLocalId = inspection.parentLocalId, let parent = all.first(where: { $0.localId == parentLocalId }) { parent.followUpRequired = false parent.followUpNote = nil } try? context.save() } catch { inspection.syncRetryCount += 1 inspection.syncErrorMessage = error.localizedDescription if inspection.syncRetryCount >= 5 { inspection.syncStatus = "failed" } syncError = "Failed to sync inspection: \(error.localizedDescription)" try? context.save() } } } // ── Outbox: Issues ──────────────────────────────────────────────────── private func processIssueQueue(context: ModelContext) async { guard let all = try? context.fetch(FetchDescriptor()) else { return } let pending = all .filter { $0.syncStatus == "pending" } .sorted { $0.createdAt < $1.createdAt } // Pre-fetch all inspections to check parent sync status AND to resolve // the parent's serverId. The allInspections array is the same set of // objects that processInspectionQueue updated (serverId written in-memory // this same triggerSync pass), so looking up serverId here is reliable. // issue.inspection?.serverId is NOT reliable — it navigates a @Relationship // that SwiftData may have loaded as a separate object instance before // processInspectionQueue wrote the serverId back, leaving it nil even // when the parent inspection already synced successfully this same pass. let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] // Issue photos are inserted standalone (FlagIssueView / StandaloneIssueView // do not append them to any relationship), so they have to be matched by // entityType + entityLocalId rather than navigated to. let allPhotos = (try? context.fetch(FetchDescriptor())) ?? [] for issue in pending { let parentLocalId = issue.inspectionLocalId let parent = allInspections.first(where: { $0.localId == parentLocalId }) // ── Photo readiness ─────────────────────────────────────────── // Wait for this issue's photos exactly as processInspectionQueue // waits for an inspection's. There was no guard here at all: the // issue submitted with photo_path = photoServerPaths.first (nil // while uploads were still in flight or being retried), and the // unconditional clear below then dropped the only local reference // to the files. let issuePhotos = allPhotos.filter { $0.entityType == "issue" && $0.entityLocalId == issue.localId } let photosSettled = issuePhotos.allSatisfy { $0.uploadStatus == "uploaded" || $0.uploadStatus == "failed" } guard photosSettled else { continue } // ── Parent inspection status guards ─────────────────────────── if let parent { switch parent.syncStatus { case "failed": // Parent permanently failed — this issue can never be linked. // Mark it failed immediately rather than creating an orphaned // server record with no inspection_id. issue.syncStatus = "failed" issue.syncErrorMessage = "Parent inspection failed to sync — issue cannot be submitted." try? context.save() syncError = "Issue \(issue.localId.prefix(8))\u{2026} blocked: parent inspection did not sync." continue case "synced": // Parent has a serverId — proceed and link correctly. break default: // Parent is still "pending" (draft or awaiting submission). // Submitting now would create a server issue with no // inspection_id — the issue and inspection appear unlinked // on the web. Defer until the next triggerSync() pass, by // which point processInspectionQueue will have synced the // parent and assigned it a serverId. continue } } // parent == nil means inspectionLocalId == "" (standalone issue) — submit without inspection_id. // Resolve the parent's server ID. Safe to force-unwrap serverId // here — the switch above guarantees parent.syncStatus == "synced" // when parent is non-nil, so serverId is always set at this point. let inspectionServerId = parent?.serverId do { let issueId = try await APIClient.shared.submitIssue(issue, inspectionServerId: inspectionServerId) issue.serverId = issueId issue.syncStatus = "synced" // Clear the local file paths ONLY when every photo actually // reached the server. Clearing unconditionally is what made a // partial upload unrecoverable: the files stayed on disk but // nothing referenced them any more, so neither the UI nor // PhotoDiagnosticView could find them again. Keeping them costs // a duplicate photo section in IssueDetailView at worst; losing // them costs the evidence itself. let allUploaded = issuePhotos.allSatisfy { $0.uploadStatus == "uploaded" } if allUploaded { issue.photoLocalPaths = [] } // No follow-up call: submitIssue() sends every photo in the // create request (photo_path + result_photos), so attachment is // atomic with creation — there is no window in which the issue // is `synced` but its photos are not attached. See rule 85. try? context.save() } catch { issue.syncRetryCount += 1 issue.syncErrorMessage = error.localizedDescription if issue.syncRetryCount >= 5 { issue.syncStatus = "failed" } try? context.save() } } } // ── Reference Data ──────────────────────────────────────────────────── func pullReferenceData() async { guard isOnline, let context = modelContext else { return } do { let facilitiesData: FacilitiesResponseData = try await APIClient.shared.request("/api/v1/facilities") let templatesData: TemplatesResponseData = try await APIClient.shared.request("/api/v1/templates") let existingFacilities = try context.fetch(FetchDescriptor()) let facilityMap = Dictionary( existingFacilities.map { ($0.serverId, $0) }, uniquingKeysWith: { a, _ in a } ) // Deduplicate the server response by id before upserting. // The server may return the same facility id more than once // (e.g. one row per contract assignment), which would insert // duplicate LocalFacility records and show buildings twice in // every picker. Keep only the first occurrence of each id. var seenFacilityIds = Set() let uniqueFacilities = facilitiesData.facilities.filter { seenFacilityIds.insert($0.id).inserted } for apiFacility in uniqueFacilities { let localFacility: LocalFacility if let existing = facilityMap[apiFacility.id] { existing.update(from: apiFacility) localFacility = existing } else { let newFacility = LocalFacility(from: apiFacility) context.insert(newFacility) localFacility = newFacility } try await upsertAreas(for: apiFacility.id, facility: localFacility, context: context) } // ── Prune facilities the server no longer returns ──────────── // /api/v1/facilities is already scoped to what this user may see, // but cached rows were never removed — so a facility survived // locally after the inspector's contract was unassigned, after it // was deactivated, or after a different user signed in on the same // iPad. Every picker derives its CONTRACT list from these rows, so // one stale facility keeps a whole contract in the Start // Inspection picker forever. (Templates were already pruned this // way below; facilities were the gap.) // // Safe because we only reach here after BOTH requests succeeded — // a failed sync throws before this point and deletes nothing. let returnedFacilityIds = Set(uniqueFacilities.map { $0.id }) // Work that has not reached the server yet still needs its // facility row: ExecuteInspectionView and MyInspectionsView resolve // the name by serverId and would otherwise show "Unknown Facility" // on a draft the inspector is midway through. Keep those rows but // mark them unavailable so no NEW work can be started against them; // they are pruned on a later sync once the work has been submitted. let localInspections = try context.fetch(FetchDescriptor()) let localIssues = try context.fetch(FetchDescriptor()) var inUseFacilityIds = Set( localInspections .filter { $0.syncStatus != "synced" } .map { $0.facilityServerId } ) // Device-created issues too: their facilityNameCache is nil until // the server round-trips, and the issue DETAIL view has no cache // fallback — it would read "Unknown Facility" outright. inUseFacilityIds.formUnion( localIssues .filter { $0.syncStatus != "synced" } .map { $0.facilityServerId } ) for existing in existingFacilities { guard !returnedFacilityIds.contains(existing.serverId) else { continue } if inUseFacilityIds.contains(existing.serverId) { // Retained for display only. The pickers filter on // isActive, so it cannot be chosen for new work. existing.isActive = false } else { context.delete(existing) // cascades to its areas } } let existingTemplates = try context.fetch(FetchDescriptor()) let templateMap = Dictionary( existingTemplates.map { ($0.serverId, $0) }, uniquingKeysWith: { a, _ in a } ) for apiSummary in templatesData.templates { let summaryChanged: Bool if let existing = templateMap[apiSummary.id] { summaryChanged = existing.updateSummary(from: apiSummary) } else { context.insert(LocalTemplate(from: apiSummary)) summaryChanged = true // new template — must fetch schema } try await upsertTemplateSchema( id: apiSummary.id, context: context, templateMap: templateMap, summaryChanged: summaryChanged ) } // Delete any cached templates the server no longer returns. // The server endpoint now only returns active templates, so any // locally cached template not in the response was deactivated. // Deleting ensures they never appear in the picker even offline. let returnedTemplateIds = Set(templatesData.templates.map { $0.id }) for existing in existingTemplates { if !returnedTemplateIds.contains(existing.serverId) { context.delete(existing) } } try context.save() } catch APIError.notAuthenticated { syncError = "Session expired. Please log in again." } catch { syncError = "Sync failed: \(error.localizedDescription)" } } // ── Pending Count ───────────────────────────────────────────────────── // ── Orphaned Photo Cleanup ──────────────────────────────────────────── // Removes local JPEG files from Documents/JQCPhotos/ that are no longer // referenced by any LocalInspection, LocalIssue, or PendingPhoto record. // Once an inspection or issue is fully synced its local photos are no // longer needed — the server holds the canonical copies. Without this, // weeks of inspections accumulate hundreds of MBs of orphaned files. // // Throttled to once per hour via UserDefaults to avoid redundant // FileManager enumeration on every 60-second sync cycle. private static let lastCleanupKey = "jqc.photoCleanup.lastRunAt" private static let cleanupInterval: TimeInterval = 3600 // 1 hour /// A file must be at least this old before cleanup will consider it. /// /// Resolution photos staged in IssueDetailView live purely in `@State` /// until they upload — no database row references them — so for that flow /// age is the only available evidence that a file is not in active use. /// The floor also covers the window between writing a JPEG and saving the /// record that points at it. private static let cleanupMinFileAge: TimeInterval = 7 * 24 * 3600 // 7 days private func cleanupOrphanedPhotos(context: ModelContext) { let last = UserDefaults.standard.object(forKey: Self.lastCleanupKey) as? Date guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return } UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey) // ── Phase 1: collect referenced paths on @MainActor (SwiftData fetches) ── // These are fast in-memory operations — always runs on the main actor. var referencedPaths = Set() // PendingPhoto — not yet uploaded if let pendingPhotos = try? context.fetch(FetchDescriptor()) { for p in pendingPhotos where p.uploadStatus != "uploaded" { referencedPaths.insert(p.localFilePath) } } // LocalInspection — EVERY field still holding the local:// sentinel, // whatever the inspection's status. // // Deliberately not limited to drafts. A sentinel surviving on a // submitted inspection means that photo never reached the server, so // the file on disk is the only copy in existence — it is exactly the // material PhotoDiagnosticView reports as "recoverable". Restricting // this to drafts would have made correcting the directory below destroy // the evidence this whole fix exists to preserve. if let inspections = try? context.fetch(FetchDescriptor()) { for insp in inspections { for val in insp.formData.values { if let s = val as? String, s.hasPrefix("local://") { referencedPaths.insert(String(s.dropFirst("local://".count))) } } } } // LocalIssue — every retained local path, synced or not, for the same // reason. processIssueQueue now clears these only once all photos have // actually uploaded, so a path still present means evidence the server // does not have. if let issues = try? context.fetch(FetchDescriptor()) { for issue in issues { for path in issue.photoLocalPaths { referencedPaths.insert(path) } } } // ── Phase 2: FileManager enumeration + deletion on a background thread ── // Directory enumeration and file removal are I/O-bound and can stutter // the main thread when JQCPhotos/ contains hundreds of files. Dispatching // here is safe because `referencedPaths` is a value type (Set) // captured by copy — no shared mutable state crosses the boundary. let minAge = Self.cleanupMinFileAge Task.detached(priority: .utility) { let fm = FileManager.default guard let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return } // "JQC/Photos" — the directory every writer actually uses // (FlagIssueView, StandaloneIssueView, CompactImageFieldView, // ImageFieldView). This previously read "JQCPhotos", which no code // path has ever written to: contentsOfDirectory failed, the guard // returned, and this function silently deleted nothing for its // entire life while photos accumulated indefinitely. // // "JQC/ResultPhotos" is deliberately NOT swept. Those files are // staged in IssueDetailView's @State with no database row, so // nothing here can prove one is unused; they are cleaned up by // uploadAndAttachResultPhotos() and removeResultPhoto() instead. let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true) guard let diskFiles = try? fm.contentsOfDirectory( at: photosDir, includingPropertiesForKeys: [.contentModificationDateKey] ) else { return } let cutoff = Date().addingTimeInterval(-minAge) var deletedCount = 0 for fileURL in diskFiles { if referencedPaths.contains(fileURL.path) { continue } // Age floor — never touch a file young enough to belong to a // capture flow that has not yet written its record. let modified = (try? fileURL.resourceValues( forKeys: [.contentModificationDateKey] ))?.contentModificationDate guard let modified, modified < cutoff else { continue } try? fm.removeItem(at: fileURL) deletedCount += 1 } if deletedCount > 0 { print("[JQC] Sync | cleanupOrphanedPhotos | removed \(deletedCount) file(s)") } } } func updatePendingCount(context: ModelContext) { // Fetch-all + filter in Swift — #Predicate with string literals is // banned under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION (CLAUDE.md rule 3). // These fetches are lightweight (no relationships loaded) and run once // per sync cycle at the very end, not in a hot loop. let inspCount = (try? context.fetch(FetchDescriptor()))? .filter { $0.syncStatus == "pending" }.count ?? 0 let issueCount = (try? context.fetch(FetchDescriptor()))? .filter { $0.syncStatus == "pending" }.count ?? 0 pendingCount = inspCount + issueCount } // ── Private Helpers ─────────────────────────────────────────────────── private func upsertAreas(for facilityId: Int, facility: LocalFacility, context: ModelContext) async throws { let areasData: AreasResponseData = try await APIClient.shared.request( "/api/v1/facilities/\(facilityId)/areas" ) let existing = (try? context.fetch(FetchDescriptor()))? .filter { $0.facilityServerId == facilityId } ?? [] let areaMap = Dictionary(existing.map { ($0.serverId, $0) }, uniquingKeysWith: { a, _ in a }) for apiArea in areasData.areas { if let ex = areaMap[apiArea.id] { ex.update(from: apiArea) // Re-wire relationship in case it was lost (e.g. cache clear) if ex.facility == nil { ex.facility = facility } } else { let newArea = LocalArea(from: apiArea) // Wire the inverse relationship so LocalFacility.areas is populated. // Without this assignment SwiftData never links the area into the // facility's areas array and selectedFacility?.areas returns []. newArea.facility = facility context.insert(newArea) } } } private func upsertTemplateSchema( id: Int, context: ModelContext, templateMap: [Int: LocalTemplate], summaryChanged: Bool ) async throws { let existing = templateMap[id] ?? (try? context.fetch(FetchDescriptor()))? .first { $0.serverId == id } // Skip the detail API call if: // • The schema was already fetched (schemaFetchedAt is non-nil) // • No summary fields changed this sync pass (name, frequency, isActive) // • The local schema is non-empty (not a first-run blank) // // This reduces N sequential GET /api/v1/templates/{id} calls to zero // on a typical sync where templates haven't changed — the common case. // The schema is always re-fetched when any summary field changes, // when schemaFetchedAt is nil (new template or first launch), or // when the local schema is empty ("[]"). if let existing, existing.schemaFetchedAt != nil, existing.formSchemaJSON != "[]", !summaryChanged { return // schema is current — no network call needed } let detailData: TemplateDetailResponseData = try await APIClient.shared.request( "/api/v1/templates/\(id)" ) existing?.updateSchema(from: detailData.template) } // ── Pull server-assigned issues ─────────────────────────────────────── // Fetches issues assigned to the current user on the server and upserts // them into SwiftData so IssuesListView shows them alongside device-created issues. // Keyed by serverId — existing records are updated in-place, new ones inserted. // These records carry syncStatus = "synced" and a generated localId so they // are never re-submitted to the server by processIssueQueue. func pullAssignedIssues(context: ModelContext) async { guard isOnline, AuthManager.shared.isAuthenticated else { return } do { let apiIssues = try await APIClient.shared.fetchAssignedIssues() // Do not return early on empty — deletion still needs to run // to remove issues that were unassigned from this inspector. // Build a map of existing LocalIssues by serverId for upsert let allLocal = (try? context.fetch(FetchDescriptor())) ?? [] var serverIdMap: [Int: LocalIssue] = [:] for local in allLocal { if let sid = local.serverId { serverIdMap[sid] = local } } for api in apiIssues { if let existing = serverIdMap[api.id] { // Update mutable fields on existing record existing.issueStatus = api.status existing.severity = api.severity existing.issueDescription = api.description if let fid = api.facilityId { existing.facilityServerId = fid } // Cache facility name so IssueDetailView works when local // facility reference cache has been cleared (Settings → Clear Cache). if let fn = api.facilityName, !fn.isEmpty { existing.facilityNameCache = fn } // Phase A — resolution details from web staff existing.resultNotes = api.resultNotes existing.verificationNote = api.verificationNote existing.reportedByName = api.reportedByName existing.areaNameCache = api.areaName existing.assignedToName = api.assignedToName // Handler ("Handled By", phase35) existing.handlerType = api.handlerType existing.handlerLabel = api.handlerLabel existing.facilityHandlerName = api.facilityHandlerName existing.facilityHandlerContact = api.facilityHandlerContact existing.facilityHandlerNotes = api.facilityHandlerNotes existing.vendorName = api.vendorName existing.vendorContact = api.vendorContact existing.vendorNotes = api.vendorNotes if let vts = api.verifiedAt, let date = Self.isoFormatter.date(from: vts) { existing.verifiedAt = date } // Refresh photos in case they were added after first pull // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). // result_photos are resolution photos — shown separately under // "Resolution Details" in resultPhotoServerPaths. var serverPaths: [String] = [] if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } serverPaths.append(contentsOf: api.mobilePhotoPaths) existing.photoServerPaths = serverPaths existing.resultPhotoServerPaths = api.resultPhotos // Absolute display URLs (presigned R2 / static) — parallel arrays. existing.photoServerUrls = api.photoUrls existing.resultPhotoServerUrls = api.resultPhotoUrls } else { // Insert new server-pulled issue let local = LocalIssue( inspectionLocalId: "", facilityServerId: api.facilityId ?? 0, severity: api.severity, description: api.description ) local.serverId = api.id local.issueStatus = api.status local.syncStatus = "synced" // never re-submit // Cache facility name for offline display local.facilityNameCache = api.facilityName // Phase A — resolution details from web staff local.resultNotes = api.resultNotes local.verificationNote = api.verificationNote local.reportedByName = api.reportedByName local.areaNameCache = api.areaName local.assignedToName = api.assignedToName // Handler ("Handled By", phase35) local.handlerType = api.handlerType local.handlerLabel = api.handlerLabel local.facilityHandlerName = api.facilityHandlerName local.facilityHandlerContact = api.facilityHandlerContact local.facilityHandlerNotes = api.facilityHandlerNotes local.vendorName = api.vendorName local.vendorContact = api.vendorContact local.vendorNotes = api.vendorNotes if let vts = api.verifiedAt, let date = Self.isoFormatter.date(from: vts) { local.verifiedAt = date } // Store server photos so IssueDetailView can show them // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). var serverPaths: [String] = [] if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } serverPaths.append(contentsOf: api.mobilePhotoPaths) local.photoServerPaths = serverPaths local.resultPhotoServerPaths = api.resultPhotos // Absolute display URLs (presigned R2 / static) — parallel arrays. local.photoServerUrls = api.photoUrls local.resultPhotoServerUrls = api.resultPhotoUrls if let ts = api.reportedAt, let date = Self.isoFormatter.date(from: ts) { local.createdAt = date local.serverReportedAt = date // accurate server timestamp } context.insert(local) } } // Remove server-pulled records that are no longer in the response. // This happens when an issue is reassigned to a different inspector — // the server stops returning it for this user, so the local copy must // be deleted. Only remove records that were pulled from the server // (syncStatus == "synced" AND serverId != nil AND inspectionLocalId == ""). // Device-created issues (inspectionLocalId != "") are never touched. let returnedServerIds = Set(apiIssues.map { $0.id }) for local in allLocal { guard let sid = local.serverId, local.syncStatus == "synced", local.inspectionLocalId == "" else { continue } if !returnedServerIds.contains(sid) { context.delete(local) } } try? context.save() } catch APIError.notAuthenticated { // Let AuthManager handle session expiry } catch { // Non-fatal — IssuesListView still shows device-created issues } } // ── Scheduled Inspections (phase36) ─────────────────────────────────── // Read-only pull of planned/recurring assignments for the Dashboard and // My Inspections "Scheduled" section. Upsert by serverId, then delete rows // the server no longer returns (schedule fulfilled, deactivated, or // reassigned to another inspector). Best-effort — never blocks the pipeline. func pullScheduledInspections(context: ModelContext) async { guard isOnline, AuthManager.shared.isAuthenticated else { return } do { let apiRows = try await APIClient.shared.fetchScheduledInspections() // Fetch-all + filter/map in Swift — no #Predicate (CLAUDE.md rule 3). let allLocal = (try? context.fetch(FetchDescriptor())) ?? [] var byServerId: [Int: LocalScheduledInspection] = [:] for row in allLocal { byServerId[row.serverId] = row } for api in apiRows { if let existing = byServerId[api.id] { existing.update(from: api) } else { context.insert(LocalScheduledInspection(from: api)) } } // Delete rows the server no longer returns. let returnedIds = Set(apiRows.map { $0.id }) for row in allLocal where !returnedIds.contains(row.serverId) { context.delete(row) } try? context.save() } catch APIError.notAuthenticated { // Let AuthManager handle session expiry } catch { // Non-fatal — stale scheduled rows stay visible until next pull } } // ── Follow-up Requests ──────────────────────────────────────────────── // Read-only pull of inspections a director flagged for follow-up, for the // Dashboard and My Inspections "Follow-up Requested" section. Upsert by // serverId, then delete rows the server no longer returns (the follow-up was // fulfilled by a linked re-inspection, or the director cleared the flag). // Best-effort — never blocks the pipeline. func pullFollowUpRequests(context: ModelContext) async { guard isOnline, AuthManager.shared.isAuthenticated else { return } do { let apiRows = try await APIClient.shared.fetchFollowUpRequests() // Fetch-all + filter/map in Swift — no #Predicate (CLAUDE.md rule 3). let allLocal = (try? context.fetch(FetchDescriptor())) ?? [] var byServerId: [Int: LocalFollowUpRequest] = [:] for row in allLocal { byServerId[row.serverId] = row } for api in apiRows { if let existing = byServerId[api.id] { existing.update(from: api) } else { context.insert(LocalFollowUpRequest(from: api)) } } // Delete rows the server no longer returns. let returnedIds = Set(apiRows.map { $0.id }) for row in allLocal where !returnedIds.contains(row.serverId) { context.delete(row) } // Keep the local copy of the flagged inspection in step, so the // follow-up badge in My Inspections / history detail agrees with the // card without waiting for the inspector to open that detail view // (which was previously the only thing that wrote these fields). var noteByServerId: [Int: String] = [:] for api in apiRows { if let note = api.followUpNote { noteByServerId[api.id] = note } } for local in (try? context.fetch(FetchDescriptor())) ?? [] { guard let sid = local.serverId else { continue } if returnedIds.contains(sid) { local.followUpRequired = true local.followUpNote = noteByServerId[sid] } else if local.followUpRequired { local.followUpRequired = false local.followUpNote = nil } } try? context.save() } catch APIError.notAuthenticated { // Let AuthManager handle session expiry } catch { // Non-fatal — stale follow-up rows stay visible until next pull } } // ── Session-scope purge ─────────────────────────────────────────────── /// Delete local data belonging to a previous session. /// /// Called when the `(server, user)` pair the database is scoped to changes /// — a different inspector signing in on this iPad, or a server switch. See /// `SessionScope` for why that combination is the boundary, and rule 88. /// /// - Parameters: /// - keepingUserId: the incoming user. Their own unsent inspections are /// preserved when `sameServer` is true; pass nil to keep nothing. /// - sameServer: false when the server changed, in which case NOTHING can /// be kept — every `serverId`, `facilityServerId` and `templateServerId` /// names a row in a different database. /// /// Deliberately NOT called on a plain logout: the common case is the same /// inspector signing back into the same server, and wiping the cache there /// would leave them with an empty app until a full sync succeeds, breaking /// offline use. That was the correct instinct in the original code — the /// bug was only that nothing ever checked whether the next login was /// actually the same person. func purgeSessionScopedData(keepingUserId: Int?, sameServer: Bool) { guard let context = modelContext else { return } // ── Reference caches ────────────────────────────────────────────── // Pure server-scoped copies with no local authorship. Cheap to re-pull, // so there is never a reason to keep one across an identity change. deleteAll(LocalFacility.self, from: context) // cascades areas deleteAll(LocalArea.self, from: context) deleteAll(LocalTemplate.self, from: context) deleteAll(LocalScheduledInspection.self, from: context) deleteAll(LocalFollowUpRequest.self, from: context) // ── Issues ──────────────────────────────────────────────────────── // All of them, unconditionally. LocalIssue carries no author field, so // an unsent one cannot be attributed — and submitting the previous // inspector's issue under the new inspector's credentials would put a // false name on a QC record. Server-pulled and already-synced rows are // the previous user's assignments and stale serverIds respectively. deleteAll(LocalIssue.self, from: context) // ── Inspections ─────────────────────────────────────────────────── // These DO carry an author (`inspectorUserId`), so the incoming user's // own unsent work can be handed back to them intact — but only when the // server is unchanged, since otherwise its facility/template ids point // into the wrong database. let inspections = (try? context.fetch(FetchDescriptor())) ?? [] var keptInspectionIds = Set() for insp in inspections { let ownedByIncomingUser = keepingUserId.map { $0 == insp.inspectorUserId } ?? false if sameServer && ownedByIncomingUser { keptInspectionIds.insert(insp.localId) } else { // Remove the JPEGs too — cleanupOrphanedPhotos would otherwise // wait out its 7-day age floor holding another user's evidence. for photo in insp.pendingPhotos { try? FileManager.default.removeItem(atPath: photo.localFilePath) } context.delete(insp) // cascades pendingPhotos + localIssues } } // ── Photos orphaned by the above ────────────────────────────────── // Issue photos are inserted standalone rather than through a // relationship, so no cascade reaches them — and every LocalIssue has // just been deleted, which is why only kept inspections can retain one. let photos = (try? context.fetch(FetchDescriptor())) ?? [] for photo in photos { let stillOwned = photo.entityType == "inspection" && keptInspectionIds.contains(photo.entityLocalId) if !stillOwned { try? FileManager.default.removeItem(atPath: photo.localFilePath) context.delete(photo) } } try? context.save() // ── In-memory state from the old session ────────────────────────── // The poll TASK is left running; only its data is dropped. Stopping it // here would leave polling dead until the next connectivity change or // foreground, because nothing on the login path restarts it. dashboardStats = nil lastNotificationFetch = nil unreadNotificationCount = 0 recentNotifications = [] syncError = nil lastSyncAt = nil updatePendingCount(context: context) print("[JQC] Sync | purgeSessionScopedData | kept \(keptInspectionIds.count) " + "inspection(s) for user \(keepingUserId.map(String.init) ?? "-")") } /// Fetch-all + delete. No #Predicate (CLAUDE.md rule 3), and `try?` /// parenthesised before `??` (rule 25). private func deleteAll(_ type: T.Type, from context: ModelContext) { let rows = (try? context.fetch(FetchDescriptor())) ?? [] for row in rows { context.delete(row) } } // ── Dashboard Stats ─────────────────────────────────────────────────── // Best-effort fetch — a network failure silently leaves dashboardStats nil // so the UI falls back to a placeholder card. Never blocks the sync pipeline. func fetchDashboardStats() async { guard isOnline, AuthManager.shared.isAuthenticated else { return } do { dashboardStats = try await APIClient.shared.fetchDashboardStats() } catch APIError.notAuthenticated { // Let AuthManager handle session expiry } catch { // Non-fatal — stale stats stay visible until next successful fetch } } }