Skip to main content

cratefield_module_notifications/
message.rs

1//! What a caller hands [`notify`](crate::Notifier::notify): text it has
2//! already written, or a message id the delivery renders per recipient
3//! (issue #190).
4//!
5//! # Why the language is not chosen here
6//!
7//! Because the caller cannot know it. One account can have an English
8//! browser and a Bahasa phone, so there is no single answer at the moment
9//! a booking is confirmed — there is one answer per device, one for the
10//! inbox and one for the mailbox, and three of them are not known until
11//! the row is drained.
12//!
13//! So a caller names a message instead of writing one, and the drain
14//! resolves the language against the subscription, then the account, then
15//! the venture's default.
16//!
17//! # A venture with one language pays nothing
18//!
19//! [`Message::Rendered`] is today's path, unchanged: `notify` takes
20//! `impl Into<Message>` and `Notification` converts, so every existing
21//! call site compiles and behaves exactly as it did. No catalog is built,
22//! no locale is read, and the notification reaches the device as written.
23
24use cratefield_core::{LocKeys, Notification};
25use cratefield_i18n::Args;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29/// What a notification says, and who decides.
30#[derive(Debug, Clone, PartialEq)]
31pub enum Message {
32    /// Text the caller wrote. Delivered as given, in every channel, to
33    /// every device — the path a single-language venture uses and the one
34    /// that existed before localisation.
35    Rendered(Box<Notification>),
36    /// A message id in the venture's catalog, rendered per recipient at
37    /// delivery.
38    Localizable(Box<Localizable>),
39}
40
41impl From<Notification> for Message {
42    fn from(notification: Notification) -> Self {
43        Message::Rendered(Box::new(notification))
44    }
45}
46
47impl From<Localizable> for Message {
48    fn from(localizable: Localizable) -> Self {
49        Message::Localizable(Box::new(localizable))
50    }
51}
52
53/// A message named rather than written.
54///
55/// `key` is a Fluent message id whose attributes are `.title`, `.body` and
56/// optionally `.subject` (mail only); `args` are its placeables.
57///
58/// Everything a [`Notification`] carries that is *not* language — the tap
59/// target, the icon, the custom payload — is here too, because those do
60/// not change with the recipient's locale.
61#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
62pub struct Localizable {
63    /// The catalog message id: `booking-confirmed`.
64    pub key: String,
65    /// Its placeables: `{ $coach }`, `{ $count }`.
66    ///
67    /// **Personal data.** A coach's name, a place, a reference — these are
68    /// the caller's values about a person, so they belong in the message
69    /// that is delivered and nowhere else. Nothing in this module puts one
70    /// in a log, an event payload or an error.
71    #[serde(default, skip_serializing_if = "Args::is_empty")]
72    pub args: Args,
73    /// Where a tap should take the recipient.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub url: Option<String>,
76    /// The custom payload the app reads.
77    #[serde(default, skip_serializing_if = "Value::is_null")]
78    pub data: Value,
79    /// An icon for the notification (Web Push).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub icon: Option<String>,
82    /// A badge count, for a category that declared `badge(true)`.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub badge: Option<u32>,
85    /// Coalesces with an undelivered notification carrying the same id.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub collapse_id: Option<String>,
88    /// The **app's own** localisation keys, for a category whose
89    /// [`RenderMode`] is not [`RenderMode::Server`].
90    ///
91    /// These are not this catalog's ids: they name strings compiled into
92    /// the iOS and Android apps, which only the app knows. The module
93    /// cannot invent them, so a caller that wants native rendering passes
94    /// them, and APNs and FCM get them alongside (or instead of) the
95    /// server's text. Web Push has no such mechanism and never sees them.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub loc: Option<LocKeys>,
98}
99
100impl Localizable {
101    /// A message naming `key`, with no arguments.
102    #[must_use]
103    pub fn new(key: impl Into<String>) -> Self {
104        Self {
105            key: key.into(),
106            ..Self::default()
107        }
108    }
109
110    /// Adds one placeable.
111    ///
112    /// A number must arrive as a number: Fluent selects the plural form
113    /// from it, and `"2"` as a string selects `other` in every language —
114    /// which reads correctly in English and wrongly in Polish.
115    #[must_use]
116    pub fn arg(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
117        self.args.insert(name.into(), value.into());
118        self
119    }
120
121    /// Where a tap should take the recipient.
122    #[must_use]
123    pub fn url(mut self, url: impl Into<String>) -> Self {
124        self.url = Some(url.into());
125        self
126    }
127
128    /// The custom payload the app reads.
129    #[must_use]
130    pub fn data(mut self, data: Value) -> Self {
131        self.data = data;
132        self
133    }
134
135    /// An icon for the notification.
136    #[must_use]
137    pub fn icon(mut self, icon: impl Into<String>) -> Self {
138        self.icon = Some(icon.into());
139        self
140    }
141
142    /// A badge count, honoured only by a category that declared
143    /// `badge(true)`.
144    #[must_use]
145    pub fn badge(mut self, badge: u32) -> Self {
146        self.badge = Some(badge);
147        self
148    }
149
150    /// Coalesces with an undelivered notification carrying the same id.
151    #[must_use]
152    pub fn collapse_id(mut self, id: impl Into<String>) -> Self {
153        self.collapse_id = Some(id.into());
154        self
155    }
156
157    /// The app's own localisation keys — see [`Localizable::loc`].
158    #[must_use]
159    pub fn loc(mut self, loc: LocKeys) -> Self {
160        self.loc = Some(loc);
161        self
162    }
163
164    /// The notification this message becomes once its title and body are
165    /// rendered: everything that does not depend on the language, filled
166    /// in from here.
167    pub(crate) fn shape(&self, title: String, body: String) -> Notification {
168        let mut notification = Notification::new(title, body);
169        notification.url.clone_from(&self.url);
170        notification.icon.clone_from(&self.icon);
171        notification.data = self.data.clone();
172        notification.badge = self.badge;
173        notification.collapse_id.clone_from(&self.collapse_id);
174        notification
175    }
176}
177
178/// Who renders the strings a device shows, per category.
179///
180/// Only ever applies to a [`Message::Localizable`]. A caller that passes a
181/// rendered `Notification` gets exactly what it wrote, `loc` included — the
182/// native passthrough documented before this issue keeps working untouched.
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "lowercase")]
185pub enum RenderMode {
186    /// The server renders, in the recipient's own language. The default,
187    /// and the only thing a browser can be sent.
188    #[default]
189    Server,
190    /// The app renders, from strings it ships. APNs and FCM get the
191    /// caller's [`Localizable::loc`] keys, and the text beside them is the
192    /// **venture's default locale** — a fallback for a device whose app is
193    /// too old to have the key, nothing more.
194    Native,
195    /// Both: the app's keys *and* the server's text in the recipient's own
196    /// language. The OS uses the app's strings where the key exists and
197    /// falls back to ours where it does not.
198    Both,
199}
200
201impl RenderMode {
202    /// Whether a token transport should be sent the app's own keys.
203    #[must_use]
204    pub fn sends_loc_keys(self) -> bool {
205        matches!(self, RenderMode::Native | RenderMode::Both)
206    }
207
208    /// Whether the text beside those keys is rendered in the recipient's
209    /// language, or in the venture's default.
210    #[must_use]
211    pub fn renders_for_recipient(self) -> bool {
212        matches!(self, RenderMode::Server | RenderMode::Both)
213    }
214
215    /// The name a report or a config value uses.
216    #[must_use]
217    pub fn as_str(self) -> &'static str {
218        match self {
219            RenderMode::Server => "server",
220            RenderMode::Native => "native",
221            RenderMode::Both => "both",
222        }
223    }
224}
225
226impl std::fmt::Display for RenderMode {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        f.write_str(self.as_str())
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn a_rendered_notification_converts_without_the_caller_saying_so() {
238        // The whole "a single-language venture pays nothing" claim, in one
239        // line: `notify(.., notification)` keeps compiling.
240        let message: Message = Notification::new("Booked", "See you Tuesday").into();
241        match message {
242            Message::Rendered(notification) => {
243                assert_eq!(notification.title, "Booked");
244            }
245            Message::Localizable(_) => panic!("a Notification is rendered text"),
246        }
247    }
248
249    #[test]
250    fn the_builder_keeps_a_number_a_number() {
251        let message = Localizable::new("booking-confirmed")
252            .arg("count", 2)
253            .arg("coach", "Sari");
254        assert!(
255            message.args["count"].is_number(),
256            "a string never selects a plural category"
257        );
258        assert_eq!(message.args["coach"], "Sari");
259    }
260
261    #[test]
262    fn a_payload_round_trips_and_omits_what_was_not_set() {
263        let message = Localizable::new("booking-confirmed").arg("coach", "Sari");
264        let json = serde_json::to_string(&message).expect("serialises");
265        assert!(!json.contains("\"url\""), "{json}");
266        assert!(!json.contains("\"loc\""), "{json}");
267        let back: Localizable = serde_json::from_str(&json).expect("round trips");
268        assert_eq!(back, message);
269    }
270
271    #[test]
272    fn the_modes_say_what_each_one_does() {
273        assert!(!RenderMode::Server.sends_loc_keys());
274        assert!(RenderMode::Native.sends_loc_keys());
275        assert!(RenderMode::Both.sends_loc_keys());
276        assert!(RenderMode::Server.renders_for_recipient());
277        assert!(
278            !RenderMode::Native.renders_for_recipient(),
279            "native means the app owns the language; the text is only a fallback"
280        );
281        assert!(RenderMode::Both.renders_for_recipient());
282        assert_eq!(RenderMode::default(), RenderMode::Server);
283    }
284
285    #[test]
286    fn the_shape_carries_everything_that_is_not_language() {
287        let message = Localizable::new("booking-confirmed")
288            .url("https://example.test/b/1")
289            .icon("bell")
290            .badge(3)
291            .collapse_id("b-1")
292            .data(serde_json::json!({ "booking_id": "b-1" }));
293        let notification = message.shape("Booked".to_owned(), "Tuesday".to_owned());
294        assert_eq!(notification.title, "Booked");
295        assert_eq!(
296            notification.url.as_deref(),
297            Some("https://example.test/b/1")
298        );
299        assert_eq!(notification.icon.as_deref(), Some("bell"));
300        assert_eq!(notification.badge, Some(3));
301        assert_eq!(notification.collapse_id.as_deref(), Some("b-1"));
302        assert_eq!(notification.data["booking_id"], "b-1");
303    }
304}