feat(notification): add persistent notification
This commit is contained in:
@@ -9,6 +9,7 @@ pub mod face;
|
||||
pub mod file;
|
||||
pub mod folder;
|
||||
pub mod magic_link_token;
|
||||
pub mod notification;
|
||||
pub mod playlist;
|
||||
pub mod session;
|
||||
pub mod share;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! In-app notification — one durable row per recipient per event.
|
||||
//!
|
||||
//! Backs the bell UI. The message bus poke on
|
||||
//! `user:{user_id}:notifications` is a fast path; the row is truth.
|
||||
//! See `docs/plan/message-bus.md § Slice E` for the wire contract.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A stable kind slug. The FE routes on this string for icon / label /
|
||||
/// action-button choice. New kinds are additive; **never repurpose an
|
||||
/// existing value** — the FE reads it as an enum-like discriminant.
|
||||
///
|
||||
/// The initial set matches the plan's Slice-E ingester list. Additional
|
||||
/// values are legal on the wire (an older FE ignores unknown kinds
|
||||
/// gracefully by falling back to a generic bell row); we still keep the
|
||||
/// canonical list here so the ingester callsites reach for symbolic
|
||||
/// constants instead of literal strings.
|
||||
///
|
||||
/// The DB column is plain `TEXT` (see `migrations/20261026000000_notifications.sql`)
|
||||
/// — no CHECK constraint. Adding a new kind is a code change only, no
|
||||
/// migration, no downtime.
|
||||
pub mod kind {
|
||||
/// A grant was created for the recipient user (they can now access
|
||||
/// a resource). Payload carries the resource id + role + granter.
|
||||
pub const SHARE_GRANTED: &str = "share_granted";
|
||||
|
||||
/// A login succeeded from a device / IP fingerprint the user
|
||||
/// hasn't seen before. Payload carries the user-agent snippet
|
||||
/// and the coarsened location if available.
|
||||
pub const NEW_LOGIN_FROM_NEW_DEVICE: &str = "new_login_from_new_device";
|
||||
|
||||
/// A background job triggered by the recipient user finished
|
||||
/// (success or failure). Payload carries the job name and
|
||||
/// `success: bool`. Clicking navigates to `/admin/jobs/<name>`.
|
||||
pub const JOB_COMPLETED_FOR_YOU: &str = "job_completed_for_you";
|
||||
|
||||
/// The recipient's storage quota crossed a warning threshold
|
||||
/// (e.g. 80 %, 95 %). Payload carries `used_bytes` / `quota_bytes`
|
||||
/// and the crossed percentage.
|
||||
pub const STORAGE_QUOTA_THRESHOLD: &str = "storage_quota_threshold";
|
||||
}
|
||||
|
||||
/// One notification row.
|
||||
///
|
||||
/// `payload` is a per-kind opaque JSON blob; the DB stays schema-free
|
||||
/// so a new field never requires a migration. Callers deserialize it
|
||||
/// against a kind-specific struct on the FE.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Notification {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub kind: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// `None` = unread; `Some(t)` = when the user explicitly marked it
|
||||
/// read via `POST /api/notifications/{id}/read` or
|
||||
/// `POST /api/notifications/read-all`.
|
||||
pub read_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// The service-layer input for [`NotificationService::create`]. Split
|
||||
/// from [`Notification`] because `id` / `created_at` are DB-generated.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewNotification {
|
||||
pub user_id: Uuid,
|
||||
pub kind: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod drive_repository;
|
||||
pub mod file_repository;
|
||||
pub mod folder_repository;
|
||||
pub mod magic_link_token_repository;
|
||||
pub mod notification_repository;
|
||||
pub mod playlist_repository;
|
||||
pub mod session_repository;
|
||||
pub mod settings_repository;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Storage port for [`Notification`].
|
||||
//!
|
||||
//! Backs the bell UI. `create` is the only ingester-facing method;
|
||||
//! `list_for_user` / `mark_read` / `mark_all_read` / `delete_by_id` /
|
||||
//! `purge_read_before` back the REST endpoints and the retention job.
|
||||
//!
|
||||
//! Every method takes `user_id` where relevant so the SQL includes the
|
||||
//! caller-scope in its WHERE clause — the application service double-
|
||||
//! checks the requested notification's owner matches the caller, but
|
||||
//! the repo scoping is defense in depth (a bug that misroutes an id
|
||||
//! still can't leak another user's row through `mark_read`).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
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".
|
||||
#[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.
|
||||
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.
|
||||
pub before: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait NotificationRepository: Send + Sync + 'static {
|
||||
/// Insert a new notification. Returns the persisted row (id +
|
||||
/// created_at populated). The application service publishes the
|
||||
/// bus event AFTER this returns Ok — see plan's "publish after
|
||||
/// commit" invariant.
|
||||
async fn create(&self, new_notif: &NewNotification) -> Result<Notification, DomainError>;
|
||||
|
||||
/// List notifications for `user_id` newest-first, honouring
|
||||
/// `filter`. Returns an empty Vec (not an error) when the user
|
||||
/// has none.
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
filter: &NotificationListFilter,
|
||||
) -> Result<Vec<Notification>, DomainError>;
|
||||
|
||||
/// Count unread rows for `user_id`. Backs the bell's unread badge.
|
||||
/// Separate from `list_for_user` so the badge can render without
|
||||
/// fetching payloads.
|
||||
async fn count_unread_for_user(&self, user_id: Uuid) -> Result<i64, DomainError>;
|
||||
|
||||
/// Mark one notification as read. Returns `Ok(true)` if a row
|
||||
/// transitioned from unread → read (i.e. was owned by `user_id`
|
||||
/// AND had `read_at IS NULL`); `Ok(false)` if the row didn't
|
||||
/// exist, was owned by someone else, or was already read.
|
||||
/// Idempotent from the caller's perspective; the `bool` is for
|
||||
/// logs / audit only.
|
||||
async fn mark_read(
|
||||
&self,
|
||||
notification_id: Uuid,
|
||||
user_id: Uuid,
|
||||
at: DateTime<Utc>,
|
||||
) -> Result<bool, DomainError>;
|
||||
|
||||
/// Bulk mark-all-read. Returns the number of rows updated.
|
||||
async fn mark_all_read_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
at: DateTime<Utc>,
|
||||
) -> Result<u64, DomainError>;
|
||||
|
||||
/// Hard-delete a single row. Same ownership scoping as
|
||||
/// [`mark_read`]. Returns `Ok(true)` iff a row was deleted.
|
||||
async fn delete_by_id(&self, notification_id: Uuid, user_id: Uuid)
|
||||
-> Result<bool, DomainError>;
|
||||
|
||||
/// Retention job: delete every read row whose `read_at` is older
|
||||
/// than `cutoff`. Returns the number of rows removed.
|
||||
/// Unread rows are preserved unconditionally — that's the whole
|
||||
/// point of the durable table.
|
||||
async fn purge_read_before(&self, cutoff: DateTime<Utc>) -> Result<u64, DomainError>;
|
||||
}
|
||||
Reference in New Issue
Block a user