Compare commits
3
Commits
1e9408c835
...
f11dba2336
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11dba2336 | ||
|
|
87f8ed6147 | ||
|
|
956058d4a0 |
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -170,7 +171,20 @@ func (h *handlers) handleMarkAllMyNotificationsRead(w http.ResponseWriter, r *ht
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := dbq.New(h.pool).MarkAllNotificationsRead(r.Context(), user.ID); err != nil {
|
||||
// Optional {"up_to": RFC3339}: only what existed then. A client replaying
|
||||
// an offline "mark all read" sends the moment the user asked.
|
||||
var body struct {
|
||||
UpTo *time.Time `json:"up_to"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
|
||||
return
|
||||
}
|
||||
params := dbq.MarkAllNotificationsReadParams{UserID: user.ID}
|
||||
if body.UpTo != nil {
|
||||
params.UpTo = pgtype.Timestamptz{Time: *body.UpTo, Valid: true}
|
||||
}
|
||||
if _, err := dbq.New(h.pool).MarkAllNotificationsRead(r.Context(), params); err != nil {
|
||||
writeErrWithLog(w, h.logger, "notifications: mark all read", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -127,6 +128,33 @@ func TestMyNotifications_MarkReadIsOwnerScopedAndCountsDown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A "mark all read" replayed from an offline queue carries the moment the
|
||||
// user asked; what arrived after it stays unread.
|
||||
func TestMyNotifications_ReadAllUpToLeavesLaterOnesUnread(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
alice := seedUser(t, pool, "notif-upto", "pw", false)
|
||||
r := notificationsRouter(h)
|
||||
|
||||
notifyN(t, pool, alice, 1)
|
||||
var page notificationsPageResp
|
||||
callAs(t, r, alice, http.MethodGet, "/api/me/notifications", "", &page)
|
||||
seen := page.Items[0].CreatedAt
|
||||
notifyN(t, pool, alice, 1)
|
||||
|
||||
body := `{"up_to":"` + seen.Format(time.RFC3339Nano) + `"}`
|
||||
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", body, nil); code != http.StatusNoContent {
|
||||
t.Fatalf("read-all up_to = %d", code)
|
||||
}
|
||||
var count unreadCountResp
|
||||
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
|
||||
if count.UnreadCount != 1 {
|
||||
t.Errorf("unread = %d, want 1 (the one that arrived later)", count.UnreadCount)
|
||||
}
|
||||
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", `{"up_to":"yesterday"}`, nil); code != http.StatusBadRequest {
|
||||
t.Errorf("malformed up_to = %d, want 400", code)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func setSMTP(t *testing.T, pool *pgxpool.Pool, enabled bool) {
|
||||
|
||||
@@ -214,11 +214,22 @@ func (q *Queries) ListNotifications(ctx context.Context, arg ListNotificationsPa
|
||||
const markAllNotificationsRead = `-- name: MarkAllNotificationsRead :execrows
|
||||
UPDATE user_notifications
|
||||
SET read_at = now()
|
||||
WHERE user_id = $1 AND read_at IS NULL
|
||||
WHERE user_id = $1
|
||||
AND read_at IS NULL
|
||||
AND ($2::timestamptz IS NULL OR created_at <= $2)
|
||||
`
|
||||
|
||||
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, markAllNotificationsRead, userID)
|
||||
type MarkAllNotificationsReadParams struct {
|
||||
UserID pgtype.UUID
|
||||
UpTo pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// up_to, when set, limits it to what existed when the user asked: a "mark
|
||||
// all read" queued offline and replayed later must not mark notices that
|
||||
// arrived in between, which the user never saw. A coalesced row updated
|
||||
// since then carries a newer created_at, so it stays unread too.
|
||||
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -57,9 +57,15 @@ UPDATE user_notifications
|
||||
WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id);
|
||||
|
||||
-- name: MarkAllNotificationsRead :execrows
|
||||
-- up_to, when set, limits it to what existed when the user asked: a "mark
|
||||
-- all read" queued offline and replayed later must not mark notices that
|
||||
-- arrived in between, which the user never saw. A coalesced row updated
|
||||
-- since then carries a newer created_at, so it stays unread too.
|
||||
UPDATE user_notifications
|
||||
SET read_at = now()
|
||||
WHERE user_id = $1 AND read_at IS NULL;
|
||||
WHERE user_id = sqlc.arg(user_id)
|
||||
AND read_at IS NULL
|
||||
AND (sqlc.narg(up_to)::timestamptz IS NULL OR created_at <= sqlc.narg(up_to));
|
||||
|
||||
-- name: TrimNotifications :execrows
|
||||
-- Retention: read rows go after read_cutoff, and anything at all after
|
||||
|
||||
@@ -151,7 +151,7 @@ func TestDuplicateSweep_NotifiesOnlyWhenSomethingNewIsProposed(t *testing.T) {
|
||||
}
|
||||
markAllRead := func() {
|
||||
t.Helper()
|
||||
if _, err := q.MarkAllNotificationsRead(ctx, admin.ID); err != nil {
|
||||
if _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: admin.ID}); err != nil {
|
||||
t.Fatalf("mark read: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
|
||||
// The notifications inbox (M489). The server renders title, body and link per
|
||||
// kind, so every client says the same thing and this module never has to know
|
||||
// what a kind means.
|
||||
|
||||
export type NotificationItem = {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
body: string;
|
||||
link: string;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
};
|
||||
|
||||
export type NotificationPage = {
|
||||
items: NotificationItem[];
|
||||
unread_count: number;
|
||||
next_before?: string;
|
||||
};
|
||||
|
||||
// The bell polls as a fallback for a dropped live-event stream; the
|
||||
// `notification.created` event is what makes it prompt. A minute is slow
|
||||
// enough to cost nothing and quick enough that a missed nudge is not missed
|
||||
// for long. Polling pauses while the tab is hidden (TanStack's default).
|
||||
export const NOTIFICATIONS_POLL_MS = 60_000;
|
||||
export const NOTIFICATIONS_PAGE_SIZE = 30;
|
||||
|
||||
export async function listNotifications(before?: string): Promise<NotificationPage> {
|
||||
const params = new URLSearchParams({ limit: String(NOTIFICATIONS_PAGE_SIZE) });
|
||||
if (before) params.set('before', before);
|
||||
return api.get<NotificationPage>(`/api/me/notifications?${params}`);
|
||||
}
|
||||
|
||||
export async function unreadNotificationCount(): Promise<number> {
|
||||
const res = await api.get<{ unread_count: number }>('/api/me/notifications/unread-count');
|
||||
return res.unread_count;
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
await api.post(`/api/me/notifications/${encodeURIComponent(id)}/read`, {});
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<void> {
|
||||
await api.post('/api/me/notifications/read-all', {});
|
||||
}
|
||||
|
||||
export function createNotificationsQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.notifications(),
|
||||
queryFn: () => listNotifications(),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: NOTIFICATIONS_POLL_MS
|
||||
});
|
||||
}
|
||||
|
||||
export function createUnreadCountQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.notificationsUnread(),
|
||||
queryFn: unreadNotificationCount,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: NOTIFICATIONS_POLL_MS
|
||||
});
|
||||
}
|
||||
|
||||
/** The badge's text: nothing at zero, the count up to nine, then "9+". */
|
||||
export function badgeLabel(count: number): string {
|
||||
if (count <= 0) return '';
|
||||
return count > 9 ? '9+' : String(count);
|
||||
}
|
||||
|
||||
// Per-user settings: which channels each kind reaches. Kinds the user cannot
|
||||
// receive (admin kinds, for a non-admin) are not listed at all.
|
||||
export type NotificationKindSetting = {
|
||||
kind: string;
|
||||
admin_only: boolean;
|
||||
inbox: boolean;
|
||||
phone: boolean;
|
||||
email: boolean;
|
||||
};
|
||||
|
||||
export type NotificationChannel = 'inbox' | 'phone' | 'email';
|
||||
|
||||
export type NotificationSettings = {
|
||||
kinds: NotificationKindSetting[];
|
||||
email_available: boolean;
|
||||
email_unavailable_reason?: 'no_address' | 'smtp_not_configured';
|
||||
};
|
||||
|
||||
export type NotificationSettingChange = { kind: string } & Partial<Record<NotificationChannel, boolean>>;
|
||||
|
||||
export async function getNotificationSettings(): Promise<NotificationSettings> {
|
||||
return api.get<NotificationSettings>('/api/me/notification-settings');
|
||||
}
|
||||
|
||||
// Partial: only what the user touched, so two quick toggles on different rows
|
||||
// cannot overwrite each other on the server.
|
||||
export async function putNotificationSettings(
|
||||
changes: NotificationSettingChange[]
|
||||
): Promise<NotificationSettings> {
|
||||
return api.put<NotificationSettings>('/api/me/notification-settings', { kinds: changes });
|
||||
}
|
||||
|
||||
export function createNotificationSettingsQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.notificationSettings(),
|
||||
queryFn: getNotificationSettings,
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
/** Short row labels, menu-style (rule 188). Unknown kinds show their key. */
|
||||
export const NOTIFICATION_KIND_LABELS: Record<string, string> = {
|
||||
request_approved: 'Request approved',
|
||||
request_rejected: 'Request declined',
|
||||
request_completed: 'New music arrived',
|
||||
request_pending: 'Requests to review',
|
||||
quarantine_flagged: 'Tracks flagged',
|
||||
scan_failed: 'Library scan failed',
|
||||
tracks_missing: 'Tracks gone missing',
|
||||
duplicates_found: 'Duplicates to review',
|
||||
playback_errors: 'Playback errors'
|
||||
};
|
||||
@@ -61,6 +61,11 @@ export const qk = {
|
||||
adminDiagnosticDevices: (userId?: string) =>
|
||||
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
||||
smtpConfig: () => ['smtpConfig'] as const,
|
||||
// Notifications inbox (M489). Both keys sit under ['notifications'] so one
|
||||
// prefix invalidation refreshes the list and the badge together.
|
||||
notifications: () => ['notifications', 'list'] as const,
|
||||
notificationsUnread: () => ['notifications', 'unread'] as const,
|
||||
notificationSettings: () => ['notificationSettings'] as const,
|
||||
suggestions: (limit?: number) =>
|
||||
['suggestions', { limit: limit ?? 12 }] as const,
|
||||
suggestionSnoozes: () => ['suggestionSnoozes'] as const,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { Bell } from 'lucide-svelte';
|
||||
import {
|
||||
badgeLabel,
|
||||
createNotificationsQuery,
|
||||
createUnreadCountQuery,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationItem
|
||||
} from '$lib/api/notifications';
|
||||
import { relativeTime } from '$lib/utils/relativeTime';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
// The notifications inbox (M489): a bell in the header with an unread badge,
|
||||
// opening a panel of what happened while you were away.
|
||||
|
||||
const client = useQueryClient();
|
||||
const unreadStore = createUnreadCountQuery();
|
||||
const listStore = createNotificationsQuery();
|
||||
const unread = $derived(($unreadStore.data as number | undefined) ?? 0);
|
||||
const items = $derived((($listStore.data?.items ?? []) as NotificationItem[]));
|
||||
const badge = $derived(badgeLabel(unread));
|
||||
|
||||
let open = $state(false);
|
||||
let panelRef: HTMLElement | undefined = $state();
|
||||
let buttonRef: HTMLElement | undefined = $state();
|
||||
|
||||
// Same outside-click shape as the user menu in Shell: no stopPropagation on
|
||||
// the panel, so SvelteKit's link interception keeps working inside it.
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
if (!open) return;
|
||||
const target = e.target as Node | null;
|
||||
if (!target) return;
|
||||
if (panelRef?.contains(target) || buttonRef?.contains(target)) return;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
client.invalidateQueries({ queryKey: ['notifications'] });
|
||||
}
|
||||
|
||||
async function openItem(item: NotificationItem) {
|
||||
open = false;
|
||||
if (!item.read_at) {
|
||||
try {
|
||||
await markNotificationRead(item.id);
|
||||
} catch {
|
||||
// Opening the subject matters more than the read mark; the next
|
||||
// refresh shows it unread again, which is the honest state.
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
if (item.link) await goto(item.link);
|
||||
}
|
||||
|
||||
async function markAll() {
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
refresh();
|
||||
} catch {
|
||||
pushToast("Couldn't mark them read. Try again in a moment.", 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (open = false)} />
|
||||
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
type="button"
|
||||
class="relative flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Bell size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||
{#if badge}
|
||||
<span
|
||||
data-testid="notification-badge"
|
||||
class="absolute right-1 top-1 min-w-[18px] rounded-full bg-text-primary px-1 text-center text-xs leading-[18px] text-background"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
bind:this={panelRef}
|
||||
class="absolute right-0 z-50 mt-1 flex max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] flex-col rounded border border-border bg-surface shadow"
|
||||
role="dialog"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span class="text-sm font-medium text-text-primary">Notifications</span>
|
||||
{#if unread > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-1 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
onclick={markAll}
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="px-3 py-6 text-center text-sm text-text-secondary">Nothing waiting for you.</p>
|
||||
{:else}
|
||||
<ul class="overflow-y-auto">
|
||||
{#each items as item (item.id)}
|
||||
<li class="border-b border-border last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="notification-item"
|
||||
data-unread={item.read_at ? undefined : 'true'}
|
||||
class="flex w-full gap-2 px-3 py-2 text-left hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||
onclick={() => openItem(item)}
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full {item.read_at ? '' : 'bg-text-primary'}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span
|
||||
class="block text-sm {item.read_at ? 'text-text-secondary' : 'font-medium text-text-primary'}"
|
||||
>
|
||||
{item.title}
|
||||
{#if !item.read_at}<span class="sr-only"> (unread)</span>{/if}
|
||||
</span>
|
||||
{#if item.body}
|
||||
<span class="block text-sm text-text-secondary">{item.body}</span>
|
||||
{/if}
|
||||
<span class="block text-xs text-text-muted">{relativeTime(item.created_at)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
|
||||
const invalidateMock = vi.fn();
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/notifications', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
badgeLabel: actual.badgeLabel,
|
||||
createUnreadCountQuery: vi.fn(),
|
||||
createNotificationsQuery: vi.fn(),
|
||||
markNotificationRead: vi.fn().mockResolvedValue(undefined),
|
||||
markAllNotificationsRead: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import NotificationBell from './NotificationBell.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
badgeLabel,
|
||||
createNotificationsQuery,
|
||||
createUnreadCountQuery,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationItem
|
||||
} from '$lib/api/notifications';
|
||||
|
||||
function item(over: Partial<NotificationItem> = {}): NotificationItem {
|
||||
return {
|
||||
id: 'n1',
|
||||
kind: 'request_completed',
|
||||
title: 'Arrived: Boards of Canada – Geogaddi',
|
||||
body: 'Your request is in the library.',
|
||||
link: '/albums/al-1',
|
||||
created_at: new Date().toISOString(),
|
||||
read_at: null,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
function setInbox(items: NotificationItem[], unread = items.filter((i) => !i.read_at).length) {
|
||||
(createUnreadCountQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: unread }));
|
||||
(createNotificationsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: { items, unread_count: unread } })
|
||||
);
|
||||
}
|
||||
|
||||
async function openPanel() {
|
||||
await fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('badgeLabel', () => {
|
||||
test('is empty at zero, the count to nine, then 9+', () => {
|
||||
expect(badgeLabel(0)).toBe('');
|
||||
expect(badgeLabel(1)).toBe('1');
|
||||
expect(badgeLabel(9)).toBe('9');
|
||||
expect(badgeLabel(10)).toBe('9+');
|
||||
expect(badgeLabel(250)).toBe('9+');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationBell', () => {
|
||||
test('shows the unread count on the badge and in the label', () => {
|
||||
setInbox([item(), item({ id: 'n2' }), item({ id: 'n3' })]);
|
||||
render(NotificationBell);
|
||||
expect(screen.getByTestId('notification-badge')).toHaveTextContent('3');
|
||||
expect(screen.getByRole('button', { name: 'Notifications, 3 unread' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('caps the badge at 9+', () => {
|
||||
setInbox([item()], 42);
|
||||
render(NotificationBell);
|
||||
expect(screen.getByTestId('notification-badge')).toHaveTextContent('9+');
|
||||
});
|
||||
|
||||
test('no badge when everything is read', () => {
|
||||
setInbox([item({ read_at: new Date().toISOString() })]);
|
||||
render(NotificationBell);
|
||||
expect(screen.queryByTestId('notification-badge')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('lists each kind with the server-rendered title and body, unread marked', async () => {
|
||||
setInbox([
|
||||
item({ id: 'a', kind: 'request_approved', title: 'Request approved', body: 'Geogaddi is on its way.' }),
|
||||
item({
|
||||
id: 'b',
|
||||
kind: 'tracks_missing',
|
||||
title: '4 tracks went missing',
|
||||
body: 'Their files are no longer where the library expects them.',
|
||||
link: '/admin/missing-files',
|
||||
read_at: new Date().toISOString()
|
||||
})
|
||||
]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
|
||||
const rows = screen.getAllByTestId('notification-item');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveTextContent('Request approved');
|
||||
expect(rows[0]).toHaveTextContent('Geogaddi is on its way.');
|
||||
expect(rows[0]).toHaveAttribute('data-unread', 'true');
|
||||
expect(rows[1]).toHaveTextContent('4 tracks went missing');
|
||||
expect(rows[1]).not.toHaveAttribute('data-unread');
|
||||
});
|
||||
|
||||
test('clicking an unread item marks it read and opens its subject', async () => {
|
||||
setInbox([item({ id: 'n7', link: '/albums/al-7' })]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
await fireEvent.click(screen.getByTestId('notification-item'));
|
||||
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/albums/al-7'));
|
||||
expect(markNotificationRead).toHaveBeenCalledWith('n7');
|
||||
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
|
||||
expect(screen.queryByRole('dialog', { name: 'Notifications' })).toBeNull();
|
||||
});
|
||||
|
||||
test('clicking a read item only opens it', async () => {
|
||||
setInbox([item({ read_at: new Date().toISOString(), link: '/requests' })]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
await fireEvent.click(screen.getByTestId('notification-item'));
|
||||
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/requests'));
|
||||
expect(markNotificationRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mark all read clears everything and refreshes', async () => {
|
||||
setInbox([item(), item({ id: 'n2' })]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }));
|
||||
|
||||
await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalled());
|
||||
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
|
||||
});
|
||||
|
||||
test('an empty inbox says so and offers no mark all', async () => {
|
||||
setInbox([]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
expect(screen.getByText('Nothing waiting for you.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Mark all read' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
NOTIFICATION_KIND_LABELS,
|
||||
createNotificationSettingsQuery,
|
||||
putNotificationSettings,
|
||||
type NotificationChannel,
|
||||
type NotificationKindSetting,
|
||||
type NotificationSettingChange,
|
||||
type NotificationSettings
|
||||
} from '$lib/api/notifications';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
// Per-user notification settings (M489): one row per kind, a toggle per
|
||||
// channel. Toggles are optimistic; a failed save puts the switch back.
|
||||
|
||||
const client = useQueryClient();
|
||||
const queryStore = createNotificationSettingsQuery();
|
||||
const settings = $derived($queryStore.data as NotificationSettings | undefined);
|
||||
const forEveryone = $derived((settings?.kinds ?? []).filter((k) => !k.admin_only));
|
||||
const forAdmins = $derived((settings?.kinds ?? []).filter((k) => k.admin_only));
|
||||
|
||||
const channels: { key: NotificationChannel; label: string }[] = [
|
||||
{ key: 'inbox', label: 'Inbox' },
|
||||
{ key: 'phone', label: 'Phone' },
|
||||
{ key: 'email', label: 'Email' }
|
||||
];
|
||||
|
||||
// Bumped by every local change, so a save resolving after a newer toggle
|
||||
// does not overwrite it, and a failed save only reverts when nothing newer
|
||||
// happened (snippet #5106's generation counter).
|
||||
let generation = 0;
|
||||
|
||||
function commit(next: NotificationSettings) {
|
||||
client.setQueryData(qk.notificationSettings(), next);
|
||||
}
|
||||
|
||||
function withChannel(
|
||||
k: NotificationKindSetting,
|
||||
channel: NotificationChannel,
|
||||
value: boolean
|
||||
): NotificationKindSetting {
|
||||
const next = { ...k };
|
||||
next[channel] = value;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function toggle(row: NotificationKindSetting, channel: NotificationChannel) {
|
||||
if (!settings) return;
|
||||
const prev = settings;
|
||||
const value = !row[channel];
|
||||
const gen = ++generation;
|
||||
commit({
|
||||
...prev,
|
||||
kinds: prev.kinds.map((k) => (k.kind === row.kind ? withChannel(k, channel, value) : k))
|
||||
});
|
||||
const change: NotificationSettingChange = { kind: row.kind };
|
||||
change[channel] = value;
|
||||
try {
|
||||
const saved = await putNotificationSettings([change]);
|
||||
if (gen === generation) commit(saved);
|
||||
} catch {
|
||||
if (gen === generation) commit(prev);
|
||||
pushToast("Couldn't save that setting. Try again in a moment.", 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Phone and email ride on the inbox: with the inbox off, nothing is stored
|
||||
// to send. Their own choices are kept and come back when the inbox does.
|
||||
function disabled(row: NotificationKindSetting, channel: NotificationChannel): boolean {
|
||||
if (channel === 'inbox') return false;
|
||||
if (!row.inbox) return true;
|
||||
return channel === 'email' && !settings?.email_available;
|
||||
}
|
||||
|
||||
function label(kind: string): string {
|
||||
return NOTIFICATION_KIND_LABELS[kind] ?? kind;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet rows(list: NotificationKindSetting[])}
|
||||
<div class="grid grid-cols-[1fr_repeat(3,3.5rem)] items-center gap-y-1 text-sm">
|
||||
<span></span>
|
||||
{#each channels as c (c.key)}
|
||||
<span class="text-center text-xs text-text-secondary">{c.label}</span>
|
||||
{/each}
|
||||
{#each list as row (row.kind)}
|
||||
<span class="py-1 text-text-primary">{label(row.kind)}</span>
|
||||
{#each channels as c (c.key)}
|
||||
<span class="flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{label(row.kind)}: {c.label}"
|
||||
checked={row[c.key] && !disabled(row, c.key)}
|
||||
disabled={disabled(row, c.key)}
|
||||
onchange={() => toggle(row, c.key)}
|
||||
/>
|
||||
</span>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<section id="notifications" class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Notifications</h2>
|
||||
|
||||
{#if !settings}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else}
|
||||
{@render rows(forEveryone)}
|
||||
|
||||
{#if forAdmins.length > 0}
|
||||
<h3 class="pt-2 text-sm font-medium text-text-secondary">Library health</h3>
|
||||
{@render rows(forAdmins)}
|
||||
{/if}
|
||||
|
||||
{#if !settings.email_available}
|
||||
<p class="text-sm text-text-secondary" data-testid="email-unavailable">
|
||||
{#if settings.email_unavailable_reason === 'no_address'}
|
||||
Email is off: <a href="#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
|
||||
{:else if user.value?.is_admin}
|
||||
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
|
||||
{:else}
|
||||
Email is off: this server doesn't send email.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
|
||||
const setQueryData = vi.fn();
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ setQueryData, invalidateQueries: vi.fn() }) };
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/notifications', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
createNotificationSettingsQuery: vi.fn(),
|
||||
putNotificationSettings: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
const userState = vi.hoisted(() => ({ current: { id: '1', username: 'alice', is_admin: false } }));
|
||||
vi.mock('$lib/auth/store.svelte', () => ({
|
||||
user: { get value() { return userState.current; } }
|
||||
}));
|
||||
|
||||
const pushToast = vi.fn();
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
|
||||
|
||||
import NotificationSettings from './NotificationSettings.svelte';
|
||||
import {
|
||||
createNotificationSettingsQuery,
|
||||
putNotificationSettings,
|
||||
type NotificationKindSetting,
|
||||
type NotificationSettings as Settings
|
||||
} from '$lib/api/notifications';
|
||||
|
||||
const requesterKinds: NotificationKindSetting[] = [
|
||||
{ kind: 'request_approved', admin_only: false, inbox: true, phone: true, email: true },
|
||||
{ kind: 'request_completed', admin_only: false, inbox: true, phone: true, email: true }
|
||||
];
|
||||
const adminKinds: NotificationKindSetting[] = [
|
||||
{ kind: 'tracks_missing', admin_only: true, inbox: true, phone: true, email: false }
|
||||
];
|
||||
|
||||
function setSettings(s: Partial<Settings>) {
|
||||
const data: Settings = { kinds: requesterKinds, email_available: true, ...s };
|
||||
(createNotificationSettingsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
|
||||
return data;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
userState.current = { id: '1', username: 'alice', is_admin: false };
|
||||
});
|
||||
|
||||
describe('NotificationSettings', () => {
|
||||
test('a listener sees their kinds with a toggle per channel and no admin heading', () => {
|
||||
setSettings({});
|
||||
render(NotificationSettings);
|
||||
expect(screen.getByRole('checkbox', { name: 'Request approved: Inbox' })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: 'New music arrived: Email' })).toBeChecked();
|
||||
expect(screen.queryByText('Library health')).toBeNull();
|
||||
});
|
||||
|
||||
test('an admin also sees library health under its own heading', () => {
|
||||
userState.current = { id: '1', username: 'root', is_admin: true };
|
||||
setSettings({ kinds: [...requesterKinds, ...adminKinds] });
|
||||
render(NotificationSettings);
|
||||
expect(screen.getByText('Library health')).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox', { name: 'Tracks gone missing: Email' })).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('toggling sends only that kind and channel, and shows the change at once', async () => {
|
||||
const data = setSettings({});
|
||||
(putNotificationSettings as ReturnType<typeof vi.fn>).mockResolvedValue(data);
|
||||
render(NotificationSettings);
|
||||
|
||||
await fireEvent.click(screen.getByRole('checkbox', { name: 'New music arrived: Email' }));
|
||||
|
||||
expect(putNotificationSettings).toHaveBeenCalledWith([{ kind: 'request_completed', email: false }]);
|
||||
const optimistic = setQueryData.mock.calls[0][1] as Settings;
|
||||
expect(optimistic.kinds.find((k) => k.kind === 'request_completed')?.email).toBe(false);
|
||||
});
|
||||
|
||||
test('a failed save puts the switch back and says so', async () => {
|
||||
const data = setSettings({});
|
||||
(putNotificationSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'));
|
||||
render(NotificationSettings);
|
||||
|
||||
await fireEvent.click(screen.getByRole('checkbox', { name: 'Request approved: Phone' }));
|
||||
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||
expect(setQueryData).toHaveBeenLastCalledWith(['notificationSettings'], data);
|
||||
});
|
||||
|
||||
test('inbox off disables phone and email for that kind', () => {
|
||||
setSettings({
|
||||
kinds: [{ kind: 'request_approved', admin_only: false, inbox: false, phone: true, email: true }]
|
||||
});
|
||||
render(NotificationSettings);
|
||||
expect(screen.getByRole('checkbox', { name: 'Request approved: Phone' })).toBeDisabled();
|
||||
expect(screen.getByRole('checkbox', { name: 'Request approved: Email' })).toBeDisabled();
|
||||
expect(screen.getByRole('checkbox', { name: 'Request approved: Inbox' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('no email address: the email column is off and points at the profile', () => {
|
||||
setSettings({ email_available: false, email_unavailable_reason: 'no_address' });
|
||||
render(NotificationSettings);
|
||||
expect(screen.getByRole('checkbox', { name: 'Request approved: Email' })).toBeDisabled();
|
||||
expect(screen.getByRole('link', { name: 'add an email address in your profile' })).toHaveAttribute(
|
||||
'href',
|
||||
'#profile'
|
||||
);
|
||||
});
|
||||
|
||||
test('no SMTP: an admin is sent to the SMTP settings, a listener is just told', () => {
|
||||
setSettings({ email_available: false, email_unavailable_reason: 'smtp_not_configured' });
|
||||
const { unmount } = render(NotificationSettings);
|
||||
expect(screen.getByTestId('email-unavailable')).toHaveTextContent("this server doesn't send email");
|
||||
expect(screen.queryByRole('link', { name: "SMTP isn't set up" })).toBeNull();
|
||||
unmount();
|
||||
|
||||
userState.current = { id: '1', username: 'root', is_admin: true };
|
||||
render(NotificationSettings);
|
||||
expect(screen.getByRole('link', { name: "SMTP isn't set up" })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/integrations'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
import { player } from '$lib/player/store.svelte';
|
||||
import { appName } from '$lib/branding';
|
||||
import MinstrelMark from './MinstrelMark.svelte';
|
||||
import NotificationBell from './NotificationBell.svelte';
|
||||
import PlayerBar from './PlayerBar.svelte';
|
||||
import SearchInput from './SearchInput.svelte';
|
||||
|
||||
@@ -92,11 +93,13 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3 md:gap-6 justify-self-end min-w-0">
|
||||
<div class="flex items-center gap-1 md:gap-3 justify-self-end min-w-0">
|
||||
<div class="hidden md:block min-w-0 max-w-xs flex-shrink">
|
||||
<SearchInput />
|
||||
</div>
|
||||
|
||||
<NotificationBell />
|
||||
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
bind:this={menuButtonRef}
|
||||
|
||||
@@ -24,6 +24,19 @@ vi.mock('$lib/auth/store.svelte', () => ({
|
||||
logout: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
// The bell's queries need a QueryClient the Shell tests do not provide; its own
|
||||
// behaviour is covered in NotificationBell.test.ts.
|
||||
vi.mock('$lib/api/notifications', async () => {
|
||||
const { mockQuery } = await import('../../test-utils/query');
|
||||
return {
|
||||
createUnreadCountQuery: () => mockQuery({ data: 0 }),
|
||||
createNotificationsQuery: () => mockQuery({ data: { items: [], unread_count: 0 } }),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
badgeLabel: () => ''
|
||||
};
|
||||
});
|
||||
|
||||
import Shell from './Shell.svelte';
|
||||
import { logout } from '$lib/auth/store.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
@@ -32,6 +32,12 @@ function onSystemRebuilt(): void {
|
||||
queryClient.invalidateQueries({ queryKey: qk.systemPlaylistsStatus() });
|
||||
}
|
||||
|
||||
// A new notification for this user (M489). The frame carries no content — it
|
||||
// is a nudge — so the inbox and badge refetch rather than trusting a payload.
|
||||
function onNotificationCreated(): void {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
}
|
||||
|
||||
// Wire once from +layout.svelte's mount. Opens the stream only while
|
||||
// authenticated and closes it on logout; EventSource auto-reconnects on
|
||||
// transient network drops.
|
||||
@@ -42,8 +48,10 @@ export function useServerEvents(): void {
|
||||
if (typeof EventSource === 'undefined') return;
|
||||
const es = new EventSource('/api/events/stream');
|
||||
es.addEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
||||
es.addEventListener('notification.created', onNotificationCreated);
|
||||
return () => {
|
||||
es.removeEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
||||
es.removeEventListener('notification.created', onNotificationCreated);
|
||||
es.close();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
import ServerVersion from '$lib/components/ServerVersion.svelte';
|
||||
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
|
||||
import NormalizationSetting from '$lib/components/NormalizationSetting.svelte';
|
||||
import NotificationSettings from '$lib/components/NotificationSettings.svelte';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -131,6 +132,8 @@
|
||||
email: profileForm.email,
|
||||
});
|
||||
pushToast('Profile saved.');
|
||||
// Whether email notifications are possible depends on the address.
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationSettings'] });
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
if (code === 'email_taken') pushToast('That email is already in use.', 'error');
|
||||
@@ -346,6 +349,8 @@
|
||||
<NormalizationSetting />
|
||||
</section>
|
||||
|
||||
<NotificationSettings />
|
||||
|
||||
<section class="space-y-4 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">ListenBrainz</h2>
|
||||
|
||||
@@ -550,7 +555,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Profile card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<section id="profile" class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Profile</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Your display name and recovery email.
|
||||
|
||||
@@ -25,6 +25,19 @@ vi.mock('$lib/api/listenbrainz', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// The Notifications section has its own tests (NotificationSettings.test.ts);
|
||||
// here it only needs a settled query so the page renders.
|
||||
vi.mock('$lib/api/notifications', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('$lib/api/notifications')>();
|
||||
const { mockQuery } = await import('../../test-utils/query');
|
||||
return {
|
||||
...mod,
|
||||
createNotificationSettingsQuery: () =>
|
||||
mockQuery({ data: { kinds: [], email_available: true } }),
|
||||
putNotificationSettings: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
// The Volume leveling setting loads on mount; keep it off the network.
|
||||
vi.mock('$lib/api/normalization', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('$lib/api/normalization')>();
|
||||
|
||||
@@ -11,6 +11,19 @@ vi.mock('$lib/api/listenbrainz', () => ({
|
||||
setListenBrainzEnabled: vi.fn()
|
||||
}));
|
||||
|
||||
// The Notifications section has its own tests (NotificationSettings.test.ts);
|
||||
// here it only needs a settled query so the page renders.
|
||||
vi.mock('$lib/api/notifications', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('$lib/api/notifications')>();
|
||||
const { mockQuery } = await import('../../test-utils/query');
|
||||
return {
|
||||
...mod,
|
||||
createNotificationSettingsQuery: () =>
|
||||
mockQuery({ data: { kinds: [], email_available: true } }),
|
||||
putNotificationSettings: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
// The Volume leveling setting loads on mount; keep it off the network.
|
||||
vi.mock('$lib/api/normalization', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('$lib/api/normalization')>();
|
||||
|
||||
Reference in New Issue
Block a user