Files
harheimertc/server/utils/notification-scheduler.js
Torsten Schulz (local) b69130c2b2
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m56s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m36s
Fixed semgrep error
2026-06-14 21:29:53 +02:00

452 lines
18 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 matchIdentity(match) {
const row = match?.row || {}
const explicit = row.BegegnungNr || row.MeetingId || row.meeting_id || row.SpielNr
if (explicit) return `id:${explicit}`
return [
berlinDateKey(match?.date || matchDate(row) || new Date(0)),
String(row.Timestamp || ''),
...matchTeams(row).map(slugify)
].join('|')
}
function uniqueMatches(matches) {
const seen = new Set()
const unique = []
for (const match of matches) {
const identity = matchIdentity(match)
if (seen.has(identity)) continue
seen.add(identity)
unique.push(match)
}
return unique
}
function localTeamSlugForSide(row, side, teamRows) {
const clubName = normalizeText(row?.[`${side}VereinName`] || row?.[`${side}Mannschaft`] || '')
if (!clubName.includes('harheimer tc')) return []
const ageClass = String(row?.[`${side}MannschaftAltersklasse`] || row?.Altersklasse || '')
const number = String(row?.[`${side}MannschaftNr`] || '1').trim() || '1'
const base = /jugend/i.test(ageClass) ? 'Jugend' : 'Erwachsene'
const candidate = slugify(`${base} ${number}`)
const known = new Set(teamRows.map(row => slugify(row.team)).filter(Boolean))
if (!known.size || known.has(candidate)) return [candidate]
if (/jugend/i.test(ageClass)) {
return teamRows
.map(row => slugify(row.team))
.filter(slug => slug.startsWith('jugend'))
}
return []
}
function teamSlugsForMatch(match, teamRows = []) {
const row = match?.row || {}
return [...new Set([
...matchTeams(row).map(slugify),
...localTeamSlugForSide(row, 'Heim', teamRows),
...localTeamSlugForSide(row, 'Gast', teamRows)
].filter(Boolean))]
}
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, teamRows = []) {
const selected = new Set((settings.selectedTeamSlugs || []).map(slugify))
if (selected.size === 0) return []
return matches.filter(match => teamSlugsForMatch(match, teamRows).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, teamRows).some(slug => ownSlugs.has(slug)))
}
function matchesForUser(user, settings, context) {
if (settings.allTeamMatches) return uniqueMatches(context.allMatches)
return uniqueMatches([
...selectedMatchesForUser(user, settings, context.allMatches, context.teamRows),
...ownMatchesForUser(user, settings, context.allMatches, context.teamRows)
])
}
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, equivalentCategories = []) {
const key = runKey(dateKey, time, category)
const equivalentKeys = equivalentCategories.map(equivalentCategory => runKey(dateKey, time, equivalentCategory))
if (!enabled || state[key] || equivalentKeys.some(equivalentKey => state[equivalentKey])) 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 teamMatchResults = []
for (const [season, context] of Object.entries(matchContexts)) {
teamMatchResults.push(await sendIfDue(state, dateKey, time, 'teamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({
title: 'Punktspiele',
body: matchSummary(context.allMatches, 'Es stehen Punktspiele an.'),
data: { type: 'team_matches', date: dateKey, season },
bodyForUser: (user, settings) => matchSummary(matchesForUser(user, settings, context), 'Es stehen Punktspiele an.'),
predicate: (user, settings) => settings.notificationTime === time &&
notificationSeasonForSettings(settings, defaultSeason) === season &&
matchesForUser(user, settings, context).length > 0,
failureLabel: 'FCM Punktspiele-Push'
}), ['allTeamMatches:' + season, 'selectedTeamMatches:' + season, 'ownTeamMatches:' + season]))
}
results.teamMatches = teamMatchResults.some(Boolean)
results.allTeamMatches = results.teamMatches
results.selectedTeamMatches = results.teamMatches
results.ownTeamMatches = results.teamMatches
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 }
}