05/02 Phase C 2

This commit is contained in:
Nguyen Ngo
2026-05-03 09:29:12 -04:00
parent 59e698b5f7
commit 51d2923e6d
6 changed files with 439 additions and 309 deletions
+131 -144
View File
@@ -1,12 +1,9 @@
// API/APIClient.swift // API/APIClient.swift
// -------------------
// Central HTTP client for all JQC API calls.
// Phase C adds: fetchInspectionHistory()
import Foundation import Foundation
import Combine import Combine
enum APIError: Error, LocalizedError { enum APIError: Error, LocalizedError, Sendable {
case invalidURL case invalidURL
case notAuthenticated case notAuthenticated
case serverError(String) 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<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 }
}
// 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 { actor APIClient {
static let shared = APIClient() static let shared = APIClient()
private let baseURL: String
private let session: URLSession private let session: URLSession
private let decoder: JSONDecoder
private init() { private init() {
self.baseURL = Constants.baseURL
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30 config.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: config) self.session = URLSession(configuration: config)
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
} }
// Generic JSON Request // Generic JSON Request
func request<T: Decodable>( func request<T: Decodable & Sendable>(
_ endpoint: String, _ endpoint: String,
method: String = "GET", method: String = "GET",
body: [String: Any]? = nil, body: [String: Any]? = nil,
retrying: Bool = false retrying: Bool = false
) async throws -> T { ) 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 { let (data, response) = try await performRequest(req)
throw APIError.invalidURL
}
var req = URLRequest(url: url) if shouldRefresh(response, retrying: retrying) {
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw APIError.networkError("Invalid response")
}
if http.statusCode == 401 && !retrying {
let refreshed = await refreshAccessToken() let refreshed = await refreshAccessToken()
if refreshed { if refreshed {
return try await request(endpoint, method: method, body: body, retrying: true) return try await request(endpoint, method: method, body: body, retrying: true)
} else { }
throw APIError.notAuthenticated throw APIError.notAuthenticated
} }
return try decode(data)
} }
let decoder = JSONDecoder() func post<T: Decodable & Sendable>(_ endpoint: String, body: [String: Any]) async throws -> T {
decoder.keyDecodingStrategy = .convertFromSnakeCase
if let envelope = try? decoder.decode(APIResponse<T>.self, from: data) {
if envelope.ok, let result = envelope.data {
return result
} else {
throw APIError.serverError(envelope.error ?? "Unknown server error")
}
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error.localizedDescription)
}
}
func post<T: Decodable>(_ endpoint: String, body: [String: Any]) async throws -> T {
return try await request(endpoint, method: "POST", body: body) 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( func fetchInspectionHistory(
limit: Int = 50, limit: Int = 50,
offset: Int = 0, offset: Int = 0,
facilityId: Int? = nil facilityId: Int? = nil
) async throws -> InspectionHistoryResponseData { ) async throws -> InspectionHistoryResponseData {
var endpoint = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed" var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
if let fid = facilityId { if let fid = facilityId { ep += "&facility_id=\(fid)" }
endpoint += "&facility_id=\(fid)" return try await request(ep)
}
return try await request(endpoint)
} }
// Photo Upload (multipart/form-data) // Photo Upload
func uploadPhoto(localPath: String, entityType: String) async throws -> String { func uploadPhoto(localPath: String, entityType: String) async throws -> String {
guard let url = URL(string: baseURL + "/api/v1/photos/upload") else { let url = try buildURL("/api/v1/photos/upload")
throw APIError.invalidURL
}
guard let imageData = FileManager.default.contents(atPath: localPath) else { 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)" 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 filename = URL(fileURLWithPath: localPath).lastPathComponent
let ext = (filename as NSString).pathExtension.lowercased() 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)!) var body = Data()
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\n\(entityType)\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\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(imageData)
body.append("\r\n".data(using: .utf8)!) body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.httpMethod = "POST" req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
forHTTPHeaderField: "Content-Type") injectToken(&req)
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
req.httpBody = body 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 { if shouldRefresh(response, retrying: false) {
throw APIError.networkError("Invalid response")
}
if http.statusCode == 401 {
let refreshed = await refreshAccessToken() let refreshed = await refreshAccessToken()
if refreshed { if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType) }
return try await uploadPhoto(localPath: localPath, entityType: entityType)
} else {
throw APIError.notAuthenticated throw APIError.notAuthenticated
} }
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
struct PhotoUploadData: Decodable { let serverPath: String }
if let envelope = try? decoder.decode(APIResponse<PhotoUploadData>.self, from: data),
envelope.ok,
let result = envelope.data {
return result.serverPath
}
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") throw APIError.serverError("Photo upload failed")
} }
@@ -190,25 +152,18 @@ actor APIClient {
"status": "completed", "status": "completed",
"form_data": inspection.formData, "form_data": inspection.formData,
"mobile_local_id": inspection.localId, "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 let areaId = inspection.areaServerId { body["area_id"] = areaId }
if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes } if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
let formatter = ISO8601DateFormatter() let fmt = ISO8601DateFormatter()
body["inspection_date"] = formatter.string(from: inspection.inspectionDate) body["inspection_date"] = fmt.string(from: inspection.inspectionDate)
if let completedAt = inspection.completedAt { if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) }
body["completed_at"] = formatter.string(from: completedAt)
}
struct InspectionResponseData: Decodable { struct R: Decodable, Sendable { let inspectionId: Int; let duplicate: Bool }
let inspectionId: Int let r: R = try await post("/api/v1/inspections", body: body)
let duplicate: Bool return r.inspectionId
}
let result: InspectionResponseData = try await post("/api/v1/inspections", body: body)
return result.inspectionId
} }
// Submit Issue // Submit Issue
@@ -220,53 +175,85 @@ actor APIClient {
"description": issue.issueDescription, "description": issue.issueDescription,
"mobile_local_id": issue.localId, "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 { struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool }
body["inspection_id"] = inspServerId let r: R = try await post("/api/v1/issues", body: body)
} return r.issueId
if let serverPhotoPath = issue.photoServerPath {
body["photo_path"] = serverPhotoPath
}
struct IssueResponseData: Decodable {
let issueId: Int
let duplicate: Bool
}
let result: IssueResponseData = try await post("/api/v1/issues", body: body)
return result.issueId
} }
// Token Refresh // Token Refresh
private func refreshAccessToken() async -> Bool { private func refreshAccessToken() async -> Bool {
guard let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken), guard let token = KeychainHelper.get(Constants.Keychain.refreshToken),
let url = URL(string: baseURL + "/api/v1/auth/refresh") else { let url = URL(string: Constants.baseURL + "/api/v1/auth/refresh")
return false else { return false }
}
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.httpMethod = "POST" req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data( req.httpBody = try? JSONSerialization.data(withJSONObject: ["refresh_token": token])
withJSONObject: ["refresh_token": refreshToken]
)
guard let (data, response) = try? await session.data(for: req), guard let (data, response) = try? await session.data(for: req),
let http = response as? HTTPURLResponse, let http = response as? HTTPURLResponse, http.statusCode == 200
http.statusCode == 200
else { return false } else { return false }
let decoder = JSONDecoder() guard let env = try? decoder.decode(_RefreshEnvelope.self, from: data),
decoder.keyDecodingStrategy = .convertFromSnakeCase env.ok,
let tokens = env.data
guard let envelope = try? decoder.decode(APIResponse<RefreshResponseData>.self, from: data),
envelope.ok,
let refreshData = envelope.data
else { return false } else { return false }
KeychainHelper.set(refreshData.accessToken, forKey: Constants.Keychain.accessToken) KeychainHelper.set(tokens.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(refreshData.refreshToken, forKey: Constants.Keychain.refreshToken) KeychainHelper.set(tokens.refreshToken, forKey: Constants.Keychain.refreshToken)
return true 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<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)
}
}
} }
+258 -82
View File
@@ -1,62 +1,145 @@
// API/APIModels.swift // API/APIModels.swift
// ------------------- // -------------------
// Codable structs that map to the JSON responses from the JQC Flask API. // All types in this file are pure value types with no actor isolation.
// All API responses use the envelope: { "ok": bool, "data": {...}, "error": string? } // AnyDecodable uses a JSONValue enum (not `Any`) so it is fully Sendable
// without @unchecked and causes no actor-isolation warnings.
import Foundation 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 // API Envelope
struct APIResponse<T: Decodable>: Decodable { struct APIResponse<T: Decodable & Sendable>: Decodable, Sendable {
let ok: Bool let ok: Bool
let data: T? let data: T?
let error: String? 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 // Auth
struct LoginResponseData: Decodable { struct APIUser: Decodable, Sendable {
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 {
let id: Int let id: Int
let username: String let username: String
let email: String let email: String
let role: String let role: String
let createdAt: String? let createdAt: String?
var displayName: String { username } 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 // Facilities
struct FacilitiesResponseData: Decodable { struct APIFacility: Decodable, Identifiable, Sendable {
let facilities: [APIFacility]
let count: Int
}
struct AreasResponseData: Decodable {
let facilityId: Int
let areas: [APIArea]
let count: Int
}
struct APIFacility: Decodable, Identifiable {
let id: Int let id: Int
let name: String let name: String
let address: String let address: String
@@ -65,51 +148,130 @@ struct APIFacility: Decodable, Identifiable {
let projectId: Int? let projectId: Int?
let projectName: String? let projectName: String?
let isActive: Bool 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 id: Int
let facilityId: Int let facilityId: Int
let name: String let name: String
let areaType: 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 // 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 templates: [APITemplateSummary]
let count: Int 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 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 { // Inspections
let id: Int
let name: String
let description: String
let frequency: String
}
struct APITemplate: Decodable, Identifiable { struct APIInspectionSummary: Decodable, Identifiable, Sendable {
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 {
let id: Int let id: Int
let templateId: Int let templateId: Int
let templateName: String let templateName: String
@@ -127,27 +289,41 @@ struct APIInspectionSummary: Decodable, Identifiable {
guard let str = inspectionDate else { return nil } guard let str = inspectionDate else { return nil }
return ISO8601DateFormatter().date(from: str) return ISO8601DateFormatter().date(from: str)
} }
}
// AnyDecodable helper nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
struct AnyDecodable: Decodable { id = try c.decode(Int.self, forKey: .id)
let value: Any templateId = try c.decode(Int.self, forKey: .templateId)
templateName = try c.decode(String.self, forKey: .templateName)
init(_ value: Any) { self.value = value } facilityId = try c.decode(Int.self, forKey: .facilityId)
facilityName = try c.decode(String.self, forKey: .facilityName)
init(from decoder: Decoder) throws { areaId = try? c.decode(Int.self, forKey: .areaId)
let container = try decoder.singleValueContainer() areaName = try? c.decode(String.self, forKey: .areaName)
if let bool = try? container.decode(Bool.self) { value = bool; return } status = try c.decode(String.self, forKey: .status)
if let int = try? container.decode(Int.self) { value = int; return } overallScore = try? c.decode(Double.self, forKey: .overallScore)
if let dbl = try? container.decode(Double.self) { value = dbl; return } inspectionDate = try? c.decode(String.self, forKey: .inspectionDate)
if let str = try? container.decode(String.self) { value = str; return } completedAt = try? c.decode(String.self, forKey: .completedAt)
if let arr = try? container.decode([AnyDecodable].self) { mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId)
value = arr.map(\.value); return
} }
if let dict = try? container.decode([String: AnyDecodable].self) { private enum CodingKeys: String, CodingKey {
value = dict.mapValues(\.value); return case id, templateId, templateName, facilityId, facilityName
} case areaId, areaName, status, overallScore
value = NSNull() 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 }
}
+4 -34
View File
@@ -1,20 +1,16 @@
// Auth/AuthManager.swift // 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 Foundation
import SwiftUI import SwiftUI
import Combine import Combine
struct EmptyDecodable: Decodable, Sendable {
nonisolated init(from decoder: any Decoder) throws {}
}
@MainActor @MainActor
class AuthManager: ObservableObject { class AuthManager: ObservableObject {
// Published State
@Published var isAuthenticated = false @Published var isAuthenticated = false
@Published var isLoading = false @Published var isLoading = false
@Published var errorMessage: String? @Published var errorMessage: String?
@@ -24,21 +20,14 @@ class AuthManager: ObservableObject {
@Published var currentUserRole: String = "" @Published var currentUserRole: String = ""
@Published var currentDisplayName: String = "" @Published var currentDisplayName: String = ""
// Singleton
static let shared = AuthManager() static let shared = AuthManager()
private init() {} 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 { func restoreSession() async {
guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else { guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else {
isAuthenticated = false isAuthenticated = false
return return
} }
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }
@@ -47,19 +36,14 @@ class AuthManager: ObservableObject {
applyUser(response.user) applyUser(response.user)
isAuthenticated = true isAuthenticated = true
} catch APIError.notAuthenticated { } catch APIError.notAuthenticated {
// Refresh failed (APIClient tried automatically) need fresh login
KeychainHelper.clearAll() KeychainHelper.clearAll()
isAuthenticated = false isAuthenticated = false
} catch { } catch {
// Network error still show as authenticated using cached Keychain data
// so the inspector can work offline
restoreUserFromKeychain() restoreUserFromKeychain()
isAuthenticated = true isAuthenticated = true
} }
} }
// Login
func login(username: String, password: String) async { func login(username: String, password: String) async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
@@ -70,14 +54,10 @@ class AuthManager: ObservableObject {
"/api/v1/auth/login", "/api/v1/auth/login",
body: ["username": username, "password": password] body: ["username": username, "password": password]
) )
// Store tokens in Keychain
KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken) KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken) KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
applyUser(response.user) applyUser(response.user)
isAuthenticated = true isAuthenticated = true
} catch APIError.serverError(let msg) { } catch APIError.serverError(let msg) {
errorMessage = msg errorMessage = msg
} catch APIError.networkError { } catch APIError.networkError {
@@ -87,17 +67,13 @@ class AuthManager: ObservableObject {
} }
} }
// Logout
func logout() async { func logout() async {
// Best-effort server-side logout (revokes refresh token)
if let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken) { if let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken) {
_ = try? await APIClient.shared.post( _ = try? await APIClient.shared.post(
"/api/v1/auth/logout", "/api/v1/auth/logout",
body: ["refresh_token": refreshToken] body: ["refresh_token": refreshToken]
) as EmptyDecodable ) as EmptyDecodable
} }
KeychainHelper.clearAll() KeychainHelper.clearAll()
isAuthenticated = false isAuthenticated = false
currentUserId = 0 currentUserId = 0
@@ -106,14 +82,11 @@ class AuthManager: ObservableObject {
currentDisplayName = "" currentDisplayName = ""
} }
// Helpers
private func applyUser(_ user: APIUser) { private func applyUser(_ user: APIUser) {
currentUserId = user.id currentUserId = user.id
currentUsername = user.username currentUsername = user.username
currentUserRole = user.role currentUserRole = user.role
currentDisplayName = user.displayName currentDisplayName = user.displayName
KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId) KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId)
KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole) KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole)
KeychainHelper.set(user.username, forKey: Constants.Keychain.username) KeychainHelper.set(user.username, forKey: Constants.Keychain.username)
@@ -127,6 +100,3 @@ class AuthManager: ObservableObject {
currentDisplayName = KeychainHelper.get(Constants.Keychain.displayName) ?? "" currentDisplayName = KeychainHelper.get(Constants.Keychain.displayName) ?? ""
} }
} }
// Used for logout response decoding (empty data field)
struct EmptyDecodable: Decodable {}
+8 -7
View File
@@ -3,8 +3,10 @@
// Thin wrapper around iOS Security framework for storing sensitive data // Thin wrapper around iOS Security framework for storing sensitive data
// (tokens) in the device Keychain. // (tokens) in the device Keychain.
// //
// The Keychain persists across app reinstalls (on the same device) and // All methods are marked nonisolated so they can be called from any
// is encrypted by the OS. Never store tokens in UserDefaults. // actor context (including APIClient which is an actor) without
// Swift 6 actor-isolation warnings.
// Security framework calls are internally thread-safe.
import Foundation import Foundation
import Security import Security
@@ -15,10 +17,9 @@ enum KeychainHelper {
// Write // Write
@discardableResult @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 } guard let data = value.data(using: .utf8) else { return false }
// Delete any existing entry first to avoid duplicate-item errors
let deleteQuery: [String: Any] = [ let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
@@ -37,7 +38,7 @@ enum KeychainHelper {
// Read // Read
static func get(_ key: String) -> String? { nonisolated static func get(_ key: String) -> String? {
let query: [String: Any] = [ let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
@@ -56,7 +57,7 @@ enum KeychainHelper {
// Delete // Delete
@discardableResult @discardableResult
static func delete(_ key: String) -> Bool { nonisolated static func delete(_ key: String) -> Bool {
let query: [String: Any] = [ let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
@@ -67,7 +68,7 @@ enum KeychainHelper {
// Delete All JQC Keys // Delete All JQC Keys
static func clearAll() { nonisolated static func clearAll() {
let keys = [ let keys = [
Constants.Keychain.accessToken, Constants.Keychain.accessToken,
Constants.Keychain.refreshToken, Constants.Keychain.refreshToken,
+7 -8
View File
@@ -1,7 +1,7 @@
// Models/LocalTemplate.swift // Models/LocalTemplate.swift
// -------------------------- // --------------------------
// SwiftData model for locally cached inspection templates. // 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 Foundation
import SwiftData import SwiftData
@@ -12,7 +12,6 @@ final class LocalTemplate {
var name: String var name: String
var templateDescription: String var templateDescription: String
var frequency: String var frequency: String
/// Raw JSON string of the form_schema array decoded on demand
var formSchemaJSON: String var formSchemaJSON: String
var lastSyncedAt: Date var lastSyncedAt: Date
@@ -33,18 +32,18 @@ final class LocalTemplate {
} }
func updateSchema(from template: APITemplate) { func updateSchema(from template: APITemplate) {
// Re-serialize the form_schema to JSON for local storage // Convert [[String: JSONValue]] JSON string via anyValue bridge
if let data = try? JSONSerialization.data(withJSONObject: template.formSchema.map({ dict in let raw = template.formSchema.map { dict in
dict.mapValues { $0.value } dict.mapValues { $0.anyValue }
})), }
if let data = try? JSONSerialization.data(withJSONObject: raw),
let str = String(data: data, encoding: .utf8) { let str = String(data: data, encoding: .utf8) {
self.formSchemaJSON = str self.formSchemaJSON = str
} }
self.lastSyncedAt = Date() self.lastSyncedAt = Date()
} }
/// Decode the stored JSON string back into an array of field dictionaries. /// Decode stored JSON back into [[String: Any]] for the form renderer
/// Returns an empty array if the JSON is invalid.
var formSchema: [[String: Any]] { 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]] let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
+12 -15
View File
@@ -1,27 +1,24 @@
// Utils/Constants.swift // Utils/Constants.swift
// --------------------- // ---------------------
// Central place for app-wide constants. // 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 import Foundation
enum Constants { // Explicitly not @MainActor these constants must be readable from
// Server // any actor context including APIClient and KeychainHelper.
/// Your JanitorialQC server base URL. No trailing slash. nonisolated enum Constants {
/// Example: "https://your-domain.com"
static let baseURL = "https://jqc1.ltservicesinc.com" static let baseURL = "https://jqc1.ltservicesinc.com"
// Keychain keys nonisolated enum Keychain {
enum Keychain { static let accessToken = "com.jqc.accessToken"
static let accessToken = "com.JanitorialQC.accessToken" static let refreshToken = "com.jqc.refreshToken"
static let refreshToken = "com.JanitorialQC.refreshToken" static let userId = "com.jqc.userId"
static let userId = "com.JanitorialQC.userId" static let userRole = "com.jqc.userRole"
static let userRole = "com.JanitorialQC.userRole" static let username = "com.jqc.username"
static let username = "com.JanitorialQC.username" static let displayName = "com.jqc.displayName"
static let displayName = "com.JanitorialQC.displayName"
} }
// Sync
/// How many minutes before the access token expires to trigger a refresh
static let tokenRefreshBufferMinutes: Double = 5 static let tokenRefreshBufferMinutes: Double = 5
} }