diff --git a/mobile/src/components/settings/LogoutSection.tsx b/mobile/src/components/settings/LogoutSection.tsx index 4aaa417..8d2f419 100644 --- a/mobile/src/components/settings/LogoutSection.tsx +++ b/mobile/src/components/settings/LogoutSection.tsx @@ -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(); }, }, ]); diff --git a/mobile/src/hooks/usePushNotifications.ts b/mobile/src/hooks/usePushNotifications.ts index 98544a8..3f876f9 100644 --- a/mobile/src/hooks/usePushNotifications.ts +++ b/mobile/src/hooks/usePushNotifications.ts @@ -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); } diff --git a/prisma/cleanup_lost_report_contacts.sql b/prisma/cleanup_lost_report_contacts.sql new file mode 100644 index 0000000..4086818 --- /dev/null +++ b/prisma/cleanup_lost_report_contacts.sql @@ -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); diff --git a/prisma/enable_rls_push_tokens.sql b/prisma/enable_rls_push_tokens.sql new file mode 100644 index 0000000..c55d380 --- /dev/null +++ b/prisma/enable_rls_push_tokens.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 549ba95..d889647 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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)]) +} diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 3137450..769ec29 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -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([]), diff --git a/src/__tests__/lost-report-admin.test.ts b/src/__tests__/lost-report-admin.test.ts index a28f93a..1617868 100644 --- a/src/__tests__/lost-report-admin.test.ts +++ b/src/__tests__/lost-report-admin.test.ts @@ -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" }), diff --git a/src/__tests__/lost-reports.test.ts b/src/__tests__/lost-reports.test.ts index 5fc7db1..b0af894 100644 --- a/src/__tests__/lost-reports.test.ts +++ b/src/__tests__/lost-reports.test.ts @@ -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(); diff --git a/src/__tests__/notify-push.test.ts b/src/__tests__/notify-push.test.ts index cc066e8..5a36408 100644 --- a/src/__tests__/notify-push.test.ts +++ b/src/__tests__/notify-push.test.ts @@ -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 } }); + }); +}); diff --git a/src/__tests__/owner.test.ts b/src/__tests__/owner.test.ts index 2fc7e34..7f93412 100644 --- a/src/__tests__/owner.test.ts +++ b/src/__tests__/owner.test.ts @@ -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 } }); + }); }); diff --git a/src/__tests__/vaccine-reminders-cron.test.ts b/src/__tests__/vaccine-reminders-cron.test.ts index ae2bba6..5946d4f 100644 --- a/src/__tests__/vaccine-reminders-cron.test.ts +++ b/src/__tests__/vaccine-reminders-cron.test.ts @@ -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); diff --git a/src/lib/lost-reports.ts b/src/lib/lost-reports.ts index 522d7ba..151c678 100644 --- a/src/lib/lost-reports.ts +++ b/src/lib/lost-reports.ts @@ -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, + prisma: Pick, pet: PetRef, type: "PET_LOST" | "PET_FOUND" ): Promise { diff --git a/src/lib/notify.ts b/src/lib/notify.ts index a647c0a..2c28d9d 100644 --- a/src/lib/notify.ts +++ b/src/lib/notify.ts @@ -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, + prisma: Pick, 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, + prisma: Pick, 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 } }); + } + } } diff --git a/src/lib/push.test.ts b/src/lib/push.test.ts new file mode 100644 index 0000000..c1803b5 --- /dev/null +++ b/src/lib/push.test.ts @@ -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"); + }); +}); diff --git a/src/lib/push.ts b/src/lib/push.ts index d0f8191..c458efd 100644 --- a/src/lib/push.ts +++ b/src/lib/push.ts @@ -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; } +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 { - 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 }; } diff --git a/src/lib/story-mentions.ts b/src/lib/story-mentions.ts index 5b2cdb1..a9b5640 100644 --- a/src/lib/story-mentions.ts +++ b/src/lib/story-mentions.ts @@ -61,7 +61,7 @@ export async function filterMentionablePetIds( * nicht die eigenen Tiere des Erstellers. */ export async function saveStoryMentions( - prisma: Pick, + prisma: Pick, 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, + prisma: Pick, storyId: string, actorPetId: string, petIds: readonly string[] diff --git a/src/lib/vaccine-reminders.ts b/src/lib/vaccine-reminders.ts index 5a548dd..59ec7be 100644 --- a/src/lib/vaccine-reminders.ts +++ b/src/lib/vaccine-reminders.ts @@ -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; +type ReminderPrisma = Pick; export interface VaccineReminderResult { sent: number; diff --git a/src/trpc/routers/lost-report-admin.ts b/src/trpc/routers/lost-report-admin.ts index cc5ad6c..d8306c7 100644 --- a/src/trpc/routers/lost-report-admin.ts +++ b/src/trpc/routers/lost-report-admin.ts @@ -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({ diff --git a/src/trpc/routers/lost-reports.ts b/src/trpc/routers/lost-reports.ts index fa72dde..fc334a6 100644 --- a/src/trpc/routers/lost-reports.ts +++ b/src/trpc/routers/lost-reports.ts @@ -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 } }), ]); diff --git a/src/trpc/routers/owner.ts b/src/trpc/routers/owner.ts index f3babbb..c82bb88 100644 --- a/src/trpc/routers/owner.ts +++ b/src/trpc/routers/owner.ts @@ -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 }; + }), });