use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::{AutumnError, AutumnResult};
use crate::pagination::{ListQuery, Page, PageRequest, SortDir};
use crate::state::AppState;
pub const NOTIFICATIONS_TABLE: &str = "notifications";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Notification {
pub id: i64,
pub recipient_id: i64,
pub kind: String,
pub payload: Value,
pub read_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
impl Notification {
#[must_use]
pub const fn is_read(&self) -> bool {
self.read_at.is_some()
}
}
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
#[error("notification store error: {message}")]
pub struct NotificationStoreError {
message: String,
}
impl NotificationStoreError {
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
pub trait NotificationStore: Send + Sync + 'static {
fn notify(
&self,
recipient_id: i64,
kind: String,
payload: Value,
) -> impl Future<Output = Result<Notification, NotificationStoreError>> + Send;
fn list(
&self,
recipient_id: i64,
query: &ListQuery,
page: &PageRequest,
) -> impl Future<Output = Result<Page<Notification>, NotificationStoreError>> + Send;
fn unread_count(
&self,
recipient_id: i64,
) -> impl Future<Output = Result<u64, NotificationStoreError>> + Send;
fn mark_read(
&self,
id: i64,
recipient_id: Option<i64>,
) -> impl Future<Output = Result<u64, NotificationStoreError>> + Send;
fn mark_all_read(
&self,
recipient_id: i64,
) -> impl Future<Output = Result<u64, NotificationStoreError>> + Send;
}
type BoxedFuture<'a, T> =
Pin<Box<dyn Future<Output = Result<T, NotificationStoreError>> + Send + 'a>>;
pub(crate) trait BoxedNotificationStore: Send + Sync + 'static {
fn boxed_notify(
&self,
recipient_id: i64,
kind: String,
payload: Value,
) -> BoxedFuture<'_, Notification>;
fn boxed_list(
&self,
recipient_id: i64,
query: ListQuery,
page: PageRequest,
) -> BoxedFuture<'_, Page<Notification>>;
fn boxed_unread_count(&self, recipient_id: i64) -> BoxedFuture<'_, u64>;
fn boxed_mark_read(&self, id: i64, recipient_id: Option<i64>) -> BoxedFuture<'_, u64>;
fn boxed_mark_all_read(&self, recipient_id: i64) -> BoxedFuture<'_, u64>;
}
impl<S: NotificationStore> BoxedNotificationStore for S {
fn boxed_notify(
&self,
recipient_id: i64,
kind: String,
payload: Value,
) -> BoxedFuture<'_, Notification> {
Box::pin(NotificationStore::notify(self, recipient_id, kind, payload))
}
fn boxed_list(
&self,
recipient_id: i64,
query: ListQuery,
page: PageRequest,
) -> BoxedFuture<'_, Page<Notification>> {
Box::pin(async move { NotificationStore::list(self, recipient_id, &query, &page).await })
}
fn boxed_unread_count(&self, recipient_id: i64) -> BoxedFuture<'_, u64> {
Box::pin(NotificationStore::unread_count(self, recipient_id))
}
fn boxed_mark_read(&self, id: i64, recipient_id: Option<i64>) -> BoxedFuture<'_, u64> {
Box::pin(NotificationStore::mark_read(self, id, recipient_id))
}
fn boxed_mark_all_read(&self, recipient_id: i64) -> BoxedFuture<'_, u64> {
Box::pin(NotificationStore::mark_all_read(self, recipient_id))
}
}
#[derive(Clone)]
pub struct Notifications {
store: Arc<dyn BoxedNotificationStore>,
#[cfg(feature = "ws")]
channels: Option<crate::channels::Channels>,
}
impl Notifications {
#[must_use]
pub fn new(store: impl NotificationStore) -> Self {
Self {
store: Arc::new(store),
#[cfg(feature = "ws")]
channels: None,
}
}
#[must_use]
pub fn topic(recipient_id: i64) -> String {
format!("notifications:{recipient_id}")
}
pub async fn notify(
&self,
recipient_id: i64,
kind: impl Into<String>,
payload: Value,
) -> AutumnResult<Notification> {
self.store
.boxed_notify(recipient_id, kind.into(), payload)
.await
.map_err(AutumnError::internal_server_error)
}
#[cfg(feature = "ws")]
pub async fn notify_with_push(
&self,
recipient_id: i64,
kind: impl Into<String>,
payload: Value,
) -> AutumnResult<Notification> {
let notification = self.notify(recipient_id, kind, payload).await?;
if let Some(channels) = &self.channels
&& let Ok(json) = serde_json::to_string(¬ification)
{
let _ = channels
.broadcast()
.publish(&Self::topic(recipient_id), json);
}
Ok(notification)
}
pub async fn list(
&self,
recipient_id: i64,
query: &ListQuery,
page: &PageRequest,
) -> AutumnResult<Page<Notification>> {
self.store
.boxed_list(recipient_id, query.clone(), *page)
.await
.map_err(AutumnError::internal_server_error)
}
pub async fn unread_count(&self, recipient_id: i64) -> AutumnResult<u64> {
self.store
.boxed_unread_count(recipient_id)
.await
.map_err(AutumnError::internal_server_error)
}
pub async fn mark_read(&self, id: i64) -> AutumnResult<()> {
self.store
.boxed_mark_read(id, None)
.await
.map(|_| ())
.map_err(AutumnError::internal_server_error)
}
pub async fn mark_read_for(&self, recipient_id: i64, id: i64) -> AutumnResult<()> {
self.store
.boxed_mark_read(id, Some(recipient_id))
.await
.map(|_| ())
.map_err(AutumnError::internal_server_error)
}
pub async fn mark_all_read(&self, recipient_id: i64) -> AutumnResult<u64> {
self.store
.boxed_mark_all_read(recipient_id)
.await
.map_err(AutumnError::internal_server_error)
}
fn default_for(state: &AppState) -> Self {
#[cfg(feature = "db")]
if let Some(pool) = crate::db::DbState::pool(state) {
return Self::new(DbNotificationStore::new(pool.clone()));
}
Self::new(MemoryNotificationStore::new())
}
}
impl std::fmt::Debug for Notifications {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Notifications").finish_non_exhaustive()
}
}
impl axum::extract::FromRequestParts<AppState> for Notifications {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
_parts: &mut axum::http::request::Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let service = state.extension_or_insert_with::<Self>(|| Self::default_for(state));
#[cfg_attr(not(feature = "ws"), allow(unused_mut))]
let mut service = (*service).clone();
#[cfg(feature = "ws")]
{
service.channels = Some(state.channels().clone());
}
Ok(service)
}
}
#[derive(Debug, Default)]
pub struct MemoryNotificationStore {
inner: std::sync::Mutex<MemoryInner>,
}
#[derive(Debug, Default)]
struct MemoryInner {
next_id: i64,
rows: Vec<Notification>,
}
impl MemoryNotificationStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, MemoryInner>, NotificationStoreError> {
self.inner
.lock()
.map_err(|_| NotificationStoreError::new("memory store mutex poisoned"))
}
}
impl NotificationStore for MemoryNotificationStore {
async fn notify(
&self,
recipient_id: i64,
kind: String,
payload: Value,
) -> Result<Notification, NotificationStoreError> {
let mut inner = self.lock()?;
inner.next_id += 1;
let notification = Notification {
id: inner.next_id,
recipient_id,
kind,
payload,
read_at: None,
created_at: Utc::now(),
};
inner.rows.push(notification.clone());
drop(inner);
Ok(notification)
}
async fn list(
&self,
recipient_id: i64,
query: &ListQuery,
page: &PageRequest,
) -> Result<Page<Notification>, NotificationStoreError> {
let filter = ListFilter::from_query(query);
let inner = self.lock()?;
let mut rows: Vec<Notification> = inner
.rows
.iter()
.filter(|n| n.recipient_id == recipient_id)
.filter(|n| !filter.unread_only || n.read_at.is_none())
.filter(|n| filter.kind.as_deref().is_none_or(|k| n.kind == k))
.cloned()
.collect();
drop(inner);
match filter.sort {
FeedSort::CreatedAt => rows.sort_by_key(|n| (n.created_at, n.id)),
FeedSort::Id => rows.sort_by_key(|n| n.id),
}
if filter.dir == SortDir::Desc {
rows.reverse();
}
let total = i64::try_from(rows.len()).unwrap_or(i64::MAX);
let start = usize::try_from(page.offset()).unwrap_or(usize::MAX);
let items: Vec<Notification> = rows
.into_iter()
.skip(start)
.take(usize::try_from(page.limit()).unwrap_or(0))
.collect();
Ok(Page::new(items, total, page))
}
async fn unread_count(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
let inner = self.lock()?;
let count = inner
.rows
.iter()
.filter(|n| n.recipient_id == recipient_id && n.read_at.is_none())
.count();
drop(inner);
Ok(count as u64)
}
async fn mark_read(
&self,
id: i64,
recipient_id: Option<i64>,
) -> Result<u64, NotificationStoreError> {
let now = Utc::now();
let mut inner = self.lock()?;
let marked = inner
.rows
.iter_mut()
.filter(|n| n.id == id)
.filter(|n| recipient_id.is_none_or(|rid| n.recipient_id == rid))
.filter(|n| n.read_at.is_none())
.map(|n| n.read_at = Some(now))
.count();
drop(inner);
Ok(marked as u64)
}
async fn mark_all_read(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
let now = Utc::now();
let mut inner = self.lock()?;
let marked = inner
.rows
.iter_mut()
.filter(|n| n.recipient_id == recipient_id && n.read_at.is_none())
.map(|n| n.read_at = Some(now))
.count();
drop(inner);
Ok(marked as u64)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FeedSort {
Id,
CreatedAt,
}
struct ListFilter {
unread_only: bool,
kind: Option<String>,
sort: FeedSort,
dir: SortDir,
}
impl ListFilter {
fn from_query(query: &ListQuery) -> Self {
let mut unread_only = false;
let mut kind = None;
for (column, value) in query.filters() {
match column {
"unread" => unread_only = value == "true" || value == "1",
"kind" => kind = Some(value.to_owned()),
_ => {}
}
}
let (sort, dir) = match query.sort() {
Some("created_at") => (FeedSort::CreatedAt, query.direction()),
Some("id") => (FeedSort::Id, query.direction()),
None | Some(_) => (FeedSort::Id, SortDir::Desc),
};
Self {
unread_only,
kind,
sort,
dir,
}
}
}
#[cfg(feature = "db")]
pub use self::db_store::DbNotificationStore;
#[cfg(feature = "db")]
mod db_store {
use super::{
FeedSort, ListFilter, Notification, NotificationStore, NotificationStoreError, Value,
};
use crate::db::RuntimeConnection;
use crate::pagination::{ListQuery, Page, PageRequest, SortDir};
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use diesel_async::pooled_connection::deadpool::Pool;
#[cfg(not(feature = "sqlite"))]
mod schema {
diesel::table! {
notifications (id) {
id -> BigInt,
recipient_id -> BigInt,
kind -> Text,
payload -> Text,
read_at -> Nullable<Timestamptz>,
created_at -> Timestamptz,
}
}
}
#[cfg(feature = "sqlite")]
mod schema {
diesel::table! {
notifications (id) {
id -> BigInt,
recipient_id -> BigInt,
kind -> Text,
payload -> Text,
read_at -> Nullable<TimestamptzSqlite>,
created_at -> TimestamptzSqlite,
}
}
}
use schema::notifications;
#[derive(Queryable, Selectable)]
#[diesel(table_name = notifications)]
struct NotificationRow {
id: i64,
recipient_id: i64,
kind: String,
payload: String,
read_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
}
impl From<NotificationRow> for Notification {
fn from(row: NotificationRow) -> Self {
let payload = match serde_json::from_str(&row.payload) {
Ok(parsed) => parsed,
Err(_) => Value::String(row.payload),
};
Self {
id: row.id,
recipient_id: row.recipient_id,
kind: row.kind,
payload,
read_at: row.read_at,
created_at: row.created_at,
}
}
}
#[derive(Insertable)]
#[diesel(table_name = notifications)]
struct NewNotificationRow {
recipient_id: i64,
kind: String,
payload: String,
created_at: DateTime<Utc>,
}
#[derive(Clone)]
pub struct DbNotificationStore {
pool: Pool<RuntimeConnection>,
}
impl DbNotificationStore {
#[must_use]
pub const fn new(pool: Pool<RuntimeConnection>) -> Self {
Self { pool }
}
async fn conn(
&self,
) -> Result<
diesel_async::pooled_connection::deadpool::Object<RuntimeConnection>,
NotificationStoreError,
> {
self.pool
.get()
.await
.map_err(|e| NotificationStoreError::new(format!("checkout failed: {e}")))
}
}
fn store_err(e: &diesel::result::Error) -> NotificationStoreError {
let message = e.to_string();
if message.contains("does not exist") || message.contains("no such table") {
return NotificationStoreError::new(format!(
"query failed: {e}. The `notifications` table is missing — scaffold it \
with `autumn generate notifications`, then apply it with `autumn migrate`"
));
}
NotificationStoreError::new(format!("query failed: {e}"))
}
impl NotificationStore for DbNotificationStore {
async fn notify(
&self,
recipient_id: i64,
kind: String,
payload: Value,
) -> Result<Notification, NotificationStoreError> {
let payload = serde_json::to_string(&payload)
.map_err(|e| NotificationStoreError::new(format!("payload serialization: {e}")))?;
let mut conn = self.conn().await?;
let row: NotificationRow = diesel::insert_into(notifications::table)
.values(NewNotificationRow {
recipient_id,
kind,
payload,
created_at: Utc::now(),
})
.returning(NotificationRow::as_returning())
.get_result(&mut conn)
.await
.map_err(|e| store_err(&e))?;
Ok(row.into())
}
async fn list(
&self,
recipient_id: i64,
query: &ListQuery,
page: &PageRequest,
) -> Result<Page<Notification>, NotificationStoreError> {
use notifications::dsl;
let filter = ListFilter::from_query(query);
let mut conn = self.conn().await?;
let mut count_query = dsl::notifications
.filter(dsl::recipient_id.eq(recipient_id))
.into_boxed();
let mut select_query = dsl::notifications
.filter(dsl::recipient_id.eq(recipient_id))
.into_boxed();
if filter.unread_only {
count_query = count_query.filter(dsl::read_at.is_null());
select_query = select_query.filter(dsl::read_at.is_null());
}
if let Some(kind) = &filter.kind {
count_query = count_query.filter(dsl::kind.eq(kind.clone()));
select_query = select_query.filter(dsl::kind.eq(kind.clone()));
}
let total: i64 = count_query
.count()
.get_result(&mut conn)
.await
.map_err(|e| store_err(&e))?;
select_query = match (filter.sort, filter.dir) {
(FeedSort::Id, SortDir::Asc) => select_query.order(dsl::id.asc()),
(FeedSort::Id, SortDir::Desc) => select_query.order(dsl::id.desc()),
(FeedSort::CreatedAt, SortDir::Asc) => {
select_query.order((dsl::created_at.asc(), dsl::id.asc()))
}
(FeedSort::CreatedAt, SortDir::Desc) => {
select_query.order((dsl::created_at.desc(), dsl::id.desc()))
}
};
let rows: Vec<NotificationRow> = select_query
.select(NotificationRow::as_select())
.limit(page.limit())
.offset(page.offset())
.load(&mut conn)
.await
.map_err(|e| store_err(&e))?;
Ok(Page::new(
rows.into_iter().map(Notification::from).collect(),
total,
page,
))
}
async fn unread_count(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
use notifications::dsl;
let mut conn = self.conn().await?;
let count: i64 = dsl::notifications
.filter(dsl::recipient_id.eq(recipient_id))
.filter(dsl::read_at.is_null())
.count()
.get_result(&mut conn)
.await
.map_err(|e| store_err(&e))?;
Ok(u64::try_from(count).unwrap_or(0))
}
async fn mark_read(
&self,
id: i64,
recipient_id: Option<i64>,
) -> Result<u64, NotificationStoreError> {
use notifications::dsl;
let mut conn = self.conn().await?;
let now = Utc::now();
let affected = match recipient_id {
Some(rid) => {
diesel::update(
dsl::notifications
.filter(dsl::id.eq(id))
.filter(dsl::recipient_id.eq(rid))
.filter(dsl::read_at.is_null()),
)
.set(dsl::read_at.eq(Some(now)))
.execute(&mut conn)
.await
}
None => {
diesel::update(
dsl::notifications
.filter(dsl::id.eq(id))
.filter(dsl::read_at.is_null()),
)
.set(dsl::read_at.eq(Some(now)))
.execute(&mut conn)
.await
}
}
.map_err(|e| store_err(&e))?;
Ok(affected as u64)
}
async fn mark_all_read(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
use notifications::dsl;
let mut conn = self.conn().await?;
let affected = diesel::update(
dsl::notifications
.filter(dsl::recipient_id.eq(recipient_id))
.filter(dsl::read_at.is_null()),
)
.set(dsl::read_at.eq(Some(Utc::now())))
.execute(&mut conn)
.await
.map_err(|e| store_err(&e))?;
Ok(affected as u64)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn list_filter_defaults_to_newest_first() {
let filter = ListFilter::from_query(&ListQuery::default());
assert!(!filter.unread_only);
assert!(filter.kind.is_none());
assert_eq!(filter.sort, FeedSort::Id);
assert_eq!(filter.dir, SortDir::Desc);
}
#[test]
fn list_filter_parses_unread_and_kind() {
let query = ListQuery::new(None, SortDir::Asc, &[("unread", "1"), ("kind", "like")]);
let filter = ListFilter::from_query(&query);
assert!(filter.unread_only);
assert_eq!(filter.kind.as_deref(), Some("like"));
}
#[test]
fn list_filter_ignores_unknown_sort_and_filters() {
let query = ListQuery::new(Some("payload"), SortDir::Asc, &[("recipient_id", "9")]);
let filter = ListFilter::from_query(&query);
assert!(!filter.unread_only);
assert!(filter.kind.is_none());
assert_eq!(filter.sort, FeedSort::Id);
assert_eq!(
filter.dir,
SortDir::Desc,
"unknown sort falls back to newest-first"
);
}
#[test]
fn explicit_sort_uses_requested_direction() {
let query = ListQuery::new(Some("created_at"), SortDir::Asc, &[]);
let filter = ListFilter::from_query(&query);
assert_eq!(filter.sort, FeedSort::CreatedAt);
assert_eq!(filter.dir, SortDir::Asc);
}
#[tokio::test]
async fn memory_store_round_trips_null_payload() {
let notifications = Notifications::new(MemoryNotificationStore::new());
let n = notifications
.notify(1, "k", Value::Null)
.await
.expect("notify");
assert_eq!(n.payload, Value::Null);
let feed = notifications
.list(1, &ListQuery::default(), &PageRequest::default())
.await
.expect("list");
assert_eq!(feed.content[0].payload, Value::Null);
}
#[tokio::test]
async fn memory_store_round_trips_payload() {
let notifications = Notifications::new(MemoryNotificationStore::new());
let n = notifications
.notify(1, "k", json!({"deep": {"value": [1, 2, 3]}}))
.await
.expect("notify");
assert_eq!(n.payload, json!({"deep": {"value": [1, 2, 3]}}));
}
}