1307 lines
66 KiB
Swift
1307 lines
66 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
|
|
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<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
|
|
}
|
|
|
|
// ── 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<PendingPhoto>()) 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<LocalInspection>())) ?? []
|
|
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
|
|
|
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<LocalInspection>()) 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<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>())) ?? []
|
|
// 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<PendingPhoto>())) ?? []
|
|
|
|
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<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
|
|
/// 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<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 — 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<LocalInspection>()) {
|
|
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<LocalIssue>()) {
|
|
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<String>)
|
|
// 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<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
|
|
}
|
|
}
|
|
|
|
// ── 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<LocalFollowUpRequest>())) ?? []
|
|
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<LocalInspection>())) ?? [] {
|
|
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<LocalInspection>())) ?? []
|
|
var keptInspectionIds = Set<String>()
|
|
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<PendingPhoto>())) ?? []
|
|
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<T: PersistentModel>(_ type: T.Type, from context: ModelContext) {
|
|
let rows = (try? context.fetch(FetchDescriptor<T>())) ?? []
|
|
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
|
|
}
|
|
}
|
|
}
|