feat(notification): recover notification since last known event on client resume

This commit is contained in:
Edouard Vanbelle
2026-09-12 00:16:06 +02:00
parent 617ae4b424
commit ebe467ee92
7 changed files with 345 additions and 132 deletions
@@ -18,21 +18,30 @@ use crate::common::errors::DomainError;
use crate::domain::entities::notification::{NewNotification, Notification};
/// Optional filter for [`NotificationRepository::list_for_user`]. All
/// fields are additive — `None` means "no restriction on this axis".
/// fields are additive — the default (Default::default) applies no
/// restriction on any axis.
#[derive(Debug, Clone, Default)]
pub struct NotificationListFilter {
/// Cap on rows returned. Default at the service layer is 50; the
/// repo does not impose one so a full-export use case remains
/// possible.
/// repo caps defensively at 500 so a runaway caller can't drag
/// the DB.
pub limit: Option<u32>,
/// When `Some(true)`, return only rows with `read_at IS NULL`.
/// When `Some(false)`, return only rows with `read_at IS NOT NULL`.
/// `None` returns both.
pub unread_only: Option<bool>,
/// When `Some(t)`, return only rows created strictly before `t`.
/// Cursor-style pagination: caller passes the oldest `created_at`
/// from the previous page.
/// `true` → return only rows with `read_at IS NULL`. `false`
/// (default) returns both read and unread. There is no
/// "read-only" filter — no consumer needed it, and adding one
/// bloats the query surface.
pub unread_only: bool,
/// When `Some(t)`, return only rows created strictly BEFORE `t`.
/// Cursor-style pagination for the "load older page" flow: caller
/// passes the oldest `created_at` from the previous page.
pub before: Option<DateTime<Utc>>,
/// When `Some(t)`, return only rows created strictly AFTER `t`.
/// Delta-catch-up cursor for the "since last seen" flow — used by
/// the FE bell on WS reconnect / tab reactivation to fetch rows
/// that arrived during a disconnect window. Combines with
/// `before` (both applied); combining them semantically bounds
/// the returned range on both sides.
pub after: Option<DateTime<Utc>>,
}
#[async_trait]
@@ -79,105 +79,36 @@ impl NotificationRepository for NotificationPgRepository {
user_id: Uuid,
filter: &NotificationListFilter,
) -> Result<Vec<Notification>, DomainError> {
// Dynamic-shape query built to still hit the
// notifications_user_created_read index — every branch keys
// on (user_id, created_at DESC).
// One dynamic query covers every combination of
// (unread_only, before, after). NULL sentinels short-circuit
// the corresponding predicate at planner time, so the
// notifications_user_created_read index still drives the
// scan — the extra `IS NULL` checks are constant-folded.
//
// `before` and `after` combine: passing both bounds the
// returned range on both sides — useful for future
// "paginate a specific window" flows, harmless today when
// callers use one at a time.
let limit: i64 = filter.limit.unwrap_or(50).min(500) as i64;
let rows = match (filter.unread_only, filter.before) {
(None, None) => {
sqlx::query(
r#"
SELECT id, user_id, kind, payload, created_at, read_at
FROM notif.notifications
WHERE user_id = $1::uuid
ORDER BY created_at DESC
LIMIT $2
"#,
)
.bind(user_id)
.bind(limit)
.fetch_all(self.pool.as_ref())
.await
}
(Some(true), None) => {
sqlx::query(
r#"
SELECT id, user_id, kind, payload, created_at, read_at
FROM notif.notifications
WHERE user_id = $1::uuid AND read_at IS NULL
ORDER BY created_at DESC
LIMIT $2
"#,
)
.bind(user_id)
.bind(limit)
.fetch_all(self.pool.as_ref())
.await
}
(Some(false), None) => {
sqlx::query(
r#"
SELECT id, user_id, kind, payload, created_at, read_at
FROM notif.notifications
WHERE user_id = $1::uuid AND read_at IS NOT NULL
ORDER BY created_at DESC
LIMIT $2
"#,
)
.bind(user_id)
.bind(limit)
.fetch_all(self.pool.as_ref())
.await
}
(None, Some(before)) => {
sqlx::query(
r#"
SELECT id, user_id, kind, payload, created_at, read_at
FROM notif.notifications
WHERE user_id = $1::uuid AND created_at < $2
ORDER BY created_at DESC
LIMIT $3
"#,
)
.bind(user_id)
.bind(before)
.bind(limit)
.fetch_all(self.pool.as_ref())
.await
}
(Some(true), Some(before)) => {
sqlx::query(
r#"
SELECT id, user_id, kind, payload, created_at, read_at
FROM notif.notifications
WHERE user_id = $1::uuid AND read_at IS NULL AND created_at < $2
ORDER BY created_at DESC
LIMIT $3
"#,
)
.bind(user_id)
.bind(before)
.bind(limit)
.fetch_all(self.pool.as_ref())
.await
}
(Some(false), Some(before)) => {
sqlx::query(
r#"
SELECT id, user_id, kind, payload, created_at, read_at
FROM notif.notifications
WHERE user_id = $1::uuid AND read_at IS NOT NULL AND created_at < $2
ORDER BY created_at DESC
LIMIT $3
"#,
)
.bind(user_id)
.bind(before)
.bind(limit)
.fetch_all(self.pool.as_ref())
.await
}
}
let rows = sqlx::query(
r#"
SELECT id, user_id, kind, payload, created_at, read_at
FROM notif.notifications
WHERE user_id = $1::uuid
AND ($2::bool = FALSE OR read_at IS NULL)
AND ($3::timestamptz IS NULL OR created_at < $3)
AND ($4::timestamptz IS NULL OR created_at > $4)
ORDER BY created_at DESC
LIMIT $5
"#,
)
.bind(user_id)
.bind(filter.unread_only)
.bind(filter.before)
.bind(filter.after)
.bind(limit)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("list_for_user", e))?;
rows.iter().map(Self::map_row).collect()
@@ -68,9 +68,15 @@ pub struct ListQuery {
/// When `true`, return only unread rows. Default: `false` (both).
#[serde(default)]
pub unread: bool,
/// Cursor — return rows strictly before this `created_at`. Omit
/// for the newest page.
/// Older-than cursor — return rows strictly BEFORE this
/// `created_at`. Used by the "load older page" pagination flow.
/// Omit for the newest page.
pub before: Option<DateTime<Utc>>,
/// Newer-than cursor — return rows strictly AFTER this
/// `created_at`. Used by the FE bell on WS reconnect / tab
/// reactivation to catch up on rows that arrived during a
/// disconnect window. Combines with `before` if both are set.
pub after: Option<DateTime<Utc>>,
/// Max rows returned. Server-side clamp at 500.
pub limit: Option<u32>,
}
@@ -101,7 +107,8 @@ pub struct MarkAllReadResponseDto {
path = "/api/notifications",
params(
("unread" = Option<bool>, Query, description = "Only return unread rows"),
("before" = Option<DateTime<Utc>>, Query, description = "Cursor — rows strictly before this created_at"),
("before" = Option<DateTime<Utc>>, Query, description = "Cursor — rows strictly before this created_at (load-older pagination)"),
("after" = Option<DateTime<Utc>>, Query, description = "Cursor — rows strictly after this created_at (delta catch-up on WS reconnect / tab reactivation)"),
("limit" = Option<u32>, Query, description = "Max rows (server-side clamp at 500)"),
),
responses(
@@ -117,8 +124,9 @@ pub async fn list_notifications(
) -> Result<Json<ListResponseDto>, AppError> {
let filter = NotificationListFilter {
limit: query.limit,
unread_only: if query.unread { Some(true) } else { None },
unread_only: query.unread,
before: query.before,
after: query.after,
};
let rows = service.list_for_user(auth_user.id, filter).await?;
let unread_count = service.count_unread_for_user(auth_user.id).await?;