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