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:
2026-10-08 07:25:31 -04:00
co-authored by Claude Opus 5.5
parent 956058d4a0
commit 87f8ed6147
7 changed files with 346 additions and 5 deletions
+53
View File
@@ -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'
};