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