Devices/Magic: - Offline-Profil-Enroll deaktiviert (410) — Lock-PW würde im Klartext im Download landen; stationärer Schutz läuft jetzt nur über Rebreak Magic - Mac-DNS-Template: ProhibitDisablement (Filter nicht abschaltbar) - Push "Neues Gerät verbunden" an mobile Geräte bei neuer Bindung - Realtime auf user_devices → Settings aktualisiert Magic-Bindings live - Geräte-Detail-Sheet (Tap auf Gerät): Status, verbunden-seit, Schutz-Donut Hard-Lock (server-gehaltenes Removal-PW, User sieht es nie): - magic_removal_password generiert/gespeichert + in Profil injiziert (Lazy-Backfill) - Reveal NUR bei Account-Löschung (user/delete) + Kündigung (stripe webhook), per Resend-Mail + in-Response - Signing config-gated (inaktiv ohne Cert; Lock greift auch unsigniert) Migrations: user_devices-Realtime-Publication + magic_removal_password-Spalten Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
/**
|
|
* 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";
|
|
import { sendVoIPPush } from "./voip-push";
|
|
|
|
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<void> {
|
|
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<string> {
|
|
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<void> {
|
|
try {
|
|
const db = usePrisma();
|
|
|
|
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<void> {
|
|
try {
|
|
const db = usePrisma();
|
|
|
|
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<string>();
|
|
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);
|
|
}
|
|
}
|