// 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. // 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( _ 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, retrying: Bool = false) 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: retrying) { let refreshed = await refreshAccessToken() if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType, retrying: true) } 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 } 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. if let first = issue.photoServerPaths.first { body["photo_path"] = first } 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. 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] ) } // ── 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) ──────────────────────────────────── /// 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 { let fmt = DateFormatter() fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" ep += "?since=\(fmt.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 } /// Fetch dashboard KPI counts for the current user (Phase B). func fetchDashboardStats() async throws -> APIDashboardStats { return try await request("/api/v1/stats/dashboard") } // ── 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 ───────────────────────────────────────────────────── private func refreshAccessToken() 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(_ 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) } } }