Skip to main content

tablo_core/notification/
stored.rs

1//! Stored notifications: the [`StoredNotification`] rows a panel user's bell lists, the
2//! [`Notifications`] handle that sends them, and the [`Inbox`] every read and write of the bell
3//! goes through.
4
5use std::time::Duration;
6
7use jiff::Timestamp;
8use toasty::{Db, stmt::Expr};
9use topcoat::context::{Cx, CxId, app_context};
10use uuid::Uuid;
11
12use super::Notification;
13use crate::{
14    auth::current_realm,
15    bus::{NotificationBus, bus},
16};
17
18/// Shipped record of a [`Notification`] kept for one panel user: what it says, whose it is, on
19/// which panel and for which tenant, and whether they read it.
20///
21/// [`Notifications::send`] writes it and the bell of a panel with
22/// [`Panel::notifications`](crate::Panel::notifications) lists it.
23#[derive(Debug, Clone, toasty::Model)]
24#[index(user_id, panel, created_at)]
25pub struct StoredNotification {
26    #[key]
27    #[auto]
28    pub id: Uuid,
29    /// [`PanelUser::user_id`](crate::PanelUser::user_id) of the user it is for.
30    pub user_id: String,
31    /// The prefix of the panel whose bell lists it, e.g. `/admin`.
32    pub panel: String,
33    /// The tenant it belongs to: the bell lists it only while the user acts for that tenant.
34    /// `None` lists it whichever tenant they act for.
35    pub tenant: Option<Uuid>,
36    /// What it says, as JSON, so a field a later release adds to [`Notification`] needs no
37    /// migration.
38    #[column(type = text)]
39    pub notification: toasty::Json<Notification>,
40    /// When the user marked it read; `None` while unread.
41    pub read_at: Option<Timestamp>,
42    pub created_at: Timestamp,
43}
44
45/// Sends [`StoredNotification`]s to one panel's users, and wakes their open bells. Cloning it is
46/// cheap.
47///
48/// In a request, [`Notifications::of`] sends on the request's panel, for the request's tenant. A
49/// job takes the handle from [`PanelHandle::notifications`](crate::PanelHandle::notifications).
50///
51/// ```rust,no_run
52/// # use tablo_core::{Notification, Notifications, PanelUser, auth};
53/// # use topcoat::context::Cx;
54/// # async fn exported(cx: &Cx, owner: &dyn PanelUser) -> topcoat::Result<()> {
55/// Notifications::of(cx)?
56///     .send(
57///         &owner.user_id(),
58///         Notification::success("Your export is ready").action("Download", "/exports/42.csv"),
59///     )
60///     .await?;
61/// # Ok(())
62/// # }
63/// ```
64#[derive(Clone)]
65pub struct Notifications {
66    db: Db,
67    bus: NotificationBus,
68    panel: String,
69    tenant: Option<Uuid>,
70    /// The request [`Self::of`] took it from; `None` for a job's handle.
71    request: Option<CxId>,
72}
73
74impl Notifications {
75    /// Sends on the request's panel, for the tenant the request acts for: the bell lists what it
76    /// sends only while the recipient acts for the same tenant. [`tenant`](Self::tenant) changes
77    /// it.
78    ///
79    /// # Errors
80    ///
81    /// A declaration error outside every panel, such as on a public page that did not
82    /// [`enter`](crate::PanelHandle::enter) one.
83    pub fn of(cx: &Cx) -> topcoat::Result<Self> {
84        let Some(realm) = current_realm(cx) else {
85            return Err(crate::error::declaration(
86                "Notifications::of needs a request under a panel".to_owned(),
87            ));
88        };
89        // The pool itself, not `db(cx)`: a hook inside the write's transaction may take the
90        // handle to `send_in` through it.
91        let mut notifications = Self::new(
92            app_context::<Db>(cx).clone(),
93            bus(cx),
94            realm.prefix.clone(),
95            crate::tenant_id(cx),
96        );
97        notifications.request = Some(cx.id());
98        Ok(notifications)
99    }
100
101    pub(crate) fn new(db: Db, bus: NotificationBus, panel: String, tenant: Option<Uuid>) -> Self {
102        Self {
103            db,
104            bus,
105            panel,
106            tenant,
107            request: None,
108        }
109    }
110
111    /// Sends for `tenant` instead: the bell lists what this handle sends only while the
112    /// recipient acts for it, or whichever tenant they act for with `None`.
113    #[must_use]
114    pub fn tenant(mut self, tenant: impl Into<Option<Uuid>>) -> Self {
115        self.tenant = tenant.into();
116        self
117    }
118
119    /// Stores `notification` for the panel user `user_id`, their
120    /// [`PanelUser::user_id`](crate::PanelUser::user_id), unread, and wakes the bells they have
121    /// open on the panel. It commits on its own connection: inside a write's transaction, such as
122    /// in [`Resource::before_commit`](crate::Resource::before_commit), use [`Self::send_in`].
123    ///
124    /// # Errors
125    ///
126    /// An opaque infrastructure failure when the row cannot be written, e.g. the database does
127    /// not register [`StoredNotification`]; the driver's text goes to the log. A declaration
128    /// failure on a handle from [`Self::of`] while the request's panel write has its transaction
129    /// open, as [`db`](crate::db::db) refuses.
130    pub async fn send(
131        &self,
132        user_id: &str,
133        notification: Notification,
134    ) -> topcoat::Result<StoredNotification> {
135        if let Some(request) = self.request {
136            crate::db::no_write_open(request, "Notifications::send")?;
137        }
138        let stored = self
139            .store(&mut self.db.clone(), user_id, notification)
140            .await?;
141        self.bus.wake(&self.panel, user_id);
142        Ok(stored)
143    }
144
145    /// Stores `notification` as [`Self::send`] does, through `ex`, so it exists only if the
146    /// transaction `ex` runs commits. Within a panel write, such as in
147    /// [`Resource::before_commit`](crate::Resource::before_commit) on a handle from
148    /// [`Self::of`], the recipient's open bells wake once the write commits, and not at all if it
149    /// rolls back; anywhere else they wake at once, and pick the row up after the commit when
150    /// they next refresh.
151    ///
152    /// ```rust
153    /// # use tablo_core::{Change, NoForm, Notification, Notifications, Resource};
154    /// # use topcoat::{Result, context::Cx};
155    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
156    /// # struct Post { #[key] #[auto] id: uuid::Uuid, author: String }
157    /// # struct PostResource;
158    /// # impl Resource for PostResource {
159    /// #     type Model = Post;
160    /// #     type Form = NoForm<Post>;
161    /// async fn before_commit(
162    ///     cx: &Cx,
163    ///     change: &Change<Post>,
164    ///     ex: &mut dyn toasty::Executor,
165    /// ) -> Result<()> {
166    ///     if let Change::Update { after, .. } = change {
167    ///         Notifications::of(cx)?
168    ///             .send_in(
169    ///                 &mut *ex,
170    ///                 &after.author,
171    ///                 Notification::info("Your post was edited"),
172    ///             )
173    ///             .await?;
174    ///     }
175    ///     Ok(())
176    /// }
177    /// # }
178    /// ```
179    ///
180    /// # Errors
181    ///
182    /// As [`Self::send`]. A failure inside a transaction leaves it to roll back.
183    pub async fn send_in(
184        &self,
185        ex: &mut dyn toasty::Executor,
186        user_id: &str,
187        notification: Notification,
188    ) -> topcoat::Result<StoredNotification> {
189        let stored = self.store(ex, user_id, notification).await?;
190        let (bus, panel, user_id) = (self.bus.clone(), self.panel.clone(), user_id.to_owned());
191        let wake = move || bus.wake(&panel, &user_id);
192        let unqueued = match self.request {
193            Some(request) => crate::db::after_write_commits(request, wake),
194            None => Err(wake),
195        };
196        if let Err(wake) = unqueued {
197            wake();
198        }
199        Ok(stored)
200    }
201
202    async fn store(
203        &self,
204        ex: &mut dyn toasty::Executor,
205        user_id: &str,
206        notification: Notification,
207    ) -> topcoat::Result<StoredNotification> {
208        toasty::create!(StoredNotification {
209            user_id: user_id.to_owned(),
210            panel: self.panel.clone(),
211            tenant: self.tenant,
212            notification: toasty::Json(notification),
213            read_at: None,
214            created_at: Timestamp::now(),
215        })
216        .exec(ex)
217        .await
218        .map_err(failure)
219    }
220
221    /// Deletes the panel's stored notifications, read or not, sent more than `age` ago. Nothing
222    /// prunes them otherwise; a job calls it on the schedule the app keeps them for.
223    ///
224    /// # Errors
225    ///
226    /// An opaque infrastructure failure when the rows cannot be deleted.
227    pub async fn prune(&self, age: Duration) -> topcoat::Result<()> {
228        let cutoff = Timestamp::now()
229            .checked_sub(jiff::SignedDuration::try_from(age).map_err(topcoat::Error::from)?)
230            .map_err(topcoat::Error::from)?;
231        let fields = StoredNotification::fields();
232        StoredNotification::filter(
233            fields
234                .panel()
235                .eq(self.panel.clone())
236                .and(fields.created_at().lt(cutoff)),
237        )
238        .delete()
239        .exec(&mut self.db.clone())
240        .await
241        .map_err(failure)
242    }
243}
244
245/// A failed read or write of stored notifications: the driver's text goes to the log, never to
246/// the page.
247fn failure(error: toasty::Error) -> topcoat::Error {
248    crate::error::driver_failure(error, "Notifications are unavailable right now.")
249}
250
251/// The stored notifications the signed-in user's bell answers for: theirs, on the request's
252/// panel, for the tenant the request acts for or for none. Every read and write the bell makes
253/// filters through it, never through an id alone.
254#[derive(Clone)]
255pub(crate) struct Inbox {
256    pub(crate) panel: String,
257    pub(crate) user_id: String,
258    tenant: Option<Uuid>,
259}
260
261impl Inbox {
262    /// The signed-in user's inbox; `None` without one.
263    pub(crate) fn of(cx: &Cx) -> Option<Self> {
264        let signed = crate::auth::signed(cx)?;
265        Some(Self {
266            panel: signed.realm.prefix.clone(),
267            user_id: signed.user.user_id(),
268            tenant: crate::tenant_id(cx),
269        })
270    }
271
272    /// The rows this inbox holds.
273    fn holds(&self) -> Expr<bool> {
274        let fields = StoredNotification::fields();
275        let tenant = match self.tenant {
276            Some(tenant) => fields.tenant().is_none().or(fields.tenant().eq(tenant)),
277            None => fields.tenant().is_none(),
278        };
279        fields
280            .user_id()
281            .eq(self.user_id.clone())
282            .and(fields.panel().eq(self.panel.clone()))
283            .and(tenant)
284    }
285
286    /// The row `id` names, when this inbox holds it.
287    fn holds_id(&self, id: Uuid) -> Expr<bool> {
288        StoredNotification::fields().id().eq(id).and(self.holds())
289    }
290
291    /// Up to `limit` rows, newest first.
292    pub(crate) async fn latest(
293        &self,
294        db: &Db,
295        limit: usize,
296    ) -> topcoat::Result<Vec<StoredNotification>> {
297        StoredNotification::filter(self.holds())
298            .order_by(StoredNotification::fields().created_at().desc())
299            .limit(limit)
300            .exec(&mut db.clone())
301            .await
302            .map_err(failure)
303    }
304
305    /// How many rows are unread.
306    pub(crate) async fn unread(&self, db: &Db) -> topcoat::Result<u64> {
307        StoredNotification::filter(
308            self.holds()
309                .and(StoredNotification::fields().read_at().is_none()),
310        )
311        .count()
312        .exec(&mut db.clone())
313        .await
314        .map_err(failure)
315    }
316
317    /// Marks the row `id` names read, or unread with `read` false.
318    pub(crate) async fn mark(&self, db: &Db, id: Uuid, read: bool) -> topcoat::Result<()> {
319        StoredNotification::filter(self.holds_id(id))
320            .update()
321            .read_at(read.then(Timestamp::now))
322            .exec(&mut db.clone())
323            .await
324            .map_err(failure)
325    }
326
327    /// Marks every unread row read.
328    pub(crate) async fn mark_all_read(&self, db: &Db) -> topcoat::Result<()> {
329        StoredNotification::filter(
330            self.holds()
331                .and(StoredNotification::fields().read_at().is_none()),
332        )
333        .update()
334        .read_at(Some(Timestamp::now()))
335        .exec(&mut db.clone())
336        .await
337        .map_err(failure)
338    }
339
340    /// Deletes the row `id` names.
341    pub(crate) async fn remove(&self, db: &Db, id: Uuid) -> topcoat::Result<()> {
342        StoredNotification::filter(self.holds_id(id))
343            .delete()
344            .exec(&mut db.clone())
345            .await
346            .map_err(failure)
347    }
348
349    /// Deletes every row.
350    pub(crate) async fn clear(&self, db: &Db) -> topcoat::Result<()> {
351        StoredNotification::filter(self.holds())
352            .delete()
353            .exec(&mut db.clone())
354            .await
355            .map_err(failure)
356    }
357}