Aug 19 - Fixed photo-loss issue
This commit is contained in:
+78
-25
@@ -89,7 +89,7 @@ JanitorialQC/
|
||||
│
|
||||
├── API/
|
||||
│ ├── APIClient.swift # actor — URLSession, JWT inject, 401-retry, photo upload
|
||||
│ │ # updateIssuePhotos() — PATCH /issues/<id>/photos
|
||||
│ │ # submitIssue() sends photo_path + result_photos (rule 85)
|
||||
│ └── APIModels.swift # All Codable/Sendable response DTOs
|
||||
│ # APIAssignedIssue has photoPath + mobilePhotoPaths + resultPhotos
|
||||
│
|
||||
@@ -197,7 +197,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()` |
|
||||
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
||||
| `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` |
|
||||
|
||||
### LocalInspection Status Flow
|
||||
@@ -247,7 +247,7 @@ guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated e
|
||||
|
||||
2. **`processInspectionQueue`** — submits completed inspections when all `pendingPhotos` are settled.
|
||||
|
||||
3. **`processIssueQueue`** — guards against submitting when parent inspection `syncStatus == "failed"`. After successful submit: sets `syncStatus = "synced"`, **clears `photoLocalPaths = []`** (prevents duplicate photo sections in `IssueDetailView`), then calls `updateIssuePhotos(issueId:resultPhotos:)` for any extra photos beyond the first (`Array(photoServerPaths.dropFirst())`).
|
||||
3. **`processIssueQueue`** — guards against submitting when parent inspection `syncStatus == "failed"`, **and waits for the issue's own photos to settle** (same rule as inspections — see rule 83). `submitIssue()` sends every evidence photo in the one create request (rule 85). After successful submit: sets `syncStatus = "synced"` and clears `photoLocalPaths = []` **only when every photo uploaded** (rule 84).
|
||||
|
||||
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.
|
||||
|
||||
@@ -269,13 +269,31 @@ guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated e
|
||||
|
||||
Records inserted by `pullAssignedIssues` are identified by: `syncStatus == "synced"` AND `inspectionLocalId == ""`. These are the only records safe to delete during reconciliation.
|
||||
|
||||
### clearServerPulledData() — on logout / server switch
|
||||
### purgeSessionScopedData() — on identity change
|
||||
|
||||
Deletes every `LocalIssue` where `serverId != nil`. This covers:
|
||||
- Server-pulled assigned issues (`inspectionLocalId == ""`, `syncStatus == "synced"`)
|
||||
- Inspector-created issues that already synced (`inspectionLocalId != ""`, `serverId != nil`)
|
||||
Replaces `clearServerPulledData()`, which deleted `LocalIssue` only and was called
|
||||
from the wrong place. See rule 88.
|
||||
|
||||
Preserves only truly pending device-created issues (`serverId == nil`, `syncStatus == "pending"`).
|
||||
**Trigger is a change of `SessionScope` — the `(server, userId)` pair the database is
|
||||
scoped to — not logout.** `AuthManager.reconcileSessionScope()` compares the incoming
|
||||
session against the recorded scope on every `login()` and `restoreSession()`, and purges
|
||||
only when they differ.
|
||||
|
||||
| Model | Kept |
|
||||
|---|---|
|
||||
| `LocalFacility` / `LocalArea` / `LocalTemplate` / `LocalScheduledInspection` / `LocalFollowUpRequest` | Nothing — pure caches, re-pulled on the next sync |
|
||||
| `LocalIssue` | Nothing — the model has no author field, so an unsent issue cannot be attributed and must not be submitted under a different inspector's name |
|
||||
| `LocalInspection` | Only rows whose `inspectorUserId` matches the incoming user, **and** only when the server is unchanged |
|
||||
| `PendingPhoto` | Only rows belonging to a kept inspection; the JPEGs of the rest are deleted from disk too |
|
||||
|
||||
A plain logout still purges nothing: the same inspector signing back into the same server
|
||||
keeps their cache and stays usable offline. That was always the right call — the defect was
|
||||
that nothing checked whether the next sign-in was the same person.
|
||||
|
||||
**`SessionScope.stored == nil` adopts the existing data rather than purging.** A fresh
|
||||
install and an upgrade from a build without the marker are indistinguishable, and guessing
|
||||
"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 polling
|
||||
|
||||
@@ -324,16 +342,20 @@ All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.bas
|
||||
|
||||
`decoder.keyDecodingStrategy = .convertFromSnakeCase` — snake_case server fields map to camelCase automatically. Server POST body keys are snake_case (`photo_path`, `result_photos`, `facility_id`, etc.).
|
||||
|
||||
**`decode()` reads the envelope header before the payload.** `_EnvelopeMeta` (`ok` + `error`) is decoded first; only if `ok` is true is the payload decoded **strictly** via `_EnvelopePayload<T>`. This separates "the server reported a failure" from "the server succeeded and we could not read it" — previously `data` was decoded with `try?`, so *any* schema drift produced `data == nil` and surfaced as `serverError("Unknown server error")`, sending every investigation to the backend for what was a client-side contract mismatch. Failures now report the offending field (`missing field 'x' in APIFoo.bar`) via `describe(_:as:)`, because `DecodingError.localizedDescription` is always the useless "data couldn't be read" string.
|
||||
|
||||
**Token refresh is coalesced (`refreshTask`).** Being an `actor` is not sufficient: `refreshAccessToken()` suspends at `await`, releasing the actor, so two requests 401-ing at once each POSTed `/auth/refresh` with the *same* refresh token. The server rotates on the first, so the second presented a spent token, failed, and signed the user out mid-sync — reachable because `pollNotifications` and `registerDevice` run alongside `triggerSync`. Concurrent callers now await one shared `Task`.
|
||||
|
||||
### Key methods
|
||||
|
||||
| Method | Endpoint | Notes |
|
||||
|---|---|---|
|
||||
| `request<T>` | Any | Generic; 401 auto-refresh once |
|
||||
| `request<T>` | Any | Generic; 401 auto-refresh once (refresh is coalesced — see above) |
|
||||
| `post<T>` | Any | POST convenience |
|
||||
| `uploadPhoto` | `POST /api/v1/photos/upload` | Multipart form-data; `entity_type="issue"` → `uploads/issue_photos/` |
|
||||
| `submitInspection` | `POST /api/v1/inspections` | Sanitises `local://` paths |
|
||||
| `submitIssue` | `POST /api/v1/issues` | Sends `photo_path` = first server photo only |
|
||||
| `updateIssuePhotos` | `PATCH /api/v1/issues/<id>/photos` | Sends `{ "result_photos": [extra paths] }`; stored server-side in `mobile_photo_paths` |
|
||||
| `updateIssuePhotos` | `PATCH /api/v1/issues/<id>/photos` | **Recovery only** — called from `pushLateIssuePhotoIfNeeded()` for a photo that succeeded *after* its issue was already created. The normal path sends every evidence photo inside `submitIssue`'s create request (rule 85); do not call this from it. Merges idempotently into `mobile_photo_paths`. |
|
||||
| `fetchAssignedIssues` | `GET /api/v1/issues` | Returns issues assigned to OR reported by current user |
|
||||
| `fetchIssueDetail` | `GET /api/v1/issues/<id>` | Fetches current status |
|
||||
| `updateIssueStatus` | `PATCH /api/v1/issues/<id>/status` | Inspector updates status |
|
||||
@@ -435,6 +457,8 @@ Flagging that row at submit time exposed a second problem: `ScheduledInspections
|
||||
|
||||
All start flows are full-screen for consistency (rule 66): draft-resume (dashboard) → `.fullScreenCover` → `ExecuteInspectionView(isModallyPresented: true)` with a leading `Close`; scheduled / new (`+`) / re-inspection → `.fullScreenCover` → `StartInspectionView` (its own Cancel). Pushed presentations (My Inspections row → `ExecuteInspectionView`) keep `isModallyPresented = false` and rely on the nav back button.
|
||||
|
||||
**Leaving after submit — `ExecuteInspectionView.onFinished`.** `StartInspectionView` *pushes* the form onto the NavigationStack inside its own cover, so `dismiss()` there only pops: the inspector finished an inspection and landed back on the "New Inspection" form that started it, with Cancel as the only way out. `StartInspectionView` passes its own dismiss as `onFinished` so the whole cover closes. Left nil everywhere popping is correct — the My Inspections row (pushed onto the list's stack) and the dashboard Resume banner (this view *is* the cover root).
|
||||
|
||||
### "Handled By" (issue handler, phase35 → mobile July 2026)
|
||||
|
||||
`IssueDetailView` (`IssuesView.swift`) shows a "Handled By" section: current handler label + detail, and — for admin/director/PM **and the assigned inspector** — an inline editor (segmented internal/facility/vendor + name/contact/notes) that PATCHes via `updateIssueHandler` and mirrors the result onto `LocalIssue`. The inspector-writable path is a deliberate divergence from the web form (web CLAUDE.md rule 78).
|
||||
@@ -539,24 +563,38 @@ The **Follow-up Requested** card / section (July 2026) is the second trigger, an
|
||||
### PendingPhoto lifecycle
|
||||
|
||||
```
|
||||
Created (uploadStatus="pending")
|
||||
Created (uploadStatus="pending", uploadRetryCount=0)
|
||||
↓ SyncManager.processPhotoQueue()
|
||||
Uploaded (uploadStatus="uploaded", serverPath set)
|
||||
↓ Parent record updated
|
||||
Inspection image fields: LocalInspection.formData[fieldId] = serverPath
|
||||
Issues: LocalIssue.photoServerPaths.append(serverPath)
|
||||
├─ success → uploadStatus="uploaded", serverPath set
|
||||
│ ↓ Parent record updated (attachServerPath)
|
||||
│ Inspection image fields: LocalInspection.formData[fieldId] = serverPath
|
||||
│ Issues: LocalIssue.photoServerPaths.append(serverPath)
|
||||
└─ error → uploadRetryCount += 1, STAYS "pending" (retried next sync)
|
||||
└─ only at maxPhotoUploadAttempts (5) → uploadStatus="failed"
|
||||
```
|
||||
|
||||
**`"failed"` is terminal and means "every attempt was used", not "one error happened"** — see rule 83. `SyncStatusView` → Retry Failed Items resets these back to `"pending"`; it is the only thing that does.
|
||||
|
||||
### Multi-photo issue submission sequence
|
||||
|
||||
```
|
||||
1. processPhotoQueue: uploads all N photos → appends each serverPath to issue.photoServerPaths
|
||||
2. processIssueQueue: submitIssue(issue) → sends photo_path = photoServerPaths[0]
|
||||
issue.photoLocalPaths = [] (clear local paths — prevents duplicate sections)
|
||||
updateIssuePhotos(issueId, photoServerPaths.dropFirst())
|
||||
→ PATCH /issues/<id>/photos with extras
|
||||
(a shared local file is uploaded ONCE; every PendingPhoto row
|
||||
pointing at it gets the same serverPath — rule 86)
|
||||
2. processIssueQueue: waits until all N photos are "uploaded" or "failed" ← rule 83
|
||||
submitIssue(issue) → photo_path = photoServerPaths[0]
|
||||
result_photos = the rest ← rule 85
|
||||
(server stores these in mobile_photo_paths)
|
||||
issue.photoLocalPaths = [] ONLY if all N uploaded ← rule 84
|
||||
```
|
||||
|
||||
**One request, not two.** There is no post-create PATCH on the normal path — see rule 85.
|
||||
Two related calls are NOT exceptions to that:
|
||||
- `pushLateIssuePhotoIfNeeded()` fires only when `issue.serverId` is already set, i.e. a
|
||||
photo recovered after the issue was created (rule 83's residual case).
|
||||
- `PATCH /issues/<id>/result_photos` is `IssueDetailView` attaching *resolution* photos,
|
||||
which genuinely are added after the fact.
|
||||
|
||||
### Photo display in IssueDetailView
|
||||
|
||||
Gated on `syncStatus`:
|
||||
@@ -600,13 +638,15 @@ While a photo is pending upload, the inspection form field value is `"local://<p
|
||||
|
||||
| Type | Rule |
|
||||
|---|---|
|
||||
| `rating` | `0` = unanswered → excluded. Each answered rating: `value / 5` of 1.0 |
|
||||
| `rating` | `0` = unanswered → excluded. Each answered rating: `value / 5` of 1.0. **The denominator is a flat 5, never the field's `max`** — `_compute_score_from_form()` hardcodes it, so anything reading `max` disagrees with the score the server stores |
|
||||
| `checkbox` | `"true"` = pass |
|
||||
| `radio` | Pass: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant` (case-insensitive) |
|
||||
| `pass_fail` | Same keywords. Empty = unanswered → excluded |
|
||||
|
||||
Returns `nil` if no scoreable fields or all unanswered.
|
||||
|
||||
**Two implementations must agree.** `ExecuteInspectionView.liveScore` recomputes the same thing from in-memory `formValues` to drive the toolbar badge as the inspector fills the form. It read `field["max"] ?? 5` for the rating denominator while `computeScore` hardcoded 5, so any template with `max != 5` showed one percentage in the toolbar and submitted another. Change both together, and check `_compute_score_from_form()` in `app/routes/inspections.py` — it is the authority.
|
||||
|
||||
---
|
||||
|
||||
## 19. Background Sync
|
||||
@@ -624,7 +664,7 @@ Requirements: `requiresNetworkConnectivity = true`, `requiresExternalPower = fal
|
||||
- **Sync Now** — triggers `triggerSync()`; disabled when offline or syncing.
|
||||
- **Clear Reference Cache** — deletes `LocalFacility`, `LocalArea`, `LocalTemplate` only. Never touches `LocalInspection`, `LocalIssue`, `PendingPhoto`. Triggers `pullReferenceData()` if online.
|
||||
- **Server picker** — see §21.
|
||||
- **Log Out** — calls `clearServerPulledData()` + `resetNotificationPoller()` + `auth.logout()`.
|
||||
- **Log Out** — calls `resetNotificationPoller()` + `auth.logout()`. Purges **nothing**: the cache stays so the same inspector can work offline after signing back in. A *different* inspector signing in is handled at login by `reconcileSessionScope()` (rule 88).
|
||||
- App version + current server URL (from `ServerConfig.current`).
|
||||
|
||||
---
|
||||
@@ -659,14 +699,19 @@ Segmented picker above the credential fields. `onChange` calls `ServerConfig.sel
|
||||
Segmented picker in a "Server" section. `onChange` snaps the picker back to the current saved server, stores intent in `pendingServer`, and shows `Alert("Switch Server?")`.
|
||||
|
||||
**Alert actions:**
|
||||
- **Switch & Log Out (destructive):** `ServerConfig.select(chosen)` → `clearServerPulledData()` → `resetNotificationPoller()` → `auth.logout()`.
|
||||
- **Switch & Log Out (destructive):** `ServerConfig.select(chosen)` → `purgeSessionScopedData(keepingUserId: nil, sameServer: false)` → `SessionScope.clear()` → `resetNotificationPoller()` → `auth.logout()`. Erases **everything**, unsynced work included — the alert says so. Previously this cleared `LocalIssue` alone and left `LocalInspection` rows holding the other server's facility/template ids.
|
||||
- **Cancel:** clears `pendingServer`, picker stays on original.
|
||||
|
||||
**Why logout is required on server switch:** `serverId` values are server-specific. A `LocalIssue` with `serverId = 48` from `jqc.ltservicesinc.com` has no meaning on `jqc1.ltservicesinc.com`. Keeping stale records causes "Issue not found" errors on every status fetch/update.
|
||||
|
||||
### clearServerPulledData() boundary
|
||||
### Scope boundary
|
||||
|
||||
Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` records (pending, never synced). This is the correct boundary — not `syncStatus == "synced" && inspectionLocalId == ""` (the old incorrect filter that missed inspector-created synced issues).
|
||||
There is no partial boundary any more. `serverId` is not the only server-specific value —
|
||||
`facilityServerId`, `templateServerId`, `areaServerId` and `parentServerId` all name rows in
|
||||
one particular database, and inspector facility scope differs per user on top of that. So a
|
||||
scope change purges wholesale rather than filtering (rule 88); the only thing carried across
|
||||
is the incoming user's own unsent `LocalInspection` rows, and only when the server is
|
||||
unchanged. See §8, `purgeSessionScopedData()`.
|
||||
|
||||
---
|
||||
|
||||
@@ -715,7 +760,7 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
|
||||
| 39 | **Server photo URLs include `/static/` prefix** | Server stores at `app/static/uploads/`; Flask serves at `/static/uploads/`. URL = `ServerConfig.current + "/static/" + relativePath`. Missing `/static/` returns 404. |
|
||||
| 40 | **Use `RetryablePhotoView` for all server photo loads** | `AsyncImage` has no retry — once in `.failure` it stays there for the view's lifetime. `RetryablePhotoView` allows tap-to-retry by toggling `.id(reloadToken)`. |
|
||||
| 41 | **Only use SF Symbols available on iOS 17** | `photo.slash` and `photo.badge.exclamationmark` are absent on some devices. Use `exclamationmark.triangle` for all photo-error states. |
|
||||
| 42 | **`clearServerPulledData()` boundary is `serverId != nil`** | Old boundary `syncStatus == "synced" && inspectionLocalId == ""` missed inspector-created synced issues, leaving stale serverIds that caused "Issue not found" after server switch. |
|
||||
| 42 | ~~**`clearServerPulledData()` boundary is `serverId != nil`**~~ | **Superseded by rule 88.** The function is gone; `purgeSessionScopedData()` replaces it and no longer filters by `serverId` at all. The history is still worth knowing: the boundary was widened twice (from `syncStatus == "synced" && inspectionLocalId == ""` to `serverId != nil`) and was wrong both times, because the problem was never which *issues* to delete — it was that issues are not the only server-scoped model, and logout is not the moment that matters. |
|
||||
| 43 | **`processIssueQueue` clears `photoLocalPaths` after successful submit** | Prevents `IssueDetailView` from rendering a duplicate "local photos" section alongside the server photos section for synced issues. |
|
||||
| 44 | **`StandaloneIssueView` uses `inspectionLocalId = ""`** | Same pattern as server-pulled issues. `processIssueQueue`'s parent-inspection guard evaluates `parent?.syncStatus == "failed"` → `false` for `""`, so standalone issues submit normally. |
|
||||
| 45 | **Facility lists deduplicate by `serverId` at both storage and display layers** | Storage: `pullReferenceData()` deduplicates server response before upsert. Display: `filteredFacilities` in both `StartInspectionView` and `StandaloneIssueView` uses `filter { seen.insert($0.serverId).inserted }`. |
|
||||
@@ -756,6 +801,14 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
|
||||
| 80 | **"Schedule Follow-up" is a server-side plan — it is the one action in the app that cannot work offline, and its link must survive the client forgetting it** | History detail (`HistoryDetailView`) carries three toolbar actions: **Re-inspect Now** (immediate, opens the linked re-inspection), **Schedule Follow-up** (deferred), and the existing email button. Starting an inspection writes locally and syncs later, but scheduling writes a `ScheduledInspection` row that only the server can create — there is no local record to queue, so the button is `.disabled(!sync.isOnline)` and failures report inline instead of dismissing as though they worked. Do not "fix" this by faking a local schedule: `pullScheduledInspections()` deletes any row the server doesn't return, so it would vanish on the next sync. The link itself is `scheduled_inspections.parent_inspection_id` (phase45): both start paths inherit it onto the inspection (`ScheduledStartTarget.parentServerId` on iPad, the web's `scheduled_inspections.start`), **and** the API's create-inspection endpoint re-derives it from the schedule when the client sends none — a belt-and-braces step that matters because an older build or a resumed draft would otherwise submit a plain inspection and leave the parent flagged forever. Creation is inspector-writable, a deliberate divergence from the web's `@project_manager_required`, and the endpoint is deliberately narrow: it takes only a parent + date and derives facility/template/assignee, so a follow-up can only ever target the thing it follows up on. |
|
||||
| 81 | **Never commit a local-dev override — repointing `ServerOption.primary` at localhost took production login down** | July 2026: `primary` was changed from `https://jqc.ltservicesinc.com` to `http://127.0.0.1:5055` for local API work, with `NSAllowsArbitraryLoads=true` added to `Info.plist` for cleartext. Both shipped in `dac7e6c`, so the "Primary" entry in the server picker dialled a developer laptop and **every inspector failed to log in** — only the untouched secondary worked. Symptom in the device log is unmistakable and is *not* an auth problem: `NSErrorFailingURLStringKey=http://127.0.0.1:5055/...` with `Connection refused [61]`. Revert a dev override in the same session that adds it; being aware of it is not a safeguard. Prefer an override that *cannot* be committed — a debug-only scheme argument, an xcconfig, or `#if DEBUG` — over editing this shared production constant. One thing that saved us: `ServerConfig.current` validates the stored UserDefaults string through `ServerOption(rawValue:)` and falls back to `.primary`, so a stale localhost selection self-heals on update — keep that round-trip validation. ATS exceptions must be scoped to the host (`NSExceptionDomains` for `127.0.0.1`/`localhost`); `NSAllowsArbitraryLoads` disables TLS validation for the *production* servers too and is an App Store review trigger. |
|
||||
| 82 | **A shared `static` read from `APIClient` (or any nonisolated context) must be declared `nonisolated`** | `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` makes **every** type implicitly `@MainActor`, including a bare constants-holder `enum`. `APIClient` is an `actor`, so reading such a static from it warns *"Main actor-isolated static property 'X' can not be referenced from a nonisolated context"* — and that becomes a **hard error** under the Swift 6 language mode, so it will block a toolchain move. `PhotoCaptureFormat.iso8601` hit this from the two `captured_at` multipart call sites. Mark the enclosing enum `nonisolated`, matching `Constants`, `ServerConfig`, `PhotoCaptureFormat` and `SyncManager.isoFormatter` (rule 35). Do **not** reach for `nonisolated(unsafe)` (used nowhere here — it hides the problem) or allocate a formatter per call (rule 35 exists because that cost is real on the upload/sync paths). Foundation formatters are thread-safe for formatting, so one shared instance is correct. |
|
||||
| 83 | **A photo upload error is TRANSIENT — leave the row `"pending"`. `"failed"` means every attempt was used, and nothing else may set it** | Aug 2026, the lost-photo defect. `processPhotoQueue` marked `uploadStatus = "failed"` on the *first* error; `processInspectionQueue`'s `photosReady` accepted `"failed"` as settled and submitted anyway; `APIClient.submitInspection` rewrote the surviving `local://` value to `""`; the inspection was then marked `synced` forever. **And nothing anywhere ever moved a row off `"failed"`** — `SyncStatusView.retryAllFailed()` reset `LocalInspection`/`LocalIssue` only. One dropped connection therefore destroyed an evidence photo permanently and silently, with the sync reported as successful. Now: `uploadRetryCount` increments and the row stays `"pending"` until `SyncManager.maxPhotoUploadAttempts` (5), so the next sync retries it **and** the parent keeps waiting. The cost is that a completed inspection can sit in the outbox for a few sync cycles while a photo retries — that is the correct trade; submitting first is what caused the loss. `retryAllFailed()` now resets photos too, and is the only escape hatch from terminal `"failed"`. `PhotoDiagnosticView` exists to size the damage already done and must stay read-only. |
|
||||
| 84 | **Clear `LocalIssue.photoLocalPaths` only when EVERY photo reached the server** | The clear was unconditional after a successful submit, so a partial upload left the JPEGs on disk with nothing referencing them — invisible to `IssueDetailView` and to `PhotoDiagnosticView` alike. Keeping them costs a duplicate photo section in the detail view at worst (rule 34's cosmetic concern); dropping them costs the evidence. `processIssueQueue` now guards on `issuePhotos.allSatisfy { $0.uploadStatus == "uploaded" }`. |
|
||||
| 85 | **Send an issue's evidence photos IN the create request — never in a follow-up call after it is marked `"synced"`** | `submitIssue()` sent `photo_path` only, then `processIssueQueue` fired `PATCH /issues/<id>/photos` for the rest with `try? await`. By then `syncStatus == "synced"`, so `processIssueQueue` never revisited the issue: one failed PATCH silently cost every photo after the first, and the loss became invisible on device too once `pullAssignedIssues` overwrote `photoServerPaths` with the server's copy. The split was never necessary — `POST /api/v1/issues` already accepts `result_photos` and stores it in `mobile_photo_paths` (`app/api/issues.py`, `create_issue`), and `processPhotoQueue` fully populates `photoServerPaths` *before* `processIssueQueue` runs, so the extras were always known at create time. `submitIssue()` now sends `photo_path` + `result_photos` together: attachment is atomic with creation, there is no `synced`-but-unattached window to reconcile, and `mobile_local_id` idempotency covers retrying the whole request. The generalisation holds beyond photos — if a second call is needed after a record is marked synced, either fold it into the first or persist the debt; `try?` there means silent permanent loss. |
|
||||
| 86 | **Two `PendingPhoto` rows sharing a local file must both receive the uploaded `serverPath`** | The de-dup pass marked the duplicates `"uploaded"` without ever setting `serverPath`, so the same image attached to two form fields submitted the second field blank. `processPhotoQueue` now uploads once and settles every row from a `localFilePath -> serverPath` map (a row whose twin failed stays `"pending"` so both retry together). Uploading once still matters independently: two uploads of one file yield two server filenames and duplicate the photo in the evidence and the PDF. |
|
||||
| 87 | **`cleanupOrphanedPhotos()` sweeps `JQC/Photos` only, references EVERY surviving `local://` path, and never deletes a file younger than 7 days** | It pointed at `Documents/JQCPhotos`, which no writer has ever used — `contentsOfDirectory` failed, the `guard` returned, and it silently deleted nothing for its entire life while photos accumulated. Correcting the path is only safe alongside rule 83, and only with the reference set widened: a `local://` sentinel surviving on a *submitted* inspection means that photo never reached the server, so the file is the only copy left and is exactly what `PhotoDiagnosticView` reports as recoverable — the old draft-only filter would have deleted it. `JQC/ResultPhotos` is deliberately **not** swept: those files are staged in `IssueDetailView`'s `@State` with no database row, so nothing can prove one is unused. The 7-day age floor covers that flow and the window between writing a JPEG and saving the record that points at it. |
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user