feat(notification): add persistent notification

This commit is contained in:
Edouard Vanbelle
2026-09-11 22:08:35 +02:00
parent a6138aa4d9
commit 617ae4b424
32 changed files with 1911 additions and 28 deletions
+72 -3
View File
@@ -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(),
+1
View File
@@ -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
}
}
+35
View File
@@ -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
+21
View File
@@ -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
{
+47
View File
@@ -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
+1
View File
@@ -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;
+70
View File
@@ -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,
}
+1
View File
@@ -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",
+1
View File
@@ -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
+1
View File
@@ -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)
}
+20
View File
@@ -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(
+22 -1
View File
@@ -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
{