From da1efa5a7418d41cb55aec8fdcae63a4a6dcd59d Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 11 Jun 2026 09:02:58 +0200 Subject: [PATCH] Added notifications for actual news --- .../notifications/HarheimerNotifications.kt | 5 + server/api/auth/register.post.js | 5 + server/api/contact.post.js | 9 +- server/api/termine-manage.post.js | 9 +- server/plugins/notification-scheduler.js | 38 ++ server/utils/notification-scheduler.js | 347 ++++++++++++++++++ server/utils/push-notifications.js | 102 ++++- 7 files changed, 497 insertions(+), 18 deletions(-) create mode 100644 server/plugins/notification-scheduler.js create mode 100644 server/utils/notification-scheduler.js diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt index 848a4f9..c49a23b 100644 --- a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt @@ -73,6 +73,11 @@ object HarheimerNotifications { private fun destinationRoute(data: Map): String = when (data["type"]) { "news" -> Destinations.MemberNews.route + "event", "events_today", "events_tomorrow" -> Destinations.Termine.route + "team_matches" -> Destinations.Spielplan.route + "birthdays" -> Destinations.MemberArea.route + "contact_request" -> Destinations.CmsContactRequests.route + "user_registration" -> Destinations.CmsBenutzer.route else -> Destinations.Home.route } } diff --git a/server/api/auth/register.post.js b/server/api/auth/register.post.js index ae5ad44..bd12a38 100644 --- a/server/api/auth/register.post.js +++ b/server/api/auth/register.post.js @@ -1,6 +1,7 @@ import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js' import { sendRegistrationNotification } from '../../utils/email-service.js' import { assertPasswordNotPwned } from '../../utils/hibp.js' +import { sendNewUserRegistrationPush } from '../../utils/push-notifications.js' export default defineEventHandler(async (event) => { try { @@ -59,6 +60,10 @@ export default defineEventHandler(async (event) => { users.push(newUser) await writeUsers(users) + sendNewUserRegistrationPush(newUser) + .then(result => console.info('Registrierungs-Push Ergebnis:', { userId: newUser.id, ...result })) + .catch(error => console.error('Registrierungs-Push fehlgeschlagen:', error)) + // Send notification to Vorstand/admin via central email service try { await sendRegistrationNotification({ name, email, phone }) diff --git a/server/api/contact.post.js b/server/api/contact.post.js index 7bf2307..1f84a92 100644 --- a/server/api/contact.post.js +++ b/server/api/contact.post.js @@ -3,6 +3,7 @@ import { promises as fs } from 'fs' import path from 'path' import { createContactRequest } from '../utils/contact-requests.js' import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js' +import { sendNewContactRequestPush } from '../utils/push-notifications.js' // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // filename is always a hardcoded constant ('config.json'), never user input @@ -111,13 +112,17 @@ export default defineEventHandler(async (event) => { } // Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt. - await createContactRequest({ + const contactRequest = { name: String(body.name).trim(), email: String(body.email).trim(), phone: body.phone ? String(body.phone).trim() : '', subject: String(body.subject).trim(), message: String(body.message).trim() - }) + } + await createContactRequest(contactRequest) + sendNewContactRequestPush(contactRequest) + .then(result => console.info('Kontaktanfrage-Push Ergebnis:', { subject: contactRequest.subject, ...result })) + .catch(error => console.error('Kontaktanfrage-Push fehlgeschlagen:', error)) const config = await loadConfig() const recipients = await collectRecipients(config) diff --git a/server/api/termine-manage.post.js b/server/api/termine-manage.post.js index 674f893..4d0ec43 100644 --- a/server/api/termine-manage.post.js +++ b/server/api/termine-manage.post.js @@ -1,5 +1,6 @@ import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js' import { saveTermin } from '../utils/termine.js' +import { sendNewEventPush } from '../utils/push-notifications.js' export default defineEventHandler(async (event) => { try { @@ -41,13 +42,17 @@ export default defineEventHandler(async (event) => { }) } - await saveTermin({ + const termin = { datum, uhrzeit: uhrzeit || '', titel, beschreibung: beschreibung || '', kategorie: kategorie || 'Sonstiges' - }) + } + await saveTermin(termin) + sendNewEventPush(termin) + .then(result => console.info('Termin-Push Ergebnis:', { titel: termin.titel, ...result })) + .catch(error => console.error('Termin-Push fehlgeschlagen:', error)) return { success: true, diff --git a/server/plugins/notification-scheduler.js b/server/plugins/notification-scheduler.js new file mode 100644 index 0000000..04b70f2 --- /dev/null +++ b/server/plugins/notification-scheduler.js @@ -0,0 +1,38 @@ +import { runNotificationSchedulerTick } from '../utils/notification-scheduler.js' +import { info as loggerInfo, error as loggerError } from '../utils/logger.js' + +const INTERVAL_MS = 60_000 +let timer = null +let running = false + +async function tick(reason = 'interval') { + if (running) return + running = true + try { + const result = await runNotificationSchedulerTick() + if (result?.dueUsers) { + loggerInfo('[notification-scheduler] Tick', { reason, ...result }) + } + } catch (error) { + loggerError('[notification-scheduler] Tick fehlgeschlagen:', { error }) + } finally { + running = false + } +} + +export default defineNitroPlugin((nitroApp) => { + if (process.env.NOTIFICATION_SCHEDULER_DISABLED === 'true') { + loggerInfo('[notification-scheduler] Deaktiviert') + return + } + + loggerInfo('[notification-scheduler] Gestartet') + timer = setInterval(() => tick(), INTERVAL_MS) + timer.unref?.() + tick('start') + + nitroApp.hooks.hookOnce('close', () => { + if (timer) clearInterval(timer) + timer = null + }) +}) diff --git a/server/utils/notification-scheduler.js b/server/utils/notification-scheduler.js new file mode 100644 index 0000000..9958ed6 --- /dev/null +++ b/server/utils/notification-scheduler.js @@ -0,0 +1,347 @@ +import { promises as fs } from 'fs' +import path from 'path' +import { readUsers, isHiddenUser } from './auth.js' +import { readMembers } from './members.js' +import { readTermine } from './termine.js' +import { readNews } from './news.js' +import { getServerDataPath } from './paths.js' +import { getDefaultSpielplanSeason, readSpielplanData } from './spielplan-data.js' +import { notificationSettingsForUser } from './notification-settings.js' +import { sendPushToUsers } from './push-notifications.js' +import { info as loggerInfo, error as loggerError } from './logger.js' + +const TIME_ZONE = 'Europe/Berlin' +const STATE_FILE = getServerDataPath('notification-scheduler-state.json') +const DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', { + timeZone: TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit' +}) +const TIME_FORMATTER = new Intl.DateTimeFormat('en-GB', { + timeZone: TIME_ZONE, + hour: '2-digit', + minute: '2-digit', + hour12: false +}) +const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('de-DE', { + timeZone: TIME_ZONE, + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false +}) + +function berlinDateKey(date = new Date()) { + return DATE_FORMATTER.format(date) +} + +function berlinTimeKey(date = new Date()) { + return TIME_FORMATTER.format(date) +} + +function addDays(date, days) { + const next = new Date(date) + next.setUTCDate(next.getUTCDate() + days) + return next +} + +function normalizeText(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[’'`]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +function slugify(value) { + return normalizeText(value).replace(/\s+/g, '-') +} + +function userDisplayName(user) { + return String(user?.name || `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || '').trim() +} + +function hasTimedSettings(user) { + const settings = notificationSettingsForUser(user) + return settings.eventsToday || settings.eventsTomorrow || settings.ownTeamMatches || + settings.allTeamMatches || settings.selectedTeamSlugs.length > 0 || settings.birthdays +} + +async function readState() { + try { + const parsed = JSON.parse(await fs.readFile(STATE_FILE, 'utf8')) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch (error) { + if (error?.code !== 'ENOENT') loggerError('[notification-scheduler] Status konnte nicht gelesen werden:', { error }) + return {} + } +} + +async function writeState(state) { + await fs.mkdir(path.dirname(STATE_FILE), { recursive: true }) + await fs.writeFile(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8') +} + +function pruneState(state, todayKey) { + const entries = Object.entries(state).filter(([key]) => key.startsWith(todayKey)) + return Object.fromEntries(entries) +} + +function runKey(dateKey, time, category) { + return `${dateKey}:${time}:${category}` +} + +function parseTerminDate(termin) { + const rawDate = String(termin?.datum || '').trim() + if (!rawDate) return null + const time = String(termin?.uhrzeit || '00:00').trim() || '00:00' + if (/^\d{4}-\d{2}-\d{2}$/.test(rawDate)) return new Date(`${rawDate}T${time.padStart(5, '0')}:00+02:00`) + const german = rawDate.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/) + if (german) { + const [, day, month, year] = german + return new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${time.padStart(5, '0')}:00+02:00`) + } + const parsed = new Date(rawDate) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function eventsOn(termine, dateKey) { + return termine + .map(termin => ({ termin, date: parseTerminDate(termin) })) + .filter(entry => entry.date && berlinDateKey(entry.date) === dateKey) + .map(entry => ({ title: entry.termin.titel, source: 'termin', item: entry.termin })) +} + +function expiringNewsOn(news, dateKey) { + return news + .filter(item => !item?.isHidden && item?.expiresAt) + .map(item => ({ item, date: new Date(item.expiresAt) })) + .filter(entry => !Number.isNaN(entry.date.getTime()) && berlinDateKey(entry.date) === dateKey) + .map(entry => ({ title: entry.item.title, source: 'news', item: entry.item })) +} + +function formatEventSummary(events, fallback) { + if (events.length === 1) return String(events[0].title || fallback).slice(0, 140) + return `${events.length} Einträge: ${events.slice(0, 3).map(event => event.title).filter(Boolean).join(', ')}`.slice(0, 140) +} + +function matchDate(row) { + const timestamp = Number(row?.Timestamp) + if (Number.isFinite(timestamp) && timestamp > 0) return new Date(timestamp * 1000) + const raw = String(row?.Termin || '').trim() + const match = raw.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}:\d{2}))?/) + if (!match) return null + const [, day, month, year, time = '00:00'] = match + const parsed = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${time}:00+02:00`) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function matchTeams(row) { + return [row?.HeimMannschaft, row?.GastMannschaft].map(value => String(value || '').trim()).filter(Boolean) +} + +function matchesOn(rows, dateKey) { + return rows + .map(row => ({ row, date: matchDate(row) })) + .filter(entry => entry.date && berlinDateKey(entry.date) === dateKey) +} + +function matchSummary(matches, fallback) { + if (matches.length === 1) { + const teams = matchTeams(matches[0].row).join(' - ') + const when = DATE_TIME_FORMATTER.format(matches[0].date) + return `${when}: ${teams}`.slice(0, 140) + } + return `${matches.length} Punktspiele am ${dateKeyToGerman(berlinDateKey(matches[0]?.date || new Date()))}` +} + +function dateKeyToGerman(dateKey) { + const [year, month, day] = String(dateKey).split('-') + return `${day}.${month}.${year}` +} + +function teamSlugsForMatch(match) { + return matchTeams(match.row).map(slugify) +} + +async function readTeamMembers(season) { + const fileNames = season ? [`mannschaften_${season}.csv`, 'mannschaften.csv'] : ['mannschaften.csv'] + for (const fileName of fileNames) { + try { + const raw = await fs.readFile(getServerDataPath('public-data', fileName), 'utf8') + const lines = raw.split(/\r?\n/).filter(line => line.trim()) + const rows = [] + for (const line of lines.slice(1)) { + const values = parseCsvLine(line) + rows.push({ team: values[0] || '', players: values[7] || '' }) + } + return rows + } catch (error) { + if (error?.code !== 'ENOENT') loggerError('[notification-scheduler] Mannschaften konnten nicht gelesen werden:', { fileName, error }) + } + } + return [] +} + +function parseCsvLine(line) { + const values = [] + let current = '' + let inQuotes = false + for (let index = 0; index < line.length; index += 1) { + const char = line[index] + const next = line[index + 1] + if (char === '"' && inQuotes && next === '"') { + current += '"' + index += 1 + } else if (char === '"') { + inQuotes = !inQuotes + } else if (char === ',' && !inQuotes) { + values.push(current.trim()) + current = '' + } else { + current += char + } + } + values.push(current.trim()) + return values +} + +function ownTeamSlugsForUser(user, teamRows) { + const name = normalizeText(userDisplayName(user)) + if (!name) return [] + return teamRows + .filter(row => normalizeText(row.players).includes(name)) + .map(row => slugify(row.team)) + .filter(Boolean) +} + +function userSelectedMatch(user, settings, matches) { + const selected = new Set(settings.selectedTeamSlugs.map(slugify)) + if (!selected.size) return false + return matches.some(match => teamSlugsForMatch(match).some(slug => selected.has(slug))) +} + +function userOwnTeamMatch(user, settings, matches, teamRows) { + if (!settings.ownTeamMatches) return false + const ownSlugs = new Set(ownTeamSlugsForUser(user, teamRows)) + if (!ownSlugs.size) return false + return matches.some(match => teamSlugsForMatch(match).some(slug => ownSlugs.has(slug))) +} + +function parseBirthday(value) { + const raw = String(value || '').trim() + if (!raw) return null + const iso = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/) + if (iso) return { month: Number(iso[2]), day: Number(iso[3]) } + const german = raw.match(/^(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?$/) + if (german) return { month: Number(german[2]), day: Number(german[1]) } + return null +} + +async function birthdaysOn(dateKey) { + const [, month, day] = dateKey.split('-').map(Number) + const [manualMembers, users] = await Promise.all([readMembers(), readUsers()]) + const people = [] + for (const member of manualMembers) { + if (member?.active === false) continue + const birthday = parseBirthday(member.geburtsdatum || member.birthday) + if (birthday?.month === month && birthday?.day === day) { + people.push(String(member.name || `${member.firstName || ''} ${member.lastName || ''}`.trim()).trim()) + } + } + for (const user of users) { + if (isHiddenUser(user) || user?.active === false) continue + if (user.visibility?.showBirthday === false) continue + const birthday = parseBirthday(user.geburtsdatum || user.birthday) + if (birthday?.month === month && birthday?.day === day) { + people.push(userDisplayName(user)) + } + } + return [...new Set(people.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de')) +} + +async function sendIfDue(state, dateKey, time, category, enabled, send) { + const key = runKey(dateKey, time, category) + if (!enabled || state[key]) return null + const result = await send() + state[key] = { at: new Date().toISOString(), result } + return result +} + +export async function runNotificationSchedulerTick(now = new Date()) { + const dateKey = berlinDateKey(now) + const time = berlinTimeKey(now) + const users = (await readUsers()).filter(user => !isHiddenUser(user) && hasTimedSettings(user)) + const dueUsers = users.filter(user => notificationSettingsForUser(user).notificationTime === time) + if (!dueUsers.length) return { dueUsers: 0, time, dateKey } + + let state = pruneState(await readState(), dateKey) + const tomorrowKey = berlinDateKey(addDays(now, 1)) + const [termine, news, season] = await Promise.all([readTermine(), readNews(), getDefaultSpielplanSeason()]) + const [spielplan, teamRows] = await Promise.all([readSpielplanData({ season }), readTeamMembers(season)]) + const todayEvents = [...eventsOn(termine, dateKey), ...expiringNewsOn(news, dateKey)] + const tomorrowEvents = [...eventsOn(termine, tomorrowKey), ...expiringNewsOn(news, tomorrowKey)] + const todayMatches = matchesOn(spielplan.data || [], dateKey) + const tomorrowMatches = matchesOn(spielplan.data || [], tomorrowKey) + const todaysBirthdays = await birthdaysOn(dateKey) + const allMatches = [...todayMatches, ...tomorrowMatches] + const results = {} + + results.eventsToday = await sendIfDue(state, dateKey, time, 'eventsToday', todayEvents.length > 0, () => sendPushToUsers({ + title: 'Termine heute', + body: formatEventSummary(todayEvents, 'Heute stehen Termine an.'), + data: { type: 'events_today', date: dateKey }, + predicate: (user, settings) => settings.notificationTime === time && settings.eventsToday, + failureLabel: 'FCM Termine-heute-Push' + })) + + results.eventsTomorrow = await sendIfDue(state, dateKey, time, 'eventsTomorrow', tomorrowEvents.length > 0, () => sendPushToUsers({ + title: 'Termine morgen', + body: formatEventSummary(tomorrowEvents, 'Morgen stehen Termine an.'), + data: { type: 'events_tomorrow', date: tomorrowKey }, + predicate: (user, settings) => settings.notificationTime === time && settings.eventsTomorrow, + failureLabel: 'FCM Termine-morgen-Push' + })) + + results.allTeamMatches = await sendIfDue(state, dateKey, time, 'allTeamMatches', allMatches.length > 0, () => sendPushToUsers({ + title: 'Punktspiele', + body: matchSummary(allMatches, 'Es stehen Punktspiele an.'), + data: { type: 'team_matches', date: dateKey }, + predicate: (user, settings) => settings.notificationTime === time && settings.allTeamMatches, + failureLabel: 'FCM Punktspiele-Push' + })) + + results.selectedTeamMatches = await sendIfDue(state, dateKey, time, 'selectedTeamMatches', allMatches.length > 0, () => sendPushToUsers({ + title: 'Punktspiele deiner Auswahl', + body: 'Für eine abonnierte Mannschaft stehen Punktspiele an.', + data: { type: 'team_matches', date: dateKey }, + predicate: (user, settings) => settings.notificationTime === time && !settings.allTeamMatches && userSelectedMatch(user, settings, allMatches), + failureLabel: 'FCM Mannschaftsauswahl-Push' + })) + + results.ownTeamMatches = await sendIfDue(state, dateKey, time, 'ownTeamMatches', allMatches.length > 0, () => sendPushToUsers({ + title: 'Punktspiel deiner Mannschaft', + body: 'Für deine Mannschaft steht ein Punktspiel an.', + data: { type: 'team_matches', date: dateKey }, + predicate: (user, settings) => settings.notificationTime === time && !settings.allTeamMatches && !userSelectedMatch(user, settings, allMatches) && userOwnTeamMatch(user, settings, allMatches, teamRows), + failureLabel: 'FCM eigene-Mannschaft-Push' + })) + + results.birthdays = await sendIfDue(state, dateKey, time, 'birthdays', todaysBirthdays.length > 0, () => sendPushToUsers({ + title: 'Geburtstage heute', + body: todaysBirthdays.length === 1 ? `${todaysBirthdays[0]} hat heute Geburtstag.` : `${todaysBirthdays.length} Mitglieder haben heute Geburtstag.`, + data: { type: 'birthdays', date: dateKey }, + predicate: (_user, settings) => settings.notificationTime === time && settings.birthdays, + failureLabel: 'FCM Geburtstags-Push' + })) + + await writeState(state) + loggerInfo('[notification-scheduler] Lauf abgeschlossen', { dateKey, time, dueUsers: dueUsers.length, results }) + return { dateKey, time, dueUsers: dueUsers.length, results } +} diff --git a/server/utils/push-notifications.js b/server/utils/push-notifications.js index a900563..2cd9b18 100644 --- a/server/utils/push-notifications.js +++ b/server/utils/push-notifications.js @@ -1,7 +1,7 @@ import crypto from 'crypto' import { promises as fs } from 'fs' import path from 'path' -import { readUsers, writeUsers, isHiddenUser } from './auth.js' +import { readUsers, writeUsers, isHiddenUser, migrateUserRoles } from './auth.js' import { notificationSettingsForUser } from './notification-settings.js' const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging' @@ -132,11 +132,25 @@ async function sendFcmMessage({ serviceAccount, accessToken, token, title, body, } } -export async function sendNewNewsPush(news) { +function notificationIdFor(value) { + return String(value || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString() +} + +function userRoles(user) { + const migrated = migrateUserRoles({ ...(user || {}) }) + return Array.isArray(migrated.roles) ? migrated.roles : [] +} + +function isVorstandUser(user) { + const roles = userRoles(user) + return roles.includes('admin') || roles.includes('vorstand') +} + +export async function sendPushToUsers({ title, body, data = {}, predicate, failureLabel = 'FCM-Push' }) { const serviceAccount = await readServiceAccount() if (!serviceAccount) { console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.') - return { sent: 0, skipped: true } + return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: true } } const accessToken = await getAccessToken(serviceAccount) const users = await readUsers() @@ -146,32 +160,29 @@ export async function sendNewNewsPush(news) { let recipients = 0 let tokenCount = 0 let changed = false - const title = 'Neue News' - const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120) - const data = { - type: 'news', - newsId: String(news.id || ''), - title, - body, - notificationId: String(news.id || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString() + const payload = { + ...Object.fromEntries(Object.entries(data).map(([key, value]) => [key, String(value ?? '')])), + title: String(title || 'Harheimer TC'), + body: String(body || '').slice(0, 240), + notificationId: String(data.notificationId || notificationIdFor(`${data.type || 'push'}:${title}:${body}`)) } for (const user of users) { if (isHiddenUser(user)) continue const settings = notificationSettingsForUser(user) - if (!settings.newNews) continue + if (predicate && !predicate(user, settings)) continue recipients += 1 const tokens = pushTokensForUser(user) tokenCount += tokens.length const validTokens = [] for (const entry of tokens) { try { - await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data }) + await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data: payload }) sent += 1 validTokens.push(entry) } catch (error) { failed += 1 - console.error('FCM News-Push fehlgeschlagen:', error.message) + console.error(`${failureLabel} fehlgeschlagen:`, error.message) if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) { validTokens.push(entry) } else { @@ -188,3 +199,66 @@ export async function sendNewNewsPush(news) { if (changed) await writeUsers(users) return { sent, failed, removed, recipients, tokenCount, skipped: false } } + +export async function sendNewNewsPush(news) { + const title = 'Neue News' + const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120) + return sendPushToUsers({ + title, + body, + data: { + type: 'news', + newsId: String(news.id || ''), + notificationId: notificationIdFor(news.id || Date.now()) + }, + predicate: (_user, settings) => settings.newNews, + failureLabel: 'FCM News-Push' + }) +} + +export async function sendNewEventPush(termin) { + const title = 'Neuer Termin' + const body = String(termin?.titel || 'Ein neuer Termin wurde eingetragen.').slice(0, 120) + return sendPushToUsers({ + title, + body, + data: { + type: 'event', + date: termin?.datum || '', + notificationId: notificationIdFor(`event:${termin?.datum || ''}:${termin?.titel || ''}`) + }, + predicate: (_user, settings) => settings.newEvents, + failureLabel: 'FCM Termin-Push' + }) +} + +export async function sendNewContactRequestPush(contactRequest) { + const title = 'Neue Kontaktanfrage' + const body = String(contactRequest?.subject || contactRequest?.name || 'Eine neue Kontaktanfrage ist eingegangen.').slice(0, 120) + return sendPushToUsers({ + title, + body, + data: { + type: 'contact_request', + notificationId: notificationIdFor(`contact:${contactRequest?.email || ''}:${contactRequest?.subject || ''}:${Date.now()}`) + }, + predicate: (user, settings) => isVorstandUser(user) && settings.newContactRequest, + failureLabel: 'FCM Kontaktanfrage-Push' + }) +} + +export async function sendNewUserRegistrationPush(registration) { + const title = 'Neue Benutzerregistrierung' + const body = String(registration?.name || registration?.email || 'Eine neue Registrierung wartet auf Freigabe.').slice(0, 120) + return sendPushToUsers({ + title, + body, + data: { + type: 'user_registration', + userId: registration?.id || '', + notificationId: notificationIdFor(`registration:${registration?.id || registration?.email || Date.now()}`) + }, + predicate: (user, settings) => isVorstandUser(user) && settings.newUserRegistration, + failureLabel: 'FCM Registrierungs-Push' + }) +}