// 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) // 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 ──────────────────────────────────────────────────── 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 } 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() 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" } // 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())) ?? [] for photo in pending { 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" // Update parent inspection form field value. // Pre-fetched before the loop — not repeated per photo. if photo.entityType == "inspection", let fieldId = photo.fieldId { let entityId = photo.entityLocalId allInspections.first(where: { $0.localId == entityId })? .setValue(serverPath, forFieldId: fieldId) } // Update parent issue photo paths array. // Pre-fetched before the loop — not repeated per photo. if photo.entityType == "issue" { let entityId = photo.entityLocalId if let issue = allIssues.first(where: { $0.localId == entityId }) { var paths = issue.photoServerPaths if !paths.contains(serverPath) { paths.append(serverPath) } issue.photoServerPaths = paths } } try? context.save() } catch { photo.uploadStatus = "failed" try? context.save() } } } // ── 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 { 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())) ?? [] for issue in pending { let parentLocalId = issue.inspectionLocalId let parent = allInspections.first(where: { $0.localId == parentLocalId }) // ── 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" // Photos are now represented by photoServerPaths on the server. // Clear the local file paths so IssueDetailView doesn't render // a duplicate "local photos" section alongside the server section. issue.photoLocalPaths = [] try? context.save() // If there are additional photos beyond the first (which was sent // as photo_path on create), PATCH them to result_photos now. // The server create endpoint only stores photo_path; result_photos // must be set via a separate PATCH call. let extras = Array(issue.photoServerPaths.dropFirst()) if !extras.isEmpty { try? await APIClient.shared.updateIssuePhotos( issueId: issueId, resultPhotos: extras ) } } 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) } 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 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 — draft photos (formData values starting with "local://") if let inspections = try? context.fetch(FetchDescriptor()) { for insp in inspections where insp.status == "draft" { for val in insp.formData.values { if let s = val as? String, s.hasPrefix("local://") { referencedPaths.insert(String(s.dropFirst("local://".count))) } } } } // LocalIssue — unsync'd issue photos if let issues = try? context.fetch(FetchDescriptor()) { for issue in issues where issue.syncStatus != "synced" { 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. Task.detached(priority: .utility) { let fm = FileManager.default guard let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return } let photosDir = docsDir.appendingPathComponent("JQCPhotos") guard let diskFiles = try? fm.contentsOfDirectory( at: photosDir, includingPropertiesForKeys: nil ) else { return } var deletedCount = 0 for fileURL in diskFiles { if !referencedPaths.contains(fileURL.path) { 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 } } // ── 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 } } }