Aug 17 - Update for customer inspector contract scope
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user