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

@@ -9,6 +9,8 @@ const { readConfig, writeConfig } = require('./services/configStore');
const foodsharingClient = require('./services/foodsharingClient');
const { scheduleConfig } = require('./services/pickupScheduler');
const adminConfig = require('./services/adminConfig');
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
const notificationService = require('./services/notificationService');
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
@@ -388,6 +390,55 @@ app.post('/api/config', requireAuth, (req, res) => {
res.json({ success: true });
});
app.get('/api/notifications/settings', requireAuth, (req, res) => {
const userSettings = readNotificationSettings(req.session.profile.id);
const adminSettings = adminConfig.readSettings();
res.json({
settings: userSettings.notifications,
capabilities: {
ntfy: {
enabled: !!(
adminSettings.notifications?.ntfy?.enabled && adminSettings.notifications?.ntfy?.serverUrl
),
serverUrl: adminSettings.notifications?.ntfy?.serverUrl || '',
topicPrefix: adminSettings.notifications?.ntfy?.topicPrefix || ''
},
telegram: {
enabled: !!(
adminSettings.notifications?.telegram?.enabled && adminSettings.notifications?.telegram?.botToken
)
}
}
});
});
app.post('/api/notifications/settings', requireAuth, (req, res) => {
const payload = {
notifications: {
ntfy: {
enabled: !!req.body?.notifications?.ntfy?.enabled,
topic: req.body?.notifications?.ntfy?.topic || '',
serverUrl: req.body?.notifications?.ntfy?.serverUrl || ''
},
telegram: {
enabled: !!req.body?.notifications?.telegram?.enabled,
chatId: req.body?.notifications?.telegram?.chatId || ''
}
}
};
const updated = writeNotificationSettings(req.session.profile.id, payload);
res.json(updated.notifications);
});
app.post('/api/notifications/test', requireAuth, async (req, res) => {
try {
await notificationService.sendTestNotification(req.session.profile.id, req.body?.channel);
res.json({ success: true });
} catch (error) {
res.status(400).json({ error: error.message || 'Testbenachrichtigung fehlgeschlagen' });
}
});
app.get('/api/stores', requireAuth, async (req, res) => {
res.json(req.session.storesCache?.data || []);
});

View File

@@ -16,7 +16,20 @@ const DEFAULT_SETTINGS = {
storeId: '51450',
description: 'TVS'
}
]
],
notifications: {
ntfy: {
enabled: false,
serverUrl: 'https://ntfy.sh',
topicPrefix: '',
username: '',
password: ''
},
telegram: {
enabled: false,
botToken: ''
}
}
};
function ensureDir() {
@@ -45,6 +58,33 @@ function sanitizeIgnoredSlots(slots = []) {
.filter((slot) => slot.storeId);
}
function sanitizeString(value) {
if (typeof value === 'string') {
return value.trim();
}
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
function sanitizeNotifications(input = {}) {
const defaults = DEFAULT_SETTINGS.notifications;
return {
ntfy: {
enabled: !!(input?.ntfy?.enabled),
serverUrl: sanitizeString(input?.ntfy?.serverUrl || defaults.ntfy.serverUrl),
topicPrefix: sanitizeString(input?.ntfy?.topicPrefix || defaults.ntfy.topicPrefix),
username: sanitizeString(input?.ntfy?.username || defaults.ntfy.username),
password: sanitizeString(input?.ntfy?.password || defaults.ntfy.password)
},
telegram: {
enabled: !!(input?.telegram?.enabled),
botToken: sanitizeString(input?.telegram?.botToken || defaults.telegram.botToken)
}
};
}
function readSettings() {
ensureDir();
if (!fs.existsSync(SETTINGS_FILE)) {
@@ -65,7 +105,8 @@ function readSettings() {
parsed.storePickupCheckDelayMs,
DEFAULT_SETTINGS.storePickupCheckDelayMs
),
ignoredSlots: sanitizeIgnoredSlots(parsed.ignoredSlots)
ignoredSlots: sanitizeIgnoredSlots(parsed.ignoredSlots),
notifications: sanitizeNotifications(parsed.notifications)
};
} catch (error) {
console.error('Konnte Admin-Einstellungen nicht lesen:', error.message);
@@ -88,7 +129,11 @@ function writeSettings(patch = {}) {
ignoredSlots:
patch.ignoredSlots !== undefined
? sanitizeIgnoredSlots(patch.ignoredSlots)
: current.ignoredSlots
: current.ignoredSlots,
notifications:
patch.notifications !== undefined
? sanitizeNotifications(patch.notifications)
: current.notifications
};
ensureDir();

View File

@@ -0,0 +1,128 @@
const axios = require('axios');
const adminConfig = require('./adminConfig');
const { readNotificationSettings } = require('./userSettingsStore');
function formatDateLabel(dateInput) {
try {
const date = new Date(dateInput);
if (Number.isNaN(date.getTime())) {
return 'unbekanntes Datum';
}
return date.toLocaleString('de-DE', {
weekday: 'short',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch (_error) {
return 'unbekanntes Datum';
}
}
async function sendNtfyNotification(adminNtfy, userNtfy, payload) {
if (!adminNtfy?.enabled || !userNtfy?.enabled || !userNtfy.topic) {
return;
}
const server = (userNtfy.serverUrl || adminNtfy.serverUrl || 'https://ntfy.sh').replace(/\/+$/, '');
const topicPrefix = adminNtfy.topicPrefix ? `${adminNtfy.topicPrefix.replace(/\/+$/, '')}/` : '';
const topic = `${topicPrefix}${userNtfy.topic.replace(/^\//, '')}`;
const url = `${server}/${topic}`;
const headers = {
Title: payload.title,
Priority: payload.priority || 'default'
};
if (adminNtfy.username && adminNtfy.password) {
const credentials = Buffer.from(`${adminNtfy.username}:${adminNtfy.password}`).toString('base64');
headers.Authorization = `Basic ${credentials}`;
}
await axios.post(url, payload.message, { headers, timeout: 15000 });
}
async function sendTelegramNotification(adminTelegram, userTelegram, payload) {
if (!adminTelegram?.enabled || !adminTelegram.botToken || !userTelegram?.enabled || !userTelegram.chatId) {
return;
}
const endpoint = `https://api.telegram.org/bot${adminTelegram.botToken}/sendMessage`;
await axios.post(
endpoint,
{
chat_id: userTelegram.chatId,
text: `${payload.title}\n${payload.message}`,
disable_web_page_preview: true
},
{ timeout: 15000 }
);
}
async function notifyChannels(profileId, template) {
const adminSettings = adminConfig.readSettings();
const userSettings = readNotificationSettings(profileId);
try {
await Promise.allSettled([
sendNtfyNotification(adminSettings.notifications?.ntfy, userSettings.notifications?.ntfy, template),
sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, template)
]);
} catch (error) {
console.error('[NOTIFICATIONS] Versand fehlgeschlagen:', error.message);
}
}
async function sendSlotNotification({ profileId, storeName, pickupDate, onlyNotify, booked }) {
const dateLabel = formatDateLabel(pickupDate);
const title = onlyNotify
? `Slot verfügbar bei ${storeName}`
: booked
? `Slot gebucht bei ${storeName}`
: `Slot gefunden bei ${storeName}`;
const message = onlyNotify
? `Es wurde ein freier Slot am ${dateLabel} entdeckt.`
: booked
? `Der Slot am ${dateLabel} wurde erfolgreich gebucht.`
: `Es wurde ein Slot am ${dateLabel} gefunden.`;
await notifyChannels(profileId, {
title,
message,
priority: booked ? 'high' : 'default'
});
}
async function sendTestNotification(profileId, channel) {
const title = 'Pickup Benachrichtigung (Test)';
const message = 'Das ist eine Testnachricht. Bei Fragen wende dich bitte an den Admin.';
const adminSettings = adminConfig.readSettings();
const userSettings = readNotificationSettings(profileId);
const tasks = [];
if (!channel || channel === 'ntfy') {
tasks.push(
sendNtfyNotification(adminSettings.notifications?.ntfy, userSettings.notifications?.ntfy, {
title,
message,
priority: 'default'
})
);
}
if (!channel || channel === 'telegram') {
tasks.push(
sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
title,
message,
priority: 'default'
})
);
}
if (tasks.length === 0) {
throw new Error('Kein unterstützter Kanal oder Kanal deaktiviert.');
}
await Promise.all(tasks);
}
module.exports = {
sendSlotNotification,
sendTestNotification
};

View File

@@ -2,6 +2,7 @@ const cron = require('node-cron');
const foodsharingClient = require('./foodsharingClient');
const sessionStore = require('./sessionStore');
const { DEFAULT_SETTINGS } = require('./adminConfig');
const notificationService = require('./notificationService');
const weekdayMap = {
Montag: 'Monday',
@@ -37,7 +38,20 @@ function resolveSettings(settings) {
initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds)
? settings.initialDelayMaxSeconds
: DEFAULT_SETTINGS.initialDelayMaxSeconds,
ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots
ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots,
notifications: {
ntfy: {
enabled: !!settings.notifications?.ntfy?.enabled,
serverUrl: settings.notifications?.ntfy?.serverUrl || DEFAULT_SETTINGS.notifications.ntfy.serverUrl,
topicPrefix: settings.notifications?.ntfy?.topicPrefix || DEFAULT_SETTINGS.notifications.ntfy.topicPrefix,
username: settings.notifications?.ntfy?.username || DEFAULT_SETTINGS.notifications.ntfy.username,
password: settings.notifications?.ntfy?.password || DEFAULT_SETTINGS.notifications.ntfy.password
},
telegram: {
enabled: !!settings.notifications?.telegram?.enabled,
botToken: settings.notifications?.telegram?.botToken || DEFAULT_SETTINGS.notifications.telegram.botToken
}
}
};
}
@@ -147,6 +161,13 @@ async function processBooking(session, entry, pickup) {
if (entry.onlyNotify) {
console.log(`[INFO] Slot gefunden (nur Hinweis) für ${storeName} am ${readableDate}`);
await notificationService.sendSlotNotification({
profileId: session.profile.id,
storeName,
pickupDate: pickup.date,
onlyNotify: true,
booked: false
});
return;
}
@@ -159,6 +180,13 @@ async function processBooking(session, entry, pickup) {
}
await foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session);
console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`);
await notificationService.sendSlotNotification({
profileId: session.profile.id,
storeName,
pickupDate: pickup.date,
onlyNotify: false,
booked: true
});
} catch (error) {
console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message);
}

View File

@@ -0,0 +1,108 @@
const fs = require('fs');
const path = require('path');
const SETTINGS_DIR = path.join(__dirname, '..', 'config');
const DEFAULT_USER_SETTINGS = {
notifications: {
ntfy: {
enabled: false,
topic: '',
serverUrl: ''
},
telegram: {
enabled: false,
chatId: ''
}
}
};
function ensureDir() {
if (!fs.existsSync(SETTINGS_DIR)) {
fs.mkdirSync(SETTINGS_DIR, { recursive: true });
}
}
function getSettingsPath(profileId = 'shared') {
return path.join(SETTINGS_DIR, `${profileId}-notifications.json`);
}
function sanitizeBoolean(value) {
return !!value;
}
function sanitizeString(value) {
if (typeof value !== 'string') {
if (value === null || value === undefined) {
return '';
}
return String(value);
}
return value.trim();
}
function hydrateSettingsFile(profileId) {
ensureDir();
const filePath = getSettingsPath(profileId);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify(DEFAULT_USER_SETTINGS, null, 2));
}
return filePath;
}
function readNotificationSettings(profileId) {
const filePath = hydrateSettingsFile(profileId);
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
return {
notifications: {
ntfy: {
enabled: sanitizeBoolean(parsed?.notifications?.ntfy?.enabled),
topic: sanitizeString(parsed?.notifications?.ntfy?.topic),
serverUrl: sanitizeString(parsed?.notifications?.ntfy?.serverUrl)
},
telegram: {
enabled: sanitizeBoolean(parsed?.notifications?.telegram?.enabled),
chatId: sanitizeString(parsed?.notifications?.telegram?.chatId)
}
}
};
} catch (error) {
console.error(`[NOTIFICATIONS] Konnte Einstellungen für ${profileId} nicht lesen:`, error.message);
return JSON.parse(JSON.stringify(DEFAULT_USER_SETTINGS));
}
}
function writeNotificationSettings(profileId, patch = {}) {
const current = readNotificationSettings(profileId);
const next = {
notifications: {
ntfy: {
enabled: sanitizeBoolean(patch?.notifications?.ntfy?.enabled ?? current.notifications.ntfy.enabled),
topic: sanitizeString(patch?.notifications?.ntfy?.topic ?? current.notifications.ntfy.topic),
serverUrl: sanitizeString(
patch?.notifications?.ntfy?.serverUrl ?? current.notifications.ntfy.serverUrl
)
},
telegram: {
enabled: sanitizeBoolean(
patch?.notifications?.telegram?.enabled ?? current.notifications.telegram.enabled
),
chatId: sanitizeString(
patch?.notifications?.telegram?.chatId ?? current.notifications.telegram.chatId
)
}
}
};
const filePath = hydrateSettingsFile(profileId);
fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
return next;
}
module.exports = {
DEFAULT_USER_SETTINGS,
readNotificationSettings,
writeNotificationSettings
};

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>