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:
1 parent
7169840000
commit
c74bbffccb
20 files changed
+461
-44
No files matched your search
+2
-1
@@ -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…",
|
||||
|
||||
+2
-1
@@ -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…",
|
||||
|
||||
@@ -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<Record<NotificationItem["type"], string>> = {
|
||||
// independently of the row's own tap target, ported from web's
|
||||
// notifications/page.tsx (name as a <Link>, 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<Record<NotificationItem["type"], string>> = {
|
||||
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() {
|
||||
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} />
|
||||
) : (
|
||||
<View style={styles.selfIcon}>
|
||||
<Text style={styles.selfIconText}>{item.type === "BIRTHDAY" ? "🎂" : "🏠"}</Text>
|
||||
<Text style={styles.selfIconText}>{SELF_ICONS[item.type] ?? "🏠"}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.rowText}>
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>{t("editPet.birthdayLabel")}</Text>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
|
||||
|
||||
<Text style={styles.label}>{t("editPet.adoptedAtLabel")}</Text>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
|
||||
@@ -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<TextInputProps, "onChangeText" | "keyboardType" | "maxLength"> & {
|
||||
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 (
|
||||
<TextField
|
||||
{...props}
|
||||
keyboardType="number-pad"
|
||||
maxLength={DISPLAY_DATE_LENGTH}
|
||||
onChangeText={(text) => onChangeText(formatDateInput(text))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<Card style={styles.form}>
|
||||
<TextField placeholder={t("health.vaccineNamePlaceholder")} value={name} onChangeText={setName} maxLength={100} />
|
||||
<TextField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
|
||||
<TextField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
|
||||
<DateField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
|
||||
<DateField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
|
||||
<View style={styles.presetRow}>
|
||||
{NEXT_DUE_PRESET_YEARS.map((years) => {
|
||||
const suggestion = addYearsToDisplayDate(dateGiven, years);
|
||||
return (
|
||||
<Pressable
|
||||
key={years}
|
||||
style={[styles.preset, !suggestion && styles.presetDisabled]}
|
||||
disabled={!suggestion}
|
||||
onPress={() => suggestion && setNextDueDate(suggestion)}
|
||||
>
|
||||
<Text style={styles.presetText}>{t("health.vaccineNextDuePreset", { count: years })}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Text style={styles.hint}>{t("health.vaccineReminderHint")}</Text>
|
||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
|
||||
<Button
|
||||
title={addMutation.isPending ? "…" : t("common.save")}
|
||||
@@ -134,5 +154,10 @@ function makeStyles(colors: ColorTokens) {
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
overdue: { color: colors.alertRed },
|
||||
presetRow: { flexDirection: "row", gap: 8 },
|
||||
preset: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: colors.marigoldTint },
|
||||
presetDisabled: { opacity: 0.4 },
|
||||
presetText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -72,7 +73,7 @@ export function VetVisitSection({ petId }: { petId: string }) {
|
||||
|
||||
{showForm && (
|
||||
<Card style={styles.form}>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<TextField placeholder={t("health.vetReasonPlaceholder")} value={reason} onChangeText={setReason} maxLength={100} />
|
||||
<TextField placeholder={t("health.vetNamePlaceholder")} value={vetName} onChangeText={setVetName} maxLength={100} />
|
||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={500} multiline />
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -65,7 +66,7 @@ export function WeightLogSection({ petId }: { petId: string }) {
|
||||
|
||||
{showForm && (
|
||||
<Card style={styles.form}>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<TextField placeholder={t("health.weightKgPlaceholder")} value={weightKg} onChangeText={setWeightKg} keyboardType="decimal-pad" />
|
||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
|
||||
<Button
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"breedLabel": "Rasse",
|
||||
"birthdayLabel": "Geburtstag (optional)",
|
||||
"adoptedAtLabel": "Adoptionstag (optional)",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben oder leer lassen.",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben oder leer lassen.",
|
||||
"deleteConfirmTitle": "Tier wirklich löschen?",
|
||||
"deleteConfirmMessage": "{{name}} und alle zugehörigen Beiträge, Kommentare und Follower-Beziehungen werden unwiderruflich gelöscht.",
|
||||
"deleteConfirmMessageFallback": "Dieses Tier",
|
||||
@@ -139,19 +139,22 @@
|
||||
"manageDesc": "Ein Widerruf löscht sofort und unwiderruflich alle Gesundheitsdaten dieses Haustiers.",
|
||||
"revokeLink": "Widerrufen",
|
||||
"weightTitle": "Gewicht",
|
||||
"weightDatePlaceholder": "TT-MM-JJJJ",
|
||||
"weightDatePlaceholder": "TT.MM.JJJJ",
|
||||
"weightKgPlaceholder": "Gewicht (kg), z.B. 4,2",
|
||||
"notesPlaceholder": "Notizen (optional)",
|
||||
"weightEmpty": "Noch keine Gewichtseinträge",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben.",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.",
|
||||
"vetTitle": "Tierarzt",
|
||||
"vetReasonPlaceholder": "Grund, z.B. Jahresuntersuchung",
|
||||
"vetNamePlaceholder": "Tierarzt / Klinik (optional)",
|
||||
"vetEmpty": "Noch keine Tierarztbesuche",
|
||||
"vaccineTitle": "Impfungen",
|
||||
"vaccineNamePlaceholder": "Impfstoff, z.B. Tollwut, DHPP",
|
||||
"vaccineGivenPlaceholder": "Gegeben am (TT-MM-JJJJ)",
|
||||
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT-MM-JJJJ)",
|
||||
"vaccineGivenPlaceholder": "Gegeben am (TT.MM.JJJJ)",
|
||||
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT.MM.JJJJ)",
|
||||
"vaccineNextDuePreset_one": "+{{count}} Jahr",
|
||||
"vaccineNextDuePreset_other": "+{{count}} Jahre",
|
||||
"vaccineReminderHint": "Mit Folgetermin erinnern wir dich 30 Tage vorher.",
|
||||
"vaccineEmpty": "Noch keine Impfungen eingetragen",
|
||||
"vaccineGiven": "Gegeben {{date}}",
|
||||
"vaccineDue": "Fällig {{date}}",
|
||||
@@ -190,6 +193,8 @@
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"notifications": {
|
||||
"vaccineDue": "💉 Die Impfung „{{name}}“ ist am {{date}} fällig – Zeit, einen Termin zu vereinbaren.",
|
||||
"vaccineDueGeneric": "💉 Eine Impfung wird bald fällig.",
|
||||
"follow": "folgt dir jetzt.",
|
||||
"reaction": "hat deinen Beitrag mit einer Pfote versehen.",
|
||||
"comment": "hat deinen Beitrag kommentiert.",
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"breedLabel": "Breed",
|
||||
"birthdayLabel": "Birthday (optional)",
|
||||
"adoptedAtLabel": "Adoption date (optional)",
|
||||
"invalidDate": "Please enter a valid date as DD-MM-YYYY, or leave it empty.",
|
||||
"invalidDate": "Please enter a valid date as DD.MM.YYYY, or leave it empty.",
|
||||
"deleteConfirmTitle": "Delete this pet?",
|
||||
"deleteConfirmMessage": "{{name}} and all associated posts, comments, and follower relationships will be permanently deleted.",
|
||||
"deleteConfirmMessageFallback": "This pet",
|
||||
@@ -139,19 +139,22 @@
|
||||
"manageDesc": "Revoking immediately and permanently deletes all health data for this pet.",
|
||||
"revokeLink": "Revoke",
|
||||
"weightTitle": "Weight",
|
||||
"weightDatePlaceholder": "DD-MM-YYYY",
|
||||
"weightDatePlaceholder": "DD.MM.YYYY",
|
||||
"weightKgPlaceholder": "Weight (kg), e.g. 4.2",
|
||||
"notesPlaceholder": "Notes (optional)",
|
||||
"weightEmpty": "No weight entries yet",
|
||||
"invalidDate": "Please enter a valid date in DD-MM-YYYY format.",
|
||||
"invalidDate": "Please enter a valid date in DD.MM.YYYY format.",
|
||||
"vetTitle": "Vet",
|
||||
"vetReasonPlaceholder": "Reason, e.g. annual checkup",
|
||||
"vetNamePlaceholder": "Vet / clinic (optional)",
|
||||
"vetEmpty": "No vet visits yet",
|
||||
"vaccineTitle": "Vaccinations",
|
||||
"vaccineNamePlaceholder": "Vaccine, e.g. rabies, DHPP",
|
||||
"vaccineGivenPlaceholder": "Given on (DD-MM-YYYY)",
|
||||
"vaccineNextDuePlaceholder": "Next due (optional, DD-MM-YYYY)",
|
||||
"vaccineGivenPlaceholder": "Given on (DD.MM.YYYY)",
|
||||
"vaccineNextDuePlaceholder": "Next due (optional, DD.MM.YYYY)",
|
||||
"vaccineNextDuePreset_one": "+{{count}} year",
|
||||
"vaccineNextDuePreset_other": "+{{count}} years",
|
||||
"vaccineReminderHint": "With a next due date, we'll remind you 30 days ahead.",
|
||||
"vaccineEmpty": "No vaccinations recorded yet",
|
||||
"vaccineGiven": "Given {{date}}",
|
||||
"vaccineDue": "Due {{date}}",
|
||||
@@ -190,6 +193,8 @@
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"notifications": {
|
||||
"vaccineDue": "💉 The \"{{name}}\" vaccination is due on {{date}} – time to book an appointment.",
|
||||
"vaccineDueGeneric": "💉 A vaccination is due soon.",
|
||||
"follow": "is now following you.",
|
||||
"reaction": "pawed your post.",
|
||||
"comment": "commented on your post.",
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
/**
|
||||
* The health forms take a free-text date field validated against the
|
||||
* backend's strict `YYYY-MM-DD` regex (src/trpc/routers/health.ts). Typing
|
||||
* dates in that order isn't the German convention (DD-MM-YYYY) — found.md:
|
||||
* users typing the natural order got a validation error the mutations never
|
||||
* surfaced (no onError handler), which looked exactly like "data isn't being
|
||||
* saved". Both problems share one fix: accept DD-MM-YYYY in the UI, convert
|
||||
* to ISO right before the mutation call, and reject anything that doesn't
|
||||
* parse as a real calendar date instead of silently sending garbage.
|
||||
* dates in that order isn't the German convention — found.md: users typing
|
||||
* the natural order got a validation error the mutations never surfaced (no
|
||||
* onError handler), which looked exactly like "data isn't being saved". Fix:
|
||||
* accept DD.MM.YYYY in the UI, convert to ISO right before the mutation call,
|
||||
* and reject anything that doesn't parse as a real calendar date instead of
|
||||
* silently sending garbage.
|
||||
*
|
||||
* Display uses "." (found.md Neu7: "-" is unusual in German). Parsing still
|
||||
* accepts "-" and "/" and one-digit day/month so older habits keep working.
|
||||
*/
|
||||
|
||||
const DISPLAY_PATTERN = /^(\d{2})-(\d{2})-(\d{4})$/;
|
||||
const DISPLAY_SEPARATOR = ".";
|
||||
const DISPLAY_PATTERN = /^(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})$/;
|
||||
const ISO_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const MAX_DATE_DIGITS = 8;
|
||||
|
||||
export function isoToDisplayDate(iso: string): string {
|
||||
const match = ISO_PATTERN.exec(iso);
|
||||
if (!match) return iso;
|
||||
const [, year, month, day] = match;
|
||||
return `${day}-${month}-${year}`;
|
||||
return [day, month, year].join(DISPLAY_SEPARATOR);
|
||||
}
|
||||
|
||||
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD-MM-YYYY calendar date. */
|
||||
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD.MM.YYYY calendar date. */
|
||||
export function displayDateToIso(display: string): string | null {
|
||||
const match = DISPLAY_PATTERN.exec(display.trim());
|
||||
if (!match) return null;
|
||||
@@ -33,7 +38,32 @@ export function displayDateToIso(display: string): string | null {
|
||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||
if (!isRealDate) return null;
|
||||
|
||||
return `${yearStr}-${monthStr}-${dayStr}`;
|
||||
return `${yearStr}-${monthStr.padStart(2, "0")}-${dayStr.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live mask for typing: keeps digits only and inserts the separators itself
|
||||
* (so the number pad, which has no "." on iOS, is enough). A separator is only
|
||||
* added once a following digit exists — otherwise backspace over "04." would
|
||||
* immediately re-add the dot and the field would get stuck.
|
||||
*/
|
||||
export function formatDateInput(text: string): string {
|
||||
const digits = text.replace(/\D/g, "").slice(0, MAX_DATE_DIGITS);
|
||||
if (digits.length <= 2) return digits;
|
||||
if (digits.length <= 4) return `${digits.slice(0, 2)}${DISPLAY_SEPARATOR}${digits.slice(2)}`;
|
||||
return `${digits.slice(0, 2)}${DISPLAY_SEPARATOR}${digits.slice(2, 4)}${DISPLAY_SEPARATOR}${digits.slice(4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folgetermin-Vorschlag „+1 / +3 Jahre“ für Impfungen. Null bei ungültigem
|
||||
* Ausgangsdatum. 29.02. + 1 Jahr landet (wie Date selbst) auf dem 01.03.
|
||||
*/
|
||||
export function addYearsToDisplayDate(display: string, years: number): string | null {
|
||||
const iso = displayDateToIso(display);
|
||||
if (!iso) return null;
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
const shifted = new Date(Date.UTC(year + years, month - 1, day));
|
||||
return isoToDisplayDate(shifted.toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
export function todayAsDisplayDate(): string {
|
||||
|
||||
+15
-8
@@ -758,14 +758,18 @@ model VetVisit {
|
||||
}
|
||||
|
||||
model Vaccine {
|
||||
id String @id @default(cuid())
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
name String @db.VarChar(100)
|
||||
dateGiven DateTime @db.Date
|
||||
nextDueDate DateTime? @db.Date
|
||||
notes String? @db.VarChar(200)
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
name String @db.VarChar(100)
|
||||
dateGiven DateTime @db.Date
|
||||
nextDueDate DateTime? @db.Date
|
||||
notes String? @db.VarChar(200)
|
||||
createdAt DateTime @default(now())
|
||||
// Gesetzt vom Cron /api/cron/vaccine-reminders, sobald die Erinnerung
|
||||
// (30 Tage vor nextDueDate) raus ist — verhindert Doppel-Erinnerungen.
|
||||
reminderSentAt DateTime?
|
||||
notifications Notification[]
|
||||
|
||||
@@index([petId, dateGiven(sort: Desc)])
|
||||
}
|
||||
@@ -837,6 +841,7 @@ enum NotificationType {
|
||||
PET_SUGGESTION
|
||||
REFERRAL_JOINED // ein Tier ist über die eigene Einladung zu PawFeed gekommen
|
||||
REFERRAL_RANK_UP // eigener Empfehlungsrang aufgestiegen (actorPetId = null)
|
||||
VACCINE_DUE // Impfung wird in <= 30 Tagen fällig (vaccineId gesetzt, actorPetId = null)
|
||||
}
|
||||
|
||||
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
|
||||
@@ -852,6 +857,8 @@ model Notification {
|
||||
postId String?
|
||||
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
commentId String?
|
||||
vaccine Vaccine? @relation(fields: [vaccineId], references: [id], onDelete: Cascade)
|
||||
vaccineId String?
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 ?? "" },
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user