Files
JQC_iOS_App/JanitorialQC/Sync/SyncManager.swift
T

1544 lines
78 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?
/// Number of `LocalNotification` rows with `isRead == false`.
///
/// Derived from the store rather than counted as items arrive: read state
/// is now real and can change from either end (a tap here, Mark All, or the
/// same account reading on the web), so an incrementing tally would drift.
/// Refreshed by `refreshUnreadNotificationCount()` after every poll and
/// every read action.
@Published var unreadNotificationCount = 0
// ── 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.
///
/// Resets the CURSOR and stops the task; it does not touch the stored
/// inbox. The same inspector signing back in should still find their
/// notifications and their read state, exactly as they find their cached
/// facilities and issues — a different inspector is handled by the identity
/// purge instead (rule 88). `unreadNotificationCount` is recomputed from
/// the store rather than zeroed, so the badge stays truthful.
func resetNotificationPoller() {
lastNotificationFetch = nil
stopPollTask()
refreshUnreadNotificationCount()
}
/// 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() }
}
// NOTE: `markNotificationsViewed()` is gone. It zeroed the badge merely
// because the inbox had been OPENED, which is incompatible with showing
// real read state — the badge would read 0 while every row still rendered
// as unread. Reading is now an explicit act: tap a row, or Mark All Read.
// ── 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 }
// Upsert into the local inbox. The endpoint only ever returns
// UNREAD rows and never sends the flag, so a row disappearing from
// the response says nothing — it may have been read on the web, or
// simply be older than the cursor. Rows are therefore never deleted
// here; `pruneReadNotifications()` handles retention instead.
if let context = modelContext {
let existing = (try? context.fetch(FetchDescriptor<LocalNotification>())) ?? []
var byServerId: [Int: LocalNotification] = [:]
for row in existing { byServerId[row.serverId] = row }
for api in notifications {
if let row = byServerId[api.id] {
row.update(from: api)
} else {
context.insert(LocalNotification(from: api))
// Banner ONLY for genuinely new rows. Previously every
// polled item was delivered, so a cold launch (cursor
// nil → the server returns all unread) re-banner'd the
// inspector's whole backlog on every app start.
deliverLocalNotification(api)
}
}
try? context.save()
refreshUnreadNotificationCount()
}
// Advance the cursor so the next poll only fetches newer items.
//
// Still no implicit mark-read: the server's read state changes only
// on a deliberate user action — a tap or Mark All, which route
// through markNotificationRead/markAllNotificationsRead. Marking on
// poll would zero the user's WEB badge simply because the iPad was
// switched on (rule 92).
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
}
}
// ── Notification read state ───────────────────────────────────────────
/// Recount unread rows and publish. Cheap: one fetch, no relationships.
func refreshUnreadNotificationCount() {
guard let context = modelContext else { return }
let all = (try? context.fetch(FetchDescriptor<LocalNotification>())) ?? []
unreadNotificationCount = all.filter { !$0.isRead }.count
}
/// Mark one notification read — optimistically local, then pushed.
///
/// Local first so the inbox responds instantly and works offline; the
/// server call is best-effort and `readSyncPending` keeps the debt until it
/// lands (the same shape as every other write in this app).
func markNotificationRead(_ notification: LocalNotification) async {
guard !notification.isRead else { return }
notification.markRead()
try? modelContext?.save()
refreshUnreadNotificationCount()
await pushNotificationReadState()
}
/// Mark every unread notification read.
func markAllNotificationsRead() async {
guard let context = modelContext else { return }
let all = (try? context.fetch(FetchDescriptor<LocalNotification>())) ?? []
let unread = all.filter { !$0.isRead }
guard !unread.isEmpty else { return }
for row in unread { row.markRead() }
try? context.save()
refreshUnreadNotificationCount()
await pushNotificationReadState()
}
/// Push any locally-read notifications the server does not know about yet.
///
/// Runs on every sync as well as immediately after a read action, so a
/// notification opened in airplane mode still clears the web badge once the
/// iPad reconnects. `markNotificationsRead` was dead code before this.
func pushNotificationReadState() async {
guard isOnline, AuthManager.shared.isAuthenticated,
let context = modelContext
else { return }
let all = (try? context.fetch(FetchDescriptor<LocalNotification>())) ?? []
let pending = all.filter { $0.readSyncPending }
guard !pending.isEmpty else { return }
do {
try await APIClient.shared.markNotificationsRead(ids: pending.map { $0.serverId })
for row in pending { row.readSyncPending = false }
try? context.save()
} catch {
// Left pending — retried on the next sync. The row already reads as
// read locally, which is what the inspector asked for.
}
}
/// Drop read notifications older than the retention window.
///
/// Needed because nothing else ever deletes a row: the poll endpoint cannot
/// tell us a notification is gone (it only returns unread), so without this
/// the inbox would grow without bound. Unread rows are never pruned however
/// old — an unread alert is outstanding work.
private static let notificationRetention: TimeInterval = 30 * 24 * 3600 // 30 days
private func pruneReadNotifications(context: ModelContext) {
let cutoff = Date().addingTimeInterval(-Self.notificationRetention)
let all = (try? context.fetch(FetchDescriptor<LocalNotification>())) ?? []
var removed = 0
for row in all where row.isRead && !row.readSyncPending && row.createdAt < cutoff {
context.delete(row)
removed += 1
}
if removed > 0 {
try? context.save()
print("[JQC] Sync | pruneReadNotifications | removed \(removed) row(s)")
}
}
// ── 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()
// Drain read state marked while offline, then trim the inbox.
await pushNotificationReadState()
pruneReadNotifications(context: context)
// 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 }
// ── Heal stale container paths FIRST ──────────────────────────────
//
// Stored paths are absolute and embed the app-container UUID, which iOS
// reassigns on every app update, reinstall and restore. The files
// survive in Documents/; the paths do not. `uploadPhoto` then fails on
// `FileManager.contents(atPath:)` and can NEVER succeed however often it
// is retried, because the path names a container that no longer exists.
//
// This is what stranded inspection #887's nine photos: reported as
// `upload: pending`, 0 rows failed, every file present on disk under the
// CURRENT container. Retrying was futile; re-resolving is all that was
// ever needed. See Utils/PhotoStore.swift and rule 91.
//
// Runs before the "pending" filter on purpose, so a row already given up
// on is REVIVED rather than left stranded — otherwise a single app
// update permanently costs an inspection its evidence.
var healed = 0
for photo in allPhotos where photo.uploadStatus != "uploaded" {
guard let live = PhotoStore.resolve(photo.localFilePath),
live != photo.localFilePath
else { continue }
photo.localFilePath = live
// Previous failures were about a path that no longer applies, so
// they are not evidence about this one — reset the retry budget.
photo.uploadRetryCount = 0
photo.lastUploadError = nil
if photo.uploadStatus == "failed" { photo.uploadStatus = "pending" }
healed += 1
}
if healed > 0 {
try? context.save()
print("[JQC] Sync | processPhotoQueue | re-resolved \(healed) stale photo path(s)")
}
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 {
// Paths were healed above, so an unresolvable one here means the
// file is genuinely gone from every container. Fail fast rather
// than burning five attempts and five sync cycles on it.
guard PhotoStore.resolve(photo.localFilePath) != nil else {
photo.uploadStatus = "failed"
photo.uploadRetryCount = Self.maxPhotoUploadAttempts
photo.lastUploadError = "File no longer on this device: "
+ PhotoStore.filename(of: photo.localFilePath)
try? context.save()
continue
}
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
photo.lastUploadError = nil
attachServerPath(serverPath, for: photo,
inspections: allInspections, issues: allIssues)
await pushLateIssuePhotoIfNeeded(serverPath, for: photo,
issues: allIssues, context: context)
await pushLateInspectionPhotoIfNeeded(serverPath, for: photo,
inspections: allInspections,
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
// Keep the reason. Without it a lost evidence photo is
// undiagnosable after the fact — see PendingPhoto.lastUploadError.
photo.lastUploadError = error.localizedDescription
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
dup.lastUploadError = nil
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()
}
/// Push a recovered photo onto an inspection that has ALREADY been submitted.
///
/// The inspection counterpart of pushLateIssuePhotoIfNeeded, and the gap
/// that made inspection photo loss permanent:
///
/// 1. the upload fails `maxPhotoUploadAttempts` times -> row goes "failed"
/// 2. "failed" counts as ready, so processInspectionQueue submits anyway
/// and APIClient.submitInspection rewrites the surviving local:// value
/// to "" — the field lands BLANK on the server
/// 3. the inspection is marked synced; nothing ever revisits it
///
/// Step 3 was the dead end. "Retry Failed Items" could re-upload the file
/// successfully, but attachServerPath only wrote the path into LOCAL form
/// data, which no longer goes anywhere — the inspection was already synced.
/// The photo sat on the device, recoverable in principle and unreachable in
/// practice. This closes the loop by PATCHing the server copy.
///
/// Normal path: processPhotoQueue runs BEFORE processInspectionQueue, so a
/// first-time inspection has no serverId yet and this does nothing — the
/// path travels in the submit body as usual. Only a recovery reaches here.
///
/// Best-effort: a failure leaves the photo attached locally and retried on
/// the next pass, exactly like the issue version.
private func pushLateInspectionPhotoIfNeeded(
_ serverPath: String,
for photo: PendingPhoto,
inspections: [LocalInspection],
context: ModelContext
) async {
guard photo.entityType == "inspection",
let fieldId = photo.fieldId
else { return }
let entityId = photo.entityLocalId
guard let inspection = inspections.first(where: { $0.localId == entityId }),
let inspectionServerId = inspection.serverId
else { return }
do {
try await APIClient.shared.updateInspectionFormData(
inspectionId: inspectionServerId,
fields: [fieldId: serverPath]
)
inspection.syncErrorMessage = nil
} catch {
inspection.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 }
// Photos are matched by entityLocalId rather than navigated to via the
// relationship — see the photosReady guard below for why.
let allPhotos = (try? context.fetch(FetchDescriptor<PendingPhoto>())) ?? []
for inspection in pending {
// "failed" is only reachable after maxPhotoUploadAttempts, so this
// 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.
//
// Matched by entityLocalId, NOT via `inspection.pendingPhotos`.
// That relationship declares no explicit inverse (rule 37, which
// LocalIssue follows and PendingPhoto does not), so it is not a
// trustworthy source of truth here — and an EMPTY array makes
// `allSatisfy` vacuously true, which silently converts this guard
// into no guard at all and submits the inspection with every photo
// still pending. processPhotoQueue and processIssueQueue already
// query globally; this is now consistent with them.
let ownPhotos = allPhotos.filter {
$0.entityType == "inspection" && $0.entityLocalId == inspection.localId
}
let photosReady = ownPhotos.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 FILENAMES on @MainActor ───────────────
// Filenames, not paths. Stored paths are absolute and embed the app
// container UUID, which iOS changes on every app update — so after an
// update every reference would fail to match its own file on disk and
// this sweep would delete the lot, including photos still awaiting
// upload. Filenames are per-save UUIDs and survive the move (PhotoStore).
var referencedNames = Set<String>()
func reference(_ storedPath: String) {
let name = PhotoStore.filename(of: storedPath)
if !name.isEmpty { referencedNames.insert(name) }
}
// PendingPhoto — not yet uploaded
if let pendingPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) {
for p in pendingPhotos where p.uploadStatus != "uploaded" {
reference(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://") {
reference(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 { reference(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 `referencedNames` 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 {
// Matched by filename — see the Phase 1 comment.
if referencedNames.contains(fileURL.lastPathComponent) { 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)
// Notifications are addressed to one user — the most personal thing in
// the store, and the clearest thing another inspector must never see.
deleteAll(LocalNotification.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 {
PhotoStore.remove(at: 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 {
PhotoStore.remove(at: 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
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
}
}
}