feat: Datumseingabe TT.MM.JJJJ (App) + Erinnerung an Folgeimpfung

Datum (found.md, iOS-Feedback): neue DateField-Komponente mit Ziffern-
tastatur und automatisch eingefügten Punkten (TT.MM.JJJJ) für Geburtstag,
Adoptionstag, Gewicht, Tierarzt und Impfungen. Parser akzeptiert weiter
"-" und "/" sowie einstellige Tage/Monate. Wheel-Picker bleibt in
PENDING-NATIVE-BUILD.md (natives Modul).

Impf-Erinnerung (found.md, iOS-Feedback): täglicher Cron
/api/cron/vaccine-reminders erinnert einmalig 30 Tage vor
Vaccine.nextDueDate per Mitteilung (VACCINE_DUE) + Push mit Impfname und
Datum. Schema: NotificationType.VACCINE_DUE, Notification.vaccineId
(Cascade), Vaccine.reminderSentAt (Claim gegen Doppelversand, wird bei
Fehler wieder freigegeben). Web + App zeigen die Mitteilung an, Tippen
öffnet die Gesundheitsseite. App: Schnellauswahl +1/+3 Jahre für den
Folgetermin plus Hinweis auf die Erinnerung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-04 12:42:20 +02:00
1 parent 7169840000
commit c74bbffccb
20 files changed
+461 -44

No files matched your search

+2
View File
@@ -284,6 +284,7 @@ interface MockVaccineClient {
create: MockFn;
delete: MockFn;
deleteMany: MockFn;
updateMany: MockFn;
}
interface MockHealthCardClient {
@@ -654,6 +655,7 @@ export function createMockPrisma(): MockPrismaClient {
create: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
updateMany: vi.fn(),
},
healthCard: {
findUnique: vi.fn(),
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { addYearsToDisplayDate, displayDateToIso, formatDateInput, isoToDisplayDate } from "../../mobile/src/lib/date-format";
describe("mobile date-format", () => {
it("displays ISO dates with dots", () => {
expect(isoToDisplayDate("2026-10-04")).toBe("04.10.2026");
});
it("parses dotted, dashed and slashed input to ISO", () => {
expect(displayDateToIso("04.10.2026")).toBe("2026-10-04");
expect(displayDateToIso("04-10-2026")).toBe("2026-10-04");
expect(displayDateToIso("04/10/2026")).toBe("2026-10-04");
});
it("pads one-digit day and month", () => {
expect(displayDateToIso("4.1.2026")).toBe("2026-01-04");
});
it("rejects impossible calendar dates and garbage", () => {
expect(displayDateToIso("31.02.2026")).toBeNull();
expect(displayDateToIso("2026-10-04")).toBeNull();
expect(displayDateToIso("abc")).toBeNull();
});
it("masks typed digits as DD.MM.YYYY", () => {
expect(formatDateInput("04")).toBe("04");
expect(formatDateInput("041")).toBe("04.1");
expect(formatDateInput("0410")).toBe("04.10");
expect(formatDateInput("04102026")).toBe("04.10.2026");
expect(formatDateInput("041020269")).toBe("04.10.2026");
});
it("lets backspace remove a trailing separator instead of re-adding it", () => {
expect(formatDateInput("04.")).toBe("04");
});
it("suggests a next due date some years ahead", () => {
expect(addYearsToDisplayDate("04.10.2026", 1)).toBe("04.10.2027");
expect(addYearsToDisplayDate("04.10.2026", 3)).toBe("04.10.2029");
expect(addYearsToDisplayDate("29.02.2028", 1)).toBe("01.03.2029");
expect(addYearsToDisplayDate("", 1)).toBeNull();
});
});
@@ -0,0 +1,131 @@
/**
* /api/cron/vaccine-reminders — erinnert 30 Tage vor dem Impf-Folgetermin.
*/
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";
const mockPrisma = createMockPrisma();
// Getter: vi.mock wird nach oben gehoben, mockPrisma existiert erst beim Zugriff.
vi.mock("@/lib/prisma", () => ({
get prisma() {
return mockPrisma;
},
}));
import { GET } from "@/app/api/cron/vaccine-reminders/route";
import { sendVaccineReminders } from "@/lib/vaccine-reminders";
const request = (auth?: string) =>
new Request("https://pawfeed.org/api/cron/vaccine-reminders", { headers: auth ? { authorization: auth } : {} });
const dueVaccine = {
id: "vac-1",
petId: "pet-1",
name: "Tollwut",
nextDueDate: new Date("2026-11-01T00:00:00Z"),
pet: { name: "Bubi" },
};
beforeEach(() => {
vi.clearAllMocks();
process.env.CRON_SECRET = "test-secret";
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.vaccine.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" });
});
describe("GET /api/cron/vaccine-reminders", () => {
it("rejects requests without the cron secret", async () => {
expect((await GET(request())).status).toBe(401);
expect((await GET(request("Bearer wrong"))).status).toBe(401);
expect(mockPrisma.vaccine.findMany).not.toHaveBeenCalled();
});
it("returns the run summary", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
const res = await GET(request("Bearer test-secret"));
expect(await res.json()).toEqual({ ok: true, sent: 1, failed: 0, more: false });
});
});
describe("sendVaccineReminders", () => {
const now = new Date("2026-10-04T08:00:00Z");
it("only looks at unreminded vaccines due between today and 30 days from now", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendVaccineReminders(mockPrisma as any, now);
const where = mockPrisma.vaccine.findMany.mock.calls[0][0].where;
expect(where.reminderSentAt).toBeNull();
expect(where.nextDueDate.gte.toISOString()).toBe("2026-10-04T00:00:00.000Z");
expect(where.nextDueDate.lte.toISOString()).toBe("2026-11-03T00:00:00.000Z");
});
it("claims the vaccine, creates a VACCINE_DUE notification and pushes the details", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result).toEqual({ sent: 1, failed: 0, more: false });
expect(mockPrisma.vaccine.updateMany).toHaveBeenCalledWith({
where: { id: "vac-1", reminderSentAt: null },
data: { reminderSentAt: now },
});
expect(mockPrisma.notification.create).toHaveBeenCalledWith({
data: { recipientPetId: "pet-1", type: "VACCINE_DUE", vaccineId: "vac-1" },
});
expect(sendExpoPushNotification).toHaveBeenCalledWith(
"ExponentPushToken[abc]",
expect.objectContaining({
title: "Impfung bald fällig",
body: expect.stringContaining("„Tollwut“ für Bubi ist am 01.11.2026 fällig"),
})
);
});
it("skips a vaccine another run already claimed", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
mockPrisma.vaccine.updateMany.mockResolvedValue({ count: 0 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result.sent).toBe(0);
expect(mockPrisma.notification.create).not.toHaveBeenCalled();
expect(sendExpoPushNotification).not.toHaveBeenCalled();
});
it("still counts the reminder when the push fails", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
sendExpoPushNotification.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);
expect(result).toEqual({ sent: 1, failed: 0, more: false });
});
it("keeps going after a failing vaccine and reports it", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine, { ...dueVaccine, id: "vac-2" }]);
mockPrisma.notification.create.mockRejectedValueOnce(new Error("db")).mockResolvedValueOnce({});
vi.spyOn(console, "error").mockImplementation(() => {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result).toEqual({ sent: 1, failed: 1, more: false });
// Fehlgeschlagene Erinnerung wird freigegeben, damit der nächste Lauf sie nachholt.
expect(mockPrisma.vaccine.updateMany).toHaveBeenCalledWith({
where: { id: "vac-1" },
data: { reminderSentAt: null },
});
});
});
+18 -3
View File
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award } from "lucide-react";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award, Syringe } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
@@ -13,10 +13,10 @@ import { getMediaUrl } from "@/lib/media-url";
import { BlurImage } from "@/components/ui/blur-image";
import { PostDetailDialog } from "@/components/feed/PostDetailDialog";
import type { PostCardPost } from "@/components/feed/PostCard";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { formatRelativeCompact } from "@/lib/format-relative-time";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP" | "VACCINE_DUE";
function notificationIcon(type: NotificationType) {
switch (type) {
@@ -31,6 +31,7 @@ function notificationIcon(type: NotificationType) {
case "PET_SUGGESTION": return <Sparkles className="h-4 w-4 text-amber-500" />;
case "REFERRAL_JOINED": return <Gift className="h-4 w-4 text-orange-500" />;
case "REFERRAL_RANK_UP": return <Award className="h-4 w-4 text-orange-500" />;
case "VACCINE_DUE": return <Syringe className="h-4 w-4 text-sky-500" />;
}
}
@@ -41,6 +42,7 @@ export default function NotificationsPage() {
const { activePetId } = useActivePet();
const trt = useTranslations("RelativeTime");
const t = useTranslations("NotificationsPage");
const locale = useLocale();
const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt);
const { data, isLoading } = useQuery({
@@ -99,6 +101,7 @@ export default function NotificationsPage() {
return "/einladen";
case "BIRTHDAY":
case "ADOPTION_DAY":
case "VACCINE_DUE":
return notif.recipientPetId ? `/pets/${notif.recipientPetId}/health` : null;
default:
return null;
@@ -193,6 +196,7 @@ export default function NotificationsPage() {
<AvatarFallback className="text-xs bg-muted">
{notif.type === "BIRTHDAY" ? "🎂" :
notif.type === "ADOPTION_DAY" ? "🏠" :
notif.type === "VACCINE_DUE" ? "💉" :
notif.actorPet?.name?.[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
@@ -233,6 +237,17 @@ export default function NotificationsPage() {
{notif.type === "PET_SUGGESTION" && t("typePetSuggestion")}
{notif.type === "REFERRAL_JOINED" && t("typeReferralJoined")}
{notif.type === "REFERRAL_RANK_UP" && t("typeReferralRankUp")}
{notif.type === "VACCINE_DUE" && notif.vaccine?.nextDueDate &&
t("typeVaccineDue", {
name: notif.vaccine.name,
// @db.Date kommt als UTC-Mitternacht — in UTC formatieren, sonst kippt der Tag.
date: new Date(notif.vaccine.nextDueDate).toLocaleDateString(locale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "UTC",
}),
})}
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { timingSafeStringEqual } from "@/lib/timing-safe-compare";
import { sendVaccineReminders } from "@/lib/vaccine-reminders";
function authorized(req: Request): boolean {
const secret = process.env.CRON_SECRET;
if (!secret) return false;
return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`);
}
/**
* Erinnert 30 Tage vor dem Folgetermin einer Impfung (`Vaccine.nextDueDate`)
* per Mitteilung + Push daran, einen Impftermin zu vereinbaren. Ausgelöst
* täglich vom NAS-Crontab über docker/run-cron.sh.
*/
export async function GET(req: Request) {
if (!authorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const result = await sendVaccineReminders(prisma);
return NextResponse.json({ ok: result.failed === 0, ...result });
}
+8 -3
View File
@@ -8,6 +8,7 @@ interface NotifyParams {
actorPetId?: string;
postId?: string;
commentId?: string;
vaccineId?: string;
}
/**
@@ -41,6 +42,7 @@ const PUSH_COPY: Record<
PET_SUGGESTION: { title: "Vorschlag für dich", genericBody: "Ein Tier könnte dir gefallen.", actorBody: (a) => `${a} könnte dir gefallen!` },
REFERRAL_JOINED: { title: "Deine Einladung hat geklappt", genericBody: "Ein neues Tier ist über deine Einladung zu PawFeed gekommen.", actorBody: (a) => `${a} ist über deine Einladung zu PawFeed gekommen.` },
REFERRAL_RANK_UP: { title: "Neuer Rang", genericBody: "Du hast einen neuen Empfehlungsrang erreicht.", actorBody: () => "Du hast einen neuen Empfehlungsrang erreicht." },
VACCINE_DUE: { title: "Impfung bald fällig", genericBody: "Eine Impfung wird bald fällig.", actorBody: () => "Eine Impfung wird bald fällig." },
};
/**
@@ -51,11 +53,14 @@ const PUSH_COPY: Record<
*
* Exported (not just used via notify() above) so a batch cron job that needs
* an awaited, counted prisma.notification.create (see notify()'s own doc
* comment) can still trigger the matching push afterward.
* comment) can still trigger the matching push afterward. `bodyOverride` lets
* such a job send a more specific text than PUSH_COPY's fixed one (e.g. the
* vaccine name and due date for VACCINE_DUE).
*/
export async function sendPushForNotification(
prisma: Pick<PrismaClient, "pet" | "owner">,
params: NotifyParams
params: NotifyParams,
bodyOverride?: string
): Promise<void> {
const recipientPet = await prisma.pet.findUnique({
where: { id: params.recipientPetId },
@@ -76,7 +81,7 @@ export async function sendPushForNotification(
const copy = PUSH_COPY[params.type];
await sendExpoPushNotification(owner.expoPushToken, {
title: copy.title,
body: actorPet ? copy.actorBody(actorPet.name) : copy.genericBody,
body: bodyOverride ?? (actorPet ? copy.actorBody(actorPet.name) : copy.genericBody),
data: { type: params.type, postId: params.postId ?? "", commentId: params.commentId ?? "" },
});
}
+82
View File
@@ -0,0 +1,82 @@
import "server-only";
import type { PrismaClient } from "@prisma/client";
import { sendPushForNotification } from "@/lib/notify";
/** Erinnerung geht raus, sobald die nächste Impfung höchstens so viele Tage entfernt ist. */
export const VACCINE_REMINDER_LEAD_DAYS = 30;
/** Obergrenze pro Lauf (run-cron.sh bricht nach 60 s ab, jede Erinnerung kostet einen Push-Aufruf) — der Rest folgt am nächsten Tag. */
const MAX_REMINDERS_PER_RUN = 100;
const DAY_MS = 24 * 60 * 60 * 1000;
type ReminderPrisma = Pick<PrismaClient, "vaccine" | "notification" | "pet" | "owner">;
export interface VaccineReminderResult {
sent: number;
failed: number;
more: boolean;
}
/** `nextDueDate` ist ein reines Datum (@db.Date) — Vergleich gegen UTC-Mitternacht. */
function startOfUtcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
function formatGermanDate(date: Date): string {
const day = String(date.getUTCDate()).padStart(2, "0");
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${day}.${month}.${date.getUTCFullYear()}`;
}
/**
* Erinnert einmalig an jede Impfung, deren Folgetermin (`nextDueDate`) in den
* nächsten 30 Tagen liegt (In-App-Mitteilung VACCINE_DUE + Push). Bereits
* überfällige Termine werden nicht nachträglich erinnert — die zeigt die
* Gesundheitsseite ohnehin als „überfällig“.
*
* Jede Impfung wird vor dem Versand per bedingtem updateMany „beansprucht“
* (reminderSentAt IS NULL → now). So verschickt auch ein doppelt gestarteter
* Lauf (3 Replikas, manueller Aufruf) keine zweite Erinnerung.
*/
export async function sendVaccineReminders(prisma: ReminderPrisma, now = new Date()): Promise<VaccineReminderResult> {
const today = startOfUtcDay(now);
const horizon = new Date(today.getTime() + VACCINE_REMINDER_LEAD_DAYS * DAY_MS);
const dueVaccines = await prisma.vaccine.findMany({
where: { reminderSentAt: null, nextDueDate: { gte: today, lte: horizon } },
select: { id: true, petId: true, name: true, nextDueDate: true, pet: { select: { name: true } } },
orderBy: { nextDueDate: "asc" },
take: MAX_REMINDERS_PER_RUN,
});
let sent = 0;
let failed = 0;
for (const vaccine of dueVaccines) {
const claim = await prisma.vaccine.updateMany({
where: { id: vaccine.id, reminderSentAt: null },
data: { reminderSentAt: now },
});
if (claim.count === 0) continue;
try {
const params = { recipientPetId: vaccine.petId, type: "VACCINE_DUE" as const, vaccineId: vaccine.id };
await prisma.notification.create({ data: params });
sent++;
const dueDate = vaccine.nextDueDate ? formatGermanDate(vaccine.nextDueDate) : "";
const body = `Die Impfung „${vaccine.name}“ für ${vaccine.pet.name} ist am ${dueDate} fällig — Zeit, einen Termin zu vereinbaren.`;
await sendPushForNotification(prisma, params, body).catch((err) =>
console.error("[vaccine-reminders] push failed", vaccine.id, err)
);
} catch (err) {
failed++;
console.error("[vaccine-reminders] failed", vaccine.id, err);
// Freigeben, damit der nächste Lauf es erneut versucht.
await prisma.vaccine
.updateMany({ where: { id: vaccine.id }, data: { reminderSentAt: null } })
.catch((releaseErr) => console.error("[vaccine-reminders] release failed", vaccine.id, releaseErr));
}
}
return { sent, failed, more: dueVaccines.length === MAX_REMINDERS_PER_RUN };
}
+1
View File
@@ -30,6 +30,7 @@ export const notificationsRouter = router({
actorPet: { select: petIdentitySelect },
post: { select: { id: true, petId: true, images: { take: 1, orderBy: { position: "asc" } } } },
comment: { select: { id: true, body: true } },
vaccine: { select: { id: true, name: true, nextDueDate: true } },
},
});