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:
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();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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)])
|
||||
}
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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 } });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user