Aug 19 - Fixed photo-loss issue

This commit is contained in:
Nguyen Ngo
2026-08-19 10:31:20 -04:00
parent 7cbc514c39
commit e31cd7e1ff
13 changed files with 856 additions and 149 deletions
+122 -13
View File
@@ -38,8 +38,35 @@ private struct _Envelope<T: Decodable & Sendable>: Decodable, Sendable {
private enum CodingKeys: String, CodingKey { case ok, data, error }
}
// Free function removed see refreshAccessToken() which decodes using a
// local JSONDecoder to avoid Swift 6 actor-isolation errors.
// Envelope header only `ok` and `error`, never the payload.
//
// Split from _Envelope so `decode()` can tell "the server reported a failure"
// apart from "the server succeeded but we could not read the payload". Those
// were indistinguishable while `data` was decoded with `try?`: any schema
// mismatch produced data == nil and surfaced as serverError("Unknown server
// error"), pointing every investigation at the backend.
private struct _EnvelopeMeta: Decodable, Sendable {
let ok: Bool
let error: String?
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
ok = try c.decode(Bool.self, forKey: .ok)
error = try? c.decode(String.self, forKey: .error)
}
private enum CodingKeys: String, CodingKey { case ok, error }
}
// Payload only, decoded STRICTLY so the failure reason propagates.
private struct _EnvelopePayload<T: Decodable & Sendable>: Decodable, Sendable {
let data: T
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
data = try c.decode(T.self, forKey: .data) // deliberately not `try?`
}
private enum CodingKeys: String, CodingKey { case data }
}
// Refresh-only envelope Sendable so it can cross actor boundaries in Swift 6.
// nonisolated init(from:) required on both types: without it the Swift 6 compiler
@@ -279,20 +306,41 @@ actor APIClient {
// inspection synced in the same triggerSync() pass.
if let id = inspectionServerId { body["inspection_id"] = id }
if let areaId = issue.areaServerId { body["area_id"] = areaId }
// photo_path = primary photo. Additional photos are sent via a
// separate PATCH call in processIssueQueue after the issue is created,
// because the server create endpoint only stores a single photo_path.
// All evidence photos go in this ONE request.
//
// `photo_path` is the primary; `result_photos` carries the rest and is
// stored server-side in `mobile_photo_paths`, so they display under
// "Photo Evidence" rather than "Resolution Details"
// (app/api/issues.py, create_issue).
//
// These used to be split: create sent photo_path only, then
// processIssueQueue fired a follow-up PATCH for the extras. The extras
// were always known before create processPhotoQueue fully populates
// photoServerPaths first so the second call bought nothing and cost a
// window in which the issue was already `synced` while its photos were
// not attached. Sending them together makes attachment atomic with
// creation, and the endpoint's mobile_local_id idempotency covers a
// retry of the whole thing (rule 85).
if let first = issue.photoServerPaths.first { body["photo_path"] = first }
let extraPhotos = Array(issue.photoServerPaths.dropFirst())
if !extraPhotos.isEmpty { body["result_photos"] = extraPhotos }
struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool }
let r: R = try await post("/api/v1/issues", body: body)
return r.issueId
}
// Attach additional photos to an existing issue
// Called after submitIssue when the issue has more than one photo.
// PATCHes /api/v1/issues/{id}/photos with result_photos = [server paths beyond the first].
// The create endpoint only stores photo_path (single); extras go here.
// Attach a LATE evidence photo to an already-created issue
//
// NOT part of the normal path: submitIssue() sends every evidence photo in
// the create request, and reintroducing a routine post-create call is
// exactly what rule 85 forbids. This exists only for recovery a photo
// that exhausted its upload attempts, was submitted without, and later
// succeeded via Pending Sync Retry Failed Items. By then the create
// request is long gone and this is the only way across.
//
// Server-side (app/api/issues.py, update_issue_photos) this merges
// idempotently into mobile_photo_paths, so repeating a path is a no-op.
func updateIssuePhotos(issueId: Int, resultPhotos: [String]) async throws {
struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int }
let _: R = try await request(
@@ -592,7 +640,32 @@ actor APIClient {
// Token Refresh
/// The refresh currently in flight, if any.
///
/// `APIClient` being an actor is NOT enough on its own: `refreshAccessToken`
/// suspends at `await`, which releases the actor and lets a second caller
/// enter. Two requests 401-ing at once would then each POST /auth/refresh
/// with the SAME refresh token the server rotates it on the first, so the
/// second presents an already-spent token, fails, and the user is signed
/// out mid-sync. Easy to hit: pollNotifications and registerDevice both run
/// alongside triggerSync.
///
/// Coalescing here means concurrent callers await one shared result. The
/// check-and-store below spans no `await`, so it is atomic within the actor.
private var refreshTask: Task<Bool, Never>?
private func refreshAccessToken() async -> Bool {
if let inFlight = refreshTask {
return await inFlight.value
}
let task = Task { await self.performTokenRefresh() }
refreshTask = task
let result = await task.value
refreshTask = nil
return result
}
private func performTokenRefresh() async -> Bool {
guard let token = KeychainHelper.get(Constants.Keychain.refreshToken),
let url = URL(string: ServerConfig.current + "/api/v1/auth/refresh")
else { return false }
@@ -657,14 +730,50 @@ actor APIClient {
}
private func decode<T: Decodable & Sendable>(_ data: Data) throws -> T {
if let env = try? decoder.decode(_Envelope<T>.self, from: data) {
if env.ok, let result = env.data { return result }
throw APIError.serverError(env.error ?? "Unknown server error")
// Read the envelope header first, so a server-reported failure and an
// unreadable payload cannot be confused for one another.
if let meta = try? decoder.decode(_EnvelopeMeta.self, from: data) {
guard meta.ok else {
throw APIError.serverError(meta.error ?? "Unknown server error")
}
do {
return try decoder.decode(_EnvelopePayload<T>.self, from: data).data
} catch {
// ok == true, so this is OUR problem, not the server's a
// contract drift between this build and the deployment.
throw APIError.decodingError(Self.describe(error, as: T.self))
}
}
// Not an envelope a few endpoints return the object bare.
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error.localizedDescription)
throw APIError.decodingError(Self.describe(error, as: T.self))
}
}
/// Turn a `DecodingError` into something that names the offending field.
///
/// `error.localizedDescription` on a DecodingError is always the useless
/// "The data couldn't be read because it isn't in the correct format",
/// which is what the old path surfaced so a renamed or retyped API field
/// gave no clue which one it was.
private static func describe<T>(_ error: Error, as type: T.Type) -> String {
func path(_ context: DecodingError.Context) -> String {
let keys = context.codingPath.map(\.stringValue).filter { !$0.isEmpty }
return keys.isEmpty ? "\(type)" : "\(type).\(keys.joined(separator: "."))"
}
switch error as? DecodingError {
case .keyNotFound(let key, let ctx):
return "missing field '\(key.stringValue)' in \(path(ctx))"
case .typeMismatch(let expected, let ctx):
return "\(path(ctx)) has the wrong type (expected \(expected))"
case .valueNotFound(let expected, let ctx):
return "\(path(ctx)) was null (expected \(expected))"
case .dataCorrupted(let ctx):
return "\(path(ctx)) is malformed: \(ctx.debugDescription)"
default:
return "could not read \(type): \(error.localizedDescription)"
}
}
}