diff --git a/CLAUDE.md b/CLAUDE.md index 07da6c1..91ef03d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1383,10 +1383,23 @@ timeout = 30 - [ ] Gate: script exits 0 → every local file verified in R2. Cross-check `verified >= referenced-present` from the Phase 0 baseline. Only then flip in Phase 4. ### Phase 4 — Cutover + iOS -- [ ] Maintenance window: final incremental sync → set `STORAGE_BACKEND=s3` → `systemctl restart janitorial-qc`. -- [ ] Smoke test: existing web issue/inspection photos load via presigned URLs; new upload from **web** and **iPad** lands in R2. -- [ ] iOS: add absolute `photo_url` to issue/inspection payloads; iPad prefers `photo_url`, falls back to `ServerConfig.current + "/static/" + path` (backward-compatible). Record as an iOS CLAUDE.md rule + `APIAssignedIssue`/`APIIssueDetail` field. -- [ ] Keep `static/uploads/` for 30 days as backup. Snapshot, then reclaim disk as a **separate, deliberate** step — never part of the migration. +**4a — server photo URLs — ✅ delivered:** +- [x] `storage.media_url(key, external=False)` — `external=True` yields an absolute URL for off-origin (iPad) consumers: local backend uses `url_for('static', ..., _external=True)`; s3 returns the (already absolute) presigned URL. Templates call with `external=False` → unchanged relative `/static/` URLs. +- [x] Issue payload (`api/issues.py` `_issue_payload`) adds `photo_urls` (absolute; order = `[photo_path] + mobile_photo_paths`, matching the iPad's evidence merge) and `result_photo_urls`. Relative keys stay as keys (they're still submission values). Helper `_photo_urls()`. +- [x] Inspection detail payload (`api/inspections.py`) adds `form_media` = `{field_id: absolute_url}` for `uploads/...` image field values. Helper `_media()`. +- These are additive — the iPad ignores them until 4b ships, and on `local` they're just absolute static URLs, so nothing breaks. + +**4b — iOS: consume the URLs (do before new post-cutover photos appear):** +- [ ] Add `photoUrls` / `resultPhotoUrls` to `APIAssignedIssue`; `resultPhotoUrls` to `APIIssueDetail`; `formMedia` to the inspection detail model. +- [ ] Store parallel URL arrays on `LocalIssue` (`photoServerUrls`, `resultPhotoServerUrls`), mapped in `pullAssignedIssues` + `refreshStatusFromServer`. +- [ ] A resolver that prefers the absolute URL and falls back to `ServerConfig.current + "/static/" + path` (older server / empty URL). Wire the 4 display sites: `IssuesView` (evidence ×1, result ×1), `InspectionHistoryView` (form image; use `formMedia[fieldId]`). +- Grace period: local files are kept 30 days post-cutover, and the iPad caches downloaded photos, so old `/static/` URLs keep working during rollout; 4b must land before the 30-day local cleanup and before users need *new* post-cutover photos. + +**Operator cutover runbook (after 4b ships + Phase 3 sync exits clean):** +- [ ] Confirm `python scripts/migrate_photos_to_r2.py` prints "✅ SAFE" (0 mismatches) and verified count ≥ Phase 0 baseline present-count. +- [ ] Maintenance window: run the sync once more (delta) → set `STORAGE_BACKEND=s3` in `.env` → `systemctl restart janitorial-qc`. +- [ ] Smoke test: existing web issue/inspection photos load; existing iPad issue photos load; a **new** upload from web and from iPad lands in R2 and renders; generate an inspection PDF + issue PDF with photos. +- [ ] Keep `static/uploads/` for 30 days as backup (untouched). Snapshot, then reclaim disk as a **separate, deliberate** step. ### Rollback (any phase after cutover) - [ ] `STORAGE_BACKEND=local` → restart. Instant revert; local files were never touched. diff --git a/app/api/inspections.py b/app/api/inspections.py index b91021a..4ab6caa 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -95,6 +95,13 @@ def _parse_datetime(value): return None +def _media(key): + """Absolute display URL for a storage key (presigned on R2, absolute-static + on local). '' for falsy keys. Used for iPad image rendering.""" + from app.utils import storage + return storage.media_url(key, external=True) if key else '' + + def _inspection_payload(inspection): """Serialize an Inspection to the dict returned in API responses.""" # Extract form responses from the notes JSON blob. @@ -136,6 +143,14 @@ def _inspection_payload(inspection): if inspection.completed_at else None, 'mobile_local_id': inspection.mobile_local_id, 'form_data': form_data, + # Absolute display URLs for image form fields (presigned on R2, + # absolute-static on local): {field_id: url}. The iPad prefers this + # over building ServerConfig + /static/ + value. + 'form_media': { + fid: _media(v) + for fid, v in (form_data or {}).items() + if isinstance(v, str) and v.startswith('uploads/') + }, 'form_schema': form_schema, 'inspector_notes': inspector_notes, # ── Follow-up / re-inspection fields ────────────────────────────── diff --git a/app/api/issues.py b/app/api/issues.py index 92a4310..75d330e 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -51,6 +51,13 @@ _UUID_RE = re.compile( ) +def _photo_urls(keys): + """Map storage keys to absolute display URLs (presigned on R2, absolute-static + on local). Falsy keys are skipped. Used for iPad photo rendering.""" + from app.utils import storage + return [storage.media_url(k, external=True) for k in keys if k] + + def _issue_payload(issue): """Serialise an Issue to the dict returned in list/detail responses.""" facility = issue.resolved_facility @@ -71,6 +78,14 @@ def _issue_payload(issue): 'photo_path': issue.photo_path or None, 'mobile_photo_paths': issue.mobile_photo_paths or [], 'result_photos': issue.result_photos or [], + # Absolute display URLs (presigned on R2, absolute-static on local) for + # the iPad, which loads photos off-origin. Relative keys above stay as + # keys. photo_urls order mirrors the iPad's evidence merge: + # [photo_path] + mobile_photo_paths. + 'photo_urls': _photo_urls( + ([issue.photo_path] if issue.photo_path else []) + + (issue.mobile_photo_paths or [])), + 'result_photo_urls': _photo_urls(issue.result_photos or []), # Resolution details — set by web staff after fixing the issue. 'result_notes': issue.result_notes or None, # Verification fields — set after a director/admin confirms fix. diff --git a/app/utils/storage.py b/app/utils/storage.py index 26ffdc6..7306353 100644 --- a/app/utils/storage.py +++ b/app/utils/storage.py @@ -76,10 +76,12 @@ class LocalBackend: def abs_local_path(self, key): return os.path.normpath(os.path.join(self._static_folder(), key)) - def media_url(self, key): + def media_url(self, key, external=False): if not key: return '' - return url_for('static', filename=key) + # external=True yields an absolute URL (scheme+host) for API responses + # consumed off-origin (the iPad); templates call with external=False. + return url_for('static', filename=key, _external=external) def read(self, key): with open(self.abs_local_path(key), 'rb') as fh: @@ -164,13 +166,14 @@ class S3Backend: # not exist) so transition code / callers can still reference it. return self._local.abs_local_path(key) - def media_url(self, key): + def media_url(self, key, external=False): if not key: return '' if self.fallback and not self._head(key): # Object not in R2 yet — serve the local copy during transition. if self._local.exists(key): - return self._local.media_url(key) + return self._local.media_url(key, external=external) + # Presigned R2 URLs are always absolute; the external flag is moot. return self._client.generate_presigned_url( 'get_object', Params={'Bucket': self.bucket, 'Key': key}, @@ -259,8 +262,8 @@ def save(file_obj, subfolder): return get_backend().save(file_obj, subfolder) -def media_url(key): - return get_backend().media_url(key) +def media_url(key, external=False): + return get_backend().media_url(key, external=external) def read(key):