Backend: - Prisma PushToken model + chat_push_enabled flag on profiles - Migration 20260530_add_push_tokens (push_tokens table + profile flag) - Service sendChatPush with expo-server-sdk (auto-disable invalid tokens) - Fire-and-forget push trigger in sendDirectMessage + createRoomMessage - API POST /users/me/push-token (upsert) + DELETE (soft-disable) Client (rebreak-native): - usePushTokenRegistration hook: permission, getExpoPushTokenAsync, Android channel 'chat', POST to backend; idempotent per session - Notification tap deep-link: dm -> /dm?userId, room -> /room?roomId Deploy: - run_quiet spinner for silent altool/xcodebuild/gradle phases - Release-notes pipeline (--notes flag / NEXT_RELEASE.md / interactive) archived to CHANGELOG.md, printed with ASC + Play Console links - Default version bump ON (--no-bump opt-out), build cleanup - NEXT_RELEASE.md with push-notification release note
129 lines
4.0 KiB
TypeScript
129 lines
4.0 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";
|
|
|
|
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) + "…";
|
|
}
|