feat(web): notification settings per kind and channel (#5345, web half)
- A Notifications section on Settings has a row per kind and a toggle each for Inbox, Phone and Email. Labels are short, menu-style. - Admin kinds sit under "Library health", for admins only. - Toggles are optimistic and send only the kind and channel touched. A failed save reverts unless something newer has happened (snippet #5106's generation counter). - With the inbox off, phone and email are disabled: they ride on it. - When email isn't usable, one line says why. With no address it links to the profile. With no SMTP an admin gets a link to Integrations and a listener is simply told. Saving the profile refreshes the settings so the line clears. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,3 +71,56 @@ 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'
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,6 @@ vi.mock('$lib/auth/store.svelte', () => ({
|
||||
|
||||
// The bell's queries need a QueryClient the Shell tests do not provide; its own
|
||||
// behaviour is covered in NotificationBell.test.ts.
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
|
||||
});
|
||||
vi.mock('$lib/api/notifications', async () => {
|
||||
const { mockQuery } = await import('../../test-utils/query');
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user