From 51d2923e6dae98e80aa26b3d7e9a39c6cd624939 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sun, 3 May 2026 09:29:12 -0400 Subject: [PATCH] 05/02 Phase C 2 --- JanitorialQC/API/APIClient.swift | 281 ++++++++++--------- JanitorialQC/API/APIModels.swift | 342 ++++++++++++++++++------ JanitorialQC/Auth/AuthManager.swift | 44 +-- JanitorialQC/Auth/KeychainHelper.swift | 23 +- JanitorialQC/Models/LocalTemplate.swift | 31 ++- JanitorialQC/Utils/Constants.swift | 27 +- 6 files changed, 439 insertions(+), 309 deletions(-) diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 7892d5f..d2ef349 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -1,12 +1,9 @@ // API/APIClient.swift -// ------------------- -// Central HTTP client for all JQC API calls. -// Phase C adds: fetchInspectionHistory() import Foundation import Combine -enum APIError: Error, LocalizedError { +enum APIError: Error, LocalizedError, Sendable { case invalidURL case notAuthenticated case serverError(String) @@ -24,160 +21,125 @@ enum APIError: Error, LocalizedError { } } +// 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 } +} + +// Refresh-only envelope — uses a non-Sendable-constrained local struct +// decoded manually to avoid pulling RefreshResponseData into the Sendable chain. +private struct _RefreshEnvelope: Decodable { + struct Tokens: Decodable { + let accessToken: String + let refreshToken: String + } + let ok: Bool + let data: Tokens? +} + actor APIClient { static let shared = APIClient() - private let baseURL: String private let session: URLSession + private let decoder: JSONDecoder private init() { - self.baseURL = Constants.baseURL - let config = URLSessionConfiguration.default + let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 self.session = URLSession(configuration: config) + self.decoder = JSONDecoder() + self.decoder.keyDecodingStrategy = .convertFromSnakeCase } // ── Generic JSON Request ────────────────────────────────────────────── - func 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) - guard let url = URL(string: baseURL + endpoint) else { - throw APIError.invalidURL - } + let (data, response) = try await performRequest(req) - 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 { + if shouldRefresh(response, retrying: retrying) { let refreshed = await refreshAccessToken() if refreshed { return try await request(endpoint, method: method, body: body, retrying: true) - } else { - throw APIError.notAuthenticated } + 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) - } + return try decode(data) } - func post(_ endpoint: String, body: [String: Any]) async throws -> T { + func post(_ endpoint: String, body: [String: Any]) async throws -> T { return try await request(endpoint, method: "POST", body: body) } - // ── Inspection History (Phase C) ────────────────────────────────────── + // ── Inspection History ──────────────────────────────────────────────── - /// 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) + 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 (multipart/form-data) ──────────────────────────────── + // ── Photo Upload ────────────────────────────────────────────────────── func uploadPhoto(localPath: String, entityType: String) async throws -> String { - guard let url = URL(string: baseURL + "/api/v1/photos/upload") else { - throw APIError.invalidURL - } + let url = try buildURL("/api/v1/photos/upload") guard let imageData = FileManager.default.contents(atPath: localPath) else { - throw APIError.networkError("Could not read photo file: \(localPath)") + throw APIError.networkError("Could not read photo: \(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" + let mime = 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)!) + 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".data(using: .utf8)!) - body.append("--\(boundary)--\r\n".data(using: .utf8)!) + 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") - if let token = KeychainHelper.get(Constants.Keychain.accessToken) { - req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + injectToken(&req) req.httpBody = body - let (data, response) = try await session.data(for: req) + let (data, response) = try await performRequest(req) - guard let http = response as? HTTPURLResponse else { - throw APIError.networkError("Invalid response") - } - - if http.statusCode == 401 { + if shouldRefresh(response, retrying: false) { 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 + 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") } @@ -190,25 +152,18 @@ actor APIClient { "status": "completed", "form_data": inspection.formData, "mobile_local_id": inspection.localId, - "overall_score": inspection.overallScore as Any, ] + if let score = inspection.overallScore { body["overall_score"] = score } + if let areaId = inspection.areaServerId { body["area_id"] = areaId } + if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes } - if let areaId = inspection.areaServerId { body["area_id"] = areaId } - 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) } - 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 + 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 ────────────────────────────────────────────────────── @@ -220,53 +175,85 @@ actor APIClient { "description": issue.issueDescription, "mobile_local_id": issue.localId, ] + if let id = issue.inspection?.serverId { body["inspection_id"] = id } + if let path = issue.photoServerPath { body["photo_path"] = path } - 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 + struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool } + let r: R = try await post("/api/v1/issues", body: body) + return r.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 - } + 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": refreshToken] - ) + 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 + 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 + guard let env = try? decoder.decode(_RefreshEnvelope.self, from: data), + env.ok, + let tokens = env.data else { return false } - KeychainHelper.set(refreshData.accessToken, forKey: Constants.Keychain.accessToken) - KeychainHelper.set(refreshData.refreshToken, forKey: Constants.Keychain.refreshToken) + 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) + } + } } diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index 01dbf29..01b430d 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -1,62 +1,145 @@ // API/APIModels.swift // ------------------- -// Codable structs that map to the JSON responses from the JQC Flask API. -// All API responses use the envelope: { "ok": bool, "data": {...}, "error": string? } +// All types in this file are pure value types with no actor isolation. +// AnyDecodable uses a JSONValue enum (not `Any`) so it is fully Sendable +// without @unchecked and causes no actor-isolation warnings. import Foundation +// ── JSONValue — replaces AnyDecodable ──────────────────────────────────────── +// +// Using `Any` as a stored property is not Sendable, which causes the Swift +// compiler to defensively infer @MainActor on any struct containing it. +// A typed enum avoids this entirely: every case is a concrete Sendable type. + +enum JSONValue: Decodable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([JSONValue]) + case object([String: JSONValue]) + case null + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + // Order matters: Bool before Int (Bool is Int-decodable on some platforms) + if let v = try? c.decode(Bool.self) { self = .bool(v); return } + if let v = try? c.decode(Int.self) { self = .int(v); return } + if let v = try? c.decode(Double.self) { self = .double(v); return } + if let v = try? c.decode(String.self) { self = .string(v); return } + if let v = try? c.decode([JSONValue].self) { self = .array(v); return } + if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return } + self = .null + } + + // Convert to Any for compatibility with existing formData/formSchema code + var anyValue: Any { + switch self { + case .string(let v): return v + case .int(let v): return v + case .double(let v): return v + case .bool(let v): return v + case .null: return NSNull() + case .array(let v): return v.map(\.anyValue) + case .object(let v): return v.mapValues(\.anyValue) + } + } +} + +// Typealias so existing code that references AnyDecodable still compiles +typealias AnyDecodable = JSONValue + // ── API Envelope ────────────────────────────────────────────────────────────── -struct APIResponse: Decodable { +struct APIResponse: 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 } } // ── Auth ────────────────────────────────────────────────────────────────────── -struct LoginResponseData: Decodable { - let accessToken: String - let refreshToken: String - let tokenType: String - let expiresIn: Int - let user: APIUser -} - -struct RefreshResponseData: Decodable { - let accessToken: String - let refreshToken: String - let tokenType: String - let expiresIn: Int -} - -struct MeResponseData: Decodable { - let user: APIUser -} - -struct APIUser: Decodable { +struct APIUser: Decodable, Sendable { let id: Int let username: String let email: String let role: String let createdAt: String? var displayName: String { username } + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + username = try c.decode(String.self, forKey: .username) + email = try c.decode(String.self, forKey: .email) + role = try c.decode(String.self, forKey: .role) + createdAt = try? c.decode(String.self, forKey: .createdAt) + } + private enum CodingKeys: String, CodingKey { + case id, username, email, role, createdAt + } +} + +struct LoginResponseData: Decodable, Sendable { + let accessToken: String + let refreshToken: String + let tokenType: String + let expiresIn: Int + let user: APIUser + + 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) + tokenType = try c.decode(String.self, forKey: .tokenType) + expiresIn = try c.decode(Int.self, forKey: .expiresIn) + user = try c.decode(APIUser.self, forKey: .user) + } + private enum CodingKeys: String, CodingKey { + case accessToken, refreshToken, tokenType, expiresIn, user + } +} + +struct RefreshResponseData: Decodable, Sendable { + let accessToken: String + let refreshToken: String + let tokenType: String + let expiresIn: Int + + 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) + tokenType = try c.decode(String.self, forKey: .tokenType) + expiresIn = try c.decode(Int.self, forKey: .expiresIn) + } + private enum CodingKeys: String, CodingKey { + case accessToken, refreshToken, tokenType, expiresIn + } +} + +struct MeResponseData: Decodable, Sendable { + let user: APIUser + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + user = try c.decode(APIUser.self, forKey: .user) + } + private enum CodingKeys: String, CodingKey { case user } } // ── Facilities ──────────────────────────────────────────────────────────────── -struct FacilitiesResponseData: Decodable { - let facilities: [APIFacility] - let count: Int -} - -struct AreasResponseData: Decodable { - let facilityId: Int - let areas: [APIArea] - let count: Int -} - -struct APIFacility: Decodable, Identifiable { +struct APIFacility: Decodable, Identifiable, Sendable { let id: Int let name: String let address: String @@ -65,51 +148,130 @@ struct APIFacility: Decodable, Identifiable { let projectId: Int? let projectName: String? let isActive: Bool + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + address = try c.decode(String.self, forKey: .address) + contactPerson = try c.decode(String.self, forKey: .contactPerson) + contactPhone = try c.decode(String.self, forKey: .contactPhone) + projectId = try? c.decode(Int.self, forKey: .projectId) + projectName = try? c.decode(String.self, forKey: .projectName) + isActive = try c.decode(Bool.self, forKey: .isActive) + } + private enum CodingKeys: String, CodingKey { + case id, name, address, contactPerson, contactPhone + case projectId, projectName, isActive + } } -struct APIArea: Decodable, Identifiable { +struct FacilitiesResponseData: Decodable, Sendable { + let facilities: [APIFacility] + let count: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + facilities = try c.decode([APIFacility].self, forKey: .facilities) + count = try c.decode(Int.self, forKey: .count) + } + private enum CodingKeys: String, CodingKey { case facilities, count } +} + +struct APIArea: Decodable, Identifiable, Sendable { let id: Int let facilityId: Int let name: String let areaType: String + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + facilityId = try c.decode(Int.self, forKey: .facilityId) + name = try c.decode(String.self, forKey: .name) + areaType = try c.decode(String.self, forKey: .areaType) + } + private enum CodingKeys: String, CodingKey { case id, facilityId, name, areaType } +} + +struct AreasResponseData: Decodable, Sendable { + let facilityId: Int + let areas: [APIArea] + let count: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + facilityId = try c.decode(Int.self, forKey: .facilityId) + areas = try c.decode([APIArea].self, forKey: .areas) + count = try c.decode(Int.self, forKey: .count) + } + private enum CodingKeys: String, CodingKey { case facilityId, areas, count } } // ── Templates ───────────────────────────────────────────────────────────────── -struct TemplatesResponseData: Decodable { +struct APITemplateSummary: Decodable, Identifiable, Sendable { + let id: Int + let name: String + let description: String + let frequency: String + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + description = try c.decode(String.self, forKey: .description) + frequency = try c.decode(String.self, forKey: .frequency) + } + private enum CodingKeys: String, CodingKey { case id, name, description, frequency } +} + +struct TemplatesResponseData: Decodable, Sendable { let templates: [APITemplateSummary] let count: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + templates = try c.decode([APITemplateSummary].self, forKey: .templates) + count = try c.decode(Int.self, forKey: .count) + } + private enum CodingKeys: String, CodingKey { case templates, count } } -struct TemplateDetailResponseData: Decodable { +struct APITemplate: Decodable, Identifiable, Sendable { + let id: Int + let name: String + let description: String + let frequency: String + // Use [[String: JSONValue]] instead of [[String: AnyDecodable]] — fully Sendable + let formSchema: [[String: JSONValue]] + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + description = try c.decode(String.self, forKey: .description) + frequency = try c.decode(String.self, forKey: .frequency) + formSchema = try c.decode([[String: JSONValue]].self, forKey: .formSchema) + } + private enum CodingKeys: String, CodingKey { + case id, name, description, frequency, formSchema + } +} + +struct TemplateDetailResponseData: Decodable, Sendable { let template: APITemplate + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + template = try c.decode(APITemplate.self, forKey: .template) + } + private enum CodingKeys: String, CodingKey { case template } } -struct APITemplateSummary: Decodable, Identifiable { - let id: Int - let name: String - let description: String - let frequency: String -} +// ── Inspections ─────────────────────────────────────────────────────────────── -struct APITemplate: Decodable, Identifiable { - let id: Int - let name: String - let description: String - let frequency: String - let formSchema: [[String: AnyDecodable]] -} - -// ── Inspections (Phase C — history) ────────────────────────────────────────── - -struct InspectionHistoryResponseData: Decodable { - let inspections: [APIInspectionSummary] - let total: Int - let limit: Int - let offset: Int -} - -struct APIInspectionSummary: Decodable, Identifiable { +struct APIInspectionSummary: Decodable, Identifiable, Sendable { let id: Int let templateId: Int let templateName: String @@ -127,27 +289,41 @@ struct APIInspectionSummary: Decodable, Identifiable { guard let str = inspectionDate else { return nil } return ISO8601DateFormatter().date(from: str) } -} -// ── AnyDecodable helper ─────────────────────────────────────────────────────── - -struct AnyDecodable: Decodable { - let value: Any - - init(_ value: Any) { self.value = value } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let bool = try? container.decode(Bool.self) { value = bool; return } - if let int = try? container.decode(Int.self) { value = int; return } - if let dbl = try? container.decode(Double.self) { value = dbl; return } - if let str = try? container.decode(String.self) { value = str; return } - if let arr = try? container.decode([AnyDecodable].self) { - value = arr.map(\.value); return - } - if let dict = try? container.decode([String: AnyDecodable].self) { - value = dict.mapValues(\.value); return - } - value = NSNull() + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + templateId = try c.decode(Int.self, forKey: .templateId) + templateName = try c.decode(String.self, forKey: .templateName) + facilityId = try c.decode(Int.self, forKey: .facilityId) + facilityName = try c.decode(String.self, forKey: .facilityName) + areaId = try? c.decode(Int.self, forKey: .areaId) + areaName = try? c.decode(String.self, forKey: .areaName) + status = try c.decode(String.self, forKey: .status) + overallScore = try? c.decode(Double.self, forKey: .overallScore) + inspectionDate = try? c.decode(String.self, forKey: .inspectionDate) + completedAt = try? c.decode(String.self, forKey: .completedAt) + mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) + } + private enum CodingKeys: String, CodingKey { + case id, templateId, templateName, facilityId, facilityName + case areaId, areaName, status, overallScore + case inspectionDate, completedAt, mobileLocalId } } + +struct InspectionHistoryResponseData: Decodable, Sendable { + let inspections: [APIInspectionSummary] + let total: Int + let limit: Int + let offset: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + inspections = try c.decode([APIInspectionSummary].self, forKey: .inspections) + total = try c.decode(Int.self, forKey: .total) + limit = try c.decode(Int.self, forKey: .limit) + offset = try c.decode(Int.self, forKey: .offset) + } + private enum CodingKeys: String, CodingKey { case inspections, total, limit, offset } +} diff --git a/JanitorialQC/Auth/AuthManager.swift b/JanitorialQC/Auth/AuthManager.swift index 676b863..5fac3be 100644 --- a/JanitorialQC/Auth/AuthManager.swift +++ b/JanitorialQC/Auth/AuthManager.swift @@ -1,20 +1,16 @@ // Auth/AuthManager.swift -// ---------------------- -// Observable class that manages the entire authentication lifecycle: -// - Login (POST /api/v1/auth/login) -// - Logout (POST /api/v1/auth/logout) -// - Session restoration on app launch (GET /api/v1/auth/me) -// - Keychain persistence of tokens and user info import Foundation import SwiftUI import Combine +struct EmptyDecodable: Decodable, Sendable { + nonisolated init(from decoder: any Decoder) throws {} +} + @MainActor class AuthManager: ObservableObject { - // ── Published State ─────────────────────────────────────────────────── - @Published var isAuthenticated = false @Published var isLoading = false @Published var errorMessage: String? @@ -24,21 +20,14 @@ class AuthManager: ObservableObject { @Published var currentUserRole: String = "" @Published var currentDisplayName: String = "" - // ── Singleton ───────────────────────────────────────────────────────── static let shared = AuthManager() private init() {} - // ── App Launch: Restore Session ─────────────────────────────────────── - - /// Called once on app launch. Checks if a valid session exists in Keychain. - /// If an access token is present, verifies it with /api/v1/auth/me. - /// If expired, attempts a refresh. If all fails, shows the login screen. func restoreSession() async { guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else { isAuthenticated = false return } - isLoading = true defer { isLoading = false } @@ -47,19 +36,14 @@ class AuthManager: ObservableObject { applyUser(response.user) isAuthenticated = true } catch APIError.notAuthenticated { - // Refresh failed (APIClient tried automatically) — need fresh login KeychainHelper.clearAll() isAuthenticated = false } catch { - // Network error — still show as authenticated using cached Keychain data - // so the inspector can work offline restoreUserFromKeychain() isAuthenticated = true } } - // ── Login ───────────────────────────────────────────────────────────── - func login(username: String, password: String) async { isLoading = true errorMessage = nil @@ -70,14 +54,10 @@ class AuthManager: ObservableObject { "/api/v1/auth/login", body: ["username": username, "password": password] ) - - // Store tokens in Keychain KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken) KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken) - applyUser(response.user) isAuthenticated = true - } catch APIError.serverError(let msg) { errorMessage = msg } catch APIError.networkError { @@ -87,17 +67,13 @@ class AuthManager: ObservableObject { } } - // ── Logout ──────────────────────────────────────────────────────────── - func logout() async { - // Best-effort server-side logout (revokes refresh token) if let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken) { _ = try? await APIClient.shared.post( "/api/v1/auth/logout", body: ["refresh_token": refreshToken] ) as EmptyDecodable } - KeychainHelper.clearAll() isAuthenticated = false currentUserId = 0 @@ -106,17 +82,14 @@ class AuthManager: ObservableObject { currentDisplayName = "" } - // ── Helpers ─────────────────────────────────────────────────────────── - private func applyUser(_ user: APIUser) { currentUserId = user.id currentUsername = user.username currentUserRole = user.role currentDisplayName = user.displayName - - KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId) - KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole) - KeychainHelper.set(user.username, forKey: Constants.Keychain.username) + KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId) + KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole) + KeychainHelper.set(user.username, forKey: Constants.Keychain.username) KeychainHelper.set(user.displayName, forKey: Constants.Keychain.displayName) } @@ -127,6 +100,3 @@ class AuthManager: ObservableObject { currentDisplayName = KeychainHelper.get(Constants.Keychain.displayName) ?? "" } } - -// Used for logout response decoding (empty data field) -struct EmptyDecodable: Decodable {} diff --git a/JanitorialQC/Auth/KeychainHelper.swift b/JanitorialQC/Auth/KeychainHelper.swift index 00d80a7..af191d0 100644 --- a/JanitorialQC/Auth/KeychainHelper.swift +++ b/JanitorialQC/Auth/KeychainHelper.swift @@ -3,8 +3,10 @@ // Thin wrapper around iOS Security framework for storing sensitive data // (tokens) in the device Keychain. // -// The Keychain persists across app reinstalls (on the same device) and -// is encrypted by the OS. Never store tokens in UserDefaults. +// All methods are marked nonisolated so they can be called from any +// actor context (including APIClient which is an actor) without +// Swift 6 actor-isolation warnings. +// Security framework calls are internally thread-safe. import Foundation import Security @@ -15,10 +17,9 @@ enum KeychainHelper { // ── Write ───────────────────────────────────────────────────────────── @discardableResult - static func set(_ value: String, forKey key: String) -> Bool { + nonisolated static func set(_ value: String, forKey key: String) -> Bool { guard let data = value.data(using: .utf8) else { return false } - // Delete any existing entry first to avoid duplicate-item errors let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, @@ -26,10 +27,10 @@ enum KeychainHelper { SecItemDelete(deleteQuery as CFDictionary) let addQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, ] let status = SecItemAdd(addQuery as CFDictionary, nil) return status == errSecSuccess @@ -37,7 +38,7 @@ enum KeychainHelper { // ── Read ────────────────────────────────────────────────────────────── - static func get(_ key: String) -> String? { + nonisolated static func get(_ key: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, @@ -56,7 +57,7 @@ enum KeychainHelper { // ── Delete ──────────────────────────────────────────────────────────── @discardableResult - static func delete(_ key: String) -> Bool { + nonisolated static func delete(_ key: String) -> Bool { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, @@ -67,7 +68,7 @@ enum KeychainHelper { // ── Delete All JQC Keys ─────────────────────────────────────────────── - static func clearAll() { + nonisolated static func clearAll() { let keys = [ Constants.Keychain.accessToken, Constants.Keychain.refreshToken, diff --git a/JanitorialQC/Models/LocalTemplate.swift b/JanitorialQC/Models/LocalTemplate.swift index 73e0309..ca61fea 100644 --- a/JanitorialQC/Models/LocalTemplate.swift +++ b/JanitorialQC/Models/LocalTemplate.swift @@ -1,7 +1,7 @@ // Models/LocalTemplate.swift // -------------------------- // SwiftData model for locally cached inspection templates. -// The form_schema is stored as a raw JSON string and decoded on demand. +// form_schema stored as raw JSON string, decoded on demand via formSchema property. import Foundation import SwiftData @@ -12,17 +12,16 @@ final class LocalTemplate { var name: String var templateDescription: String var frequency: String - /// Raw JSON string of the form_schema array — decoded on demand var formSchemaJSON: String var lastSyncedAt: Date init(from summary: APITemplateSummary) { - self.serverId = summary.id - self.name = summary.name - self.templateDescription = summary.description - self.frequency = summary.frequency - self.formSchemaJSON = "[]" - self.lastSyncedAt = Date() + self.serverId = summary.id + self.name = summary.name + self.templateDescription = summary.description + self.frequency = summary.frequency + self.formSchemaJSON = "[]" + self.lastSyncedAt = Date() } func updateSummary(from summary: APITemplateSummary) { @@ -33,20 +32,20 @@ final class LocalTemplate { } func updateSchema(from template: APITemplate) { - // Re-serialize the form_schema to JSON for local storage - if let data = try? JSONSerialization.data(withJSONObject: template.formSchema.map({ dict in - dict.mapValues { $0.value } - })), - let str = String(data: data, encoding: .utf8) { + // Convert [[String: JSONValue]] → JSON string via anyValue bridge + let raw = template.formSchema.map { dict in + dict.mapValues { $0.anyValue } + } + if let data = try? JSONSerialization.data(withJSONObject: raw), + let str = String(data: data, encoding: .utf8) { self.formSchemaJSON = str } self.lastSyncedAt = Date() } - /// Decode the stored JSON string back into an array of field dictionaries. - /// Returns an empty array if the JSON is invalid. + /// Decode stored JSON back into [[String: Any]] for the form renderer var formSchema: [[String: Any]] { - guard let data = formSchemaJSON.data(using: .utf8), + guard let data = formSchemaJSON.data(using: .utf8), let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } return array diff --git a/JanitorialQC/Utils/Constants.swift b/JanitorialQC/Utils/Constants.swift index d602bf0..f44eff3 100644 --- a/JanitorialQC/Utils/Constants.swift +++ b/JanitorialQC/Utils/Constants.swift @@ -1,27 +1,24 @@ // Utils/Constants.swift // --------------------- // Central place for app-wide constants. -// IMPORTANT: Replace the baseURL with your actual server URL. +// IMPORTANT: Replace baseURL with your actual server URL. import Foundation -enum Constants { - // ── Server ──────────────────────────────────────────────────────────── - /// Your JanitorialQC server base URL. No trailing slash. - /// Example: "https://your-domain.com" +// Explicitly not @MainActor — these constants must be readable from +// any actor context including APIClient and KeychainHelper. +nonisolated enum Constants { + static let baseURL = "https://jqc1.ltservicesinc.com" - // ── Keychain keys ───────────────────────────────────────────────────── - enum Keychain { - static let accessToken = "com.JanitorialQC.accessToken" - static let refreshToken = "com.JanitorialQC.refreshToken" - static let userId = "com.JanitorialQC.userId" - static let userRole = "com.JanitorialQC.userRole" - static let username = "com.JanitorialQC.username" - static let displayName = "com.JanitorialQC.displayName" + nonisolated enum Keychain { + static let accessToken = "com.jqc.accessToken" + static let refreshToken = "com.jqc.refreshToken" + static let userId = "com.jqc.userId" + static let userRole = "com.jqc.userRole" + static let username = "com.jqc.username" + static let displayName = "com.jqc.displayName" } - // ── Sync ────────────────────────────────────────────────────────────── - /// How many minutes before the access token expires to trigger a refresh static let tokenRefreshBufferMinutes: Double = 5 }