Aug 17 - Update for customer inspector contract scope

This commit is contained in:
2026-08-17 16:07:50 -04:00
parent 1921f95faf
commit 5a01d141f4
4 changed files with 78 additions and 4 deletions
+8
View File
@@ -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<Int>()`** 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.
+47
View File
@@ -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<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) },
+10 -2
View File
@@ -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<Int>()
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<Int>()
return facilities
return availableFacilities
.filter { $0.projectId == pid }
.filter { seen.insert($0.serverId).inserted }
}
@@ -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<Int>()
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<Int>()
return facilities
return availableFacilities
.filter { $0.projectId == pid }
.filter { seen.insert($0.serverId).inserted }
}