From 9216f0c702d7f813d1bc4bc82739a928ecbb98e5 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 4 Oct 2026 18:24:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Gedenkmodus=20=E2=80=9EIn=20Erinnerung?= =?UTF-8?q?=E2=80=9C=20f=C3=BCr=20verstorbene=20Tiere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pet.deceasedAt + NotificationType.PET_DECEASED (additiv) - pets.setDeceased: nur Besitzer, kein Datum in der Zukunft, rückgängig machbar; beim ersten Setzen einmalig stille Mitteilung (ohne Push) an Follower anderer Besitzer - Verstorbene Tiere bekommen keine Geburtstags-/Adoptionstag-Mitteilungen, Impf-Erinnerungen und Tier-Vorschläge mehr und werden nicht vorgeschlagen - Abzeichen „In Erinnerung“ (Ribbon) überall neben dem Namen, Gedenk-Banner im Profil, Bereich „Gedenkmodus“ in „Profil bearbeiten“ (Web + App) - Mitteilung führt zum Profil des Tiers (Liste + Push-Tap) Co-Authored-By: Claude Opus 5.5 --- messages/de.json | 22 ++- messages/en.json | 22 ++- mobile/app/(app)/(tabs)/notifications.tsx | 1 + mobile/app/(app)/(tabs)/pets.tsx | 2 +- mobile/app/(app)/pets/[petId]/edit.tsx | 3 + mobile/app/(app)/pets/[petId]/index.tsx | 28 +++- mobile/src/components/PetIdentityBadges.tsx | 11 +- mobile/src/components/PostCard.tsx | 3 +- .../src/components/pet/MemorialSettings.tsx | 115 ++++++++++++++++ mobile/src/i18n/locales/de.json | 23 +++- mobile/src/i18n/locales/en.json | 23 +++- mobile/src/lib/notification-route.test.ts | 4 +- mobile/src/lib/notification-route.ts | 1 + prisma/schema.prisma | 5 + src/__tests__/helpers/prisma-mock.ts | 2 + src/__tests__/pet-profile.test.ts | 57 ++++++++ src/app/(app)/notifications/page.tsx | 9 +- src/app/(app)/pets/[petId]/edit/page.tsx | 5 + src/app/(app)/pets/[petId]/page.tsx | 15 ++- src/app/api/cron/anniversaries/route.ts | 2 + src/app/api/cron/pet-suggestions/route.ts | 3 +- src/components/feed/PostCard.tsx | 3 +- src/components/layout/MobileNav.tsx | 5 +- src/components/layout/Sidebar.tsx | 5 +- src/components/pet/MemorialSettings.tsx | 127 ++++++++++++++++++ src/components/ui/in-memory-badge.tsx | 17 +++ src/components/ui/pet-identity-badges.tsx | 7 +- src/lib/notify.ts | 1 + src/lib/vaccine-reminders.ts | 3 +- src/repositories/pet-repository.ts | 2 + src/trpc/routers/pets.ts | 36 +++++ 31 files changed, 537 insertions(+), 25 deletions(-) create mode 100644 mobile/src/components/pet/MemorialSettings.tsx create mode 100644 src/components/pet/MemorialSettings.tsx create mode 100644 src/components/ui/in-memory-badge.tsx diff --git a/messages/de.json b/messages/de.json index 6143e59..9bd37c3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -487,7 +487,8 @@ "typeStoryMention": "hat dich in einer Story markiert.", "typeStoryComment": "hat deine Story kommentiert.", "typeStoryReaction": "hat deiner Story eine Pfote gegeben.", - "typeUnknown": "hat eine neue Mitteilung für dich." + "typeUnknown": "hat eine neue Mitteilung für dich.", + "typePetDeceased": "ist leider verstorben. In Erinnerung 🕯️" }, "PhotoPostForm": { "captionPlaceholder": "Bildunterschrift schreiben…", @@ -859,5 +860,24 @@ "FoundingMember": { "label": "Gründungsmitglied", "tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed" + }, + "InMemory": { + "label": "In Erinnerung", + "tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen", + "banner": "In Erinnerung an {name} · {date}", + "settingsHeading": "Gedenkmodus", + "settingsHint": "Wenn dein Tier verstorben ist, kannst du sein Profil als Erinnerung erhalten. Es bekommt das Abzeichen „In Erinnerung“, Beiträge bleiben sichtbar und du kannst weiter Erinnerungen teilen. Geburtstags-Mitteilungen, Impf-Erinnerungen und Vorschläge enden.", + "activeHint": "{name} ist im Gedenkmodus. Du kannst das Datum korrigieren oder den Modus beenden.", + "dateLabel": "Verstorben am", + "activate": "Gedenkmodus aktivieren", + "saveDate": "Datum speichern", + "remove": "Gedenkmodus beenden", + "confirmTitle": "{name} in Erinnerung halten?", + "confirmDescription": "Das Profil bekommt das Abzeichen „In Erinnerung“. Wer deinem Tier folgt, erhält einmalig eine stille Mitteilung (ohne Push). Du kannst das jederzeit zurücknehmen.", + "confirm": "Ja, aktivieren", + "cancel": "Abbrechen", + "saved": "Gedenkmodus gespeichert", + "removed": "Gedenkmodus beendet", + "saveFailed": "Konnte nicht gespeichert werden" } } diff --git a/messages/en.json b/messages/en.json index 1eb841a..d3f0118 100644 --- a/messages/en.json +++ b/messages/en.json @@ -487,7 +487,8 @@ "typeStoryMention": "tagged you in a story.", "typeStoryComment": "commented on your story.", "typeStoryReaction": "gave your story a paw.", - "typeUnknown": "has a new notification for you." + "typeUnknown": "has a new notification for you.", + "typePetDeceased": "has sadly passed away. In memory 🕯️" }, "PhotoPostForm": { "captionPlaceholder": "Write a caption…", @@ -859,5 +860,24 @@ "FoundingMember": { "label": "Founding member", "tooltip": "Founding member – one of the first 250 pets on PawFeed" + }, + "InMemory": { + "label": "In memory", + "tooltip": "In memory – this pet has crossed the rainbow bridge", + "banner": "In memory of {name} · {date}", + "settingsHeading": "Memorial mode", + "settingsHint": "If your pet has passed away, you can keep their profile as a memorial. It gets the “In memory” badge, posts stay visible and you can keep sharing memories. Birthday notifications, vaccine reminders and suggestions stop.", + "activeHint": "{name} is in memorial mode. You can correct the date or end the mode.", + "dateLabel": "Passed away on", + "activate": "Turn on memorial mode", + "saveDate": "Save date", + "remove": "End memorial mode", + "confirmTitle": "Keep {name} in memory?", + "confirmDescription": "The profile gets the “In memory” badge. Everyone following your pet receives a single quiet notification (no push). You can undo this at any time.", + "confirm": "Yes, turn on", + "cancel": "Cancel", + "saved": "Memorial mode saved", + "removed": "Memorial mode ended", + "saveFailed": "Could not be saved" } } diff --git a/mobile/app/(app)/(tabs)/notifications.tsx b/mobile/app/(app)/(tabs)/notifications.tsx index 744ea19..c67070f 100644 --- a/mobile/app/(app)/(tabs)/notifications.tsx +++ b/mobile/app/(app)/(tabs)/notifications.tsx @@ -26,6 +26,7 @@ const ACTOR_MESSAGE_KEYS: Partial> = { STORY_MENTION: "notifications.storyMention", STORY_COMMENT: "notifications.storyComment", STORY_REACTION: "notifications.storyReaction", + PET_DECEASED: "notifications.petDeceased", }; // BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the diff --git a/mobile/app/(app)/(tabs)/pets.tsx b/mobile/app/(app)/(tabs)/pets.tsx index 97d09fd..9a07bfa 100644 --- a/mobile/app/(app)/(tabs)/pets.tsx +++ b/mobile/app/(app)/(tabs)/pets.tsx @@ -84,7 +84,7 @@ function PetRow({ {formatPetName(pet)} - + {pet.breed?.name ?? pet.species.name} diff --git a/mobile/app/(app)/pets/[petId]/edit.tsx b/mobile/app/(app)/pets/[petId]/edit.tsx index 933f900..1909b98 100644 --- a/mobile/app/(app)/pets/[petId]/edit.tsx +++ b/mobile/app/(app)/pets/[petId]/edit.tsx @@ -12,6 +12,7 @@ import { DateField } from "../../../../src/components/DateField"; import { Button } from "../../../../src/components/Button"; import { BreedPicker } from "../../../../src/components/pet/BreedPicker"; import { PrivacySettings } from "../../../../src/components/pet/PrivacySettings"; +import { MemorialSettings } from "../../../../src/components/pet/MemorialSettings"; import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens"; import { displayDateToIso, isoToDisplayDate } from "../../../../src/lib/date-format"; import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding"; @@ -177,6 +178,8 @@ export default function EditPetScreen() { followerVisibility={pet.followerVisibility} /> + + {t("editPet.deletePet")} diff --git a/mobile/app/(app)/pets/[petId]/index.tsx b/mobile/app/(app)/pets/[petId]/index.tsx index 5a3e247..dc9a623 100644 --- a/mobile/app/(app)/pets/[petId]/index.tsx +++ b/mobile/app/(app)/pets/[petId]/index.tsx @@ -3,7 +3,7 @@ import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, StyleSheet } import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { HeartPulse, Trophy, MessageCircle, MoreHorizontal } from "lucide-react-native"; +import { HeartPulse, Trophy, MessageCircle, MoreHorizontal, Ribbon } from "lucide-react-native"; import { useTRPC } from "../../../../src/api/trpc"; import { useActivePet } from "../../../../src/context/ActivePetContext"; import { useTheme } from "../../../../src/context/ThemeContext"; @@ -17,6 +17,7 @@ import { ReportSheet } from "../../../../src/components/ReportSheet"; import { useBlockPet } from "../../../../src/hooks/useBlockPet"; import { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds"; import { formatPetName } from "../../../../src/lib/format"; +import { isoToDisplayDate } from "../../../../src/lib/date-format"; import { getAvatarUrl } from "../../../../src/lib/media-url"; import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens"; import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding"; @@ -145,8 +146,19 @@ export default function PetProfileScreen() { {formatPetName(pet)} - + + {pet.deceasedAt && ( + + + + {t("inMemory.banner", { + name: pet.name, + date: isoToDisplayDate(new Date(pet.deceasedAt).toISOString().slice(0, 10)), + })} + + + )} {pet.isFoundingMember && {t("foundingMember.tooltip")}} {pet.species.name} {pet.invitedByPet && ( @@ -230,6 +242,18 @@ function makeStyles(colors: ColorTokens) { species: { ...typography.body, color: colors.quietGray }, invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray }, foundingHint: { ...typography.body, fontSize: 12, color: "#059669" }, + memorialBanner: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 999, + borderWidth: 1, + borderColor: colors.hairlineGray, + backgroundColor: colors.whisperGray, + }, + memorialText: { ...typography.body, fontSize: 12, color: colors.quietGray }, invitedByName: { color: colors.ink, fontWeight: "600" }, statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 }, stat: { alignItems: "center" }, diff --git a/mobile/src/components/PetIdentityBadges.tsx b/mobile/src/components/PetIdentityBadges.tsx index 582aca8..010970d 100644 --- a/mobile/src/components/PetIdentityBadges.tsx +++ b/mobile/src/components/PetIdentityBadges.tsx @@ -1,8 +1,9 @@ import { View, StyleSheet } from "react-native"; -import { BadgeCheck, Sparkles, Sprout } from "lucide-react-native"; +import { BadgeCheck, Ribbon, Sparkles, Sprout } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { isActiveSponsor } from "../lib/sponsor"; import { colors } from "../theme/tokens"; +import { useTheme } from "../context/ThemeContext"; import { ReferralIcon } from "../lib/referral-icons"; import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks"; @@ -10,6 +11,8 @@ type Props = { isVerified?: boolean; /** Eines der ersten 250 Tiere (Pet.isFoundingMember). */ isFoundingMember?: boolean; + /** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */ + isDeceased?: boolean; sponsor?: { sponsorSince?: Date | string | null; sponsorPeriodEnd?: Date | string | null } | null; /** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */ referral?: ReferralOwnerFields | null; @@ -18,14 +21,16 @@ type Props = { /** Port of web's src/components/ui/pet-identity-badges.tsx. */ const FOUNDING_COLOR = "#059669"; // emerald-600, wie im Web -export function PetIdentityBadges({ isVerified, isFoundingMember, sponsor, referral }: Props) { +export function PetIdentityBadges({ isVerified, isFoundingMember, isDeceased, sponsor, referral }: Props) { const { t } = useTranslation(); + const { colors: theme } = useTheme(); const showSponsor = sponsor ? isActiveSponsor(sponsor) : false; const rank = useOwnerRankWithPerk(referral, "NAME_BADGE"); - if (!isVerified && !isFoundingMember && !showSponsor && !rank) return null; + if (!isDeceased && !isVerified && !isFoundingMember && !showSponsor && !rank) return null; return ( + {isDeceased && } {isVerified && } {showSponsor && } {isFoundingMember && } diff --git a/mobile/src/components/PostCard.tsx b/mobile/src/components/PostCard.tsx index 85684e9..dfc1464 100644 --- a/mobile/src/components/PostCard.tsx +++ b/mobile/src/components/PostCard.tsx @@ -60,6 +60,7 @@ export type PostCardPost = { nickname?: string | null; avatarKey?: string | null; isVerified?: boolean; + deceasedAt?: Date | string | null; isFoundingMember?: boolean; // Sponsor fields only come with petCardSelect (feed/explore); a plain pet // identity's owner (posts.byPetId, comments) carries just the referral rank. @@ -152,7 +153,7 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr {formatPetName(post.pet)} - + {isTrending && ( diff --git a/mobile/src/components/pet/MemorialSettings.tsx b/mobile/src/components/pet/MemorialSettings.tsx new file mode 100644 index 0000000..4ffae16 --- /dev/null +++ b/mobile/src/components/pet/MemorialSettings.tsx @@ -0,0 +1,115 @@ +import { useMemo, useState } from "react"; +import { View, Text, Alert, StyleSheet } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Ribbon } from "lucide-react-native"; +import { useTRPC } from "../../api/trpc"; +import { useTheme } from "../../context/ThemeContext"; +import { DateField } from "../DateField"; +import { Button } from "../Button"; +import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens"; +import { displayDateToIso, isoToDisplayDate, todayAsDisplayDate } from "../../lib/date-format"; + +type Props = { + petId: string; + petName: string; + deceasedAt: Date | string | null; +}; + +/** YYYY-MM-DD eines @db.Date-Werts (kommt als UTC-Mitternacht). */ +function toIso(value: Date | string | null): string | null { + return value ? new Date(value).toISOString().slice(0, 10) : null; +} + +/** + * Gedenkmodus „In Erinnerung“ — Port von web src/components/pet/MemorialSettings.tsx. + * Datum setzen (mit Bestätigung, Follower werden einmalig still informiert), + * korrigieren oder beenden. Server: pets.setDeceased. + */ +export function MemorialSettings({ petId, petName, deceasedAt }: Props) { + const { t } = useTranslation(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { colors } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); + const savedIso = toIso(deceasedAt); + const isDeceased = savedIso != null; + const [date, setDate] = useState(() => (savedIso ? isoToDisplayDate(savedIso) : todayAsDisplayDate())); + const [error, setError] = useState(null); + + const mutation = useMutation( + trpc.pets.setDeceased.mutationOptions({ + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries(); + Alert.alert(variables.deceasedAt ? t("inMemory.saved") : t("inMemory.removed")); + }, + onError: () => Alert.alert(t("inMemory.saveFailed")), + }) + ); + + /** Gültiges, nicht zukünftiges Datum als YYYY-MM-DD — sonst Fehlermeldung. */ + function validIso(): string | null { + const iso = displayDateToIso(date); + const todayIso = displayDateToIso(todayAsDisplayDate()); + if (!iso || (todayIso && iso > todayIso)) { + setError(t("inMemory.invalidDate")); + return null; + } + setError(null); + return iso; + } + + function activate() { + const iso = validIso(); + if (!iso) return; + Alert.alert(t("inMemory.confirmTitle", { name: petName }), t("inMemory.confirmDescription"), [ + { text: t("inMemory.cancel"), style: "cancel" }, + { text: t("inMemory.confirm"), onPress: () => mutation.mutate({ petId, deceasedAt: iso }) }, + ]); + } + + function saveDate() { + const iso = validIso(); + if (iso && iso !== savedIso) mutation.mutate({ petId, deceasedAt: iso }); + } + + return ( + + + + {t("inMemory.settingsHeading")} + + + {isDeceased ? t("inMemory.activeHint", { name: petName }) : t("inMemory.settingsHint")} + + {t("inMemory.dateLabel")} + + {error ? {error} : null} + {isDeceased ? ( + <> + + + + ) : ( + + )} + + + + + + {t("confirmTitle", { name: petName })} + {t("confirmDescription")} + + + setConfirmOpen(false)} />}> + {t("cancel")} + + + + + + + ); +} diff --git a/src/components/ui/in-memory-badge.tsx b/src/components/ui/in-memory-badge.tsx new file mode 100644 index 0000000..b9d4aaf --- /dev/null +++ b/src/components/ui/in-memory-badge.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { Ribbon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { cn } from "@/lib/utils"; + +const ICON_SIZE = { sm: "h-3 w-3", md: "h-3.5 w-3.5", lg: "h-4 w-4" } as const; + +/** Gedenkmodus „In Erinnerung“ — das Tier ist verstorben (Pet.deceasedAt). */ +export function InMemoryBadge({ size = "md" }: { size?: keyof typeof ICON_SIZE }) { + const t = useTranslations("InMemory"); + return ( + + + + ); +} diff --git a/src/components/ui/pet-identity-badges.tsx b/src/components/ui/pet-identity-badges.tsx index 60ddd12..52337be 100644 --- a/src/components/ui/pet-identity-badges.tsx +++ b/src/components/ui/pet-identity-badges.tsx @@ -3,6 +3,7 @@ import { isActiveSponsor } from "@/lib/sponsor"; import { cn } from "@/lib/utils"; import { ReferralRankBadge, type ReferralOwnerFields } from "@/components/referrals/ReferralRankBadge"; import { FoundingMemberBadge } from "@/components/ui/founding-member-badge"; +import { InMemoryBadge } from "@/components/ui/in-memory-badge"; type SponsorWindow = { // Optional: a pet identity's owner (petIdentitySelect) carries only the referral rank. @@ -14,6 +15,8 @@ type Props = { isVerified?: boolean; /** Eines der ersten 250 Tiere (Pet.isFoundingMember). */ isFoundingMember?: boolean; + /** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */ + isDeceased?: boolean; /** Omit entirely (rather than passing null) when the caller has no owner/sponsor data to show. */ sponsor?: SponsorWindow | null; /** Owner-Felder referralRankId/referralPerks (petCardSelect.owner) — Rang-Abzeichen des Empfehlungsprogramms. */ @@ -44,6 +47,7 @@ function isSponsorActive(sponsor: SponsorWindow): boolean { export function PetIdentityBadges({ isVerified, isFoundingMember, + isDeceased, sponsor, referral, size = "md", @@ -53,10 +57,11 @@ export function PetIdentityBadges({ const iconClass = ICON_SIZE[size]; const showSponsor = sponsor != null && isSponsorActive(sponsor); - if (!isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null; + if (!isDeceased && !isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null; return ( <> + {isDeceased && } {isVerified && ( "Eine Impfung wird bald fällig." }, STORY_MENTION: { title: "In einer Story markiert", genericBody: "Du wurdest in einer Story markiert.", actorBody: (a) => `${a} hat dich in einer Story markiert.` }, STORY_COMMENT: { title: "Neuer Story-Kommentar", genericBody: "Jemand hat deine Story kommentiert.", actorBody: (a) => `${a} hat deine Story kommentiert.` }, + PET_DECEASED: { title: "In Erinnerung", genericBody: "Ein Tier, dem du folgst, ist verstorben.", actorBody: (a) => `${a} ist leider verstorben.` }, STORY_REACTION: { title: "Neue Pfote", genericBody: "Jemand hat deiner Story eine Pfote gegeben.", actorBody: (a) => `${a} hat deiner Story eine Pfote gegeben.` }, }; diff --git a/src/lib/vaccine-reminders.ts b/src/lib/vaccine-reminders.ts index 58c0014..5a548dd 100644 --- a/src/lib/vaccine-reminders.ts +++ b/src/lib/vaccine-reminders.ts @@ -42,7 +42,8 @@ export async function sendVaccineReminders(prisma: ReminderPrisma, now = new Dat 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 } }, + // Verstorbene Tiere („In Erinnerung“) bekommen keine Impf-Erinnerungen mehr. + where: { reminderSentAt: null, nextDueDate: { gte: today, lte: horizon }, pet: { deceasedAt: null } }, select: { id: true, petId: true, name: true, nextDueDate: true, pet: { select: { name: true } } }, orderBy: { nextDueDate: "asc" }, take: MAX_REMINDERS_PER_RUN, diff --git a/src/repositories/pet-repository.ts b/src/repositories/pet-repository.ts index 77782fe..72e7d69 100644 --- a/src/repositories/pet-repository.ts +++ b/src/repositories/pet-repository.ts @@ -22,6 +22,8 @@ export const petIdentitySelect = { name: true, nickname: true, avatarKey: true, + // „In Erinnerung“-Abzeichen überall neben dem Namen + deceasedAt: true, owner: { select: referralOwnerSelect }, } satisfies Prisma.PetSelect; diff --git a/src/trpc/routers/pets.ts b/src/trpc/routers/pets.ts index e209528..a79cfbd 100644 --- a/src/trpc/routers/pets.ts +++ b/src/trpc/routers/pets.ts @@ -153,6 +153,42 @@ export const petsRouter = router({ * Asserts ownership before any DB write (T-3-01). * T-3-02: Zod enforces character limits server-side. */ + /** + * Gedenkmodus „In Erinnerung“: Besitzer markiert sein Tier als verstorben + * (Datum) oder nimmt das zurück (null). Beim ersten Setzen bekommen die + * Follower einmalig eine stille Mitteilung (PET_DECEASED, bewusst ohne Push), + * eigene Tiere des Besitzers nicht. + */ + setDeceased: protectedProcedure + .input( + z.object({ + petId: z.string().min(1), + deceasedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable(), + }) + ) + .mutation(async ({ ctx, input }) => { + const pet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); + const deceasedAt = input.deceasedAt ? new Date(input.deceasedAt) : null; + if (deceasedAt && deceasedAt.getTime() > Date.now()) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Das Datum liegt in der Zukunft." }); + } + + await ctx.prisma.pet.update({ where: { id: input.petId }, data: { deceasedAt } }); + + if (deceasedAt && !pet.deceasedAt) { + const followers = await ctx.prisma.follow.findMany({ + where: { followeePetId: input.petId, follower: { ownerId: { not: pet.ownerId } } }, + select: { followerPetId: true }, + }); + if (followers.length > 0) { + await ctx.prisma.notification.createMany({ + data: followers.map((f) => ({ recipientPetId: f.followerPetId, type: "PET_DECEASED" as const, actorPetId: input.petId })), + }); + } + } + return { deceasedAt }; + }), + update: protectedProcedure .input( z.object({