feat(notify): own webhook as a full notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
The custom webhook could be entered but never switched on since the notification matrix replaced the old alarm channels. It is now a third column next to e-mail and Discord for every event and the daily summary; bundles go out as JSON (event "digest"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c154af251
commit
261579f0f2
10 files changed
+202
-56
No files matched your search
@@ -51,6 +51,7 @@ export function buildNotificationSettings(user: User): NotificationSettings {
|
||||
const hasLegacy = Array.isArray(user.alarmChannels);
|
||||
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
|
||||
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
|
||||
const dueWebhookDefault = hasLegacy ? user.alarmChannels!.includes('webhook') : false;
|
||||
const s = user.notificationSettings;
|
||||
return {
|
||||
paused: Boolean(s?.paused),
|
||||
@@ -59,18 +60,22 @@ export function buildNotificationSettings(user: User): NotificationSettings {
|
||||
due: {
|
||||
email: s?.events?.due?.email ?? dueEmailDefault,
|
||||
discord: s?.events?.due?.discord ?? dueDiscordDefault,
|
||||
webhook: s?.events?.due?.webhook ?? dueWebhookDefault,
|
||||
},
|
||||
shared: {
|
||||
email: s?.events?.shared?.email ?? true,
|
||||
discord: s?.events?.shared?.discord ?? false,
|
||||
webhook: s?.events?.shared?.webhook ?? false,
|
||||
},
|
||||
taskDone: {
|
||||
email: s?.events?.taskDone?.email ?? false,
|
||||
discord: s?.events?.taskDone?.discord ?? false,
|
||||
webhook: s?.events?.taskDone?.webhook ?? false,
|
||||
},
|
||||
watched: {
|
||||
email: s?.events?.watched?.email ?? true,
|
||||
discord: s?.events?.watched?.discord ?? true,
|
||||
webhook: s?.events?.watched?.webhook ?? false,
|
||||
},
|
||||
},
|
||||
dailySummary: {
|
||||
@@ -78,6 +83,7 @@ export function buildNotificationSettings(user: User): NotificationSettings {
|
||||
time: s?.dailySummary?.time || '07:00',
|
||||
email: s?.dailySummary?.email ?? true,
|
||||
discord: s?.dailySummary?.discord ?? false,
|
||||
webhook: s?.dailySummary?.webhook ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -250,9 +256,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// --- Multi-Channel Alarms State ---
|
||||
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
||||
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
|
||||
);
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
|
||||
buildNotificationSettings(currentUser)
|
||||
);
|
||||
@@ -270,7 +273,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
setDisplayName(currentUser.displayName || '');
|
||||
setEmail(currentUser.email || '');
|
||||
setAvatarUrl(currentUser.avatarUrl || '');
|
||||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
||||
setNotificationSettings(buildNotificationSettings(currentUser));
|
||||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
||||
@@ -278,13 +280,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
||||
}, [currentUser]);
|
||||
|
||||
const toggleAlarmChannel = (ch: string) => {
|
||||
setAlarmChannels((prev) =>
|
||||
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
|
||||
);
|
||||
};
|
||||
|
||||
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => {
|
||||
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook', value: boolean) => {
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
events: {
|
||||
@@ -386,7 +383,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const nextAlarmChannels = [
|
||||
...(notificationSettings.events.due.email ? ['email'] : []),
|
||||
...(notificationSettings.events.due.discord ? ['discord'] : []),
|
||||
...(alarmChannels.includes('webhook') ? ['webhook'] : []),
|
||||
...(notificationSettings.events.due.webhook ? ['webhook'] : []),
|
||||
];
|
||||
const updated = await api.updateSettings({
|
||||
alarmChannels: nextAlarmChannels,
|
||||
@@ -1119,13 +1116,14 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Routing Matrix: Events x (E-Mail / Discord) */}
|
||||
{/* Routing Matrix: Events x (E-Mail / Discord / own webhook) */}
|
||||
{(() => {
|
||||
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
|
||||
const hasCustomWebhook = Boolean(customWebhookUrl.trim());
|
||||
return (
|
||||
<>
|
||||
<div className="p-4 bg-canvas border border-border space-y-3">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-4 pb-2 border-b border-border">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 pb-2 border-b border-border">
|
||||
<span className="font-bold text-main">Ereignis</span>
|
||||
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
||||
<Mail className="w-3.5 h-3.5 text-accent" />
|
||||
@@ -1135,6 +1133,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<MessageSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Discord</span>
|
||||
</span>
|
||||
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
||||
<Globe className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Webhook</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
@@ -1143,7 +1145,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className="grid grid-cols-[1fr_auto_auto] items-center gap-4 py-2.5"
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 py-2.5"
|
||||
>
|
||||
<span className="font-medium text-main">{row.label}</span>
|
||||
<div className="w-16 flex justify-center">
|
||||
@@ -1165,6 +1167,18 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-16 flex justify-center">
|
||||
<ToggleSwitch
|
||||
checked={routing.webhook}
|
||||
disabled={!hasCustomWebhook}
|
||||
onChange={(v) => setEventChannel(row.key, 'webhook', v)}
|
||||
title={
|
||||
hasCustomWebhook
|
||||
? `${row.label} – Webhook`
|
||||
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1178,6 +1192,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
|
||||
</p>
|
||||
)}
|
||||
{!hasCustomWebhook && (
|
||||
<p className="text-[11px] text-faint">
|
||||
Webhook-Schalter werden aktiv, sobald unten eine eigene Webhook-URL eingetragen ist.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Summary */}
|
||||
@@ -1250,6 +1269,24 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">Webhook</span>
|
||||
<ToggleSwitch
|
||||
checked={notificationSettings.dailySummary.webhook}
|
||||
disabled={!hasCustomWebhook}
|
||||
onChange={(v) =>
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
dailySummary: { ...prev.dailySummary, webhook: v },
|
||||
}))
|
||||
}
|
||||
title={
|
||||
hasCustomWebhook
|
||||
? 'Tageszusammenfassung – Webhook'
|
||||
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1359,17 +1396,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
{/* Channel 3: Custom HTTP Webhook */}
|
||||
<div
|
||||
className={`p-4 border transition-colors space-y-3 bg-canvas ${
|
||||
alarmChannels.includes('webhook') ? 'border-accent' : 'border-border'
|
||||
customWebhookUrl.trim() ? 'border-accent' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alarmChannels.includes('webhook')}
|
||||
onChange={() => toggleAlarmChannel('webhook')}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Globe className="w-4 h-4 text-accent" />
|
||||
<div>
|
||||
<span className="font-bold text-main block">
|
||||
@@ -1377,10 +1408,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</span>
|
||||
<span className="text-[11px] text-muted">
|
||||
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder
|
||||
Automatisierungen.
|
||||
Automatisierungen. Welche Ereignisse hierher gehen, stellst du oben in der Spalte „Webhook“ ein.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -88,19 +88,53 @@ describe('UserSettingsView notification matrix', () => {
|
||||
paused: true,
|
||||
includeOwnWatched: true,
|
||||
events: {
|
||||
due: { email: true, discord: true },
|
||||
shared: { email: true, discord: false },
|
||||
taskDone: { email: true, discord: false },
|
||||
watched: { email: true, discord: false },
|
||||
due: { email: true, discord: true, webhook: false },
|
||||
shared: { email: true, discord: false, webhook: false },
|
||||
taskDone: { email: true, discord: false, webhook: false },
|
||||
watched: { email: true, discord: false, webhook: false },
|
||||
},
|
||||
dailySummary: {
|
||||
enabled: true,
|
||||
time: '08:30',
|
||||
email: true,
|
||||
discord: true,
|
||||
webhook: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('enables the webhook column once an own webhook URL is entered and saves it as due channel', async () => {
|
||||
render(
|
||||
<UserSettingsView
|
||||
currentUser={baseUser}
|
||||
activeTheme="dark"
|
||||
themes={[]}
|
||||
customThemes={[]}
|
||||
onThemeChange={() => {}}
|
||||
onCustomThemesChange={() => {}}
|
||||
onUserUpdated={() => {}}
|
||||
onBack={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Alarme'));
|
||||
const off = screen.getAllByTitle('Bitte zuerst unten eine eigene Webhook URL eintragen');
|
||||
expect(off.length).toBe(5);
|
||||
expect((off[0] as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/dein-server\.local/), {
|
||||
target: { value: 'https://hooks.example.com/mindsift' },
|
||||
});
|
||||
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Webhook'));
|
||||
fireEvent.click(screen.getByTitle('Freigabe erhalten – Webhook'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
|
||||
|
||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
|
||||
const patch = vi.mocked(api.updateSettings).mock.calls[0][0];
|
||||
expect(patch.customWebhookUrl).toBe('https://hooks.example.com/mindsift');
|
||||
expect(patch.alarmChannels).toEqual(['email', 'webhook']);
|
||||
expect(patch.notificationSettings?.events.due.webhook).toBe(true);
|
||||
expect(patch.notificationSettings?.events.shared.webhook).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user