From 0323e8d449047529eaecc522da10682c22875cd0 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 6 Sep 2026 09:49:02 +0200 Subject: [PATCH] feat: add Expo push notification infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner.expoPushToken + owner.registerPushToken/clearPushToken, plus a best-effort Expo push send wired into notify() alongside the existing in-app Notification row. Missing/expired token or unreachable Expo push service silently no-ops — push delivery must never break the underlying action it's attached to. Co-Authored-By: Claude Sonnet 5 --- prisma/schema.prisma | 1 + src/__tests__/helpers/prisma-mock.ts | 2 + src/__tests__/notify-push.test.ts | 111 +++++++++++++++++++++++++++ src/__tests__/owner.test.ts | 37 +++++++++ src/lib/notify.ts | 55 ++++++++++++- src/lib/push.ts | 32 ++++++++ src/trpc/routers/owner.ts | 22 ++++++ 7 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/notify-push.test.ts create mode 100644 src/lib/push.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6045305..e8e3d89 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,7 @@ model Owner { warnings Warning[] 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 + expoPushToken String? // mobile app's Expo push token, registered via owner.registerPushToken; single device only, overwritten on re-register // Sponsoring (Gitea #22) — freiwilliger Beitrag/Abo gegen ein "Sponsor"-Badge. stripeCustomerId String? @unique diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 727d5ba..a1f693c 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -30,6 +30,7 @@ interface MockOwnerClient { findMany: MockFn; create: MockFn; update: MockFn; + updateMany: MockFn; count: MockFn; } @@ -382,6 +383,7 @@ export function createMockPrisma(): MockPrismaClient { findMany: vi.fn(), create: vi.fn(), update: vi.fn(), + updateMany: vi.fn(), count: vi.fn(), }, // pets.create's idempotent starter-invite-code seeding runs on every call — diff --git a/src/__tests__/notify-push.test.ts b/src/__tests__/notify-push.test.ts new file mode 100644 index 0000000..5504570 --- /dev/null +++ b/src/__tests__/notify-push.test.ts @@ -0,0 +1,111 @@ +/** + * Unit tests for notify.ts's best-effort Expo push add-on (Gitea mobile + * push-notifications task). The in-app Notification row is covered by the + * existing per-router tests (follows, reactions, comments, messages); this + * file covers only the push side-effect notify() fires alongside it. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("server-only", () => ({})); + +const { sendExpoPushNotification } = vi.hoisted(() => ({ + sendExpoPushNotification: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/push", () => ({ sendExpoPushNotification })); + +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; +import { notify } from "@/lib/notify"; + +// notify() fires the push asynchronously and doesn't expose a handle to await — +// flush microtasks so the fire-and-forget chain settles before assertions. +async function flush() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("notify() push add-on", () => { + beforeEach(() => { + sendExpoPushNotification.mockClear(); + }); + + it("does not push when the recipient owner has no registered token", async () => { + const mockPrisma = createMockPrisma(); + mockPrisma.notification.create.mockResolvedValue({}); + mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" }); + mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: null }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + notify(mockPrisma as any, { recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" }); + await flush(); + + expect(sendExpoPushNotification).not.toHaveBeenCalled(); + }); + + it("does not push when the recipient pet no longer exists", async () => { + const mockPrisma = createMockPrisma(); + mockPrisma.notification.create.mockResolvedValue({}); + mockPrisma.pet.findUnique.mockResolvedValue(null); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + notify(mockPrisma as any, { recipientPetId: "pet-1", type: "MESSAGE" }); + await flush(); + + expect(sendExpoPushNotification).not.toHaveBeenCalled(); + expect(mockPrisma.owner.findUnique).not.toHaveBeenCalled(); + }); + + it("sends a personalized push with the actor pet's name when a token is registered", async () => { + const mockPrisma = createMockPrisma(); + mockPrisma.notification.create.mockResolvedValue({}); + mockPrisma.pet.findUnique + .mockResolvedValueOnce({ ownerId: "owner-1" }) // recipient lookup + .mockResolvedValueOnce({ name: "Rex" }); // actor lookup + mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" }); + + notify( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPrisma as any, + { recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" } + ); + await flush(); + + expect(sendExpoPushNotification).toHaveBeenCalledWith( + "ExponentPushToken[abc]", + expect.objectContaining({ title: "Neuer Follower", body: "Rex folgt dir jetzt." }) + ); + }); + + it("falls back to generic copy when there is no actor pet (e.g. birthday/anniversary cron)", async () => { + const mockPrisma = createMockPrisma(); + mockPrisma.notification.create.mockResolvedValue({}); + mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" }); + mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" }); + + notify( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPrisma as any, + { recipientPetId: "pet-1", type: "BIRTHDAY" } + ); + await flush(); + + expect(sendExpoPushNotification).toHaveBeenCalledWith( + "ExponentPushToken[abc]", + expect.objectContaining({ title: "Geburtstag", body: "Ein Tier hat heute Geburtstag." }) + ); + }); + + it("never throws from notify() even if the push request itself rejects", async () => { + sendExpoPushNotification.mockRejectedValueOnce(new Error("Expo push down")); + const mockPrisma = createMockPrisma(); + mockPrisma.notification.create.mockResolvedValue({}); + mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" }); + mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" }); + + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + notify(mockPrisma as any, { recipientPetId: "pet-1", type: "COMMENT", actorPetId: "pet-2" }) + ).not.toThrow(); + await flush(); + }); +}); diff --git a/src/__tests__/owner.test.ts b/src/__tests__/owner.test.ts index 1b476cd..7ae28ef 100644 --- a/src/__tests__/owner.test.ts +++ b/src/__tests__/owner.test.ts @@ -125,3 +125,40 @@ describe("owner.setHideSpidersPreference", () => { expect(result).toEqual({ ok: true }); }); }); + +describe("owner.registerPushToken", () => { + it("upserts the Expo push token on the calling owner", async () => { + const { caller, mockPrisma } = await createOwnerCaller(); + mockPrisma.owner.upsert.mockResolvedValue({ id: "owner-1" }); + + const result = await caller.owner.registerPushToken({ expoPushToken: "ExponentPushToken[abc]" }); + + expect(mockPrisma.owner.upsert).toHaveBeenCalledWith({ + where: { id: "owner-1" }, + create: { id: "owner-1", expoPushToken: "ExponentPushToken[abc]" }, + update: { expoPushToken: "ExponentPushToken[abc]" }, + }); + expect(result).toEqual({ ok: true }); + }); + + it("rejects an empty token", async () => { + const { caller } = await createOwnerCaller(); + + await expect(caller.owner.registerPushToken({ expoPushToken: "" })).rejects.toThrow(); + }); +}); + +describe("owner.clearPushToken", () => { + it("clears the stored token for the calling owner", async () => { + const { caller, mockPrisma } = await createOwnerCaller(); + mockPrisma.owner.updateMany.mockResolvedValue({ count: 1 }); + + const result = await caller.owner.clearPushToken(); + + expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({ + where: { id: "owner-1" }, + data: { expoPushToken: null }, + }); + expect(result).toEqual({ ok: true }); + }); +}); diff --git a/src/lib/notify.ts b/src/lib/notify.ts index d1da5b9..6ea3ecd 100644 --- a/src/lib/notify.ts +++ b/src/lib/notify.ts @@ -1,5 +1,6 @@ import "server-only"; import type { PrismaClient, NotificationType } from "@prisma/client"; +import { sendExpoPushNotification } from "@/lib/push"; interface NotifyParams { recipientPetId: string; @@ -17,6 +18,58 @@ interface NotifyParams { * cron jobs that track a created-count), call prisma.notification.create * directly instead. */ -export function notify(prisma: Pick, params: NotifyParams): void { +export function notify( + prisma: Pick, + params: NotifyParams +): void { prisma.notification.create({ data: params }).catch((err) => console.error("[notify] failed", err)); + sendPushForNotification(prisma, params).catch((err) => console.error("[notify] push failed", err)); +} + +const PUSH_COPY: Record< + NotificationType, + { title: string; genericBody: string; actorBody: (actorName: string) => string } +> = { + FOLLOW: { title: "Neuer Follower", genericBody: "Du hast einen neuen Follower.", actorBody: (a) => `${a} folgt dir jetzt.` }, + REACTION: { title: "Neue Reaktion", genericBody: "Jemand hat auf deinen Beitrag reagiert.", actorBody: (a) => `${a} hat auf deinen Beitrag reagiert.` }, + COMMENT: { title: "Neuer Kommentar", genericBody: "Neuer Kommentar zu deinem Beitrag.", actorBody: (a) => `${a} hat deinen Beitrag kommentiert.` }, + MESSAGE: { title: "Neue Nachricht", genericBody: "Du hast eine neue Nachricht erhalten.", actorBody: (a) => `${a} hat dir eine Nachricht geschickt.` }, + BIRTHDAY: { title: "Geburtstag", genericBody: "Ein Tier hat heute Geburtstag.", actorBody: (a) => `${a} hat heute Geburtstag!` }, + ADOPTION_DAY: { title: "Adoptionstag", genericBody: "Ein Adoptionstag steht an.", actorBody: (a) => `${a} feiert heute den Adoptionstag!` }, + MENTION: { title: "Erwähnung", genericBody: "Du wurdest in einem Beitrag erwähnt.", actorBody: (a) => `${a} hat dich erwähnt.` }, + PAW_BACK: { title: "Paw-Back", genericBody: "Jemand hat deinen Beitrag geteilt.", actorBody: (a) => `${a} hat deinen Beitrag geteilt.` }, +}; + +/** + * Best-effort Expo push to the recipient owner's registered device, 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. + */ +async function sendPushForNotification( + prisma: Pick, + params: NotifyParams +): Promise { + const recipientPet = await prisma.pet.findUnique({ + where: { id: params.recipientPetId }, + select: { ownerId: true }, + }); + if (!recipientPet) return; + + const owner = await prisma.owner.findUnique({ + where: { id: recipientPet.ownerId }, + select: { expoPushToken: true }, + }); + if (!owner?.expoPushToken) 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, { + title: copy.title, + body: actorPet ? copy.actorBody(actorPet.name) : copy.genericBody, + data: { type: params.type, postId: params.postId ?? "", commentId: params.commentId ?? "" }, + }); } diff --git a/src/lib/push.ts b/src/lib/push.ts new file mode 100644 index 0000000..d0f8191 --- /dev/null +++ b/src/lib/push.ts @@ -0,0 +1,32 @@ +import "server-only"; + +const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; + +interface PushPayload { + title: string; + body: string; + data?: Record; +} + +/** + * 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. + */ +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}`); + } +} diff --git a/src/trpc/routers/owner.ts b/src/trpc/routers/owner.ts index 5fdae24..b738392 100644 --- a/src/trpc/routers/owner.ts +++ b/src/trpc/routers/owner.ts @@ -45,4 +45,26 @@ 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. + registerPushToken: protectedProcedure + .input(z.object({ expoPushToken: z.string().min(1) })) + .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 }, + }); + return { ok: true }; + }), + + clearPushToken: protectedProcedure.mutation(async ({ ctx }) => { + await ctx.prisma.owner.updateMany({ + where: { id: ctx.userId }, + data: { expoPushToken: null }, + }); + return { ok: true }; + }), });