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, title, body, 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, notification: { title, body }, data, android: { priority: 'high', notification: { channel_id: 'harheimer_tc_updates', click_action: 'OPEN_NEWS' } } } }) }) if (!response.ok) { const text = await response.text().catch(() => '') throw new Error(`FCM send fehlgeschlagen: ${response.status} ${text}`) } } 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') } export async function sendPushToUsers({ title, body, data = {}, predicate, failureLabel = 'FCM-Push' }) { const serviceAccount = await readServiceAccount() if (!serviceAccount) { 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 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}`)) } for (const user of users) { if (isHiddenUser(user)) continue const settings = notificationSettingsForUser(user) if (predicate && !predicate(user, settings)) continue 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 }) sent += 1 validTokens.push(entry) } catch (error) { failed += 1 console.error(`${failureLabel} fehlgeschlagen:`, error.message) if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) { validTokens.push(entry) } else { removed += 1 changed = true } } } if (validTokens.length !== tokens.length) { user.pushTokens = validTokens changed = true } } if (changed) await writeUsers(users) return { sent, failed, removed, recipients, tokenCount, skipped: false } } 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' }) }