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:
1 parent
eda6d3b801
commit
0323e8d449
7 files changed
+259
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 ?? "" },
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user