feat(notification): enrich notifications, resources are clicable
This commit is contained in:
@@ -45,6 +45,17 @@ export default ts.config(
|
|||||||
'no-undef': 'off'
|
'no-undef': 'off'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Auto-generated AsyncAPI DTOs (Modelina output). Empty
|
||||||
|
// interfaces are legitimate for wire messages whose `data`
|
||||||
|
// field is intentionally a no-fields object (pure-poke events
|
||||||
|
// like `notification_received`). See
|
||||||
|
// `docs/plan/templated-messages.md § Bus event is a pure poke`.
|
||||||
|
files: ['src/lib/generated/message-bus/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-empty-object-type': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// `static/` holds vendored, verbatim assets (the delta-upload worker and
|
// `static/` holds vendored, verbatim assets (the delta-upload worker and
|
||||||
// the wasm-bindgen hash glue) — lint them as the upstream ships them.
|
// the wasm-bindgen hash glue) — lint them as the upstream ships them.
|
||||||
|
|||||||
@@ -971,3 +971,33 @@ export const NOTIFICATION_KIND = {
|
|||||||
JOB_COMPLETED_FOR_YOU: 'job_completed_for_you',
|
JOB_COMPLETED_FOR_YOU: 'job_completed_for_you',
|
||||||
STORAGE_QUOTA_THRESHOLD: 'storage_quota_threshold'
|
STORAGE_QUOTA_THRESHOLD: 'storage_quota_threshold'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload shape for `share_granted` notifications — hand-mirror of
|
||||||
|
* `crate::domain::entities::notification::SharegrantedPayload`.
|
||||||
|
* `#[derive(ToSchema)]` on the Rust struct makes it authoritative;
|
||||||
|
* this interface is a projection for FE type-narrowing until the
|
||||||
|
* codebase adopts `openapi-typescript` for the REST surface.
|
||||||
|
*
|
||||||
|
* See `docs/plan/templated-messages.md § Making payloads a real
|
||||||
|
* Rust struct` for the rationale — OpenAPI owns the payload; the
|
||||||
|
* bus event is a pure poke.
|
||||||
|
*/
|
||||||
|
export interface SharegrantedPayload {
|
||||||
|
granter_id: string;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
resource_name?: string;
|
||||||
|
/** Storage path for `folder` / `file` kinds only. `undefined`
|
||||||
|
* for drive / calendar / address_book / playlist. */
|
||||||
|
resource_path?: string;
|
||||||
|
/** FE-navigation hint for kinds whose `resource_id` isn't itself
|
||||||
|
* a folder id. Populated for `drive` (the drive's root folder
|
||||||
|
* id — the FE routes to `/files/{navigate_folder_id}` because
|
||||||
|
* drives don't have a browsable URL of their own). Absent for
|
||||||
|
* folder (uses `resource_id` directly), file (routes to
|
||||||
|
* `/shared-with-me?file=`), and non-browsable kinds. */
|
||||||
|
navigate_folder_id?: string;
|
||||||
|
role: string;
|
||||||
|
expires_at?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
notifications as persistentNotifications,
|
notifications as persistentNotifications,
|
||||||
useNotifications
|
useNotifications
|
||||||
} from '$lib/composables/useNotifications.svelte';
|
} from '$lib/composables/useNotifications.svelte';
|
||||||
|
import NotificationRow from '$lib/components/NotificationRow.svelte';
|
||||||
import { errorToast } from '$lib/utils/errors';
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
|
|
||||||
@@ -346,67 +347,6 @@
|
|||||||
const totalUnread = $derived(ui.unread + persistentNotifications.unread);
|
const totalUnread = $derived(ui.unread + persistentNotifications.unread);
|
||||||
const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread));
|
const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread));
|
||||||
|
|
||||||
/** Format the server-side `created_at` for a persistent row. */
|
|
||||||
function formatPersistentTime(iso: string): string {
|
|
||||||
try {
|
|
||||||
return formatTime(new Date(iso).getTime());
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Human summary for a persistent notification. Kind-specific
|
|
||||||
* wording lives here so the DTO stays payload-agnostic. */
|
|
||||||
function persistentSummary(row: { kind: string; payload: Record<string, unknown> }): string {
|
|
||||||
switch (row.kind) {
|
|
||||||
case 'share_granted': {
|
|
||||||
const role = String(row.payload.role ?? 'a role');
|
|
||||||
const resType = String(row.payload.resource_type ?? 'resource');
|
|
||||||
return t(
|
|
||||||
'notifications.persistent.share_granted',
|
|
||||||
{ role, resType },
|
|
||||||
`You were granted ${role} on a ${resType}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'new_login_from_new_device':
|
|
||||||
return t(
|
|
||||||
'notifications.persistent.new_device_login',
|
|
||||||
'A new device signed into your account.'
|
|
||||||
);
|
|
||||||
case 'job_completed_for_you': {
|
|
||||||
const name = String(row.payload.name ?? row.payload.job_name ?? 'a job');
|
|
||||||
return t('notifications.persistent.job_completed', { name }, `Job "${name}" finished.`);
|
|
||||||
}
|
|
||||||
case 'storage_quota_threshold':
|
|
||||||
return t(
|
|
||||||
'notifications.persistent.quota_threshold',
|
|
||||||
'You are approaching your storage quota.'
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return t(
|
|
||||||
'notifications.persistent.generic',
|
|
||||||
{ kind: row.kind },
|
|
||||||
`Notification (${row.kind}).`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Icon for a persistent row's kind. Falls back to a generic bell. */
|
|
||||||
function persistentIcon(kind: string): string {
|
|
||||||
switch (kind) {
|
|
||||||
case 'share_granted':
|
|
||||||
return 'user-plus';
|
|
||||||
case 'new_login_from_new_device':
|
|
||||||
return 'shield-alt';
|
|
||||||
case 'job_completed_for_you':
|
|
||||||
return 'check-circle';
|
|
||||||
case 'storage_quota_threshold':
|
|
||||||
return 'database';
|
|
||||||
default:
|
|
||||||
return 'bell';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMobileSearch() {
|
function openMobileSearch() {
|
||||||
searchActive = true;
|
searchActive = true;
|
||||||
requestAnimationFrame(() => searchInputEl?.focus());
|
requestAnimationFrame(() => searchInputEl?.focus());
|
||||||
@@ -966,32 +906,7 @@
|
|||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each persistentNotifications.items as row (row.id)}
|
{#each persistentNotifications.items as row (row.id)}
|
||||||
<div
|
<NotificationRow {row} onactivate={() => (notifOpen = false)} />
|
||||||
class="notif-item notif-item--{row.kind}"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
data-testid="appshell-notif-persistent-item"
|
|
||||||
aria-label={persistentSummary(row)}
|
|
||||||
style:font-weight={row.read_at === null ? '500' : 'normal'}
|
|
||||||
style:cursor="pointer"
|
|
||||||
onclick={() => void persistentNotifications.markRead(row.id)}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
void persistentNotifications.markRead(row.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span class="notif-item-icon">
|
|
||||||
<Icon name={persistentIcon(row.kind)} />
|
|
||||||
</span>
|
|
||||||
<div class="notif-item-body">
|
|
||||||
<div class="notif-item-text">{persistentSummary(row)}</div>
|
|
||||||
<div class="notif-item-time">
|
|
||||||
{formatPersistentTime(row.created_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<!--
|
||||||
|
Single-file renderer for one persistent notification (bell).
|
||||||
|
|
||||||
|
Slice E's rendering path — reuses the AppShell bell's existing
|
||||||
|
`.notif-item*` classes so it slots into the same dropdown as the
|
||||||
|
transient toast rows. One switch on `row.kind`; a kind's block gets
|
||||||
|
extracted to its own component only when it exceeds ~30 lines,
|
||||||
|
needs local `$state`, or two kinds start sharing a sub-component.
|
||||||
|
See `docs/plan/templated-messages.md § Rendering`.
|
||||||
|
|
||||||
|
Click semantics:
|
||||||
|
- Row body click → navigate to the resource + mark-read
|
||||||
|
- Anchor click (inner) → same navigation, `stopPropagation` so
|
||||||
|
the outer click doesn't re-fire
|
||||||
|
- Close button click → delete the row, `stopPropagation`
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import UserVignette from '$lib/components/UserVignette.svelte';
|
||||||
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
|
import type { Notification, SharegrantedPayload } from '$lib/api/types';
|
||||||
|
import { NOTIFICATION_KIND } from '$lib/api/types';
|
||||||
|
import { notifications } from '$lib/composables/useNotifications.svelte';
|
||||||
|
|
||||||
|
/** SvelteKit's `resolve()` is typed with compile-time route keys.
|
||||||
|
* The bell's `href` is a runtime-computed path from the resource's
|
||||||
|
* storage path, not a compile-time key — same pattern as
|
||||||
|
* `AppShell::navHref`. Cast at one wrapper site rather than
|
||||||
|
* scattering `@ts-expect-error` per callsite. */
|
||||||
|
function runtimeResolve(href: string): string {
|
||||||
|
// @ts-expect-error runtime-known path, not a literal typed route key
|
||||||
|
return resolve(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
row: Notification;
|
||||||
|
/** Called after the row-body click's mark-read/navigate — the
|
||||||
|
* bell panel wraps this to close its dropdown on activation. */
|
||||||
|
onactivate?: () => void;
|
||||||
|
}
|
||||||
|
let { row, onactivate }: Props = $props();
|
||||||
|
|
||||||
|
function formatPersistentTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistentIcon(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case NOTIFICATION_KIND.SHARE_GRANTED:
|
||||||
|
return 'user-plus';
|
||||||
|
case NOTIFICATION_KIND.NEW_LOGIN_FROM_NEW_DEVICE:
|
||||||
|
return 'shield-alt';
|
||||||
|
case NOTIFICATION_KIND.JOB_COMPLETED_FOR_YOU:
|
||||||
|
return 'check-circle';
|
||||||
|
case NOTIFICATION_KIND.STORAGE_QUOTA_THRESHOLD:
|
||||||
|
return 'database';
|
||||||
|
default:
|
||||||
|
return 'bell';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort resource link by kind:
|
||||||
|
*
|
||||||
|
* - `folder` → `/files/{id}` (SvelteKit's `/files/[...path]`
|
||||||
|
* route resolves by UUID; the caller has Read on the shared
|
||||||
|
* folder by construction of the grant).
|
||||||
|
* - `file` → `/shared-with-me?file={id}` — the `/files/{id}`
|
||||||
|
* route requires a FOLDER id AND access to the parent folder,
|
||||||
|
* neither of which is guaranteed for a file-scoped grant.
|
||||||
|
* `/shared-with-me` is the guaranteed-accessible home for
|
||||||
|
* shares (every recipient of a `share_granted` sees their
|
||||||
|
* row here), and its `?file=` deep link opens the inline
|
||||||
|
* FileViewer.
|
||||||
|
* - `drive` → `/files/{navigate_folder_id}` — the drive
|
||||||
|
* itself has no browsable URL, but its root folder does.
|
||||||
|
* Backend enrichment populates `navigate_folder_id` from
|
||||||
|
* `Drive.root_folder_id`. If the lookup failed at ingest
|
||||||
|
* (`navigate_folder_id` absent), the row falls back to
|
||||||
|
* bold-text — better silent than a broken link.
|
||||||
|
* - Other kinds (calendar, address_book, playlist) → `null`.
|
||||||
|
* Not reachable via `/files/*`; the row renders the
|
||||||
|
* resource name as bold text (not a link). Adding routing
|
||||||
|
* for those = one branch here + backend enrichment for the
|
||||||
|
* kind.
|
||||||
|
*
|
||||||
|
* `resource_path` on the payload is kept as a display-time
|
||||||
|
* snapshot (used by hover / a11y labels) but not the anchor
|
||||||
|
* target; the anchor uses `resource_id` (or
|
||||||
|
* `navigate_folder_id`) so the link survives future renames +
|
||||||
|
* moves. See `docs/plan/templated-messages.md § File
|
||||||
|
* notification routing`. */
|
||||||
|
function resourceHref(payload: unknown): string | null {
|
||||||
|
if (typeof payload !== 'object' || payload === null) return null;
|
||||||
|
const p = payload as Record<string, unknown>;
|
||||||
|
const type = p.resource_type;
|
||||||
|
const id = p.resource_id;
|
||||||
|
if (typeof id !== 'string' || id.length === 0) return null;
|
||||||
|
if (type === 'folder') return `/files/${id}`;
|
||||||
|
if (type === 'file') return `/shared-with-me?file=${encodeURIComponent(id)}`;
|
||||||
|
if (type === 'drive') {
|
||||||
|
const navId = p.navigate_folder_id;
|
||||||
|
if (typeof navId === 'string' && navId.length > 0) {
|
||||||
|
return `/files/${navId}`;
|
||||||
|
}
|
||||||
|
// Enrichment failed at ingest — no browsable target.
|
||||||
|
// Bold-text fallback in the template.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBodyClick(): void {
|
||||||
|
const href = resourceHref(row.payload);
|
||||||
|
void notifications.markRead(row.id);
|
||||||
|
onactivate?.();
|
||||||
|
// `href` is a runtime path from the resource's storage
|
||||||
|
// path — not a compile-time route key. `runtimeResolve`
|
||||||
|
// wraps `resolve()` at a single site (lint rule looks for
|
||||||
|
// the literal call, so we disable at the callsite).
|
||||||
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
|
if (href) void goto(runtimeResolve(href));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBodyKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onBodyClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAnchorClick(e: MouseEvent): void {
|
||||||
|
// The <a> element handles navigation natively (letting the
|
||||||
|
// browser go via <a href> preserves cmd-click / middle-click
|
||||||
|
// semantics). Just mark-read + stop the outer click.
|
||||||
|
e.stopPropagation();
|
||||||
|
void notifications.markRead(row.id);
|
||||||
|
onactivate?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloseClick(e: MouseEvent): void {
|
||||||
|
e.stopPropagation();
|
||||||
|
void notifications.delete(row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloseKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void notifications.delete(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="notif-item notif-item--{row.kind}"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
data-testid="notification-row"
|
||||||
|
style:font-weight={row.read_at === null ? '500' : 'normal'}
|
||||||
|
style:cursor="pointer"
|
||||||
|
onclick={onBodyClick}
|
||||||
|
onkeydown={onBodyKeydown}
|
||||||
|
>
|
||||||
|
<span class="notif-item-icon">
|
||||||
|
<Icon name={persistentIcon(row.kind)} />
|
||||||
|
</span>
|
||||||
|
<div class="notif-item-body">
|
||||||
|
<div class="notif-item-text">
|
||||||
|
{#if row.kind === NOTIFICATION_KIND.SHARE_GRANTED}
|
||||||
|
{@const p = row.payload as unknown as SharegrantedPayload}
|
||||||
|
{@const href = resourceHref(row.payload)}
|
||||||
|
<UserVignette userId={p.granter_id} />
|
||||||
|
<!-- eslint-disable-next-line svelte/no-useless-mustaches -->
|
||||||
|
{' '}{t(
|
||||||
|
'notifications.share_granted.verb',
|
||||||
|
'shared'
|
||||||
|
)}<!-- eslint-disable-next-line svelte/no-useless-mustaches -->
|
||||||
|
{' '}
|
||||||
|
{#if href}
|
||||||
|
<!-- `runtimeResolve` wraps SvelteKit's `resolve()`
|
||||||
|
with the same `@ts-expect-error` pattern
|
||||||
|
AppShell uses — bell href is a runtime path,
|
||||||
|
not a compile-time route key. -->
|
||||||
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
|
<a href={runtimeResolve(href)} onclick={onAnchorClick}>
|
||||||
|
{p.resource_name ?? p.resource_type}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<strong>{p.resource_name ?? p.resource_type}</strong>
|
||||||
|
{/if}
|
||||||
|
<!-- eslint-disable-next-line svelte/no-useless-mustaches -->
|
||||||
|
{' '}({p.role})
|
||||||
|
{:else}
|
||||||
|
<!-- Generic fallback for kinds without their own template
|
||||||
|
yet (new_login_from_new_device, job_completed_for_you,
|
||||||
|
storage_quota_threshold — each waits for its
|
||||||
|
ingester to ship, see `docs/plan/templated-messages.md
|
||||||
|
§ Deferred`). -->
|
||||||
|
<span>
|
||||||
|
{t('notifications.persistent.generic', { kind: row.kind }, `Notification (${row.kind}).`)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="notif-item-time">{formatPersistentTime(row.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="notif-item-close"
|
||||||
|
aria-label={t('common.delete', 'Delete')}
|
||||||
|
onclick={onCloseClick}
|
||||||
|
onkeydown={onCloseKeydown}
|
||||||
|
>
|
||||||
|
<Icon name="times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* Row layout classes (`.notif-item`, `.notif-item-icon`,
|
||||||
|
* `.notif-item-body`, `.notif-item-text`, `.notif-item-time`)
|
||||||
|
* are inherited from AppShell's bell dropdown. Only the close
|
||||||
|
* button needs its own styling — everything else is styled
|
||||||
|
* by the parent panel.
|
||||||
|
*
|
||||||
|
* `:global()` needed because Svelte scopes styles per
|
||||||
|
* component; the button's class is applied to a real element
|
||||||
|
* in this component but interacts with the parent's `.notif-item`
|
||||||
|
* hover state.
|
||||||
|
*/
|
||||||
|
.notif-item-close {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: flex-start;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-item-close:hover,
|
||||||
|
.notif-item-close:focus-visible {
|
||||||
|
background: var(--color-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -40,6 +40,8 @@
|
|||||||
import { messageBus } from '$lib/message-bus/client.svelte';
|
import { messageBus } from '$lib/message-bus/client.svelte';
|
||||||
import { session } from '$lib/stores/session.svelte';
|
import { session } from '$lib/stores/session.svelte';
|
||||||
import { serverConfig } from '$lib/stores/serverConfig.svelte';
|
import { serverConfig } from '$lib/stores/serverConfig.svelte';
|
||||||
|
import { ui } from '$lib/stores/ui.svelte';
|
||||||
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import {
|
import {
|
||||||
deleteNotification as apiDelete,
|
deleteNotification as apiDelete,
|
||||||
getUnreadCount,
|
getUnreadCount,
|
||||||
@@ -47,11 +49,54 @@ import {
|
|||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
markNotificationRead
|
markNotificationRead
|
||||||
} from '$lib/api/endpoints/notifications';
|
} from '$lib/api/endpoints/notifications';
|
||||||
import type { Notification } from '$lib/api/types';
|
import type { Notification, SharegrantedPayload } from '$lib/api/types';
|
||||||
|
import { NOTIFICATION_KIND } from '$lib/api/types';
|
||||||
import log from 'loglevel';
|
import log from 'loglevel';
|
||||||
|
|
||||||
const bellLog = log.getLogger('oxi:notifications');
|
const bellLog = log.getLogger('oxi:notifications');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-string summary of a persistent notification — used for the
|
||||||
|
* transient toast preview (fade-to-bell UX affordance) and for
|
||||||
|
* accessible labels where a component can't render rich content.
|
||||||
|
*
|
||||||
|
* Rich Svelte rendering lives in `NotificationRow.svelte`. This is
|
||||||
|
* the string-only equivalent for toasts and aria-labels.
|
||||||
|
*/
|
||||||
|
export function summaryFor(row: Notification): string {
|
||||||
|
switch (row.kind) {
|
||||||
|
case NOTIFICATION_KIND.SHARE_GRANTED: {
|
||||||
|
const p = row.payload as unknown as SharegrantedPayload;
|
||||||
|
const resource = p.resource_name ?? p.resource_type ?? 'a resource';
|
||||||
|
return t(
|
||||||
|
'notifications.persistent.share_granted',
|
||||||
|
{ role: p.role ?? 'access', resource },
|
||||||
|
`Someone shared "${resource}" with you (${p.role ?? 'access'}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case NOTIFICATION_KIND.NEW_LOGIN_FROM_NEW_DEVICE:
|
||||||
|
return t(
|
||||||
|
'notifications.persistent.new_device_login',
|
||||||
|
'A new device signed into your account.'
|
||||||
|
);
|
||||||
|
case NOTIFICATION_KIND.JOB_COMPLETED_FOR_YOU: {
|
||||||
|
const name = String((row.payload as { name?: unknown }).name ?? 'a job');
|
||||||
|
return t('notifications.persistent.job_completed', { name }, `Job "${name}" finished.`);
|
||||||
|
}
|
||||||
|
case NOTIFICATION_KIND.STORAGE_QUOTA_THRESHOLD:
|
||||||
|
return t(
|
||||||
|
'notifications.persistent.quota_threshold',
|
||||||
|
'You are approaching your storage quota.'
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return t(
|
||||||
|
'notifications.persistent.generic',
|
||||||
|
{ kind: row.kind },
|
||||||
|
`Notification (${row.kind}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge `incoming` rows into `existing`, deduplicating on `id`.
|
* Merge `incoming` rows into `existing`, deduplicating on `id`.
|
||||||
* Where an id appears in both, the incoming (fresh-from-server)
|
* Where an id appears in both, the incoming (fresh-from-server)
|
||||||
@@ -125,6 +170,13 @@ class NotificationsStore {
|
|||||||
* Merges via `mergeById` so a concurrent WS push and reconnect
|
* Merges via `mergeById` so a concurrent WS push and reconnect
|
||||||
* catch-up can't double-count a row that landed twice.
|
* catch-up can't double-count a row that landed twice.
|
||||||
*
|
*
|
||||||
|
* Fresh rows — rows the local set didn't have before the merge —
|
||||||
|
* each fire a transient toast via `ui.notify(..., record: false)`
|
||||||
|
* so the user gets a peripheral awareness cue that fades to the
|
||||||
|
* bell (which keeps the row in its persistent history). The
|
||||||
|
* bell's ring animation plays via `ui.ringBell()` so a single
|
||||||
|
* bump signals "something new is in there".
|
||||||
|
*
|
||||||
* Silent no-op when the server returns 0 rows — we're already in
|
* Silent no-op when the server returns 0 rows — we're already in
|
||||||
* sync. Updates `#lastReceivedAt` to the newest of the merged set.
|
* sync. Updates `#lastReceivedAt` to the newest of the merged set.
|
||||||
*/
|
*/
|
||||||
@@ -144,10 +196,30 @@ class NotificationsStore {
|
|||||||
limit: 100
|
limit: 100
|
||||||
});
|
});
|
||||||
if (res.items.length > 0) {
|
if (res.items.length > 0) {
|
||||||
|
// Snapshot the pre-merge id set so we can identify
|
||||||
|
// which rows are genuinely fresh vs already-known
|
||||||
|
// (an already-known row can come back on a delta
|
||||||
|
// fetch if its read_at flipped on another device).
|
||||||
|
const before = new Set(this.#items.map((n) => n.id));
|
||||||
this.#items = mergeById(this.#items, res.items);
|
this.#items = mergeById(this.#items, res.items);
|
||||||
// Newest of merged set — take the first item's
|
// Newest of merged set — take the first item's
|
||||||
// created_at since the result is sorted DESC.
|
// created_at since the result is sorted DESC.
|
||||||
this.#lastReceivedAt = res.items[0].created_at;
|
this.#lastReceivedAt = res.items[0].created_at;
|
||||||
|
|
||||||
|
// Toast preview for every fresh row. Pass
|
||||||
|
// `record: false` so it doesn't add a phantom
|
||||||
|
// transient entry to `ui.notifications` that would
|
||||||
|
// duplicate the persistent row already in
|
||||||
|
// `notifications.items` (the bell dropdown shows
|
||||||
|
// them side by side). `ui.ringBell()` bumps the
|
||||||
|
// bell-ring animation once for the batch.
|
||||||
|
const fresh = res.items.filter((r) => !before.has(r.id));
|
||||||
|
if (fresh.length > 0) {
|
||||||
|
for (const row of fresh) {
|
||||||
|
ui.notify(summaryFor(row), 'info', 4000, false);
|
||||||
|
}
|
||||||
|
ui.ringBell();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// unread_count is the authoritative live server count —
|
// unread_count is the authoritative live server count —
|
||||||
// always update it even when the delta was empty (a row
|
// always update it even when the delta was empty (a row
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
// AUTO-GENERATED — do not edit by hand.
|
// AUTO-GENERATED — do not edit by hand.
|
||||||
// Regenerate with `just asyncapi-ts`.
|
// Regenerate with `just asyncapi-ts`.
|
||||||
interface NotificationReceivedData {
|
interface NotificationReceivedData {}
|
||||||
created_at: string;
|
|
||||||
kind: string;
|
|
||||||
notification_id: string;
|
|
||||||
}
|
|
||||||
export type { NotificationReceivedData as default };
|
export type { NotificationReceivedData as default };
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
import { primeContextPage } from '$lib/utils/listContext';
|
import { primeContextPage } from '$lib/utils/listContext';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { onMount } from 'svelte';
|
import { page } from '$app/state';
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
import {
|
import {
|
||||||
addFavorite,
|
addFavorite,
|
||||||
dateBucket,
|
dateBucket,
|
||||||
@@ -131,6 +132,63 @@
|
|||||||
if (viewerOpen) void fileViewer.load();
|
if (viewerOpen) void fileViewer.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── File-preview deep link (?file=<id>) ──────────────────────────────────
|
||||||
|
// Notification bell's `share_granted` for a FILE resource links here with
|
||||||
|
// `?file=<id>` because `/files/{uuid}` requires a folder id and the
|
||||||
|
// recipient may not have access to the file's parent folder. Every
|
||||||
|
// recipient of a `share_granted` sees the shared item in this list, so
|
||||||
|
// this route is the guaranteed-accessible home for the deep link.
|
||||||
|
// Mirrors the URL↔viewer pattern in `routes/files/[...path]/+page.svelte`.
|
||||||
|
// See `docs/plan/templated-messages.md § File notification routing`.
|
||||||
|
//
|
||||||
|
// URL → viewer. Runs on navigation, on Back/Forward, and once the initial
|
||||||
|
// listing arrives (the item may not be in `items` yet on cold deep link).
|
||||||
|
// `untrack` stops re-firing on viewer-state changes so a user-initiated
|
||||||
|
// close can't be re-opened here.
|
||||||
|
$effect(() => {
|
||||||
|
const fileId = page.url.searchParams.get('file');
|
||||||
|
const currentItems = items;
|
||||||
|
untrack(() => {
|
||||||
|
if (!fileId) {
|
||||||
|
if (viewerOpen) viewerOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (viewerOpen && viewerFile?.id === fileId) return;
|
||||||
|
const match = currentItems.find((it) => isFile(it) && it.id === fileId);
|
||||||
|
if (match && isFile(match)) {
|
||||||
|
viewerFile = match;
|
||||||
|
viewerOpen = true;
|
||||||
|
}
|
||||||
|
// If the file isn't in the current page yet, do nothing —
|
||||||
|
// the next `load()` completion re-fires this effect (items
|
||||||
|
// is `$derived`) and picks up the deep link when the item
|
||||||
|
// lands. A shared file the caller no longer has access to
|
||||||
|
// (revoked between notification and click) never lands and
|
||||||
|
// the viewer stays closed — same failure mode as any
|
||||||
|
// deferred-navigation dangling reference.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Viewer → URL: on close (X / Esc / backdrop), drop the `?file=` param.
|
||||||
|
// `replaceState` so closing doesn't add a history entry. Only act on a
|
||||||
|
// genuine open→closed transition — on cold deep link the viewer starts
|
||||||
|
// closed *with* the param while the listing is still loading, and
|
||||||
|
// stripping it there would race the URL→viewer effect above.
|
||||||
|
let viewerWasOpen = false;
|
||||||
|
$effect(() => {
|
||||||
|
const open = viewerOpen;
|
||||||
|
const hasParam = page.url.searchParams.get('file') !== null;
|
||||||
|
untrack(() => {
|
||||||
|
if (viewerWasOpen && !open && hasParam) {
|
||||||
|
const url = new URL(page.url);
|
||||||
|
url.searchParams.delete('file');
|
||||||
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
|
void goto(url, { keepFocus: true, noScroll: true, replaceState: true });
|
||||||
|
}
|
||||||
|
viewerWasOpen = open;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function open(item: FileItem | FolderItem) {
|
function open(item: FileItem | FolderItem) {
|
||||||
if (!isFile(item)) {
|
if (!isFile(item)) {
|
||||||
goto(resolve(`/files/${item.id}`));
|
goto(resolve(`/files/${item.id}`));
|
||||||
@@ -138,6 +196,18 @@
|
|||||||
}
|
}
|
||||||
viewerFile = item;
|
viewerFile = item;
|
||||||
viewerOpen = true;
|
viewerOpen = true;
|
||||||
|
// Reflect the open file in the URL — makes the view linkable
|
||||||
|
// (paste-into-slack a `/shared-with-me?file=<id>` and it opens
|
||||||
|
// straight into the viewer), matches the pattern
|
||||||
|
// `/files/[...path]` uses, and lets the browser's Back button
|
||||||
|
// close the viewer. `pushState` (not `replaceState`) so Back
|
||||||
|
// pops the viewer off history instead of leaving the page.
|
||||||
|
const url = new URL(page.url);
|
||||||
|
if (url.searchParams.get('file') !== item.id) {
|
||||||
|
url.searchParams.set('file', item.id);
|
||||||
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
|
void goto(url, { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -308,23 +308,20 @@ pub enum MessageBusEvent {
|
|||||||
AuthzChanged { affected_folders: Vec<Uuid> },
|
AuthzChanged { affected_folders: Vec<Uuid> },
|
||||||
|
|
||||||
/// A new notification was created for the caller — publishes on
|
/// A new notification was created for the caller — publishes on
|
||||||
/// [`Topic::UserNotifications`]. Payload is deliberately thin: the
|
/// [`Topic::UserNotifications`]. **Pure cache-invalidation
|
||||||
/// FE learns "there's something new to look at" and calls
|
/// event** — no fields on the wire. The topic itself signals
|
||||||
/// `GET /api/notifications` to load the row. Same recovery path a
|
/// the semantic; the FE responds by refetching `GET
|
||||||
/// missed push takes on next mount, so the wire event stays a
|
/// /api/notifications` (or a delta via `?after=<cursor>`), and
|
||||||
/// pure poke — no fields the bell needs to render on its own.
|
/// the REST DTO carries every payload field.
|
||||||
///
|
///
|
||||||
/// `kind` is the notification's registered kind slug
|
/// Serde emits `{"event":"notification_received","data":{}}`.
|
||||||
/// (`share_granted`, `job_completed_for_you`,
|
///
|
||||||
/// `new_login_from_new_device`, `storage_quota_threshold`, …).
|
/// Design principle: AsyncAPI defines the envelope + transport;
|
||||||
/// The FE may use it to route the toast (high-priority kinds pop
|
/// OpenAPI defines the payload. Keeping this event fieldless
|
||||||
/// a toast; low-priority ones just bump the badge) but never
|
/// enforces the split at its strongest — zero schema overlap
|
||||||
/// treats it as authoritative — the DB row is the truth.
|
/// between the two specs for this kind. See
|
||||||
NotificationReceived {
|
/// `docs/plan/templated-messages.md § Bus event is a pure poke`.
|
||||||
notification_id: Uuid,
|
NotificationReceived,
|
||||||
kind: String,
|
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// A background job's run started. Published on
|
/// A background job's run started. Published on
|
||||||
/// [`Topic::Job`]. `started_at` is server wall-clock (RFC 3339
|
/// [`Topic::Job`]. `started_at` is server wall-clock (RFC 3339
|
||||||
@@ -713,11 +710,7 @@ mod tests {
|
|||||||
"authz_changed",
|
"authz_changed",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
MessageBusEvent::NotificationReceived {
|
MessageBusEvent::NotificationReceived,
|
||||||
notification_id: Uuid::nil(),
|
|
||||||
kind: "share_granted".into(),
|
|
||||||
created_at: chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap(),
|
|
||||||
},
|
|
||||||
"notification_received",
|
"notification_received",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -51,18 +51,15 @@ impl NotificationApplicationService {
|
|||||||
pub async fn create(&self, new_notif: NewNotification) -> Result<Notification, DomainError> {
|
pub async fn create(&self, new_notif: NewNotification) -> Result<Notification, DomainError> {
|
||||||
let row = self.repo.create(&new_notif).await?;
|
let row = self.repo.create(&new_notif).await?;
|
||||||
|
|
||||||
// Publish AFTER the row is durable. Silent no-op if the bus
|
// Publish a pure poke AFTER the row is durable. No fields
|
||||||
// is disabled at boot (`OXICLOUD_MESSAGEBUS_ENABLE=false`) —
|
// on the wire — the topic itself signals the semantic, the
|
||||||
// the WS route is unmounted so the publish just hits a dead
|
// FE refetches via REST to render. Silent no-op if the bus
|
||||||
// sender. The FE bell still works: it reads from the DB on
|
// is disabled at boot (`OXICLOUD_MESSAGEBUS_ENABLE=false`).
|
||||||
// mount. See plan § "Slice E".
|
// See `docs/plan/templated-messages.md § Bus event is a
|
||||||
|
// pure poke`.
|
||||||
self.bus.publish(
|
self.bus.publish(
|
||||||
&Topic::UserNotifications(row.user_id),
|
&Topic::UserNotifications(row.user_id),
|
||||||
MessageBusEvent::NotificationReceived {
|
MessageBusEvent::NotificationReceived,
|
||||||
notification_id: row.id,
|
|
||||||
kind: row.kind.clone(),
|
|
||||||
created_at: row.created_at,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(row)
|
Ok(row)
|
||||||
|
|||||||
@@ -755,15 +755,17 @@ fn folder_deleted_schema() -> Value {
|
|||||||
// event is just an invalidation.
|
// event is just an invalidation.
|
||||||
|
|
||||||
fn notification_received_schema() -> Value {
|
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!({
|
json!({
|
||||||
"type": "object",
|
"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.",
|
"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.",
|
||||||
"required": ["notification_id", "kind", "created_at"],
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {}
|
||||||
"notification_id": { "type": "string", "format": "uuid" },
|
|
||||||
"kind": { "type": "string" },
|
|
||||||
"created_at": { "type": "string", "format": "date-time" },
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,3 +68,65 @@ pub struct NewNotification {
|
|||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub payload: serde_json::Value,
|
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::FolderMoved { .. } => "folder_moved",
|
||||||
MessageBusEvent::FolderDeleted { .. } => "folder_deleted",
|
MessageBusEvent::FolderDeleted { .. } => "folder_deleted",
|
||||||
MessageBusEvent::AuthzChanged { .. } => "authz_changed",
|
MessageBusEvent::AuthzChanged { .. } => "authz_changed",
|
||||||
MessageBusEvent::NotificationReceived { .. } => "notification_received",
|
MessageBusEvent::NotificationReceived => "notification_received",
|
||||||
MessageBusEvent::JobRunStarted { .. } => "job_run_started",
|
MessageBusEvent::JobRunStarted { .. } => "job_run_started",
|
||||||
MessageBusEvent::JobRunProgress { .. } => "job_run_progress",
|
MessageBusEvent::JobRunProgress { .. } => "job_run_progress",
|
||||||
MessageBusEvent::JobRunEnded { .. } => "job_run_ended",
|
MessageBusEvent::JobRunEnded { .. } => "job_run_ended",
|
||||||
|
|||||||
@@ -365,6 +365,18 @@ pub async fn create_grant(
|
|||||||
},
|
},
|
||||||
Subject::Token(_) => Vec::new(),
|
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 {
|
for rid in recipient_ids {
|
||||||
// Self-shares (owner grants themselves via a group they
|
// Self-shares (owner grants themselves via a group they
|
||||||
// are also in) would fire a bell on the owner — filter
|
// are also in) would fire a bell on the owner — filter
|
||||||
@@ -374,17 +386,27 @@ pub async fn create_grant(
|
|||||||
if rid == caller_id {
|
if rid == caller_id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let payload = serde_json::json!({
|
let payload = crate::domain::entities::notification::SharegrantedPayload {
|
||||||
"granter_id": caller_id,
|
granter_id: caller_id,
|
||||||
"resource_type": resource.type_str(),
|
resource_type: resource.type_str().to_string(),
|
||||||
"resource_id": resource.id(),
|
resource_id: resource.id(),
|
||||||
"role": role.as_str(),
|
resource_name: resource_name.clone(),
|
||||||
"expires_at": expires_at,
|
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 {
|
let new_notif = crate::domain::entities::notification::NewNotification {
|
||||||
user_id: rid,
|
user_id: rid,
|
||||||
kind: crate::domain::entities::notification::kind::SHARE_GRANTED.to_string(),
|
kind: crate::domain::entities::notification::kind::SHARE_GRANTED.to_string(),
|
||||||
payload,
|
payload: payload_value,
|
||||||
};
|
};
|
||||||
if let Err(e) = notif_svc.create(new_notif).await {
|
if let Err(e) = notif_svc.create(new_notif).await {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1377,3 +1399,101 @@ pub async fn list_my_shares(
|
|||||||
// touch it directly.
|
// touch it directly.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn _ensure_subject_dto_compiles(_: SubjectDto) {}
|
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::record_item_access,
|
||||||
handlers::recent_handler::remove_from_recent,
|
handlers::recent_handler::remove_from_recent,
|
||||||
handlers::recent_handler::clear_recent_items,
|
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)
|
// Photos handler (free function)
|
||||||
handlers::photos_handler::list_photos,
|
handlers::photos_handler::list_photos,
|
||||||
handlers::photos_handler::list_photos_geo,
|
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`.
|
// Public server-config discovery — `GET /api/config`.
|
||||||
ServerConfigDto,
|
ServerConfigDto,
|
||||||
FeaturesDto,
|
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,
|
HeaderPayload,
|
||||||
ProgressHeader,
|
ProgressHeader,
|
||||||
OidcProviderInfoDto,
|
OidcProviderInfoDto,
|
||||||
|
|||||||
@@ -779,13 +779,14 @@ fi
|
|||||||
|| { cat "$out_s13"; die "S13: expected 1 event, got $(jq -r '.events | length' "$out_s13")"; }
|
|| { cat "$out_s13"; die "S13: expected 1 event, got $(jq -r '.events | length' "$out_s13")"; }
|
||||||
[[ "$(jq -r '.events[0].event' "$out_s13")" == "notification_received" ]] \
|
[[ "$(jq -r '.events[0].event' "$out_s13")" == "notification_received" ]] \
|
||||||
|| die "S13: wrong event discriminator: $(jq -r '.events[0].event' "$out_s13")"
|
|| die "S13: wrong event discriminator: $(jq -r '.events[0].event' "$out_s13")"
|
||||||
[[ "$(jq -r '.events[0].data.kind' "$out_s13")" == "share_granted" ]] \
|
# Pure cache-invalidation event — `data` is intentionally an empty
|
||||||
|| die "S13: wrong notification kind: $(jq -r '.events[0].data.kind' "$out_s13")"
|
# object. Everything about the notification (id, kind, payload) is
|
||||||
# `notification_id` is a fresh UUID stamped by the DB — check it's
|
# on the REST wire (asserted by S14 below), not here. See
|
||||||
# non-empty and non-null. Value asserted by S14 via GET /api/notifications.
|
# `docs/plan/templated-messages.md § Bus event is a pure poke`.
|
||||||
[[ -n "$(jq -r '.events[0].data.notification_id' "$out_s13")" ]] \
|
# If a future change adds fields back to the wire, this assertion
|
||||||
&& [[ "$(jq -r '.events[0].data.notification_id' "$out_s13")" != "null" ]] \
|
# will fail loudly so the drift is caught.
|
||||||
|| die "S13: notification_id missing on wire payload"
|
[[ "$(jq -c '.events[0].data' "$out_s13")" == "{}" ]] \
|
||||||
|
|| { cat "$out_s13"; die "S13: expected empty data on the wire, got $(jq -c '.events[0].data' "$out_s13")"; }
|
||||||
log "S13 OK"
|
log "S13 OK"
|
||||||
|
|
||||||
# ── Scenario 14 — Notification DB row (Slice E) ─────────────────────────────
|
# ── Scenario 14 — Notification DB row (Slice E) ─────────────────────────────
|
||||||
@@ -814,6 +815,26 @@ first_read_at=$(printf '%s' "$notifs" | jq -r --arg fc "$folder_c" \
|
|||||||
'first(.items[] | select(.kind == "share_granted" and .payload.resource_id == $fc)) | .read_at')
|
'first(.items[] | select(.kind == "share_granted" and .payload.resource_id == $fc)) | .read_at')
|
||||||
[[ "$first_read_at" == "null" ]] \
|
[[ "$first_read_at" == "null" ]] \
|
||||||
|| die "S14: matched row unexpectedly marked read: read_at=$first_read_at"
|
|| die "S14: matched row unexpectedly marked read: read_at=$first_read_at"
|
||||||
|
|
||||||
|
# Payload enrichment (Slice E — templated messages): the ingester
|
||||||
|
# snapshots the resource's display name + storage path at grant
|
||||||
|
# time so the FE bell can render "Alice shared 'rt_bus_C_…'" with
|
||||||
|
# a clickable link. If enrichment silently regressed to ID-only,
|
||||||
|
# the FE would still work via generic fallback but lose the
|
||||||
|
# clickable / labelled UX. Assert the enrichment fields landed on
|
||||||
|
# the folder-C row.
|
||||||
|
folder_c_name="rt_bus_C_$suffix"
|
||||||
|
first_name=$(printf '%s' "$notifs" | jq -r --arg fc "$folder_c" \
|
||||||
|
'first(.items[] | select(.kind == "share_granted" and .payload.resource_id == $fc)) | .payload.resource_name')
|
||||||
|
[[ "$first_name" == "$folder_c_name" ]] \
|
||||||
|
|| { printf '%s\n' "$notifs" >&2; die "S14: expected resource_name='$folder_c_name', got '$first_name'"; }
|
||||||
|
first_path=$(printf '%s' "$notifs" | jq -r --arg fc "$folder_c" \
|
||||||
|
'first(.items[] | select(.kind == "share_granted" and .payload.resource_id == $fc)) | .payload.resource_path')
|
||||||
|
# Path must be non-null AND non-empty AND end with the folder's
|
||||||
|
# name — the FE builds `/files${path}` for the bell's anchor, so a
|
||||||
|
# missing/wrong path breaks the click-through.
|
||||||
|
[[ -n "$first_path" && "$first_path" != "null" && "$first_path" == *"$folder_c_name" ]] \
|
||||||
|
|| { printf '%s\n' "$notifs" >&2; die "S14: expected resource_path ending with '$folder_c_name', got '$first_path'"; }
|
||||||
log "S14 OK"
|
log "S14 OK"
|
||||||
|
|
||||||
# ── Scenario 15 — Cross-user notifications identity gate ────────────────────
|
# ── Scenario 15 — Cross-user notifications identity gate ────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user