Files
JQC_iOS_App/JanitorialQC/Sync/SyncManager.swift
T

943 lines
47 KiB
Swift

// 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
@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<Void, Never>? // 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
}
/// 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 }
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<PendingPhoto>()) 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<String>()
var duplicates: [PendingPhoto] = []
pending = pending.filter { photo in
if seenPaths.contains(photo.localFilePath) {
duplicates.append(photo)
return false
}
seenPaths.insert(photo.localFilePath)
return true
}
for dup in duplicates {
// Mark the duplicate row as uploaded without re-uploading — the
// first row for this file will populate serverPath/photoServerPaths.
dup.uploadStatus = "uploaded"
}
// 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<LocalInspection>())) ?? []
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
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<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.
// 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<LocalIssue>()) 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<LocalInspection>())) ?? []
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<LocalFacility>())
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<Int>()
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<LocalInspection>())
let localIssues = try context.fetch(FetchDescriptor<LocalIssue>())
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<LocalTemplate>())
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<String>()
// PendingPhoto — not yet uploaded
if let pendingPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) {
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<LocalInspection>()) {
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<LocalIssue>()) {
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<String>)
// 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<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, 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<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)
// 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<LocalTemplate>()))?
.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<LocalIssue>())) ?? []
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<LocalScheduledInspection>())) ?? []
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
}
}
}