// Models/LocalFollowUpRequest.swift // --------------------------------- // SwiftData model for follow-up requests raised by a director/admin on the web // app against an inspection this inspector already completed. // // Read-only reference data pulled from the server // (GET /api/v1/inspections?follow_up_required=true) and refreshed by // SyncManager.pullFollowUpRequests() — never created or mutated on device. // Surfaced in the "Follow-up Requested" section on the Dashboard and My // Inspections. Tapping "Re-inspect" opens the normal new-inspection flow with // the facility + template preselected and `parentServerId` set, so the // submission lands as a linked re-inspection. // // WHY THIS EXISTS AS ITS OWN CACHE, rather than reading followUpRequired off // LocalInspection: the flag is set on the WEB, after the inspection has already // synced. By then the local copy either shows `status == "synced"` (filtered out // of every My Inspections @Query) or is not on this device at all — a reinstall, // a second iPad, or a follow-up raised weeks later all leave nothing to badge. // The request has to be pulled as its own work item to be actionable. // // Follows LocalScheduledInspection field-for-field: a `.unique` serverId WITHOUT // an inline default (a default on the unique key breaks @Model's PersistentModel // conformance) and a full init(from:)/update(from:) pair. import Foundation import SwiftData @Model final class LocalFollowUpRequest { /// Server ID of the flagged (parent) Inspection — stable unique identity, /// and the value passed as `parentServerId` when the re-inspection starts. @Attribute(.unique) var serverId: Int var facilityServerId: Int var facilityName: String var templateServerId: Int var templateName: String /// Score the flagged inspection came back with, when it has one. Shown on /// the row: the reason a follow-up was raised is usually the low score. var overallScore: Double? /// Raw server date string of the original inspection — sortable (ISO strings /// sort chronologically) and the source for the parsed `inspectedOn`. var inspectionDateString: String /// The director's note explaining what the follow-up should address. /// Stored raw; read through `note` for the normalised form. var followUpNote: String? /// Display name of the inspector this follow-up was handed to, when it was /// assigned to somebody other than whoever performed the original /// inspection (phase53). nil = it belongs to the original inspector. /// /// Purely for display — the server only ever returns follow-ups this user /// owns, so nothing here decides what is shown. Optional so existing /// SwiftData stores migrate lightweight (rule 8). var assignedToName: String? /// Set on device the moment a re-inspection of this request is submitted, so /// the FOLLOW-UP REQUESTED lists hide the row immediately — online or /// offline — without waiting for the round trip. /// /// Purely a display flag, and the exact counterpart of /// `LocalScheduledInspection.fulfilledLocally` (see that file for the full /// rationale). The lifecycle stays server-driven: the server clears /// `follow_up_required` when the linked re-inspection arrives, the next pull /// stops returning the row, and it is deleted. If the submission never /// lands, the server still reports the flag and the row comes back — /// self-healing. /// /// Non-optional with an inline default, so SwiftData migrates lightweight /// (CLAUDE.md rule 12): existing rows read as `false`, no app reinstall. var fulfilledLocally: Bool = false /// The flagged inspection's answers, JSON-encoded `[String: String]`, cached /// so the re-inspection can pre-fill from them. /// /// WHY CACHED HERE rather than read from the parent at start time: the /// parent `LocalInspection` is usually **not on this device**. A follow-up is /// raised on the web after the inspection already synced, so by then the /// local copy may be long gone (reinstall, a second iPad, a follow-up raised /// weeks later) — the same reason this model exists at all. The prefill in /// `StartInspectionView` matched on a local parent only, found nothing, and /// silently produced a blank form, unlike the web. /// /// Free to carry: `GET /api/v1/inspections` already returns `form_data` on /// every row (`_inspection_payload`), so this costs no extra request and the /// prefill works offline — the values are captured at pull time. /// /// Non-optional with an inline default so SwiftData migrates lightweight /// (CLAUDE.md rule 12); `"{}"` decodes to an empty dictionary. var parentFormDataJSON: String = "{}" /// Last time this row was refreshed from the server pull. var updatedAt: Date /// The follow-up note, normalised: nil for nil, empty, or whitespace-only /// text so callers can guard with a single `if let` instead of repeating the /// emptiness check. Computed, not stored — no SwiftData schema change. var note: String? { guard let t = followUpNote?.trimmingCharacters(in: .whitespacesAndNewlines), !t.isEmpty else { return nil } return t } /// Parsed inspection date for display. Computed properties are not persisted /// by SwiftData; sort on `inspectionDateString` (not this) in @Query. /// /// Parses the leading `yyyy-MM-dd` rather than the whole timestamp on /// purpose. `SyncManager.isoFormatter` is fixed at `yyyy-MM-dd'T'HH:mm:ss` /// and returns nil the moment the server includes fractional seconds — /// which it does whenever the column carries microseconds (SQLite keeps /// them; MySQL truncates by default), so the same field parses on one /// deployment and not another. Only the day is displayed here, so taking the /// date prefix sidesteps the whole variation. var inspectedOn: Date? { guard inspectionDateString.count >= 10 else { return nil } return Self.dateOnlyFormatter.date(from: String(inspectionDateString.prefix(10))) } private static let dateOnlyFormatter: DateFormatter = { let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") f.dateFormat = "yyyy-MM-dd" return f }() init(from api: APIInspectionSummary) { self.serverId = api.id self.facilityServerId = api.facilityId self.facilityName = api.facilityName self.templateServerId = api.templateId self.templateName = api.templateName self.overallScore = api.overallScore self.inspectionDateString = api.inspectionDate ?? "" self.followUpNote = api.followUpNote self.assignedToName = api.followUpAssignedToName self.fulfilledLocally = false self.parentFormDataJSON = Self.encode(api) self.updatedAt = Date() } /// Serialise the parent's answers for storage. /// /// Uses the raw `formDataRaw` values rather than the flattened `formValues` /// so the structure survives the round trip exactly as the web's prefill /// copies it — an array field stays an array instead of being joined into /// `"a, b"`, which would be written back as a single bogus string. /// `JSONValue.anyValue` yields only JSON-serialisable types (NSNull for /// null), so `JSONSerialization` accepts the result. /// /// Returns `"{}"` on failure so the property is always valid JSON and /// `parentFormData` can decode it without a special case. private static func encode(_ api: APIInspectionSummary) -> String { let raw = api.formDataRaw.mapValues(\.anyValue) guard JSONSerialization.isValidJSONObject(raw), let data = try? JSONSerialization.data(withJSONObject: raw), let str = String(data: data, encoding: .utf8) else { return "{}" } return str } /// The flagged inspection's answers, decoded for prefill. Typed `[String: Any]` /// to match `LocalInspection.formData`, which is what the value is copied into. var parentFormData: [String: Any] { guard let data = parentFormDataJSON.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } return dict } func update(from api: APIInspectionSummary) { self.facilityServerId = api.facilityId self.facilityName = api.facilityName self.templateServerId = api.templateId self.templateName = api.templateName self.overallScore = api.overallScore self.inspectionDateString = api.inspectionDate ?? "" self.followUpNote = api.followUpNote self.assignedToName = api.followUpAssignedToName self.parentFormDataJSON = Self.encode(api) // The server is authoritative. Being returned by the pull at all means // the follow-up is still outstanding, so any local "just did it" flag is // stale by definition — a re-inspection that never synced, or one the // server rejected. self.fulfilledLocally = false self.updatedAt = Date() } }