Jul 13 - Update codes to catch up with the web app updates: scheduled inspection and issue's handler
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
// API/APIClient.swift
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
enum APIError: Error, LocalizedError, Sendable {
|
||||
case invalidURL
|
||||
case notAuthenticated
|
||||
case serverError(String)
|
||||
case decodingError(String)
|
||||
case networkError(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid URL."
|
||||
case .notAuthenticated: return "Session expired. Please log in again."
|
||||
case .serverError(let msg): return msg
|
||||
case .decodingError(let msg): return "Data error: \(msg)"
|
||||
case .networkError(let msg): return "Network error: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File-scope envelope — cannot be nested in a generic function (Swift restriction).
|
||||
// T is constrained to Sendable so `data: T?` does not inherit @MainActor isolation.
|
||||
private struct _Envelope<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,
|
||||
fromDate: Date? = nil,
|
||||
toDate: Date? = nil
|
||||
) async throws -> InspectionHistoryResponseData {
|
||||
var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
|
||||
if let fid = facilityId { ep += "&facility_id=\(fid)" }
|
||||
if let d = fromDate { ep += "&from_date=\(Self.apiDateFmt.string(from: d))" }
|
||||
if let d = toDate { ep += "&to_date=\(Self.apiDateFmt.string(from: d))" }
|
||||
return try await request(ep)
|
||||
}
|
||||
|
||||
private static let apiDateFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; f.locale = Locale(identifier: "en_US_POSIX")
|
||||
return f
|
||||
}()
|
||||
|
||||
// ── Photo Upload ──────────────────────────────────────────────────────
|
||||
|
||||
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 }
|
||||
if let lat = inspection.submitLatitude { body["submit_latitude"] = lat }
|
||||
if let lng = inspection.submitLongitude { body["submit_longitude"] = lng }
|
||||
|
||||
// IMPORTANT: timeZone must be explicitly set to UTC.
|
||||
// ISO8601DateFormatter() default timeZone is the DEVICE local timezone,
|
||||
// which produces offset strings like "2026-06-11T10:30:00-04:00".
|
||||
// The server's _parse_datetime() only recognises the Z suffix as UTC;
|
||||
// offset-format strings fail all strptime patterns and return None,
|
||||
// causing the server to fall back to now_eastern() — the sync time —
|
||||
// instead of the actual inspection/completion time.
|
||||
let fmt = ISO8601DateFormatter()
|
||||
fmt.timeZone = TimeZone(identifier: "UTC")!
|
||||
body["inspection_date"] = fmt.string(from: inspection.inspectionDate)
|
||||
if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) }
|
||||
|
||||
struct R: Decodable, Sendable { let inspectionId: Int; let duplicate: Bool }
|
||||
let r: R = try await post("/api/v1/inspections", body: body)
|
||||
return r.inspectionId
|
||||
}
|
||||
|
||||
// ── Submit Issue ──────────────────────────────────────────────────────
|
||||
|
||||
func submitIssue(_ issue: LocalIssue, inspectionServerId: Int?) async throws -> Int {
|
||||
var body: [String: Any] = [
|
||||
"facility_id": issue.facilityServerId,
|
||||
"severity": issue.severity,
|
||||
"description": issue.issueDescription,
|
||||
"mobile_local_id": issue.localId,
|
||||
]
|
||||
// Use the explicitly passed serverId rather than issue.inspection?.serverId.
|
||||
// The ORM relationship object is a separate fetch instance from the one
|
||||
// processInspectionQueue updated, so its serverId is nil even after the
|
||||
// inspection synced in the same triggerSync() pass.
|
||||
if let id = inspectionServerId { body["inspection_id"] = id }
|
||||
if let areaId = issue.areaServerId { body["area_id"] = areaId }
|
||||
// photo_path = primary photo. Additional photos are sent via a
|
||||
// separate PATCH call in processIssueQueue after the issue is created,
|
||||
// because the server create endpoint only stores a single photo_path.
|
||||
if let first = issue.photoServerPaths.first { body["photo_path"] = first }
|
||||
|
||||
struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool }
|
||||
let r: R = try await post("/api/v1/issues", body: body)
|
||||
return r.issueId
|
||||
}
|
||||
|
||||
// ── Attach additional photos to an existing issue ─────────────────────
|
||||
// Called after submitIssue when the issue has more than one photo.
|
||||
// PATCHes /api/v1/issues/{id}/photos with result_photos = [server paths beyond the first].
|
||||
// The create endpoint only stores photo_path (single); extras go here.
|
||||
func updateIssuePhotos(issueId: Int, resultPhotos: [String]) async throws {
|
||||
struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int }
|
||||
let _: R = try await request(
|
||||
"/api/v1/issues/\(issueId)/photos",
|
||||
method: "PATCH",
|
||||
body: ["result_photos": resultPhotos]
|
||||
)
|
||||
}
|
||||
|
||||
// ── Upload a resolution photo (entity_type = issue_result) ────────────
|
||||
// Saves to issue_result_photos subfolder on the server — same bucket
|
||||
// as photos uploaded via the web update form.
|
||||
func uploadResultPhoto(localPath: String, retrying: Bool = false) async throws -> String {
|
||||
let url = try buildURL("/api/v1/photos/upload")
|
||||
|
||||
guard let imageData = FileManager.default.contents(atPath: localPath) else {
|
||||
throw APIError.networkError("Could not read photo: \(localPath)")
|
||||
}
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
let filename = URL(fileURLWithPath: localPath).lastPathComponent
|
||||
let ext = (filename as NSString).pathExtension.lowercased()
|
||||
let mime = ext == "png" ? "image/png" : "image/jpeg"
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\nissue_result\r\n".data(using: .utf8)!)
|
||||
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(imageData)
|
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
injectToken(&req)
|
||||
req.httpBody = body
|
||||
|
||||
let (data, response) = try await performRequest(req)
|
||||
|
||||
if shouldRefresh(response, retrying: retrying) {
|
||||
let refreshed = await refreshAccessToken()
|
||||
if refreshed { return try await uploadResultPhoto(localPath: localPath, 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("Result photo upload failed")
|
||||
}
|
||||
|
||||
// ── Attach resolution photos to an existing issue ─────────────────────
|
||||
// PATCHes /api/v1/issues/{id}/result_photos — writes to Issue.result_photos
|
||||
// (Resolution Details on web), not mobile_photo_paths (Photo Evidence).
|
||||
func updateIssueResultPhotos(issueId: Int, resultPhotos: [String]) async throws {
|
||||
struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int }
|
||||
let _: R = try await request(
|
||||
"/api/v1/issues/\(issueId)/result_photos",
|
||||
method: "PATCH",
|
||||
body: ["result_photos": resultPhotos]
|
||||
)
|
||||
}
|
||||
|
||||
// ── Fetch Issue Detail (status + assigned_to) ─────────────────────────
|
||||
|
||||
func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail {
|
||||
return try await request("/api/v1/issues/\(issueId)")
|
||||
}
|
||||
|
||||
// ── Update Issue Status ───────────────────────────────────────────────
|
||||
|
||||
func updateIssueStatus(issueId: Int, status: String) async throws -> String {
|
||||
let result: APIIssueStatusUpdate = try await request(
|
||||
"/api/v1/issues/\(issueId)/status",
|
||||
method: "PATCH",
|
||||
body: ["status": status]
|
||||
)
|
||||
return result.status
|
||||
}
|
||||
|
||||
// ── Notification polling (Phase C) ────────────────────────────────────
|
||||
|
||||
// Shared formatter for the ?since= query parameter.
|
||||
// DateFormatter init is expensive — creating one per fetchNotifications()
|
||||
// call (every 60 seconds) adds unnecessary allocations on the sync cycle.
|
||||
private static let notifSinceFmt: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Fetch notifications, optionally scoped to those created after `since`.
|
||||
func fetchNotifications(since: Date? = nil) async throws -> [APINotification] {
|
||||
var ep = "/api/v1/notifications"
|
||||
if let since {
|
||||
ep += "?since=\(Self.notifSinceFmt.string(from: since))"
|
||||
}
|
||||
let result: APINotificationsResponseData = try await request(ep)
|
||||
return result.notifications
|
||||
}
|
||||
|
||||
/// Mark the given notification IDs as read on the server.
|
||||
func markNotificationsRead(ids: [Int]) async throws {
|
||||
guard !ids.isEmpty else { return }
|
||||
let _: APIMarkReadResponseData = try await request(
|
||||
"/api/v1/notifications/mark-read",
|
||||
method: "PATCH",
|
||||
body: ["ids": ids]
|
||||
)
|
||||
}
|
||||
|
||||
/// Fetch issues assigned to the current user from the server.
|
||||
func fetchAssignedIssues() async throws -> [APIAssignedIssue] {
|
||||
let result: APIAssignedIssuesResponseData = try await request("/api/v1/issues")
|
||||
return result.issues
|
||||
}
|
||||
|
||||
// ── Scheduled Inspections (phase36) ───────────────────────────────────
|
||||
|
||||
func fetchScheduledInspections() async throws -> [APIScheduledInspection] {
|
||||
let result: APIScheduledInspectionsResponseData =
|
||||
try await request("/api/v1/scheduled-inspections")
|
||||
return result.scheduled
|
||||
}
|
||||
|
||||
// ── Issue Handler ("Handled By") ──────────────────────────────────────
|
||||
|
||||
/// Set who handles an issue. `details` carries any of the optional
|
||||
/// facility_handler_* / vendor_* fields; only keys present are updated.
|
||||
func updateIssueHandler(
|
||||
issueId: Int,
|
||||
handlerType: String,
|
||||
details: [String: String] = [:]
|
||||
) async throws -> String {
|
||||
var body: [String: Any] = ["handler_type": handlerType]
|
||||
for (k, v) in details { body[k] = v }
|
||||
let result: APIIssueHandlerUpdate = try await request(
|
||||
"/api/v1/issues/\(issueId)/handler",
|
||||
method: "PATCH",
|
||||
body: body
|
||||
)
|
||||
return result.handlerType
|
||||
}
|
||||
|
||||
/// Fetch dashboard KPI counts for the current user (Phase B).
|
||||
func fetchDashboardStats() async throws -> APIDashboardStats {
|
||||
return try await request("/api/v1/stats/dashboard")
|
||||
}
|
||||
|
||||
// ── Device Registration ───────────────────────────────────────────────
|
||||
// Called on every app foreground (active scenePhase) when authenticated.
|
||||
// Upserts a device_registrations row on the server so the admin can see
|
||||
// all installed devices and their versions.
|
||||
// Errors are suppressed — device registration is best-effort and must
|
||||
// never block the normal app launch flow.
|
||||
|
||||
/// Returns or creates a stable device UUID, persisted in Keychain so it
|
||||
/// survives app restarts but is unique per physical device.
|
||||
nonisolated static func stableDeviceId() -> String {
|
||||
if let existing = KeychainHelper.get(Constants.Keychain.deviceId) {
|
||||
return existing
|
||||
}
|
||||
let new = UUID().uuidString
|
||||
KeychainHelper.set(new, forKey: Constants.Keychain.deviceId)
|
||||
return new
|
||||
}
|
||||
|
||||
func registerDevice() async {
|
||||
guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else { return }
|
||||
|
||||
let deviceId = Self.stableDeviceId()
|
||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
// UIDevice.current is @MainActor — read on MainActor then pass as plain Strings
|
||||
let (deviceName, iosVersion): (String, String) = await MainActor.run {
|
||||
(UIDevice.current.name, UIDevice.current.systemVersion)
|
||||
}
|
||||
|
||||
let body: [String: Any] = [
|
||||
"device_id": deviceId,
|
||||
"device_name": deviceName,
|
||||
"app_version": appVersion,
|
||||
"ios_version": iosVersion,
|
||||
]
|
||||
|
||||
do {
|
||||
// nonisolated init required — SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor
|
||||
// taints synthesised Decodable inits (CLAUDE.md rule 29).
|
||||
struct R: Decodable, Sendable {
|
||||
let registered: Bool
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
registered = try c.decode(Bool.self, forKey: .registered)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case registered }
|
||||
}
|
||||
let _: R = try await request("/api/v1/devices/register", method: "POST", body: body)
|
||||
print("[JQC] registerDevice succeeded")
|
||||
} catch {
|
||||
// Log raw response to diagnose server-side failures
|
||||
if let url = URL(string: ServerConfig.current + "/api/v1/devices/register"),
|
||||
let token = KeychainHelper.get(Constants.Keychain.accessToken) {
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
if let (data, resp) = try? await URLSession.shared.data(for: req) {
|
||||
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let raw = String(data: data, encoding: .utf8) ?? "<binary>"
|
||||
print("[JQC] registerDevice HTTP \(status): \(raw)")
|
||||
}
|
||||
}
|
||||
print("[JQC] registerDevice error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issue Comments (Phase D) ──────────────────────────────────────────
|
||||
|
||||
/// Fetch all comments for an issue, oldest-first.
|
||||
func fetchIssueComments(issueId: Int) async throws -> [APIIssueComment] {
|
||||
let result: APIIssueCommentsResponseData = try await request(
|
||||
"/api/v1/issues/\(issueId)/comments"
|
||||
)
|
||||
return result.comments
|
||||
}
|
||||
|
||||
/// Post a new comment on an issue. Returns the new comment ID.
|
||||
func postIssueComment(issueId: Int, body: String) async throws -> Int {
|
||||
let result: APIAddCommentResponseData = try await request(
|
||||
"/api/v1/issues/\(issueId)/comments",
|
||||
method: "POST",
|
||||
body: ["body": body]
|
||||
)
|
||||
return result.commentId
|
||||
}
|
||||
|
||||
// ── Token Refresh ─────────────────────────────────────────────────────
|
||||
|
||||
private func refreshAccessToken() async -> Bool {
|
||||
guard let token = KeychainHelper.get(Constants.Keychain.refreshToken),
|
||||
let url = URL(string: ServerConfig.current + "/api/v1/auth/refresh")
|
||||
else { return false }
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: ["refresh_token": token])
|
||||
|
||||
guard let (data, response) = try? await session.data(for: req),
|
||||
let http = response as? HTTPURLResponse, http.statusCode == 200
|
||||
else { return false }
|
||||
|
||||
// Use a local decoder — avoids referencing the actor-isolated self.decoder
|
||||
// which would trigger a Swift 6 main-actor isolation error.
|
||||
let localDecoder = JSONDecoder()
|
||||
localDecoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
guard let env = try? localDecoder.decode(_RefreshEnvelope.self, from: data),
|
||||
env.ok,
|
||||
let tokens = env.data
|
||||
else { return false }
|
||||
|
||||
KeychainHelper.set(tokens.accessToken, forKey: Constants.Keychain.accessToken)
|
||||
KeychainHelper.set(tokens.refreshToken, forKey: Constants.Keychain.refreshToken)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Private Helpers ───────────────────────────────────────────────────
|
||||
|
||||
private func buildURL(_ endpoint: String) throws -> URL {
|
||||
guard let url = URL(string: ServerConfig.current + endpoint) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func buildRequest(url: URL, method: String, body: [String: Any]?) -> URLRequest {
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
if let body { req.httpBody = try? JSONSerialization.data(withJSONObject: body) }
|
||||
return req
|
||||
}
|
||||
|
||||
private func injectToken(_ req: inout URLRequest) {
|
||||
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
private func performRequest(_ req: URLRequest) async throws -> (Data, URLResponse) {
|
||||
do {
|
||||
return try await session.data(for: req)
|
||||
} catch {
|
||||
throw APIError.networkError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRefresh(_ response: URLResponse, retrying: Bool) -> Bool {
|
||||
guard !retrying, let http = response as? HTTPURLResponse else { return false }
|
||||
return http.statusCode == 401
|
||||
}
|
||||
|
||||
private func decode<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
// API/APIModels.swift
|
||||
// -------------------
|
||||
// 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<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 }
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct APIUser: Decodable, Sendable {
|
||||
let id: Int
|
||||
let username: String
|
||||
let fullName: String // empty string when not set; never nil
|
||||
let email: String
|
||||
let role: String
|
||||
let createdAt: String?
|
||||
/// Returns full_name when set, otherwise falls back to username.
|
||||
/// Mirrors User.display_name on the server.
|
||||
var displayName: String { fullName.isEmpty ? username : fullName }
|
||||
|
||||
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)
|
||||
fullName = (try? c.decode(String.self, forKey: .fullName)) ?? ""
|
||||
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, fullName, 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 APIFacility: Decodable, Identifiable, Sendable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let address: String
|
||||
let contactPerson: String
|
||||
let contactPhone: String
|
||||
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 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 APITemplateSummary: Decodable, Identifiable, Sendable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let description: String
|
||||
let frequency: 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)
|
||||
description = try c.decode(String.self, forKey: .description)
|
||||
frequency = try c.decode(String.self, forKey: .frequency)
|
||||
// Default true — backwards compatible if server omits the field
|
||||
isActive = (try? c.decode(Bool.self, forKey: .isActive)) ?? true
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case id, name, description, frequency, isActive }
|
||||
}
|
||||
|
||||
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 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 }
|
||||
}
|
||||
|
||||
// ── Inspections ───────────────────────────────────────────────────────────────
|
||||
|
||||
struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
|
||||
let id: Int
|
||||
let templateId: Int
|
||||
let templateName: String
|
||||
let facilityId: Int
|
||||
let facilityName: String
|
||||
let areaId: Int?
|
||||
let areaName: String?
|
||||
let status: String
|
||||
let overallScore: Double?
|
||||
let inspectionDate: String?
|
||||
let completedAt: String?
|
||||
let mobileLocalId: String?
|
||||
let inspectorNotes: String
|
||||
// Form responses and schema — included in every history response so the
|
||||
// detail view works without a local SwiftData copy (e.g. after reinstall).
|
||||
let formDataRaw: [String: JSONValue]
|
||||
let formSchemaRaw: [[String: JSONValue]]
|
||||
// ── Follow-up / re-inspection ─────────────────────────────────────────
|
||||
let followUpRequired: Bool
|
||||
let followUpNote: String?
|
||||
let parentInspectionId: Int?
|
||||
|
||||
/// Form field values as [fieldId: stringValue] for the grid renderer.
|
||||
var formValues: [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
for (k, v) in formDataRaw {
|
||||
switch v {
|
||||
case .string(let s): result[k] = s
|
||||
case .int(let n): result[k] = String(n)
|
||||
case .double(let d): result[k] = String(d)
|
||||
case .bool(let b): result[k] = b ? "true" : "false"
|
||||
case .array(let a): result[k] = a.map { "\($0.anyValue)" }.joined(separator: ", ")
|
||||
case .null: result[k] = ""
|
||||
case .object: result[k] = ""
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Form schema as [[String: Any]] for ReadOnlyGridFormView.
|
||||
var formSchema: [[String: Any]] {
|
||||
formSchemaRaw.map { dict in dict.mapValues { $0.anyValue } }
|
||||
}
|
||||
|
||||
var inspectionDateParsed: Date? {
|
||||
guard let str = inspectionDate else { return nil }
|
||||
// Server sends "yyyy-MM-dd'T'HH:mm:ss" with no timezone suffix.
|
||||
// ISO8601DateFormatter() requires a timezone by default and returns nil
|
||||
// for timezone-less strings — use the shared DateFormatter instead.
|
||||
return SyncManager.isoFormatter.date(from: str)
|
||||
}
|
||||
|
||||
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)
|
||||
inspectorNotes = (try? c.decode(String.self, forKey: .inspectorNotes)) ?? ""
|
||||
formDataRaw = (try? c.decode([String: JSONValue].self, forKey: .formData)) ?? [:]
|
||||
formSchemaRaw = (try? c.decode([[String: JSONValue]].self, forKey: .formSchema)) ?? []
|
||||
followUpRequired = (try? c.decode(Bool.self, forKey: .followUpRequired)) ?? false
|
||||
followUpNote = try? c.decode(String.self, forKey: .followUpNote)
|
||||
parentInspectionId = try? c.decode(Int.self, forKey: .parentInspectionId)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, templateId, templateName, facilityId, facilityName
|
||||
case areaId, areaName, status, overallScore
|
||||
case inspectionDate, completedAt, mobileLocalId, inspectorNotes
|
||||
case formData, formSchema
|
||||
case followUpRequired, followUpNote, parentInspectionId
|
||||
}
|
||||
|
||||
// Explicit Hashable — formDataRaw/formSchemaRaw contain JSONValue which
|
||||
// has no Hashable conformance; identity is determined by server id alone.
|
||||
static func == (lhs: APIInspectionSummary, rhs: APIInspectionSummary) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
// ── Issue Detail ──────────────────────────────────────────────────────────────
|
||||
|
||||
struct APIIssueDetail: Decodable, Sendable {
|
||||
let id: Int
|
||||
let status: String
|
||||
let severity: String
|
||||
let description: String
|
||||
let assignedTo: Int?
|
||||
let facilityId: Int?
|
||||
let facilityName: String?
|
||||
let reportedAt: String?
|
||||
let resolvedAt: String?
|
||||
// Phase A — resolution details from web
|
||||
let resultNotes: String?
|
||||
let verifiedAt: String?
|
||||
let verificationNote: String?
|
||||
let reportedByName: String?
|
||||
// Resolution photos uploaded via web or mobile resolve flow
|
||||
let resultPhotos: [String]
|
||||
// Phase E — area and assignee context
|
||||
let areaName: String?
|
||||
let assignedToName: String?
|
||||
// Handler ("Handled By", phase35)
|
||||
let handlerType: String?
|
||||
let handlerLabel: String?
|
||||
let facilityHandlerName: String?
|
||||
let facilityHandlerContact: String?
|
||||
let facilityHandlerNotes: String?
|
||||
let vendorName: String?
|
||||
let vendorContact: String?
|
||||
let vendorNotes: String?
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
status = try c.decode(String.self, forKey: .status)
|
||||
severity = try c.decode(String.self, forKey: .severity)
|
||||
description = try c.decode(String.self, forKey: .description)
|
||||
assignedTo = try? c.decode(Int.self, forKey: .assignedTo)
|
||||
facilityId = try? c.decode(Int.self, forKey: .facilityId)
|
||||
facilityName = try? c.decode(String.self, forKey: .facilityName)
|
||||
reportedAt = try? c.decode(String.self, forKey: .reportedAt)
|
||||
resolvedAt = try? c.decode(String.self, forKey: .resolvedAt)
|
||||
resultNotes = try? c.decode(String.self, forKey: .resultNotes)
|
||||
verifiedAt = try? c.decode(String.self, forKey: .verifiedAt)
|
||||
verificationNote = try? c.decode(String.self, forKey: .verificationNote)
|
||||
reportedByName = try? c.decode(String.self, forKey: .reportedByName)
|
||||
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
|
||||
areaName = try? c.decode(String.self, forKey: .areaName)
|
||||
assignedToName = try? c.decode(String.self, forKey: .assignedToName)
|
||||
handlerType = try? c.decode(String.self, forKey: .handlerType)
|
||||
handlerLabel = try? c.decode(String.self, forKey: .handlerLabel)
|
||||
facilityHandlerName = try? c.decode(String.self, forKey: .facilityHandlerName)
|
||||
facilityHandlerContact = try? c.decode(String.self, forKey: .facilityHandlerContact)
|
||||
facilityHandlerNotes = try? c.decode(String.self, forKey: .facilityHandlerNotes)
|
||||
vendorName = try? c.decode(String.self, forKey: .vendorName)
|
||||
vendorContact = try? c.decode(String.self, forKey: .vendorContact)
|
||||
vendorNotes = try? c.decode(String.self, forKey: .vendorNotes)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, status, severity, description, assignedTo
|
||||
case facilityId, facilityName, reportedAt, resolvedAt
|
||||
case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos
|
||||
case areaName, assignedToName
|
||||
case handlerType, handlerLabel
|
||||
case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes
|
||||
case vendorName, vendorContact, vendorNotes
|
||||
}
|
||||
}
|
||||
|
||||
struct APIIssueStatusUpdate: Decodable, Sendable {
|
||||
let issueId: Int
|
||||
let status: String
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
issueId = try c.decode(Int.self, forKey: .issueId)
|
||||
status = try c.decode(String.self, forKey: .status)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case issueId, status }
|
||||
}
|
||||
|
||||
struct APIIssueHandlerUpdate: Decodable, Sendable {
|
||||
let issueId: Int
|
||||
let handlerType: String
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
issueId = try c.decode(Int.self, forKey: .issueId)
|
||||
handlerType = try c.decode(String.self, forKey: .handlerType)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case issueId, handlerType }
|
||||
}
|
||||
|
||||
// ── Notification polling (Phase C) ────────────────────────────────────────────
|
||||
|
||||
struct APINotification: Decodable, Identifiable, Sendable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let body: String
|
||||
let eventType: String?
|
||||
let issueId: Int?
|
||||
let createdAt: String
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
title = try c.decode(String.self, forKey: .title)
|
||||
body = try c.decode(String.self, forKey: .body)
|
||||
eventType = try? c.decode(String.self, forKey: .eventType)
|
||||
issueId = try? c.decode(Int.self, forKey: .issueId)
|
||||
createdAt = try c.decode(String.self, forKey: .createdAt)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, title, body, eventType, issueId, createdAt
|
||||
}
|
||||
}
|
||||
|
||||
struct APINotificationsResponseData: Decodable, Sendable {
|
||||
let notifications: [APINotification]
|
||||
let count: Int
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
notifications = try c.decode([APINotification].self, forKey: .notifications)
|
||||
count = try c.decode(Int.self, forKey: .count)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case notifications, count }
|
||||
}
|
||||
|
||||
struct APIMarkReadResponseData: Decodable, Sendable {
|
||||
let marked: Int
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
marked = try c.decode(Int.self, forKey: .marked)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case marked }
|
||||
}
|
||||
|
||||
// ── Assigned issues list (Phase C) ────────────────────────────────────────────
|
||||
|
||||
struct APIAssignedIssue: Decodable, Identifiable, Sendable {
|
||||
let id: Int
|
||||
let status: String
|
||||
let severity: String
|
||||
let description: String
|
||||
let assignedTo: Int?
|
||||
let facilityId: Int?
|
||||
let facilityName: String?
|
||||
let reportedAt: String?
|
||||
let mobileLocalId: String?
|
||||
let photoPath: String? // primary evidence photo
|
||||
let mobilePhotoPaths: [String] // extra evidence photos from iPad
|
||||
let resultPhotos: [String] // resolution photos added via web
|
||||
// Phase A — resolution details from web
|
||||
let resultNotes: String?
|
||||
let verifiedAt: String?
|
||||
let verificationNote: String?
|
||||
let reportedByName: String?
|
||||
// Phase E — area and assignee context
|
||||
let areaName: String?
|
||||
let assignedToName: String?
|
||||
// Handler ("Handled By", phase35) — who resolves the issue
|
||||
let handlerType: String? // "internal" | "facility" | "vendor"
|
||||
let handlerLabel: String? // human-readable label
|
||||
let facilityHandlerName: String?
|
||||
let facilityHandlerContact: String?
|
||||
let facilityHandlerNotes: String?
|
||||
let vendorName: String?
|
||||
let vendorContact: String?
|
||||
let vendorNotes: String?
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
status = try c.decode(String.self, forKey: .status)
|
||||
severity = try c.decode(String.self, forKey: .severity)
|
||||
description = try c.decode(String.self, forKey: .description)
|
||||
assignedTo = try? c.decode(Int.self, forKey: .assignedTo)
|
||||
facilityId = try? c.decode(Int.self, forKey: .facilityId)
|
||||
facilityName = try? c.decode(String.self, forKey: .facilityName)
|
||||
reportedAt = try? c.decode(String.self, forKey: .reportedAt)
|
||||
mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId)
|
||||
photoPath = try? c.decode(String.self, forKey: .photoPath)
|
||||
mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? []
|
||||
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
|
||||
resultNotes = try? c.decode(String.self, forKey: .resultNotes)
|
||||
verifiedAt = try? c.decode(String.self, forKey: .verifiedAt)
|
||||
verificationNote = try? c.decode(String.self, forKey: .verificationNote)
|
||||
reportedByName = try? c.decode(String.self, forKey: .reportedByName)
|
||||
areaName = try? c.decode(String.self, forKey: .areaName)
|
||||
assignedToName = try? c.decode(String.self, forKey: .assignedToName)
|
||||
handlerType = try? c.decode(String.self, forKey: .handlerType)
|
||||
handlerLabel = try? c.decode(String.self, forKey: .handlerLabel)
|
||||
facilityHandlerName = try? c.decode(String.self, forKey: .facilityHandlerName)
|
||||
facilityHandlerContact = try? c.decode(String.self, forKey: .facilityHandlerContact)
|
||||
facilityHandlerNotes = try? c.decode(String.self, forKey: .facilityHandlerNotes)
|
||||
vendorName = try? c.decode(String.self, forKey: .vendorName)
|
||||
vendorContact = try? c.decode(String.self, forKey: .vendorContact)
|
||||
vendorNotes = try? c.decode(String.self, forKey: .vendorNotes)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, status, severity, description, assignedTo
|
||||
case facilityId, facilityName, reportedAt, mobileLocalId
|
||||
case photoPath, mobilePhotoPaths, resultPhotos
|
||||
case resultNotes, verifiedAt, verificationNote, reportedByName
|
||||
case areaName, assignedToName
|
||||
case handlerType, handlerLabel
|
||||
case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes
|
||||
case vendorName, vendorContact, vendorNotes
|
||||
}
|
||||
}
|
||||
|
||||
struct APIAssignedIssuesResponseData: Decodable, Sendable {
|
||||
let issues: [APIAssignedIssue]
|
||||
let total: Int
|
||||
let limit: Int
|
||||
let offset: Int
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
issues = try c.decode([APIAssignedIssue].self, forKey: .issues)
|
||||
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 issues, total, limit, offset }
|
||||
}
|
||||
|
||||
// ── Scheduled Inspections (phase36 — planned/recurring assignments) ───────────
|
||||
|
||||
struct APIScheduledInspection: Decodable, Identifiable, Sendable {
|
||||
let id: Int
|
||||
let facilityId: Int
|
||||
let facilityName: String?
|
||||
let templateId: Int
|
||||
let templateName: String?
|
||||
let inspectorId: Int?
|
||||
let frequency: String
|
||||
let frequencyLabel: String?
|
||||
let nextDueDate: String? // ISO date "YYYY-MM-DD"
|
||||
let isOverdue: Bool
|
||||
let notes: 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)
|
||||
facilityName = try? c.decode(String.self, forKey: .facilityName)
|
||||
templateId = try c.decode(Int.self, forKey: .templateId)
|
||||
templateName = try? c.decode(String.self, forKey: .templateName)
|
||||
inspectorId = try? c.decode(Int.self, forKey: .inspectorId)
|
||||
frequency = (try? c.decode(String.self, forKey: .frequency)) ?? "once"
|
||||
frequencyLabel = try? c.decode(String.self, forKey: .frequencyLabel)
|
||||
nextDueDate = try? c.decode(String.self, forKey: .nextDueDate)
|
||||
isOverdue = (try? c.decode(Bool.self, forKey: .isOverdue)) ?? false
|
||||
notes = try? c.decode(String.self, forKey: .notes)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, facilityId, facilityName, templateId, templateName
|
||||
case inspectorId, frequency, frequencyLabel, nextDueDate, isOverdue, notes
|
||||
}
|
||||
}
|
||||
|
||||
struct APIScheduledInspectionsResponseData: Decodable, Sendable {
|
||||
let scheduled: [APIScheduledInspection]
|
||||
let total: Int
|
||||
let limit: Int
|
||||
let offset: Int
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
scheduled = try c.decode([APIScheduledInspection].self, forKey: .scheduled)
|
||||
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 scheduled, total, limit, offset }
|
||||
}
|
||||
|
||||
// ── Dashboard Stats (Phase B) ─────────────────────────────────────────────────
|
||||
|
||||
struct APIDashboardStats: Decodable, Sendable {
|
||||
let todayInspections: Int
|
||||
let completedToday: Int
|
||||
let openIssues: Int
|
||||
let avgScore30d: Double?
|
||||
let pendingFollowups: Int
|
||||
let slaBreached: Int
|
||||
let slaAtRisk: Int
|
||||
// Phase E — severity breakdown of open issues
|
||||
let severityCritical: Int
|
||||
let severityHigh: Int
|
||||
let severityMedium: Int
|
||||
let severityLow: Int
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
todayInspections = (try? c.decode(Int.self, forKey: .todayInspections)) ?? 0
|
||||
completedToday = (try? c.decode(Int.self, forKey: .completedToday)) ?? 0
|
||||
openIssues = (try? c.decode(Int.self, forKey: .openIssues)) ?? 0
|
||||
avgScore30d = try? c.decode(Double.self, forKey: .avgScore30d)
|
||||
pendingFollowups = (try? c.decode(Int.self, forKey: .pendingFollowups)) ?? 0
|
||||
slaBreached = (try? c.decode(Int.self, forKey: .slaBreached)) ?? 0
|
||||
slaAtRisk = (try? c.decode(Int.self, forKey: .slaAtRisk)) ?? 0
|
||||
// Decode from nested severity_breakdown dict
|
||||
if let breakdown = try? c.decode([String: Int].self, forKey: .severityBreakdown) {
|
||||
severityCritical = breakdown["critical"] ?? 0
|
||||
severityHigh = breakdown["high"] ?? 0
|
||||
severityMedium = breakdown["medium"] ?? 0
|
||||
severityLow = breakdown["low"] ?? 0
|
||||
} else {
|
||||
severityCritical = 0
|
||||
severityHigh = 0
|
||||
severityMedium = 0
|
||||
severityLow = 0
|
||||
}
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case todayInspections, completedToday, openIssues
|
||||
case avgScore30d, pendingFollowups, slaBreached, slaAtRisk
|
||||
case severityBreakdown
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issue Comments (Phase D) ──────────────────────────────────────────────────
|
||||
|
||||
struct APIIssueComment: Decodable, Identifiable, Sendable {
|
||||
let id: Int
|
||||
let issueId: Int
|
||||
let authorName: String
|
||||
let authorRole: String
|
||||
let statusAtTime: String
|
||||
let body: String
|
||||
let createdAt: String
|
||||
|
||||
var createdAtDate: Date? {
|
||||
SyncManager.isoFormatter.date(from: createdAt)
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
issueId = try c.decode(Int.self, forKey: .issueId)
|
||||
authorName = (try? c.decode(String.self, forKey: .authorName)) ?? "Unknown"
|
||||
authorRole = (try? c.decode(String.self, forKey: .authorRole)) ?? ""
|
||||
statusAtTime = (try? c.decode(String.self, forKey: .statusAtTime)) ?? ""
|
||||
body = try c.decode(String.self, forKey: .body)
|
||||
createdAt = try c.decode(String.self, forKey: .createdAt)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, issueId, authorName, authorRole, statusAtTime, body, createdAt
|
||||
}
|
||||
}
|
||||
|
||||
struct APIIssueCommentsResponseData: Decodable, Sendable {
|
||||
let issueId: Int
|
||||
let comments: [APIIssueComment]
|
||||
let count: Int
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
issueId = try c.decode(Int.self, forKey: .issueId)
|
||||
comments = try c.decode([APIIssueComment].self, forKey: .comments)
|
||||
count = try c.decode(Int.self, forKey: .count)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case issueId, comments, count }
|
||||
}
|
||||
|
||||
struct APIAddCommentResponseData: Decodable, Sendable {
|
||||
let commentId: Int
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
commentId = try c.decode(Int.self, forKey: .commentId)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case commentId }
|
||||
}
|
||||
Reference in New Issue
Block a user