diff --git a/messages/de.json b/messages/de.json
index 8bb2219..2e5e6e3 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -482,7 +482,8 @@
"typeAdoptionDay": "🏠Heute ist der Adoptionstag! Feiere den Jahrestag.",
"typePetSuggestion": "könnte dir gefallen",
"typeReferralJoined": "ist ĂĽber deine Einladung zu PawFeed gekommen",
- "typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!"
+ "typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!",
+ "typeVaccineDue": "💉 Die Impfung „{name}“ ist am {date} fällig – Zeit, einen Termin zu vereinbaren."
},
"PhotoPostForm": {
"captionPlaceholder": "Bildunterschrift schreiben…",
diff --git a/messages/en.json b/messages/en.json
index 697af95..37d7a9b 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -482,7 +482,8 @@
"typeAdoptionDay": "🏠Today is adoption day! Celebrate the anniversary.",
"typePetSuggestion": "might be a good match",
"typeReferralJoined": "joined PawFeed through your invite",
- "typeReferralRankUp": "You reached a new referral rank. Take a look!"
+ "typeReferralRankUp": "You reached a new referral rank. Take a look!",
+ "typeVaccineDue": "💉 The \"{name}\" vaccination is due on {date} – time to book an appointment."
},
"PhotoPostForm": {
"captionPlaceholder": "Write a caption…",
diff --git a/mobile/app/(app)/(tabs)/notifications.tsx b/mobile/app/(app)/(tabs)/notifications.tsx
index 25bd357..f765089 100644
--- a/mobile/app/(app)/(tabs)/notifications.tsx
+++ b/mobile/app/(app)/(tabs)/notifications.tsx
@@ -9,6 +9,7 @@ import { useActivePet } from "../../../src/context/ActivePetContext";
import { useTheme } from "../../../src/context/ThemeContext";
import { PetAvatar } from "../../../src/components/PetAvatar";
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
+import { isoToDisplayDate } from "../../../src/lib/date-format";
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
import type { NotificationItem } from "../../../src/api/types";
@@ -37,6 +38,11 @@ const SELF_MESSAGE_KEYS: Partial> = {
// independently of the row's own tap target, ported from web's
// notifications/page.tsx (name as a , rest as plain text).
function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
+ if (item.type === "VACCINE_DUE") {
+ if (!item.vaccine?.nextDueDate) return t("notifications.vaccineDueGeneric");
+ const iso = new Date(item.vaccine.nextDueDate).toISOString().slice(0, 10);
+ return t("notifications.vaccineDue", { name: item.vaccine.name, date: isoToDisplayDate(iso) });
+ }
const selfKey = SELF_MESSAGE_KEYS[item.type];
if (selfKey) return t(selfKey);
const actorKey = ACTOR_MESSAGE_KEYS[item.type];
@@ -44,6 +50,11 @@ function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
return item.type;
}
+const SELF_ICONS: Partial> = {
+ BIRTHDAY: "🎂",
+ VACCINE_DUE: "đź’‰",
+};
+
/**
* Port of web's notifications/page.tsx getHref()/getPostId(): FOLLOW goes to
* the actor's profile, BIRTHDAY/ADOPTION_DAY to this pet's own health page
@@ -64,6 +75,7 @@ function getNotificationRoute(item: NotificationItem): string | null {
return "/invite";
case "BIRTHDAY":
case "ADOPTION_DAY":
+ case "VACCINE_DUE":
return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null;
case "REACTION":
case "COMMENT":
@@ -160,7 +172,7 @@ export default function NotificationsScreen() {
) : (
- {item.type === "BIRTHDAY" ? "🎂" : "🏠"}
+ {SELF_ICONS[item.type] ?? "🏠"}
)}
diff --git a/mobile/app/(app)/pets/[petId]/edit.tsx b/mobile/app/(app)/pets/[petId]/edit.tsx
index d1461cc..fcb5e77 100644
--- a/mobile/app/(app)/pets/[petId]/edit.tsx
+++ b/mobile/app/(app)/pets/[petId]/edit.tsx
@@ -8,6 +8,7 @@ import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { useTheme } from "../../../../src/context/ThemeContext";
import { TextField } from "../../../../src/components/TextField";
+import { DateField } from "../../../../src/components/DateField";
import { Button } from "../../../../src/components/Button";
import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
@@ -156,10 +157,10 @@ export default function EditPetScreen() {
/>
{t("editPet.birthdayLabel")}
-
+
{t("editPet.adoptedAtLabel")}
-
+
{error ? {error} : null}
diff --git a/mobile/src/components/DateField.tsx b/mobile/src/components/DateField.tsx
new file mode 100644
index 0000000..a0bab14
--- /dev/null
+++ b/mobile/src/components/DateField.tsx
@@ -0,0 +1,25 @@
+import type { TextInputProps } from "react-native";
+import { TextField } from "./TextField";
+import { formatDateInput } from "../lib/date-format";
+
+const DISPLAY_DATE_LENGTH = 10; // DD.MM.YYYY
+
+type DateFieldProps = Omit & {
+ onChangeText: (value: string) => void;
+};
+
+/**
+ * Text date input with a DD.MM.YYYY mask: number pad only, the dots are
+ * inserted automatically (iOS' number pad has no "." key). A native wheel
+ * picker needs @react-native-community/datetimepicker → PENDING-NATIVE-BUILD.md.
+ */
+export function DateField({ onChangeText, ...props }: DateFieldProps) {
+ return (
+ onChangeText(formatDateInput(text))}
+ />
+ );
+}
diff --git a/mobile/src/components/health/VaccineSection.tsx b/mobile/src/components/health/VaccineSection.tsx
index 5104b90..779314a 100644
--- a/mobile/src/components/health/VaccineSection.tsx
+++ b/mobile/src/components/health/VaccineSection.tsx
@@ -6,10 +6,14 @@ import { Plus, Trash2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { TextField } from "../TextField";
+import { DateField } from "../DateField";
import { Button } from "../Button";
import { Card } from "../Card";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
-import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
+import { addYearsToDisplayDate, displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
+
+/** Übliche Auffrisch-Intervalle (z. B. Tollwut 1–3 Jahre) als Schnellauswahl für den Folgetermin. */
+const NEXT_DUE_PRESET_YEARS = [1, 3];
/** Port of web's health.ts vaccine CRUD — see health/page.tsx's Impfungen tab. */
export function VaccineSection({ petId }: { petId: string }) {
@@ -80,8 +84,24 @@ export function VaccineSection({ petId }: { petId: string }) {
{showForm && (
-
-
+
+
+
+ {NEXT_DUE_PRESET_YEARS.map((years) => {
+ const suggestion = addYearsToDisplayDate(dateGiven, years);
+ return (
+ suggestion && setNextDueDate(suggestion)}
+ >
+ {t("health.vaccineNextDuePreset", { count: years })}
+
+ );
+ })}
+
+ {t("health.vaccineReminderHint")}
diff --git a/src/app/api/cron/vaccine-reminders/route.ts b/src/app/api/cron/vaccine-reminders/route.ts
new file mode 100644
index 0000000..7763123
--- /dev/null
+++ b/src/app/api/cron/vaccine-reminders/route.ts
@@ -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 });
+}
diff --git a/src/lib/notify.ts b/src/lib/notify.ts
index e4cf60c..9fcda6c 100644
--- a/src/lib/notify.ts
+++ b/src/lib/notify.ts
@@ -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,
- params: NotifyParams
+ params: NotifyParams,
+ bodyOverride?: string
): Promise {
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 ?? "" },
});
}
diff --git a/src/lib/vaccine-reminders.ts b/src/lib/vaccine-reminders.ts
new file mode 100644
index 0000000..58c0014
--- /dev/null
+++ b/src/lib/vaccine-reminders.ts
@@ -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;
+
+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 {
+ 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 };
+}
diff --git a/src/trpc/routers/notifications.ts b/src/trpc/routers/notifications.ts
index 67a3a7b..e3027de 100644
--- a/src/trpc/routers/notifications.ts
+++ b/src/trpc/routers/notifications.ts
@@ -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 } },
},
});