diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index cabdf36..7f06a8b 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -250,6 +250,14 @@ guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated e 4. **`pullReferenceData`** — fetches facilities, areas, templates. **Deduplicates facility response by `id` using `seenFacilityIds = Set()`** before upserting — prevents duplicate buildings in pickers when server returns same facility ID multiple times. + **Prunes facilities the server no longer returns (Aug 2026).** `/api/v1/facilities` is already scoped server-side, 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 (`StartInspectionView.contracts`, `IssuesView.contracts` both map over `LocalFacility`), so one stale facility kept a whole contract in the Start Inspection picker forever — which is how this surfaced. Templates already had this prune; facilities were the gap. + + Two rules in the prune: + - Deletion only runs after **both** requests succeeded, so a failed sync can never empty the cache (it throws first). + - A facility still referenced by **unsynced** local work (a `LocalInspection` or `LocalIssue` with `syncStatus != "synced"`) is **kept but marked `isActive = false`** instead of deleted. `ExecuteInspectionView`/`MyInspectionsView` resolve the facility name by `serverId` and the issue *detail* view has no `facilityNameCache` fallback, so deleting it would turn an in-progress draft into "Unknown Facility". The row is pruned on a later sync once that work has been submitted, and `update(from:)` flips `isActive` back to true if the facility returns to scope. + + **Every picker must therefore filter on `isActive`** — both views expose `availableFacilities` for this and derive `contracts` / `filteredFacilities` from it, never from the raw `@Query`. A retained out-of-scope row is for *display only*; offering it would let an inspector start work the server then rejects. + 5. **`pullAssignedIssues`** — fetches `GET /api/v1/issues`. Merges `api.photoPath` + `api.mobilePhotoPaths` into `photoServerPaths`. **Does NOT include `api.resultPhotos`** — resolution photos are web-only. Deletion pass runs always (not short-circuited on empty response). 6. **`pollNotifications`** — fetches new notifications since `lastNotificationFetch` cursor. diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 07d91f5..f2c1051 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -513,6 +513,53 @@ class SyncManager: ObservableObject { 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()) + let localIssues = try context.fetch(FetchDescriptor()) + 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()) let templateMap = Dictionary( existingTemplates.map { ($0.serverId, $0) }, diff --git a/JanitorialQC/Views/Dashboard/IssuesView.swift b/JanitorialQC/Views/Dashboard/IssuesView.swift index 2a7085a..54370df 100644 --- a/JanitorialQC/Views/Dashboard/IssuesView.swift +++ b/JanitorialQC/Views/Dashboard/IssuesView.swift @@ -1089,11 +1089,19 @@ struct StandaloneIssueView: View { } private var remainingSlots: Int { maxPhotos - photos.count } + /// Facilities this user may file a new issue against. + /// Excludes rows SyncManager retained purely so an unsynced draft could + /// still show its facility name — see StartInspectionView for the full + /// explanation. Out-of-scope facilities must not be offered for new work. + private var availableFacilities: [LocalFacility] { + facilities.filter { $0.isActive } + } + /// Unique contracts derived from cached facilities, sorted by name. private var contracts: [(id: Int, name: String)] { var seen = Set() var result: [(id: Int, name: String)] = [] - for f in facilities { + for f in availableFacilities { if seen.insert(f.projectId).inserted { result.append((id: f.projectId, name: f.projectName)) } @@ -1106,7 +1114,7 @@ struct StandaloneIssueView: View { private var filteredFacilities: [LocalFacility] { guard let pid = selectedProjectId else { return [] } var seen = Set() - return facilities + return availableFacilities .filter { $0.projectId == pid } .filter { seen.insert($0.serverId).inserted } } diff --git a/JanitorialQC/Views/Dashboard/StartInspectionView.swift b/JanitorialQC/Views/Dashboard/StartInspectionView.swift index 3e4c904..058e3cc 100644 --- a/JanitorialQC/Views/Dashboard/StartInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/StartInspectionView.swift @@ -39,12 +39,23 @@ struct StartInspectionView: View { // ── Derived lists ───────────────────────────────────────────────────── + /// Facilities this inspector may actually start work at. + /// + /// The cache can hold a facility that is no longer in scope — SyncManager + /// keeps such a row (marked inactive) when an unsynced draft still needs + /// its name, rather than deleting it and showing "Unknown Facility". It + /// must not be offered for NEW work, and neither must its contract, so + /// every derived list below starts here rather than from `facilities`. + private var availableFacilities: [LocalFacility] { + facilities.filter { $0.isActive } + } + /// Unique contracts (projectId, projectName) sorted by name. /// Facilities with projectId == 0 are grouped under "No Contract". private var contracts: [(id: Int, name: String)] { var seen = Set() var result: [(id: Int, name: String)] = [] - for f in facilities { + for f in availableFacilities { if seen.insert(f.projectId).inserted { result.append((id: f.projectId, name: f.projectName)) } @@ -58,7 +69,7 @@ struct StartInspectionView: View { private var filteredFacilities: [LocalFacility] { guard let pid = selectedProjectId else { return [] } var seen = Set() - return facilities + return availableFacilities .filter { $0.projectId == pid } .filter { seen.insert($0.serverId).inserted } }