// Sync/SyncManager.swift // ---------------------- // Manages connectivity monitoring, reference data sync (Phase A), // and the outbox queue for offline inspection/issue submission (Phase B). import Foundation import Network import SwiftData import SwiftUI import Combine @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 // ── Dependencies ────────────────────────────────────────────────────── private let monitor = NWPathMonitor() private let monitorQueue = DispatchQueue(label: "com.jqc.networkmonitor") var modelContext: ModelContext? 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 wasOffline && self.isOnline { await self.triggerSync() } } } monitor.start(queue: monitorQueue) } // ── 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 } isSyncing = true syncError = nil defer { isSyncing = false } await processPhotoQueue(context: context) await processInspectionQueue(context: context) await processIssueQueue(context: context) await pullReferenceData() 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 } let pending = allPhotos .filter { $0.uploadStatus == "pending" } .sorted { $0.createdAt < $1.createdAt } for photo in pending { do { let serverPath = try await APIClient.shared.uploadPhoto( localPath: photo.localFilePath, entityType: photo.entityType ) photo.serverPath = serverPath photo.uploadStatus = "uploaded" // Update parent inspection form field value if photo.entityType == "inspection", let fieldId = photo.fieldId { let entityId = photo.entityLocalId let inspections = try? context.fetch( FetchDescriptor( predicate: #Predicate { $0.localId == entityId } ) ) inspections?.first?.setValue(serverPath, forFieldId: fieldId) } // Update parent issue photo path if photo.entityType == "issue" { let entityId = photo.entityLocalId let issues = try? context.fetch( FetchDescriptor( predicate: #Predicate { $0.localId == entityId } ) ) issues?.first?.photoServerPath = serverPath } 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. if let parentLocalId = inspection.parentLocalId { let allInspections = try? context.fetch(FetchDescriptor()) if let parent = allInspections?.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. let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] for issue in pending { // Guard: if the parent inspection permanently failed to sync, // submitting this issue without an inspection_id would create an // orphaned server record. Mark it failed immediately instead. let parentId = issue.inspectionLocalId let parent = allInspections.first(where: { $0.localId == parentId }) if parent?.syncStatus == "failed" { 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 } do { let issueId = try await APIClient.shared.submitIssue(issue) issue.serverId = issueId issue.syncStatus = "synced" 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 } ) for apiFacility in facilitiesData.facilities { if let existing = facilityMap[apiFacility.id] { existing.update(from: apiFacility) } else { context.insert(LocalFacility(from: apiFacility)) } try await upsertAreas(for: apiFacility.id, 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 { if let existing = templateMap[apiSummary.id] { existing.updateSummary(from: apiSummary) } else { context.insert(LocalTemplate(from: apiSummary)) } try await upsertTemplateSchema( id: apiSummary.id, context: context, templateMap: templateMap ) } try context.save() } catch APIError.notAuthenticated { syncError = "Session expired. Please log in again." } catch { syncError = "Sync failed: \(error.localizedDescription)" } } // ── Pending Count ───────────────────────────────────────────────────── func updatePendingCount(context: ModelContext) { 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, 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) } else { context.insert(LocalArea(from: apiArea)) } } } private func upsertTemplateSchema( id: Int, context: ModelContext, templateMap: [Int: LocalTemplate] ) async throws { let detailData: TemplateDetailResponseData = try await APIClient.shared.request( "/api/v1/templates/\(id)" ) if let existing = templateMap[id] { existing.updateSchema(from: detailData.template) } else { (try? context.fetch(FetchDescriptor()))? .first { $0.serverId == id }? .updateSchema(from: detailData.template) } } }