import crypto from 'crypto' import { promises as fs } from 'fs' import path from 'path' import { readUsers, writeUsers, isHiddenUser, migrateUserRoles } from './auth.js' import { notificationSettingsForUser } from './notification-settings.js' const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging' const TOKEN_URL = 'https://oauth2.googleapis.com/token' const tokenCache = { accessToken: null, expiresAt: 0 } function base64Url(input) { return Buffer.from(input).toString('base64url') } function projectIdFromServiceAccount(serviceAccount) { return process.env.FCM_PROJECT_ID || serviceAccount.project_id } function serviceAccountCandidatePaths() { const filename = 'harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json' const cwd = process.cwd() const candidates = [] if (process.env.GOOGLE_APPLICATION_CREDENTIALS) candidates.push(process.env.GOOGLE_APPLICATION_CREDENTIALS) candidates.push(path.join(cwd, 'server/data', filename)) candidates.push(path.join(cwd, '../server/data', filename)) return [...new Set(candidates)] } async function readServiceAccount() { if (process.env.FCM_SERVICE_ACCOUNT_JSON) { return JSON.parse(process.env.FCM_SERVICE_ACCOUNT_JSON) } for (const candidate of serviceAccountCandidatePaths()) { try { const raw = await fs.readFile(candidate, 'utf8') return JSON.parse(raw) } catch (error) { if (error?.code !== 'ENOENT') { console.warn(`FCM Service-Account konnte nicht gelesen werden (${candidate}): ${error.message}`) } } } return null } async function getAccessToken(serviceAccount) { if (tokenCache.accessToken && tokenCache.expiresAt > Date.now() + 60_000) { return tokenCache.accessToken } const now = Math.floor(Date.now() / 1000) const assertion = [ base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })), base64Url(JSON.stringify({ iss: serviceAccount.client_email, scope: FCM_SCOPE, aud: TOKEN_URL, iat: now, exp: now + 3600 })) ].join('.') const signature = crypto .createSign('RSA-SHA256') .update(assertion) .sign(serviceAccount.private_key, 'base64url') const response = await fetch(TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: `${assertion}.${signature}` }) }) if (!response.ok) { throw new Error(`FCM OAuth fehlgeschlagen: ${response.status}`) } const body = await response.json() tokenCache.accessToken = body.access_token tokenCache.expiresAt = Date.now() + Number(body.expires_in || 3600) * 1000 return tokenCache.accessToken } function pushTokensForUser(user) { return Array.isArray(user.pushTokens) ? user.pushTokens.filter(entry => entry?.token && entry.platform === 'android') : [] } export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) { const normalizedToken = String(token || '').trim() if (!normalizedToken) return user const now = new Date().toISOString() const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : [] const next = tokens.filter(entry => entry?.token !== normalizedToken) next.push({ token: normalizedToken, platform: String(platform || 'android').slice(0, 30), appVersion: appVersion ? String(appVersion).slice(0, 80) : null, updatedAt: now, createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now }) user.pushTokens = next.slice(-20) return user } async function sendFcmMessage({ serviceAccount, accessToken, token, data = {} }) { const projectId = projectIdFromServiceAccount(serviceAccount) if (!projectId) throw new Error('FCM project_id fehlt.') const response = await fetch(`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`, { method: 'POST', headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, body: JSON.stringify({ message: { token, data, android: { priority: 'high', // Keep this as a data message. With a `notification` payload FCM // renders background messages itself, bypassing our notification // channel and HarheimerMessagingService. Data messages use the // same app-controlled channel while the app is foregrounded and // backgrounded. } } }) }) if (!response.ok) { const text = await response.text().catch(() => '') throw new Error(`FCM send fehlgeschlagen: ${response.status} ${text}`) } } function isStaleFcmTokenError(error) { return /UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error?.message || error || '')) } function notificationIdFor(value) { return String(value || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString() } function userRoles(user) { const migrated = migrateUserRoles({ ...(user || {}) }) return Array.isArray(migrated.roles) ? migrated.roles : [] } function isVorstandUser(user) { const roles = userRoles(user) return roles.includes('admin') || roles.includes('vorstand') } function normalizePersonName(value) { return String(value || '') .trim() .toLowerCase() .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[’'`]/g, '') .replace(/\s+/g, ' ') } function userMatchesPlayer(user, playerName) { const player = normalizePersonName(playerName) if (!player) return false const candidates = [ user?.name, `${user?.firstName || ''} ${user?.lastName || ''}`.trim() ].map(normalizePersonName).filter(Boolean) return candidates.includes(player) } export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) { const serviceAccount = await readServiceAccount() 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 } } const accessToken = await getAccessToken(serviceAccount) const users = await readUsers() let sent = 0 let failed = 0 let removed = 0 let recipients = 0 let tokenCount = 0 let changed = false 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, data: payload }) sent += 1 validTokens.push(entry) } catch (error) { if (isStaleFcmTokenError(error)) { removed += 1 changed = true console.warn('FCM Push-Token entfernt:', { failureLabel, reason: error.message }) } else { failed += 1 console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message }) validTokens.push(entry) } } } if (validTokens.length < tokens.length) { user.pushTokens = validTokens changed = true } } if (changed) await writeUsers(users) const result = { sent, failed, removed, recipients, tokenCount, skipped: false } // This makes an absent registration immediately visible in the production // log without exposing tokens or user data. console.info('FCM Push Ergebnis:', { failureLabel, ...result }) return result } export async function sendNewNewsPush(news) { const title = 'Neue News' const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120) return sendPushToUsers({ title, body, data: { type: 'news', newsId: String(news.id || ''), notificationId: notificationIdFor(news.id || Date.now()) }, predicate: (_user, settings) => settings.newNews, failureLabel: 'FCM News-Push' }) } export async function sendNewEventPush(termin) { const title = 'Neuer Termin' const body = String(termin?.titel || 'Ein neuer Termin wurde eingetragen.').slice(0, 120) return sendPushToUsers({ title, body, data: { type: 'event', date: termin?.datum || '', notificationId: notificationIdFor(`event:${termin?.datum || ''}:${termin?.titel || ''}`) }, predicate: (_user, settings) => settings.newEvents, failureLabel: 'FCM Termin-Push' }) } export async function sendNewContactRequestPush(contactRequest) { const title = 'Neue Kontaktanfrage' const body = String(contactRequest?.subject || contactRequest?.name || 'Eine neue Kontaktanfrage ist eingegangen.').slice(0, 120) return sendPushToUsers({ title, body, data: { type: 'contact_request', notificationId: notificationIdFor(`contact:${contactRequest?.email || ''}:${contactRequest?.subject || ''}:${Date.now()}`) }, predicate: (user, settings) => isVorstandUser(user) && settings.newContactRequest, failureLabel: 'FCM Kontaktanfrage-Push' }) } export async function sendNewUserRegistrationPush(registration) { const title = 'Neue Benutzerregistrierung' const body = String(registration?.name || registration?.email || 'Eine neue Registrierung wartet auf Freigabe.').slice(0, 120) return sendPushToUsers({ title, body, data: { type: 'user_registration', userId: registration?.id || '', notificationId: notificationIdFor(`registration:${registration?.id || registration?.email || Date.now()}`) }, predicate: (user, settings) => isVorstandUser(user) && settings.newUserRegistration, failureLabel: 'FCM Registrierungs-Push' }) } export async function sendOwnTtrChangePush(changes = []) { const byPlayer = new Map( changes .filter(change => change?.playerName && Number.isFinite(change?.previousTtr) && Number.isFinite(change?.currentTtr)) .map(change => [normalizePersonName(change.playerName), change]) ) if (!byPlayer.size) return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: false } return sendPushToUsers({ title: 'TTR-Wert geändert', data: { type: 'ttr_change' }, predicate: (user, settings) => settings.ownTtrChanges && [...byPlayer.values()].some(change => userMatchesPlayer(user, change.playerName)), bodyForUser: (user) => { const change = [...byPlayer.values()].find(entry => userMatchesPlayer(user, entry.playerName)) return `Dein TTR-Wert hat sich von ${change.previousTtr} auf ${change.currentTtr} geändert.` }, dataForUser: (user) => { const change = [...byPlayer.values()].find(entry => userMatchesPlayer(user, entry.playerName)) return { previousTtr: change.previousTtr, currentTtr: change.currentTtr, notificationId: notificationIdFor(`ttr:${change.playerName}:${change.previousTtr}:${change.currentTtr}`) } }, failureLabel: 'FCM TTR-Änderungs-Push' }) } export async function sendTestPushToUser(userId) { return sendPushToUsers({ title: 'Harheimer TC: Push-Test', body: 'Diese Test-Benachrichtigung wurde erfolgreich vom CMS ausgelöst.', data: { type: 'test', notificationId: notificationIdFor(`test:${userId}:${Date.now()}`) }, predicate: user => user?.id === userId, failureLabel: 'FCM Test-Push' }) }