dev #42
@@ -73,6 +73,11 @@ object HarheimerNotifications {
|
|||||||
|
|
||||||
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
||||||
"news" -> Destinations.MemberNews.route
|
"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
|
else -> Destinations.Home.route
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js'
|
import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js'
|
||||||
import { sendRegistrationNotification } from '../../utils/email-service.js'
|
import { sendRegistrationNotification } from '../../utils/email-service.js'
|
||||||
import { assertPasswordNotPwned } from '../../utils/hibp.js'
|
import { assertPasswordNotPwned } from '../../utils/hibp.js'
|
||||||
|
import { sendNewUserRegistrationPush } from '../../utils/push-notifications.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -59,6 +60,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
users.push(newUser)
|
users.push(newUser)
|
||||||
await writeUsers(users)
|
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
|
// Send notification to Vorstand/admin via central email service
|
||||||
try {
|
try {
|
||||||
await sendRegistrationNotification({ name, email, phone })
|
await sendRegistrationNotification({ name, email, phone })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { promises as fs } from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { createContactRequest } from '../utils/contact-requests.js'
|
import { createContactRequest } from '../utils/contact-requests.js'
|
||||||
import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.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
|
// 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
|
// 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.
|
// Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt.
|
||||||
await createContactRequest({
|
const contactRequest = {
|
||||||
name: String(body.name).trim(),
|
name: String(body.name).trim(),
|
||||||
email: String(body.email).trim(),
|
email: String(body.email).trim(),
|
||||||
phone: body.phone ? String(body.phone).trim() : '',
|
phone: body.phone ? String(body.phone).trim() : '',
|
||||||
subject: String(body.subject).trim(),
|
subject: String(body.subject).trim(),
|
||||||
message: String(body.message).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 config = await loadConfig()
|
||||||
const recipients = await collectRecipients(config)
|
const recipients = await collectRecipients(config)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { saveTermin } from '../utils/termine.js'
|
import { saveTermin } from '../utils/termine.js'
|
||||||
|
import { sendNewEventPush } from '../utils/push-notifications.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -41,13 +42,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveTermin({
|
const termin = {
|
||||||
datum,
|
datum,
|
||||||
uhrzeit: uhrzeit || '',
|
uhrzeit: uhrzeit || '',
|
||||||
titel,
|
titel,
|
||||||
beschreibung: beschreibung || '',
|
beschreibung: beschreibung || '',
|
||||||
kategorie: kategorie || 'Sonstiges'
|
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 {
|
return {
|
||||||
success: true,
|
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 crypto from 'crypto'
|
||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
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'
|
import { notificationSettingsForUser } from './notification-settings.js'
|
||||||
|
|
||||||
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
|
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()
|
const serviceAccount = await readServiceAccount()
|
||||||
if (!serviceAccount) {
|
if (!serviceAccount) {
|
||||||
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
|
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 accessToken = await getAccessToken(serviceAccount)
|
||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
@@ -146,32 +160,29 @@ export async function sendNewNewsPush(news) {
|
|||||||
let recipients = 0
|
let recipients = 0
|
||||||
let tokenCount = 0
|
let tokenCount = 0
|
||||||
let changed = false
|
let changed = false
|
||||||
const title = 'Neue News'
|
const payload = {
|
||||||
const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120)
|
...Object.fromEntries(Object.entries(data).map(([key, value]) => [key, String(value ?? '')])),
|
||||||
const data = {
|
title: String(title || 'Harheimer TC'),
|
||||||
type: 'news',
|
body: String(body || '').slice(0, 240),
|
||||||
newsId: String(news.id || ''),
|
notificationId: String(data.notificationId || notificationIdFor(`${data.type || 'push'}:${title}:${body}`))
|
||||||
title,
|
|
||||||
body,
|
|
||||||
notificationId: String(news.id || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (isHiddenUser(user)) continue
|
if (isHiddenUser(user)) continue
|
||||||
const settings = notificationSettingsForUser(user)
|
const settings = notificationSettingsForUser(user)
|
||||||
if (!settings.newNews) continue
|
if (predicate && !predicate(user, settings)) continue
|
||||||
recipients += 1
|
recipients += 1
|
||||||
const tokens = pushTokensForUser(user)
|
const tokens = pushTokensForUser(user)
|
||||||
tokenCount += tokens.length
|
tokenCount += tokens.length
|
||||||
const validTokens = []
|
const validTokens = []
|
||||||
for (const entry of tokens) {
|
for (const entry of tokens) {
|
||||||
try {
|
try {
|
||||||
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data })
|
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data: payload })
|
||||||
sent += 1
|
sent += 1
|
||||||
validTokens.push(entry)
|
validTokens.push(entry)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed += 1
|
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))) {
|
if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
|
||||||
validTokens.push(entry)
|
validTokens.push(entry)
|
||||||
} else {
|
} else {
|
||||||
@@ -188,3 +199,66 @@ export async function sendNewNewsPush(news) {
|
|||||||
if (changed) await writeUsers(users)
|
if (changed) await writeUsers(users)
|
||||||
return { sent, failed, removed, recipients, tokenCount, skipped: false }
|
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