feat(notification): recover notification since last known event on client resume
This commit is contained in:
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user