Files
harheimertc/server/utils/notification-scheduler.js
Torsten Schulz (local) 77aabef4a9
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m53s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Benachrichtigungen erweitert
Emails korrigiert
2026-06-14 01:05:19 +02:00

421 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.newNews || 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 formatNewsExpirySummary(news, fallback) {
if (news.length === 1) return String(news[0].title || fallback).slice(0, 140)
return `${news.length} News laufen heute ab: ${news.slice(0, 3).map(item => item.title).filter(Boolean).join(', ')}`.slice(0, 140)
}
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) return 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] || '', captain: values[6] || '', 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 personNameMatches(candidate, userName) {
const normalizedCandidate = normalizeText(candidate)
const normalizedUserName = normalizeText(userName)
if (!normalizedCandidate || !normalizedUserName) return false
if (normalizedCandidate === normalizedUserName) return true
const candidateParts = new Set(normalizedCandidate.split(' ').filter(Boolean))
const userParts = normalizedUserName.split(' ').filter(Boolean)
return userParts.length >= 2 && userParts.every(part => candidateParts.has(part))
}
function ownTeamSlugsForUser(user, teamRows) {
const name = userDisplayName(user)
if (!normalizeText(name)) return []
return teamRows
.filter(row => personNameMatches(row.captain, name) ||
String(row.players || '').replace(/\r?\n/g, ';').split(/[;,]+/).some(player => personNameMatches(player, name)))
.map(row => slugify(row.team))
.filter(Boolean)
}
function selectedMatchesForUser(_user, settings, matches) {
const selected = new Set((settings.selectedTeamSlugs || []).map(slugify))
if (selected.size === 0) return []
return matches.filter(match => teamSlugsForMatch(match).some(slug => selected.has(slug)))
}
function ownMatchesForUser(user, settings, matches, teamRows) {
if (settings.ownTeamMatches === false) return []
const ownSlugs = new Set(ownTeamSlugsForUser(user, teamRows))
if (ownSlugs.size === 0) return []
return matches.filter(match => teamSlugsForMatch(match).some(slug => ownSlugs.has(slug)))
}
function notificationSeasonForSettings(settings, fallbackSeason) {
return String(settings?.selectedTeamSeason || fallbackSeason || '').trim()
}
async function loadMatchContextForSeasons(seasons, dateKey, tomorrowKey) {
const entries = await Promise.all(seasons.map(async season => {
const [spielplan, teamRows] = await Promise.all([readSpielplanData({ season }), readTeamMembers(season)])
const todayMatches = matchesOn(spielplan.data || [], dateKey)
const tomorrowMatches = matchesOn(spielplan.data || [], tomorrowKey)
return [season, { spielplan, teamRows, todayMatches, tomorrowMatches, allMatches: [...todayMatches, ...tomorrowMatches] }]
}))
return Object.fromEntries(entries)
}
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
}
function hasBirthdayNotificationConsent(person) {
return person?.visibility?.showBirthday === true || person?.showBirthday === true
}
function formatBirthdaySummary(names) {
const visibleNames = names.map(name => String(name || '').trim()).filter(Boolean)
if (visibleNames.length === 1) return `${visibleNames[0]} hat heute Geburtstag.`
return `Geburtstage heute: ${visibleNames.slice(0, 5).join(', ')}${visibleNames.length > 5 ? ` und ${visibleNames.length - 5} weitere` : ''}.`
}
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
if (!hasBirthdayNotificationConsent(member)) 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 (!hasBirthdayNotificationConsent(user)) 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, defaultSeason] = await Promise.all([readTermine(), readNews(), getDefaultSpielplanSeason()])
const todayTermine = eventsOn(termine, dateKey)
const tomorrowTermine = eventsOn(termine, tomorrowKey)
const expiringNewsToday = expiringNewsOn(news, dateKey)
const todayEvents = todayTermine
const tomorrowEvents = tomorrowTermine
const seasonsForMatches = [...new Set(dueUsers
.map(user => notificationSeasonForSettings(notificationSettingsForUser(user), defaultSeason))
.filter(Boolean))]
const matchContexts = await loadMatchContextForSeasons(seasonsForMatches, dateKey, tomorrowKey)
const todaysBirthdays = await birthdaysOn(dateKey)
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.expiringNews = await sendIfDue(state, dateKey, time, 'expiringNews', expiringNewsToday.length > 0, () => sendPushToUsers({
title: 'News laufen heute ab',
body: formatNewsExpirySummary(expiringNewsToday, 'Heute laufen News ab.'),
data: { type: 'news_expiring', date: dateKey },
predicate: (_user, settings) => settings.notificationTime === time && settings.newNews && !settings.eventsToday,
failureLabel: 'FCM News-Ablauf-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'
}))
const allResults = []
const selectedResults = []
const ownResults = []
for (const [season, context] of Object.entries(matchContexts)) {
allResults.push(await sendIfDue(state, dateKey, time, 'allTeamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({
title: 'Punktspiele',
body: matchSummary(context.allMatches, 'Es stehen Punktspiele an.'),
data: { type: 'team_matches', date: dateKey, season },
predicate: (user, settings) => settings.notificationTime === time &&
notificationSeasonForSettings(settings, defaultSeason) === season &&
settings.allTeamMatches,
failureLabel: 'FCM Punktspiele-Push'
})))
selectedResults.push(await sendIfDue(state, dateKey, time, 'selectedTeamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({
title: 'Punktspiele deiner Auswahl',
body: 'Für eine abonnierte Mannschaft steht ein Punktspiel an.',
bodyForUser: (user, settings) => matchSummary(selectedMatchesForUser(user, settings, context.allMatches), 'Für eine abonnierte Mannschaft steht ein Punktspiel an.'),
data: { type: 'team_matches', date: dateKey, season },
predicate: (user, settings) => settings.notificationTime === time &&
notificationSeasonForSettings(settings, defaultSeason) === season &&
settings.allTeamMatches === false &&
selectedMatchesForUser(user, settings, context.allMatches).length > 0,
failureLabel: 'FCM Mannschaftsauswahl-Push'
})))
ownResults.push(await sendIfDue(state, dateKey, time, 'ownTeamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({
title: 'Punktspiel deiner Mannschaft',
body: 'Für deine Mannschaft steht ein Punktspiel an.',
bodyForUser: (user, settings) => matchSummary(ownMatchesForUser(user, settings, context.allMatches, context.teamRows), 'Für deine Mannschaft steht ein Punktspiel an.'),
data: { type: 'team_matches', date: dateKey, season },
predicate: (user, settings) => settings.notificationTime === time &&
notificationSeasonForSettings(settings, defaultSeason) === season &&
settings.allTeamMatches === false &&
selectedMatchesForUser(user, settings, context.allMatches).length === 0 &&
ownMatchesForUser(user, settings, context.allMatches, context.teamRows).length > 0,
failureLabel: 'FCM eigene-Mannschaft-Push'
})))
}
results.allTeamMatches = allResults.some(Boolean)
results.selectedTeamMatches = selectedResults.some(Boolean)
results.ownTeamMatches = ownResults.some(Boolean)
results.birthdays = await sendIfDue(state, dateKey, time, 'birthdays', todaysBirthdays.length > 0, () => sendPushToUsers({
title: 'Geburtstage heute',
body: formatBirthdaySummary(todaysBirthdays),
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 }
}