// API/APIClient.swift // ------------------- // Central HTTP client for all JQC API calls. // Phase C adds: fetchInspectionHistory() import Foundation import Combine enum APIError: Error, LocalizedError { 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)" } } } actor APIClient { static let shared = APIClient() private let baseURL: String private let session: URLSession private init() { self.baseURL = Constants.baseURL let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 self.session = URLSession(configuration: config) } // ── Generic JSON Request ────────────────────────────────────────────── func request( _ endpoint: String, method: String = "GET", body: [String: Any]? = nil, retrying: Bool = false ) async throws -> T { guard let url = URL(string: baseURL + endpoint) else { throw APIError.invalidURL } var req = URLRequest(url: url) req.httpMethod = method req.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = KeychainHelper.get(Constants.Keychain.accessToken) { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body { req.httpBody = try? JSONSerialization.data(withJSONObject: body) } let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { throw APIError.networkError("Invalid response") } if http.statusCode == 401 && !retrying { let refreshed = await refreshAccessToken() if refreshed { return try await request(endpoint, method: method, body: body, retrying: true) } else { throw APIError.notAuthenticated } } let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase if let envelope = try? decoder.decode(APIResponse.self, from: data) { if envelope.ok, let result = envelope.data { return result } else { throw APIError.serverError(envelope.error ?? "Unknown server error") } } do { return try decoder.decode(T.self, from: data) } catch { throw APIError.decodingError(error.localizedDescription) } } func post(_ endpoint: String, body: [String: Any]) async throws -> T { return try await request(endpoint, method: "POST", body: body) } // ── Inspection History (Phase C) ────────────────────────────────────── /// Fetch the inspector's synced inspection history from the server. /// Returns up to `limit` records starting at `offset`. func fetchInspectionHistory( limit: Int = 50, offset: Int = 0, facilityId: Int? = nil ) async throws -> InspectionHistoryResponseData { var endpoint = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed" if let fid = facilityId { endpoint += "&facility_id=\(fid)" } return try await request(endpoint) } // ── Photo Upload (multipart/form-data) ──────────────────────────────── func uploadPhoto(localPath: String, entityType: String) async throws -> String { guard let url = URL(string: baseURL + "/api/v1/photos/upload") else { throw APIError.invalidURL } guard let imageData = FileManager.default.contents(atPath: localPath) else { throw APIError.networkError("Could not read photo file: \(localPath)") } let boundary = "Boundary-\(UUID().uuidString)" var body = Data() body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"entity_type\"\r\n\r\n".data(using: .utf8)!) body.append("\(entityType)\r\n".data(using: .utf8)!) let filename = URL(fileURLWithPath: localPath).lastPathComponent let ext = (filename as NSString).pathExtension.lowercased() let mimeType = ext == "png" ? "image/png" : "image/jpeg" body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) body.append(imageData) body.append("\r\n".data(using: .utf8)!) body.append("--\(boundary)--\r\n".data(using: .utf8)!) var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") if let token = KeychainHelper.get(Constants.Keychain.accessToken) { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } req.httpBody = body let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { throw APIError.networkError("Invalid response") } if http.statusCode == 401 { let refreshed = await refreshAccessToken() if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType) } else { throw APIError.notAuthenticated } } let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase struct PhotoUploadData: Decodable { let serverPath: String } if let envelope = try? decoder.decode(APIResponse.self, from: data), envelope.ok, let result = envelope.data { return result.serverPath } throw APIError.serverError("Photo upload failed") } // ── Submit Inspection ───────────────────────────────────────────────── func submitInspection(_ inspection: LocalInspection) async throws -> Int { var body: [String: Any] = [ "template_id": inspection.templateServerId, "facility_id": inspection.facilityServerId, "status": "completed", "form_data": inspection.formData, "mobile_local_id": inspection.localId, "overall_score": inspection.overallScore as Any, ] if let areaId = inspection.areaServerId { body["area_id"] = areaId } if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes } let formatter = ISO8601DateFormatter() body["inspection_date"] = formatter.string(from: inspection.inspectionDate) if let completedAt = inspection.completedAt { body["completed_at"] = formatter.string(from: completedAt) } struct InspectionResponseData: Decodable { let inspectionId: Int let duplicate: Bool } let result: InspectionResponseData = try await post("/api/v1/inspections", body: body) return result.inspectionId } // ── Submit Issue ────────────────────────────────────────────────────── func submitIssue(_ issue: LocalIssue) async throws -> Int { var body: [String: Any] = [ "area_id": issue.areaServerId, "severity": issue.severity, "description": issue.issueDescription, "mobile_local_id": issue.localId, ] if let inspServerId = issue.inspection?.serverId { body["inspection_id"] = inspServerId } if let serverPhotoPath = issue.photoServerPath { body["photo_path"] = serverPhotoPath } struct IssueResponseData: Decodable { let issueId: Int let duplicate: Bool } let result: IssueResponseData = try await post("/api/v1/issues", body: body) return result.issueId } // ── Token Refresh ───────────────────────────────────────────────────── private func refreshAccessToken() async -> Bool { guard let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken), let url = URL(string: 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": refreshToken] ) guard let (data, response) = try? await session.data(for: req), let http = response as? HTTPURLResponse, http.statusCode == 200 else { return false } let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase guard let envelope = try? decoder.decode(APIResponse.self, from: data), envelope.ok, let refreshData = envelope.data else { return false } KeychainHelper.set(refreshData.accessToken, forKey: Constants.Keychain.accessToken) KeychainHelper.set(refreshData.refreshToken, forKey: Constants.Keychain.refreshToken) return true } }