From 2a0b264a5ceffdf38eee0aae4c868b2c41d3beac Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Tue, 14 Jul 2026 15:40:16 -0400 Subject: [PATCH] Jul 14 - Using CDN - update --- JanitorialQC/API/APIModels.swift | 28 +++++++ JanitorialQC/CLAUDE.md | 1 + JanitorialQC/Models/LocalIssue.swift | 23 ++++++ .../Models/LocalScheduledInspection.swift | 76 ++++++++++++++----- JanitorialQC/Sync/SyncManager.swift | 36 +++------ JanitorialQC/Utils/Constants.swift | 10 +++ JanitorialQC/Views/Dashboard/IssuesView.swift | 17 ++++- .../Inspection/InspectionHistoryView.swift | 20 ++++- 8 files changed, 162 insertions(+), 49 deletions(-) diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index e2c954a..8b3b194 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -296,6 +296,8 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable { // detail view works without a local SwiftData copy (e.g. after reinstall). let formDataRaw: [String: JSONValue] let formSchemaRaw: [[String: JSONValue]] + /// {field_id: absolute_url} for image fields (presigned R2 / absolute static). + let formMedia: [String: String] // ── Follow-up / re-inspection ───────────────────────────────────────── let followUpRequired: Bool let followUpNote: String? @@ -323,6 +325,20 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable { formSchemaRaw.map { dict in dict.mapValues { $0.anyValue } } } + /// {relative_path: absolute_url} for image fields, derived by joining + /// formMedia (fieldId -> url) with the form values (fieldId -> path). Lets + /// the image renderer resolve a presigned URL from just the stored path. + var mediaURLByPath: [String: String] { + var out: [String: String] = [:] + let values = formValues + for (fid, url) in formMedia { + if let path = values[fid], !path.isEmpty { + out[path] = url + } + } + return out + } + var inspectionDateParsed: Date? { guard let str = inspectionDate else { return nil } // Server sends "yyyy-MM-dd'T'HH:mm:ss" with no timezone suffix. @@ -347,6 +363,7 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable { mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) inspectorNotes = (try? c.decode(String.self, forKey: .inspectorNotes)) ?? "" formDataRaw = (try? c.decode([String: JSONValue].self, forKey: .formData)) ?? [:] + formMedia = (try? c.decode([String: String].self, forKey: .formMedia)) ?? [:] formSchemaRaw = (try? c.decode([[String: JSONValue]].self, forKey: .formSchema)) ?? [] followUpRequired = (try? c.decode(Bool.self, forKey: .followUpRequired)) ?? false followUpNote = try? c.decode(String.self, forKey: .followUpNote) @@ -357,6 +374,7 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable { case areaId, areaName, status, overallScore case inspectionDate, completedAt, mobileLocalId, inspectorNotes case formData, formSchema + case formMedia case followUpRequired, followUpNote, parentInspectionId } @@ -405,6 +423,7 @@ struct APIIssueDetail: Decodable, Sendable { let reportedByName: String? // Resolution photos uploaded via web or mobile resolve flow let resultPhotos: [String] + let resultPhotoUrls: [String] // absolute display URLs (presigned R2 / static) // Phase E — area and assignee context let areaName: String? let assignedToName: String? @@ -434,6 +453,7 @@ struct APIIssueDetail: Decodable, Sendable { verificationNote = try? c.decode(String.self, forKey: .verificationNote) reportedByName = try? c.decode(String.self, forKey: .reportedByName) resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] + resultPhotoUrls = (try? c.decode([String].self, forKey: .resultPhotoUrls)) ?? [] areaName = try? c.decode(String.self, forKey: .areaName) assignedToName = try? c.decode(String.self, forKey: .assignedToName) handlerType = try? c.decode(String.self, forKey: .handlerType) @@ -449,6 +469,7 @@ struct APIIssueDetail: Decodable, Sendable { case id, status, severity, description, assignedTo case facilityId, facilityName, reportedAt, resolvedAt case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos + case resultPhotoUrls case areaName, assignedToName case handlerType, handlerLabel case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes @@ -541,6 +562,10 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable { let photoPath: String? // primary evidence photo let mobilePhotoPaths: [String] // extra evidence photos from iPad let resultPhotos: [String] // resolution photos added via web + // Absolute display URLs (presigned R2 / absolute static). photoUrls order + // mirrors the evidence merge: [photoPath] + mobilePhotoPaths. + let photoUrls: [String] + let resultPhotoUrls: [String] // Phase A — resolution details from web let resultNotes: String? let verifiedAt: String? @@ -573,6 +598,8 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable { photoPath = try? c.decode(String.self, forKey: .photoPath) mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? [] resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] + photoUrls = (try? c.decode([String].self, forKey: .photoUrls)) ?? [] + resultPhotoUrls = (try? c.decode([String].self, forKey: .resultPhotoUrls)) ?? [] resultNotes = try? c.decode(String.self, forKey: .resultNotes) verifiedAt = try? c.decode(String.self, forKey: .verifiedAt) verificationNote = try? c.decode(String.self, forKey: .verificationNote) @@ -592,6 +619,7 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable { case id, status, severity, description, assignedTo case facilityId, facilityName, reportedAt, mobileLocalId case photoPath, mobilePhotoPaths, resultPhotos + case photoUrls, resultPhotoUrls case resultNotes, verifiedAt, verificationNote, reportedByName case areaName, assignedToName case handlerType, handlerLabel diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index e782236..cabdf36 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -679,6 +679,7 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record | 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack` root instead. The dashboard scheduled card puts its cover on the VStack; `MyInspectionsView` puts the scheduled "Start" cover on the `List`. | | 65 | **`CodingKeys` stay plain camelCase (decoder uses `.convertFromSnakeCase`); request-body keys are raw snake_case** | The shared `JSONDecoder` sets `keyDecodingStrategy = .convertFromSnakeCase`, converting JSON `facility_handler_name` → `facilityHandlerName` **before** matching — so `CodingKeys` must be bare camelCase; adding an explicit `= "facility_handler_name"` raw value double-converts and breaks decode. Conversely PATCH/POST bodies are `[String: Any]` encoded with `JSONSerialization` (no key strategy), so body keys must be the literal snake_case the server reads (`"handler_type"`, `"vendor_name"`, …). | | 66 | **Modal (fullScreenCover root) views need an explicit Close/Cancel; pushed views get the nav back button for free** | `ExecuteInspectionView` takes `isModallyPresented` and shows a leading `Close` only when true (draft-resume from the dashboard is the root of its `NavigationStack`, no back button). All inspection-start flows now use `.fullScreenCover` for a consistent full-screen form: draft-resume → `ExecuteInspectionView(isModallyPresented: true)`; scheduled/new/re-inspection → `StartInspectionView` (its own `.cancellationAction` Cancel). `.onDisappear`/auto-save preserves work, so Close is always safe. | +| 67 | **Render server photos through `ServerConfig.mediaURL(absolute:path:)`, never by hand-building `current + "/static/" + path`** | After the R2 migration the server returns absolute display URLs (presigned R2, or absolute-static on the local backend): `photo_urls`/`result_photo_urls` on issues (`APIAssignedIssue`/`APIIssueDetail` → `LocalIssue.photoServerUrls`/`resultPhotoServerUrls`, parallel to the path arrays), and `form_media` `{fieldId: url}` on inspection detail (`APIInspectionSummary.mediaURLByPath`, injected into the read-only grid via the `\.mediaURLByPath` environment for `PhotoThumbnailView`). The resolver prefers the absolute URL and falls back to `/static/` for older servers. Presigned URLs expire (24h) — always render from the freshest pull/detail fetch; don't persist a URL and reuse it days later. | --- diff --git a/JanitorialQC/Models/LocalIssue.swift b/JanitorialQC/Models/LocalIssue.swift index 4c2c683..a9bafac 100644 --- a/JanitorialQC/Models/LocalIssue.swift +++ b/JanitorialQC/Models/LocalIssue.swift @@ -26,6 +26,11 @@ final class LocalIssue { /// JSON-encoded array of resolution photo server paths (issue_result_photos bucket). /// Mirrors Issue.result_photos on the server — shown under "Resolution Details". var resultPhotoServerPathsJSON: String = "[]" + /// JSON-encoded absolute display URLs (presigned R2 / absolute static), + /// parallel to photoServerPaths / resultPhotoServerPaths (same order). + /// Empty ("[]") when talking to an older server that omits the *_url fields. + var photoServerUrlsJSON: String = "[]" + var resultPhotoServerUrlsJSON: String = "[]" // Shared coders — JSONDecoder/Encoder init is expensive (parses locale and // calendar info). Allocating them inside computed property getters means @@ -115,6 +120,22 @@ final class LocalIssue { } } + /// Absolute display URLs parallel to photoServerPaths (same order). + var photoServerUrls: [String] { + get { (try? Self.jsonDecoder.decode([String].self, + from: Data(photoServerUrlsJSON.utf8))) ?? [] } + set { photoServerUrlsJSON = (try? String(data: Self.jsonEncoder.encode(newValue), + encoding: .utf8)) ?? "[]" } + } + + /// Absolute display URLs parallel to resultPhotoServerPaths (same order). + var resultPhotoServerUrls: [String] { + get { (try? Self.jsonDecoder.decode([String].self, + from: Data(resultPhotoServerUrlsJSON.utf8))) ?? [] } + set { resultPhotoServerUrlsJSON = (try? String(data: Self.jsonEncoder.encode(newValue), + encoding: .utf8)) ?? "[]" } + } + var createdAt: Date var syncStatus: String // "pending" | "synced" | "failed" var syncRetryCount: Int @@ -193,6 +214,8 @@ final class LocalIssue { self.photoLocalPathsJSON = "[]" self.photoServerPathsJSON = "[]" self.resultPhotoServerPathsJSON = "[]" + self.photoServerUrlsJSON = "[]" + self.resultPhotoServerUrlsJSON = "[]" self.createdAt = Date() self.syncStatus = "pending" self.syncRetryCount = 0 diff --git a/JanitorialQC/Models/LocalScheduledInspection.swift b/JanitorialQC/Models/LocalScheduledInspection.swift index 66adadc..fd5ecf2 100644 --- a/JanitorialQC/Models/LocalScheduledInspection.swift +++ b/JanitorialQC/Models/LocalScheduledInspection.swift @@ -9,8 +9,9 @@ // facility + template preselected; the schedule lifecycle (fulfil / roll-forward) // stays server-driven. // -// All non-optional stored properties carry explicit inline defaults so SwiftData -// lightweight migration can add the new table without a migration plan. +// Follows the same pattern as LocalFacility / LocalArea: a `.unique` serverId +// WITHOUT an inline default (a default on the unique key breaks @Model's +// PersistentModel conformance) and a full init(from:)/update(from:) pair. import Foundation import SwiftData @@ -19,29 +20,66 @@ import SwiftData final class LocalScheduledInspection { /// Server ID of the ScheduledInspection row — stable unique identity. - @Attribute(.unique) var serverId: Int = 0 + @Attribute(.unique) var serverId: Int - var facilityServerId: Int = 0 - var facilityName: String = "" - var templateServerId: Int = 0 - var templateName: String = "" - var inspectorId: Int? = nil + var facilityServerId: Int + var facilityName: String + var templateServerId: Int + var templateName: String + var inspectorId: Int? - var frequency: String = "once" // once | daily | weekly | monthly - var frequencyLabel: String = "" // human-readable label from server + var frequency: String // once | daily | weekly | monthly + var frequencyLabel: String - /// Raw ISO date string "YYYY-MM-DD" from the server (display fallback). - var dueDateString: String = "" - /// Parsed due date — used for @Query sorting. Nil if the string was absent. - var nextDue: Date? = nil + /// Raw server date string "YYYY-MM-DD" — sortable (ISO strings sort + /// chronologically) and the source for the parsed `nextDue`. + var dueDateString: String - var isOverdue: Bool = false - var notes: String? = nil + var isOverdue: Bool + var notes: String? /// Last time this row was refreshed from the server pull. - var updatedAt: Date = Date() + var updatedAt: Date - init(serverId: Int) { - self.serverId = serverId + /// Parsed due date for display. Computed properties are not persisted by + /// SwiftData; sort on `dueDateString` (not this) in @Query. + var nextDue: Date? { + Self.dateOnlyFormatter.date(from: dueDateString) + } + + private static let dateOnlyFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f + }() + + init(from api: APIScheduledInspection) { + self.serverId = api.id + self.facilityServerId = api.facilityId + self.facilityName = api.facilityName ?? "" + self.templateServerId = api.templateId + self.templateName = api.templateName ?? "" + self.inspectorId = api.inspectorId + self.frequency = api.frequency + self.frequencyLabel = api.frequencyLabel ?? "" + self.dueDateString = api.nextDueDate ?? "" + self.isOverdue = api.isOverdue + self.notes = api.notes + self.updatedAt = Date() + } + + func update(from api: APIScheduledInspection) { + self.facilityServerId = api.facilityId + self.facilityName = api.facilityName ?? "" + self.templateServerId = api.templateId + self.templateName = api.templateName ?? "" + self.inspectorId = api.inspectorId + self.frequency = api.frequency + self.frequencyLabel = api.frequencyLabel ?? "" + self.dueDateString = api.nextDueDate ?? "" + self.isOverdue = api.isOverdue + self.notes = api.notes + self.updatedAt = Date() } } diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index c101099..8ca027e 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -60,14 +60,6 @@ class SyncManager: ObservableObject { return f }() - /// Parses server date-only strings ("YYYY-MM-DD"), e.g. scheduled due dates. - nonisolated static let dateOnlyFormatter: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.dateFormat = "yyyy-MM-dd" - return f - }() - static let shared = SyncManager() private init() {} @@ -761,6 +753,9 @@ class SyncManager: ObservableObject { serverPaths.append(contentsOf: api.mobilePhotoPaths) existing.photoServerPaths = serverPaths existing.resultPhotoServerPaths = api.resultPhotos + // Absolute display URLs (presigned R2 / static) — parallel arrays. + existing.photoServerUrls = api.photoUrls + existing.resultPhotoServerUrls = api.resultPhotoUrls } else { // Insert new server-pulled issue let local = LocalIssue( @@ -800,6 +795,9 @@ class SyncManager: ObservableObject { serverPaths.append(contentsOf: api.mobilePhotoPaths) local.photoServerPaths = serverPaths local.resultPhotoServerPaths = api.resultPhotos + // Absolute display URLs (presigned R2 / static) — parallel arrays. + local.photoServerUrls = api.photoUrls + local.resultPhotoServerUrls = api.resultPhotoUrls if let ts = api.reportedAt, let date = Self.isoFormatter.date(from: ts) { local.createdAt = date @@ -852,23 +850,11 @@ class SyncManager: ObservableObject { for row in allLocal { byServerId[row.serverId] = row } for api in apiRows { - let row = byServerId[api.id] ?? { - let r = LocalScheduledInspection(serverId: api.id) - context.insert(r) - return r - }() - row.facilityServerId = api.facilityId - row.facilityName = api.facilityName ?? "" - row.templateServerId = api.templateId - row.templateName = api.templateName ?? "" - row.inspectorId = api.inspectorId - row.frequency = api.frequency - row.frequencyLabel = api.frequencyLabel ?? "" - row.dueDateString = api.nextDueDate ?? "" - row.nextDue = api.nextDueDate.flatMap { Self.dateOnlyFormatter.date(from: $0) } - row.isOverdue = api.isOverdue - row.notes = api.notes - row.updatedAt = Date() + if let existing = byServerId[api.id] { + existing.update(from: api) + } else { + context.insert(LocalScheduledInspection(from: api)) + } } // Delete rows the server no longer returns. diff --git a/JanitorialQC/Utils/Constants.swift b/JanitorialQC/Utils/Constants.swift index 5811407..371d0fc 100644 --- a/JanitorialQC/Utils/Constants.swift +++ b/JanitorialQC/Utils/Constants.swift @@ -47,6 +47,16 @@ nonisolated enum ServerConfig { let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? "" return ServerOption(rawValue: raw) ?? .primary } + + /// Resolve a photo URL, preferring an absolute server-provided URL + /// (presigned R2 on the s3 backend, absolute-static on local) and falling + /// back to building one from the relative 'uploads/...' key for older + /// servers that don't send the *_url fields. + nonisolated static func mediaURL(absolute: String?, path: String) -> URL? { + if let a = absolute, !a.isEmpty { return URL(string: a) } + guard !path.isEmpty else { return nil } + return URL(string: current + "/static/" + path) + } } // MARK: - App-wide constants diff --git a/JanitorialQC/Views/Dashboard/IssuesView.swift b/JanitorialQC/Views/Dashboard/IssuesView.swift index 3f6a6f9..b1bc898 100644 --- a/JanitorialQC/Views/Dashboard/IssuesView.swift +++ b/JanitorialQC/Views/Dashboard/IssuesView.swift @@ -497,9 +497,13 @@ struct IssueDetailView: View { // ── Resolution Photos (server-side, read display) ────────────── if !issue.resultPhotoServerPaths.isEmpty { Section("Resolution Photos (\(issue.resultPhotoServerPaths.count))") { - ForEach(issue.resultPhotoServerPaths, id: \.self) { relativePath in + let paths = issue.resultPhotoServerPaths + let urls = issue.resultPhotoServerUrls + ForEach(paths.indices, id: \.self) { idx in RetryablePhotoView( - url: URL(string: ServerConfig.current + "/static/" + relativePath) + url: ServerConfig.mediaURL( + absolute: idx < urls.count ? urls[idx] : nil, + path: paths[idx]) ) } } @@ -571,9 +575,13 @@ struct IssueDetailView: View { // Synced: show server photos only if !issue.photoServerPaths.isEmpty { Section("Photos (\(issue.photoServerPaths.count))") { - ForEach(issue.photoServerPaths, id: \.self) { relativePath in + let paths = issue.photoServerPaths + let urls = issue.photoServerUrls + ForEach(paths.indices, id: \.self) { idx in RetryablePhotoView( - url: URL(string: ServerConfig.current + "/static/" + relativePath) + url: ServerConfig.mediaURL( + absolute: idx < urls.count ? urls[idx] : nil, + path: paths[idx]) ) } } @@ -748,6 +756,7 @@ struct IssueDetailView: View { // Refresh resolution photos from server if !detail.resultPhotos.isEmpty { issue.resultPhotoServerPaths = detail.resultPhotos + issue.resultPhotoServerUrls = detail.resultPhotoUrls } try? context.save() } catch { diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index deecf04..0524470 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -452,6 +452,7 @@ struct HistoryDetailView: View { schema: formSchema, formValues: savedValues ) + .environment(\.mediaURLByPath, inspection.mediaURLByPath) .padding(.horizontal, 24) } } @@ -928,6 +929,7 @@ struct ReadOnlyCellView: View { struct PhotoThumbnailView: View { let value: String + @Environment(\.mediaURLByPath) private var mediaURLByPath @State private var showLightbox = false var body: some View { @@ -954,7 +956,7 @@ struct PhotoThumbnailView: View { .font(.system(size: 11)).foregroundStyle(.secondary) } } else if value.hasPrefix("uploads/") { - let url = URL(string: "\(ServerConfig.current)/static/\(value)") + let url = ServerConfig.mediaURL(absolute: mediaURLByPath[value], path: value) thumbnailButton { AsyncImage(url: url) { phase in switch phase { @@ -1042,3 +1044,19 @@ struct MailComposeView: UIViewControllerRepresentable { } } } + +// MARK: - Media URL environment +// Injects a {relative_path: absolute_url} map (from APIInspectionSummary.mediaURLByPath) +// so image cells deep inside the read-only grid can resolve presigned R2 URLs +// without threading field IDs through every layer. Empty map → the resolver +// falls back to building a /static/ URL from the relative path. +private struct MediaURLByPathKey: EnvironmentKey { + static let defaultValue: [String: String] = [:] +} + +extension EnvironmentValues { + var mediaURLByPath: [String: String] { + get { self[MediaURLByPathKey.self] } + set { self[MediaURLByPathKey.self] = newValue } + } +}