feat(notification): add persistent notification
This commit is contained in:
@@ -64,6 +64,16 @@ pub enum Topic {
|
||||
/// the eviction wiring lands (Phase-A follow-up).
|
||||
UserAuthz(Uuid),
|
||||
|
||||
/// A user's private notifications channel — poked when a
|
||||
/// [`MessageBusEvent::NotificationReceived`] event fires. The WS
|
||||
/// handler auto-subscribes each session at session open (same
|
||||
/// pattern as [`Topic::UserAuthz`]). Payload is a thin fact
|
||||
/// (`notification_id` + `kind`); the client refetches the row from
|
||||
/// `GET /api/notifications` for the details. AuthZ: **strict
|
||||
/// identity match** — no admin bypass, direct UUID equality,
|
||||
/// anti-enumeration parity with [`Topic::UserAuthz`].
|
||||
UserNotifications(Uuid),
|
||||
|
||||
/// A named background job's run lifecycle — start / progress /
|
||||
/// end. Consumed by the admin job dashboard so operators who
|
||||
/// trigger a long-running job (backend migration, thumb import…)
|
||||
@@ -83,6 +93,7 @@ impl Topic {
|
||||
match self {
|
||||
Topic::Folder(id) => format!("folder:{id}"),
|
||||
Topic::UserAuthz(id) => format!("user:{id}:authz"),
|
||||
Topic::UserNotifications(id) => format!("user:{id}:notifications"),
|
||||
Topic::Job(name) => format!("job:{name}"),
|
||||
}
|
||||
}
|
||||
@@ -97,10 +108,14 @@ impl Topic {
|
||||
return Ok(Topic::Folder(id));
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("user:")
|
||||
&& let Some((id_str, "authz")) = rest.rsplit_once(':')
|
||||
&& let Some((id_str, suffix)) = rest.rsplit_once(':')
|
||||
{
|
||||
let id = Uuid::parse_str(id_str).map_err(|_| ParseTopicErr::BadUuid)?;
|
||||
return Ok(Topic::UserAuthz(id));
|
||||
return match suffix {
|
||||
"authz" => Ok(Topic::UserAuthz(id)),
|
||||
"notifications" => Ok(Topic::UserNotifications(id)),
|
||||
_ => Err(ParseTopicErr::Unknown),
|
||||
};
|
||||
}
|
||||
if let Some(name) = s.strip_prefix("job:") {
|
||||
// Job names are scheduler-registered short slugs — see
|
||||
@@ -135,6 +150,7 @@ impl Topic {
|
||||
resource: BusResource::Folder(*id),
|
||||
},
|
||||
Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id },
|
||||
Topic::UserNotifications(id) => AuthzCheck::IdentityMatch { user_id: *id },
|
||||
Topic::Job(_) => AuthzCheck::RoleAdmin,
|
||||
}
|
||||
}
|
||||
@@ -291,6 +307,25 @@ pub enum MessageBusEvent {
|
||||
/// plan's Phase-B roadmap.
|
||||
AuthzChanged { affected_folders: Vec<Uuid> },
|
||||
|
||||
/// A new notification was created for the caller — publishes on
|
||||
/// [`Topic::UserNotifications`]. Payload is deliberately thin: the
|
||||
/// FE learns "there's something new to look at" and calls
|
||||
/// `GET /api/notifications` to load the row. Same recovery path a
|
||||
/// missed push takes on next mount, so the wire event stays a
|
||||
/// pure poke — no fields the bell needs to render on its own.
|
||||
///
|
||||
/// `kind` is the notification's registered kind slug
|
||||
/// (`share_granted`, `job_completed_for_you`,
|
||||
/// `new_login_from_new_device`, `storage_quota_threshold`, …).
|
||||
/// The FE may use it to route the toast (high-priority kinds pop
|
||||
/// a toast; low-priority ones just bump the badge) but never
|
||||
/// treats it as authoritative — the DB row is the truth.
|
||||
NotificationReceived {
|
||||
notification_id: Uuid,
|
||||
kind: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
|
||||
/// A background job's run started. Published on
|
||||
/// [`Topic::Job`]. `started_at` is server wall-clock (RFC 3339
|
||||
/// serialised by serde). Admin dashboard's job-list view uses
|
||||
@@ -497,6 +532,15 @@ mod tests {
|
||||
assert_eq!(Topic::parse(&wire).unwrap(), t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_notifications_topic_roundtrip() {
|
||||
let id = Uuid::new_v4();
|
||||
let t = Topic::UserNotifications(id);
|
||||
let wire = t.to_wire_key();
|
||||
assert_eq!(wire, format!("user:{id}:notifications"));
|
||||
assert_eq!(Topic::parse(&wire).unwrap(), t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_topic_roundtrip() {
|
||||
let t = Topic::Job("backend_migration".to_string());
|
||||
@@ -539,7 +583,12 @@ mod tests {
|
||||
assert_eq!(
|
||||
Topic::parse(&format!("user:{}", Uuid::new_v4())),
|
||||
Err(ParseTopicErr::Unknown),
|
||||
"user:<uuid> without :authz suffix is not a known topic in MVP"
|
||||
"user:<uuid> without a known suffix (:authz, :notifications) is not a known topic"
|
||||
);
|
||||
assert_eq!(
|
||||
Topic::parse(&format!("user:{}:whatever", Uuid::new_v4())),
|
||||
Err(ParseTopicErr::Unknown),
|
||||
"an unrecognised suffix rejects — no partial match on the prefix"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,6 +612,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_perm_user_notifications_is_identity_match() {
|
||||
// Same strict-privacy gate as :authz — no admin bypass, direct
|
||||
// UUID equality, anti-enum parity. A regression here would
|
||||
// let admins snoop on other users' notification streams.
|
||||
let id = Uuid::new_v4();
|
||||
assert_eq!(
|
||||
Topic::UserNotifications(id).required_perm(),
|
||||
AuthzCheck::IdentityMatch { user_id: id }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_serializes_with_snake_case_discriminator() {
|
||||
// The `#[serde(tag = "event")]` shape is the WS wire contract for
|
||||
@@ -651,6 +712,14 @@ mod tests {
|
||||
},
|
||||
"authz_changed",
|
||||
),
|
||||
(
|
||||
MessageBusEvent::NotificationReceived {
|
||||
notification_id: Uuid::nil(),
|
||||
kind: "share_granted".into(),
|
||||
created_at: chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap(),
|
||||
},
|
||||
"notification_received",
|
||||
),
|
||||
(
|
||||
MessageBusEvent::JobRunStarted {
|
||||
name: "backend_migration".into(),
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod mount_registry;
|
||||
pub mod music_service;
|
||||
pub mod nextcloud_file_id_service;
|
||||
pub mod nextcloud_login_flow_service;
|
||||
pub mod notification_application_service;
|
||||
pub mod people_service;
|
||||
pub mod places_service;
|
||||
pub mod recent_service;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Orchestrates persistent notifications.
|
||||
//!
|
||||
//! `create()` is the single ingester entry point:
|
||||
//!
|
||||
//! 1. Insert the row via [`NotificationRepository::create`].
|
||||
//! 2. Publish a thin `NotificationReceived` event on
|
||||
//! `user:{user_id}:notifications` so subscribed sessions refetch
|
||||
//! immediately.
|
||||
//!
|
||||
//! The DB row is the truth (see `docs/plan/message-bus.md § Slice E`).
|
||||
//! The bus is best-effort — a subscriber offline at publish time
|
||||
//! recovers on next `GET /api/notifications`. Publish happens AFTER
|
||||
//! the DB write succeeds, never inside a transaction — the plan's
|
||||
//! "publish after commit" invariant.
|
||||
//!
|
||||
//! Reads (`list_for_user`, `count_unread_for_user`) and state changes
|
||||
//! (`mark_read`, `mark_all_read`, `delete`) back the REST endpoints in
|
||||
//! `interfaces/api/handlers/notifications.rs`. Every mutating method
|
||||
//! is scoped on `user_id` at the SQL layer; the service does not run
|
||||
//! its own AuthZ check because the identity is by construction
|
||||
//! (`caller_id == user_id`, extracted from the auth middleware).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::message_bus_ports::{MessageBus, MessageBusEvent, Topic};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::notification::{NewNotification, Notification};
|
||||
use crate::domain::repositories::notification_repository::{
|
||||
NotificationListFilter, NotificationRepository,
|
||||
};
|
||||
|
||||
pub struct NotificationApplicationService {
|
||||
repo: Arc<dyn NotificationRepository>,
|
||||
bus: Arc<dyn MessageBus>,
|
||||
}
|
||||
|
||||
impl NotificationApplicationService {
|
||||
pub fn new(repo: Arc<dyn NotificationRepository>, bus: Arc<dyn MessageBus>) -> Self {
|
||||
Self { repo, bus }
|
||||
}
|
||||
|
||||
/// Insert a row for `new_notif` and publish a thin bus event.
|
||||
/// Returns the persisted row. This is the ingester-facing method
|
||||
/// — called from `ShareService::create_grant`,
|
||||
/// `AuthApplicationService` (new-device login),
|
||||
/// `SchedulerEngine` (job completed for actor), and the quota
|
||||
/// threshold hook.
|
||||
pub async fn create(&self, new_notif: NewNotification) -> Result<Notification, DomainError> {
|
||||
let row = self.repo.create(&new_notif).await?;
|
||||
|
||||
// Publish AFTER the row is durable. Silent no-op if the bus
|
||||
// is disabled at boot (`OXICLOUD_MESSAGEBUS_ENABLE=false`) —
|
||||
// the WS route is unmounted so the publish just hits a dead
|
||||
// sender. The FE bell still works: it reads from the DB on
|
||||
// mount. See plan § "Slice E".
|
||||
self.bus.publish(
|
||||
&Topic::UserNotifications(row.user_id),
|
||||
MessageBusEvent::NotificationReceived {
|
||||
notification_id: row.id,
|
||||
kind: row.kind.clone(),
|
||||
created_at: row.created_at,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// List notifications for `user_id` newest-first. Default limit at
|
||||
/// this layer is 50 rows (the repo caps at 500 defensively).
|
||||
pub async fn list_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
filter: NotificationListFilter,
|
||||
) -> Result<Vec<Notification>, DomainError> {
|
||||
self.repo.list_for_user(user_id, &filter).await
|
||||
}
|
||||
|
||||
/// Unread badge count.
|
||||
pub async fn count_unread_for_user(&self, user_id: Uuid) -> Result<i64, DomainError> {
|
||||
self.repo.count_unread_for_user(user_id).await
|
||||
}
|
||||
|
||||
/// Mark one notification as read. Returns `true` if the row
|
||||
/// transitioned unread → read (i.e. was owned by `caller_id` and
|
||||
/// was previously unread). Returns `false` for already-read,
|
||||
/// missing, or misowned rows — indistinguishable at the wire so
|
||||
/// enumeration doesn't leak.
|
||||
pub async fn mark_read(
|
||||
&self,
|
||||
notification_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<bool, DomainError> {
|
||||
self.repo
|
||||
.mark_read(notification_id, caller_id, Utc::now())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Bulk mark-all-read. Returns rows updated.
|
||||
pub async fn mark_all_read(&self, caller_id: Uuid) -> Result<u64, DomainError> {
|
||||
self.repo
|
||||
.mark_all_read_for_user(caller_id, Utc::now())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Hard-delete one row. Same anti-enumeration semantics as
|
||||
/// [`mark_read`] — returns `false` for missing / misowned.
|
||||
pub async fn delete(
|
||||
&self,
|
||||
notification_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<bool, DomainError> {
|
||||
self.repo.delete_by_id(notification_id, caller_id).await
|
||||
}
|
||||
|
||||
/// Retention job entry point. Called by `notifications_cleanup`
|
||||
/// on its daily cadence — deletes read rows older than `cutoff`.
|
||||
/// Unread rows are always preserved.
|
||||
pub async fn purge_read_before_cutoff(
|
||||
&self,
|
||||
cutoff: chrono::DateTime<Utc>,
|
||||
) -> Result<u64, DomainError> {
|
||||
self.repo.purge_read_before(cutoff).await
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,17 @@ fn channels() -> Value {
|
||||
"UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" },
|
||||
}
|
||||
},
|
||||
"UserNotifications": {
|
||||
"address": "user:{userId}:notifications",
|
||||
"description": "A user's private notifications channel. Identity-scoped: caller_id must equal userId (no admin bypass). Auto-subscribed at session open; the FE bell refetches `GET /api/notifications` when a `notification_received` event fires. The DB row is authoritative — a missed push recovers on the next mount.",
|
||||
"parameters": {
|
||||
"userId": { "description": "User UUID — must match the authenticated caller" }
|
||||
},
|
||||
"messages": {
|
||||
"SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" },
|
||||
"UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" },
|
||||
}
|
||||
},
|
||||
"Job": {
|
||||
"address": "job:{jobName}",
|
||||
"description": "A named background job's run lifecycle — Started / Progress / Ended. Consumed by the admin dashboard so operators who trigger a long-running job (backend migration, thumb import…) can navigate off the admin page and come back without losing progress. AuthZ: admin-only (Class 3 role-scoped) — non-admin gets `topic_forbidden`, indistinguishable on the wire from an unknown topic.",
|
||||
@@ -316,6 +327,7 @@ fn components() -> Value {
|
||||
"FolderRenamedData": folder_renamed_schema(),
|
||||
"FolderMovedData": folder_moved_schema(),
|
||||
"FolderDeletedData": folder_deleted_schema(),
|
||||
"NotificationReceivedData": notification_received_schema(),
|
||||
"JobRunStartedData": job_run_started_schema(),
|
||||
"JobRunProgressData": job_run_progress_schema(),
|
||||
"JobRunEndedData": job_run_ended_schema(),
|
||||
@@ -598,6 +610,7 @@ fn event_kind_schema() -> Value {
|
||||
"enum": [
|
||||
"file_created", "file_renamed", "file_moved", "file_deleted",
|
||||
"folder_created", "folder_renamed", "folder_moved", "folder_deleted",
|
||||
"notification_received",
|
||||
"job_run_started", "job_run_progress", "job_run_ended",
|
||||
],
|
||||
})
|
||||
@@ -615,6 +628,7 @@ fn event_data_union_schema() -> Value {
|
||||
ref_schema("FolderRenamedData"),
|
||||
ref_schema("FolderMovedData"),
|
||||
ref_schema("FolderDeletedData"),
|
||||
ref_schema("NotificationReceivedData"),
|
||||
ref_schema("JobRunStartedData"),
|
||||
ref_schema("JobRunProgressData"),
|
||||
ref_schema("JobRunEndedData"),
|
||||
@@ -732,6 +746,27 @@ fn folder_deleted_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────── Notification event payload ──────────────────
|
||||
// Published on `Topic::UserNotifications(user_id)`. Identity-scoped
|
||||
// (Class 2) — caller must equal the topic's user_id, no admin
|
||||
// bypass. Payload is a thin poke: `notification_id` + `kind` +
|
||||
// `created_at`. The FE bell refetches `GET /api/notifications` on
|
||||
// receipt for the row's full payload; the DB is the truth, the bus
|
||||
// event is just an invalidation.
|
||||
|
||||
fn notification_received_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"description": "A new notification was created for the caller. Payload is intentionally thin — the FE refetches `GET /api/notifications` for the row's full contents. `kind` is the notification's registered kind slug (`share_granted`, `job_completed_for_you`, `new_login_from_new_device`, `storage_quota_threshold`, …); the FE may use it to route a toast for high-priority kinds but never treats it as authoritative.",
|
||||
"required": ["notification_id", "kind", "created_at"],
|
||||
"properties": {
|
||||
"notification_id": { "type": "string", "format": "uuid" },
|
||||
"kind": { "type": "string" },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────── Job event data payloads ─────────────────────
|
||||
// Published on `Topic::Job(name)`. AuthZ is Class-3 (admin-only) —
|
||||
// non-admins get `topic_forbidden` on subscribe, so these payloads
|
||||
|
||||
@@ -2311,6 +2311,17 @@ pub struct FeaturesConfig {
|
||||
/// Enabled by default: expired-auth-row cleanup is a
|
||||
/// security-hygiene default, not opt-in.
|
||||
pub grant_cleanup: GrantCleanupConfig,
|
||||
|
||||
/// Retention window (in days) for read notification rows —
|
||||
/// `notif.notifications` with `read_at IS NOT NULL`. Unread rows
|
||||
/// are preserved unconditionally; the `notifications_cleanup`
|
||||
/// scheduled job deletes read rows older than this on a daily
|
||||
/// cadence.
|
||||
///
|
||||
/// Env: `OXICLOUD_NOTIFICATIONS_RETENTION_DAYS` (default `30`).
|
||||
/// Minimum 1 (0 would delete every read row on every tick — the
|
||||
/// service clamps defensively).
|
||||
pub notifications_retention_days: u32,
|
||||
}
|
||||
|
||||
/// Config for the daily expired-grant purge (see
|
||||
@@ -2498,6 +2509,7 @@ impl Default for FeaturesConfig {
|
||||
webdav_drive_listing_prefix: "@drive".to_string(),
|
||||
enable_message_bus: true, // Message bus (WS + ticket) on by default
|
||||
grant_cleanup: GrantCleanupConfig::default(),
|
||||
notifications_retention_days: 30, // 30 days is the plan's default
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3377,6 +3389,15 @@ impl AppConfig {
|
||||
config.features.enable_message_bus = val;
|
||||
}
|
||||
|
||||
// Slice E — notification retention. Read as u32 so a
|
||||
// non-numeric or negative value falls back to the declared
|
||||
// default (30 days) rather than crashing at boot.
|
||||
if let Ok(raw) = env::var("OXICLOUD_NOTIFICATIONS_RETENTION_DAYS")
|
||||
&& let Ok(val) = raw.parse::<u32>()
|
||||
{
|
||||
config.features.notifications_retention_days = val.max(1);
|
||||
}
|
||||
|
||||
if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_search
|
||||
{
|
||||
|
||||
@@ -2418,6 +2418,7 @@ impl AppServiceFactory {
|
||||
mock_email_sender: None, // populated below
|
||||
magic_link_invite_service: None, // populated below
|
||||
recipient_notification_service: None, // populated below alongside magic_link_invite_service
|
||||
notification_service: None, // populated below (Slice E)
|
||||
// Per-caller limits, configurable since the hardcoded ceilings
|
||||
// had no escape hatch for deployments where several actors share
|
||||
// one identity — a CI suite running as a single `admin` shares
|
||||
@@ -2536,6 +2537,42 @@ impl AppServiceFactory {
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Persistent in-app notifications (Slice E). Repo + bus
|
||||
// are both always available when auth is on; the service
|
||||
// wraps them into the ingester-facing `create()` +
|
||||
// bell-facing reads. Always wired under `auth_service` —
|
||||
// notifications are per-user and require an authenticated
|
||||
// caller everywhere they surface.
|
||||
let notif_repo: Arc<
|
||||
dyn crate::domain::repositories::notification_repository::NotificationRepository,
|
||||
> = Arc::new(
|
||||
crate::infrastructure::repositories::pg::NotificationPgRepository::new(
|
||||
pool.clone(),
|
||||
),
|
||||
);
|
||||
let notif_bus: Arc<dyn crate::application::ports::message_bus_ports::MessageBus> =
|
||||
app_state.bus.clone();
|
||||
let notification_service = Arc::new(
|
||||
crate::application::services::notification_application_service::NotificationApplicationService::new(
|
||||
notif_repo,
|
||||
notif_bus,
|
||||
),
|
||||
);
|
||||
app_state.notification_service = Some(notification_service.clone());
|
||||
|
||||
// Retention sweep — daily; deletes read notifications
|
||||
// older than OXICLOUD_NOTIFICATIONS_RETENTION_DAYS. Same
|
||||
// self-registering pattern as `trash_cleanup`.
|
||||
let retention_days = app_state.core.config.features.notifications_retention_days;
|
||||
let _ = Arc::new(
|
||||
crate::infrastructure::services::notifications_cleanup_service::NotificationsCleanupService::new(
|
||||
notification_service,
|
||||
retention_days,
|
||||
),
|
||||
)
|
||||
.register(&app_state.core.job_registry)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 9b. Wire admin settings service when auth is available
|
||||
@@ -3441,6 +3478,16 @@ pub struct AppState {
|
||||
pub recipient_notification_service: Option<
|
||||
Arc<crate::application::services::recipient_notification_service::RecipientNotificationService>,
|
||||
>,
|
||||
/// Persistent in-app notifications — bell UI, retention job, four
|
||||
/// initial ingesters (share-granted, new-login-from-new-device,
|
||||
/// job-completed-for-you, storage-quota-threshold). Always
|
||||
/// populated when auth is enabled (bell requires an authenticated
|
||||
/// caller). Wraps a PG repo + the message bus; `create()` writes
|
||||
/// the row AND publishes on `user:{u}:notifications` in one call.
|
||||
/// See `docs/plan/message-bus.md § Slice E`.
|
||||
pub notification_service: Option<
|
||||
Arc<crate::application::services::notification_application_service::NotificationApplicationService>,
|
||||
>,
|
||||
/// Per-caller sliding-window limiter for `GET /api/users/{id}`. The
|
||||
/// endpoint's primary defense is the visibility check, but a stale
|
||||
/// JWT could in theory iterate UUIDs against the related-by-grant
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ mod favorites_pg_repository;
|
||||
pub mod file_metadata_repository;
|
||||
mod magic_link_token_pg_repository;
|
||||
mod nextcloud_object_id_repository;
|
||||
mod notification_pg_repository;
|
||||
mod opaque_pg_repository;
|
||||
pub mod playlist_pg_repository;
|
||||
mod recent_items_pg_repository;
|
||||
@@ -48,6 +49,7 @@ pub use file_metadata_repository::FileMetadataRepository;
|
||||
pub use folder_db_repository::FolderDbRepository;
|
||||
pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository;
|
||||
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
||||
pub use notification_pg_repository::NotificationPgRepository;
|
||||
pub use opaque_pg_repository::OpaquePgRepository;
|
||||
pub use playlist_pg_repository::{
|
||||
AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! PostgreSQL implementation of [`NotificationRepository`].
|
||||
//!
|
||||
//! Backs the bell UI plus the daily retention job. All queries scope on
|
||||
//! `user_id` at the SQL layer so a row misroute in the caller can't
|
||||
//! leak another user's data through mark_read / delete. Schema lives
|
||||
//! in `migrations/20261026000000_notifications.sql`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::notification::{NewNotification, Notification};
|
||||
use crate::domain::repositories::notification_repository::{
|
||||
NotificationListFilter, NotificationRepository,
|
||||
};
|
||||
|
||||
pub struct NotificationPgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl NotificationPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_row(row: &sqlx::postgres::PgRow) -> Result<Notification, DomainError> {
|
||||
let map_err = |field: &str, e: sqlx::Error| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"Notification",
|
||||
format!("read {field}: {e}"),
|
||||
)
|
||||
};
|
||||
Ok(Notification {
|
||||
id: row.try_get("id").map_err(|e| map_err("id", e))?,
|
||||
user_id: row.try_get("user_id").map_err(|e| map_err("user_id", e))?,
|
||||
kind: row.try_get("kind").map_err(|e| map_err("kind", e))?,
|
||||
payload: row.try_get("payload").map_err(|e| map_err("payload", e))?,
|
||||
created_at: row
|
||||
.try_get("created_at")
|
||||
.map_err(|e| map_err("created_at", e))?,
|
||||
read_at: row.try_get("read_at").ok(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn db_err(op: &'static str, e: sqlx::Error) -> DomainError {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"Notification",
|
||||
format!("{op}: {e}"),
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationRepository for NotificationPgRepository {
|
||||
async fn create(&self, new_notif: &NewNotification) -> Result<Notification, DomainError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO notif.notifications (user_id, kind, payload)
|
||||
VALUES ($1::uuid, $2, $3)
|
||||
RETURNING id, user_id, kind, payload, created_at, read_at
|
||||
"#,
|
||||
)
|
||||
.bind(new_notif.user_id)
|
||||
.bind(&new_notif.kind)
|
||||
.bind(&new_notif.payload)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("create", e))?;
|
||||
Self::map_row(&row)
|
||||
}
|
||||
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
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).
|
||||
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
|
||||
}
|
||||
}
|
||||
.map_err(|e| db_err("list_for_user", e))?;
|
||||
|
||||
rows.iter().map(Self::map_row).collect()
|
||||
}
|
||||
|
||||
async fn count_unread_for_user(&self, user_id: Uuid) -> Result<i64, DomainError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(*)::bigint AS c
|
||||
FROM notif.notifications
|
||||
WHERE user_id = $1::uuid AND read_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("count_unread_for_user", e))?;
|
||||
row.try_get::<i64, _>("c")
|
||||
.map_err(|e| db_err("count_unread_for_user.map", e))
|
||||
}
|
||||
|
||||
async fn mark_read(
|
||||
&self,
|
||||
notification_id: Uuid,
|
||||
user_id: Uuid,
|
||||
at: DateTime<Utc>,
|
||||
) -> Result<bool, DomainError> {
|
||||
// Guard on read_at IS NULL so a re-issued call from a client
|
||||
// that's already ack'd the row is a no-op instead of stamping
|
||||
// a later timestamp over the earlier one.
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
UPDATE notif.notifications
|
||||
SET read_at = $3
|
||||
WHERE id = $1::uuid
|
||||
AND user_id = $2::uuid
|
||||
AND read_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(notification_id)
|
||||
.bind(user_id)
|
||||
.bind(at)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("mark_read", e))?;
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn mark_all_read_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
at: DateTime<Utc>,
|
||||
) -> Result<u64, DomainError> {
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
UPDATE notif.notifications
|
||||
SET read_at = $2
|
||||
WHERE user_id = $1::uuid AND read_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(at)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("mark_all_read_for_user", e))?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
async fn delete_by_id(
|
||||
&self,
|
||||
notification_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<bool, DomainError> {
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM notif.notifications
|
||||
WHERE id = $1::uuid AND user_id = $2::uuid
|
||||
"#,
|
||||
)
|
||||
.bind(notification_id)
|
||||
.bind(user_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("delete_by_id", e))?;
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn purge_read_before(&self, cutoff: DateTime<Utc>) -> Result<u64, DomainError> {
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM notif.notifications
|
||||
WHERE read_at IS NOT NULL AND read_at < $1
|
||||
"#,
|
||||
)
|
||||
.bind(cutoff)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("purge_read_before", e))?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,7 @@ fn event_kind(event: &MessageBusEvent) -> &'static str {
|
||||
MessageBusEvent::FolderMoved { .. } => "folder_moved",
|
||||
MessageBusEvent::FolderDeleted { .. } => "folder_deleted",
|
||||
MessageBusEvent::AuthzChanged { .. } => "authz_changed",
|
||||
MessageBusEvent::NotificationReceived { .. } => "notification_received",
|
||||
MessageBusEvent::JobRunStarted { .. } => "job_run_started",
|
||||
MessageBusEvent::JobRunProgress { .. } => "job_run_progress",
|
||||
MessageBusEvent::JobRunEnded { .. } => "job_run_ended",
|
||||
|
||||
@@ -39,6 +39,7 @@ pub mod mock_email_sender;
|
||||
pub mod mount_provider_factory;
|
||||
pub mod nextcloud_chunked_upload_service;
|
||||
pub mod noop_face_analyzer;
|
||||
pub mod notifications_cleanup_service;
|
||||
pub mod oidc_service;
|
||||
#[cfg(feature = "faces-onnx")]
|
||||
pub mod onnx_face_analyzer;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! `notifications_cleanup` scheduled job — daily retention sweep.
|
||||
//!
|
||||
//! Deletes rows from `notif.notifications` where `read_at IS NOT NULL`
|
||||
//! and older than the retention window. Unread rows are preserved
|
||||
//! unconditionally (the whole point of the durable table is that a
|
||||
//! user offline for a month still sees the share-granted notice on
|
||||
//! next login).
|
||||
//!
|
||||
//! Retention window comes from `OXICLOUD_NOTIFICATIONS_RETENTION_DAYS`
|
||||
//! (default 30), applied at job dispatch — one env var maps to one
|
||||
//! `retention_days` parameter so an operator can override the default
|
||||
//! at trigger time without a redeploy.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use tracing::info;
|
||||
|
||||
use crate::application::services::notification_application_service::NotificationApplicationService;
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs, Mutates};
|
||||
|
||||
/// Parameter declaration table. Kept at module scope so
|
||||
/// `JobHandler::parameters` can return a `'static` slice without
|
||||
/// stack-allocating each call.
|
||||
static PARAMETERS: [crate::infrastructure::scheduler::JobParam; 1] =
|
||||
[crate::infrastructure::scheduler::JobParam::number(
|
||||
"retention_days",
|
||||
30,
|
||||
"Delete read notifications older than this many days.",
|
||||
)];
|
||||
|
||||
pub struct NotificationsCleanupService {
|
||||
service: Arc<NotificationApplicationService>,
|
||||
/// Default retention window in days when the trigger call did NOT
|
||||
/// supply an explicit `retention_days` parameter. Read from
|
||||
/// `OXICLOUD_NOTIFICATIONS_RETENTION_DAYS` at boot; the constructor
|
||||
/// clamps to a minimum of 1 day (0 would purge every read row on
|
||||
/// every tick).
|
||||
default_retention_days: i64,
|
||||
}
|
||||
|
||||
impl NotificationsCleanupService {
|
||||
pub const JOB_NAME: &'static str = "notifications_cleanup";
|
||||
|
||||
pub fn new(service: Arc<NotificationApplicationService>, default_retention_days: u32) -> Self {
|
||||
Self {
|
||||
service,
|
||||
default_retention_days: default_retention_days.max(1) as i64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Interval — daily. Same tier as `trash_cleanup`; retention is a
|
||||
/// "days" concept, so a finer cadence buys nothing.
|
||||
fn interval() -> Duration {
|
||||
Duration::from_secs(24 * 3600)
|
||||
}
|
||||
|
||||
/// Register self with the scheduler. Chained DI helper, same shape
|
||||
/// as [`TrashCleanupService::register`].
|
||||
pub async fn register(self: Arc<Self>, registry: &JobRegistry) -> Arc<Self> {
|
||||
registry
|
||||
.register(self.clone(), Some(Self::interval()), None)
|
||||
.await;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl JobHandler for NotificationsCleanupService {
|
||||
fn name(&self) -> &str {
|
||||
Self::JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Deletes read notifications older than the retention window \
|
||||
(default 30 days, override via `retention_days` parameter or \
|
||||
OXICLOUD_NOTIFICATIONS_RETENTION_DAYS). Unread rows are \
|
||||
preserved unconditionally."
|
||||
}
|
||||
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Always
|
||||
}
|
||||
|
||||
fn parameters(&self) -> &'static [crate::infrastructure::scheduler::JobParam] {
|
||||
// Declared default of 30 days is the SAME literal the config
|
||||
// block's env fallback uses (`OXICLOUD_NOTIFICATIONS_RETENTION_DAYS`
|
||||
// default), so an operator who never sets the env sees 30
|
||||
// everywhere. The env-derived `default_retention_days` on
|
||||
// this struct only diverges from 30 when the operator DID
|
||||
// set the env — see the guard in `run()` below.
|
||||
&PARAMETERS
|
||||
}
|
||||
|
||||
async fn run(&self, args: &JobRunArgs) -> JobOutcome {
|
||||
// `get_number` returns the fallback ONLY when the arg is
|
||||
// absent — but declared defaults are seeded by the engine
|
||||
// before `run` runs (see JobRunArgs::normalized_for), so the
|
||||
// param is always present with either the caller's value or
|
||||
// the declared 30. We treat "declared default AND env
|
||||
// override differs" as "use env override" to keep the
|
||||
// OXICLOUD_NOTIFICATIONS_RETENTION_DAYS knob effective
|
||||
// without teaching the engine per-instance defaults.
|
||||
let declared_default = 30_i64;
|
||||
let raw = args.get_number("retention_days", declared_default);
|
||||
let retention_days = if raw == declared_default {
|
||||
self.default_retention_days
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
.max(1);
|
||||
let cutoff = Utc::now() - chrono::Duration::days(retention_days);
|
||||
|
||||
match self.service.purge_read_before_cutoff(cutoff).await {
|
||||
Ok(removed) => {
|
||||
info!(
|
||||
target: "audit",
|
||||
event = "notifications.retention_sweep",
|
||||
retention_days,
|
||||
removed,
|
||||
"🧹 notifications retention sweep: {removed} row(s) purged (retention {retention_days} d)"
|
||||
);
|
||||
JobOutcome::ok_with(
|
||||
removed,
|
||||
serde_json::json!({
|
||||
"retention_days": retention_days,
|
||||
"removed": removed,
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(e) => JobOutcome::err(format!("notifications cleanup failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,6 +330,71 @@ pub async fn create_grant(
|
||||
"🤝 grant created with role '{}'", role.as_str(),
|
||||
);
|
||||
|
||||
// Slice E — persistent in-app notification (bell) for every
|
||||
// recipient user. Separate channel from the email path below:
|
||||
// the DB row is authoritative and survives SMTP being down /
|
||||
// the recipient not having email, and it powers the FE bell +
|
||||
// unread badge.
|
||||
//
|
||||
// Fan out to the resolved user ids:
|
||||
// - Subject::User(id) → one row for that user
|
||||
// - Subject::Group(id) → one row per transitive member (uses
|
||||
// subject_group_service if wired; groups
|
||||
// without a service configured skip the
|
||||
// bell but still get email via the
|
||||
// recipient service below)
|
||||
// - Subject::Token(_) → no bell row (anonymous share link, no
|
||||
// target user to route it to)
|
||||
//
|
||||
// Every failure here is best-effort — a row-write hiccup logs a
|
||||
// warn and continues to the email path. The grant row is already
|
||||
// durable in `role_grants`; the recipient can still discover the
|
||||
// share via the resources-shared-with-me listing.
|
||||
if let Some(notif_svc) = state.notification_service.as_ref() {
|
||||
let recipient_ids: Vec<uuid::Uuid> = match subject {
|
||||
Subject::User(id) => vec![id],
|
||||
Subject::Group(group_id) => match state.subject_group_service.as_ref() {
|
||||
Some(sgs) => sgs
|
||||
.list_transitive_users(group_id)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("group {group_id} member expansion failed; skipping bell: {e}");
|
||||
Vec::new()
|
||||
}),
|
||||
None => Vec::new(),
|
||||
},
|
||||
Subject::Token(_) => Vec::new(),
|
||||
};
|
||||
for rid in recipient_ids {
|
||||
// Self-shares (owner grants themselves via a group they
|
||||
// are also in) would fire a bell on the owner — filter
|
||||
// that out here. Every other filter (opt-out flag, etc.)
|
||||
// is deferred; in-app notifications are less intrusive
|
||||
// than SMTP so the ceremony is lighter.
|
||||
if rid == caller_id {
|
||||
continue;
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"granter_id": caller_id,
|
||||
"resource_type": resource.type_str(),
|
||||
"resource_id": resource.id(),
|
||||
"role": role.as_str(),
|
||||
"expires_at": expires_at,
|
||||
});
|
||||
let new_notif = crate::domain::entities::notification::NewNotification {
|
||||
user_id: rid,
|
||||
kind: crate::domain::entities::notification::kind::SHARE_GRANTED.to_string(),
|
||||
payload,
|
||||
};
|
||||
if let Err(e) = notif_svc.create(new_notif).await {
|
||||
warn!(
|
||||
"notification.create failed for share_granted (recipient={rid}, resource={:?}): {e}",
|
||||
resource
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PR N1 — route the post-grant notification through the unified
|
||||
// RecipientNotificationService. Handles user/group/token subjects
|
||||
// uniformly (Token subjects return an empty outcome set); applies
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod grant_handler;
|
||||
pub mod i18n_handler;
|
||||
pub mod magic_link_handler;
|
||||
pub mod music_handler;
|
||||
pub mod notifications_handler;
|
||||
pub mod opaque_auth_handler;
|
||||
pub mod people_handler;
|
||||
pub mod photos_handler;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! `/api/notifications/*` — the bell UI's REST surface.
|
||||
//!
|
||||
//! Five endpoints back the FE `NotificationBell`:
|
||||
//!
|
||||
//! - `GET /api/notifications` — list newest-first; optional
|
||||
//! `unread=true` filter, `before` cursor, `limit` cap.
|
||||
//! - `GET /api/notifications/unread` — badge-only fast path (count).
|
||||
//! - `POST /api/notifications/{id}/read` — mark one as read.
|
||||
//! - `POST /api/notifications/read-all` — bulk mark-all-read.
|
||||
//! - `DELETE /api/notifications/{id}` — hard-delete one row.
|
||||
//!
|
||||
//! Every endpoint scopes on `auth_user.id` at the SQL layer via the
|
||||
//! application service, so an id enumeration against
|
||||
//! `POST /api/notifications/{id}/read` returns the same 204 whether
|
||||
//! the row exists-and-belongs-to-somebody-else, or doesn't exist at
|
||||
//! all. Anti-enumeration is the reason the response body doesn't
|
||||
//! distinguish "already read" from "not yours" — the service returns
|
||||
//! a bool for our logs, we always return 204 to the wire.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::services::notification_application_service::NotificationApplicationService;
|
||||
use crate::domain::entities::notification::Notification;
|
||||
use crate::domain::repositories::notification_repository::NotificationListFilter;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Wire shape for one notification row. `payload` stays a raw JSON
|
||||
/// value — per-kind decoding happens on the FE using the `kind`
|
||||
/// discriminant.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct NotificationDto {
|
||||
pub id: Uuid,
|
||||
pub kind: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub payload: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// `null` = unread.
|
||||
pub read_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<Notification> for NotificationDto {
|
||||
fn from(n: Notification) -> Self {
|
||||
Self {
|
||||
id: n.id,
|
||||
kind: n.kind,
|
||||
payload: n.payload,
|
||||
created_at: n.created_at,
|
||||
read_at: n.read_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query params for `GET /api/notifications`.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
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.
|
||||
pub before: Option<DateTime<Utc>>,
|
||||
/// Max rows returned. Server-side clamp at 500.
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ListResponseDto {
|
||||
pub items: Vec<NotificationDto>,
|
||||
/// Unread rows for this user across the whole table — the bell
|
||||
/// badge reads this. Kept on the list response so a bell open
|
||||
/// doesn't need a second round-trip for the badge.
|
||||
pub unread_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UnreadCountDto {
|
||||
pub unread_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct MarkAllReadResponseDto {
|
||||
/// Number of rows that transitioned unread → read.
|
||||
pub marked: u64,
|
||||
}
|
||||
|
||||
/// GET /api/notifications
|
||||
#[utoipa::path(
|
||||
get,
|
||||
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"),
|
||||
("limit" = Option<u32>, Query, description = "Max rows (server-side clamp at 500)"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of notifications", body = ListResponseDto),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "notifications"
|
||||
)]
|
||||
pub async fn list_notifications(
|
||||
State(service): State<Arc<NotificationApplicationService>>,
|
||||
auth_user: AuthUser,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Json<ListResponseDto>, AppError> {
|
||||
let filter = NotificationListFilter {
|
||||
limit: query.limit,
|
||||
unread_only: if query.unread { Some(true) } else { None },
|
||||
before: query.before,
|
||||
};
|
||||
let rows = service.list_for_user(auth_user.id, filter).await?;
|
||||
let unread_count = service.count_unread_for_user(auth_user.id).await?;
|
||||
Ok(Json(ListResponseDto {
|
||||
items: rows.into_iter().map(NotificationDto::from).collect(),
|
||||
unread_count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/notifications/unread — badge-only fast path.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/notifications/unread",
|
||||
responses(
|
||||
(status = 200, description = "Unread count", body = UnreadCountDto),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "notifications"
|
||||
)]
|
||||
pub async fn unread_count(
|
||||
State(service): State<Arc<NotificationApplicationService>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<Json<UnreadCountDto>, AppError> {
|
||||
let unread_count = service.count_unread_for_user(auth_user.id).await?;
|
||||
Ok(Json(UnreadCountDto { unread_count }))
|
||||
}
|
||||
|
||||
/// POST /api/notifications/{id}/read — mark one as read.
|
||||
///
|
||||
/// Always responds 204 regardless of whether the row existed and
|
||||
/// belonged to the caller — the service's `bool` return is logged
|
||||
/// (audit reason `notification.marked_read` on success), never
|
||||
/// surfaced to the wire.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/notifications/{id}/read",
|
||||
params(("id" = Uuid, Path, description = "Notification id")),
|
||||
responses((status = 204, description = "Marked read (idempotent, anti-enum)")),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "notifications"
|
||||
)]
|
||||
pub async fn mark_read(
|
||||
State(service): State<Arc<NotificationApplicationService>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let transitioned = service.mark_read(id, auth_user.id).await?;
|
||||
if transitioned {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::notifications",
|
||||
caller_id = %auth_user.id,
|
||||
notification_id = %id,
|
||||
"notification marked read"
|
||||
);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// POST /api/notifications/read-all — bulk mark-all-read.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/notifications/read-all",
|
||||
responses((status = 200, description = "Rows marked", body = MarkAllReadResponseDto)),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "notifications"
|
||||
)]
|
||||
pub async fn mark_all_read(
|
||||
State(service): State<Arc<NotificationApplicationService>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<Json<MarkAllReadResponseDto>, AppError> {
|
||||
let marked = service.mark_all_read(auth_user.id).await?;
|
||||
Ok(Json(MarkAllReadResponseDto { marked }))
|
||||
}
|
||||
|
||||
/// DELETE /api/notifications/{id} — hard-delete one row.
|
||||
///
|
||||
/// Same anti-enum semantics as `mark_read` — always 204.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/notifications/{id}",
|
||||
params(("id" = Uuid, Path, description = "Notification id")),
|
||||
responses((status = 204, description = "Deleted (idempotent, anti-enum)")),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "notifications"
|
||||
)]
|
||||
pub async fn delete_notification(
|
||||
State(service): State<Arc<NotificationApplicationService>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let deleted = service.delete(id, auth_user.id).await?;
|
||||
if deleted {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::notifications",
|
||||
caller_id = %auth_user.id,
|
||||
notification_id = %id,
|
||||
"notification deleted"
|
||||
);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -401,6 +401,20 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppSt
|
||||
// effect is the `rt.revoked` per evicted sub.
|
||||
install_subscription(Topic::UserAuthz(caller_id), &mut subs, &out_tx, &state);
|
||||
|
||||
// Auto-subscribe to the caller's private notifications topic —
|
||||
// same identity-scoped invariant as `:authz`. Events on this
|
||||
// stream (`MessageBusEvent::NotificationReceived`) forward
|
||||
// through as an `rt.event` notification so the FE bell can flip
|
||||
// its unread badge without a poll. The DB row is the truth (see
|
||||
// `docs/plan/message-bus.md § Slice E`); a missed push recovers
|
||||
// on the next `GET /api/notifications`.
|
||||
install_subscription(
|
||||
Topic::UserNotifications(caller_id),
|
||||
&mut subs,
|
||||
&out_tx,
|
||||
&state,
|
||||
);
|
||||
|
||||
// Server-initiated protocol Ping ticker — prevents intermediate
|
||||
// proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping
|
||||
// the TCP session as idle. Browsers can't send Ping control frames
|
||||
@@ -722,6 +736,12 @@ fn handle_unsubscribe(id: Value, params: Value, subs: &mut HashMap<String, Sub>)
|
||||
/// loop then walks the sub set and drops matching topics. Any other
|
||||
/// event kind on this topic is ignored (defensive; shouldn't happen
|
||||
/// in MVP).
|
||||
/// - For `Topic::UserNotifications(_)`: an incoming
|
||||
/// `MessageBusEvent::NotificationReceived` is forwarded through the
|
||||
/// default path — the FE bell listens for `rt.event` on the
|
||||
/// auto-subscribed identity topic and refetches `GET
|
||||
/// /api/notifications` when it sees one. Same anti-enumeration
|
||||
/// invariant as `:authz` (identity-scoped, no admin bypass).
|
||||
/// - For every other topic: bus events are wrapped into a client-
|
||||
/// visible `rt.event` notification and pushed as `SessionOut::Frame`.
|
||||
fn install_subscription(
|
||||
|
||||
@@ -195,6 +195,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
let share_service = app_state.share_service.clone();
|
||||
let favorites_service = app_state.favorites_service.clone();
|
||||
let recent_service = app_state.recent_service.clone();
|
||||
let notification_service = app_state.notification_service.clone();
|
||||
// authorization is no longer extracted separately — the grants router now
|
||||
// uses app_state directly so handlers can access all services.
|
||||
|
||||
@@ -409,6 +410,25 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
};
|
||||
|
||||
// Notifications bell (Slice E). Mounted only when the service is
|
||||
// wired (i.e. auth is enabled — bell requires a caller). Non-
|
||||
// registration path: with the flag off, the routes 404 instead of
|
||||
// 5xx-ing on a NULL service — matches the OXICLOUD_MESSAGEBUS_ENABLE
|
||||
// approach for `/api/rt/*` and `OXICLOUD_ENABLE_EXTERNAL_MOUNTS`
|
||||
// for admin mounts.
|
||||
let notifications_router = if let Some(ref svc) = notification_service {
|
||||
use crate::interfaces::api::handlers::notifications_handler;
|
||||
Router::new()
|
||||
.route("/", get(notifications_handler::list_notifications))
|
||||
.route("/unread", get(notifications_handler::unread_count))
|
||||
.route("/read-all", post(notifications_handler::mark_all_read))
|
||||
.route("/{id}/read", post(notifications_handler::mark_read))
|
||||
.route("/{id}", delete(notifications_handler::delete_notification))
|
||||
.with_state(svc.clone())
|
||||
} else {
|
||||
Router::new()
|
||||
};
|
||||
|
||||
// Create routes for chunked uploads (large files >10MB).
|
||||
// All five handlers are free functions — see chunked_upload_handler.rs for why
|
||||
// #[utoipa::path] cannot be applied to ChunkedUploadHandler impl methods directly.
|
||||
@@ -455,7 +475,8 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.nest("/shares", share_router)
|
||||
.nest("/grants", grants_router)
|
||||
.nest("/favorites", favorites_router)
|
||||
.nest("/recent", recent_router);
|
||||
.nest("/recent", recent_router)
|
||||
.nest("/notifications", notifications_router);
|
||||
|
||||
// Photos timeline endpoint — lists all image/video files sorted by capture date
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user