Skip to main content

autumn_web/
notifications.rs

1//! First-class in-app notification store with a read/unread feed (issue
2//! #1148).
3//!
4//! Nearly every account-based app grows a "notification bell": a persistent,
5//! per-recipient feed with read/unread state and an unread count. This module
6//! ships that composition on top of the primitives Autumn already provides —
7//! the database layer for persistence, [`Page`]/[`ListQuery`] for the feed,
8//! and (optionally) `channels` for realtime push — so an app adds a feed with
9//! one `notify(...)` call instead of a hand-rolled table and four queries.
10//!
11//! # Quick start
12//!
13//! Declare the [`Notifications`] extractor in any handler:
14//!
15//! ```rust,ignore
16//! use autumn_web::notifications::Notifications;
17//! use autumn_web::prelude::*;
18//!
19//! #[post("/comments")]
20//! async fn create_comment(notifications: Notifications) -> AutumnResult<&'static str> {
21//!     // ... create the comment ...
22//!     notifications
23//!         .notify(recipient_id, "comment.created", serde_json::json!({"post": 42}))
24//!         .await?;
25//!     Ok("ok")
26//! }
27//! ```
28//!
29//! Reading the feed reuses the shipped pagination types:
30//!
31//! ```rust,ignore
32//! #[get("/notifications")]
33//! async fn feed(
34//!     Auth(user): Auth<CurrentUser>,
35//!     query: ListQuery,
36//!     page: PageRequest,
37//!     notifications: Notifications,
38//! ) -> AutumnResult<Json<Page<Notification>>> {
39//!     Ok(Json(notifications.list(user.id, &query, &page).await?))
40//! }
41//! ```
42//!
43//! # Storage backends
44//!
45//! [`Notifications`] delegates to a pluggable [`NotificationStore`], resolved
46//! per app in this order:
47//!
48//! 1. A store registered via `AppBuilder::with_notification_store(...)`.
49//! 2. [`DbNotificationStore`] when a database pool is configured (the
50//!    `notifications` table is scaffolded by `autumn generate notifications`).
51//! 3. [`MemoryNotificationStore`] otherwise — a process-local fallback for
52//!    tests and DB-less development.
53//!
54//! This mirrors how `Session` resolves its `SessionStore`.
55//!
56//! # Realtime push over `channels` (optional, best-effort)
57//!
58//! With the `ws` feature enabled, [`Notifications::notify_with_push`] persists
59//! the notification and then publishes its JSON on the conventional
60//! per-recipient topic [`Notifications::topic`] (`notifications:{recipient}`).
61//! The broadcast is **best-effort**: a channel failure (including the common
62//! "no subscribers" case) never fails `notify`. Equivalent manual wiring:
63//!
64//! ```rust,ignore
65//! let n = notifications.notify(user.id, "like", payload).await?;
66//! // Best-effort: ignore the publish result.
67//! let _ = state
68//!     .broadcast()
69//!     .publish(&Notifications::topic(user.id), serde_json::to_string(&n)?);
70//! ```
71//!
72//! # Multi-tenancy
73//!
74//! Rows carry a `tenant_id` column. The framework fills it from the tenant it
75//! resolved for the request. When tenancy is off, the tenant is `""`. Two
76//! tenants can use the same `recipient_id`. They never see each other's feed,
77//! counts or channel topic. Outside a request (a job, a script) wrap the call in
78//! [`with_tenant`](crate::tenancy::with_tenant).
79//!
80//! # List filters and ordering
81//!
82//! [`Notifications::list`] understands two equality filters from
83//! [`ListQuery`]: `unread` (`filter[unread]=true` keeps only rows whose
84//! `read_at` is unset) and `kind` (`filter[kind]=comment.created`). Sorting
85//! accepts `id` or `created_at`; without an explicit `sort=` the feed returns
86//! newest-first. Unknown filters and sort keys are ignored, matching the
87//! repository `list()` contract.
88
89// autumn-determinism-gate: production code in this module must read time and
90// mint identifiers through the framework's injected seams (ClockSource /
91// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
92// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
93// (issue #1797). Justify exceptions with
94// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
95#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
96
97use std::future::Future;
98use std::pin::Pin;
99use std::sync::Arc;
100
101use chrono::{DateTime, Utc};
102use serde::{Deserialize, Serialize};
103use serde_json::Value;
104
105use crate::error::{AutumnError, AutumnResult};
106use crate::pagination::{ListQuery, Page, PageRequest, SortDir};
107use crate::state::AppState;
108
109/// The `notifications` table name shared by [`DbNotificationStore`] and the
110/// `autumn generate notifications` scaffolding.
111pub const NOTIFICATIONS_TABLE: &str = "notifications";
112
113// ── Record ──────────────────────────────────────────────────────────────────
114
115/// A persisted per-recipient notification.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Notification {
118    /// Primary key.
119    pub id: i64,
120    /// The user this notification belongs to.
121    pub recipient_id: i64,
122    /// Application-defined discriminator, e.g. `"comment.created"`.
123    pub kind: String,
124    /// Application-defined JSON payload.
125    pub payload: Value,
126    /// When the notification was marked read; `None` while unread.
127    pub read_at: Option<DateTime<Utc>>,
128    /// Creation time.
129    pub created_at: DateTime<Utc>,
130}
131
132impl Notification {
133    /// `true` once the notification has been marked read.
134    #[must_use]
135    pub const fn is_read(&self) -> bool {
136        self.read_at.is_some()
137    }
138}
139
140// ── Store error ─────────────────────────────────────────────────────────────
141
142/// Error returned by [`NotificationStore`] implementations.
143#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
144#[error("notification store error: {message}")]
145pub struct NotificationStoreError {
146    message: String,
147}
148
149impl NotificationStoreError {
150    /// Create a new error with the given message.
151    #[must_use]
152    pub fn new(message: impl Into<String>) -> Self {
153        Self {
154            message: message.into(),
155        }
156    }
157}
158
159// ── Store trait ─────────────────────────────────────────────────────────────
160
161/// Pluggable notification storage backend.
162///
163/// Implement this to store notifications somewhere other than the built-in
164/// backends. All read/unread semantics live in the store so every backend
165/// behaves identically:
166///
167/// - `unread_count` and the `unread` list filter count only rows whose
168///   `read_at` is unset.
169/// - Rows belong to a tenant. The built-in stores read the tenant from the
170///   task-local [`CURRENT_TENANT`](crate::tenancy::CURRENT_TENANT), never from
171///   the request, and scope every method by it (`""` outside a tenant scope).
172///   A custom store must do the same.
173/// - `mark_read`/`mark_all_read` are idempotent: re-marking a read
174///   notification is a no-op (it must not error and must not overwrite the
175///   original `read_at`).
176pub trait NotificationStore: Send + Sync + 'static {
177    /// Persist a new, unread notification and return the stored record.
178    fn notify(
179        &self,
180        recipient_id: i64,
181        kind: String,
182        payload: Value,
183    ) -> impl Future<Output = Result<Notification, NotificationStoreError>> + Send;
184
185    /// One page of the recipient's feed. See the module docs for the
186    /// supported filters (`unread`, `kind`) and sort keys (`id`,
187    /// `created_at`; default newest-first).
188    fn list(
189        &self,
190        recipient_id: i64,
191        query: &ListQuery,
192        page: &PageRequest,
193    ) -> impl Future<Output = Result<Page<Notification>, NotificationStoreError>> + Send;
194
195    /// Number of unread notifications for the recipient.
196    fn unread_count(
197        &self,
198        recipient_id: i64,
199    ) -> impl Future<Output = Result<u64, NotificationStoreError>> + Send;
200
201    /// Mark one notification read, optionally scoped to a recipient. Returns
202    /// the number of rows transitioned (0 when already read, missing, or —
203    /// with a recipient scope — owned by someone else).
204    fn mark_read(
205        &self,
206        id: i64,
207        recipient_id: Option<i64>,
208    ) -> impl Future<Output = Result<u64, NotificationStoreError>> + Send;
209
210    /// Mark all of the recipient's unread notifications read. Returns the
211    /// number of rows transitioned.
212    fn mark_all_read(
213        &self,
214        recipient_id: i64,
215    ) -> impl Future<Output = Result<u64, NotificationStoreError>> + Send;
216}
217
218// ── Erasure bridge (same shape as session::BoxedSessionStore) ───────────────
219//
220// `NotificationStore` uses RPIT futures and is not dyn-compatible, so
221// [`Notifications`] holds a pub(crate) shadow trait with a blanket impl.
222// Users only ever see `NotificationStore`.
223
224type BoxedFuture<'a, T> =
225    Pin<Box<dyn Future<Output = Result<T, NotificationStoreError>> + Send + 'a>>;
226
227pub(crate) trait BoxedNotificationStore: Send + Sync + 'static {
228    fn boxed_notify(
229        &self,
230        recipient_id: i64,
231        kind: String,
232        payload: Value,
233    ) -> BoxedFuture<'_, Notification>;
234    fn boxed_list(
235        &self,
236        recipient_id: i64,
237        query: ListQuery,
238        page: PageRequest,
239    ) -> BoxedFuture<'_, Page<Notification>>;
240    fn boxed_unread_count(&self, recipient_id: i64) -> BoxedFuture<'_, u64>;
241    fn boxed_mark_read(&self, id: i64, recipient_id: Option<i64>) -> BoxedFuture<'_, u64>;
242    fn boxed_mark_all_read(&self, recipient_id: i64) -> BoxedFuture<'_, u64>;
243}
244
245impl<S: NotificationStore> BoxedNotificationStore for S {
246    fn boxed_notify(
247        &self,
248        recipient_id: i64,
249        kind: String,
250        payload: Value,
251    ) -> BoxedFuture<'_, Notification> {
252        Box::pin(NotificationStore::notify(self, recipient_id, kind, payload))
253    }
254
255    fn boxed_list(
256        &self,
257        recipient_id: i64,
258        query: ListQuery,
259        page: PageRequest,
260    ) -> BoxedFuture<'_, Page<Notification>> {
261        Box::pin(async move { NotificationStore::list(self, recipient_id, &query, &page).await })
262    }
263
264    fn boxed_unread_count(&self, recipient_id: i64) -> BoxedFuture<'_, u64> {
265        Box::pin(NotificationStore::unread_count(self, recipient_id))
266    }
267
268    fn boxed_mark_read(&self, id: i64, recipient_id: Option<i64>) -> BoxedFuture<'_, u64> {
269        Box::pin(NotificationStore::mark_read(self, id, recipient_id))
270    }
271
272    fn boxed_mark_all_read(&self, recipient_id: i64) -> BoxedFuture<'_, u64> {
273        Box::pin(NotificationStore::mark_all_read(self, recipient_id))
274    }
275}
276
277// ── Service / extractor ─────────────────────────────────────────────────────
278
279/// The in-app notifications service.
280///
281/// Declare it as a handler parameter (it implements `FromRequestParts`, like
282/// [`Session`](crate::session::Session)) or construct one directly with
283/// [`Notifications::new`] in tests and background jobs. See the module docs
284/// for store resolution and examples.
285#[derive(Clone)]
286pub struct Notifications {
287    store: Arc<dyn BoxedNotificationStore>,
288    #[cfg(feature = "ws")]
289    channels: Option<crate::channels::Channels>,
290}
291
292impl Notifications {
293    /// Build a service over an explicit store.
294    #[must_use]
295    pub fn new(store: impl NotificationStore) -> Self {
296        Self {
297            store: Arc::new(store),
298            #[cfg(feature = "ws")]
299            channels: None,
300        }
301    }
302
303    /// The conventional per-recipient channel topic
304    /// (`notifications:{recipient_id}`, or `notifications:{tenant}:{recipient_id}`
305    /// inside a tenant scope) used by
306    /// [`notify_with_push`](Self::notify_with_push) and available to
307    /// subscribers (e.g. a WebSocket feed handler).
308    #[must_use]
309    pub fn topic(recipient_id: i64) -> String {
310        let tenant = crate::tenancy::current_tenant_id();
311        if tenant.is_empty() {
312            format!("notifications:{recipient_id}")
313        } else {
314            format!("notifications:{tenant}:{recipient_id}")
315        }
316    }
317
318    /// Persist a new, unread notification for `recipient_id` and return it.
319    ///
320    /// The write is immediately visible to [`list`](Self::list) and
321    /// [`unread_count`](Self::unread_count).
322    ///
323    /// # Errors
324    ///
325    /// Returns a 500 [`AutumnError`] when the underlying store fails.
326    pub async fn notify(
327        &self,
328        recipient_id: i64,
329        kind: impl Into<String>,
330        payload: Value,
331    ) -> AutumnResult<Notification> {
332        self.store
333            .boxed_notify(recipient_id, kind.into(), payload)
334            .await
335            .map_err(AutumnError::internal_server_error)
336    }
337
338    /// [`notify`](Self::notify), then publish the stored notification as JSON
339    /// on [`Notifications::topic`] over the app's `channels` transport.
340    ///
341    /// The broadcast is **best-effort**: any publish failure (most commonly
342    /// "no subscribers on the topic") is ignored and never fails the notify.
343    /// Outside of a request context (a service built with
344    /// [`Notifications::new`]) there is no channel registry and the push is
345    /// skipped entirely.
346    ///
347    /// # Errors
348    ///
349    /// Returns a 500 [`AutumnError`] when the underlying store fails —
350    /// never because of the channel publish.
351    #[cfg(feature = "ws")]
352    pub async fn notify_with_push(
353        &self,
354        recipient_id: i64,
355        kind: impl Into<String>,
356        payload: Value,
357    ) -> AutumnResult<Notification> {
358        let notification = self.notify(recipient_id, kind, payload).await?;
359        if let Some(channels) = &self.channels
360            && let Ok(json) = serde_json::to_string(&notification)
361        {
362            // Best-effort: a dead or subscriber-less channel never fails
363            // notify.
364            let _ = channels
365                .broadcast()
366                .publish(&Self::topic(recipient_id), json);
367        }
368        Ok(notification)
369    }
370
371    /// One page of the recipient's feed. See the module docs for the
372    /// supported [`ListQuery`] filters and sort keys.
373    ///
374    /// # Errors
375    ///
376    /// Returns a 500 [`AutumnError`] when the underlying store fails.
377    pub async fn list(
378        &self,
379        recipient_id: i64,
380        query: &ListQuery,
381        page: &PageRequest,
382    ) -> AutumnResult<Page<Notification>> {
383        self.store
384            .boxed_list(recipient_id, query.clone(), *page)
385            .await
386            .map_err(AutumnError::internal_server_error)
387    }
388
389    /// Number of unread notifications for the recipient.
390    ///
391    /// # Errors
392    ///
393    /// Returns a 500 [`AutumnError`] when the underlying store fails.
394    pub async fn unread_count(&self, recipient_id: i64) -> AutumnResult<u64> {
395        self.store
396            .boxed_unread_count(recipient_id)
397            .await
398            .map_err(AutumnError::internal_server_error)
399    }
400
401    /// Mark one notification read. Idempotent: re-marking a read notification
402    /// (or a missing id) is a no-op, and the original `read_at` is preserved.
403    ///
404    /// This variant is unscoped — any notification id can be marked. In a
405    /// handler acting on behalf of a signed-in user prefer
406    /// [`mark_read_for`](Self::mark_read_for), which refuses to touch other
407    /// recipients' notifications.
408    ///
409    /// # Errors
410    ///
411    /// Returns a 500 [`AutumnError`] when the underlying store fails.
412    pub async fn mark_read(&self, id: i64) -> AutumnResult<()> {
413        self.store
414            .boxed_mark_read(id, None)
415            .await
416            .map(|_| ())
417            .map_err(AutumnError::internal_server_error)
418    }
419
420    /// Mark one of `recipient_id`'s own notifications read. A notification
421    /// owned by a different recipient is left untouched (a no-op, not an
422    /// error), making this the safe choice for user-facing handlers.
423    ///
424    /// # Errors
425    ///
426    /// Returns a 500 [`AutumnError`] when the underlying store fails.
427    pub async fn mark_read_for(&self, recipient_id: i64, id: i64) -> AutumnResult<()> {
428        self.store
429            .boxed_mark_read(id, Some(recipient_id))
430            .await
431            .map(|_| ())
432            .map_err(AutumnError::internal_server_error)
433    }
434
435    /// Mark all of the recipient's unread notifications read and return how
436    /// many transitioned. Idempotent: a second call returns 0.
437    ///
438    /// # Errors
439    ///
440    /// Returns a 500 [`AutumnError`] when the underlying store fails.
441    pub async fn mark_all_read(&self, recipient_id: i64) -> AutumnResult<u64> {
442        self.store
443            .boxed_mark_all_read(recipient_id)
444            .await
445            .map_err(AutumnError::internal_server_error)
446    }
447
448    /// Default service for an app that never registered a store: the
449    /// database-backed store when a pool is configured, the in-memory store
450    /// otherwise.
451    fn default_for(state: &AppState) -> Self {
452        #[cfg(feature = "db")]
453        if let Some(pool) = crate::db::DbState::pool(state) {
454            return Self::new(DbNotificationStore::new(pool.clone()));
455        }
456        Self::new(MemoryNotificationStore::new())
457    }
458}
459
460impl std::fmt::Debug for Notifications {
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        f.debug_struct("Notifications").finish_non_exhaustive()
463    }
464}
465
466impl axum::extract::FromRequestParts<AppState> for Notifications {
467    // Resolution always succeeds (a store fallback always exists); failures
468    // surface from the service methods, not extraction — same contract as
469    // `Session`.
470    type Rejection = std::convert::Infallible;
471
472    async fn from_request_parts(
473        _parts: &mut axum::http::request::Parts,
474        state: &AppState,
475    ) -> Result<Self, Self::Rejection> {
476        // `extension_or_insert_with` keeps the resolved service (and thus the
477        // memory store's contents) stable across requests, and lets
478        // `AppBuilder::with_notification_store` pre-empt the default.
479        let service = state.extension_or_insert_with::<Self>(|| Self::default_for(state));
480        #[cfg_attr(not(feature = "ws"), allow(unused_mut))]
481        let mut service = (*service).clone();
482        #[cfg(feature = "ws")]
483        {
484            service.channels = Some(state.channels().clone());
485        }
486        Ok(service)
487    }
488}
489
490// ── In-memory store ─────────────────────────────────────────────────────────
491
492/// Process-local [`NotificationStore`] used by default when no database is
493/// configured.
494///
495/// Suitable for tests and DB-less development; contents are lost on restart
496/// and the store grows without bound (no eviction), so it is not a
497/// production store.
498#[derive(Debug, Default)]
499pub struct MemoryNotificationStore {
500    inner: std::sync::Mutex<MemoryInner>,
501}
502
503#[derive(Debug, Default)]
504struct MemoryInner {
505    next_id: i64,
506    /// `(tenant_id, notification)`; see [`crate::tenancy::current_tenant_id`].
507    rows: Vec<(String, Notification)>,
508}
509
510impl MemoryNotificationStore {
511    /// Create an empty store.
512    #[must_use]
513    pub fn new() -> Self {
514        Self::default()
515    }
516
517    fn lock(&self) -> Result<std::sync::MutexGuard<'_, MemoryInner>, NotificationStoreError> {
518        self.inner
519            .lock()
520            .map_err(|_| NotificationStoreError::new("memory store mutex poisoned"))
521    }
522}
523
524impl NotificationStore for MemoryNotificationStore {
525    async fn notify(
526        &self,
527        recipient_id: i64,
528        kind: String,
529        payload: Value,
530    ) -> Result<Notification, NotificationStoreError> {
531        let tenant = crate::tenancy::current_tenant_id();
532        let mut inner = self.lock()?;
533        inner.next_id += 1;
534        let notification = Notification {
535            id: inner.next_id,
536            recipient_id,
537            kind,
538            payload,
539            read_at: None,
540            created_at: crate::time::ambient_now(),
541        };
542        inner.rows.push((tenant, notification.clone()));
543        drop(inner);
544        Ok(notification)
545    }
546
547    async fn list(
548        &self,
549        recipient_id: i64,
550        query: &ListQuery,
551        page: &PageRequest,
552    ) -> Result<Page<Notification>, NotificationStoreError> {
553        let filter = ListFilter::from_query(query);
554        let tenant = crate::tenancy::current_tenant_id();
555        let inner = self.lock()?;
556        let mut rows: Vec<Notification> = inner
557            .rows
558            .iter()
559            .filter(|(t, n)| *t == tenant && n.recipient_id == recipient_id)
560            .map(|(_, n)| n)
561            .filter(|n| !filter.unread_only || n.read_at.is_none())
562            .filter(|n| filter.kind.as_deref().is_none_or(|k| n.kind == k))
563            .cloned()
564            .collect();
565        drop(inner);
566
567        // `id` is assigned monotonically, so it doubles as insertion order and
568        // as the tiebreaker for equal `created_at` values.
569        match filter.sort {
570            FeedSort::CreatedAt => rows.sort_by_key(|n| (n.created_at, n.id)),
571            FeedSort::Id => rows.sort_by_key(|n| n.id),
572        }
573        if filter.dir == SortDir::Desc {
574            rows.reverse();
575        }
576
577        let total = i64::try_from(rows.len()).unwrap_or(i64::MAX);
578        let start = usize::try_from(page.offset()).unwrap_or(usize::MAX);
579        let items: Vec<Notification> = rows
580            .into_iter()
581            .skip(start)
582            .take(usize::try_from(page.limit()).unwrap_or(0))
583            .collect();
584        Ok(Page::new(items, total, page))
585    }
586
587    async fn unread_count(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
588        let tenant = crate::tenancy::current_tenant_id();
589        let inner = self.lock()?;
590        let count = inner
591            .rows
592            .iter()
593            .filter(|(t, n)| *t == tenant && n.recipient_id == recipient_id)
594            .filter(|(_, n)| n.read_at.is_none())
595            .count();
596        drop(inner);
597        Ok(count as u64)
598    }
599
600    async fn mark_read(
601        &self,
602        id: i64,
603        recipient_id: Option<i64>,
604    ) -> Result<u64, NotificationStoreError> {
605        let now = crate::time::ambient_now();
606        let tenant = crate::tenancy::current_tenant_id();
607        let mut inner = self.lock()?;
608        let marked = inner
609            .rows
610            .iter_mut()
611            .filter(|(t, n)| *t == tenant && n.id == id)
612            .filter(|(_, n)| recipient_id.is_none_or(|rid| n.recipient_id == rid))
613            .filter(|(_, n)| n.read_at.is_none())
614            .map(|(_, n)| n.read_at = Some(now))
615            .count();
616        drop(inner);
617        Ok(marked as u64)
618    }
619
620    async fn mark_all_read(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
621        let now = crate::time::ambient_now();
622        let tenant = crate::tenancy::current_tenant_id();
623        let mut inner = self.lock()?;
624        let marked = inner
625            .rows
626            .iter_mut()
627            .filter(|(t, n)| *t == tenant && n.recipient_id == recipient_id)
628            .filter(|(_, n)| n.read_at.is_none())
629            .map(|(_, n)| n.read_at = Some(now))
630            .count();
631        drop(inner);
632        Ok(marked as u64)
633    }
634}
635
636// ── Shared list-parameter interpretation ────────────────────────────────────
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639enum FeedSort {
640    Id,
641    CreatedAt,
642}
643
644/// The [`ListQuery`] subset both stores understand, so the memory and
645/// database backends interpret feed parameters identically.
646struct ListFilter {
647    unread_only: bool,
648    kind: Option<String>,
649    sort: FeedSort,
650    dir: SortDir,
651}
652
653impl ListFilter {
654    fn from_query(query: &ListQuery) -> Self {
655        let mut unread_only = false;
656        let mut kind = None;
657        for (column, value) in query.filters() {
658            match column {
659                "unread" => unread_only = value == "true" || value == "1",
660                "kind" => kind = Some(value.to_owned()),
661                // Unknown filters are ignored, matching the repository
662                // `list()` allowlist contract.
663                _ => {}
664            }
665        }
666        // Without an explicit sort the feed reads newest-first; an explicit
667        // sort key uses the requested direction (`ListQuery` defaults to
668        // ascending).
669        let (sort, dir) = match query.sort() {
670            Some("created_at") => (FeedSort::CreatedAt, query.direction()),
671            Some("id") => (FeedSort::Id, query.direction()),
672            // No sort requested, or a key outside the allowlist: newest-first.
673            None | Some(_) => (FeedSort::Id, SortDir::Desc),
674        };
675        Self {
676            unread_only,
677            kind,
678            sort,
679            dir,
680        }
681    }
682}
683
684// ── Database-backed store ───────────────────────────────────────────────────
685
686#[cfg(feature = "db")]
687pub use self::db_store::DbNotificationStore;
688
689#[cfg(feature = "db")]
690mod db_store {
691    use super::{
692        FeedSort, ListFilter, Notification, NotificationStore, NotificationStoreError, Value,
693    };
694    use crate::db::RuntimeConnection;
695    use crate::pagination::{ListQuery, Page, PageRequest, SortDir};
696    use chrono::{DateTime, Utc};
697    use diesel::prelude::*;
698    use diesel_async::RunQueryDsl;
699    use diesel_async::pooled_connection::deadpool::Pool;
700
701    // The `notifications` table as scaffolded by `autumn generate
702    // notifications`. Timestamps are TIMESTAMPTZ on Postgres and RFC 3339
703    // TEXT on SQLite (diesel's `TimestamptzSqlite`), matching the DDL the
704    // generator emits per backend.
705    #[cfg(not(feature = "sqlite"))]
706    mod schema {
707        diesel::table! {
708            notifications (id) {
709                id -> BigInt,
710                tenant_id -> Text,
711                recipient_id -> BigInt,
712                kind -> Text,
713                payload -> Text,
714                read_at -> Nullable<Timestamptz>,
715                created_at -> Timestamptz,
716            }
717        }
718    }
719    #[cfg(feature = "sqlite")]
720    mod schema {
721        diesel::table! {
722            notifications (id) {
723                id -> BigInt,
724                tenant_id -> Text,
725                recipient_id -> BigInt,
726                kind -> Text,
727                payload -> Text,
728                read_at -> Nullable<TimestamptzSqlite>,
729                created_at -> TimestamptzSqlite,
730            }
731        }
732    }
733
734    use schema::notifications;
735
736    #[derive(Queryable, Selectable)]
737    #[diesel(table_name = notifications)]
738    struct NotificationRow {
739        id: i64,
740        recipient_id: i64,
741        kind: String,
742        payload: String,
743        read_at: Option<DateTime<Utc>>,
744        created_at: DateTime<Utc>,
745    }
746
747    impl From<NotificationRow> for Notification {
748        fn from(row: NotificationRow) -> Self {
749            // A payload that no longer parses as JSON (e.g. edited out of
750            // band) degrades to a JSON string of the raw text rather than
751            // failing the whole feed.
752            let payload = match serde_json::from_str(&row.payload) {
753                Ok(parsed) => parsed,
754                Err(_) => Value::String(row.payload),
755            };
756            Self {
757                id: row.id,
758                recipient_id: row.recipient_id,
759                kind: row.kind,
760                payload,
761                read_at: row.read_at,
762                created_at: row.created_at,
763            }
764        }
765    }
766
767    #[derive(Insertable)]
768    #[diesel(table_name = notifications)]
769    struct NewNotificationRow {
770        tenant_id: String,
771        recipient_id: i64,
772        kind: String,
773        payload: String,
774        created_at: DateTime<Utc>,
775    }
776
777    /// [`NotificationStore`] backed by the app's database pool.
778    ///
779    /// Expects the `notifications` table scaffolded by
780    /// `autumn generate notifications`. This is the default store whenever
781    /// the app has a database configured.
782    #[derive(Clone)]
783    pub struct DbNotificationStore {
784        pool: Pool<RuntimeConnection>,
785    }
786
787    impl DbNotificationStore {
788        /// Build a store over the given pool.
789        #[must_use]
790        pub const fn new(pool: Pool<RuntimeConnection>) -> Self {
791            Self { pool }
792        }
793
794        async fn conn(
795            &self,
796        ) -> Result<
797            diesel_async::pooled_connection::deadpool::Object<RuntimeConnection>,
798            NotificationStoreError,
799        > {
800            self.pool
801                .get()
802                .await
803                .map_err(|e| NotificationStoreError::new(format!("checkout failed: {e}")))
804        }
805    }
806
807    fn store_err(e: &diesel::result::Error) -> NotificationStoreError {
808        let message = e.to_string();
809        // A missing table means the app has a database but never scaffolded
810        // the notifications feed — turn the bare SQL error into an
811        // actionable one ("relation … does not exist" on Postgres, "no such
812        // table" on SQLite).
813        if message.contains("tenant_id") && message.contains("column") {
814            return NotificationStoreError::new(format!(
815                "query failed: {e}. The `notifications` table has no `tenant_id` column — \
816                 see docs/migrations/0.9.0.md"
817            ));
818        }
819        if message.contains("does not exist") || message.contains("no such table") {
820            return NotificationStoreError::new(format!(
821                "query failed: {e}. The `notifications` table is missing — scaffold it \
822                 with `autumn generate notifications`, then apply it with `autumn migrate`"
823            ));
824        }
825        NotificationStoreError::new(format!("query failed: {e}"))
826    }
827
828    impl NotificationStore for DbNotificationStore {
829        async fn notify(
830            &self,
831            recipient_id: i64,
832            kind: String,
833            payload: Value,
834        ) -> Result<Notification, NotificationStoreError> {
835            let payload = serde_json::to_string(&payload)
836                .map_err(|e| NotificationStoreError::new(format!("payload serialization: {e}")))?;
837            let mut conn = self.conn().await?;
838            let row: NotificationRow = diesel::insert_into(notifications::table)
839                .values(NewNotificationRow {
840                    tenant_id: crate::tenancy::current_tenant_id(),
841                    recipient_id,
842                    kind,
843                    payload,
844                    created_at: crate::time::ambient_now(),
845                })
846                .returning(NotificationRow::as_returning())
847                .get_result(&mut conn)
848                .await
849                .map_err(|e| store_err(&e))?;
850            Ok(row.into())
851        }
852
853        async fn list(
854            &self,
855            recipient_id: i64,
856            query: &ListQuery,
857            page: &PageRequest,
858        ) -> Result<Page<Notification>, NotificationStoreError> {
859            use notifications::dsl;
860
861            let filter = ListFilter::from_query(query);
862            let mut conn = self.conn().await?;
863
864            let tenant = crate::tenancy::current_tenant_id();
865            let mut count_query = dsl::notifications
866                .filter(dsl::tenant_id.eq(tenant.clone()))
867                .filter(dsl::recipient_id.eq(recipient_id))
868                .into_boxed();
869            let mut select_query = dsl::notifications
870                .filter(dsl::tenant_id.eq(tenant))
871                .filter(dsl::recipient_id.eq(recipient_id))
872                .into_boxed();
873            if filter.unread_only {
874                count_query = count_query.filter(dsl::read_at.is_null());
875                select_query = select_query.filter(dsl::read_at.is_null());
876            }
877            if let Some(kind) = &filter.kind {
878                count_query = count_query.filter(dsl::kind.eq(kind.clone()));
879                select_query = select_query.filter(dsl::kind.eq(kind.clone()));
880            }
881
882            let total: i64 = count_query
883                .count()
884                .get_result(&mut conn)
885                .await
886                .map_err(|e| store_err(&e))?;
887
888            select_query = match (filter.sort, filter.dir) {
889                (FeedSort::Id, SortDir::Asc) => select_query.order(dsl::id.asc()),
890                (FeedSort::Id, SortDir::Desc) => select_query.order(dsl::id.desc()),
891                (FeedSort::CreatedAt, SortDir::Asc) => {
892                    select_query.order((dsl::created_at.asc(), dsl::id.asc()))
893                }
894                (FeedSort::CreatedAt, SortDir::Desc) => {
895                    select_query.order((dsl::created_at.desc(), dsl::id.desc()))
896                }
897            };
898
899            let rows: Vec<NotificationRow> = select_query
900                .select(NotificationRow::as_select())
901                .limit(page.limit())
902                .offset(page.offset())
903                .load(&mut conn)
904                .await
905                .map_err(|e| store_err(&e))?;
906
907            Ok(Page::new(
908                rows.into_iter().map(Notification::from).collect(),
909                total,
910                page,
911            ))
912        }
913
914        async fn unread_count(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
915            use notifications::dsl;
916            let mut conn = self.conn().await?;
917            let count: i64 = dsl::notifications
918                .filter(dsl::tenant_id.eq(crate::tenancy::current_tenant_id()))
919                .filter(dsl::recipient_id.eq(recipient_id))
920                .filter(dsl::read_at.is_null())
921                .count()
922                .get_result(&mut conn)
923                .await
924                .map_err(|e| store_err(&e))?;
925            Ok(u64::try_from(count).unwrap_or(0))
926        }
927
928        async fn mark_read(
929            &self,
930            id: i64,
931            recipient_id: Option<i64>,
932        ) -> Result<u64, NotificationStoreError> {
933            use notifications::dsl;
934            let mut conn = self.conn().await?;
935            let now = crate::time::ambient_now();
936            // `read_at IS NULL` in the predicate makes the update idempotent:
937            // an already-read row matches zero rows and keeps its original
938            // timestamp.
939            let affected = match recipient_id {
940                Some(rid) => {
941                    diesel::update(
942                        dsl::notifications
943                            .filter(dsl::tenant_id.eq(crate::tenancy::current_tenant_id()))
944                            .filter(dsl::id.eq(id))
945                            .filter(dsl::recipient_id.eq(rid))
946                            .filter(dsl::read_at.is_null()),
947                    )
948                    .set(dsl::read_at.eq(Some(now)))
949                    .execute(&mut conn)
950                    .await
951                }
952                None => {
953                    diesel::update(
954                        dsl::notifications
955                            .filter(dsl::tenant_id.eq(crate::tenancy::current_tenant_id()))
956                            .filter(dsl::id.eq(id))
957                            .filter(dsl::read_at.is_null()),
958                    )
959                    .set(dsl::read_at.eq(Some(now)))
960                    .execute(&mut conn)
961                    .await
962                }
963            }
964            .map_err(|e| store_err(&e))?;
965            Ok(affected as u64)
966        }
967
968        async fn mark_all_read(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
969            use notifications::dsl;
970            let mut conn = self.conn().await?;
971            let affected = diesel::update(
972                dsl::notifications
973                    .filter(dsl::tenant_id.eq(crate::tenancy::current_tenant_id()))
974                    .filter(dsl::recipient_id.eq(recipient_id))
975                    .filter(dsl::read_at.is_null()),
976            )
977            .set(dsl::read_at.eq(Some(crate::time::ambient_now())))
978            .execute(&mut conn)
979            .await
980            .map_err(|e| store_err(&e))?;
981            Ok(affected as u64)
982        }
983    }
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989    use serde_json::json;
990
991    #[test]
992    fn list_filter_defaults_to_newest_first() {
993        let filter = ListFilter::from_query(&ListQuery::default());
994        assert!(!filter.unread_only);
995        assert!(filter.kind.is_none());
996        assert_eq!(filter.sort, FeedSort::Id);
997        assert_eq!(filter.dir, SortDir::Desc);
998    }
999
1000    #[test]
1001    fn list_filter_parses_unread_and_kind() {
1002        let query = ListQuery::new(None, SortDir::Asc, &[("unread", "1"), ("kind", "like")]);
1003        let filter = ListFilter::from_query(&query);
1004        assert!(filter.unread_only);
1005        assert_eq!(filter.kind.as_deref(), Some("like"));
1006    }
1007
1008    #[test]
1009    fn list_filter_ignores_unknown_sort_and_filters() {
1010        let query = ListQuery::new(Some("payload"), SortDir::Asc, &[("recipient_id", "9")]);
1011        let filter = ListFilter::from_query(&query);
1012        assert!(!filter.unread_only);
1013        assert!(filter.kind.is_none());
1014        assert_eq!(filter.sort, FeedSort::Id);
1015        assert_eq!(
1016            filter.dir,
1017            SortDir::Desc,
1018            "unknown sort falls back to newest-first"
1019        );
1020    }
1021
1022    #[test]
1023    fn explicit_sort_uses_requested_direction() {
1024        let query = ListQuery::new(Some("created_at"), SortDir::Asc, &[]);
1025        let filter = ListFilter::from_query(&query);
1026        assert_eq!(filter.sort, FeedSort::CreatedAt);
1027        assert_eq!(filter.dir, SortDir::Asc);
1028    }
1029
1030    #[tokio::test]
1031    async fn memory_store_round_trips_null_payload() {
1032        // A JSON `null` payload must round-trip as `Value::Null`, not be
1033        // confused with "no payload".
1034        let notifications = Notifications::new(MemoryNotificationStore::new());
1035        let n = notifications
1036            .notify(1, "k", Value::Null)
1037            .await
1038            .expect("notify");
1039        assert_eq!(n.payload, Value::Null);
1040        let feed = notifications
1041            .list(1, &ListQuery::default(), &PageRequest::default())
1042            .await
1043            .expect("list");
1044        assert_eq!(feed.content[0].payload, Value::Null);
1045    }
1046
1047    #[tokio::test]
1048    async fn memory_store_round_trips_payload() {
1049        let notifications = Notifications::new(MemoryNotificationStore::new());
1050        let n = notifications
1051            .notify(1, "k", json!({"deep": {"value": [1, 2, 3]}}))
1052            .await
1053            .expect("notify");
1054        assert_eq!(n.payload, json!({"deep": {"value": [1, 2, 3]}}));
1055    }
1056}