Skip to main content

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}