feat: add Expo push notification infrastructure

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-06 09:49:02 +02:00
1 parent eda6d3b801
commit 0323e8d449
7 files changed
+259 -1

No files matched your search

+1
View File
@@ -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
+2
View File
@@ -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 —
+111
View File
@@ -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();
});
});
+37
View File
@@ -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 });
});
});
+54 -1
View File
@@ -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<PrismaClient, "notification">, params: NotifyParams): void {
export function notify(
prisma: Pick<PrismaClient, "notification" | "pet" | "owner">,
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<PrismaClient, "pet" | "owner">,
params: NotifyParams
): Promise<void> {
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 ?? "" },
});
}
+32
View File
@@ -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<string, 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.
*/
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}`);
}
}
+22
View File
@@ -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 };
}),
});