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