Files
JQC_iOS_App/JanitorialQC/API/APIClient.swift
T

813 lines
38 KiB
Swift

// API/APIClient.swift
import Foundation
import Combine
import UIKit
enum APIError: Error, LocalizedError, Sendable {
case invalidURL
case notAuthenticated
case serverError(String)
case decodingError(String)
case networkError(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL."
case .notAuthenticated: return "Session expired. Please log in again."
case .serverError(let msg): return msg
case .decodingError(let msg): return "Data error: \(msg)"
case .networkError(let msg): return "Network error: \(msg)"
}
}
}
// File-scope envelope — cannot be nested in a generic function (Swift restriction).
// T is constrained to Sendable so `data: T?` does not inherit @MainActor isolation.
private struct _Envelope<T: Decodable & Sendable>: Decodable, Sendable {
let ok: Bool
let data: T?
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)
data = try? c.decode(T.self, forKey: .data)
error = try? c.decode(String.self, forKey: .error)
}
private enum CodingKeys: String, CodingKey { case ok, data, error }
}
// 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
// infers @MainActor isolation on the Decodable conformance from the surrounding
// file context, producing "cannot be used in actor-isolated context" errors.
private struct _RefreshEnvelope: Decodable, Sendable {
struct Tokens: Decodable, Sendable {
let accessToken: String
let refreshToken: String
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
accessToken = try c.decode(String.self, forKey: .accessToken)
refreshToken = try c.decode(String.self, forKey: .refreshToken)
}
private enum CodingKeys: String, CodingKey { case accessToken, refreshToken }
}
let ok: Bool
let data: Tokens?
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
ok = try c.decode(Bool.self, forKey: .ok)
data = try? c.decode(Tokens.self, forKey: .data)
}
private enum CodingKeys: String, CodingKey { case ok, data }
}
actor APIClient {
static let shared = APIClient()
private let session: URLSession
private let decoder: JSONDecoder
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: config)
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
}
// ── Generic JSON Request ──────────────────────────────────────────────
func request<T: Decodable & Sendable>(
_ endpoint: String,
method: String = "GET",
body: [String: Any]? = nil,
retrying: Bool = false
) async throws -> T {
let url = try buildURL(endpoint)
var req = buildRequest(url: url, method: method, body: body)
injectToken(&req)
let (data, response) = try await performRequest(req)
if shouldRefresh(response, retrying: retrying) {
let refreshed = await refreshAccessToken()
if refreshed {
return try await request(endpoint, method: method, body: body, retrying: true)
}
throw APIError.notAuthenticated
}
return try decode(data)
}
func post<T: Decodable & Sendable>(_ endpoint: String, body: [String: Any]) async throws -> T {
return try await request(endpoint, method: "POST", body: body)
}
// ── Inspection History ────────────────────────────────────────────────
func fetchInspectionHistory(
limit: Int = 50,
offset: Int = 0,
facilityId: Int? = nil,
fromDate: Date? = nil,
toDate: Date? = nil
) async throws -> InspectionHistoryResponseData {
var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
if let fid = facilityId { ep += "&facility_id=\(fid)" }
if let d = fromDate { ep += "&from_date=\(Self.apiDateFmt.string(from: d))" }
if let d = toDate { ep += "&to_date=\(Self.apiDateFmt.string(from: d))" }
return try await request(ep)
}
private static let apiDateFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
// ── Photo Upload ──────────────────────────────────────────────────────
/// Upload a photo and return its server path.
///
/// `capturedAt` / `latitude` / `longitude` drive the timestamp + GPS overlay
/// the server burns into the image. They are optional on the wire, but this
/// app must send them: photos are re-encoded on save (jpegData), which
/// strips EXIF, so the server has no other way to learn the true capture
/// moment — it would fall back to upload time, which is wrong for anything
/// captured offline. See Utils/PhotoCapture.swift.
func uploadPhoto(localPath: String,
entityType: String,
capturedAt: Date? = nil,
latitude: Double? = nil,
longitude: Double? = nil,
retrying: Bool = false) async throws -> String {
let url = try buildURL("/api/v1/photos/upload")
// Read via PhotoStore, not the raw path. Stored paths embed the app
// container UUID, which iOS reassigns on every app update — the file
// survives, the path does not, and a raw read then fails forever on a
// photo that is sitting right there on disk (rule 91).
guard let imageData = PhotoStore.contents(at: localPath) else {
throw APIError.networkError("Could not read photo: \(localPath)")
}
let boundary = "Boundary-\(UUID().uuidString)"
let filename = URL(fileURLWithPath: localPath).lastPathComponent
let ext = (filename as NSString).pathExtension.lowercased()
let mime = ext == "png" ? "image/png" : "image/jpeg"
var body = Data()
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\n\(entityType)\r\n".data(using: .utf8)!)
body.append(Self.formField("captured_at", capturedAt.map { PhotoCaptureFormat.iso8601.string(from: $0) }, boundary))
body.append(Self.formField("latitude", latitude.map { String(format: "%.6f", $0) }, boundary))
body.append(Self.formField("longitude", longitude.map { String(format: "%.6f", $0) }, boundary))
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
body.append(imageData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
injectToken(&req)
req.httpBody = body
let (data, response) = try await performRequest(req)
if shouldRefresh(response, retrying: retrying) {
let refreshed = await refreshAccessToken()
if refreshed {
return try await uploadPhoto(localPath: localPath, entityType: entityType,
capturedAt: capturedAt, latitude: latitude,
longitude: longitude, retrying: true)
}
throw APIError.notAuthenticated
}
// `stamped` / `capturedAt` / `captureSource` are also returned; decoded
// as optionals so older servers (which omit them) still parse.
struct PhotoResult: Decodable, Sendable {
let serverPath: String
let stamped: Bool?
let captureSource: String?
}
if let env = try? decoder.decode(_Envelope<PhotoResult>.self, from: data),
env.ok, let r = env.data {
if r.stamped == false {
print("[JQC] Photo stored unstamped (source=\(r.captureSource ?? "?")): \(r.serverPath)")
}
return r.serverPath
}
throw APIError.serverError("Photo upload failed")
}
/// Build one multipart text field, or empty Data when the value is nil.
private static func formField(_ name: String, _ value: String?, _ boundary: String) -> Data {
guard let value else { return Data() }
return "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n"
.data(using: .utf8) ?? Data()
}
// ── Submit Inspection ─────────────────────────────────────────────────
func submitInspection(_ inspection: LocalInspection) async throws -> Int {
// Sanitise form_data: replace any field values that are still a local
// file reference ("local://...") with an empty string. This happens
// when a photo upload failed but the inspection was submitted anyway.
// JSONSerialization silently drops non-serialisable values, so without
// this guard the server would receive a dict missing those fields
// entirely — worse than receiving an empty string.
var sanitisedFormData: [String: Any] = [:]
for (k, v) in inspection.formData {
if let s = v as? String, s.hasPrefix("local://") {
sanitisedFormData[k] = "" // upload failed; clear the field
} else {
sanitisedFormData[k] = v
}
}
var body: [String: Any] = [
"template_id": inspection.templateServerId,
"facility_id": inspection.facilityServerId,
"status": "completed",
"form_data": sanitisedFormData,
"mobile_local_id": inspection.localId,
]
if let score = inspection.overallScore { body["overall_score"] = score }
if let areaId = inspection.areaServerId { body["area_id"] = areaId }
if let parentId = inspection.parentServerId { body["parent_inspection_id"] = parentId }
// Links the submission back to the schedule it was started from so the
// server fulfils it (clears the banner) and badges it as "Scheduled".
if let schedId = inspection.scheduledInspectionServerId {
body["scheduled_inspection_id"] = schedId
}
if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
if let lat = inspection.submitLatitude { body["submit_latitude"] = lat }
if let lng = inspection.submitLongitude { body["submit_longitude"] = lng }
// IMPORTANT: timeZone must be explicitly set to UTC.
// ISO8601DateFormatter() default timeZone is the DEVICE local timezone,
// which produces offset strings like "2026-06-11T10:30:00-04:00".
// The server's _parse_datetime() only recognises the Z suffix as UTC;
// offset-format strings fail all strptime patterns and return None,
// causing the server to fall back to now_eastern() — the sync time —
// instead of the actual inspection/completion time.
let fmt = ISO8601DateFormatter()
fmt.timeZone = TimeZone(identifier: "UTC")!
body["inspection_date"] = fmt.string(from: inspection.inspectionDate)
if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) }
struct R: Decodable, Sendable { let inspectionId: Int; let duplicate: Bool }
let r: R = try await post("/api/v1/inspections", body: body)
return r.inspectionId
}
// ── Submit Issue ──────────────────────────────────────────────────────
func submitIssue(_ issue: LocalIssue, inspectionServerId: Int?) async throws -> Int {
var body: [String: Any] = [
"facility_id": issue.facilityServerId,
"severity": issue.severity,
"description": issue.issueDescription,
"mobile_local_id": issue.localId,
]
// Use the explicitly passed serverId rather than issue.inspection?.serverId.
// The ORM relationship object is a separate fetch instance from the one
// processInspectionQueue updated, so its serverId is nil even after the
// inspection synced in the same triggerSync() pass.
if let id = inspectionServerId { body["inspection_id"] = id }
if let areaId = issue.areaServerId { body["area_id"] = areaId }
// 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 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(
"/api/v1/issues/\(issueId)/photos",
method: "PATCH",
body: ["result_photos": resultPhotos]
)
}
// ── Attach a late photo to an ALREADY-SUBMITTED inspection ────────────
// The inspection twin of updateIssuePhotos above, and the piece that was
// missing: an inspection photo that exhausted its upload attempts was
// submitted with its field blanked, and nothing could ever put it back.
// attachServerPath() wrote the recovered path into LOCAL form data only,
// so the server copy stayed empty forever even after a successful retry.
//
// Server-side (app/api/inspections.py, update_inspection) form_data is
// merged field-by-field via _merge_form_data: a non-empty incoming value
// wins, and existing 'uploads/...' paths are never blanked. Sending only
// the recovered fields is therefore safe and idempotent.
//
// `status` is deliberately NOT sent: including it would re-run the
// draft→completed transition server-side, which is what fulfils a linked
// schedule. Omitting it leaves status, score and schedule untouched.
func updateInspectionFormData(inspectionId: Int,
fields: [String: String]) async throws {
struct R: Decodable, Sendable { let id: Int }
let _: R = try await request(
"/api/v1/inspections/\(inspectionId)",
method: "PATCH",
body: ["form_data": fields]
)
}
// ── Upload a resolution photo (entity_type = issue_result) ────────────
// Saves to issue_result_photos subfolder on the server — same bucket
// as photos uploaded via the web update form.
func uploadResultPhoto(localPath: String,
capturedAt: Date? = nil,
latitude: Double? = nil,
longitude: Double? = nil,
retrying: Bool = false) async throws -> String {
let url = try buildURL("/api/v1/photos/upload")
// Read via PhotoStore, not the raw path. Stored paths embed the app
// container UUID, which iOS reassigns on every app update — the file
// survives, the path does not, and a raw read then fails forever on a
// photo that is sitting right there on disk (rule 91).
guard let imageData = PhotoStore.contents(at: localPath) else {
throw APIError.networkError("Could not read photo: \(localPath)")
}
let boundary = "Boundary-\(UUID().uuidString)"
let filename = URL(fileURLWithPath: localPath).lastPathComponent
let ext = (filename as NSString).pathExtension.lowercased()
let mime = ext == "png" ? "image/png" : "image/jpeg"
var body = Data()
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\nissue_result\r\n".data(using: .utf8)!)
body.append(Self.formField("captured_at", capturedAt.map { PhotoCaptureFormat.iso8601.string(from: $0) }, boundary))
body.append(Self.formField("latitude", latitude.map { String(format: "%.6f", $0) }, boundary))
body.append(Self.formField("longitude", longitude.map { String(format: "%.6f", $0) }, boundary))
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
body.append(imageData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
injectToken(&req)
req.httpBody = body
let (data, response) = try await performRequest(req)
if shouldRefresh(response, retrying: retrying) {
let refreshed = await refreshAccessToken()
if refreshed {
return try await uploadResultPhoto(localPath: localPath, capturedAt: capturedAt,
latitude: latitude, longitude: longitude,
retrying: true)
}
throw APIError.notAuthenticated
}
struct PhotoResult: Decodable, Sendable { let serverPath: String }
if let env = try? decoder.decode(_Envelope<PhotoResult>.self, from: data),
env.ok, let r = env.data { return r.serverPath }
throw APIError.serverError("Result photo upload failed")
}
// ── Attach resolution photos to an existing issue ─────────────────────
// PATCHes /api/v1/issues/{id}/result_photos — writes to Issue.result_photos
// (Resolution Details on web), not mobile_photo_paths (Photo Evidence).
func updateIssueResultPhotos(issueId: Int, resultPhotos: [String]) async throws {
struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int }
let _: R = try await request(
"/api/v1/issues/\(issueId)/result_photos",
method: "PATCH",
body: ["result_photos": resultPhotos]
)
}
// ── Fetch Issue Detail (status + assigned_to) ─────────────────────────
func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail {
return try await request("/api/v1/issues/\(issueId)")
}
// ── Update Issue Status ───────────────────────────────────────────────
func updateIssueStatus(issueId: Int, status: String) async throws -> String {
let result: APIIssueStatusUpdate = try await request(
"/api/v1/issues/\(issueId)/status",
method: "PATCH",
body: ["status": status]
)
return result.status
}
// ── Notification polling (Phase C) ────────────────────────────────────
// Shared formatter for the ?since= query parameter.
// DateFormatter init is expensive — creating one per fetchNotifications()
// call (every 60 seconds) adds unnecessary allocations on the sync cycle.
private static let notifSinceFmt: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
return f
}()
/// Fetch notifications, optionally scoped to those created after `since`.
func fetchNotifications(since: Date? = nil) async throws -> [APINotification] {
var ep = "/api/v1/notifications"
if let since {
ep += "?since=\(Self.notifSinceFmt.string(from: since))"
}
let result: APINotificationsResponseData = try await request(ep)
return result.notifications
}
/// Mark the given notification IDs as read on the server.
func markNotificationsRead(ids: [Int]) async throws {
guard !ids.isEmpty else { return }
let _: APIMarkReadResponseData = try await request(
"/api/v1/notifications/mark-read",
method: "PATCH",
body: ["ids": ids]
)
}
/// Fetch issues assigned to the current user from the server.
func fetchAssignedIssues() async throws -> [APIAssignedIssue] {
let result: APIAssignedIssuesResponseData = try await request("/api/v1/issues")
return result.issues
}
// ── Scheduled Inspections (phase36) ───────────────────────────────────
func fetchScheduledInspections() async throws -> [APIScheduledInspection] {
let result: APIScheduledInspectionsResponseData =
try await request("/api/v1/scheduled-inspections")
return result.scheduled
}
/// Plan a follow-up re-inspection of `parentInspectionId` for `dueDate`
/// (phase45) — the deferred twin of "Re-inspect Now" in history detail.
///
/// Only the parent and the date are sent: the server derives facility,
/// template and assignee from the parent inspection, so a follow-up can
/// only ever target the thing it is a follow-up of. The schedule it creates
/// carries `parent_inspection_id`, which the inspection started from it
/// inherits — that is what makes the eventual run a linked re-inspection.
///
/// Idempotent server-side: retrying re-dates the existing active follow-up
/// for this parent instead of creating a second one.
///
/// `dueDate` must be formatted `yyyy-MM-dd`; the server rejects a past date.
func createScheduledFollowUp(
parentInspectionId: Int,
dueDate: String,
notes: String?
) async throws -> APIScheduledInspection {
var body: [String: Any] = [
"parent_inspection_id": parentInspectionId,
"due_date": dueDate,
]
// Raw snake_case body keys — JSONSerialization applies no key strategy
// (rule 65).
if let n = notes?.trimmingCharacters(in: .whitespacesAndNewlines), !n.isEmpty {
body["notes"] = n
}
let result: APIScheduledFollowUpResponseData = try await post(
"/api/v1/scheduled-inspections/follow-up", body: body
)
return result.scheduled
}
// ── Follow-up Requests ────────────────────────────────────────────────
/// Inspections a director/admin has flagged as needing a follow-up.
///
/// Same endpoint and response shape as `fetchInspectionHistory`, but with
/// the `follow_up_required` filter so the server returns the complete
/// outstanding set rather than the recent page history shows. The limit is
/// the endpoint's maximum for the same reason — this list drives actionable
/// work, and a follow-up raised on a months-old inspection must still
/// appear. Inspector-scoped server-side.
func fetchFollowUpRequests() async throws -> [APIInspectionSummary] {
let result: InspectionHistoryResponseData = try await request(
"/api/v1/inspections?follow_up_required=true&limit=200"
)
return result.inspections
}
// ── Issue Handler ("Handled By") ──────────────────────────────────────
/// Set who handles an issue. `details` carries any of the optional
/// facility_handler_* / vendor_* fields; only keys present are updated.
func updateIssueHandler(
issueId: Int,
handlerType: String,
details: [String: String] = [:]
) async throws -> String {
var body: [String: Any] = ["handler_type": handlerType]
for (k, v) in details { body[k] = v }
let result: APIIssueHandlerUpdate = try await request(
"/api/v1/issues/\(issueId)/handler",
method: "PATCH",
body: body
)
return result.handlerType
}
/// Fetch dashboard KPI counts for the current user (Phase B).
func fetchDashboardStats() async throws -> APIDashboardStats {
return try await request("/api/v1/stats/dashboard")
}
// ── Device Registration ───────────────────────────────────────────────
// Called on every app foreground (active scenePhase) when authenticated.
// Upserts a device_registrations row on the server so the admin can see
// all installed devices and their versions.
// Errors are suppressed — device registration is best-effort and must
// never block the normal app launch flow.
/// Returns or creates a stable device UUID, persisted in Keychain so it
/// survives app restarts but is unique per physical device.
nonisolated static func stableDeviceId() -> String {
if let existing = KeychainHelper.get(Constants.Keychain.deviceId) {
return existing
}
let new = UUID().uuidString
KeychainHelper.set(new, forKey: Constants.Keychain.deviceId)
return new
}
func registerDevice() async {
guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else { return }
let deviceId = Self.stableDeviceId()
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
// UIDevice.current is @MainActor — read on MainActor then pass as plain Strings
let (deviceName, iosVersion): (String, String) = await MainActor.run {
(UIDevice.current.name, UIDevice.current.systemVersion)
}
let body: [String: Any] = [
"device_id": deviceId,
"device_name": deviceName,
"app_version": appVersion,
"ios_version": iosVersion,
]
do {
// nonisolated init required — SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor
// taints synthesised Decodable inits (CLAUDE.md rule 29).
struct R: Decodable, Sendable {
let registered: Bool
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
registered = try c.decode(Bool.self, forKey: .registered)
}
private enum CodingKeys: String, CodingKey { case registered }
}
let _: R = try await request("/api/v1/devices/register", method: "POST", body: body)
print("[JQC] registerDevice succeeded")
} catch {
// Log raw response to diagnose server-side failures
if let url = URL(string: ServerConfig.current + "/api/v1/devices/register"),
let token = KeychainHelper.get(Constants.Keychain.accessToken) {
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
if let (data, resp) = try? await URLSession.shared.data(for: req) {
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
let raw = String(data: data, encoding: .utf8) ?? "<binary>"
print("[JQC] registerDevice HTTP \(status): \(raw)")
}
}
print("[JQC] registerDevice error: \(error)")
}
}
// ── Issue Comments (Phase D) ──────────────────────────────────────────
/// Fetch all comments for an issue, oldest-first.
func fetchIssueComments(issueId: Int) async throws -> [APIIssueComment] {
let result: APIIssueCommentsResponseData = try await request(
"/api/v1/issues/\(issueId)/comments"
)
return result.comments
}
/// Post a new comment on an issue. Returns the new comment ID.
func postIssueComment(issueId: Int, body: String) async throws -> Int {
let result: APIAddCommentResponseData = try await request(
"/api/v1/issues/\(issueId)/comments",
method: "POST",
body: ["body": body]
)
return result.commentId
}
// ── 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 }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: ["refresh_token": token])
guard let (data, response) = try? await session.data(for: req),
let http = response as? HTTPURLResponse, http.statusCode == 200
else { return false }
// Use a local decoder — avoids referencing the actor-isolated self.decoder
// which would trigger a Swift 6 main-actor isolation error.
let localDecoder = JSONDecoder()
localDecoder.keyDecodingStrategy = .convertFromSnakeCase
guard let env = try? localDecoder.decode(_RefreshEnvelope.self, from: data),
env.ok,
let tokens = env.data
else { return false }
KeychainHelper.set(tokens.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(tokens.refreshToken, forKey: Constants.Keychain.refreshToken)
return true
}
// ── Private Helpers ───────────────────────────────────────────────────
private func buildURL(_ endpoint: String) throws -> URL {
guard let url = URL(string: ServerConfig.current + endpoint) else {
throw APIError.invalidURL
}
return url
}
private func buildRequest(url: URL, method: String, body: [String: Any]?) -> URLRequest {
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let body { req.httpBody = try? JSONSerialization.data(withJSONObject: body) }
return req
}
private func injectToken(_ req: inout URLRequest) {
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
}
private func performRequest(_ req: URLRequest) async throws -> (Data, URLResponse) {
do {
return try await session.data(for: req)
} catch {
throw APIError.networkError(error.localizedDescription)
}
}
private func shouldRefresh(_ response: URLResponse, retrying: Bool) -> Bool {
guard !retrying, let http = response as? HTTPURLResponse else { return false }
return http.statusCode == 401
}
private func decode<T: Decodable & Sendable>(_ data: Data) throws -> T {
// 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(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)"
}
}
}