feat: Push an mehrere Geräte, Abmelden trägt das Gerät aus

- Neue Tabelle PushToken (ein Eintrag pro Gerät, Token eindeutig, max. 10
  pro Konto); owner.registerPushToken trägt zusätzlich ein statt zu
  überschreiben, optional mit Plattform (alte App-Versionen funktionieren)
- Versand an alle Geräte in einem Expo-Batch; Tokens mit
  „DeviceNotRegistered“ werden gelöscht; Owner.expoPushToken wird bis zur
  Übernahme nur noch mitgelesen (Übernahme per enable_rls_push_tokens.sql)
- App: meldet Plattform mit und trägt das Gerät beim Abmelden aus
  (clearPushToken mit Token), damit kein Push mehr fürs alte Konto kommt
- Vermisst: Telefon/E-Mail werden beim Finden bzw. Schließen gelöscht
  (Datensparsamkeit); Altbestand per cleanup_lost_report_contacts.sql

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 11:30:01 +02:00
1 parent ce8d478ecd
commit 95d39ca1b8
20 files changed
+307 -84

No files matched your search

@@ -1,6 +1,9 @@
import { Alert } from "react-native";
import { useTranslation } from "react-i18next";
import { useAuth } from "@clerk/expo";
import { useMutation } from "@tanstack/react-query";
import { useTRPC } from "../../api/trpc";
import { getRegisteredPushToken } from "../../hooks/usePushNotifications";
import { useActivePet } from "../../context/ActivePetContext";
import { Button } from "../Button";
@@ -22,6 +25,17 @@ export function LogoutSection() {
const { t } = useTranslation();
const { signOut } = useAuth();
const { clearActivePetId } = useActivePet();
const trpc = useTRPC();
const clearPushToken = useMutation(trpc.owner.clearPushToken.mutationOptions());
const logout = async () => {
// Dieses Gerät vor dem Abmelden austragen, sonst kämen weiter Mitteilungen des alten Kontos an.
// Fehler (z. B. offline) dürfen das Abmelden nie blockieren.
const token = getRegisteredPushToken();
if (token) await clearPushToken.mutateAsync({ expoPushToken: token }).catch(() => undefined);
clearActivePetId();
await signOut();
};
const confirmLogout = () => {
Alert.alert(t("pets.logoutConfirmTitle"), t("pets.logoutConfirmMessage"), [
@@ -30,8 +44,7 @@ export function LogoutSection() {
text: t("pets.logoutButton"),
style: "destructive",
onPress: () => {
clearActivePetId();
void signOut();
void logout();
},
},
]);
+11 -1
View File
@@ -6,6 +6,12 @@ import { useMutation } from "@tanstack/react-query";
import { useAuth } from "@clerk/expo";
import { useTRPC } from "../api/trpc";
/** Token dieses Geräts, sobald registriert — zum Abmelden beim Logout (owner.clearPushToken). */
let registeredPushToken: string | null = null;
export function getRegisteredPushToken(): string | null {
return registeredPushToken;
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
@@ -72,7 +78,11 @@ export function usePushNotifications(): void {
setTimeout(() => reject(new Error("Timed out after 15s")), 15000)
),
]);
if (!cancelled) registerMutation.mutate({ expoPushToken: token });
if (!cancelled) {
registeredPushToken = token;
// Push an mehrere Geräte: jedes Gerät wird zusätzlich eingetragen, nichts wird überschrieben.
registerMutation.mutate({ expoPushToken: token, platform: Platform.OS === "ios" ? "ios" : "android" });
}
} catch (err) {
console.warn("[push] Failed to get Expo push token", err);
}
+6
View File
@@ -0,0 +1,6 @@
-- Datensparsamkeit (05.10.2026): Kontaktdaten bereits beendeter Vermisst-Meldungen
-- löschen. Ab jetzt passiert das beim Finden/Schließen automatisch
-- (lostReports.markFound, lostReportAdmin.moderate). Einmal ausführen.
UPDATE public."LostReport"
SET "contactPhone" = NULL, "contactEmail" = NULL
WHERE "foundAt" IS NOT NULL AND ("contactPhone" IS NOT NULL OR "contactEmail" IS NOT NULL);
+12
View File
@@ -0,0 +1,12 @@
-- Push an mehrere Geräte: neue Tabelle für den PostgREST-Zugriff sperren, wie
-- alle anderen Tabellen (siehe enable_rls.sql). Die App greift ausschließlich
-- über Prisma (Service-Rolle) zu. Nach dem prisma db push einmal ausführen.
ALTER TABLE public."PushToken" ENABLE ROW LEVEL SECURITY;
-- Bestehende Einzel-Tokens übernehmen, damit vorhandene Geräte nahtlos weiter
-- Push bekommen (Owner.expoPushToken wird ab jetzt nicht mehr geschrieben).
INSERT INTO public."PushToken" ("id", "ownerId", "token", "platform", "createdAt", "lastSeenAt")
SELECT 'mig_' || md5("id"), "id", "expoPushToken", NULL, now(), now()
FROM public."Owner"
WHERE "expoPushToken" IS NOT NULL
ON CONFLICT ("token") DO NOTHING;
+18 -1
View File
@@ -24,7 +24,7 @@ model Owner {
themePreference ThemePreference? // null = follows system preference, never explicitly toggled
hideSpiders Boolean @default(false) // Arachnophobie-Helfer: hides Species="Spider" pets/posts from feed/explore/search when true
petSuggestionsEnabled Boolean @default(true) // opt-out for the PET_SUGGESTION cron notification (src/app/api/cron/pet-suggestions)
expoPushToken String? // mobile app's Expo push token, registered via owner.registerPushToken; single device only, overwritten on re-register
expoPushToken String? // ALT (bis 10/2026): ein Gerät pro Konto. Wird nicht mehr geschrieben, beim Versand nur noch mitgelesen — neue Geräte stehen in PushToken
// Sponsoring (Gitea #22) — freiwilliger Beitrag/Abo gegen ein "Sponsor"-Badge.
stripeCustomerId String? @unique
@@ -34,6 +34,7 @@ model Owner {
sponsorSubscriptionStatus String? @db.VarChar(20) // mirrors Stripe subscription.status
contributions SponsorContribution[]
broadcastReceipts BroadcastReceipt[]
pushTokens PushToken[]
// Empfehlungsprogramm — Clerk-unabhängig: der Code wird erst nach der
// Registrierung im Onboarding eingelöst (src/lib/referrals.ts). invitedById
@@ -1162,3 +1163,19 @@ model BroadcastReceipt {
@@unique([broadcastId, ownerId])
@@index([ownerId, readAt])
}
// Push an mehrere Geräte (Roadmap 05.10.): ein Eintrag pro Gerät/App-Installation.
// Der Token ist eindeutig — meldet sich auf demselben Handy ein anderes Konto an,
// wandert der Eintrag zu diesem Konto. Tokens, die Expo als „DeviceNotRegistered“
// meldet (App deinstalliert), löscht src/lib/notify.ts beim Versand.
model PushToken {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
token String @unique
platform String? @db.VarChar(10) // "ios" | "android"
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now()) // bei jeder erneuten Registrierung (App-Start) aktualisiert
@@index([ownerId, lastSeenAt(sort: Desc)])
}
+6
View File
@@ -379,6 +379,7 @@ interface MockPrismaClient {
storyMention: { createMany: MockFn; findMany: MockFn; deleteMany: MockFn };
lostReport: { findFirst: MockFn; findUnique: MockFn; findMany: MockFn; count: MockFn; create: MockFn; update: MockFn; delete: MockFn };
verificationRequest: { findFirst: MockFn; findMany: MockFn; count: MockFn };
pushToken: { upsert: MockFn; findMany: MockFn; deleteMany: MockFn };
message: { findFirst: MockFn; findMany: MockFn; count: MockFn };
storyView: MockStoryViewClient;
follow: MockFollowClient;
@@ -512,6 +513,11 @@ export function createMockPrisma(): MockPrismaClient {
deleteMany: vi.fn(),
aggregate: vi.fn(),
},
pushToken: {
upsert: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
verificationRequest: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
+1 -1
View File
@@ -51,7 +51,7 @@ describe("lostReportAdmin", () => {
await lostReportAdminRouter.createCaller(ctx(prisma)).moderate({ reportId: "r1", mode: "CLOSE", reason: "vergessen" });
expect(prisma.lostReport.update).toHaveBeenCalledWith({ where: { id: "r1" }, data: { foundAt: expect.any(Date) } });
expect(prisma.lostReport.update).toHaveBeenCalledWith({ where: { id: "r1" }, data: { foundAt: expect.any(Date), contactPhone: null, contactEmail: null } });
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { lostSince: null } });
expect(prisma.moderationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "CLOSE_LOST_REPORT", targetId: "pet-1", reason: "vergessen", moderatorRole: "MODERATOR" }),
+1 -1
View File
@@ -125,7 +125,7 @@ describe("lostReports router", () => {
prisma.lostReport.findFirst.mockResolvedValue({ id: "r1" });
await caller(OWNER, prisma).markFound({ petId: "pet-1", notifyAudience: false });
expect(prisma.lostReport.update).toHaveBeenCalledWith({ where: { id: "r1" }, data: { foundAt: expect.any(Date) } });
expect(prisma.lostReport.update).toHaveBeenCalledWith({ where: { id: "r1" }, data: { foundAt: expect.any(Date), contactPhone: null, contactEmail: null } });
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { lostSince: null } });
expect(prisma.follow.findMany).not.toHaveBeenCalled();
+50 -13
View File
@@ -9,10 +9,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
const { sendExpoPushNotification } = vi.hoisted(() => ({
sendExpoPushNotification: vi.fn().mockResolvedValue(undefined),
const { sendExpoPushToTokens } = vi.hoisted(() => ({
sendExpoPushToTokens: vi.fn().mockResolvedValue({ invalidTokens: [] }),
}));
vi.mock("@/lib/push", () => ({ sendExpoPushNotification }));
vi.mock("@/lib/push", () => ({ sendExpoPushToTokens }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { notify } from "@/lib/notify";
@@ -26,7 +26,7 @@ async function flush() {
describe("notify() push add-on", () => {
beforeEach(() => {
sendExpoPushNotification.mockClear();
sendExpoPushToTokens.mockClear();
});
it("does not push when the recipient owner has no registered token", async () => {
@@ -39,7 +39,7 @@ describe("notify() push add-on", () => {
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" });
await flush();
expect(sendExpoPushNotification).not.toHaveBeenCalled();
expect(sendExpoPushToTokens).not.toHaveBeenCalled();
});
it("does not push when the recipient pet no longer exists", async () => {
@@ -51,7 +51,7 @@ describe("notify() push add-on", () => {
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "MESSAGE" });
await flush();
expect(sendExpoPushNotification).not.toHaveBeenCalled();
expect(sendExpoPushToTokens).not.toHaveBeenCalled();
expect(mockPrisma.owner.findUnique).not.toHaveBeenCalled();
});
@@ -70,8 +70,8 @@ describe("notify() push add-on", () => {
);
await flush();
expect(sendExpoPushNotification).toHaveBeenCalledWith(
"ExponentPushToken[abc]",
expect(sendExpoPushToTokens).toHaveBeenCalledWith(
["ExponentPushToken[abc]"],
expect.objectContaining({ title: "Neuer Follower", body: "Rex folgt dir jetzt." })
);
});
@@ -89,8 +89,8 @@ describe("notify() push add-on", () => {
);
await flush();
expect(sendExpoPushNotification).toHaveBeenCalledWith(
"ExponentPushToken[abc]",
expect(sendExpoPushToTokens).toHaveBeenCalledWith(
["ExponentPushToken[abc]"],
expect.objectContaining({ title: "Geburtstag", body: "Ein Tier hat heute Geburtstag." })
);
});
@@ -110,14 +110,14 @@ describe("notify() push add-on", () => {
);
await flush();
expect(sendExpoPushNotification).toHaveBeenCalledWith(
"ExponentPushToken[abc]",
expect(sendExpoPushToTokens).toHaveBeenCalledWith(
["ExponentPushToken[abc]"],
expect.objectContaining({ title: "Vorschlag für dich", body: "Luna könnte dir gefallen!" })
);
});
it("never throws from notify() even if the push request itself rejects", async () => {
sendExpoPushNotification.mockRejectedValueOnce(new Error("Expo push down"));
sendExpoPushToTokens.mockRejectedValueOnce(new Error("Expo push down"));
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
@@ -130,3 +130,40 @@ describe("notify() push add-on", () => {
await flush();
});
});
describe("notify() push to multiple devices", () => {
beforeEach(() => {
sendExpoPushToTokens.mockClear();
});
it("pushes to every registered device plus the legacy token, without duplicates", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValueOnce({ ownerId: "owner-1" }).mockResolvedValueOnce({ name: "Rex" });
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[phone]" }, { token: "ExponentPushToken[tablet]" }]);
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[phone]" });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" });
await flush();
expect(sendExpoPushToTokens).toHaveBeenCalledWith(["ExponentPushToken[phone]", "ExponentPushToken[tablet]"], expect.anything());
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith({ where: { ownerId: "owner-1" }, select: { token: true } });
});
it("deletes tokens Expo reports as no longer registered, including the legacy one", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValueOnce({ ownerId: "owner-1" }).mockResolvedValueOnce(null);
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[old]" }, { token: "ExponentPushToken[new]" }]);
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[old]" });
sendExpoPushToTokens.mockResolvedValueOnce({ invalidTokens: ["ExponentPushToken[old]"] });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "BIRTHDAY" });
await flush();
expect(mockPrisma.pushToken.deleteMany).toHaveBeenCalledWith({ where: { token: { in: ["ExponentPushToken[old]"] } } });
expect(mockPrisma.owner.update).toHaveBeenCalledWith({ where: { id: "owner-1" }, data: { expoPushToken: null } });
});
});
+44 -10
View File
@@ -163,20 +163,43 @@ describe("owner.setPetSuggestionsPreference", () => {
});
describe("owner.registerPushToken", () => {
it("upserts the Expo push token on the calling owner", async () => {
it("adds this device without touching other devices of the owner", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.upsert.mockResolvedValue({ id: "owner-1" });
const result = await caller.owner.registerPushToken({ expoPushToken: "ExponentPushToken[abc]" });
const result = await caller.owner.registerPushToken({ expoPushToken: "ExponentPushToken[abc]", platform: "ios" });
expect(mockPrisma.owner.upsert).toHaveBeenCalledWith({
where: { id: "owner-1" },
create: { id: "owner-1", expoPushToken: "ExponentPushToken[abc]" },
update: { expoPushToken: "ExponentPushToken[abc]" },
expect(mockPrisma.pushToken.upsert).toHaveBeenCalledWith({
where: { token: "ExponentPushToken[abc]" },
create: { ownerId: "owner-1", token: "ExponentPushToken[abc]", platform: "ios" },
update: { ownerId: "owner-1", lastSeenAt: expect.any(Date), platform: "ios" },
});
expect(mockPrisma.owner.upsert).toHaveBeenCalledWith(expect.objectContaining({ update: {} }));
expect(result).toEqual({ ok: true });
});
it("accepts older app versions without platform", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.upsert.mockResolvedValue({ id: "owner-1" });
await caller.owner.registerPushToken({ expoPushToken: "ExponentPushToken[abc]" });
expect(mockPrisma.pushToken.upsert).toHaveBeenCalledWith(
expect.objectContaining({ create: expect.objectContaining({ platform: null }), update: { ownerId: "owner-1", lastSeenAt: expect.any(Date) } })
);
});
it("keeps only the ten most recently used devices", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.upsert.mockResolvedValue({ id: "owner-1" });
mockPrisma.pushToken.findMany.mockResolvedValue([{ id: "old-1" }, { id: "old-2" }]);
await caller.owner.registerPushToken({ expoPushToken: "ExponentPushToken[new]" });
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { ownerId: "owner-1" }, skip: 10 }));
expect(mockPrisma.pushToken.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ["old-1", "old-2"] } } });
});
it("rejects an empty token", async () => {
const { caller } = await createOwnerCaller();
@@ -185,16 +208,27 @@ describe("owner.registerPushToken", () => {
});
describe("owner.clearPushToken", () => {
it("clears the stored token for the calling owner", async () => {
it("removes only this device on sign-out", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.owner.updateMany.mockResolvedValue({ count: 0 });
const result = await caller.owner.clearPushToken();
const result = await caller.owner.clearPushToken({ expoPushToken: "ExponentPushToken[abc]" });
expect(mockPrisma.pushToken.deleteMany).toHaveBeenCalledWith({ where: { ownerId: "owner-1", token: "ExponentPushToken[abc]" } });
expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({
where: { id: "owner-1" },
where: { id: "owner-1", expoPushToken: "ExponentPushToken[abc]" },
data: { expoPushToken: null },
});
expect(result).toEqual({ ok: true });
});
it("removes all devices of the owner without a token", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.updateMany.mockResolvedValue({ count: 1 });
await caller.owner.clearPushToken();
expect(mockPrisma.pushToken.deleteMany).toHaveBeenCalledWith({ where: { ownerId: "owner-1" } });
expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({ where: { id: "owner-1" }, data: { expoPushToken: null } });
});
});
+7 -7
View File
@@ -5,10 +5,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
const { sendExpoPushNotification } = vi.hoisted(() => ({
sendExpoPushNotification: vi.fn().mockResolvedValue(undefined),
const { sendExpoPushToTokens } = vi.hoisted(() => ({
sendExpoPushToTokens: vi.fn().mockResolvedValue({ invalidTokens: [] }),
}));
vi.mock("@/lib/push", () => ({ sendExpoPushNotification }));
vi.mock("@/lib/push", () => ({ sendExpoPushToTokens }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
@@ -84,8 +84,8 @@ describe("sendVaccineReminders", () => {
expect(mockPrisma.notification.create).toHaveBeenCalledWith({
data: { recipientPetId: "pet-1", type: "VACCINE_DUE", vaccineId: "vac-1" },
});
expect(sendExpoPushNotification).toHaveBeenCalledWith(
"ExponentPushToken[abc]",
expect(sendExpoPushToTokens).toHaveBeenCalledWith(
["ExponentPushToken[abc]"],
expect.objectContaining({
title: "Impfung bald fällig",
body: expect.stringContaining("„Tollwut“ für Bubi ist am 01.11.2026 fällig"),
@@ -101,12 +101,12 @@ describe("sendVaccineReminders", () => {
expect(result.sent).toBe(0);
expect(mockPrisma.notification.create).not.toHaveBeenCalled();
expect(sendExpoPushNotification).not.toHaveBeenCalled();
expect(sendExpoPushToTokens).not.toHaveBeenCalled();
});
it("still counts the reminder when the push fails", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
sendExpoPushNotification.mockRejectedValueOnce(new Error("expo down"));
sendExpoPushToTokens.mockRejectedValueOnce(new Error("expo down"));
vi.spyOn(console, "error").mockImplementation(() => {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
+1 -1
View File
@@ -33,7 +33,7 @@ export function pickRecipientsPerOwner(ownerId: string, followers: PetRef[], fol
* blockieren oder scheitern lassen. Gibt die Zahl der Empfänger zurück.
*/
export async function notifyLostReportAudience(
prisma: Pick<PrismaClient, "follow" | "notification" | "pet" | "owner">,
prisma: Pick<PrismaClient, "follow" | "notification" | "pet" | "owner" | "pushToken">,
pet: PetRef,
type: "PET_LOST" | "PET_FOUND"
): Promise<number> {
+21 -10
View File
@@ -1,6 +1,6 @@
import "server-only";
import type { PrismaClient, NotificationType } from "@prisma/client";
import { sendExpoPushNotification } from "@/lib/push";
import { sendExpoPushToTokens } from "@/lib/push";
interface NotifyParams {
recipientPetId: string;
@@ -27,7 +27,7 @@ export type PushExtras = { conversationId?: string };
* directly instead.
*/
export function notify(
prisma: Pick<PrismaClient, "notification" | "pet" | "owner">,
prisma: Pick<PrismaClient, "notification" | "pet" | "owner" | "pushToken">,
params: NotifyParams,
extras?: PushExtras
): void {
@@ -68,7 +68,7 @@ const PUSH_COPY: Record<
};
/**
* Best-effort Expo push to the recipient owner's registered device, if any.
* Best-effort Expo push to all of the recipient owner's registered devices, if any.
* Separate from the in-app Notification row above — a missing/expired push
* token, or the owner never having opened the mobile app, is the normal case
* and must silently no-op rather than error.
@@ -80,7 +80,7 @@ const PUSH_COPY: Record<
* vaccine name and due date for VACCINE_DUE).
*/
export async function sendPushForNotification(
prisma: Pick<PrismaClient, "pet" | "owner">,
prisma: Pick<PrismaClient, "pet" | "owner" | "pushToken">,
params: NotifyParams,
bodyOverride?: string,
extras?: PushExtras
@@ -91,18 +91,21 @@ export async function sendPushForNotification(
});
if (!recipientPet) return;
const owner = await prisma.owner.findUnique({
where: { id: recipientPet.ownerId },
select: { expoPushToken: true },
});
if (!owner?.expoPushToken) return;
// Alle Geräte des Halters (PushToken) plus der alte Einzel-Token aus der Zeit
// vor „Push an mehrere Geräte“, bis jedes Gerät sich neu registriert hat.
const [devices, owner] = await Promise.all([
prisma.pushToken.findMany({ where: { ownerId: recipientPet.ownerId }, select: { token: true } }),
prisma.owner.findUnique({ where: { id: recipientPet.ownerId }, select: { expoPushToken: true } }),
]);
const tokens = [...new Set([...devices.map((d) => d.token), ...(owner?.expoPushToken ? [owner.expoPushToken] : [])])];
if (tokens.length === 0) return;
const actorPet = params.actorPetId
? await prisma.pet.findUnique({ where: { id: params.actorPetId }, select: { name: true } })
: null;
const copy = PUSH_COPY[params.type];
await sendExpoPushNotification(owner.expoPushToken, {
const { invalidTokens } = await sendExpoPushToTokens(tokens, {
title: copy.title,
body: bodyOverride ?? (actorPet ? copy.actorBody(actorPet.name) : copy.genericBody),
// Ziele für das Antippen der Push-Mitteilung (App: src/lib/notification-route.ts).
@@ -116,4 +119,12 @@ export async function sendPushForNotification(
conversationId: extras?.conversationId ?? "",
},
});
// Deinstallierte Apps/abgelaufene Tokens aufräumen, damit sie nicht bei jeder Mitteilung erneut scheitern.
if (invalidTokens.length > 0) {
await prisma.pushToken.deleteMany({ where: { token: { in: invalidTokens } } });
if (owner?.expoPushToken && invalidTokens.includes(owner.expoPushToken)) {
await prisma.owner.update({ where: { id: recipientPet.ownerId }, data: { expoPushToken: null } });
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, afterEach } from "vitest";
vi.mock("server-only", () => ({}));
import { sendExpoPushToTokens } from "@/lib/push";
const payload = { title: "T", body: "B" };
afterEach(() => vi.unstubAllGlobals());
describe("sendExpoPushToTokens", () => {
it("sends one batched request and reports DeviceNotRegistered tokens", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ status: "ok" }, { status: "error", details: { error: "DeviceNotRegistered" } }, { status: "error", details: { error: "MessageRateExceeded" } }] }),
});
vi.stubGlobal("fetch", fetchMock);
const result = await sendExpoPushToTokens(["a", "b", "c"], payload);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0]![1].body);
expect(body.map((m: { to: string }) => m.to)).toEqual(["a", "b", "c"]);
expect(result.invalidTokens).toEqual(["b"]);
});
it("splits more than 100 tokens into several requests", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [] }) });
vi.stubGlobal("fetch", fetchMock);
await sendExpoPushToTokens(Array.from({ length: 150 }, (_, i) => `t${i}`), payload);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("throws on HTTP errors so the caller can log them", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 }));
await expect(sendExpoPushToTokens(["a"], payload)).rejects.toThrow("503");
});
});
+32 -19
View File
@@ -1,32 +1,45 @@
import "server-only";
const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
/** Expo nimmt höchstens 100 Nachrichten pro Anfrage an. */
const EXPO_MAX_BATCH = 100;
interface PushPayload {
export interface PushPayload {
title: string;
body: string;
data?: Record<string, string>;
}
interface ExpoTicket {
status: "ok" | "error";
details?: { error?: string };
}
/**
* Sends one push through Expo's push service (no expo-server-sdk dependency
* needed — it's a plain JSON POST). Never throws: callers (notify.ts) treat
* push delivery as best-effort and must not let a failure here affect the
* in-app notification it's paired with.
* Schickt dieselbe Push-Mitteilung an mehrere Geräte über Expos Push-Dienst
* (reiner JSON-POST, kein expo-server-sdk nötig). Liefert die Tokens zurück,
* die Expo als „DeviceNotRegistered“ meldet (App deinstalliert/Token abgelaufen) —
* der Aufrufer löscht sie. Wirft bei HTTP-Fehlern; notify.ts fängt das ab, weil
* Push immer nur „best effort“ neben der In-App-Mitteilung ist.
*/
export async function sendExpoPushNotification(token: string, payload: PushPayload): Promise<void> {
const response = await fetch(EXPO_PUSH_URL, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
to: token,
title: payload.title,
body: payload.body,
data: payload.data ?? {},
sound: "default",
}),
});
if (!response.ok) {
throw new Error(`Expo push request failed with status ${response.status}`);
export async function sendExpoPushToTokens(tokens: string[], payload: PushPayload): Promise<{ invalidTokens: string[] }> {
const invalidTokens: string[] = [];
for (let i = 0; i < tokens.length; i += EXPO_MAX_BATCH) {
const batch = tokens.slice(i, i + EXPO_MAX_BATCH);
const response = await fetch(EXPO_PUSH_URL, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(
batch.map((to) => ({ to, title: payload.title, body: payload.body, data: payload.data ?? {}, sound: "default" }))
),
});
if (!response.ok) {
throw new Error(`Expo push request failed with status ${response.status}`);
}
const json = (await response.json().catch(() => null)) as { data?: ExpoTicket[] } | null;
json?.data?.forEach((ticket, index) => {
if (ticket.status === "error" && ticket.details?.error === "DeviceNotRegistered") invalidTokens.push(batch[index]!);
});
}
return { invalidTokens };
}
+2 -2
View File
@@ -61,7 +61,7 @@ export async function filterMentionablePetIds(
* nicht die eigenen Tiere des Erstellers.
*/
export async function saveStoryMentions(
prisma: Pick<PrismaClient, "storyMention" | "notification" | "pet" | "owner">,
prisma: Pick<PrismaClient, "storyMention" | "notification" | "pet" | "owner" | "pushToken">,
storyId: string,
actorPetId: string,
petIds: readonly string[],
@@ -95,7 +95,7 @@ export async function petsOfOtherOwners(
/** Fire-and-forget wie notify() selbst — darf das Erstellen der Story nie scheitern lassen. */
export function notifyStoryMentions(
prisma: Pick<PrismaClient, "notification" | "pet" | "owner">,
prisma: Pick<PrismaClient, "notification" | "pet" | "owner" | "pushToken">,
storyId: string,
actorPetId: string,
petIds: readonly string[]
+1 -1
View File
@@ -8,7 +8,7 @@ export const VACCINE_REMINDER_LEAD_DAYS = 30;
const MAX_REMINDERS_PER_RUN = 100;
const DAY_MS = 24 * 60 * 60 * 1000;
type ReminderPrisma = Pick<PrismaClient, "vaccine" | "notification" | "pet" | "owner">;
type ReminderPrisma = Pick<PrismaClient, "vaccine" | "notification" | "pet" | "owner" | "pushToken">;
export interface VaccineReminderResult {
sent: number;
+1 -1
View File
@@ -77,7 +77,7 @@ export const lostReportAdminRouter = router({
await ctx.prisma.$transaction([
input.mode === "DELETE"
? ctx.prisma.lostReport.delete({ where: { id: report.id } })
: ctx.prisma.lostReport.update({ where: { id: report.id }, data: { foundAt: report.foundAt ?? new Date() } }),
: ctx.prisma.lostReport.update({ where: { id: report.id }, data: { foundAt: report.foundAt ?? new Date(), contactPhone: null, contactEmail: null } }),
// Abzeichen nur entfernen, wenn es diese Meldung war (aktive Meldung).
...(report.foundAt ? [] : [ctx.prisma.pet.update({ where: { id: report.petId }, data: { lostSince: null } })]),
ctx.prisma.moderationLog.create({
+1 -1
View File
@@ -137,7 +137,7 @@ export const lostReportsRouter = router({
if (!active) throw new TRPCError({ code: "NOT_FOUND" });
await ctx.prisma.$transaction([
ctx.prisma.lostReport.update({ where: { id: active.id }, data: { foundAt: new Date() } }),
ctx.prisma.lostReport.update({ where: { id: active.id }, data: { foundAt: new Date(), contactPhone: null, contactEmail: null } }), // Kontaktdaten nach dem Finden nicht weiter aufbewahren (Datensparsamkeit)
ctx.prisma.pet.update({ where: { id: input.petId }, data: { lostSince: null } }),
]);
+38 -13
View File
@@ -2,6 +2,9 @@ import "server-only";
import { z } from "zod";
import { router, protectedProcedure } from "@/trpc/init";
/** Höchstens so viele Geräte pro Konto bekommen Push (die zuletzt genutzten). */
const MAX_PUSH_DEVICES = 10;
export const ownerRouter = router({
getThemePreference: protectedProcedure.query(async ({ ctx }) => {
const owner = await ctx.prisma.owner.findUnique({
@@ -67,25 +70,47 @@ export const ownerRouter = router({
return { ok: true };
}),
// Registers this device's Expo push token for the signed-in owner. Single
// device only (overwrites any previous token) — good enough until a device
// actually needs the app on two phones at once.
// Push an mehrere Geräte: trägt dieses Gerät (Expo-Push-Token) für den
// angemeldeten Halter ein. Der Token ist eindeutig — meldet sich auf demselben
// Handy ein anderes Konto an, wandert er dorthin. Ältere App-Versionen schicken
// keine Plattform mit; das ist erlaubt.
registerPushToken: protectedProcedure
.input(z.object({ expoPushToken: z.string().min(1) }))
.input(z.object({ expoPushToken: z.string().min(1).max(200), platform: z.enum(["ios", "android"]).optional() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.owner.upsert({
where: { id: ctx.userId },
create: { id: ctx.userId, expoPushToken: input.expoPushToken, registrationIp: ctx.ip },
update: { expoPushToken: input.expoPushToken },
create: { id: ctx.userId, registrationIp: ctx.ip },
update: {},
});
await ctx.prisma.pushToken.upsert({
where: { token: input.expoPushToken },
create: { ownerId: ctx.userId, token: input.expoPushToken, platform: input.platform ?? null },
update: { ownerId: ctx.userId, lastSeenAt: new Date(), ...(input.platform ? { platform: input.platform } : {}) },
});
// Nicht unbegrenzt sammeln: nur die zuletzt genutzten Geräte behalten.
const stale = await ctx.prisma.pushToken.findMany({
where: { ownerId: ctx.userId },
orderBy: { lastSeenAt: "desc" },
skip: MAX_PUSH_DEVICES,
select: { id: true },
});
if (stale.length > 0) {
await ctx.prisma.pushToken.deleteMany({ where: { id: { in: stale.map((t) => t.id) } } });
}
return { ok: true };
}),
clearPushToken: protectedProcedure.mutation(async ({ ctx }) => {
await ctx.prisma.owner.updateMany({
where: { id: ctx.userId },
data: { expoPushToken: null },
});
return { ok: true };
}),
// Beim Abmelden: nur dieses Gerät austragen (mit Token), sonst alle Geräte des Kontos.
clearPushToken: protectedProcedure
.input(z.object({ expoPushToken: z.string().min(1).max(200) }).optional())
.mutation(async ({ ctx, input }) => {
await ctx.prisma.pushToken.deleteMany({
where: { ownerId: ctx.userId, ...(input?.expoPushToken ? { token: input.expoPushToken } : {}) },
});
await ctx.prisma.owner.updateMany({
where: { id: ctx.userId, ...(input?.expoPushToken ? { expoPushToken: input.expoPushToken } : {}) },
data: { expoPushToken: null },
});
return { ok: true };
}),
});