Files
Pickup-Config/services/notificationService.js
2026-02-09 20:41:42 +01:00

365 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const axios = require('axios');
const adminConfig = require('./adminConfig');
const { readNotificationSettings } = require('./userSettingsStore');
const DISPLAY_TIME_ZONE = 'Europe/Berlin';
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',
timeZone: DISPLAY_TIME_ZONE
});
} catch (_error) {
return 'unbekanntes Datum';
}
}
function formatDateOnly(dateInput) {
try {
const date = new Date(dateInput);
if (Number.isNaN(date.getTime())) {
return 'unbekanntes Datum';
}
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: DISPLAY_TIME_ZONE
});
} catch (_error) {
return 'unbekanntes Datum';
}
}
function extractFirstName(profileName) {
if (!profileName || typeof profileName !== 'string') {
return null;
}
const trimmed = profileName.trim();
if (!trimmed) {
return null;
}
if (trimmed.includes(',')) {
const parts = trimmed.split(',').map((part) => part.trim()).filter(Boolean);
if (parts.length > 1) {
return parts[1].split(/\s+/)[0] || null;
}
}
return trimmed.split(/\s+/)[0] || null;
}
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 sanitizedPrefix = (adminNtfy.topicPrefix || '').replace(/^-+|-+$/g, '');
const sanitizedTopic = (userNtfy.topic || '').replace(/^-+|-+$/g, '');
const topic = `${sanitizedPrefix}${sanitizedTopic}` || sanitizedPrefix || sanitizedTopic;
let url = `${server}/${topic}`;
if (payload.title) {
const delimiter = url.includes('?') ? '&' : '?';
url = `${url}${delimiter}title=${encodeURIComponent(payload.title)}`;
}
const headers = {
Priority: payload.priority || 'default',
'Content-Type': 'text/plain; charset=utf-8'
};
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`;
const profileLabel = extractFirstName(payload?.profileName);
const messageParts = [
payload?.title ? `*${payload.title}*` : null,
profileLabel ? `Profil: ${profileLabel}` : null,
payload?.message || null
].filter(Boolean);
await axios.post(
endpoint,
{
chat_id: userTelegram.chatId,
text: messageParts.join('\n'),
parse_mode: payload?.title ? 'Markdown' : undefined,
disable_web_page_preview: true
},
{ timeout: 15000 }
);
}
async function sendAdminTelegramNotification(payload) {
const adminSettings = adminConfig.readSettings();
const adminTelegram = adminSettings.notifications?.telegram;
if (!adminTelegram?.enabled || !adminTelegram.botToken || !adminTelegram.chatId) {
return;
}
await sendTelegramNotification(
adminTelegram,
{ enabled: true, chatId: adminTelegram.chatId },
payload
);
}
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, profileName, storeName, pickupDate, onlyNotify, booked, storeId }) {
const dateLabel = formatDateLabel(pickupDate);
const title = onlyNotify
? `Slot verfügbar bei ${storeName}`
: booked
? `Slot gebucht bei ${storeName}`
: `Slot gefunden bei ${storeName}`;
const baseMessage = onlyNotify
? `Es wurde ein freier Slot am ${dateLabel} entdeckt.`
: booked
? `Der Slot am ${dateLabel} wurde erfolgreich gebucht.`
: `Es wurde ein Slot am ${dateLabel} gefunden.`;
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
const fullMessage = storeLink ? `${baseMessage}\n${storeLink}` : baseMessage;
await notifyChannels(profileId, {
title,
message: fullMessage,
link: storeLink,
priority: booked ? 'high' : 'default',
profileName
});
}
function formatStoreWatchStatus(status) {
if (status === 1) {
return 'Suchend';
}
if (status === 0) {
return 'Nicht suchend';
}
return 'Status unbekannt';
}
async function sendStoreWatchNotification({ profileId, profileName, storeName, storeId, regionName }) {
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
const title = `Team sucht Verstärkung: ${storeName}`;
const regionText = regionName ? ` (${regionName})` : '';
const messageBase = `Der Betrieb${regionText} sucht wieder aktiv neue Teammitglieder.`;
const message = storeLink ? `${messageBase}\n${storeLink}` : messageBase;
await notifyChannels(profileId, {
title,
message,
link: storeLink,
priority: 'high',
profileName
});
}
async function sendStoreWatchSummaryNotification({ profileId, profileName, entries = [], triggeredBy = 'manual' }) {
if (!profileId || !Array.isArray(entries) || entries.length === 0) {
return;
}
const lines = entries
.map((entry) => {
const regionSuffix = entry.regionName ? ` (${entry.regionName})` : '';
const statusLabel = formatStoreWatchStatus(entry.status);
const timestamp = entry.checkedAt ? ` Stand ${formatDateLabel(entry.checkedAt)}` : '';
const errorLabel = entry.error ? ` Fehler: ${entry.error}` : '';
return `${entry.storeName || `Store ${entry.storeId}`}${regionSuffix}: ${statusLabel}${timestamp}${errorLabel}`;
})
.join('\n');
const prefix =
triggeredBy === 'manual'
? 'Manuell angestoßene Store-Watch-Aktualisierung abgeschlossen:'
: 'Store-Watch-Prüfung abgeschlossen:';
const title =
triggeredBy === 'manual' ? 'Store-Watch-Aktualisierung' : 'Store-Watch-Prüfung';
const message = `${prefix}\n${lines}`;
await notifyChannels(profileId, {
title,
message,
priority: 'default',
profileName
});
}
async function sendDesiredWindowMissedNotification({ profileId, profileName, storeName, desiredWindowLabel }) {
if (!profileId) {
return;
}
const title = `Keine Slots gefunden: ${storeName}`;
const messageBase = desiredWindowLabel
? `Für den gewünschten Zeitraum ${desiredWindowLabel} konnte kein Slot gefunden werden.`
: 'Für den gewünschten Zeitraum konnte kein Slot gefunden werden.';
const message = `${messageBase} Der Eintrag wurde deaktiviert.`;
await notifyChannels(profileId, {
title,
message,
link: null,
priority: 'default',
profileName
});
}
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);
}
async function sendDormantPickupWarning({ profileId, profileName, storeName, storeId, reasonLines = [] }) {
if (!profileId || !Array.isArray(reasonLines) || reasonLines.length === 0) {
return;
}
const adminSettings = adminConfig.readSettings();
const userSettings = readNotificationSettings(profileId);
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
const title = `Prüfung fällig: ${storeName}`;
const messageBody = reasonLines.join('\n');
const message = storeLink ? `${messageBody}\n${storeLink}` : messageBody;
await sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
title,
message,
priority: 'high',
profileName
});
}
async function sendJournalReminderNotification({
profileId,
profileName,
storeName,
pickupDate,
reminderDate,
note
}) {
if (!profileId) {
return;
}
const adminSettings = adminConfig.readSettings();
const userSettings = readNotificationSettings(profileId);
const title = `Erinnerung: Abholung bei ${storeName}`;
const reminderLabel = formatDateOnly(reminderDate);
const pickupLabel = formatDateOnly(pickupDate);
const noteLine = note ? `Notiz: ${note}` : null;
const messageLines = [
`Geplante Abholung: ${pickupLabel}`,
`Erinnerungstermin: ${reminderLabel}`,
noteLine
].filter(Boolean);
await sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
title,
message: messageLines.join('\n'),
priority: 'default',
profileName
});
}
async function sendAdminBookingErrorNotification({ profileId, profileEmail, storeName, storeId, pickupDate, error }) {
const dateLabel = formatDateLabel(pickupDate);
const storeLabel = storeName || storeId || 'Unbekannter Store';
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
const profileLabel = profileEmail || profileId || 'Unbekanntes Profil';
const messageLines = [
`Buchung fehlgeschlagen für ${storeLabel} am ${dateLabel}.`,
`Profil: ${profileLabel}.`,
`Fehler: ${error || 'Unbekannter Fehler'}.`
];
if (storeLink) {
messageLines.push(storeLink);
}
await sendAdminTelegramNotification({
title: 'Fehler beim Slot-Buchen',
message: messageLines.join('\n'),
priority: 'high'
});
}
async function sendAdminSessionErrorNotification({
profileId,
profileEmail,
profileName,
sessionId,
error,
label
}) {
const profileLabel = profileEmail || profileId || 'Unbekanntes Profil';
const messageLines = [
'Session-Login fehlgeschlagen.',
`Profil: ${profileLabel}.`,
profileName ? `Name: ${profileName}.` : null,
sessionId ? `Session: ${sessionId}.` : null,
label ? `Kontext: ${label}.` : null,
`Fehler: ${error || 'Unbekannter Fehler'}.`
].filter(Boolean);
await sendAdminTelegramNotification({
title: 'Fehler beim Session-Login',
message: messageLines.join('\n'),
priority: 'high'
});
}
module.exports = {
sendSlotNotification,
sendStoreWatchNotification,
sendStoreWatchSummaryNotification,
sendTestNotification,
sendDesiredWindowMissedNotification,
sendDormantPickupWarning,
sendJournalReminderNotification,
sendAdminBookingErrorNotification,
sendAdminSessionErrorNotification
};