feat: add ntfy and telegram notification workflows

This commit is contained in:
2025-11-09 20:08:14 +01:00
parent d4a28c9897
commit 539759ed60
6 changed files with 820 additions and 6 deletions

View File

@@ -57,6 +57,16 @@ const buildSelectionRange = (start, end, minDate) => {
};
};
const defaultNotificationSettings = {
ntfy: { enabled: false, topic: '', serverUrl: '' },
telegram: { enabled: false, chatId: '' }
};
const defaultNotificationCapabilities = {
ntfy: { enabled: false, serverUrl: '', topicPrefix: '' },
telegram: { enabled: false }
};
function App() {
const [session, setSession] = useState(null);
const [credentials, setCredentials] = useState({ email: '', password: '' });
@@ -83,6 +93,13 @@ function App() {
const [dirtyDialogSaving, setDirtyDialogSaving] = useState(false);
const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null });
const [activeRangePicker, setActiveRangePicker] = useState(null);
const [notificationSettings, setNotificationSettings] = useState(defaultNotificationSettings);
const [notificationCapabilities, setNotificationCapabilities] = useState(defaultNotificationCapabilities);
const [notificationDirty, setNotificationDirty] = useState(false);
const [notificationLoading, setNotificationLoading] = useState(false);
const [notificationSaving, setNotificationSaving] = useState(false);
const [notificationMessage, setNotificationMessage] = useState('');
const [notificationError, setNotificationError] = useState('');
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
@@ -192,7 +209,20 @@ function App() {
storeId: slot?.storeId ? String(slot.storeId) : '',
description: slot?.description || ''
}))
: []
: [],
notifications: {
ntfy: {
enabled: !!raw.notifications?.ntfy?.enabled,
serverUrl: raw.notifications?.ntfy?.serverUrl || 'https://ntfy.sh',
topicPrefix: raw.notifications?.ntfy?.topicPrefix || '',
username: raw.notifications?.ntfy?.username || '',
password: raw.notifications?.ntfy?.password || ''
},
telegram: {
enabled: !!raw.notifications?.telegram?.enabled,
botToken: raw.notifications?.telegram?.botToken || ''
}
}
};
}, []);
@@ -206,6 +236,13 @@ function App() {
setAdminSettingsLoading(false);
setAvailableCollapsed(true);
setInitializing(false);
setNotificationSettings(defaultNotificationSettings);
setNotificationCapabilities(defaultNotificationCapabilities);
setNotificationDirty(false);
setNotificationError('');
setNotificationMessage('');
setNotificationLoading(false);
setNotificationSaving(false);
}, []);
const handleUnauthorized = useCallback(() => {
@@ -290,6 +327,47 @@ function App() {
[handleUnauthorized, session?.token]
);
const loadNotificationSettings = useCallback(async () => {
if (!session?.token) {
return;
}
setNotificationLoading(true);
setNotificationError('');
try {
const response = await authorizedFetch('/api/notifications/settings');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setNotificationSettings({
ntfy: {
enabled: Boolean(data?.settings?.ntfy?.enabled),
topic: data?.settings?.ntfy?.topic || '',
serverUrl: data?.settings?.ntfy?.serverUrl || ''
},
telegram: {
enabled: Boolean(data?.settings?.telegram?.enabled),
chatId: data?.settings?.telegram?.chatId || ''
}
});
setNotificationCapabilities({
ntfy: {
enabled: Boolean(data?.capabilities?.ntfy?.enabled),
serverUrl: data?.capabilities?.ntfy?.serverUrl || '',
topicPrefix: data?.capabilities?.ntfy?.topicPrefix || ''
},
telegram: {
enabled: Boolean(data?.capabilities?.telegram?.enabled)
}
});
setNotificationDirty(false);
} catch (err) {
setNotificationError(`Benachrichtigungseinstellungen konnten nicht geladen werden: ${err.message}`);
} finally {
setNotificationLoading(false);
}
}, [authorizedFetch, session?.token]);
useEffect(() => {
if (!session?.token || !session.isAdmin) {
setAdminSettings(null);
@@ -445,6 +523,13 @@ function App() {
}
}, [session?.token, authorizedFetch]);
useEffect(() => {
if (!session?.token) {
return;
}
loadNotificationSettings();
}, [session?.token, loadNotificationSettings]);
const syncStoresWithProgress = useCallback(
async ({ block = false, reason = 'manual', startJob = true, reuseOverlay = false, tokenOverride } = {}) => {
const effectiveToken = tokenOverride || session?.token;
@@ -951,6 +1036,81 @@ function App() {
);
};
const handleNotificationFieldChange = (channel, field, value) => {
setNotificationSettings((prev) => {
const nextChannel = {
...prev[channel],
[field]: value
};
return {
...prev,
[channel]: nextChannel
};
});
setNotificationDirty(true);
};
const saveNotificationSettings = async () => {
if (!session?.token) {
return;
}
setNotificationSaving(true);
setNotificationError('');
setNotificationMessage('');
try {
const response = await authorizedFetch('/api/notifications/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notifications: notificationSettings })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setNotificationSettings({
ntfy: {
enabled: Boolean(data?.ntfy?.enabled),
topic: data?.ntfy?.topic || notificationSettings.ntfy.topic,
serverUrl: data?.ntfy?.serverUrl || notificationSettings.ntfy.serverUrl
},
telegram: {
enabled: Boolean(data?.telegram?.enabled),
chatId: data?.telegram?.chatId || notificationSettings.telegram.chatId
}
});
setNotificationDirty(false);
setNotificationMessage('Benachrichtigungseinstellungen gespeichert.');
setTimeout(() => setNotificationMessage(''), 4000);
} catch (err) {
setNotificationError(`Speichern der Benachrichtigungen fehlgeschlagen: ${err.message}`);
} finally {
setNotificationSaving(false);
}
};
const sendNotificationTest = async (channel) => {
if (!session?.token) {
return;
}
setNotificationError('');
setNotificationMessage('');
try {
const response = await authorizedFetch('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await response.json();
setNotificationMessage('Testbenachrichtigung gesendet.');
setTimeout(() => setNotificationMessage(''), 4000);
} catch (err) {
setNotificationError(`Testbenachrichtigung fehlgeschlagen: ${err.message}`);
}
};
const handleAdminSettingChange = (field, value, isNumber = false) => {
setAdminSettings((prev) => {
if (!prev) {
@@ -967,6 +1127,24 @@ function App() {
});
};
const handleAdminNotificationChange = (channel, field, value) => {
setAdminSettings((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
notifications: {
...(prev.notifications || {}),
[channel]: {
...(prev.notifications?.[channel] || {}),
[field]: value
}
}
};
});
};
const handleIgnoredSlotChange = (index, field, value) => {
setAdminSettings((prev) => {
if (!prev) {
@@ -1034,7 +1212,20 @@ function App() {
ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({
storeId: slot.storeId || '',
description: slot.description || ''
}))
})),
notifications: {
ntfy: {
enabled: !!adminSettings.notifications?.ntfy?.enabled,
serverUrl: adminSettings.notifications?.ntfy?.serverUrl || '',
topicPrefix: adminSettings.notifications?.ntfy?.topicPrefix || '',
username: adminSettings.notifications?.ntfy?.username || '',
password: adminSettings.notifications?.ntfy?.password || ''
},
telegram: {
enabled: !!adminSettings.notifications?.telegram?.enabled,
botToken: adminSettings.notifications?.telegram?.botToken || ''
}
}
};
const response = await authorizedFetch('/api/admin/settings', {
@@ -1240,6 +1431,165 @@ function App() {
)}
</div>
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-gray-800">Benachrichtigungen</h2>
<p className="text-sm text-gray-600">
Erhalte Hinweise über ntfy oder Telegram, sobald Slots gefunden oder gebucht wurden.
</p>
</div>
{notificationLoading && <span className="text-sm text-gray-500">Lade Einstellungen</span>}
</div>
{notificationError && (
<div className="mt-4 bg-red-100 border border-red-300 text-red-700 px-4 py-2 rounded">
{notificationError}
</div>
)}
{notificationMessage && (
<div className="mt-4 bg-green-100 border border-green-300 text-green-700 px-4 py-2 rounded">
{notificationMessage}
</div>
)}
<div className="grid md:grid-cols-2 gap-4 mt-4">
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-gray-800">ntfy</h3>
<p className="text-xs text-gray-500">
Push aufs Handy über die ntfy-App oder Browser (Themenkanal notwendig).
</p>
</div>
<label className="inline-flex items-center space-x-2 text-sm text-gray-700">
<input
type="checkbox"
checked={notificationSettings.ntfy.enabled}
onChange={(e) => handleNotificationFieldChange('ntfy', 'enabled', e.target.checked)}
disabled={!notificationCapabilities.ntfy.enabled}
className="h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span>Aktiv</span>
</label>
</div>
{!notificationCapabilities.ntfy.enabled ? (
<p className="text-sm text-gray-500">
Diese Option wurde vom Admin deaktiviert. Bitte frage nach, wenn du ntfy nutzen möchtest.
</p>
) : (
<div className="space-y-3">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Topic / Kanal</label>
<input
type="text"
value={notificationSettings.ntfy.topic}
onChange={(e) => handleNotificationFieldChange('ntfy', 'topic', e.target.value)}
placeholder="z. B. mein-pickup-topic"
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={!notificationSettings.ntfy.enabled}
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">
Eigener Server (optional)
</label>
<input
type="text"
value={notificationSettings.ntfy.serverUrl}
onChange={(e) => handleNotificationFieldChange('ntfy', 'serverUrl', e.target.value)}
placeholder={notificationCapabilities.ntfy.serverUrl || 'https://ntfy.sh'}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={!notificationSettings.ntfy.enabled}
/>
{notificationCapabilities.ntfy.topicPrefix && (
<p className="text-xs text-gray-500 mt-1">
Vom Admin vorgegebenes Präfix: {notificationCapabilities.ntfy.topicPrefix}
</p>
)}
</div>
<button
type="button"
onClick={() => sendNotificationTest('ntfy')}
disabled={!notificationSettings.ntfy.enabled || notificationSaving}
className="text-sm text-blue-600 hover:text-blue-800"
>
Test senden
</button>
</div>
)}
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-gray-800">Telegram</h3>
<p className="text-xs text-gray-500">
Nutze den Bot des Admins, um Nachrichten direkt in Telegram zu erhalten.
</p>
</div>
<label className="inline-flex items-center space-x-2 text-sm text-gray-700">
<input
type="checkbox"
checked={notificationSettings.telegram.enabled}
onChange={(e) => handleNotificationFieldChange('telegram', 'enabled', e.target.checked)}
disabled={!notificationCapabilities.telegram.enabled}
className="h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span>Aktiv</span>
</label>
</div>
{!notificationCapabilities.telegram.enabled ? (
<p className="text-sm text-gray-500">
Telegram-Benachrichtigungen sind derzeit deaktiviert oder der Bot ist nicht konfiguriert.
</p>
) : (
<div className="space-y-3">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Chat-ID</label>
<input
type="text"
value={notificationSettings.telegram.chatId}
onChange={(e) => handleNotificationFieldChange('telegram', 'chatId', e.target.value)}
placeholder="z. B. 123456789"
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={!notificationSettings.telegram.enabled}
/>
<p className="text-xs text-gray-500 mt-1">
Tipp: Schreibe dem Telegram-Bot und nutze @userinfobot oder ein Chat-ID-Tool.
</p>
</div>
<button
type="button"
onClick={() => sendNotificationTest('telegram')}
disabled={!notificationSettings.telegram.enabled || notificationSaving}
className="text-sm text-blue-600 hover:text-blue-800"
>
Test senden
</button>
</div>
)}
</div>
</div>
<div className="flex items-center justify-end mt-4 gap-3">
<button
type="button"
onClick={loadNotificationSettings}
className="text-sm text-gray-600 hover:text-gray-900"
disabled={notificationLoading}
>
Zurücksetzen
</button>
<button
type="button"
onClick={saveNotificationSettings}
disabled={!notificationDirty || notificationSaving}
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors disabled:opacity-60"
>
{notificationSaving ? 'Speichere...' : 'Benachrichtigungen speichern'}
</button>
</div>
</div>
<div className="flex justify-center mb-4 space-x-4">
<a
href="https://foodsharing.de/?page=dashboard"
@@ -1586,6 +1936,110 @@ function App() {
</div>
</div>
<div className="mt-6">
<h2 className="text-lg font-semibold text-purple-900 mb-2">Benachrichtigungen</h2>
<p className="text-sm text-gray-600 mb-4">
Lege fest, welche Kanäle zur Verfügung stehen und welche Zugangsdaten verwendet werden.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border border-purple-100 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-semibold text-purple-900">ntfy</h3>
<p className="text-xs text-gray-500">Server-basierte Push-Benachrichtigungen</p>
</div>
<label className="inline-flex items-center space-x-2 text-sm text-gray-600">
<input
type="checkbox"
checked={adminSettings.notifications?.ntfy?.enabled || false}
onChange={(e) => handleAdminNotificationChange('ntfy', 'enabled', e.target.checked)}
className="h-4 w-4 text-purple-600 rounded focus:ring-purple-500"
/>
<span>Aktiv</span>
</label>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Server-URL</label>
<input
type="text"
value={adminSettings.notifications?.ntfy?.serverUrl || ''}
onChange={(e) => handleAdminNotificationChange('ntfy', 'serverUrl', e.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="https://ntfy.sh"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Topic-Präfix</label>
<input
type="text"
value={adminSettings.notifications?.ntfy?.topicPrefix || ''}
onChange={(e) => handleAdminNotificationChange('ntfy', 'topicPrefix', e.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="optional"
/>
<p className="text-xs text-gray-500 mt-1">
Wird vor jedes User-Topic gesetzt (z.B. <code>pickup/</code>).
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Benutzername</label>
<input
type="text"
value={adminSettings.notifications?.ntfy?.username || ''}
onChange={(e) => handleAdminNotificationChange('ntfy', 'username', e.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="optional"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Passwort</label>
<input
type="password"
value={adminSettings.notifications?.ntfy?.password || ''}
onChange={(e) => handleAdminNotificationChange('ntfy', 'password', e.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="optional"
/>
</div>
</div>
</div>
</div>
<div className="bg-white border border-purple-100 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-semibold text-purple-900">Telegram</h3>
<p className="text-xs text-gray-500">Benachrichtigungen über einen Bot</p>
</div>
<label className="inline-flex items-center space-x-2 text-sm text-gray-600">
<input
type="checkbox"
checked={adminSettings.notifications?.telegram?.enabled || false}
onChange={(e) => handleAdminNotificationChange('telegram', 'enabled', e.target.checked)}
className="h-4 w-4 text-purple-600 rounded focus:ring-purple-500"
/>
<span>Aktiv</span>
</label>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Bot Token</label>
<input
type="text"
value={adminSettings.notifications?.telegram?.botToken || ''}
onChange={(e) => handleAdminNotificationChange('telegram', 'botToken', e.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="123456:ABCDEF"
/>
<p className="text-xs text-gray-500 mt-1">
Erstelle einen Bot via @BotFather und trage den Token hier ein.
</p>
</div>
</div>
</div>
</div>
<div className="border border-purple-200 rounded-lg bg-white p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-purple-900">Ignorierte Slots</h3>