From b69130c2b2ab342461f5f549290e55357d8a75a5 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sun, 14 Jun 2026 21:29:53 +0200 Subject: [PATCH] Fixed semgrep error --- scripts/verify-no-public-writes.js | 9 +- server/utils/notification-scheduler.js | 113 ++++++++++++++++--------- tests/notification-scheduler.spec.ts | 100 +++++++++++++++++++++- 3 files changed, 179 insertions(+), 43 deletions(-) diff --git a/scripts/verify-no-public-writes.js b/scripts/verify-no-public-writes.js index 3a4ea44..55cc78d 100644 --- a/scripts/verify-no-public-writes.js +++ b/scripts/verify-no-public-writes.js @@ -7,10 +7,17 @@ const sourceExtensions = new Set(['.js', '.mjs', '.ts']) const publicWritePattern = /\b(writeFile|appendFile|copyFile|rename|mkdir)\s*\([^)]*(public[/\\](?:data|uploads)|['"`]public['"`]\s*,\s*['"`](?:data|uploads)['"`])/s +function childPath(dir, name) { + if (name !== path.basename(name) || name.includes('/') || name.includes('\\')) { + throw new Error(`Ungueltiger Dateiname beim Scannen: ${name}`) + } + return `${dir}${path.sep}${name}` +} + function walk(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }) return entries.flatMap((entry) => { - const fullPath = path.join(dir, entry.name) + const fullPath = childPath(dir, entry.name) if (entry.isDirectory()) return walk(fullPath) return [fullPath] }) diff --git a/server/utils/notification-scheduler.js b/server/utils/notification-scheduler.js index 8bc1f67..fa8d8e9 100644 --- a/server/utils/notification-scheduler.js +++ b/server/utils/notification-scheduler.js @@ -172,8 +172,55 @@ function dateKeyToGerman(dateKey) { return `${day}.${month}.${year}` } -function teamSlugsForMatch(match) { - return matchTeams(match.row).map(slugify) +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) { @@ -239,17 +286,25 @@ function ownTeamSlugsForUser(user, teamRows) { .filter(Boolean) } -function selectedMatchesForUser(_user, settings, matches) { +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).some(slug => selected.has(slug))) + 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).some(slug => ownSlugs.has(slug))) + 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) { @@ -309,9 +364,10 @@ async function birthdaysOn(dateKey) { return [...new Set(people.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de')) } -async function sendIfDue(state, dateKey, time, category, enabled, send) { +async function sendIfDue(state, dateKey, time, category, enabled, send, equivalentCategories = []) { const key = runKey(dateKey, time, category) - if (!enabled || state[key]) return null + 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 @@ -363,48 +419,23 @@ export async function runNotificationSchedulerTick(now = new Date()) { failureLabel: 'FCM Termine-morgen-Push' })) - const allResults = [] - const selectedResults = [] - const ownResults = [] + const teamMatchResults = [] for (const [season, context] of Object.entries(matchContexts)) { - allResults.push(await sendIfDue(state, dateKey, time, 'allTeamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({ + 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 && - settings.allTeamMatches, + matchesForUser(user, settings, context).length > 0, 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' - }))) + }), ['allTeamMatches:' + season, 'selectedTeamMatches:' + season, 'ownTeamMatches:' + season])) } - results.allTeamMatches = allResults.some(Boolean) - results.selectedTeamMatches = selectedResults.some(Boolean) - results.ownTeamMatches = ownResults.some(Boolean) + 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', diff --git a/tests/notification-scheduler.spec.ts b/tests/notification-scheduler.spec.ts index dc55cc0..f69662a 100644 --- a/tests/notification-scheduler.spec.ts +++ b/tests/notification-scheduler.spec.ts @@ -36,6 +36,7 @@ vi.mock('../server/utils/logger.js', () => ({ const authUtils = await import('../server/utils/auth.js') const memberUtils = await import('../server/utils/members.js') const pushUtils = await import('../server/utils/push-notifications.js') +const spielplanUtils = await import('../server/utils/spielplan-data.js') const { runNotificationSchedulerTick } = await import('../server/utils/notification-scheduler.js') @@ -53,11 +54,22 @@ const recipient = { describe('Notification Scheduler', () => { beforeEach(() => { vi.clearAllMocks() - vi.spyOn(fs, 'readFile').mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => { + if (String(filePath).includes('mannschaften_25--26.csv')) { + return [ + 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung', + 'Erwachsene 1,,,,,,Mannschaftsfuehrer,Max Spieler,,', + 'Erwachsene 2,,,,,,Andere Person,Andere Spieler,,' + ].join('\n') + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined) vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined) memberUtils.readMembers.mockResolvedValue([]) authUtils.readUsers.mockResolvedValue([recipient]) + spielplanUtils.getDefaultSpielplanSeason.mockResolvedValue('25--26') + spielplanUtils.readSpielplanData.mockResolvedValue({ data: [] }) }) it('sendet Geburtstags-Push nur fuer Mitglieder mit expliziter Geburtstagsfreigabe', async () => { @@ -90,4 +102,90 @@ describe('Notification Scheduler', () => { expect(payload.body).not.toMatch(/\b\d+\b/) expect(payload.body).not.toContain('Jahre') }) + + it('sendet Punktspiel-Push nur einmal, wenn alle, eigene und ausgewaehlte Mannschaft dasselbe Spiel treffen', async () => { + const matchUser = { + id: 'match-user', + name: 'Max Spieler', + active: true, + notificationSettings: { + allTeamMatches: true, + ownTeamMatches: true, + selectedTeamSlugs: ['erwachsene-1'], + selectedTeamSeason: '25--26', + notificationTime: '09:00' + } + } + authUtils.readUsers.mockResolvedValue([matchUser]) + spielplanUtils.readSpielplanData.mockResolvedValue({ + data: [{ + Termin: '14.06.2026 20:15', + BegegnungNr: 'spiel-1', + Altersklasse: 'Erwachsene', + HeimVereinName: 'Harheimer TC', + HeimMannschaftAltersklasse: 'Erwachsene', + HeimMannschaftNr: '1', + HeimMannschaft: 'Harheimer TC', + GastVereinName: 'Gastverein', + GastMannschaftAltersklasse: 'Erwachsene', + GastMannschaftNr: '1', + GastMannschaft: 'Gastverein' + }] + }) + + await runNotificationSchedulerTick(schedulerNow) + + expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1) + const payload = pushUtils.sendPushToUsers.mock.calls[0][0] + expect(payload.title).toBe('Punktspiele') + expect(payload.predicate(matchUser, matchUser.notificationSettings)).toBe(true) + expect(payload.bodyForUser(matchUser, matchUser.notificationSettings)).toContain('Harheimer TC - Gastverein') + }) + + it('fasst eigene und ausgewaehlte Punktspiele in einer Benachrichtigung zusammen', async () => { + const matchUser = { + id: 'match-user', + name: 'Max Spieler', + active: true, + notificationSettings: { + allTeamMatches: false, + ownTeamMatches: true, + selectedTeamSlugs: ['erwachsene-2'], + selectedTeamSeason: '25--26', + notificationTime: '09:00' + } + } + authUtils.readUsers.mockResolvedValue([matchUser]) + spielplanUtils.readSpielplanData.mockResolvedValue({ + data: [ + { + Termin: '14.06.2026 20:15', + BegegnungNr: 'spiel-1', + Altersklasse: 'Erwachsene', + HeimVereinName: 'Harheimer TC', + HeimMannschaftAltersklasse: 'Erwachsene', + HeimMannschaftNr: '1', + HeimMannschaft: 'Harheimer TC', + GastMannschaft: 'Gastverein' + }, + { + Termin: '14.06.2026 20:30', + BegegnungNr: 'spiel-2', + Altersklasse: 'Erwachsene', + HeimMannschaft: 'Gastverein II', + GastVereinName: 'Harheimer TC', + GastMannschaftAltersklasse: 'Erwachsene', + GastMannschaftNr: '2', + GastMannschaft: 'Harheimer TC II' + } + ] + }) + + await runNotificationSchedulerTick(schedulerNow) + + expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1) + const payload = pushUtils.sendPushToUsers.mock.calls[0][0] + expect(payload.predicate(matchUser, matchUser.notificationSettings)).toBe(true) + expect(payload.bodyForUser(matchUser, matchUser.notificationSettings)).toBe('2 Punktspiele am 14.06.2026') + }) })