Aug 27 - Fixed inspection lost photos recovery

This commit is contained in:
Nguyen Ngo
2026-08-27 10:42:01 -04:00
parent ca7c09f982
commit 308710538b
14 changed files with 794 additions and 114 deletions
+102
View File
@@ -0,0 +1,102 @@
// Models/LocalNotification.swift
// ------------------------------
// SwiftData model backing the in-app notification inbox.
//
// WHY THIS IS A LOCAL STORE, unlike every other server-backed list in the app.
// `GET /api/v1/notifications` is a POLLER, not an inbox: it filters to
// `is_read = False` and never sends the flag at all
// (app/api/notifications.py :: list_notifications). So the instant a
// notification is marked read the server stops returning it there is no
// response the iPad could render as "read", and before this every row in the
// list was unread by definition, which is why they all looked identical.
// Keeping our own copy is the only way to show read and unread side by side.
//
// The web already has a real inbox (routes/notifications.py :: index, with an
// all / unread / read filter and paging). If a mobile equivalent is ever added,
// this model should become a cache of it rather than the source of truth see
// rule 92.
//
// Scope: notifications are per-user, so this is purged on an identity change
// like everything else (rule 88).
import Foundation
import SwiftData
@Model
final class LocalNotification {
/// Server notification id stable identity, and the value
/// `PATCH /api/v1/notifications/mark-read` takes.
/// No inline default: a `.unique` key must not carry one (rule 63).
@Attribute(.unique) var serverId: Int
var title: String
var body: String
/// e.g. `issue_assigned`, `sla_alert`, `scheduled_inspection`. Nil for rows
/// created before the server's phase17 migration added the column.
var eventType: String?
/// Set when the notification refers to an issue drives "View Issue".
var issueId: Int?
/// Server `created_at`, parsed. Falls back to receipt time when the string
/// cannot be parsed so ordering never collapses to a single instant.
var createdAt: Date
/// When THIS device first saw it. Distinct from `createdAt`: a notification
/// raised while the iPad was offline arrives late but keeps its real time.
var receivedAt: Date
// Read state
// Set optimistically on tap / Mark All so the UI responds offline, then
// pushed to the server. Read state is shared with the web (rule 92).
var isRead: Bool = false
var readAt: Date?
/// True while this row's read state has not yet reached the server.
/// Drained by `SyncManager.pushNotificationReadState()`.
///
/// Non-optional with an inline default so SwiftData migrates lightweight
/// (rule 8).
var readSyncPending: Bool = false
init(from api: APINotification) {
self.serverId = api.id
self.title = api.title
self.body = api.body
self.eventType = api.eventType
self.issueId = api.issueId
self.createdAt = SyncManager.isoFormatter.date(from: api.createdAt) ?? Date()
self.receivedAt = Date()
self.isRead = false
self.readAt = nil
self.readSyncPending = false
}
/// Refresh the mutable text from a later poll.
///
/// Deliberately does NOT touch `isRead`. The endpoint only ever returns
/// UNREAD rows, so being returned again carries no information about read
/// state it usually just means our mark-read has not been pushed yet.
/// Clobbering it here would make a notification the inspector just opened
/// pop straight back to unread.
func update(from api: APINotification) {
self.title = api.title
self.body = api.body
self.eventType = api.eventType
self.issueId = api.issueId
if let parsed = SyncManager.isoFormatter.date(from: api.createdAt) {
self.createdAt = parsed
}
}
/// Mark read locally and queue the server push. Idempotent.
func markRead() {
guard !isRead else { return }
isRead = true
readAt = Date()
readSyncPending = true
}
}