103 lines
4.1 KiB
Swift
103 lines
4.1 KiB
Swift
// 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
|
|
}
|
|
}
|