Aug 27 - Fixed inspection lost photos recovery
This commit is contained in:
@@ -181,6 +181,7 @@ Stored keys (all prefixed `com.jqc.`): `accessToken`, `refreshToken`, `userId`,
|
||||
```swift
|
||||
LocalFacility.self, LocalArea.self, LocalTemplate.self,
|
||||
LocalInspection.self, LocalIssue.self, LocalScheduledInspection.self,
|
||||
LocalFollowUpRequest.self, LocalNotification.self,
|
||||
PendingPhoto.self, SyncQueueEntry.self
|
||||
```
|
||||
|
||||
@@ -197,6 +198,7 @@ PendingPhoto.self, SyncQueueEntry.self
|
||||
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) |
|
||||
| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed), `parentInspectionServerId` (`Int?`, phase45 — set when the schedule is a planned follow-up; becomes the run's `parentServerId`). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` |
|
||||
| `LocalFollowUpRequest` | Read-only cached follow-up request raised on the web (July 2026) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63; this is the *flagged parent* inspection's id and the `parentServerId` the re-inspection links to), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `overallScore`, `inspectionDateString` (sort key), `followUpNote`, `note` (computed, trimmed/nil-ed), `inspectedOn` (computed, parses the `yyyy-MM-dd` prefix only — see the file comment), `fulfilledLocally` (`= false`, rule 71), `parentFormDataJSON` (`= "{}"`, the parent's answers cached for re-inspection prefill — rule 79), `parentFormData` (computed). Pulled by `pullFollowUpRequests()` |
|
||||
| `LocalNotification` | In-app notification inbox (Aug 2026) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `title`, `body`, `eventType` (nil pre-phase17), `issueId`, `createdAt`, `receivedAt`, `isRead` (`= false`), `readAt`, `readSyncPending` (`= false`). Upserted by `pollNotifications()`; the local store IS the inbox, because the API only returns unread — rule 92 |
|
||||
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `uploadRetryCount` (`Int = 0`, rule 83 — the row stays `"pending"` until it hits 5), `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
||||
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
|
||||
|
||||
@@ -340,6 +342,33 @@ install and an upgrade from a build without the marker are indistinguishable, an
|
||||
"purge" would delete an in-progress draft belonging to the person signing in right then.
|
||||
Every identity change after that first login is covered.
|
||||
|
||||
### Notification inbox (Aug 2026)
|
||||
|
||||
Notifications are persisted as `LocalNotification` and the inbox reads that store,
|
||||
not the poll response. **Why:** `GET /api/v1/notifications` is a poller, not an inbox —
|
||||
it filters to `is_read = False` and never sends the flag, so the list was all-unread by
|
||||
construction (every row looked identical, which is the defect this fixed) and a
|
||||
notification became invisible the moment it was read.
|
||||
|
||||
- `pollNotifications()` **upserts** by `serverId` and never deletes. A row missing from a
|
||||
response means nothing: it may have been read on the web, or just predate the cursor.
|
||||
- A local banner fires **only for newly-inserted rows**. Previously every polled item was
|
||||
delivered, so a cold launch (cursor nil → server returns the whole unread backlog)
|
||||
re-banner'd all of it on every app start.
|
||||
- `unreadNotificationCount` is **derived** by `refreshUnreadNotificationCount()`, not
|
||||
tallied as items arrive — read state changes from both ends now.
|
||||
- `markNotificationsViewed()` is **gone**. It zeroed the badge because the screen had been
|
||||
opened, which cannot coexist with real read state (badge 0, every row still unread).
|
||||
- Reading is explicit: tap a row, swipe, or **Mark All Read**. Local first
|
||||
(`LocalNotification.markRead()` sets `readSyncPending`), then pushed by
|
||||
`pushNotificationReadState()` — so it works offline and drains on reconnect.
|
||||
- `pruneReadNotifications()` drops **read** rows older than 30 days. Unread rows are never
|
||||
pruned at any age; nothing else deletes a row, so without this the store grows forever.
|
||||
|
||||
`NotificationDetailView` shows the full text and links to the referenced issue when that
|
||||
issue is cached locally, and says so plainly when it is not — the issue may belong to
|
||||
another inspector or simply not be pulled yet.
|
||||
|
||||
### Notification polling
|
||||
|
||||
- `startPollTask()` creates a `Task` with `Task.sleep(nanoseconds: 60_000_000_000)` loop.
|
||||
@@ -854,6 +883,8 @@ unchanged. See §8, `purgeSessionScopedData()`.
|
||||
| 88 | **Local data is scoped to a `(server, userId)` pair — purge on an identity CHANGE, never on logout** | Two defects, one cause. (a) Logout deliberately kept the cache so the same inspector could work offline after signing back in — correct — but nothing checked that the next sign-in *was* the same inspector. `pullAssignedIssues`' reconciliation only deletes rows with `inspectionLocalId == ""`, so device-authored synced issues survived indefinitely and a different inspector on the same iPad simply inherited them. (b) The server switch cleared `LocalIssue` alone, leaving `LocalInspection` rows carrying `facilityServerId`/`templateServerId` values that name different rows on the server being switched to — ready to be submitted against it. `SessionScope` (UserDefaults, **not** Keychain — it must outlive `KeychainHelper.clearAll()`) records the pair; `AuthManager.reconcileSessionScope()` compares on every `login()`/`restoreSession()` and calls `SyncManager.purgeSessionScopedData()` only on a mismatch, before `isAuthenticated` flips so no view ever renders the previous user's data. `LocalInspection` is the only model with an author (`inspectorUserId`), so it is the only one whose unsent rows can be handed back; `LocalIssue` has none, and submitting one under a different inspector's credentials would put a false name on a QC record. A nil marker adopts the existing data rather than purging — fresh install and pre-marker upgrade are indistinguishable, and guessing wrong would delete the signing-in user's own draft. |
|
||||
| 89 | **Never raise a second alert from inside the first one's button action** | Both alerts hang off the same view, so the new presentation is discarded while the first is still tearing down. `ExecuteInspectionView`'s Submit set `showNoGPSAlert = true` from inside the confirm alert's action, and the warning simply never appeared — tapping Submit without a GPS fix did *nothing at all*: no alert, no submission, no feedback. Park the intent in a `@State` flag and act on it from `onChange(of:)` when the first alert's binding flips false, with a short hop so the dismissal animation has finished. Applies to `.sheet`/`.confirmationDialog` chained onto one view too. |
|
||||
| 90 | **`date` form fields are `"yyyy-MM-dd"`, and an unanswered one must render as unanswered** | Two defects in one widget, both in `CellDatePicker` and `DateFieldView`. (a) They stored `ISO8601DateFormatter().string(...)` — a full `2026-08-18T14:30:00Z` timestamp — into a field the web writes with `<input type="date">` and both the read-only grid and the PDF print verbatim. `FormDateFormat` (UTC + POSIX, `yyyy-MM-dd`) is now the single definition, and parses a leading date out of legacy timestamp values. (b) A `DatePicker` bound to an empty value still displays TODAY, so the field looked answered — but the setter only fires on a *change*, so selecting the already-shown date wrote nothing and `missingRequiredFields()` reported it missing with a date visible on screen. An explicit "Set date" affordance replaces the picker while the value is empty, plus an × to return to unanswered. |
|
||||
| 91 | **A stored photo path is only valid inside the container that wrote it — resolve by FILENAME, never trust the absolute path** | Photo paths are absolute and embed the app-container UUID (`/var/mobile/Containers/Data/Application/<UUID>/Documents/JQC/Photos/<file>.jpg`). iOS assigns a NEW container UUID on every app update, reinstall and restore: `Documents/` survives, every stored path dies. Nothing accounted for that, so `uploadPhoto`'s `FileManager.contents(atPath:)` returned nil and the upload could **never** succeed no matter how often it retried — the path named a container that no longer existed. Any photo still awaiting upload when the app updated was stranded permanently and its inspection submitted with the field blank. Confirmed in the field on inspection #887 (Aug 2026): nine photos, all `upload: pending`, **0 rows failed**, every file present on disk under the current container. `PhotoStore.resolve()` re-resolves by basename (filenames are per-save UUIDs, so a basename is unambiguous) and is now used by every reader, writer and deleter of a stored path. `processPhotoQueue` heals paths BEFORE the pending filter so a row already given up on is revived, and resets `uploadRetryCount` — earlier failures were about a path that no longer applies. **The retry budget added in rule 83 does not help here and never could: retrying an unresolvable path is futile.** Two corollaries: an unresolvable path now fails fast instead of burning five sync cycles, and `cleanupOrphanedPhotos` compares FILENAMES — comparing full paths meant that after an update every reference missed its own file and the sweep would have deleted exactly the photos that were still recoverable. |
|
||||
| 92 | **The notification API is a POLLER, not an inbox — `LocalNotification` is the inbox** | `GET /api/v1/notifications` filters to `is_read = False` and never sends the flag (`app/api/notifications.py`), so the iPad list was all-unread by construction — every row rendered identically, with nothing to read or dismiss — and marking one read made it disappear rather than grey out. The fix is a local store the poll upserts into and never deletes from; a row's absence from a response carries no information. Read state is shared with the web: a tap or Mark All is a **deliberate user action**, so it pushes via `PATCH /notifications/mark-read` (previously dead code) and clears the web badge too. That does not contradict the long-standing rule against marking read on POLL — auto-marking would zero the user's web badge just because the iPad was switched on, and `pollNotifications()` still never does it. Writes are local-first with `readSyncPending`, drained by `pushNotificationReadState()`, so reading works in airplane mode. If a mobile inbox endpoint mirroring `routes/notifications.py::index` is ever added, this model becomes a cache of it rather than the source of truth. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user