Skip to main content

rainier_framework/
notifications.rs

1//! The notification channel that needs a database.
2//!
3//! `rainier-notify` depends on mail and support and nothing else, so an
4//! application with no database compiles neither. The
5//! [`DatabaseChannel`] lives here for the same reason `JobTask` does: this is
6//! the crate that has both halves.
7
8use std::sync::Arc;
9
10use chrono::{DateTime, Utc};
11use rainier_database::{Criteria, Database, EntityRepository, Migrator, Model, Repository};
12use rainier_notify::{Channel, Delivery};
13use rainier_orm::Entity;
14use rainier_support::{BoxFuture, Error, Result};
15use serde::{Deserialize, Serialize};
16
17/// One stored notification.
18///
19/// The row behind an in-app bell menu: what it was, who it was for, and whether
20/// they have read it.
21#[derive(Debug, Clone, PartialEq, Entity, Serialize, Deserialize)]
22#[orm(table = "rainier_notifications")]
23#[orm(index = "notifiable_type, notifiable_id, read_at")]
24pub struct NotificationRow {
25    /// A unique id for this stored notification.
26    #[orm(pk)]
27    pub id: String,
28    /// The notification's stable name.
29    pub notification: String,
30    /// What kind of thing it was for.
31    #[orm(index)]
32    pub notifiable_type: String,
33    /// Which one.
34    #[orm(index)]
35    pub notifiable_id: String,
36    /// The payload, as JSON text.
37    pub payload: String,
38    /// When it was read, or `NULL` while unread.
39    pub read_at: Option<DateTime<Utc>>,
40    /// When it arrived.
41    pub created_at: DateTime<Utc>,
42}
43
44impl Model for NotificationRow {}
45
46impl NotificationRow {
47    /// Whether it is still unread.
48    pub fn is_unread(&self) -> bool {
49        self.read_at.is_none()
50    }
51
52    /// The payload, parsed.
53    pub fn data(&self) -> Result<serde_json::Value> {
54        Ok(serde_json::from_str(&self.payload)?)
55    }
56}
57
58/// Stores notifications in a table, for an in-app notification list.
59///
60/// Uses [`to_data`](rainier_notify::Notification::to_data), falling back to
61/// wrapping [`to_text`](rainier_notify::Notification::to_text) — so a
62/// notification that only wrote a line still gets a row rather than being
63/// silently dropped.
64///
65/// Addresses the recipient by their **id**, not by a route: every notifiable
66/// has one, so this channel is never skipped for want of an address.
67pub struct DatabaseChannel {
68    rows: Arc<dyn Repository<NotificationRow>>,
69}
70
71impl DatabaseChannel {
72    /// A channel writing to `db`.
73    pub fn new(db: Database) -> Self {
74        Self { rows: Arc::new(EntityRepository::<NotificationRow>::new(db)) }
75    }
76
77    /// The migration this channel needs.
78    ///
79    /// Merge it into the application's migrator:
80    ///
81    /// ```ignore
82    /// Migrator::new()
83    ///     .create_table::<User>("0001_create_users")
84    ///     .merge(DatabaseChannel::migrations())
85    /// ```
86    pub fn migrations() -> Migrator {
87        Migrator::new().create_table::<NotificationRow>("rainier_notify_0001_notifications")
88    }
89
90    /// Everything sent to a recipient, newest first.
91    pub async fn for_recipient(
92        &self,
93        notifiable_type: &str,
94        notifiable_id: &str,
95        limit: u64,
96    ) -> Result<Vec<NotificationRow>> {
97        self.rows.matching(Self::addressed_to(notifiable_type, notifiable_id).limit(limit)).await
98    }
99
100    /// The unread ones, newest first.
101    pub async fn unread(
102        &self,
103        notifiable_type: &str,
104        notifiable_id: &str,
105        limit: u64,
106    ) -> Result<Vec<NotificationRow>> {
107        self.rows
108            .matching(
109                Self::addressed_to(notifiable_type, notifiable_id)
110                    .where_null("read_at")
111                    .limit(limit),
112            )
113            .await
114    }
115
116    /// How many are unread — what the badge on the bell shows.
117    pub async fn unread_count(&self, notifiable_type: &str, notifiable_id: &str) -> Result<u64> {
118        self.rows
119            .count_matching(
120                Self::addressed_to(notifiable_type, notifiable_id).where_null("read_at"),
121            )
122            .await
123    }
124
125    /// One notification, but only if it belongs to this recipient.
126    ///
127    /// What an endpoint should look it up with. Reading the row and *then*
128    /// comparing the owner in the handler is the same query with one more
129    /// place to forget the comparison.
130    pub async fn find_for(
131        &self,
132        notifiable_type: &str,
133        notifiable_id: &str,
134        id: &str,
135    ) -> Result<Option<NotificationRow>> {
136        let Some(row) = self.rows.find(id.into()).await? else { return Ok(None) };
137
138        let theirs = row.notifiable_type == notifiable_type && row.notifiable_id == notifiable_id;
139        Ok(theirs.then_some(row))
140    }
141
142    /// Mark one as read. `true` if it was there and unread.
143    pub async fn mark_read(&self, id: &str) -> Result<bool> {
144        let Some(mut row) = self.rows.find(id.into()).await? else { return Ok(false) };
145        if row.read_at.is_some() {
146            return Ok(false);
147        }
148
149        row.read_at = Some(Utc::now());
150        Ok(self.rows.update(&row).await? > 0)
151    }
152
153    /// Mark everything for a recipient as read. Returns how many changed.
154    pub async fn mark_all_read(&self, notifiable_type: &str, notifiable_id: &str) -> Result<u64> {
155        let unread = self.unread(notifiable_type, notifiable_id, u64::MAX).await?;
156
157        let mut marked = 0;
158        for mut row in unread {
159            row.read_at = Some(Utc::now());
160            marked += self.rows.update(&row).await?;
161        }
162        Ok(marked)
163    }
164
165    /// Delete everything read before `before`. Returns how many.
166    ///
167    /// A notifications table grows forever otherwise. Worth
168    /// [scheduling](crate::scheduler).
169    pub async fn prune_read(&self, before: DateTime<Utc>) -> Result<u64> {
170        self.rows
171            .delete_matching(Criteria::new().where_not_null("read_at").where_lt("read_at", before))
172            .await
173    }
174
175    fn addressed_to(notifiable_type: &str, notifiable_id: &str) -> Criteria {
176        Criteria::new()
177            .where_eq("notifiable_type", notifiable_type)
178            .where_eq("notifiable_id", notifiable_id)
179            .order_by_desc("created_at")
180    }
181}
182
183/// A unique id for a stored notification: a timestamp for rough ordering plus a
184/// counter for uniqueness.
185///
186/// Not a UUID, to avoid the dependency — the requirement is only that two rows
187/// written in the same microsecond differ. The same approach the queue uses.
188fn generate_id() -> String {
189    use std::sync::atomic::{AtomicU64, Ordering};
190    static COUNTER: AtomicU64 = AtomicU64::new(0);
191
192    let micros = Utc::now().timestamp_micros();
193    let sequence = COUNTER.fetch_add(1, Ordering::Relaxed);
194    format!("{micros:x}-{sequence:x}")
195}
196
197impl Channel for DatabaseChannel {
198    fn name(&self) -> &'static str {
199        "database"
200    }
201
202    fn send<'a>(&'a self, delivery: &'a Delivery) -> BoxFuture<'a, Result<()>> {
203        Box::pin(async move {
204            let Some(data) = delivery.data_or_text() else {
205                return Err(delivery.nothing_to_send("database", "data or text"));
206            };
207
208            self.rows
209                .create(NotificationRow {
210                    id: generate_id(),
211                    notification: delivery.notification.to_string(),
212                    notifiable_type: delivery.recipient_type.to_string(),
213                    notifiable_id: delivery.recipient_id.clone(),
214                    payload: serde_json::to_string(&data)
215                        .map_err(|e| Error::internal(format!("unserialisable payload: {e}")))?,
216                    read_at: None,
217                    created_at: Utc::now(),
218                })
219                .await?;
220
221            Ok(())
222        })
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use rainier_database::testing::{fake_database, MemoryConnection};
230    use rainier_database::Dialect;
231    use rainier_notify::{Channels, Notifiable, Notification, Notifier};
232
233    struct User(u64);
234
235    impl Notifiable for User {
236        fn notifiable_id(&self) -> String {
237            self.0.to_string()
238        }
239        fn notifiable_type(&self) -> &'static str {
240            "User"
241        }
242        fn route_for(&self, _: &str) -> Option<String> {
243            // Deliberately unreachable everywhere: the database channel
244            // addresses by id, so it must still work.
245            None
246        }
247    }
248
249    struct Mentioned {
250        by: String,
251    }
252
253    impl Notification<User> for Mentioned {
254        fn notification_name(&self) -> &'static str {
255            "post.mentioned"
256        }
257        fn via(&self, _: &User) -> Channels {
258            Channels::new().with::<DatabaseChannel>()
259        }
260        fn to_data(&self, _: &User) -> Option<serde_json::Value> {
261            Some(serde_json::json!({ "by": self.by }))
262        }
263    }
264
265    struct TextOnly;
266
267    impl Notification<User> for TextOnly {
268        fn notification_name(&self) -> &'static str {
269            "test.text-only"
270        }
271        fn via(&self, _: &User) -> Channels {
272            Channels::new().with::<DatabaseChannel>()
273        }
274        fn to_text(&self, _: &User) -> Option<String> {
275            Some("something happened".into())
276        }
277    }
278
279    #[test]
280    fn the_migration_creates_the_table() {
281        let migrator = DatabaseChannel::migrations();
282        assert_eq!(migrator.names(), vec!["rainier_notify_0001_notifications"]);
283
284        let ddl =
285            rainier_database::schema::schema_ddl::<NotificationRow>(Dialect::Sqlite).join("\n");
286        assert!(ddl.contains("rainier_notifications"), "{ddl}");
287        assert!(ddl.contains("read_at"), "{ddl}");
288    }
289
290    #[tokio::test]
291    async fn it_writes_a_row_addressed_by_id() {
292        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
293        let notifier = Notifier::new().with(DatabaseChannel::new(db));
294
295        let receipt = notifier.send(&User(7), &Mentioned { by: "ada".into() }).await.unwrap();
296
297        assert!(receipt.delivered_anywhere());
298        let sql = connection.last_statement().unwrap();
299        assert!(sql.contains("rainier_notifications"), "{sql}");
300    }
301
302    #[tokio::test]
303    async fn a_recipient_with_no_address_anywhere_still_gets_a_row() {
304        // The property that makes this channel useful: it needs no route, so a
305        // user with no email and no phone still has a bell menu.
306        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
307        let notifier = Notifier::new().with(DatabaseChannel::new(db));
308
309        notifier.send(&User(7), &Mentioned { by: "ada".into() }).await.unwrap();
310
311        assert_eq!(connection.statement_count(), 1);
312    }
313
314    #[tokio::test]
315    async fn a_text_only_notification_still_gets_a_row() {
316        // Falling back to wrapping the text beats dropping it: a notification
317        // the author only wrote one line for should still reach the bell menu.
318        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
319        let notifier = Notifier::new().with(DatabaseChannel::new(db));
320
321        let receipt = notifier.send(&User(7), &TextOnly).await.unwrap();
322
323        assert!(receipt.delivered_anywhere());
324        let recorded = connection.recorded();
325        let payload = recorded[0]
326            .params
327            .iter()
328            .find_map(|value| match value {
329                rainier_orm::sea_query::Value::String(Some(text)) if text.contains("message") => {
330                    Some(text.to_string())
331                }
332                _ => None,
333            })
334            .expect("the payload should be a JSON string");
335
336        assert!(payload.contains("something happened"), "{payload}");
337    }
338
339    #[tokio::test]
340    async fn a_lookup_scoped_to_the_wrong_recipient_finds_nothing() {
341        // Ids are opaque strings, not secrets. The scope is what stops one
342        // user marking another's notification read.
343        let (db, _) = fake_database(MemoryConnection::new(Dialect::Sqlite));
344        let channel = DatabaseChannel::new(db);
345
346        assert!(channel.find_for("User", "7", "whatever").await.unwrap().is_none());
347    }
348
349    #[test]
350    fn a_row_knows_whether_it_has_been_read() {
351        let mut row = NotificationRow {
352            id: "n1".into(),
353            notification: "post.mentioned".into(),
354            notifiable_type: "User".into(),
355            notifiable_id: "7".into(),
356            payload: r#"{"by":"ada"}"#.into(),
357            read_at: None,
358            created_at: Utc::now(),
359        };
360
361        assert!(row.is_unread());
362        assert_eq!(row.data().unwrap()["by"], "ada");
363
364        row.read_at = Some(Utc::now());
365        assert!(!row.is_unread());
366    }
367}