Files
JQC_iOS_App/JanitorialQC/Sync/SyncManager.swift
T

298 lines
13 KiB
Swift

// 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<PendingPhoto>()) 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<LocalInspection>(
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<LocalIssue>(
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<LocalInspection>()) 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<LocalInspection>())
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<LocalIssue>()) 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<LocalInspection>())) ?? []
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<LocalFacility>())
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<LocalTemplate>())
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<LocalInspection>()))?
.filter { $0.syncStatus == "pending" }.count ?? 0
let issueCount = (try? context.fetch(FetchDescriptor<LocalIssue>()))?
.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<LocalArea>()))?
.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<LocalTemplate>()))?
.first { $0.serverId == id }?
.updateSchema(from: detailData.template)
}
}
}