Files
JQC_iOS_App/JanitorialQC/Models/LocalFollowUpRequest.swift
T

182 lines
8.7 KiB
Swift

// 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()
}
}