Files
JQC_iOS_App/JanitorialQC/API/APIClient.swift
T

388 lines
17 KiB
Swift

// 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<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 }
}
// 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<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
) 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<PhotoResult>.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<T: Decodable & Sendable>(_ data: Data) throws -> T {
if let env = try? decoder.decode(_Envelope<T>.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)
}
}
}