use std::collections::BTreeMap;
use std::sync::Arc;
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::User;
use crate::db::{DateTime, Db, now};
use crate::i18n::with_locale;
use crate::mail::Mail;
use crate::queue::{Job, JobContext};
use crate::toast::{ToastAction, ToastKind};
use crate::{AppState, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Channel {
Mail,
Database,
Custom(&'static str),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Recipient {
user: Option<User>,
routes: BTreeMap<String, String>,
#[serde(default)]
language: Option<String>,
}
impl Recipient {
pub fn for_user(user: &User) -> Self {
Self {
user: Some(user.clone()),
routes: BTreeMap::new(),
language: None,
}
}
pub fn user(&self) -> Option<&User> {
self.user.as_ref()
}
pub fn to(channel: &str, address: impl Into<String>) -> Self {
Self::default().and(channel, address)
}
pub fn and(mut self, channel: &str, address: impl Into<String>) -> Self {
self.routes.insert(channel.to_owned(), address.into());
self
}
pub fn address(&self, channel: &str) -> Option<String> {
self.routes.get(channel).cloned().or_else(|| match channel {
"mail" => self.user.as_ref().map(|u| u.email.clone()),
_ => None,
})
}
pub(crate) fn has_address(&self, address: &str) -> bool {
self.routes.values().any(|a| a == address) || self.email().as_deref() == Some(address)
}
pub fn email(&self) -> Option<String> {
self.address("mail")
}
pub fn in_locale(mut self, locale: impl Into<String>) -> Self {
self.language = Some(locale.into());
self
}
pub fn locale(&self) -> Option<String> {
self.language.clone().or_else(|| {
self.user
.as_ref()?
.get::<String>("locale")
.filter(|l| !l.is_empty())
})
}
}
impl From<&User> for Recipient {
fn from(user: &User) -> Self {
Self::for_user(user)
}
}
impl From<&crate::AuthUser> for Recipient {
fn from(user: &crate::AuthUser) -> Self {
Self::for_user(user)
}
}
impl From<&Recipient> for Recipient {
fn from(recipient: &Recipient) -> Self {
recipient.clone()
}
}
pub trait Notification: Send + Sync {
fn kind(&self) -> &'static str;
fn channels(&self, to: &Recipient) -> Vec<Channel> {
let _ = to;
vec![Channel::Mail]
}
fn to_mail(&self, _to: &Recipient, _state: &AppState) -> Result<Mail> {
Err(anyhow!("notification `{}` has no mail version", self.kind()).into())
}
fn to_database(&self, to: &Recipient, state: &AppState) -> Result<Value> {
let _ = (to, state);
Ok(Value::Null)
}
fn to_channel(&self, channel: &str, to: &Recipient, state: &AppState) -> Result<Value> {
let _ = (to, state);
Err(anyhow!(
"notification `{}` has no version for the `{channel}` channel",
self.kind()
)
.into())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DatabaseMessage {
pub status: ToastKind,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<ToastAction>,
#[serde(flatten)]
pub extra: serde_json::Map<String, Value>,
}
impl DatabaseMessage {
pub fn new(status: ToastKind, title: impl Into<String>) -> Self {
Self {
status,
title: title.into(),
body: None,
url: None,
actions: Vec::new(),
extra: serde_json::Map::new(),
}
}
pub fn success(title: impl Into<String>) -> Self {
Self::new(ToastKind::Success, title)
}
pub fn info(title: impl Into<String>) -> Self {
Self::new(ToastKind::Info, title)
}
pub fn warning(title: impl Into<String>) -> Self {
Self::new(ToastKind::Warning, title)
}
pub fn error(title: impl Into<String>) -> Self {
Self::new(ToastKind::Error, title)
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
pub fn action(mut self, action: ToastAction) -> Self {
self.actions.push(action);
self
}
pub fn link(self, label: impl Into<String>, url: impl Into<String>) -> Self {
self.action(ToastAction::link(label, url))
}
pub fn with(mut self, key: &str, value: impl Serialize) -> Self {
self.extra.insert(
key.to_owned(),
serde_json::to_value(value).unwrap_or(Value::Null),
);
self
}
}
impl From<DatabaseMessage> for Value {
fn from(message: DatabaseMessage) -> Self {
serde_json::to_value(message).unwrap_or(Value::Null)
}
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct DatabaseNotification {
pub id: i64,
pub kind: String,
pub data: Value,
pub read_at: Option<DateTime>,
pub created_at: DateTime,
}
impl DatabaseNotification {
pub fn message(&self) -> Option<DatabaseMessage> {
serde_json::from_value(self.data.clone()).ok()
}
}
pub(crate) struct Hub {
tx: tokio::sync::broadcast::Sender<Signal>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Signal {
User(i64),
Event(Option<i64>, Arc<Broadcast>),
Stop,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Broadcast {
pub(crate) event: String,
pub(crate) data: String,
}
impl Hub {
pub(crate) fn new() -> Self {
Self {
tx: tokio::sync::broadcast::channel(256).0,
}
}
pub(crate) fn touch(&self, user_id: i64) {
let _ = self.tx.send(Signal::User(user_id));
}
pub(crate) fn event(&self, user_id: Option<i64>, event: Broadcast) {
let _ = self.tx.send(Signal::Event(user_id, Arc::new(event)));
}
pub(crate) fn stop(&self) {
let _ = self.tx.send(Signal::Stop);
}
pub(crate) fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Signal> {
self.tx.subscribe()
}
}
pub(crate) type ChannelFn = Arc<
dyn Fn(
AppState,
Recipient,
Value,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result> + Send>>
+ Send
+ Sync,
>;
pub(crate) fn channel_fn<F, Fut>(send: F) -> ChannelFn
where
F: Fn(Recipient, Value, AppState) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result> + Send + 'static,
{
Arc::new(move |state, to, message| Box::pin(send(to, message, state)))
}
#[derive(Serialize, Deserialize)]
pub(crate) struct SendToChannel {
channel: String,
to: Recipient,
message: Value,
}
impl Job for SendToChannel {
const NAME: &'static str = "renox.send-to-channel";
const MAX_ATTEMPTS: u32 = 5;
async fn handle(self, ctx: JobContext) -> Result {
let send = ctx.state.channel(&self.channel)?;
send(ctx.state.clone(), self.to, self.message).await
}
}
fn ordered(notification: &impl Notification, to: &Recipient) -> Vec<Channel> {
let mut channels = notification.channels(to);
channels.sort_by_key(|channel| match channel {
Channel::Database => 0,
Channel::Custom(_) => 1,
_ => 2,
});
channels
}
fn event_name(name: &str) -> Result<&str> {
let valid = !name.is_empty()
&& name.len() <= 100
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'));
if valid {
Ok(name)
} else {
Err(anyhow!(
"`{name}` can't be a broadcast event's name: use letters, digits, `-`, `_`, `:` and `.`"
)
.into())
}
}
impl AppState {
pub fn broadcast(&self, event: &str, data: impl Serialize) -> Result {
self.send_broadcast(None, event, data)
}
pub fn broadcast_to(&self, user_id: i64, event: &str, data: impl Serialize) -> Result {
self.send_broadcast(Some(user_id), event, data)
}
fn send_broadcast(&self, user_id: Option<i64>, event: &str, data: impl Serialize) -> Result {
let event = event_name(event)?.to_owned();
let data = serde_json::to_value(&data).map_err(anyhow::Error::from)?;
let sent = crate::SentBroadcast {
user_id,
event,
data,
};
if self.fakes.record_broadcast(sent.clone()) {
return Ok(());
}
self.notification_hub.event(
user_id,
Broadcast {
event: sent.event,
data: sent.data.to_string(),
},
);
Ok(())
}
fn channel(&self, name: &str) -> Result<ChannelFn> {
self.channels.get(name).cloned().ok_or_else(|| {
anyhow!("no `{name}` notification channel: register it with `App::channel`").into()
})
}
async fn store_notification(&self, to: &Recipient, notification: &impl Notification) -> Result {
let Some(user) = &to.user else { return Ok(()) };
crate::db::sql(
"INSERT INTO notifications (user_id, kind, data, created_at) VALUES (?, ?, ?, ?)",
)
.bind(user.id)
.bind(notification.kind())
.bind(
with_locale(to.locale().as_deref(), || {
notification.to_database(to, self)
})?
.to_string(),
)
.bind(now())
.execute(&self.db)
.await?;
self.notification_hub.touch(user.id);
Ok(())
}
pub async fn notify(
&self,
to: impl Into<Recipient>,
notification: &impl Notification,
) -> Result {
let to = &to.into();
if self.fakes.record_notification(notification.kind(), to) {
return Ok(());
}
let locale = to.locale();
let locale = locale.as_deref();
for channel in ordered(notification, to) {
match channel {
Channel::Database => self.store_notification(to, notification).await?,
Channel::Custom(name) => {
let send = self.channel(name)?;
let message = with_locale(locale, || notification.to_channel(name, to, self))?;
send(self.clone(), to.clone(), message).await?;
}
Channel::Mail => {
let mail = with_locale(locale, || notification.to_mail(to, self))?;
self.mailer.send(mail).await?;
}
}
}
Ok(())
}
pub async fn notify_later(
&self,
to: impl Into<Recipient>,
notification: &impl Notification,
) -> Result {
let to = to.into();
if self.fakes.record_notification(notification.kind(), &to) {
return Ok(());
}
let locale = to.locale();
let locale = locale.as_deref();
for channel in ordered(notification, &to) {
match channel {
Channel::Database => self.store_notification(&to, notification).await?,
Channel::Custom(name) => {
self.channel(name)?; let message = with_locale(locale, || notification.to_channel(name, &to, self))?;
self.dispatch(SendToChannel {
channel: name.to_owned(),
to: to.clone(),
message,
})
.await?;
}
Channel::Mail => {
let mail = with_locale(locale, || notification.to_mail(&to, self))?;
self.queue_mail(mail).await?;
}
}
}
Ok(())
}
}
pub(super) fn from_row(row: &crate::db::Row) -> Result<DatabaseNotification> {
let data: String = row.try_get("data")?;
Ok(DatabaseNotification {
id: row.try_get("id")?,
kind: row.try_get("kind")?,
data: serde_json::from_str(&data).unwrap_or(Value::Null),
read_at: row.try_get("read_at")?,
created_at: row.try_get("created_at")?,
})
}
impl User {
pub async fn notifications(&self, db: &Db, limit: u32) -> Result<Vec<DatabaseNotification>> {
let rows = crate::db::sql(
"SELECT id, kind, data, read_at, created_at FROM notifications \
WHERE user_id = ? ORDER BY id DESC LIMIT ?",
)
.bind(self.id)
.bind(i64::from(limit))
.fetch_all(db)
.await?;
rows.iter().map(from_row).collect()
}
pub async fn notifications_before(
&self,
db: &Db,
before: i64,
limit: u32,
) -> Result<Vec<DatabaseNotification>> {
let rows = crate::db::sql(
"SELECT id, kind, data, read_at, created_at FROM notifications \
WHERE user_id = ? AND id < ? ORDER BY id DESC LIMIT ?",
)
.bind(self.id)
.bind(before)
.bind(i64::from(limit))
.fetch_all(db)
.await?;
rows.iter().map(from_row).collect()
}
pub async fn notification(&self, db: &Db, id: i64) -> Result<Option<DatabaseNotification>> {
let row = crate::db::sql(
"SELECT id, kind, data, read_at, created_at FROM notifications \
WHERE id = ? AND user_id = ?",
)
.bind(id)
.bind(self.id)
.fetch_optional(db)
.await?;
row.as_ref().map(from_row).transpose()
}
pub async fn unread_notifications(&self, db: &Db) -> Result<Vec<DatabaseNotification>> {
let rows = crate::db::sql(
"SELECT id, kind, data, read_at, created_at FROM notifications \
WHERE user_id = ? AND read_at IS NULL ORDER BY id DESC",
)
.bind(self.id)
.fetch_all(db)
.await?;
rows.iter().map(from_row).collect()
}
pub async fn unread_notification_count(&self, db: &Db) -> Result<i64> {
Ok(crate::db::sql(
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND read_at IS NULL",
)
.bind(self.id)
.scalar(db)
.await?)
}
pub async fn mark_notification_read(&self, db: &Db, id: i64) -> Result<bool> {
let done = crate::db::sql(
"UPDATE notifications SET read_at = COALESCE(read_at, ?) WHERE id = ? AND user_id = ?",
)
.bind(now())
.bind(id)
.bind(self.id)
.execute(db)
.await?;
Ok(done > 0)
}
pub async fn mark_notification_unread(&self, db: &Db, id: i64) -> Result<bool> {
let done =
crate::db::sql("UPDATE notifications SET read_at = NULL WHERE id = ? AND user_id = ?")
.bind(id)
.bind(self.id)
.execute(db)
.await?;
Ok(done > 0)
}
pub async fn delete_notification(&self, db: &Db, id: i64) -> Result<bool> {
let done = crate::db::sql("DELETE FROM notifications WHERE id = ? AND user_id = ?")
.bind(id)
.bind(self.id)
.execute(db)
.await?;
Ok(done > 0)
}
pub async fn delete_notifications(&self, db: &Db) -> Result<u64> {
Ok(
crate::db::sql("DELETE FROM notifications WHERE user_id = ?")
.bind(self.id)
.execute(db)
.await?,
)
}
pub async fn mark_all_notifications_read(&self, db: &Db) -> Result<u64> {
let done = crate::db::sql(
"UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL",
)
.bind(now())
.bind(self.id)
.execute(db)
.await?;
Ok(done)
}
}
pub async fn prune_read_notifications(db: &Db, age: std::time::Duration) -> Result<u64> {
let before = now() - chrono::Duration::from_std(age).unwrap_or_default();
Ok(
crate::db::sql("DELETE FROM notifications WHERE read_at < ?")
.bind(before)
.execute(db)
.await?,
)
}