Jul 13 - Update codes to catch up with the web app updates: scheduled inspection and issue's handler

This commit is contained in:
Nguyen Ngo
2026-07-13 14:03:41 -04:00
parent c05f0029fb
commit 20f9a99646
22 changed files with 5800 additions and 18 deletions
@@ -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 }
}