feat(notification): enrich notifications, resources are clicable
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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": {}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user