1use 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#[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 #[orm(pk)]
27 pub id: String,
28 pub notification: String,
30 #[orm(index)]
32 pub notifiable_type: String,
33 #[orm(index)]
35 pub notifiable_id: String,
36 pub payload: String,
38 pub read_at: Option<DateTime<Utc>>,
40 pub created_at: DateTime<Utc>,
42}
43
44impl Model for NotificationRow {}
45
46impl NotificationRow {
47 pub fn is_unread(&self) -> bool {
49 self.read_at.is_none()
50 }
51
52 pub fn data(&self) -> Result<serde_json::Value> {
54 Ok(serde_json::from_str(&self.payload)?)
55 }
56}
57
58pub struct DatabaseChannel {
68 rows: Arc<dyn Repository<NotificationRow>>,
69}
70
71impl DatabaseChannel {
72 pub fn new(db: Database) -> Self {
74 Self { rows: Arc::new(EntityRepository::<NotificationRow>::new(db)) }
75 }
76
77 pub fn migrations() -> Migrator {
87 Migrator::new().create_table::<NotificationRow>("rainier_notify_0001_notifications")
88 }
89
90 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 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 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 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 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 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 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
183fn 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 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 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 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 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}