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