// 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: 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, 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") 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(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.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 } // 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] ) } // ── 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") 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\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.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) ?? "" 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 ───────────────────────────────────────────────────── 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) } } }