Files
harheimertc/server/utils/notification-scheduler.js
Torsten Schulz (local) da1efa5a74
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
Added notifications for actual news
2026-06-11 09:03:16 +02:00

348 lines
14 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.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 }
}