Jul 30 - Update iPad - inspector can re-inspect and create follow-up
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
// 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?
|
||||
|
||||
/// 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.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.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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user