cratefield_core/ports/push.rs
1//! The `Push` port (issue #104): a notification to a phone. Mail reaches an
2//! inbox through [`Mailer`](crate::Mailer); nothing else reached a device until
3//! this. APNs today (a booking confirmed, a room starting in ten minutes),
4//! FCM later.
5//!
6//! The device-token registry is venture code — each venture decides what a
7//! token belongs to — but the port defines the [`PushError::Unregistered`]
8//! contract so a module knows when to prune a dead token.
9
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use thiserror::Error;
14
15/// How urgently the notification should be delivered. Maps to APNs priority
16/// `10` (deliver now, may wake the device) and `5` (deliver to save power).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum Priority {
20 /// Deliver immediately.
21 #[default]
22 Immediate,
23 /// Deliver when convenient, to conserve power.
24 Conserve,
25}
26
27/// One notification. `data` is the custom key-value payload the app reads;
28/// `collapse_id` coalesces notifications the user has not seen yet.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Notification {
31 pub title: String,
32 pub body: String,
33 /// The notification category (an app-defined action group).
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub category: Option<String>,
36 /// Groups related notifications in the UI.
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub thread_id: Option<String>,
39 /// Custom payload the app reads; merged alongside the `aps` block.
40 #[serde(default)]
41 pub data: Value,
42 /// Coalesces with any undelivered notification carrying the same id.
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub collapse_id: Option<String>,
45 #[serde(default)]
46 pub priority: Priority,
47}
48
49impl Notification {
50 /// A minimal notification with a title and body.
51 #[must_use]
52 pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
53 Self {
54 title: title.into(),
55 body: body.into(),
56 category: None,
57 thread_id: None,
58 data: Value::Null,
59 collapse_id: None,
60 priority: Priority::Immediate,
61 }
62 }
63}
64
65/// The result of a send that the provider accepted.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum PushOutcome {
68 /// Accepted; carries the provider's id where it gives one (`apns-id`).
69 Delivered { id: Option<String> },
70 /// The adapter is not configured (no key / unverified). The caller can
71 /// degrade rather than treating it as a failure, the same way
72 /// [`SendOutcome::NotConfigured`](crate::SendOutcome) works for mail.
73 NotConfigured,
74}
75
76/// Push failures. `Unregistered` is separated because the caller must act on
77/// it — the device token is dead and should be pruned — where the others are
78/// transient or a bad request.
79#[derive(Debug, Clone, Error)]
80pub enum PushError {
81 /// The provider says the token is no longer valid (APNs `410`): delete it.
82 #[error("device token is no longer registered; delete it")]
83 Unregistered,
84 /// The provider rejected the request (a `4xx` that is not `410`); not
85 /// retryable without a change.
86 #[error("push rejected: {0}")]
87 Rejected(String),
88 /// A transient failure (a `5xx`, a transport error): retry later.
89 #[error("push failed, retryable: {0}")]
90 Transient(String),
91}
92
93/// Sends notifications to a device. APNs today, FCM later; both over the
94/// runtime's `HttpClient`.
95#[async_trait]
96pub trait Push: Send + Sync {
97 /// Sends `notification` to `device_token`.
98 async fn send(
99 &self,
100 device_token: &str,
101 notification: &Notification,
102 ) -> Result<PushOutcome, PushError>;
103}