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}