/** * Push-Notifications via Expo Server SDK. * * Verwendet von: * - backend/server/db/chat.ts sendDirectMessage() → triggert nach Insert * - backend/server/db/chat-rooms.ts createRoomMessage() → triggert nach Insert * * Versand ist fire-and-forget (kein Block des HTTP-Response): Errors werden geloggt * aber nicht propagiert, damit Chat-Send nicht failed wenn Expo-Server down ist. * * Token-Cleanup: Bei DeviceNotRegistered Receipts werden Tokens automatisch * disabled (PushToken.enabled = false) — Re-Enable nur durch Re-Registrierung * vom Client. * * IMPORT-HINWEIS: expo-server-sdk v6+ ist type:"module" und importiert undici (CJS) * intern via ESM-Syntax. Nitro/Rollup kann diesen ESM→CJS-Interop beim statischen * Bundlen nicht korrekt auflösen → `Class extends value [object Module] is not a * constructor` zur Laufzeit. Workaround: dynamic import (analog @parse/node-apn in * voip-push.ts), sodass das Modul erst zur Laufzeit aus node_modules geladen wird. */ import { usePrisma } from "../utils/prisma"; import { sendVoIPPush } from "./voip-push"; // expo-server-sdk lazy-geladen via dynamic import — verhindert Rollup-ESM/CJS-Interop-Bug. type ExpoModule = typeof import("expo-server-sdk"); type ExpoInstance = InstanceType; type ExpoPushMessage = ExpoModule["ExpoPushMessage"]; let _expoMod: ExpoModule | null = null; let _expo: ExpoInstance | null = null; async function ensureExpo(): Promise<{ expo: ExpoInstance; Expo: ExpoModule["Expo"] }> { if (_expo && _expoMod) return { expo: _expo, Expo: _expoMod.Expo }; const mod = (await import("expo-server-sdk")) as unknown as ExpoModule & { default?: ExpoModule; }; _expoMod = mod.default ?? mod; _expo = new _expoMod.Expo(); return { expo: _expo, Expo: _expoMod.Expo }; } export interface ChatPushPayload { /** Empfänger-User-ID (kann mehrere Tokens haben) */ receiverId: string; /** Sender-Display-Name (für Titel "Max sendete: …") */ senderName: string; /** Nachrichten-Vorschau (max ~100 Zeichen) */ preview: string; /** Deep-Link-Daten — Client navigiert beim Tap */ data: { type: "dm" | "room"; /** Bei DM: senderId, bei Room: roomId */ targetId: string; messageId: string; }; } export async function sendChatPush(payload: ChatPushPayload): Promise { try { const db = usePrisma(); const { expo, Expo } = await ensureExpo(); // 1) Profile-Opt-out prüfen const profile = await db.profile.findUnique({ where: { id: payload.receiverId }, select: { chatPushEnabled: true, deletedAt: true }, }); if (!profile || profile.deletedAt || !profile.chatPushEnabled) { return; } // 2) Alle aktiven Tokens des Receivers const tokens = await db.pushToken.findMany({ where: { userId: payload.receiverId, enabled: true }, select: { id: true, token: true }, }); if (tokens.length === 0) return; // 3) Messages bauen (nur valide Expo-Tokens) const messages: ExpoPushMessage[] = []; const validTokenIds: string[] = []; for (const t of tokens) { if (!Expo.isExpoPushToken(t.token)) { // Ungültiger Token-Format → disablen await db.pushToken .update({ where: { id: t.id }, data: { enabled: false } }) .catch(() => {}); continue; } messages.push({ to: t.token, sound: "default", title: payload.senderName, body: payload.preview, data: payload.data, // Channel-ID für Android — wird in app.config.ts/native registriert channelId: "chat", }); validTokenIds.push(t.id); } if (messages.length === 0) return; // 4) Senden in Chunks (Expo akzeptiert max 100 pro Request) const chunks = expo.chunkPushNotifications(messages); for (const chunk of chunks) { try { await expo.sendPushNotificationsAsync(chunk); } catch (err) { console.error("[push] chunk send failed:", err); } } // 5) lastUsedAt bumpen (best-effort) await db.pushToken .updateMany({ where: { id: { in: validTokenIds } }, data: { lastUsedAt: new Date() }, }) .catch(() => {}); // Hinweis: Receipt-Polling (DeviceNotRegistered → token disablen) wird in // einem separaten Cron-Job gemacht (TODO: scripts/push-receipts-cleanup.ts). // Für MVP: Tokens bleiben aktiv bis sie explizit vom Client deregistriert // werden oder Expo dauerhaft DeviceNotRegistered meldet. } catch (err) { console.error("[push] sendChatPush failed:", err); } } /** * Helper für Username-Lookup (für Push-Titel). * Fällt auf "Jemand" zurück wenn kein nickname/username gesetzt. */ export async function getDisplayName(userId: string): Promise { const db = usePrisma(); const p = await db.profile.findUnique({ where: { id: userId }, select: { nickname: true, username: true }, }); return p?.nickname || p?.username || "Jemand"; } /** * Truncate für Push-Body (Expo hat 4kb hard limit, aber UI zeigt eh nur ~2 Zeilen). */ export function truncatePreview(text: string, max = 100): string { if (text.length <= max) return text; return text.slice(0, max - 1) + "…"; } export interface DeviceAddedPushPayload { /** Account-Owner — bekommt den Push auf seine mobilen Geräte */ userId: string; /** Anzeigename des neu verbundenen Geräts (Hostname/Label) */ deviceLabel: string; /** "macos" | "windows" | … — für den Plattform-Hinweis */ platform: string; } function devicePlatformLabel(platform: string): string { const p = platform.toLowerCase(); if (p.startsWith("mac")) return "Mac"; if (p.startsWith("win")) return "Windows"; if (p.startsWith("ios")) return "iPhone"; if (p.startsWith("android")) return "Android"; return "Gerät"; } /** * Push bei neuer Geräte-Bindung („Neues Gerät verbunden"). * * Account-Security-Signal: geht an ALLE aktiven Push-Tokens des Users (nur * mobile Geräte haben welche — Mac/Windows-Magic registriert keine Tokens). * Bewusst NICHT durch `chatPushEnabled` gegated — das ist eine sicherheits- * relevante Account-Benachrichtigung, kein Social-Push. * * Fire-and-forget wie die übrigen Push-Pfade. */ export async function sendDeviceAddedPush( payload: DeviceAddedPushPayload, ): Promise { try { const db = usePrisma(); const { expo, Expo } = await ensureExpo(); const profile = await db.profile.findUnique({ where: { id: payload.userId }, select: { deletedAt: true }, }); if (!profile || profile.deletedAt) return; const tokens = await db.pushToken.findMany({ where: { userId: payload.userId, enabled: true }, select: { id: true, token: true }, }); if (tokens.length === 0) return; const platformLabel = devicePlatformLabel(payload.platform); const messages: ExpoPushMessage[] = []; const validTokenIds: string[] = []; for (const t of tokens) { if (!Expo.isExpoPushToken(t.token)) { await db.pushToken .update({ where: { id: t.id }, data: { enabled: false } }) .catch(() => {}); continue; } messages.push({ to: t.token, sound: "default", title: "Neues Gerät verbunden", body: `${payload.deviceLabel} (${platformLabel}) ist jetzt geschützt.`, data: { type: "device_added", platform: payload.platform }, // Dedizierter Channel (HIGH) — siehe usePushTokenRegistration.ts. // Fällt vor App-Re-Registrierung graceful auf Default-Channel zurück. channelId: "devices", }); validTokenIds.push(t.id); } if (messages.length === 0) return; const chunks = expo.chunkPushNotifications(messages); for (const chunk of chunks) { try { await expo.sendPushNotificationsAsync(chunk); } catch (err) { console.error("[push] device-added chunk send failed:", err); } } await db.pushToken .updateMany({ where: { id: { in: validTokenIds } }, data: { lastUsedAt: new Date() }, }) .catch(() => {}); } catch (err) { console.error("[push] sendDeviceAddedPush failed:", err); } } export interface CallRingPushPayload { /** Empfänger (Callee) — bekommt den Push */ receiverId: string; /** Caller-Display-Name (für Titel) */ callerName: string; /** Caller-Profil (für Show-Screen nach Tap) */ callerId: string; callerNickname: string; callerAvatar: string | null; /** Call-ID — muss matchen damit Callee an die richtige Session andocken kann */ callId: string; } /** * Push für eingehenden Voice-Call. * * Foreground-Calls funktionieren via Supabase Realtime; dieser Push deckt den * Background/locked-screen-Fall ab. Beim Tap navigiert der Client direkt in * den /call-Screen und triggert `useCall.receiveIncoming(callId, from)` — * die normale Accept/Decline-UI greift dann. * * Wichtig: KEIN VoIPPushKit (Apple-only, braucht CallKit). Wir nutzen den * regulären APNs-Alert-Push mit `priority: high` + Notifications-Sound. * Trade-off: Auf iOS wacht ein force-quit-killed Process NICHT auf — das ist * dasselbe Verhalten wie reguläre Chat-Pushes. Für echte CallKit-Integration * → Phase 2 (eigene Initiative). */ export async function sendCallRingPush(payload: CallRingPushPayload): Promise { try { const db = usePrisma(); const { expo, Expo } = await ensureExpo(); const profile = await db.profile.findUnique({ where: { id: payload.receiverId }, select: { chatPushEnabled: true, deletedAt: true }, }); if (!profile || profile.deletedAt || !profile.chatPushEnabled) return; const tokens = await db.pushToken.findMany({ where: { userId: payload.receiverId, enabled: true }, select: { id: true, token: true, voipToken: true, platform: true }, }); console.log( `[call-ring] receiver=${payload.receiverId.slice(0,8)} tokens=${tokens.length} ` + `(ios=${tokens.filter(t=>t.platform==='ios').length}/voip=${tokens.filter(t=>t.platform==='ios'&&t.voipToken).length}, ` + `android=${tokens.filter(t=>t.platform==='android').length})`, ); if (tokens.length === 0) return; // ─── 1) VoIP-Pushes (iOS, CallKit-Wake-from-killed-State) ───────────── // Läuft parallel zum regulären Push. Wenn voipToken NULL ist (Android, // alte iOS-Builds ohne PushKit-Setup) wird das Device hier übersprungen // und fällt auf den normalen Lockscreen-Banner zurück. const voipHandledTokenIds = new Set(); for (const t of tokens) { if (t.platform === "ios" && t.voipToken) { voipHandledTokenIds.add(t.id); void sendVoIPPush({ voipToken: t.voipToken, callId: payload.callId, callerName: payload.callerName, callerId: payload.callerId, callerNickname: payload.callerNickname, callerAvatar: payload.callerAvatar, }); } } // ─── 2) Reguläre Expo-Pushes (Android FCM + iOS-Fallback) ──────────── // Wichtig: iOS-Devices die schon einen VoIP-Push bekommen haben werden // hier ÜBERSPRUNGEN — sonst doppeltes Klingeln (CallKit + Banner). const messages: ExpoPushMessage[] = []; const validTokenIds: string[] = []; for (const t of tokens) { if (voipHandledTokenIds.has(t.id)) continue; if (!Expo.isExpoPushToken(t.token)) { await db.pushToken .update({ where: { id: t.id }, data: { enabled: false } }) .catch(() => {}); continue; } messages.push({ to: t.token, sound: "default", title: `📞 ${payload.callerName}`, body: "Eingehender Anruf", priority: "high", // Android: dedizierter Calls-Channel (im Client mit Importance.MAX + // Vibration + Bypass-DND zu konfigurieren) channelId: "calls", // iOS: zeigt als alert auch im Lockscreen (interruption-level) // 'time-sensitive' wäre besser, braucht aber Critical-Alerts-Entitlement. // 'active' (default) reicht für Phase 1. data: { type: "call", callId: payload.callId, from: { id: payload.callerId, nickname: payload.callerNickname, avatar: payload.callerAvatar, }, }, }); validTokenIds.push(t.id); } if (messages.length === 0) return; console.log(`[call-ring] expo-push to ${messages.length} non-voip token(s) for receiver=${payload.receiverId.slice(0,8)}`); const chunks = expo.chunkPushNotifications(messages); for (const chunk of chunks) { try { await expo.sendPushNotificationsAsync(chunk); } catch (err) { console.error("[push] call-ring chunk send failed:", err); } } await db.pushToken .updateMany({ where: { id: { in: validTokenIds } }, data: { lastUsedAt: new Date() }, }) .catch(() => {}); } catch (err) { console.error("[push] sendCallRingPush failed:", err); } }