feat(notification): add persistent notification
This commit is contained in:
@@ -120,6 +120,7 @@ rather than as a visible error.
|
|||||||
| `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). |
|
| `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). |
|
||||||
| `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. |
|
| `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. |
|
||||||
| `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` | `24` | How often the grant-cleanup daemon fires. Clamped to a minimum of 1 hour. Adjusting this doesn't change what gets deleted — only how promptly. Daily is fine for any realistic grant volume. |
|
| `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` | `24` | How often the grant-cleanup daemon fires. Clamped to a minimum of 1 hour. Adjusting this doesn't change what gets deleted — only how promptly. Daily is fine for any realistic grant volume. |
|
||||||
|
| `OXICLOUD_NOTIFICATIONS_RETENTION_DAYS` | `30` | Retention window for **read** notification rows (`notif.notifications`). The `notifications_cleanup` scheduled job runs daily and deletes rows where `read_at IS NOT NULL` and `read_at < now() - retention_days`. 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. Clamped to a minimum of 1 (0 would purge every read row on every tick). Adjust down for compliance-sensitive deployments where "cleared once seen" matters; adjust up when operators expect users to reference old notifications for support. |
|
||||||
| `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive/<uuid\|name>/…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav/<uuid\|name>/…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. |
|
| `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive/<uuid\|name>/…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav/<uuid\|name>/…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. |
|
||||||
|
|
||||||
## Storage Entries (multi-entry, recommended)
|
## Storage Entries (multi-entry, recommended)
|
||||||
|
|||||||
+29
-20
@@ -18,9 +18,9 @@ almost no extra scaffolding.
|
|||||||
|
|
||||||
## Status — 2026-09-11
|
## Status — 2026-09-11
|
||||||
|
|
||||||
The `feat/message-bus` branch delivers **D + F + follow-ups shipped
|
The `feat/message-bus` branch delivers **D + F + Job dashboard live +
|
||||||
end-to-end** on the FE and BE, verified by S1–S11 in the api-test
|
follow-ups shipped end-to-end** on the FE and BE, verified by S1–S12
|
||||||
smoke suite plus manual multi-user E2E. Live today:
|
in the api-test smoke suite plus manual multi-user E2E. Live today:
|
||||||
|
|
||||||
- **Bus core** — `MessageBus` port + `InProcessMessageBus` +
|
- **Bus core** — `MessageBus` port + `InProcessMessageBus` +
|
||||||
`NoopReplicator`. `📤 bus publish` trace under
|
`NoopReplicator`. `📤 bus publish` trace under
|
||||||
@@ -71,21 +71,29 @@ smoke suite plus manual multi-user E2E. Live today:
|
|||||||
in every mutation entry point so `$state` reads don't leak into
|
in every mutation entry point so `$state` reads don't leak into
|
||||||
caller `$effect` deps.
|
caller `$effect` deps.
|
||||||
- **AuthZ tested** — S3 (folder no_read), S4 (nonexistent folder =
|
- **AuthZ tested** — S3 (folder no_read), S4 (nonexistent folder =
|
||||||
anti-enum parity), S9 (cross-user identity topic → `topic_forbidden`).
|
anti-enum parity), S9 (cross-user identity topic → `topic_forbidden`),
|
||||||
|
S12 (`job:*` non-admin denied — Class-3 role-scoped gate).
|
||||||
- **Ticket tested** — S10 (happy path), S11 (single-use replay
|
- **Ticket tested** — S10 (happy path), S11 (single-use replay
|
||||||
rejected).
|
rejected).
|
||||||
|
- **Job dashboard live** — scheduler engine publishes
|
||||||
|
`JobRunStarted` / `JobRunEnded` on `Topic::Job(name)` around every
|
||||||
|
dispatch; `useJobTopic` + `AdminJobsPanel` subscribes to every
|
||||||
|
registered job's topic (keyed on the sorted name set so the 5 s
|
||||||
|
poll doesn't churn subs). State flips within a network hop instead
|
||||||
|
of waiting up to POLL_MS. Progress publishes are deferred (see
|
||||||
|
below); the polling refresh stays as fallback.
|
||||||
|
|
||||||
Active — still under Phase A, ordered by priority:
|
Active — still under Phase A, ordered by priority:
|
||||||
|
|
||||||
- **Job dashboard live** (next) — `JobRegistry` publishes step
|
|
||||||
progress + terminal state on `job:{id}`; the admin jobs view
|
|
||||||
subscribes and drops its polling. Small; same shape as folder-live.
|
|
||||||
Value: an operator who triggers a long-running job (backend
|
|
||||||
migration, thumbnail import, etc.) can navigate to another admin
|
|
||||||
page and come back without losing progress visibility.
|
|
||||||
- **Notifications table + bell** (E) — topic + producer + auto-sub
|
- **Notifications table + bell** (E) — topic + producer + auto-sub
|
||||||
land here. Same pattern as `:authz`. Larger; unblocks Phase-B
|
land here. Same pattern as `:authz`. Larger; unblocks Phase-B
|
||||||
`@mentions`.
|
`@mentions`.
|
||||||
|
- **Job progress publishes** (small follow-up to Job dashboard) —
|
||||||
|
handler-side per-run publisher + 3 s throttle so long jobs
|
||||||
|
(backend_migration, thumb_derived_import…) push `JobRunProgress`
|
||||||
|
events. Wire is already in place (`useJobTopic.onProgress`,
|
||||||
|
`MessageBusEvent::JobRunProgress`); waits for a per-run
|
||||||
|
`ProgressReporter` handle threaded into `JobHandler::run`.
|
||||||
|
|
||||||
Deferred — see the Roadmap section's `## Deferred` block and the
|
Deferred — see the Roadmap section's `## Deferred` block and the
|
||||||
`project_message_bus_reconnect_gap` memory:
|
`project_message_bus_reconnect_gap` memory:
|
||||||
@@ -1242,13 +1250,13 @@ Ships the infrastructure and the two most visible consumers together.
|
|||||||
`useReconnect` composable → folder view refetches after WS comes
|
`useReconnect` composable → folder view refetches after WS comes
|
||||||
back. Bridges the in-memory-bus "events lost during outage" gap
|
back. Bridges the in-memory-bus "events lost during outage" gap
|
||||||
(see `project_message_bus_reconnect_gap` memory).
|
(see `project_message_bus_reconnect_gap` memory).
|
||||||
- **Job dashboard live** — TODO (next slice). `JobRegistry`
|
- **Job dashboard live** — SHIPPED 2026-09-11. Scheduler engine
|
||||||
publishes step progress and terminal state on `job:{id}`; FE
|
publishes `JobRunStarted` + `JobRunEnded` on `Topic::Job(name)`
|
||||||
job dashboard subscribes and replaces polling. Operator value:
|
around every dispatch; `useJobTopic` + `AdminJobsPanel` subscribe
|
||||||
once a long-running job is triggered (backend migration, thumb
|
to every registered job's topic and flip state within a network
|
||||||
import, blobs consistency…), the admin can navigate to another
|
hop. Progress publishes deferred to a follow-up (needs a per-run
|
||||||
page and come back without losing progress visibility — the WS
|
`ProgressReporter` threaded into `JobHandler::run`). The 5 s
|
||||||
push keeps whatever component is subscribed up-to-date.
|
poll stays as fallback.
|
||||||
- **Notifications table + bell** — TODO (Slice E). New
|
- **Notifications table + bell** — TODO (Slice E). New
|
||||||
`notifications` table + `NotificationService` port; initial
|
`notifications` table + `NotificationService` port; initial
|
||||||
ingesters for `share-granted`, `new-login-from-new-device`,
|
ingesters for `share-granted`, `new-login-from-new-device`,
|
||||||
@@ -1261,9 +1269,10 @@ Ships the infrastructure and the two most visible consumers together.
|
|||||||
which this plan sketches but doesn't ship (`rt_ws.rs` today drops
|
which this plan sketches but doesn't ship (`rt_ws.rs` today drops
|
||||||
binary frames with a debug log).
|
binary frames with a debug log).
|
||||||
|
|
||||||
Deliverables sized ~4 weeks end-to-end. Slice D (folder-live) and
|
Deliverables sized ~4 weeks end-to-end. Slice D (folder-live),
|
||||||
Slice F (ticket flow) landed 2026-09-11. Slices E + collab are the
|
Slice F (ticket flow), and Job dashboard live all landed 2026-09-11.
|
||||||
open work in Phase A.
|
Slice E (notifications bell) + collab are the remaining open work
|
||||||
|
in Phase A.
|
||||||
|
|
||||||
### Deferred — everything below is on the shelf
|
### Deferred — everything below is on the shelf
|
||||||
|
|
||||||
|
|||||||
+11
@@ -406,6 +406,17 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
|
|||||||
#OXICLOUD_GRANT_CLEANUP_GRACE_DAYS=15
|
#OXICLOUD_GRANT_CLEANUP_GRACE_DAYS=15
|
||||||
#OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS=24
|
#OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS=24
|
||||||
|
|
||||||
|
# ─── Persistent notifications (bell) ─────────────────────────────
|
||||||
|
# Retention window (in days) for READ notifications in the bell.
|
||||||
|
# Unread rows are preserved unconditionally — that's the point of
|
||||||
|
# the durable table: a user offline for a month still sees the
|
||||||
|
# share-granted notice on next login. The `notifications_cleanup`
|
||||||
|
# scheduled job runs daily and deletes read rows older than this.
|
||||||
|
#
|
||||||
|
# Minimum 1 day (0 would purge every read row on every tick — the
|
||||||
|
# service clamps defensively). Default: 30.
|
||||||
|
#OXICLOUD_NOTIFICATIONS_RETENTION_DAYS=30
|
||||||
|
|
||||||
# Enable search functionality (default: true)
|
# Enable search functionality (default: true)
|
||||||
#OXICLOUD_ENABLE_SEARCH=true
|
#OXICLOUD_ENABLE_SEARCH=true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Persistent notifications (bell) — REST client.
|
||||||
|
*
|
||||||
|
* Backs `useNotifications` (composable) and `NotificationBell`
|
||||||
|
* (component). The bell reads from these; the message bus is a
|
||||||
|
* cache-invalidation hint that triggers a refetch, not a data path.
|
||||||
|
* See `docs/plan/message-bus.md § Slice E` for the pattern.
|
||||||
|
*/
|
||||||
|
import { apiJson } from '$lib/api/client';
|
||||||
|
import { apiFetch } from '$lib/api/client';
|
||||||
|
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||||
|
import type {
|
||||||
|
MarkAllReadResponse,
|
||||||
|
Notification,
|
||||||
|
NotificationListResponse,
|
||||||
|
UnreadCountResponse
|
||||||
|
} from '$lib/api/types';
|
||||||
|
|
||||||
|
/** List newest-first. Optional `unread` filter, `before` cursor, `limit` cap. */
|
||||||
|
export async function listNotifications(opts?: {
|
||||||
|
unread?: boolean;
|
||||||
|
before?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<NotificationListResponse> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (opts?.unread) q.set('unread', 'true');
|
||||||
|
if (opts?.before) q.set('before', opts.before);
|
||||||
|
if (opts?.limit !== undefined) q.set('limit', String(opts.limit));
|
||||||
|
const suffix = q.toString();
|
||||||
|
return apiJson<NotificationListResponse>(`/api/notifications${suffix ? `?${suffix}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Badge-only fast path — no payloads fetched. */
|
||||||
|
export async function getUnreadCount(): Promise<number> {
|
||||||
|
const res = await apiJson<UnreadCountResponse>('/api/notifications/unread');
|
||||||
|
return res.unread_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark one notification as read. Always resolves — the server responds
|
||||||
|
* 204 regardless of whether the row existed or belonged to the caller
|
||||||
|
* (anti-enumeration). Duplicated calls are safe.
|
||||||
|
*/
|
||||||
|
export async function markNotificationRead(id: string): Promise<void> {
|
||||||
|
const res = await apiFetch(`/api/notifications/${encodeURIComponent(id)}/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getCsrfHeaders()
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
throw new Error(`markNotificationRead failed: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk mark-all-read. Returns the number of rows the server flipped. */
|
||||||
|
export async function markAllNotificationsRead(): Promise<number> {
|
||||||
|
const res = await apiJson<MarkAllReadResponse>('/api/notifications/read-all', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getCsrfHeaders()
|
||||||
|
});
|
||||||
|
return res.marked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard-delete one row. Same anti-enum shape as mark-read. */
|
||||||
|
export async function deleteNotification(id: string): Promise<void> {
|
||||||
|
const res = await apiFetch(`/api/notifications/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getCsrfHeaders()
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
throw new Error(`deleteNotification failed: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: expose the row shape for consumers that don't want
|
||||||
|
// to import from `$lib/api/types` too.
|
||||||
|
export type { Notification };
|
||||||
@@ -934,3 +934,40 @@ export interface ServerConfig {
|
|||||||
features: ServerFeatures;
|
features: ServerFeatures;
|
||||||
server_status: ServerStatus;
|
server_status: ServerStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Notifications (Slice E) ─────────────────────────────────────
|
||||||
|
// Row shape mirrors `application/dtos` output of the Rust backend.
|
||||||
|
// `payload` stays a raw JSON object (`Record<string, unknown>`) —
|
||||||
|
// per-kind decoding is a UI concern (kind-specific components read
|
||||||
|
// what they need from the blob). Adding a new kind server-side does
|
||||||
|
// NOT churn this file; the FE renders a generic bell row for any
|
||||||
|
// unknown kind.
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
/** `null` = unread. */
|
||||||
|
read_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationListResponse {
|
||||||
|
items: Notification[];
|
||||||
|
unread_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnreadCountResponse {
|
||||||
|
unread_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkAllReadResponse {
|
||||||
|
marked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical kind slugs — mirror `domain::entities::notification::kind`. */
|
||||||
|
export const NOTIFICATION_KIND = {
|
||||||
|
SHARE_GRANTED: 'share_granted',
|
||||||
|
NEW_LOGIN_FROM_NEW_DEVICE: 'new_login_from_new_device',
|
||||||
|
JOB_COMPLETED_FOR_YOU: 'job_completed_for_you',
|
||||||
|
STORAGE_QUOTA_THRESHOLD: 'storage_quota_threshold'
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
import { session } from '$lib/stores/session.svelte';
|
import { session } from '$lib/stores/session.svelte';
|
||||||
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
||||||
import { ui } from '$lib/stores/ui.svelte';
|
import { ui } from '$lib/stores/ui.svelte';
|
||||||
|
import {
|
||||||
|
notifications as persistentNotifications,
|
||||||
|
useNotifications
|
||||||
|
} from '$lib/composables/useNotifications.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';
|
||||||
|
|
||||||
@@ -327,6 +331,82 @@
|
|||||||
setTimeout(() => (bellRinging = false), 900);
|
setTimeout(() => (bellRinging = false), 900);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Persistent notifications (Slice E) — server-backed rows,
|
||||||
|
// survive reload, delivered via `user:{me}:notifications` bus
|
||||||
|
// topic + refetched from `GET /api/notifications`. Fires the
|
||||||
|
// initial hydrate + subscribes to the topic. Independent of the
|
||||||
|
// transient toast bell above (`ui.notifications`) — that stays
|
||||||
|
// as-is for upload-progress / one-shot messages; this stream
|
||||||
|
// carries `share_granted` and friends.
|
||||||
|
useNotifications();
|
||||||
|
|
||||||
|
// Merged unread count for the bell badge — transient toasts plus
|
||||||
|
// persistent unread rows. Same wire and same UX affordance so a
|
||||||
|
// user sees one number and one bell for both classes.
|
||||||
|
const totalUnread = $derived(ui.unread + persistentNotifications.unread);
|
||||||
|
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());
|
||||||
@@ -802,11 +882,19 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
notifOpen = !notifOpen;
|
notifOpen = !notifOpen;
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
if (notifOpen) ui.markNotificationsRead();
|
if (notifOpen) {
|
||||||
|
ui.markNotificationsRead();
|
||||||
|
// Persistent rows stay unread until the user
|
||||||
|
// explicitly clicks one — opening the panel
|
||||||
|
// doesn't mark them read (unlike the transient
|
||||||
|
// toast bell, which resets on view). Keeps the
|
||||||
|
// bell's badge accurate to "still-relevant
|
||||||
|
// server-side rows" without a bulk mark-read.
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="bell" />
|
<Icon name="bell" />
|
||||||
{#if ui.unread > 0}<span class="notif-badge">{ui.unreadBadge}</span>{/if}
|
{#if totalUnread > 0}<span class="notif-badge">{totalUnreadBadge}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
<div class="notif-panel">
|
<div class="notif-panel">
|
||||||
<div class="notif-panel-header">
|
<div class="notif-panel-header">
|
||||||
@@ -827,7 +915,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-panel-body">
|
<div class="notif-panel-body">
|
||||||
{#if ui.notifications.length === 0}
|
{#if ui.notifications.length === 0 && persistentNotifications.items.length === 0}
|
||||||
<div class="notif-empty">
|
<div class="notif-empty">
|
||||||
<Icon name="bell-slash" />
|
<Icon name="bell-slash" />
|
||||||
<span>{t('notifications.empty', 'No notifications')}</span>
|
<span>{t('notifications.empty', 'No notifications')}</span>
|
||||||
@@ -869,6 +957,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if persistentNotifications.items.length > 0}
|
||||||
|
{#if ui.notifications.length > 0}
|
||||||
|
<div
|
||||||
|
class="notif-section-divider"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#each persistentNotifications.items as row (row.id)}
|
||||||
|
<div
|
||||||
|
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}
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1295,6 +1420,14 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Divider between transient toasts and persistent (server-backed)
|
||||||
|
rows. Slice E adds a section under the toast list; the divider
|
||||||
|
is only rendered when both sections have content. */
|
||||||
|
.notif-section-divider {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Bell "ring" animation, replayed when bellRinging toggles on. */
|
/* Bell "ring" animation, replayed when bellRinging toggles on. */
|
||||||
.notif-bell-btn.ring :global(svg),
|
.notif-bell-btn.ring :global(svg),
|
||||||
.notif-bell-btn.ring :global(i) {
|
.notif-bell-btn.ring :global(i) {
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Global bell store — persistent notifications (Slice E).
|
||||||
|
*
|
||||||
|
* Owns the reactive state for `NotificationBell`. Module-scoped so
|
||||||
|
* one instance drives every consumer in the SPA (badge in AppShell,
|
||||||
|
* slide-out panel, admin dashboard hooks, …). Same lifetime as
|
||||||
|
* `messageBus`: survives every intra-SPA navigation, dies only on
|
||||||
|
* full reload / tab close.
|
||||||
|
*
|
||||||
|
* Message-bus contract: the FE subscribes to `user:{me}:notifications`
|
||||||
|
* (auto-subscribed server-side on WS session open — no `rt.subscribe`
|
||||||
|
* frame needed from the client) and refetches the row list whenever
|
||||||
|
* a `notification_received` event arrives. The DB is truth; the bus
|
||||||
|
* event just says "there's new data, refresh".
|
||||||
|
*/
|
||||||
|
import { messageBus } from '$lib/message-bus/client.svelte';
|
||||||
|
import { session } from '$lib/stores/session.svelte';
|
||||||
|
import { serverConfig } from '$lib/stores/serverConfig.svelte';
|
||||||
|
import {
|
||||||
|
deleteNotification as apiDelete,
|
||||||
|
getUnreadCount,
|
||||||
|
listNotifications,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
markNotificationRead
|
||||||
|
} from '$lib/api/endpoints/notifications';
|
||||||
|
import type { Notification } from '$lib/api/types';
|
||||||
|
import log from 'loglevel';
|
||||||
|
|
||||||
|
const bellLog = log.getLogger('oxi:notifications');
|
||||||
|
|
||||||
|
class NotificationsStore {
|
||||||
|
#items = $state<Notification[]>([]);
|
||||||
|
#unread = $state<number>(0);
|
||||||
|
#loading = $state<boolean>(false);
|
||||||
|
#error = $state<string | null>(null);
|
||||||
|
|
||||||
|
get items(): Notification[] {
|
||||||
|
return this.#items;
|
||||||
|
}
|
||||||
|
get unread(): number {
|
||||||
|
return this.#unread;
|
||||||
|
}
|
||||||
|
get loading(): boolean {
|
||||||
|
return this.#loading;
|
||||||
|
}
|
||||||
|
get error(): string | null {
|
||||||
|
return this.#error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the newest page + refresh the badge count. Idempotent —
|
||||||
|
* safe to call on every bus push, on mount, on visibility return.
|
||||||
|
*/
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
this.#loading = true;
|
||||||
|
try {
|
||||||
|
const res = await listNotifications({ limit: 50 });
|
||||||
|
this.#items = res.items;
|
||||||
|
this.#unread = res.unread_count;
|
||||||
|
this.#error = null;
|
||||||
|
} catch (e) {
|
||||||
|
this.#error = e instanceof Error ? e.message : String(e);
|
||||||
|
bellLog.warn('notifications refresh failed', e);
|
||||||
|
} finally {
|
||||||
|
this.#loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Badge-only fast path — avoids fetching payloads. */
|
||||||
|
async refreshBadge(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.#unread = await getUnreadCount();
|
||||||
|
} catch (e) {
|
||||||
|
bellLog.warn('badge refresh failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(id: string): Promise<void> {
|
||||||
|
// Optimistic update — flip locally, then confirm on the wire.
|
||||||
|
// Same pattern the folder-view uses on rename: reactive-first,
|
||||||
|
// server-eventually. A wire failure re-fetches from truth.
|
||||||
|
const row = this.#items.find((n) => n.id === id);
|
||||||
|
if (row && row.read_at === null) {
|
||||||
|
row.read_at = new Date().toISOString();
|
||||||
|
this.#unread = Math.max(0, this.#unread - 1);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await markNotificationRead(id);
|
||||||
|
} catch (e) {
|
||||||
|
bellLog.warn('markRead failed; reconciling', e);
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllRead(): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const row of this.#items) {
|
||||||
|
if (row.read_at === null) row.read_at = now;
|
||||||
|
}
|
||||||
|
this.#unread = 0;
|
||||||
|
try {
|
||||||
|
await markAllNotificationsRead();
|
||||||
|
} catch (e) {
|
||||||
|
bellLog.warn('markAllRead failed; reconciling', e);
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const idx = this.#items.findIndex((n) => n.id === id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const [removed] = this.#items.splice(idx, 1);
|
||||||
|
if (removed && removed.read_at === null) {
|
||||||
|
this.#unread = Math.max(0, this.#unread - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiDelete(id);
|
||||||
|
} catch (e) {
|
||||||
|
bellLog.warn('delete failed; reconciling', e);
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset — called on logout so a switch-user doesn't inherit the
|
||||||
|
* previous session's rows. */
|
||||||
|
reset(): void {
|
||||||
|
this.#items = [];
|
||||||
|
this.#unread = 0;
|
||||||
|
this.#error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Module-scoped singleton — one bell state per SPA lifetime. */
|
||||||
|
export const notifications = new NotificationsStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire the bell into a component's lifecycle. Fires an initial fetch
|
||||||
|
* on mount, subscribes to `user:{me}:notifications` for live pushes,
|
||||||
|
* refetches on reconnect (bus events lost during outage window).
|
||||||
|
*
|
||||||
|
* Call once from the app root (`+layout.svelte`) — this store is
|
||||||
|
* global. Additional callers do NOT need to re-mount; they can just
|
||||||
|
* read `notifications.items` / `notifications.unread`.
|
||||||
|
*/
|
||||||
|
export function useNotifications(): void {
|
||||||
|
$effect(() => {
|
||||||
|
const userId = session.user?.id;
|
||||||
|
if (!userId) return; // not logged in — nothing to fetch
|
||||||
|
// Initial hydrate from DB truth. Runs whether or not the bus
|
||||||
|
// is enabled — the bell has to work in "polling only" mode
|
||||||
|
// when OXICLOUD_MESSAGEBUS_ENABLE=false too.
|
||||||
|
void notifications.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!serverConfig.features.message_bus) return;
|
||||||
|
const userId = session.user?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
// The topic is auto-subscribed server-side on WS session open
|
||||||
|
// (same pattern as `:authz`); this call refcounts up to the
|
||||||
|
// existing sub, doesn't fire a second `rt.subscribe` frame.
|
||||||
|
const release = messageBus.subscribe(
|
||||||
|
`user:${userId}:notifications`,
|
||||||
|
(params) => {
|
||||||
|
if (params.event === 'notification_received') {
|
||||||
|
// Bus event carries only the poke. Refetch the
|
||||||
|
// list — cheap, gives us the new row with its
|
||||||
|
// full payload from truth.
|
||||||
|
void notifications.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Server-evicted (session flipped) — clear local so
|
||||||
|
// the badge stops showing stale count.
|
||||||
|
notifications.reset();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const releaseReconnect = messageBus.onReconnect(() => {
|
||||||
|
// A push we missed during the outage window is only
|
||||||
|
// recoverable by rereading the DB.
|
||||||
|
void notifications.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
release();
|
||||||
|
releaseReconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// AUTO-GENERATED — do not edit by hand.
|
||||||
|
// Regenerate with `just asyncapi-ts`.
|
||||||
|
interface NotificationReceivedData {
|
||||||
|
created_at: string;
|
||||||
|
kind: string;
|
||||||
|
notification_id: string;
|
||||||
|
}
|
||||||
|
export type { NotificationReceivedData as default };
|
||||||
@@ -7,6 +7,7 @@ enum RtEventKind {
|
|||||||
FOLDER_RENAMED = 'folder_renamed',
|
FOLDER_RENAMED = 'folder_renamed',
|
||||||
FOLDER_MOVED = 'folder_moved',
|
FOLDER_MOVED = 'folder_moved',
|
||||||
FOLDER_DELETED = 'folder_deleted',
|
FOLDER_DELETED = 'folder_deleted',
|
||||||
|
NOTIFICATION_RECEIVED = 'notification_received',
|
||||||
JOB_RUN_STARTED = 'job_run_started',
|
JOB_RUN_STARTED = 'job_run_started',
|
||||||
JOB_RUN_PROGRESS = 'job_run_progress',
|
JOB_RUN_PROGRESS = 'job_run_progress',
|
||||||
JOB_RUN_ENDED = 'job_run_ended'
|
JOB_RUN_ENDED = 'job_run_ended'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type FolderCreatedData from './FolderCreatedData';
|
|||||||
import type FolderRenamedData from './FolderRenamedData';
|
import type FolderRenamedData from './FolderRenamedData';
|
||||||
import type FolderMovedData from './FolderMovedData';
|
import type FolderMovedData from './FolderMovedData';
|
||||||
import type FolderDeletedData from './FolderDeletedData';
|
import type FolderDeletedData from './FolderDeletedData';
|
||||||
|
import type NotificationReceivedData from './NotificationReceivedData';
|
||||||
import type JobRunStartedData from './JobRunStartedData';
|
import type JobRunStartedData from './JobRunStartedData';
|
||||||
import type JobRunProgressData from './JobRunProgressData';
|
import type JobRunProgressData from './JobRunProgressData';
|
||||||
import type JobRunEndedData from './JobRunEndedData';
|
import type JobRunEndedData from './JobRunEndedData';
|
||||||
@@ -22,6 +23,7 @@ interface RtEventParams {
|
|||||||
| FolderRenamedData
|
| FolderRenamedData
|
||||||
| FolderMovedData
|
| FolderMovedData
|
||||||
| FolderDeletedData
|
| FolderDeletedData
|
||||||
|
| NotificationReceivedData
|
||||||
| JobRunStartedData
|
| JobRunStartedData
|
||||||
| JobRunProgressData
|
| JobRunProgressData
|
||||||
| JobRunEndedData;
|
| JobRunEndedData;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- notif.notifications — durable per-user notification records.
|
||||||
|
--
|
||||||
|
-- Backs the bell UI and the retention job. The message bus is best-effort
|
||||||
|
-- (a subscriber offline at publish time misses the push); this table is
|
||||||
|
-- the truth. Every `NotificationService::create` writes a row AND
|
||||||
|
-- publishes a `NotificationReceived` event on `user:{user_id}:notifications`.
|
||||||
|
-- A missed bus event recovers on the next `GET /api/notifications`.
|
||||||
|
--
|
||||||
|
-- See `docs/plan/message-bus.md § Slice E` for the wire contract and
|
||||||
|
-- retention policy.
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS notif;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notif.notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Recipient. Every row is scoped to exactly one user; a share fanned
|
||||||
|
-- to N members is N rows. Fanout truncation for very-large groups
|
||||||
|
-- happens in the ingester (see plan § Notification fanout truncated),
|
||||||
|
-- not here.
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Notification kind — a stable slug the FE routes on for icon/label/
|
||||||
|
-- action-button choice. New kinds are additive; never repurpose an
|
||||||
|
-- existing one. Initial kinds:
|
||||||
|
-- share_granted, new_login_from_new_device,
|
||||||
|
-- job_completed_for_you, storage_quota_threshold
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Per-kind opaque JSON with the fields the FE needs to render the
|
||||||
|
-- row without a follow-up API call (subject name, resource id,
|
||||||
|
-- action link…). Shape is a per-kind contract owned by the ingester;
|
||||||
|
-- the DB stays schema-free here so a new field doesn't require a
|
||||||
|
-- migration.
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Wall-clock creation stamp. Sort key for the bell. Server-clock,
|
||||||
|
-- not caller-clock — this is a DB-generated fact.
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- NULL = unread; non-NULL = when the user explicitly marked it
|
||||||
|
-- read. Retention job deletes rows where read_at IS NOT NULL AND
|
||||||
|
-- read_at < now() - retention_days.
|
||||||
|
read_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Bell fetch — GET /api/notifications lists a user's rows newest-first,
|
||||||
|
-- typically capped at ~50, sometimes filtered on unread. This one index
|
||||||
|
-- covers both the list query and the mark-all-read filter, and the
|
||||||
|
-- INCLUDE clause keeps common bell renders (id, kind, created_at,
|
||||||
|
-- read_at) index-only.
|
||||||
|
CREATE INDEX IF NOT EXISTS notifications_user_created_read
|
||||||
|
ON notif.notifications (user_id, created_at DESC)
|
||||||
|
INCLUDE (read_at, kind);
|
||||||
|
|
||||||
|
-- Retention job DELETE — scans read-and-old rows only. Partial keeps
|
||||||
|
-- the index tiny in the typical steady state where most rows are
|
||||||
|
-- unread.
|
||||||
|
CREATE INDEX IF NOT EXISTS notifications_read_at
|
||||||
|
ON notif.notifications (read_at)
|
||||||
|
WHERE read_at IS NOT NULL;
|
||||||
@@ -64,6 +64,16 @@ pub enum Topic {
|
|||||||
/// the eviction wiring lands (Phase-A follow-up).
|
/// the eviction wiring lands (Phase-A follow-up).
|
||||||
UserAuthz(Uuid),
|
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 /
|
/// A named background job's run lifecycle — start / progress /
|
||||||
/// end. Consumed by the admin job dashboard so operators who
|
/// end. Consumed by the admin job dashboard so operators who
|
||||||
/// trigger a long-running job (backend migration, thumb import…)
|
/// trigger a long-running job (backend migration, thumb import…)
|
||||||
@@ -83,6 +93,7 @@ impl Topic {
|
|||||||
match self {
|
match self {
|
||||||
Topic::Folder(id) => format!("folder:{id}"),
|
Topic::Folder(id) => format!("folder:{id}"),
|
||||||
Topic::UserAuthz(id) => format!("user:{id}:authz"),
|
Topic::UserAuthz(id) => format!("user:{id}:authz"),
|
||||||
|
Topic::UserNotifications(id) => format!("user:{id}:notifications"),
|
||||||
Topic::Job(name) => format!("job:{name}"),
|
Topic::Job(name) => format!("job:{name}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,10 +108,14 @@ impl Topic {
|
|||||||
return Ok(Topic::Folder(id));
|
return Ok(Topic::Folder(id));
|
||||||
}
|
}
|
||||||
if let Some(rest) = s.strip_prefix("user:")
|
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)?;
|
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:") {
|
if let Some(name) = s.strip_prefix("job:") {
|
||||||
// Job names are scheduler-registered short slugs — see
|
// Job names are scheduler-registered short slugs — see
|
||||||
@@ -135,6 +150,7 @@ impl Topic {
|
|||||||
resource: BusResource::Folder(*id),
|
resource: BusResource::Folder(*id),
|
||||||
},
|
},
|
||||||
Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id },
|
Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id },
|
||||||
|
Topic::UserNotifications(id) => AuthzCheck::IdentityMatch { user_id: *id },
|
||||||
Topic::Job(_) => AuthzCheck::RoleAdmin,
|
Topic::Job(_) => AuthzCheck::RoleAdmin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,6 +307,25 @@ pub enum MessageBusEvent {
|
|||||||
/// plan's Phase-B roadmap.
|
/// plan's Phase-B roadmap.
|
||||||
AuthzChanged { affected_folders: Vec<Uuid> },
|
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
|
/// 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
|
||||||
/// serialised by serde). Admin dashboard's job-list view uses
|
/// serialised by serde). Admin dashboard's job-list view uses
|
||||||
@@ -497,6 +532,15 @@ mod tests {
|
|||||||
assert_eq!(Topic::parse(&wire).unwrap(), t);
|
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]
|
#[test]
|
||||||
fn job_topic_roundtrip() {
|
fn job_topic_roundtrip() {
|
||||||
let t = Topic::Job("backend_migration".to_string());
|
let t = Topic::Job("backend_migration".to_string());
|
||||||
@@ -539,7 +583,12 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
Topic::parse(&format!("user:{}", Uuid::new_v4())),
|
Topic::parse(&format!("user:{}", Uuid::new_v4())),
|
||||||
Err(ParseTopicErr::Unknown),
|
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]
|
#[test]
|
||||||
fn event_serializes_with_snake_case_discriminator() {
|
fn event_serializes_with_snake_case_discriminator() {
|
||||||
// The `#[serde(tag = "event")]` shape is the WS wire contract for
|
// The `#[serde(tag = "event")]` shape is the WS wire contract for
|
||||||
@@ -651,6 +712,14 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"authz_changed",
|
"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 {
|
MessageBusEvent::JobRunStarted {
|
||||||
name: "backend_migration".into(),
|
name: "backend_migration".into(),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub mod mount_registry;
|
|||||||
pub mod music_service;
|
pub mod music_service;
|
||||||
pub mod nextcloud_file_id_service;
|
pub mod nextcloud_file_id_service;
|
||||||
pub mod nextcloud_login_flow_service;
|
pub mod nextcloud_login_flow_service;
|
||||||
|
pub mod notification_application_service;
|
||||||
pub mod people_service;
|
pub mod people_service;
|
||||||
pub mod places_service;
|
pub mod places_service;
|
||||||
pub mod recent_service;
|
pub mod recent_service;
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! Orchestrates persistent notifications.
|
||||||
|
//!
|
||||||
|
//! `create()` is the single ingester entry point:
|
||||||
|
//!
|
||||||
|
//! 1. Insert the row via [`NotificationRepository::create`].
|
||||||
|
//! 2. Publish a thin `NotificationReceived` event on
|
||||||
|
//! `user:{user_id}:notifications` so subscribed sessions refetch
|
||||||
|
//! immediately.
|
||||||
|
//!
|
||||||
|
//! The DB row is the truth (see `docs/plan/message-bus.md § Slice E`).
|
||||||
|
//! The bus is best-effort — a subscriber offline at publish time
|
||||||
|
//! recovers on next `GET /api/notifications`. Publish happens AFTER
|
||||||
|
//! the DB write succeeds, never inside a transaction — the plan's
|
||||||
|
//! "publish after commit" invariant.
|
||||||
|
//!
|
||||||
|
//! Reads (`list_for_user`, `count_unread_for_user`) and state changes
|
||||||
|
//! (`mark_read`, `mark_all_read`, `delete`) back the REST endpoints in
|
||||||
|
//! `interfaces/api/handlers/notifications.rs`. Every mutating method
|
||||||
|
//! is scoped on `user_id` at the SQL layer; the service does not run
|
||||||
|
//! its own AuthZ check because the identity is by construction
|
||||||
|
//! (`caller_id == user_id`, extracted from the auth middleware).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::ports::message_bus_ports::{MessageBus, MessageBusEvent, Topic};
|
||||||
|
use crate::common::errors::DomainError;
|
||||||
|
use crate::domain::entities::notification::{NewNotification, Notification};
|
||||||
|
use crate::domain::repositories::notification_repository::{
|
||||||
|
NotificationListFilter, NotificationRepository,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct NotificationApplicationService {
|
||||||
|
repo: Arc<dyn NotificationRepository>,
|
||||||
|
bus: Arc<dyn MessageBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationApplicationService {
|
||||||
|
pub fn new(repo: Arc<dyn NotificationRepository>, bus: Arc<dyn MessageBus>) -> Self {
|
||||||
|
Self { repo, bus }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a row for `new_notif` and publish a thin bus event.
|
||||||
|
/// Returns the persisted row. This is the ingester-facing method
|
||||||
|
/// — called from `ShareService::create_grant`,
|
||||||
|
/// `AuthApplicationService` (new-device login),
|
||||||
|
/// `SchedulerEngine` (job completed for actor), and the quota
|
||||||
|
/// threshold hook.
|
||||||
|
pub async fn create(&self, new_notif: NewNotification) -> Result<Notification, DomainError> {
|
||||||
|
let row = self.repo.create(&new_notif).await?;
|
||||||
|
|
||||||
|
// Publish AFTER the row is durable. Silent no-op if the bus
|
||||||
|
// is disabled at boot (`OXICLOUD_MESSAGEBUS_ENABLE=false`) —
|
||||||
|
// the WS route is unmounted so the publish just hits a dead
|
||||||
|
// sender. The FE bell still works: it reads from the DB on
|
||||||
|
// mount. See plan § "Slice E".
|
||||||
|
self.bus.publish(
|
||||||
|
&Topic::UserNotifications(row.user_id),
|
||||||
|
MessageBusEvent::NotificationReceived {
|
||||||
|
notification_id: row.id,
|
||||||
|
kind: row.kind.clone(),
|
||||||
|
created_at: row.created_at,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List notifications for `user_id` newest-first. Default limit at
|
||||||
|
/// this layer is 50 rows (the repo caps at 500 defensively).
|
||||||
|
pub async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
filter: NotificationListFilter,
|
||||||
|
) -> Result<Vec<Notification>, DomainError> {
|
||||||
|
self.repo.list_for_user(user_id, &filter).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unread badge count.
|
||||||
|
pub async fn count_unread_for_user(&self, user_id: Uuid) -> Result<i64, DomainError> {
|
||||||
|
self.repo.count_unread_for_user(user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark one notification as read. Returns `true` if the row
|
||||||
|
/// transitioned unread → read (i.e. was owned by `caller_id` and
|
||||||
|
/// was previously unread). Returns `false` for already-read,
|
||||||
|
/// missing, or misowned rows — indistinguishable at the wire so
|
||||||
|
/// enumeration doesn't leak.
|
||||||
|
pub async fn mark_read(
|
||||||
|
&self,
|
||||||
|
notification_id: Uuid,
|
||||||
|
caller_id: Uuid,
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
|
self.repo
|
||||||
|
.mark_read(notification_id, caller_id, Utc::now())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bulk mark-all-read. Returns rows updated.
|
||||||
|
pub async fn mark_all_read(&self, caller_id: Uuid) -> Result<u64, DomainError> {
|
||||||
|
self.repo
|
||||||
|
.mark_all_read_for_user(caller_id, Utc::now())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard-delete one row. Same anti-enumeration semantics as
|
||||||
|
/// [`mark_read`] — returns `false` for missing / misowned.
|
||||||
|
pub async fn delete(
|
||||||
|
&self,
|
||||||
|
notification_id: Uuid,
|
||||||
|
caller_id: Uuid,
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
|
self.repo.delete_by_id(notification_id, caller_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retention job entry point. Called by `notifications_cleanup`
|
||||||
|
/// on its daily cadence — deletes read rows older than `cutoff`.
|
||||||
|
/// Unread rows are always preserved.
|
||||||
|
pub async fn purge_read_before_cutoff(
|
||||||
|
&self,
|
||||||
|
cutoff: chrono::DateTime<Utc>,
|
||||||
|
) -> Result<u64, DomainError> {
|
||||||
|
self.repo.purge_read_before(cutoff).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,17 @@ fn channels() -> Value {
|
|||||||
"UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" },
|
"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": {
|
"Job": {
|
||||||
"address": "job:{jobName}",
|
"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.",
|
"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(),
|
"FolderRenamedData": folder_renamed_schema(),
|
||||||
"FolderMovedData": folder_moved_schema(),
|
"FolderMovedData": folder_moved_schema(),
|
||||||
"FolderDeletedData": folder_deleted_schema(),
|
"FolderDeletedData": folder_deleted_schema(),
|
||||||
|
"NotificationReceivedData": notification_received_schema(),
|
||||||
"JobRunStartedData": job_run_started_schema(),
|
"JobRunStartedData": job_run_started_schema(),
|
||||||
"JobRunProgressData": job_run_progress_schema(),
|
"JobRunProgressData": job_run_progress_schema(),
|
||||||
"JobRunEndedData": job_run_ended_schema(),
|
"JobRunEndedData": job_run_ended_schema(),
|
||||||
@@ -598,6 +610,7 @@ fn event_kind_schema() -> Value {
|
|||||||
"enum": [
|
"enum": [
|
||||||
"file_created", "file_renamed", "file_moved", "file_deleted",
|
"file_created", "file_renamed", "file_moved", "file_deleted",
|
||||||
"folder_created", "folder_renamed", "folder_moved", "folder_deleted",
|
"folder_created", "folder_renamed", "folder_moved", "folder_deleted",
|
||||||
|
"notification_received",
|
||||||
"job_run_started", "job_run_progress", "job_run_ended",
|
"job_run_started", "job_run_progress", "job_run_ended",
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -615,6 +628,7 @@ fn event_data_union_schema() -> Value {
|
|||||||
ref_schema("FolderRenamedData"),
|
ref_schema("FolderRenamedData"),
|
||||||
ref_schema("FolderMovedData"),
|
ref_schema("FolderMovedData"),
|
||||||
ref_schema("FolderDeletedData"),
|
ref_schema("FolderDeletedData"),
|
||||||
|
ref_schema("NotificationReceivedData"),
|
||||||
ref_schema("JobRunStartedData"),
|
ref_schema("JobRunStartedData"),
|
||||||
ref_schema("JobRunProgressData"),
|
ref_schema("JobRunProgressData"),
|
||||||
ref_schema("JobRunEndedData"),
|
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 ─────────────────────
|
// ─────────────────── Job event data payloads ─────────────────────
|
||||||
// Published on `Topic::Job(name)`. AuthZ is Class-3 (admin-only) —
|
// Published on `Topic::Job(name)`. AuthZ is Class-3 (admin-only) —
|
||||||
// non-admins get `topic_forbidden` on subscribe, so these payloads
|
// non-admins get `topic_forbidden` on subscribe, so these payloads
|
||||||
|
|||||||
@@ -2311,6 +2311,17 @@ pub struct FeaturesConfig {
|
|||||||
/// Enabled by default: expired-auth-row cleanup is a
|
/// Enabled by default: expired-auth-row cleanup is a
|
||||||
/// security-hygiene default, not opt-in.
|
/// security-hygiene default, not opt-in.
|
||||||
pub grant_cleanup: GrantCleanupConfig,
|
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
|
/// Config for the daily expired-grant purge (see
|
||||||
@@ -2498,6 +2509,7 @@ impl Default for FeaturesConfig {
|
|||||||
webdav_drive_listing_prefix: "@drive".to_string(),
|
webdav_drive_listing_prefix: "@drive".to_string(),
|
||||||
enable_message_bus: true, // Message bus (WS + ticket) on by default
|
enable_message_bus: true, // Message bus (WS + ticket) on by default
|
||||||
grant_cleanup: GrantCleanupConfig::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;
|
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>())
|
if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH").map(|v| v.parse::<bool>())
|
||||||
&& let Ok(val) = enable_search
|
&& let Ok(val) = enable_search
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2418,6 +2418,7 @@ impl AppServiceFactory {
|
|||||||
mock_email_sender: None, // populated below
|
mock_email_sender: None, // populated below
|
||||||
magic_link_invite_service: None, // populated below
|
magic_link_invite_service: None, // populated below
|
||||||
recipient_notification_service: None, // populated below alongside magic_link_invite_service
|
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
|
// Per-caller limits, configurable since the hardcoded ceilings
|
||||||
// had no escape hatch for deployments where several actors share
|
// had no escape hatch for deployments where several actors share
|
||||||
// one identity — a CI suite running as a single `admin` shares
|
// 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
|
// 9b. Wire admin settings service when auth is available
|
||||||
@@ -3441,6 +3478,16 @@ pub struct AppState {
|
|||||||
pub recipient_notification_service: Option<
|
pub recipient_notification_service: Option<
|
||||||
Arc<crate::application::services::recipient_notification_service::RecipientNotificationService>,
|
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
|
/// Per-caller sliding-window limiter for `GET /api/users/{id}`. The
|
||||||
/// endpoint's primary defense is the visibility check, but a stale
|
/// endpoint's primary defense is the visibility check, but a stale
|
||||||
/// JWT could in theory iterate UUIDs against the related-by-grant
|
/// JWT could in theory iterate UUIDs against the related-by-grant
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod face;
|
|||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
pub mod magic_link_token;
|
pub mod magic_link_token;
|
||||||
|
pub mod notification;
|
||||||
pub mod playlist;
|
pub mod playlist;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod share;
|
pub mod share;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//! In-app notification — one durable row per recipient per event.
|
||||||
|
//!
|
||||||
|
//! Backs the bell UI. The message bus poke on
|
||||||
|
//! `user:{user_id}:notifications` is a fast path; the row is truth.
|
||||||
|
//! See `docs/plan/message-bus.md § Slice E` for the wire contract.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A stable kind slug. The FE routes on this string for icon / label /
|
||||||
|
/// action-button choice. New kinds are additive; **never repurpose an
|
||||||
|
/// existing value** — the FE reads it as an enum-like discriminant.
|
||||||
|
///
|
||||||
|
/// The initial set matches the plan's Slice-E ingester list. Additional
|
||||||
|
/// values are legal on the wire (an older FE ignores unknown kinds
|
||||||
|
/// gracefully by falling back to a generic bell row); we still keep the
|
||||||
|
/// canonical list here so the ingester callsites reach for symbolic
|
||||||
|
/// constants instead of literal strings.
|
||||||
|
///
|
||||||
|
/// The DB column is plain `TEXT` (see `migrations/20261026000000_notifications.sql`)
|
||||||
|
/// — no CHECK constraint. Adding a new kind is a code change only, no
|
||||||
|
/// migration, no downtime.
|
||||||
|
pub mod kind {
|
||||||
|
/// A grant was created for the recipient user (they can now access
|
||||||
|
/// a resource). Payload carries the resource id + role + granter.
|
||||||
|
pub const SHARE_GRANTED: &str = "share_granted";
|
||||||
|
|
||||||
|
/// A login succeeded from a device / IP fingerprint the user
|
||||||
|
/// hasn't seen before. Payload carries the user-agent snippet
|
||||||
|
/// and the coarsened location if available.
|
||||||
|
pub const NEW_LOGIN_FROM_NEW_DEVICE: &str = "new_login_from_new_device";
|
||||||
|
|
||||||
|
/// A background job triggered by the recipient user finished
|
||||||
|
/// (success or failure). Payload carries the job name and
|
||||||
|
/// `success: bool`. Clicking navigates to `/admin/jobs/<name>`.
|
||||||
|
pub const JOB_COMPLETED_FOR_YOU: &str = "job_completed_for_you";
|
||||||
|
|
||||||
|
/// The recipient's storage quota crossed a warning threshold
|
||||||
|
/// (e.g. 80 %, 95 %). Payload carries `used_bytes` / `quota_bytes`
|
||||||
|
/// and the crossed percentage.
|
||||||
|
pub const STORAGE_QUOTA_THRESHOLD: &str = "storage_quota_threshold";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One notification row.
|
||||||
|
///
|
||||||
|
/// `payload` is a per-kind opaque JSON blob; the DB stays schema-free
|
||||||
|
/// so a new field never requires a migration. Callers deserialize it
|
||||||
|
/// against a kind-specific struct on the FE.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Notification {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub kind: String,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// `None` = unread; `Some(t)` = when the user explicitly marked it
|
||||||
|
/// read via `POST /api/notifications/{id}/read` or
|
||||||
|
/// `POST /api/notifications/read-all`.
|
||||||
|
pub read_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The service-layer input for [`NotificationService::create`]. Split
|
||||||
|
/// from [`Notification`] because `id` / `created_at` are DB-generated.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NewNotification {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub kind: String,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub mod drive_repository;
|
|||||||
pub mod file_repository;
|
pub mod file_repository;
|
||||||
pub mod folder_repository;
|
pub mod folder_repository;
|
||||||
pub mod magic_link_token_repository;
|
pub mod magic_link_token_repository;
|
||||||
|
pub mod notification_repository;
|
||||||
pub mod playlist_repository;
|
pub mod playlist_repository;
|
||||||
pub mod session_repository;
|
pub mod session_repository;
|
||||||
pub mod settings_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;
|
pub mod file_metadata_repository;
|
||||||
mod magic_link_token_pg_repository;
|
mod magic_link_token_pg_repository;
|
||||||
mod nextcloud_object_id_repository;
|
mod nextcloud_object_id_repository;
|
||||||
|
mod notification_pg_repository;
|
||||||
mod opaque_pg_repository;
|
mod opaque_pg_repository;
|
||||||
pub mod playlist_pg_repository;
|
pub mod playlist_pg_repository;
|
||||||
mod recent_items_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 folder_db_repository::FolderDbRepository;
|
||||||
pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository;
|
pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository;
|
||||||
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
||||||
|
pub use notification_pg_repository::NotificationPgRepository;
|
||||||
pub use opaque_pg_repository::OpaquePgRepository;
|
pub use opaque_pg_repository::OpaquePgRepository;
|
||||||
pub use playlist_pg_repository::{
|
pub use playlist_pg_repository::{
|
||||||
AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository,
|
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::FolderMoved { .. } => "folder_moved",
|
||||||
MessageBusEvent::FolderDeleted { .. } => "folder_deleted",
|
MessageBusEvent::FolderDeleted { .. } => "folder_deleted",
|
||||||
MessageBusEvent::AuthzChanged { .. } => "authz_changed",
|
MessageBusEvent::AuthzChanged { .. } => "authz_changed",
|
||||||
|
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",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub mod mock_email_sender;
|
|||||||
pub mod mount_provider_factory;
|
pub mod mount_provider_factory;
|
||||||
pub mod nextcloud_chunked_upload_service;
|
pub mod nextcloud_chunked_upload_service;
|
||||||
pub mod noop_face_analyzer;
|
pub mod noop_face_analyzer;
|
||||||
|
pub mod notifications_cleanup_service;
|
||||||
pub mod oidc_service;
|
pub mod oidc_service;
|
||||||
#[cfg(feature = "faces-onnx")]
|
#[cfg(feature = "faces-onnx")]
|
||||||
pub mod onnx_face_analyzer;
|
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(),
|
"🤝 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
|
// PR N1 — route the post-grant notification through the unified
|
||||||
// RecipientNotificationService. Handles user/group/token subjects
|
// RecipientNotificationService. Handles user/group/token subjects
|
||||||
// uniformly (Token subjects return an empty outcome set); applies
|
// uniformly (Token subjects return an empty outcome set); applies
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod grant_handler;
|
|||||||
pub mod i18n_handler;
|
pub mod i18n_handler;
|
||||||
pub mod magic_link_handler;
|
pub mod magic_link_handler;
|
||||||
pub mod music_handler;
|
pub mod music_handler;
|
||||||
|
pub mod notifications_handler;
|
||||||
pub mod opaque_auth_handler;
|
pub mod opaque_auth_handler;
|
||||||
pub mod people_handler;
|
pub mod people_handler;
|
||||||
pub mod photos_handler;
|
pub mod photos_handler;
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
//! `/api/notifications/*` — the bell UI's REST surface.
|
||||||
|
//!
|
||||||
|
//! Five endpoints back the FE `NotificationBell`:
|
||||||
|
//!
|
||||||
|
//! - `GET /api/notifications` — list newest-first; optional
|
||||||
|
//! `unread=true` filter, `before` cursor, `limit` cap.
|
||||||
|
//! - `GET /api/notifications/unread` — badge-only fast path (count).
|
||||||
|
//! - `POST /api/notifications/{id}/read` — mark one as read.
|
||||||
|
//! - `POST /api/notifications/read-all` — bulk mark-all-read.
|
||||||
|
//! - `DELETE /api/notifications/{id}` — hard-delete one row.
|
||||||
|
//!
|
||||||
|
//! Every endpoint scopes on `auth_user.id` at the SQL layer via the
|
||||||
|
//! application service, so an id enumeration against
|
||||||
|
//! `POST /api/notifications/{id}/read` returns the same 204 whether
|
||||||
|
//! the row exists-and-belongs-to-somebody-else, or doesn't exist at
|
||||||
|
//! all. Anti-enumeration is the reason the response body doesn't
|
||||||
|
//! distinguish "already read" from "not yours" — the service returns
|
||||||
|
//! a bool for our logs, we always return 204 to the wire.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::services::notification_application_service::NotificationApplicationService;
|
||||||
|
use crate::domain::entities::notification::Notification;
|
||||||
|
use crate::domain::repositories::notification_repository::NotificationListFilter;
|
||||||
|
use crate::interfaces::errors::AppError;
|
||||||
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
|
/// Wire shape for one notification row. `payload` stays a raw JSON
|
||||||
|
/// value — per-kind decoding happens on the FE using the `kind`
|
||||||
|
/// discriminant.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct NotificationDto {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub kind: String,
|
||||||
|
#[schema(value_type = Object)]
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// `null` = unread.
|
||||||
|
pub read_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Notification> for NotificationDto {
|
||||||
|
fn from(n: Notification) -> Self {
|
||||||
|
Self {
|
||||||
|
id: n.id,
|
||||||
|
kind: n.kind,
|
||||||
|
payload: n.payload,
|
||||||
|
created_at: n.created_at,
|
||||||
|
read_at: n.read_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query params for `GET /api/notifications`.
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct ListQuery {
|
||||||
|
/// When `true`, return only unread rows. Default: `false` (both).
|
||||||
|
#[serde(default)]
|
||||||
|
pub unread: bool,
|
||||||
|
/// Cursor — return rows strictly before this `created_at`. Omit
|
||||||
|
/// for the newest page.
|
||||||
|
pub before: Option<DateTime<Utc>>,
|
||||||
|
/// Max rows returned. Server-side clamp at 500.
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct ListResponseDto {
|
||||||
|
pub items: Vec<NotificationDto>,
|
||||||
|
/// Unread rows for this user across the whole table — the bell
|
||||||
|
/// badge reads this. Kept on the list response so a bell open
|
||||||
|
/// doesn't need a second round-trip for the badge.
|
||||||
|
pub unread_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct UnreadCountDto {
|
||||||
|
pub unread_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct MarkAllReadResponseDto {
|
||||||
|
/// Number of rows that transitioned unread → read.
|
||||||
|
pub marked: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/notifications
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/notifications",
|
||||||
|
params(
|
||||||
|
("unread" = Option<bool>, Query, description = "Only return unread rows"),
|
||||||
|
("before" = Option<DateTime<Utc>>, Query, description = "Cursor — rows strictly before this created_at"),
|
||||||
|
("limit" = Option<u32>, Query, description = "Max rows (server-side clamp at 500)"),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "List of notifications", body = ListResponseDto),
|
||||||
|
),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
tag = "notifications"
|
||||||
|
)]
|
||||||
|
pub async fn list_notifications(
|
||||||
|
State(service): State<Arc<NotificationApplicationService>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
) -> Result<Json<ListResponseDto>, AppError> {
|
||||||
|
let filter = NotificationListFilter {
|
||||||
|
limit: query.limit,
|
||||||
|
unread_only: if query.unread { Some(true) } else { None },
|
||||||
|
before: query.before,
|
||||||
|
};
|
||||||
|
let rows = service.list_for_user(auth_user.id, filter).await?;
|
||||||
|
let unread_count = service.count_unread_for_user(auth_user.id).await?;
|
||||||
|
Ok(Json(ListResponseDto {
|
||||||
|
items: rows.into_iter().map(NotificationDto::from).collect(),
|
||||||
|
unread_count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/notifications/unread — badge-only fast path.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/notifications/unread",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Unread count", body = UnreadCountDto),
|
||||||
|
),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
tag = "notifications"
|
||||||
|
)]
|
||||||
|
pub async fn unread_count(
|
||||||
|
State(service): State<Arc<NotificationApplicationService>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<Json<UnreadCountDto>, AppError> {
|
||||||
|
let unread_count = service.count_unread_for_user(auth_user.id).await?;
|
||||||
|
Ok(Json(UnreadCountDto { unread_count }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/notifications/{id}/read — mark one as read.
|
||||||
|
///
|
||||||
|
/// Always responds 204 regardless of whether the row existed and
|
||||||
|
/// belonged to the caller — the service's `bool` return is logged
|
||||||
|
/// (audit reason `notification.marked_read` on success), never
|
||||||
|
/// surfaced to the wire.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/notifications/{id}/read",
|
||||||
|
params(("id" = Uuid, Path, description = "Notification id")),
|
||||||
|
responses((status = 204, description = "Marked read (idempotent, anti-enum)")),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
tag = "notifications"
|
||||||
|
)]
|
||||||
|
pub async fn mark_read(
|
||||||
|
State(service): State<Arc<NotificationApplicationService>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let transitioned = service.mark_read(id, auth_user.id).await?;
|
||||||
|
if transitioned {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "oxicloud::notifications",
|
||||||
|
caller_id = %auth_user.id,
|
||||||
|
notification_id = %id,
|
||||||
|
"notification marked read"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/notifications/read-all — bulk mark-all-read.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/notifications/read-all",
|
||||||
|
responses((status = 200, description = "Rows marked", body = MarkAllReadResponseDto)),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
tag = "notifications"
|
||||||
|
)]
|
||||||
|
pub async fn mark_all_read(
|
||||||
|
State(service): State<Arc<NotificationApplicationService>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<Json<MarkAllReadResponseDto>, AppError> {
|
||||||
|
let marked = service.mark_all_read(auth_user.id).await?;
|
||||||
|
Ok(Json(MarkAllReadResponseDto { marked }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/notifications/{id} — hard-delete one row.
|
||||||
|
///
|
||||||
|
/// Same anti-enum semantics as `mark_read` — always 204.
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/notifications/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "Notification id")),
|
||||||
|
responses((status = 204, description = "Deleted (idempotent, anti-enum)")),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
tag = "notifications"
|
||||||
|
)]
|
||||||
|
pub async fn delete_notification(
|
||||||
|
State(service): State<Arc<NotificationApplicationService>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let deleted = service.delete(id, auth_user.id).await?;
|
||||||
|
if deleted {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "oxicloud::notifications",
|
||||||
|
caller_id = %auth_user.id,
|
||||||
|
notification_id = %id,
|
||||||
|
"notification deleted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -401,6 +401,20 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppSt
|
|||||||
// effect is the `rt.revoked` per evicted sub.
|
// effect is the `rt.revoked` per evicted sub.
|
||||||
install_subscription(Topic::UserAuthz(caller_id), &mut subs, &out_tx, &state);
|
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
|
// Server-initiated protocol Ping ticker — prevents intermediate
|
||||||
// proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping
|
// proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping
|
||||||
// the TCP session as idle. Browsers can't send Ping control frames
|
// 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
|
/// loop then walks the sub set and drops matching topics. Any other
|
||||||
/// event kind on this topic is ignored (defensive; shouldn't happen
|
/// event kind on this topic is ignored (defensive; shouldn't happen
|
||||||
/// in MVP).
|
/// 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-
|
/// - For every other topic: bus events are wrapped into a client-
|
||||||
/// visible `rt.event` notification and pushed as `SessionOut::Frame`.
|
/// visible `rt.event` notification and pushed as `SessionOut::Frame`.
|
||||||
fn install_subscription(
|
fn install_subscription(
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
let share_service = app_state.share_service.clone();
|
let share_service = app_state.share_service.clone();
|
||||||
let favorites_service = app_state.favorites_service.clone();
|
let favorites_service = app_state.favorites_service.clone();
|
||||||
let recent_service = app_state.recent_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
|
// authorization is no longer extracted separately — the grants router now
|
||||||
// uses app_state directly so handlers can access all services.
|
// 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()
|
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).
|
// Create routes for chunked uploads (large files >10MB).
|
||||||
// All five handlers are free functions — see chunked_upload_handler.rs for why
|
// All five handlers are free functions — see chunked_upload_handler.rs for why
|
||||||
// #[utoipa::path] cannot be applied to ChunkedUploadHandler impl methods directly.
|
// #[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("/shares", share_router)
|
||||||
.nest("/grants", grants_router)
|
.nest("/grants", grants_router)
|
||||||
.nest("/favorites", favorites_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
|
// Photos timeline endpoint — lists all image/video files sorted by capture date
|
||||||
{
|
{
|
||||||
|
|||||||
+141
-1
@@ -64,6 +64,39 @@
|
|||||||
# unit tests; the seeded suite has no
|
# unit tests; the seeded suite has no
|
||||||
# admin token, and minting one here
|
# admin token, and minting one here
|
||||||
# would pollute state for other files.
|
# would pollute state for other files.
|
||||||
|
# S13 Notification wire push — user2 subscribes to
|
||||||
|
# `user:{user2_id}:notifications`; user1
|
||||||
|
# creates a grant that targets user2.
|
||||||
|
# Server must publish one `rt.event`
|
||||||
|
# with `event="notification_received"`,
|
||||||
|
# `data.kind="share_granted"`. Guards
|
||||||
|
# the Slice-E ingester + the auto-sub
|
||||||
|
# delivery path together — this is the
|
||||||
|
# only scenario that exercises the
|
||||||
|
# wire push from the `NotificationService`.
|
||||||
|
# S14 Notification DB row — after S13's grant, GET
|
||||||
|
# `/api/notifications` as user2 lists
|
||||||
|
# at least one row with
|
||||||
|
# `kind="share_granted"` whose payload
|
||||||
|
# references the freshly-shared folder,
|
||||||
|
# and `unread_count >= 1`. Guards the
|
||||||
|
# authoritative side of the pattern —
|
||||||
|
# a subscriber offline at publish time
|
||||||
|
# recovers via this endpoint.
|
||||||
|
# S15 Cross-user notif deny — user1 subscribes to
|
||||||
|
# `user:{user2_id}:notifications`
|
||||||
|
# (an identity-scoped topic that
|
||||||
|
# resolves to somebody else). Server
|
||||||
|
# must reject with `topic_forbidden`
|
||||||
|
# (same wire shape as unknown topic,
|
||||||
|
# same rule as S9 for `:authz`).
|
||||||
|
# Guards the strict-privacy Class-2
|
||||||
|
# AuthZ gate on Slice-E notifications
|
||||||
|
# — no admin bypass, direct UUID
|
||||||
|
# equality only. If this ever accepts
|
||||||
|
# and delivers events, an admin (or
|
||||||
|
# anyone else) could snoop on other
|
||||||
|
# users' notification streams.
|
||||||
#
|
#
|
||||||
# Exit non-zero on any failure — run.sh treats that as a suite failure.
|
# Exit non-zero on any failure — run.sh treats that as a suite failure.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -679,4 +712,111 @@ if ! "$HELPER_BIN" expect-denied \
|
|||||||
fi
|
fi
|
||||||
log "S12 OK"
|
log "S12 OK"
|
||||||
|
|
||||||
log "All twelve message-bus scenarios passed."
|
# ── Scenario 13 — Notification wire push (Slice E) ──────────────────────────
|
||||||
|
# The `share_granted` ingester runs in `grant_handler::create_grant`:
|
||||||
|
# after `set_role` lands and before the email path, it calls
|
||||||
|
# `NotificationService::create` for every resolved recipient user.
|
||||||
|
# `create` writes the DB row AND publishes a thin
|
||||||
|
# `MessageBusEvent::NotificationReceived` on
|
||||||
|
# `user:{user_id}:notifications`. This scenario exercises the wire
|
||||||
|
# path end-to-end: user2 opens a WS + explicitly subscribes to their
|
||||||
|
# own notifications topic (idempotent with the server's auto-sub),
|
||||||
|
# user1 fires a fresh grant, user2 sees the one event.
|
||||||
|
#
|
||||||
|
# A fresh folder C is used so this scenario is independent of the
|
||||||
|
# S8 grant/revoke sequence — user2 already has DB rows from S8's
|
||||||
|
# grants on A + B, but those events fired BEFORE user2's WS opened
|
||||||
|
# so no wire delivery competes with S13's.
|
||||||
|
log "S13: create folder C, subscribe user2 to their notifications, expect one share_granted event."
|
||||||
|
folder_c=$(c_post "$base_url/api/folders" "$user1_token" \
|
||||||
|
"$(printf '{"name":"rt_bus_C_%s","parent_id":"%s"}' "$suffix" "$root_id")" | jq -r '.id')
|
||||||
|
[[ -n "$folder_c" && "$folder_c" != "null" ]] || die "S13: folder C creation failed"
|
||||||
|
|
||||||
|
out_s13="$(mktemp -t rtbus_s13.XXXXXX)"
|
||||||
|
ready_s13="$(mktemp -t rtbus_s13_ready.XXXXXX)"; rm -f "$ready_s13"
|
||||||
|
"$HELPER_BIN" subscribe-and-collect \
|
||||||
|
--url "$ws_url" \
|
||||||
|
--token "$user2_token" \
|
||||||
|
--subscribe "user:${user2_id}:notifications" \
|
||||||
|
--expect-events 1 \
|
||||||
|
--timeout 5s \
|
||||||
|
--ready-file "$ready_s13" \
|
||||||
|
--output "$out_s13" &
|
||||||
|
helper_pid=$!
|
||||||
|
wait_ready "$ready_s13"
|
||||||
|
|
||||||
|
grant_c=$(c_post "$base_url/api/grants" "$user1_token" \
|
||||||
|
"$(printf '{"subject":{"type":"user","id":"%s"},"resource":{"type":"folder","id":"%s"},"role":"viewer"}' \
|
||||||
|
"$user2_id" "$folder_c")")
|
||||||
|
grant_c_id=$(printf '%s' "$grant_c" | jq -r '.grants[0].id')
|
||||||
|
[[ -n "$grant_c_id" && "$grant_c_id" != "null" ]] \
|
||||||
|
|| die "S13: grant on folder C failed: $grant_c"
|
||||||
|
|
||||||
|
if ! wait "$helper_pid"; then
|
||||||
|
cat "$out_s13" >&2 || true
|
||||||
|
die "S13: helper did not observe the notification_received event"
|
||||||
|
fi
|
||||||
|
[[ "$(jq -r '.events | length' "$out_s13")" == "1" ]] \
|
||||||
|
|| { cat "$out_s13"; die "S13: expected 1 event, got $(jq -r '.events | length' "$out_s13")"; }
|
||||||
|
[[ "$(jq -r '.events[0].event' "$out_s13")" == "notification_received" ]] \
|
||||||
|
|| die "S13: wrong event discriminator: $(jq -r '.events[0].event' "$out_s13")"
|
||||||
|
[[ "$(jq -r '.events[0].data.kind' "$out_s13")" == "share_granted" ]] \
|
||||||
|
|| die "S13: wrong notification kind: $(jq -r '.events[0].data.kind' "$out_s13")"
|
||||||
|
# `notification_id` is a fresh UUID stamped by the DB — check it's
|
||||||
|
# non-empty and non-null. Value asserted by S14 via GET /api/notifications.
|
||||||
|
[[ -n "$(jq -r '.events[0].data.notification_id' "$out_s13")" ]] \
|
||||||
|
&& [[ "$(jq -r '.events[0].data.notification_id' "$out_s13")" != "null" ]] \
|
||||||
|
|| die "S13: notification_id missing on wire payload"
|
||||||
|
log "S13 OK"
|
||||||
|
|
||||||
|
# ── Scenario 14 — Notification DB row (Slice E) ─────────────────────────────
|
||||||
|
# The bus event is best-effort. The DB row is truth: a subscriber
|
||||||
|
# offline at publish time recovers via `GET /api/notifications`.
|
||||||
|
# S13 fired a grant on folder C; the ingester wrote a row for user2.
|
||||||
|
# This scenario reads it back and asserts on shape.
|
||||||
|
#
|
||||||
|
# `unread_count` from the same response reflects ALL unread rows,
|
||||||
|
# including the 2 from S8's grants (folders A + B) — the fresh grant
|
||||||
|
# in S13 brings the total to >= 3. We assert >= 1 (loose enough to
|
||||||
|
# not couple to S8's state, tight enough to prove the row landed).
|
||||||
|
log "S14: GET /api/notifications as user2; expect a share_granted row for folder C."
|
||||||
|
notifs=$(c_get "$base_url/api/notifications" "$user2_token")
|
||||||
|
unread=$(printf '%s' "$notifs" | jq -r '.unread_count')
|
||||||
|
[[ "$unread" -ge 1 ]] \
|
||||||
|
|| { printf '%s\n' "$notifs" >&2; die "S14: unread_count expected >= 1, got $unread"; }
|
||||||
|
# Filter for the S13 row: kind == share_granted AND payload.resource_id == folder_c.
|
||||||
|
match_count=$(printf '%s' "$notifs" | jq --arg fc "$folder_c" \
|
||||||
|
'[.items[] | select(.kind == "share_granted" and .payload.resource_id == $fc)] | length')
|
||||||
|
[[ "$match_count" -ge 1 ]] \
|
||||||
|
|| { printf '%s\n' "$notifs" >&2; die "S14: no share_granted row for folder C (matches=$match_count)"; }
|
||||||
|
# The matched row must be unread (read_at is null) — the caller
|
||||||
|
# hasn't clicked it yet, so the bell would still badge it.
|
||||||
|
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_read_at" == "null" ]] \
|
||||||
|
|| die "S14: matched row unexpectedly marked read: read_at=$first_read_at"
|
||||||
|
log "S14 OK"
|
||||||
|
|
||||||
|
# ── Scenario 15 — Cross-user notifications identity gate ────────────────────
|
||||||
|
# `Topic::UserNotifications(u)` maps to `AuthzCheck::IdentityMatch{u}`
|
||||||
|
# in `application/ports/message_bus_ports.rs::required_perm`. Direct
|
||||||
|
# UUID equality only — no admin bypass. A caller subscribing to
|
||||||
|
# another user's notifications channel MUST be denied with the same
|
||||||
|
# wire shape (`topic_forbidden`) as an unknown topic — anti-enum.
|
||||||
|
#
|
||||||
|
# If this ever regresses (identity check dropped, engine wired on
|
||||||
|
# this class, admin bypass added) it becomes a privacy leak on par
|
||||||
|
# with an admin snooping on `:authz` streams. Same guard as S9,
|
||||||
|
# different topic suffix.
|
||||||
|
log "S15: user1 subscribes to user:{user2_id}:notifications; expect topic_forbidden."
|
||||||
|
if ! "$HELPER_BIN" expect-denied \
|
||||||
|
--url "$ws_url" \
|
||||||
|
--token "$user1_token" \
|
||||||
|
--subscribe "user:${user2_id}:notifications" \
|
||||||
|
--reason topic_forbidden \
|
||||||
|
--timeout 3s; then
|
||||||
|
die "S15: user1 was NOT denied on user2's notifications topic (identity gate broken?)"
|
||||||
|
fi
|
||||||
|
log "S15 OK"
|
||||||
|
|
||||||
|
log "All fifteen message-bus scenarios passed."
|
||||||
|
|||||||
Reference in New Issue
Block a user