feat: add ntfy and telegram notification workflows
This commit is contained in:
51
server.js
51
server.js
@@ -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 || []);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
128
services/notificationService.js
Normal file
128
services/notificationService.js
Normal 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
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
108
services/userSettingsStore.js
Normal file
108
services/userSettingsStore.js
Normal 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
|
||||
};
|
||||
458
src/App.js
458
src/App.js
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user