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}