feat(notification): enrich notifications, resources are clicable

This commit is contained in:
Edouard Vanbelle
2026-09-12 13:04:50 +02:00
parent 78d1b7ab77
commit c4dfa9ccf2
15 changed files with 707 additions and 148 deletions
+14 -21
View File
@@ -308,23 +308,20 @@ pub enum MessageBusEvent {
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.
/// [`Topic::UserNotifications`]. **Pure cache-invalidation
/// event** — no fields on the wire. The topic itself signals
/// the semantic; the FE responds by refetching `GET
/// /api/notifications` (or a delta via `?after=<cursor>`), and
/// the REST DTO carries every payload field.
///
/// `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>,
},
/// Serde emits `{"event":"notification_received","data":{}}`.
///
/// Design principle: AsyncAPI defines the envelope + transport;
/// OpenAPI defines the payload. Keeping this event fieldless
/// enforces the split at its strongest — zero schema overlap
/// between the two specs for this kind. See
/// `docs/plan/templated-messages.md § Bus event is a pure poke`.
NotificationReceived,
/// A background job's run started. Published on
/// [`Topic::Job`]. `started_at` is server wall-clock (RFC 3339
@@ -713,11 +710,7 @@ 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(),
},
MessageBusEvent::NotificationReceived,
"notification_received",
),
(
@@ -51,18 +51,15 @@ impl NotificationApplicationService {
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".
// Publish a pure poke AFTER the row is durable. No fields
// on the wire — the topic itself signals the semantic, the
// FE refetches via REST to render. Silent no-op if the bus
// is disabled at boot (`OXICLOUD_MESSAGEBUS_ENABLE=false`).
// See `docs/plan/templated-messages.md § Bus event is a
// pure poke`.
self.bus.publish(
&Topic::UserNotifications(row.user_id),
MessageBusEvent::NotificationReceived {
notification_id: row.id,
kind: row.kind.clone(),
created_at: row.created_at,
},
MessageBusEvent::NotificationReceived,
);
Ok(row)
+9 -7
View File
@@ -755,15 +755,17 @@ fn folder_deleted_schema() -> Value {
// event is just an invalidation.
fn notification_received_schema() -> Value {
// Pure cache-invalidation event — no fields on the wire.
// The topic (`user:{u}:notifications`) signals the semantic;
// the FE responds by refetching `GET /api/notifications`
// (or a delta via `?after=<cursor>`). All payload data lives
// in the REST DTO (OpenAPI), not here. See
// `docs/plan/templated-messages.md § Schema ownership`.
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" },
}
"description": "A new notification was created for the caller. Pure cache-invalidation event — no fields on the wire. The FE refetches `GET /api/notifications` on receipt and reads the payload from the REST DTO (see `openapi.json`). Zero schema overlap between the bus wire (this file) and the REST wire — the strict form of the AsyncAPI-defines-envelope / OpenAPI-defines-payload split.",
"additionalProperties": false,
"properties": {}
})
}
+62
View File
@@ -68,3 +68,65 @@ pub struct NewNotification {
pub kind: String,
pub payload: serde_json::Value,
}
// ════════════════════════════════════════════════════════════════════════════
// Per-kind payload types (OpenAPI-owned)
//
// Each kind's payload shape lives here as a real Rust struct with
// `#[derive(ToSchema)]`. OpenAPI auto-derives the schema from Rust;
// AsyncAPI never sees these types (the bus event is a pure poke —
// see `docs/plan/templated-messages.md § Schema ownership`). Adding
// a new kind = new struct here + a Rust `kind::` const above + a
// template branch in `NotificationRow.svelte`.
// ════════════════════════════════════════════════════════════════════════════
/// Payload written on the DB row when a `share_granted` notification
/// is created. Kind = [`kind::SHARE_GRANTED`].
///
/// The `resource_name` and `resource_path` fields are snapshotted at
/// grant time — even if the resource is later renamed or moved, the
/// notification still reflects what it was called when the share
/// happened. `resource_path` is populated for kinds addressable via
/// `/files/[...path]` (folders + files); `None` for calendars,
/// address books, playlists, drives.
///
/// Wire form matches the `payload` JSONB column exactly — Rust is
/// the source of truth, OpenAPI schema auto-derives via
/// `#[derive(ToSchema)]`. Adding a new field is additive on the
/// JSONB column; no migration needed.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct SharegrantedPayload {
/// The user who created the grant.
pub granter_id: Uuid,
/// Resource kind slug: `folder`, `file`, `drive`, `calendar`,
/// `address_book`, `playlist`. Same string form as
/// [`crate::domain::services::authorization::Resource::type_str`].
pub resource_type: String,
/// Resource UUID.
pub resource_id: Uuid,
/// Display name at grant time. `None` if the lookup failed at
/// ingest (bell renders a generic fallback in that case).
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_name: Option<String>,
/// Storage path at grant time. Populated for
/// `folder` / `file` kinds; `None` for other kinds.
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_path: Option<String>,
/// FE-navigation hint — the folder id the notification link
/// should route to. Populated when `resource_type` isn't itself
/// a folder-shaped resource but the FE still wants to land in
/// `/files/{id}` (concretely: **drives** — the recipient lands
/// on the drive's root folder). For `resource_type == 'folder'`
/// the FE uses `resource_id` directly and this field stays
/// `None`; same for `file` (routes to `/shared-with-me?file=`
/// via a different path). `None` for calendar / address_book /
/// playlist — those aren't reachable via `/files/*` at all.
#[serde(skip_serializing_if = "Option::is_none")]
pub navigate_folder_id: Option<Uuid>,
/// Role granted (`viewer`, `editor`, `owner`, …). Same string
/// form as [`crate::domain::services::authorization::Role::as_str`].
pub role: String,
/// Grant expiry, if bounded. `None` = never expires.
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
@@ -199,7 +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::NotificationReceived => "notification_received",
MessageBusEvent::JobRunStarted { .. } => "job_run_started",
MessageBusEvent::JobRunProgress { .. } => "job_run_progress",
MessageBusEvent::JobRunEnded { .. } => "job_run_ended",
+128 -8
View File
@@ -365,6 +365,18 @@ pub async fn create_grant(
},
Subject::Token(_) => Vec::new(),
};
// Enrich the payload with the resource's display name and,
// for browsable kinds (folder / file), its storage path.
// Snapshotted at grant time — the FE bell renders "Alice
// shared 'Q4 Report'", and stays correct even if the folder
// is later renamed. Lookup failure logs a warn + falls back
// to a payload without name/path; the FE renders the generic
// fallback in that case. Only fetched once per grant, then
// reused for every recipient of the fan-out.
let (resource_name, resource_path, navigate_folder_id) =
resolve_resource_display(&state, resource).await;
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
@@ -374,17 +386,27 @@ pub async fn create_grant(
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 payload = crate::domain::entities::notification::SharegrantedPayload {
granter_id: caller_id,
resource_type: resource.type_str().to_string(),
resource_id: resource.id(),
resource_name: resource_name.clone(),
resource_path: resource_path.clone(),
navigate_folder_id,
role: role.as_str().to_string(),
expires_at,
};
let payload_value = match serde_json::to_value(&payload) {
Ok(v) => v,
Err(e) => {
warn!("share_granted payload serialize failed: {e}");
continue;
}
};
let new_notif = crate::domain::entities::notification::NewNotification {
user_id: rid,
kind: crate::domain::entities::notification::kind::SHARE_GRANTED.to_string(),
payload,
payload: payload_value,
};
if let Err(e) = notif_svc.create(new_notif).await {
warn!(
@@ -1377,3 +1399,101 @@ pub async fn list_my_shares(
// touch it directly.
#[allow(dead_code)]
fn _ensure_subject_dto_compiles(_: SubjectDto) {}
/// Look up a resource's display name (`name`) and, for kinds
/// addressable via `/files/[...path]`, its storage path. Fed into
/// [`SharegrantedPayload`] at grant time so the FE bell renders
/// "Alice shared `Q4 Report`" with a clickable link.
///
/// Best-effort — a lookup failure returns `(None, None)` and the
/// FE bell falls back to a generic template. Slice E's ingester
/// treats bell enrichment as best-effort by design; the grant
/// itself is already durable in `role_grants` when this runs.
///
/// Only `Resource::Folder` and `Resource::File` carry a
/// `resource_path`. Drives, calendars, address books and playlists
/// have a display name but no `/files/*` route — link renders as
/// bold text via the FE's null-path fallback.
/// Enrichment tuple: `(display_name, storage_path,
/// navigate_folder_id)`. All three fields are optional; each is
/// populated per resource kind (see match arms below). Fed into the
/// `SharegrantedPayload` at grant time.
type ResourceDisplay = (Option<String>, Option<String>, Option<Uuid>);
async fn resolve_resource_display(state: &AppStateRef, resource: Resource) -> ResourceDisplay {
match resource {
Resource::Folder(id) => {
match state
.applications
.folder_service_concrete
.get_folders_by_ids(&[id.to_string()])
.await
{
Ok(mut dtos) => match dtos.pop() {
// `navigate_folder_id` stays None for folders —
// the FE uses `resource_id` directly to build
// `/files/{id}`.
Some(dto) => (Some(dto.name), Some(dto.path), None),
None => (None, None, None),
},
Err(e) => {
warn!("resolve_resource_display: folder {id} lookup failed: {e}");
(None, None, None)
}
}
}
Resource::File(id) => {
match state
.applications
.file_retrieval_service
.get_files_by_ids(&[id.to_string()])
.await
{
Ok(mut dtos) => match dtos.pop() {
// File's `path` is the file's own storage path
// — the FE bell renders it in the tooltip but
// the actual link routes to
// `/shared-with-me?file=<id>` (path can't help
// when the recipient has no parent-folder
// access). `navigate_folder_id` stays None.
Some(dto) => (Some(dto.name), Some(dto.path), None),
None => (None, None, None),
},
Err(e) => {
warn!("resolve_resource_display: file {id} lookup failed: {e}");
(None, None, None)
}
}
}
Resource::Drive(id) => {
// A drive grant is really "here's the drive, land on
// its root folder." The FE follows `navigate_folder_id`
// into `/files/{root_folder_id}` — the drive itself has
// no browsable URL, but its root folder does.
//
// `resource_name` comes from the root folder's display
// name (drives don't have their own name column; the
// root folder's `storage.folders.name` is the drive's
// canonical label — see `DriveWithRootName`).
match state.drive_repo.get_by_id(id).await {
Ok(dwn) => (
Some(dwn.root_folder_name),
None,
Some(dwn.drive.root_folder_id),
),
Err(e) => {
warn!("resolve_resource_display: drive {id} lookup failed: {e:?}");
(None, None, None)
}
}
}
// Non-browsable resources — no `/files` link at all.
// Calendars / address books / playlists render as bold
// text in the bell via the null-fallback FE path. Fetching
// a name for these is deferred; today the bell shows
// "shared a <resource_type>" for them.
Resource::Calendar(_) | Resource::AddressBook(_) | Resource::Playlist(_) => {
(None, None, None)
}
}
}
+17
View File
@@ -172,6 +172,12 @@ use crate::interfaces::middleware::server_status::{HeaderPayload, ProgressHeader
handlers::recent_handler::record_item_access,
handlers::recent_handler::remove_from_recent,
handlers::recent_handler::clear_recent_items,
// Notifications (Slice E — bell + retention)
handlers::notifications_handler::list_notifications,
handlers::notifications_handler::unread_count,
handlers::notifications_handler::mark_read,
handlers::notifications_handler::mark_all_read,
handlers::notifications_handler::delete_notification,
// Photos handler (free function)
handlers::photos_handler::list_photos,
handlers::photos_handler::list_photos_geo,
@@ -382,6 +388,17 @@ use crate::interfaces::middleware::server_status::{HeaderPayload, ProgressHeader
// Public server-config discovery — `GET /api/config`.
ServerConfigDto,
FeaturesDto,
// Notifications (Slice E) — bell REST DTOs + per-kind
// typed payload structs. The generic `NotificationDto.payload`
// stays `serde_json::Value` on the schema; each per-kind
// struct (e.g. `SharegrantedPayload`) is registered here
// so FE consumers can type-narrow on `kind`. Adding a
// new kind = one more entry here + one Rust struct.
handlers::notifications_handler::NotificationDto,
handlers::notifications_handler::ListResponseDto,
handlers::notifications_handler::UnreadCountDto,
handlers::notifications_handler::MarkAllReadResponseDto,
crate::domain::entities::notification::SharegrantedPayload,
HeaderPayload,
ProgressHeader,
OidcProviderInfoDto,