diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 3237e85..37e0178 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -148,11 +148,26 @@ actor APIClient { // ── 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": inspection.formData, + "form_data": sanitisedFormData, "mobile_local_id": inspection.localId, ] if let score = inspection.overallScore { body["overall_score"] = score } diff --git a/JanitorialQC/Auth/LocalInspection.swift b/JanitorialQC/Auth/LocalInspection.swift new file mode 100644 index 0000000..04c5e99 --- /dev/null +++ b/JanitorialQC/Auth/LocalInspection.swift @@ -0,0 +1,185 @@ +// Models/LocalInspection.swift +// ---------------------------- +// SwiftData model for locally stored inspections. +// Created immediately when the inspector starts a new inspection. +// Written entirely offline; synced to server when connectivity returns. + +import Foundation +import SwiftData + +@Model +final class LocalInspection { + + // ── Identity ────────────────────────────────────────────────────────── + /// UUID generated on device — stable local identity and idempotency key + @Attribute(.unique) var localId: String + /// Set by server after successful sync; nil until then + var serverId: Int? + + // ── Foreign keys (server IDs, from cached reference data) ───────────── + var templateServerId: Int + var facilityServerId: Int + var areaServerId: Int? + var inspectorUserId: Int + + // ── Inspection data ──────────────────────────────────────────────────── + /// "draft" | "completed" | "synced" | "sync_failed" + var status: String + /// JSON dict of { field_id: value } — same shape as server form_data + var formDataJSON: String + var inspectorNotes: String + var overallScore: Double? + var inspectionDate: Date + var completedAt: Date? + var createdAt: Date + var lastModifiedAt: Date + + // ── Sync ────────────────────────────────────────────────────────────── + var syncStatus: String // "pending" | "synced" | "failed" + var syncErrorMessage: String? + var syncRetryCount: Int + + // ── Follow-up / re-inspection (populated from server after sync) ─────── + /// Set by director/admin on the web app; signals this inspection needs a follow-up. + @Attribute var followUpRequired: Bool = false + /// Optional note explaining what the follow-up should address. + var followUpNote: String? + /// Server ID of the parent inspection this record is a re-inspection of. + var parentServerId: Int? + /// Local UUID of the parent LocalInspection — set at creation, always available + /// regardless of whether the parent has synced. Used to clear the parent's + /// followUpRequired badge without relying on parentServerId being non-nil. + var parentLocalId: String? + + // ── Relationships ────────────────────────────────────────────────────── + @Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto] + @Relationship(deleteRule: .cascade) var localIssues: [LocalIssue] + + init( + templateServerId: Int, + facilityServerId: Int, + areaServerId: Int?, + inspectorUserId: Int + ) { + self.localId = UUID().uuidString + self.serverId = nil + self.templateServerId = templateServerId + self.facilityServerId = facilityServerId + self.areaServerId = areaServerId + self.inspectorUserId = inspectorUserId + self.status = "draft" + self.formDataJSON = "{}" + self.inspectorNotes = "" + self.overallScore = nil + self.inspectionDate = Date() + self.completedAt = nil + self.createdAt = Date() + self.lastModifiedAt = Date() + self.syncStatus = "pending" + self.syncErrorMessage = nil + self.syncRetryCount = 0 + self.followUpRequired = false + self.followUpNote = nil + self.parentServerId = nil + self.parentLocalId = nil + self.pendingPhotos = [] + self.localIssues = [] + } + + // ── Form data helpers ────────────────────────────────────────────────── + + var formData: [String: Any] { + get { + guard let data = formDataJSON.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return [:] } + return dict + } + set { + if let data = try? JSONSerialization.data(withJSONObject: newValue), + let str = String(data: data, encoding: .utf8) { + formDataJSON = str + lastModifiedAt = Date() + } + } + } + + func setValue(_ value: Any, forFieldId fieldId: String) { + var current = formData + current[fieldId] = value + formData = current + } + + func getValue(forFieldId fieldId: String) -> Any? { + formData[fieldId] + } + + // ── Score calculation (mirrors Python _compute_score_from_form) ──────── + + func computeScore(fromSchema schema: [[String: Any]]) -> Double? { + let scoreable = schema.filter { + ["rating", "checkbox", "radio", "pass_fail"].contains($0["type"] as? String ?? "") + } + guard !scoreable.isEmpty else { return nil } + + var total = 0 + var earned = 0 + + for field in scoreable { + // Resolve field ID to String regardless of whether the JSON encoded + // it as a String or an Int. The web app stores IDs as integers in + // form_schema JSON (e.g. "id": 5); JSONSerialization decodes these + // as Int, not String. The formData dict is keyed by String (the + // ID is always stringified before storage), so the lookup key must + // also be a String. The previous guard-let with Optional.map was + // producing Optional("5") rather than "5", causing all lookups to + // miss when field IDs were integers. + let fid: String + if let s = field["id"] as? String { + fid = s + } else if let n = field["id"] as? Int { + fid = String(n) + } else { + continue // field has no usable id — skip + } + + guard let ftype = field["type"] as? String else { continue } + + let val = formData[fid].map { "\($0)" } ?? "" + + switch ftype { + case "rating": + if let v = Int(val), v > 0 { + earned += v + total += 5 + } + case "checkbox": + total += 1 + if val == "true" { earned += 1 } + case "radio": + total += 1 + if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { + earned += 1 + } + case "pass_fail": + if val.isEmpty { continue } + total += 1 + if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { + earned += 1 + } + default: break + } + } + + guard total > 0 else { return nil } + return (Double(earned) / Double(total) * 100).rounded(toPlaces: 2) + } +} + +// Helper for rounding +extension Double { + func rounded(toPlaces places: Int) -> Double { + let divisor = pow(10.0, Double(places)) + return (self * divisor).rounded() / divisor + } +}