Updated stuff
This commit is contained in:
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
|
||||
let backupSequence = 0
|
||||
|
||||
function getProjectRoot() {
|
||||
const cwd = process.cwd()
|
||||
@@ -30,8 +31,9 @@ function sanitizeFileKey(filePath) {
|
||||
}
|
||||
|
||||
function buildBackupName(date = new Date()) {
|
||||
const sequence = (backupSequence++).toString(36).padStart(6, '0')
|
||||
const randomSuffix = Math.random().toString(36).slice(2, 8)
|
||||
return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak`
|
||||
return `${date.toISOString().replace(/[:.]/g, '-')}-${sequence}-${randomSuffix}.bak`
|
||||
}
|
||||
|
||||
export function resolveDataFileBackupPath(backupDir, backupName) {
|
||||
|
||||
@@ -158,6 +158,7 @@ function matchesOn(rows, 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)
|
||||
@@ -184,7 +185,7 @@ async function readTeamMembers(season) {
|
||||
const rows = []
|
||||
for (const line of lines.slice(1)) {
|
||||
const values = parseCsvLine(line)
|
||||
rows.push({ team: values[0] || '', players: values[7] || '' })
|
||||
rows.push({ team: values[0] || '', captain: values[6] || '', players: values[7] || '' })
|
||||
}
|
||||
return rows
|
||||
} catch (error) {
|
||||
@@ -217,26 +218,52 @@ function parseCsvLine(line) {
|
||||
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 = normalizeText(userDisplayName(user))
|
||||
if (!name) return []
|
||||
const name = userDisplayName(user)
|
||||
if (!normalizeText(name)) return []
|
||||
return teamRows
|
||||
.filter(row => normalizeText(row.players).includes(name))
|
||||
.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 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 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 userOwnTeamMatch(user, settings, matches, teamRows) {
|
||||
if (!settings.ownTeamMatches) return false
|
||||
function ownMatchesForUser(user, settings, matches, teamRows) {
|
||||
if (settings.ownTeamMatches === false) return []
|
||||
const ownSlugs = new Set(ownTeamSlugsForUser(user, teamRows))
|
||||
if (!ownSlugs.size) return false
|
||||
return matches.some(match => teamSlugsForMatch(match).some(slug => ownSlugs.has(slug)))
|
||||
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) {
|
||||
@@ -288,17 +315,17 @@ export async function runNotificationSchedulerTick(now = new Date()) {
|
||||
|
||||
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 [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, ...expiringNewsToday]
|
||||
const todayEvents = todayTermine
|
||||
const tomorrowEvents = tomorrowTermine
|
||||
const todayMatches = matchesOn(spielplan.data || [], dateKey)
|
||||
const tomorrowMatches = matchesOn(spielplan.data || [], tomorrowKey)
|
||||
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 allMatches = [...todayMatches, ...tomorrowMatches]
|
||||
const results = {}
|
||||
|
||||
results.eventsToday = await sendIfDue(state, dateKey, time, 'eventsToday', todayEvents.length > 0, () => sendPushToUsers({
|
||||
@@ -325,29 +352,48 @@ export async function runNotificationSchedulerTick(now = new Date()) {
|
||||
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'
|
||||
}))
|
||||
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'
|
||||
})))
|
||||
|
||||
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'
|
||||
}))
|
||||
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'
|
||||
})))
|
||||
|
||||
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'
|
||||
}))
|
||||
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',
|
||||
|
||||
@@ -146,9 +146,9 @@ function isVorstandUser(user) {
|
||||
return roles.includes('admin') || roles.includes('vorstand')
|
||||
}
|
||||
|
||||
export async function sendPushToUsers({ title, body, data = {}, predicate, failureLabel = 'FCM-Push' }) {
|
||||
export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) {
|
||||
const serviceAccount = await readServiceAccount()
|
||||
if (!serviceAccount) {
|
||||
if (serviceAccount == null) {
|
||||
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
|
||||
return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: true }
|
||||
}
|
||||
@@ -160,30 +160,34 @@ export async function sendPushToUsers({ title, body, data = {}, predicate, failu
|
||||
let recipients = 0
|
||||
let tokenCount = 0
|
||||
let changed = false
|
||||
const payload = {
|
||||
...Object.fromEntries(Object.entries(data).map(([key, value]) => [key, String(value ?? '')])),
|
||||
title: String(title || 'Harheimer TC'),
|
||||
body: String(body || '').slice(0, 240),
|
||||
notificationId: String(data.notificationId || notificationIdFor(`${data.type || 'push'}:${title}:${body}`))
|
||||
}
|
||||
const baseData = Object.fromEntries(Object.entries(data).map(([key, value]) => [key, String(value ?? '')]))
|
||||
|
||||
for (const user of users) {
|
||||
if (isHiddenUser(user)) continue
|
||||
const settings = notificationSettingsForUser(user)
|
||||
if (predicate && !predicate(user, settings)) continue
|
||||
const userBody = String(bodyForUser ? bodyForUser(user, settings) : body || '').slice(0, 240)
|
||||
const userData = dataForUser ? dataForUser(user, settings) : {}
|
||||
const payload = {
|
||||
...baseData,
|
||||
...Object.fromEntries(Object.entries(userData || {}).map(([key, value]) => [key, String(value ?? '')])),
|
||||
title: String(title || 'Harheimer TC'),
|
||||
body: userBody,
|
||||
notificationId: String((userData && userData.notificationId) || data.notificationId || notificationIdFor([data.type || 'push', title, userBody].join(':')))
|
||||
}
|
||||
recipients += 1
|
||||
const tokens = pushTokensForUser(user)
|
||||
tokenCount += tokens.length
|
||||
const validTokens = []
|
||||
for (const entry of tokens) {
|
||||
try {
|
||||
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data: payload })
|
||||
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body: userBody, data: payload })
|
||||
sent += 1
|
||||
validTokens.push(entry)
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message })
|
||||
if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
|
||||
if (/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message)) === false) {
|
||||
validTokens.push(entry)
|
||||
} else {
|
||||
removed += 1
|
||||
@@ -191,7 +195,7 @@ export async function sendPushToUsers({ title, body, data = {}, predicate, failu
|
||||
}
|
||||
}
|
||||
}
|
||||
if (validTokens.length !== tokens.length) {
|
||||
if (validTokens.length < tokens.length) {
|
||||
user.pushTokens = validTokens
|
||||
changed = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user