// API/APIClient.swift import Foundation import Combine 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: 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 } } // Free function removed — see refreshAccessToken() which decodes using a // local JSONDecoder to avoid Swift 6 actor-isolation errors. // Refresh-only envelope — Sendable so it can cross actor boundaries in Swift 6. private struct _RefreshEnvelope: Decodable, Sendable { struct Tokens: Decodable, Sendable { let accessToken: String let refreshToken: String } let ok: Bool let data: Tokens? } 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( _ 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(_ 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 ) async throws -> InspectionHistoryResponseData { var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed" if let fid = facilityId { ep += "&facility_id=\(fid)" } return try await request(ep) } // ── Photo Upload ────────────────────────────────────────────────────── func uploadPhoto(localPath: String, entityType: String) async throws -> String { let url = try buildURL("/api/v1/photos/upload") guard let imageData = FileManager.default.contents(atPath: 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("--\(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: false) { let refreshed = await refreshAccessToken() if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType) } throw APIError.notAuthenticated } struct PhotoResult: Decodable, Sendable { let serverPath: String } if let env = try? decoder.decode(_Envelope.self, from: data), env.ok, let r = env.data { return r.serverPath } throw APIError.serverError("Photo upload failed") } // ── 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 } if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes } let fmt = ISO8601DateFormatter() 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) async throws -> Int { var body: [String: Any] = [ "facility_id": issue.facilityServerId, "severity": issue.severity, "description": issue.issueDescription, "mobile_local_id": issue.localId, ] if let id = issue.inspection?.serverId { body["inspection_id"] = id } // Send the first uploaded photo as photo_path (server Issue.photo_path is a single column) if let firstPhoto = issue.photoServerPaths.first { body["photo_path"] = firstPhoto } struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool } let r: R = try await post("/api/v1/issues", body: body) return r.issueId } // ── 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 } // ── Token Refresh ───────────────────────────────────────────────────── private func refreshAccessToken() async -> Bool { guard let token = KeychainHelper.get(Constants.Keychain.refreshToken), let url = URL(string: Constants.baseURL + "/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: Constants.baseURL + 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(_ data: Data) throws -> T { if let env = try? decoder.decode(_Envelope.self, from: data) { if env.ok, let result = env.data { return result } throw APIError.serverError(env.error ?? "Unknown server error") } do { return try decoder.decode(T.self, from: data) } catch { throw APIError.decodingError(error.localizedDescription) } } }