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}