Added notifications for actual news
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m59s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

This commit is contained in:
Torsten Schulz (local)
2026-06-11 09:02:58 +02:00
parent c7a306e8fa
commit da1efa5a74
7 changed files with 497 additions and 18 deletions

View 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 }
}