use std::sync::Arc;
use chrono::{DateTime, Utc};
use rainier_database::{Criteria, Database, EntityRepository, Migrator, Model, Repository};
use rainier_notify::{Channel, Delivery};
use rainier_orm::Entity;
use rainier_support::{BoxFuture, Error, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Entity, Serialize, Deserialize)]
#[orm(table = "rainier_notifications")]
#[orm(index = "notifiable_type, notifiable_id, read_at")]
pub struct NotificationRow {
#[orm(pk)]
pub id: String,
pub notification: String,
#[orm(index)]
pub notifiable_type: String,
#[orm(index)]
pub notifiable_id: String,
pub payload: String,
pub read_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
impl Model for NotificationRow {}
impl NotificationRow {
pub fn is_unread(&self) -> bool {
self.read_at.is_none()
}
pub fn data(&self) -> Result<serde_json::Value> {
Ok(serde_json::from_str(&self.payload)?)
}
}
pub struct DatabaseChannel {
rows: Arc<dyn Repository<NotificationRow>>,
}
impl DatabaseChannel {
pub fn new(db: Database) -> Self {
Self { rows: Arc::new(EntityRepository::<NotificationRow>::new(db)) }
}
pub fn migrations() -> Migrator {
Migrator::new().create_table::<NotificationRow>("rainier_notify_0001_notifications")
}
pub async fn for_recipient(
&self,
notifiable_type: &str,
notifiable_id: &str,
limit: u64,
) -> Result<Vec<NotificationRow>> {
self.rows.matching(Self::addressed_to(notifiable_type, notifiable_id).limit(limit)).await
}
pub async fn unread(
&self,
notifiable_type: &str,
notifiable_id: &str,
limit: u64,
) -> Result<Vec<NotificationRow>> {
self.rows
.matching(
Self::addressed_to(notifiable_type, notifiable_id)
.where_null("read_at")
.limit(limit),
)
.await
}
pub async fn unread_count(&self, notifiable_type: &str, notifiable_id: &str) -> Result<u64> {
self.rows
.count_matching(
Self::addressed_to(notifiable_type, notifiable_id).where_null("read_at"),
)
.await
}
pub async fn find_for(
&self,
notifiable_type: &str,
notifiable_id: &str,
id: &str,
) -> Result<Option<NotificationRow>> {
let Some(row) = self.rows.find(id.into()).await? else { return Ok(None) };
let theirs = row.notifiable_type == notifiable_type && row.notifiable_id == notifiable_id;
Ok(theirs.then_some(row))
}
pub async fn mark_read(&self, id: &str) -> Result<bool> {
let Some(mut row) = self.rows.find(id.into()).await? else { return Ok(false) };
if row.read_at.is_some() {
return Ok(false);
}
row.read_at = Some(Utc::now());
Ok(self.rows.update(&row).await? > 0)
}
pub async fn mark_all_read(&self, notifiable_type: &str, notifiable_id: &str) -> Result<u64> {
let unread = self.unread(notifiable_type, notifiable_id, u64::MAX).await?;
let mut marked = 0;
for mut row in unread {
row.read_at = Some(Utc::now());
marked += self.rows.update(&row).await?;
}
Ok(marked)
}
pub async fn prune_read(&self, before: DateTime<Utc>) -> Result<u64> {
self.rows
.delete_matching(Criteria::new().where_not_null("read_at").where_lt("read_at", before))
.await
}
fn addressed_to(notifiable_type: &str, notifiable_id: &str) -> Criteria {
Criteria::new()
.where_eq("notifiable_type", notifiable_type)
.where_eq("notifiable_id", notifiable_id)
.order_by_desc("created_at")
}
}
fn generate_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let micros = Utc::now().timestamp_micros();
let sequence = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{micros:x}-{sequence:x}")
}
impl Channel for DatabaseChannel {
fn name(&self) -> &'static str {
"database"
}
fn send<'a>(&'a self, delivery: &'a Delivery) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
let Some(data) = delivery.data_or_text() else {
return Err(delivery.nothing_to_send("database", "data or text"));
};
self.rows
.create(NotificationRow {
id: generate_id(),
notification: delivery.notification.to_string(),
notifiable_type: delivery.recipient_type.to_string(),
notifiable_id: delivery.recipient_id.clone(),
payload: serde_json::to_string(&data)
.map_err(|e| Error::internal(format!("unserialisable payload: {e}")))?,
read_at: None,
created_at: Utc::now(),
})
.await?;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use rainier_database::testing::{fake_database, MemoryConnection};
use rainier_database::Dialect;
use rainier_notify::{Channels, Notifiable, Notification, Notifier};
struct User(u64);
impl Notifiable for User {
fn notifiable_id(&self) -> String {
self.0.to_string()
}
fn notifiable_type(&self) -> &'static str {
"User"
}
fn route_for(&self, _: &str) -> Option<String> {
None
}
}
struct Mentioned {
by: String,
}
impl Notification<User> for Mentioned {
fn notification_name(&self) -> &'static str {
"post.mentioned"
}
fn via(&self, _: &User) -> Channels {
Channels::new().with::<DatabaseChannel>()
}
fn to_data(&self, _: &User) -> Option<serde_json::Value> {
Some(serde_json::json!({ "by": self.by }))
}
}
struct TextOnly;
impl Notification<User> for TextOnly {
fn notification_name(&self) -> &'static str {
"test.text-only"
}
fn via(&self, _: &User) -> Channels {
Channels::new().with::<DatabaseChannel>()
}
fn to_text(&self, _: &User) -> Option<String> {
Some("something happened".into())
}
}
#[test]
fn the_migration_creates_the_table() {
let migrator = DatabaseChannel::migrations();
assert_eq!(migrator.names(), vec!["rainier_notify_0001_notifications"]);
let ddl =
rainier_database::schema::schema_ddl::<NotificationRow>(Dialect::Sqlite).join("\n");
assert!(ddl.contains("rainier_notifications"), "{ddl}");
assert!(ddl.contains("read_at"), "{ddl}");
}
#[tokio::test]
async fn it_writes_a_row_addressed_by_id() {
let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
let notifier = Notifier::new().with(DatabaseChannel::new(db));
let receipt = notifier.send(&User(7), &Mentioned { by: "ada".into() }).await.unwrap();
assert!(receipt.delivered_anywhere());
let sql = connection.last_statement().unwrap();
assert!(sql.contains("rainier_notifications"), "{sql}");
}
#[tokio::test]
async fn a_recipient_with_no_address_anywhere_still_gets_a_row() {
let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
let notifier = Notifier::new().with(DatabaseChannel::new(db));
notifier.send(&User(7), &Mentioned { by: "ada".into() }).await.unwrap();
assert_eq!(connection.statement_count(), 1);
}
#[tokio::test]
async fn a_text_only_notification_still_gets_a_row() {
let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
let notifier = Notifier::new().with(DatabaseChannel::new(db));
let receipt = notifier.send(&User(7), &TextOnly).await.unwrap();
assert!(receipt.delivered_anywhere());
let recorded = connection.recorded();
let payload = recorded[0]
.params
.iter()
.find_map(|value| match value {
rainier_orm::sea_query::Value::String(Some(text)) if text.contains("message") => {
Some(text.to_string())
}
_ => None,
})
.expect("the payload should be a JSON string");
assert!(payload.contains("something happened"), "{payload}");
}
#[tokio::test]
async fn a_lookup_scoped_to_the_wrong_recipient_finds_nothing() {
let (db, _) = fake_database(MemoryConnection::new(Dialect::Sqlite));
let channel = DatabaseChannel::new(db);
assert!(channel.find_for("User", "7", "whatever").await.unwrap().is_none());
}
#[test]
fn a_row_knows_whether_it_has_been_read() {
let mut row = NotificationRow {
id: "n1".into(),
notification: "post.mentioned".into(),
notifiable_type: "User".into(),
notifiable_id: "7".into(),
payload: r#"{"by":"ada"}"#.into(),
read_at: None,
created_at: Utc::now(),
};
assert!(row.is_unread());
assert_eq!(row.data().unwrap()["by"], "ada");
row.read_at = Some(Utc::now());
assert!(!row.is_unread());
}
}