452 lines
18 KiB
JavaScript
452 lines
18 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.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 }
|
||
}
|