/** * 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 { Expo, type ExpoPushMessage } from "expo-server-sdk"; import { usePrisma } from "../utils/prisma"; const expo = new 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(); // 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) + "…"; }