Aug 19 - Fixed photo-loss issue
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user