Added notifications for actual news
This commit is contained in:
@@ -73,6 +73,11 @@ object HarheimerNotifications {
|
||||
|
||||
private fun destinationRoute(data: Map<String, String>): 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
server/plugins/notification-scheduler.js
Normal file
38
server/plugins/notification-scheduler.js
Normal file
@@ -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
|
||||
})
|
||||
})
|
||||
347
server/utils/notification-scheduler.js
Normal file
347
server/utils/notification-scheduler.js
Normal file
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user