feat: Mitbetreuer – ein zweites Konto kümmert sich mit um ein Tier

- Neue Tabelle PetCaretaker (max. 1 pro Tier): Code einladen (7 Tage) ->
  einlösen (PENDING) -> Freigabe durch den Haupt-Besitzer (ACTIVE)
- assertPetOwnership erlaubt aktiven Mitbetreuer; assertPetMainOwner für
  Löschen, Privatsphäre, Gedenkmodus, Gesundheits-Einwilligung
- pets.list/byId inkl. mitbetreuter Tiere mit Rolle; Push auch an die Geräte
  des Mitbetreuers; Konto-Löschung übergibt das Tier an ihn
- Web: Verwaltung in "Profil bearbeiten", Code einlösen unter "Meine Tiere"
  und im Onboarding, Link /mitbetreuen/<code>, Mitteilung CARETAKER_REQUEST
- App: dieselben Abläufe (OTA-fähig, Teilen statt Kopieren)
- Datenschutz: Absatz zum Mitbetreuer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 12:23:34 +02:00
1 parent 55e5ab747a
commit cccda31525
44 files changed
+1851 -200

No files matched your search

+44 -3
View File
@@ -126,7 +126,11 @@
"addPhotoDesc": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
"done": "Fertig",
"skipForNow": "Jetzt überspringen",
"loading": "Wird geladen"
"loading": "Wird geladen",
"caretakerSwitch": "Ich betreue ein Tier mit",
"caretakerTitle": "Ein Tier mitbetreuen",
"caretakerDesc": "Gib den Einladungscode ein, den du vom Haupt-Besitzer bekommen hast. Sobald er deine Anfrage freigibt, kannst du loslegen.",
"caretakerBack": "Doch ein eigenes Tier anlegen"
},
"Language": {
"switch": "Sprache",
@@ -490,7 +494,8 @@
"typeUnknown": "hat eine neue Mitteilung für dich.",
"typePetDeceased": "ist über die Regenbogenbrücke gegangen. In liebevoller Erinnerung 🤍",
"typePetLost": "wird vermisst. Vielleicht kannst du helfen – tippe für alle Infos.",
"typePetFound": "ist wieder sicher zu Hause. Danke fürs Mithelfen! 🎉"
"typePetFound": "ist wieder sicher zu Hause. Danke fürs Mithelfen! 🎉",
"typeCaretakerRequest": "Jemand möchte dein Tier mitbetreuen – bitte prüfe die Anfrage."
},
"PhotoPostForm": {
"captionPlaceholder": "Bildunterschrift schreiben…",
@@ -646,7 +651,8 @@
"title": "Meine Tiere",
"addPet": "Tier anlegen",
"emptyTitle": "Füge dein erstes Tier hinzu",
"emptyDesc": "Gib deinem Tier ein eigenes Profil auf PawFeed."
"emptyDesc": "Gib deinem Tier ein eigenes Profil auf PawFeed.",
"caretakerHeading": "Ich betreue ein Tier mit"
},
"PetProfile": {
"editProfile": "Profil bearbeiten",
@@ -936,5 +942,40 @@
"bannerTitle": "{name} wird vermisst",
"bannerText": "Zuletzt gesehen: {place} · {date}",
"bannerLink": "Alle Infos & Seite teilen"
},
"Caretaker": {
"heading": "Mitbetreuer",
"intro": "Kümmert sich noch jemand um {name}? Lade eine zweite Person ein – sie nutzt ihr eigenes Konto und kann als {name} posten, Nachrichten beantworten und die Gesundheitsdaten sehen.",
"createInvite": "Mitbetreuer einladen",
"inviteHint": "Gib diesen Code an die Person weiter. Sie gibt ihn in der App unter „Meine Tiere“ oder bei der Anmeldung ein. Danach entscheidest du, ob du die Anfrage freigibst.",
"copy": "Code kopieren",
"copyLink": "Link kopieren",
"copied": "Kopiert",
"validUntil": "Gültig bis {date}",
"newCode": "Neuen Code erzeugen",
"revokeInvite": "Einladung zurückziehen",
"requestFrom": "Anfrage zur Mitbetreuung von {who}",
"requestHint": "Gibst du frei, kann diese Person als {name} handeln. Löschen, Privatsphäre und Gedenkmodus bleiben bei dir.",
"approve": "Freigeben",
"reject": "Ablehnen",
"approved": "Freigegeben – ihr kümmert euch jetzt gemeinsam.",
"activeWith": "{who} betreut {name} mit dir.",
"remove": "Mitbetreuer entfernen",
"removeConfirm": "Soll die Person {name} nicht mehr mitbetreuen? Ihre bisherigen Beiträge als {name} bleiben erhalten.",
"youCoCare": "Du betreust {name} mit.",
"caretakerLimits": "Löschen, Privatsphäre und Gedenkmodus verwaltet der Haupt-Besitzer.",
"leave": "Mitbetreuung beenden",
"leaveConfirm": "Möchtest du {name} nicht mehr mitbetreuen? Du kannst später wieder eingeladen werden.",
"left": "Du betreust {name} nicht mehr mit.",
"cancel": "Abbrechen",
"unknownAccount": "einem anderen Konto",
"failed": "Das hat leider nicht geklappt.",
"codeLabel": "Einladungscode",
"codeHint": "Den Code bekommst du vom Haupt-Besitzer des Tiers. Er ist 7 Tage gültig.",
"redeem": "Einlösen",
"requestSent": "Anfrage gesendet – sobald sie freigegeben ist, findest du {name} unter „Meine Tiere“.",
"waiting": "Deine Anfrage für {name} wartet auf Freigabe.",
"checkAgain": "Erneut prüfen",
"withdraw": "Zurückziehen"
}
}
+44 -3
View File
@@ -126,7 +126,11 @@
"addPhotoDesc": "Give your pet a face on PawFeed. You can always change this later.",
"done": "Done",
"skipForNow": "Skip for now",
"loading": "Loading"
"loading": "Loading",
"caretakerSwitch": "I co-care for a pet",
"caretakerTitle": "Co-care for a pet",
"caretakerDesc": "Enter the invitation code you got from the main owner. As soon as they approve your request, you're all set.",
"caretakerBack": "Create my own pet instead"
},
"Language": {
"switch": "Language",
@@ -490,7 +494,8 @@
"typeUnknown": "has a new notification for you.",
"typePetDeceased": "has crossed the rainbow bridge. In loving memory 🤍",
"typePetLost": "is missing. Maybe you can help – tap for all details.",
"typePetFound": "is safely back home. Thanks for helping! 🎉"
"typePetFound": "is safely back home. Thanks for helping! 🎉",
"typeCaretakerRequest": "Someone would like to co-care for your pet – please review the request."
},
"PhotoPostForm": {
"captionPlaceholder": "Write a caption…",
@@ -646,7 +651,8 @@
"title": "My Pets",
"addPet": "Add Pet",
"emptyTitle": "Add your first pet",
"emptyDesc": "Give your pet their own profile on PawFeed."
"emptyDesc": "Give your pet their own profile on PawFeed.",
"caretakerHeading": "I co-care for a pet"
},
"PetProfile": {
"editProfile": "Edit profile",
@@ -936,5 +942,40 @@
"bannerTitle": "{name} is missing",
"bannerText": "Last seen: {place} · {date}",
"bannerLink": "All details & share page"
},
"Caretaker": {
"heading": "Co-caretaker",
"intro": "Does someone else look after {name} too? Invite a second person – they use their own account and can post as {name}, answer messages and see the health records.",
"createInvite": "Invite a co-caretaker",
"inviteHint": "Share this code with that person. They enter it under “My pets” or during sign-up. You then decide whether to approve the request.",
"copy": "Copy code",
"copyLink": "Copy link",
"copied": "Copied",
"validUntil": "Valid until {date}",
"newCode": "Create new code",
"revokeInvite": "Withdraw invitation",
"requestFrom": "Co-care request from {who}",
"requestHint": "If you approve, this person can act as {name}. Deleting, privacy and memorial mode stay with you.",
"approve": "Approve",
"reject": "Decline",
"approved": "Approved – you now care for this pet together.",
"activeWith": "{who} co-cares for {name} with you.",
"remove": "Remove co-caretaker",
"removeConfirm": "Should this person stop co-caring for {name}? Their posts as {name} stay.",
"youCoCare": "You co-care for {name}.",
"caretakerLimits": "Deleting, privacy and memorial mode are managed by the main owner.",
"leave": "Stop co-caring",
"leaveConfirm": "Do you want to stop co-caring for {name}? You can be invited again later.",
"left": "You no longer co-care for {name}.",
"cancel": "Cancel",
"unknownAccount": "another account",
"failed": "Sorry, that didn't work.",
"codeLabel": "Invitation code",
"codeHint": "You get the code from the pet's main owner. It is valid for 7 days.",
"redeem": "Redeem",
"requestSent": "Request sent – once approved, you'll find {name} under “My pets”.",
"waiting": "Your request for {name} is waiting for approval.",
"checkAgain": "Check again",
"withdraw": "Withdraw"
}
}
@@ -38,6 +38,7 @@ const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
BIRTHDAY: "notifications.birthday",
ADOPTION_DAY: "notifications.adoptionDay",
REFERRAL_RANK_UP: "notifications.referralRankUp",
CARETAKER_REQUEST: "notifications.caretakerRequest",
};
// Suffix only — the actor's name is rendered separately as its own tappable
+12
View File
@@ -11,6 +11,7 @@ import { Card } from "../../../src/components/Card";
import { formatPetName } from "../../../src/lib/format";
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
import type { Pet } from "../../../src/api/types";
import { CaretakerRedeem } from "../../../src/components/caretaker/CaretakerRedeem";
/**
* List of the owner's own pets (pets.list()). Tapping a row opens that
@@ -60,6 +61,15 @@ export default function PetsScreen() {
<Text style={getTypography(colors).body}>{t("pets.empty")}</Text>
</View>
}
// Mitbetreuer: Einladungscode eines anderen Kontos einlösen
ListFooterComponent={
<View style={styles.caretaker}>
<Text style={styles.caretakerHeading}>{t("caretaker.redeemHeading")}</Text>
<CaretakerRedeem />
</View>
}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
/>
);
}
@@ -112,6 +122,8 @@ function makeStyles(colors: ColorTokens) {
return StyleSheet.create({
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
separator: { height: 12 },
caretaker: { marginTop: 32, gap: 8 },
caretakerHeading: { ...typography.title, fontSize: 16 },
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
row: { flexDirection: "row", alignItems: "center", gap: 12 },
rowText: { flex: 1, gap: 2 },
+24 -11
View File
@@ -14,6 +14,7 @@ import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
import { PrivacySettings } from "../../../../src/components/pet/PrivacySettings";
import { MemorialSettings } from "../../../../src/components/pet/MemorialSettings";
import { LostReportSettings } from "../../../../src/components/lost/LostReportSettings";
import { CaretakerSettings } from "../../../../src/components/caretaker/CaretakerSettings";
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";
@@ -121,6 +122,9 @@ export default function EditPetScreen() {
);
}
// Ältere Server ohne Mitbetreuer liefern keine Rolle → wie bisher Haupt-Besitzer.
const isMainOwner = pet.role !== "CARETAKER";
return (
// FlatList, not ScrollView — kept from the original nested-ScrollView
// fix (see onboarding/pet.tsx's comment); OptionPicker's dropdown now
@@ -175,19 +179,28 @@ export default function EditPetScreen() {
<Button title={t("common.save")} disabled={updateMutation.isPending} onPress={handleSave} />
<PrivacySettings
petId={pet.id}
petName={pet.name}
dmPolicy={pet.dmPolicy}
followerVisibility={pet.followerVisibility}
/>
{/* Privatsphäre, Gedenkmodus, Löschen: nur der Haupt-Besitzer */}
{isMainOwner && (
<PrivacySettings
petId={pet.id}
petName={pet.name}
dmPolicy={pet.dmPolicy}
followerVisibility={pet.followerVisibility}
/>
)}
<MemorialSettings petId={pet.id} petName={pet.name} deceasedAt={pet.deceasedAt} />
<CaretakerSettings petId={pet.id} petName={pet.name} />
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
<Trash2 color={colors.alertRed} size={18} />
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
</Pressable>
{isMainOwner && (
<>
<MemorialSettings petId={pet.id} petName={pet.name} deceasedAt={pet.deceasedAt} />
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
<Trash2 color={colors.alertRed} size={18} />
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
</Pressable>
</>
)}
</>
}
/>
+19 -6
View File
@@ -11,6 +11,9 @@ import { AcquisitionStep } from "../../src/components/onboarding/AcquisitionStep
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "../../src/api/trpc";
import { useTheme } from "../../src/context/ThemeContext";
import { CaretakerOnboarding } from "../../src/components/caretaker/CaretakerOnboarding";
import { Button } from "../../src/components/Button";
import { useTranslation } from "react-i18next";
/**
* Port of web's src/app/onboarding/pet/page.tsx — same step order:
@@ -29,6 +32,9 @@ export default function OnboardingPetScreen() {
const [arachnophobiaStepDone, setArachnophobiaStepDone] = useState(false);
const [createdPet, setCreatedPet] = useState<{ id: string; name: string } | null>(null);
const [acquisitionStepDone, setAcquisitionStepDone] = useState(false);
// „Ich betreue ein Tier mit“ statt eigenes Tier anlegen (Mitbetreuer-Einladung)
const [isCaretakerPath, setIsCaretakerPath] = useState(false);
const { t } = useTranslation();
const trpc = useTRPC();
// Erst nach dem Anlegen des Tiers abfragen — vorher existiert die Owner-Zeile ggf. noch nicht.
const { data: referralState } = useQuery({
@@ -57,14 +63,21 @@ export default function OnboardingPetScreen() {
content = <AcquisitionStep canRedeem={referralState?.canRedeem ?? false} onDone={() => setAcquisitionStepDone(true)} />;
} else if (createdPet) {
content = <AvatarStep petId={createdPet.id} petName={createdPet.name} onDone={finish} />;
} else if (isCaretakerPath) {
content = <CaretakerOnboarding onApproved={finish} onBack={() => setIsCaretakerPath(false)} />;
} else {
content = (
<PetCreateForm
onSuccess={(pet) => {
setActivePetId(pet.id);
setCreatedPet(pet);
}}
/>
<>
<PetCreateForm
onSuccess={(pet) => {
setActivePetId(pet.id);
setCreatedPet(pet);
}}
/>
<View style={{ paddingHorizontal: 24, paddingBottom: 32 }}>
<Button title={t("caretaker.onboardingSwitch")} variant="outline" onPress={() => setIsCaretakerPath(true)} />
</View>
</>
);
}
@@ -0,0 +1,33 @@
import { useMemo } from "react";
import { View, Text, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useTheme } from "../../context/ThemeContext";
import { Button } from "../Button";
import { CaretakerRedeem } from "./CaretakerRedeem";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
type Props = { onApproved: () => void; onBack: () => void };
/** Onboarding-Weg „Ich betreue ein Tier mit“ statt eigenes Tier anlegen (web: onboarding/pet/page.tsx). */
export function CaretakerOnboarding({ onApproved, onBack }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<View style={styles.wrap}>
<Text style={styles.title}>{t("caretaker.onboardingTitle")}</Text>
<Text style={styles.desc}>{t("caretaker.onboardingDesc")}</Text>
<CaretakerRedeem onApproved={onApproved} />
<Button title={t("caretaker.onboardingBack")} variant="outline" onPress={onBack} />
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
wrap: { padding: 24, gap: 12 },
title: { ...typography.title, fontSize: 20 },
desc: { ...typography.body, color: colors.quietGray, marginBottom: 8 },
});
}
@@ -0,0 +1,100 @@
import { useMemo, useState } from "react";
import { View, Text, Alert, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Clock } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { TextField } from "../TextField";
import { Button } from "../Button";
import { Card } from "../Card";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
type Props = {
/** Nach „Erneut prüfen“, wenn das Tier freigegeben ist (Onboarding → App). */
onApproved?: () => void;
};
/**
* „Ich betreue ein Tier mit“ — Port von web src/components/caretaker/CaretakerRedeem.tsx:
* Einladungscode einlösen und offene Anfragen zeigen, bis der Haupt-Besitzer freigibt.
*/
export function CaretakerRedeem({ onApproved }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const [code, setCode] = useState("");
const { data: requests } = useQuery(trpc.caretakers.myRequests.queryOptions());
const refresh = () => queryClient.invalidateQueries(trpc.caretakers.myRequests.queryFilter());
const onError = (err: { message: string }) => Alert.alert(err.message || t("caretaker.failed"));
const redeem = useMutation(
trpc.caretakers.redeem.mutationOptions({
onSuccess: async ({ petName }) => {
Alert.alert(t("caretaker.requestSent", { name: petName }));
setCode("");
await refresh();
},
onError,
})
);
const withdraw = useMutation(trpc.caretakers.leave.mutationOptions({ onSuccess: refresh, onError }));
const checkAgain = async () => {
await refresh();
const pets = await queryClient.fetchQuery({ ...trpc.pets.list.queryOptions(), staleTime: 0 });
await queryClient.invalidateQueries(trpc.pets.pathFilter());
if (pets.length > 0) onApproved?.();
else Alert.alert(t("caretaker.stillWaiting"));
};
return (
<View style={styles.wrap}>
{requests?.map((r) => (
<Card key={r.petId} style={styles.request}>
<View style={styles.requestRow}>
<Clock size={16} color={colors.quietGray} />
<Text style={styles.body}>{t("caretaker.waiting", { name: r.petName })}</Text>
</View>
<Button title={t("caretaker.checkAgain")} variant="outline" onPress={checkAgain} />
<Button
title={t("caretaker.withdraw")}
variant="outline"
disabled={withdraw.isPending}
onPress={() => withdraw.mutate({ petId: r.petId })}
/>
</Card>
))}
<Text style={styles.label}>{t("caretaker.codeLabel")}</Text>
<TextField
value={code}
onChangeText={setCode}
maxLength={20}
autoCapitalize="characters"
autoCorrect={false}
placeholder="7K3M-QX9A"
/>
<Text style={styles.hint}>{t("caretaker.codeHint")}</Text>
<Button
title={t("caretaker.redeem")}
disabled={redeem.isPending || !code.trim()}
onPress={() => redeem.mutate({ code })}
/>
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
wrap: { gap: 8 },
request: { gap: 8 },
requestRow: { flexDirection: "row", alignItems: "center", gap: 8 },
body: { ...typography.body, flex: 1 },
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
});
}
@@ -0,0 +1,185 @@
import { useMemo } from "react";
import { View, Text, Alert, Share, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Users } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useActivePet } from "../../context/ActivePetContext";
import { useTheme } from "../../context/ThemeContext";
import { Button } from "../Button";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { isoToDisplayDate } from "../../lib/date-format";
const WEB_ORIGIN = "https://pawfeed.org";
type Account = { name: string | null; email: string | null } | null;
function accountLabel(account: Account, fallback: string): string {
if (!account) return fallback;
if (account.name && account.email) return `${account.name} (${account.email})`;
return account.name ?? account.email ?? fallback;
}
/**
* Mitbetreuer in „Profil bearbeiten“ — Port von web
* src/components/caretaker/CaretakerSettings.tsx. Haupt-Besitzer: einladen
* (Code teilen), freigeben, ablehnen, entfernen. Mitbetreuer: beenden.
* Teilen statt Kopieren: Share braucht kein neues natives Modul (OTA-fähig).
*/
export function CaretakerSettings({ petId, petName }: { petId: string; petName: string }) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const router = useRouter();
const { activePetId, clearActivePetId } = useActivePet();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const { data } = useQuery(trpc.caretakers.status.queryOptions({ petId }));
const refresh = () => queryClient.invalidateQueries(trpc.caretakers.status.queryFilter({ petId }));
const onError = (err: { message: string }) => Alert.alert(err.message || t("caretaker.failed"));
const createInvite = useMutation(trpc.caretakers.createInvite.mutationOptions({ onSuccess: refresh, onError }));
const approve = useMutation(
trpc.caretakers.approve.mutationOptions({
onSuccess: async () => {
Alert.alert(t("caretaker.approved"));
await refresh();
},
onError,
})
);
const remove = useMutation(trpc.caretakers.remove.mutationOptions({ onSuccess: refresh, onError }));
const leave = useMutation(
trpc.caretakers.leave.mutationOptions({
onSuccess: async () => {
if (activePetId === petId) clearActivePetId();
await queryClient.invalidateQueries();
router.replace("/(app)/(tabs)/pets");
},
onError,
})
);
if (!data) return null;
const c = data.caretaker;
const confirm = (title: string, message: string, action: string, onPress: () => void) =>
Alert.alert(title, message, [
{ text: t("common.cancel"), style: "cancel" },
{ text: action, style: "destructive", onPress },
]);
const shareCode = (code: string) =>
Share.share({ message: t("caretaker.shareMessage", { name: petName, code, link: `${WEB_ORIGIN}/mitbetreuen/${code}` }) });
return (
<View style={styles.section}>
<View style={styles.headingRow}>
<Users size={16} color={colors.ink} />
<Text style={styles.heading}>{t("caretaker.heading")}</Text>
</View>
{data.role === "CARETAKER" ? (
<>
<Text style={styles.hint}>{t("caretaker.youCoCare", { name: petName })}</Text>
<Text style={styles.hint}>{t("caretaker.caretakerLimits")}</Text>
<Button
title={t("caretaker.leave")}
variant="outline"
disabled={leave.isPending}
onPress={() =>
confirm(t("caretaker.leave"), t("caretaker.leaveConfirm", { name: petName }), t("caretaker.leave"), () =>
leave.mutate({ petId })
)
}
/>
</>
) : !c ? (
<>
<Text style={styles.hint}>{t("caretaker.intro", { name: petName })}</Text>
<Button
title={t("caretaker.createInvite")}
variant="outline"
disabled={createInvite.isPending}
onPress={() => createInvite.mutate({ petId })}
/>
</>
) : c.status === "INVITED" && c.inviteCode ? (
<>
<Text style={styles.hint}>{t("caretaker.inviteHint", { name: petName })}</Text>
<Text style={styles.code} selectable>
{c.inviteCode}
</Text>
{c.inviteExpiresAt ? (
<Text style={styles.hint}>
{t("caretaker.validUntil", { date: isoToDisplayDate(new Date(c.inviteExpiresAt).toISOString().slice(0, 10)) })}
</Text>
) : null}
<Button title={t("caretaker.share")} onPress={() => shareCode(c.inviteCode!)} />
<Button
title={t("caretaker.newCode")}
variant="outline"
disabled={createInvite.isPending}
onPress={() => createInvite.mutate({ petId })}
/>
<Button
title={t("caretaker.revokeInvite")}
variant="outline"
disabled={remove.isPending}
onPress={() => remove.mutate({ petId })}
/>
</>
) : c.status === "PENDING" ? (
<>
<Text style={styles.body}>{t("caretaker.requestFrom", { who: accountLabel(c.account, t("caretaker.unknownAccount")) })}</Text>
<Text style={styles.hint}>{t("caretaker.requestHint", { name: petName })}</Text>
<Button title={t("caretaker.approve")} disabled={approve.isPending} onPress={() => approve.mutate({ petId })} />
<Button
title={t("caretaker.reject")}
variant="outline"
disabled={remove.isPending}
onPress={() => remove.mutate({ petId })}
/>
</>
) : (
<>
<Text style={styles.body}>
{t("caretaker.activeWith", { who: accountLabel(c.account, t("caretaker.unknownAccount")), name: petName })}
</Text>
<Button
title={t("caretaker.remove")}
variant="outline"
disabled={remove.isPending}
onPress={() =>
confirm(t("caretaker.remove"), t("caretaker.removeConfirm", { name: petName }), t("caretaker.remove"), () =>
remove.mutate({ petId })
)
}
/>
</>
)}
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
section: { marginTop: 24, gap: 8 },
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
heading: { ...typography.title, fontSize: 16 },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
body: { ...typography.body },
code: {
...typography.title,
fontSize: 22,
letterSpacing: 3,
textAlign: "center",
paddingVertical: 10,
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.hairlineGray,
},
});
}
+40 -1
View File
@@ -217,7 +217,8 @@
"unknown": "Neue Mitteilung.",
"petDeceased": "ist über die Regenbogenbrücke gegangen. In liebevoller Erinnerung 🤍",
"petLost": "wird vermisst. Vielleicht kannst du helfen – tippe für alle Infos.",
"petFound": "ist wieder sicher zu Hause. Danke fürs Mithelfen! 🎉"
"petFound": "ist wieder sicher zu Hause. Danke fürs Mithelfen! 🎉",
"caretakerRequest": "Jemand möchte dein Tier mitbetreuen – bitte prüfe die Anfrage."
},
"pets": {
"empty": "Noch keine Tiere angelegt.",
@@ -566,5 +567,43 @@
"toProfile": "Zum Profil",
"notActiveTitle": "Keine aktive Vermisst-Meldung",
"notActiveText": "Vielleicht ist das Tier schon wieder sicher zu Hause. 🎉"
},
"caretaker": {
"heading": "Mitbetreuer",
"intro": "Kümmert sich noch jemand um {{name}}? Lade eine zweite Person ein – sie nutzt ihr eigenes Konto und kann als {{name}} posten, Nachrichten beantworten und die Gesundheitsdaten sehen.",
"createInvite": "Mitbetreuer einladen",
"inviteHint": "Gib diesen Code an die Person weiter. Sie gibt ihn unter „Meine Tiere“ oder bei der Anmeldung ein. Danach entscheidest du, ob du die Anfrage freigibst.",
"share": "Code teilen",
"shareMessage": "Hilfst du mir, {{name}} auf PawFeed mitzubetreuen? Gib in der App unter „Meine Tiere“ diesen Code ein: {{code}} – oder öffne {{link}}",
"validUntil": "Gültig bis {{date}}",
"newCode": "Neuen Code erzeugen",
"revokeInvite": "Einladung zurückziehen",
"requestFrom": "Anfrage zur Mitbetreuung von {{who}}",
"requestHint": "Gibst du frei, kann diese Person als {{name}} handeln. Löschen, Privatsphäre und Gedenkmodus bleiben bei dir.",
"approve": "Freigeben",
"reject": "Ablehnen",
"approved": "Freigegeben – ihr kümmert euch jetzt gemeinsam.",
"activeWith": "{{who}} betreut {{name}} mit dir.",
"remove": "Mitbetreuer entfernen",
"removeConfirm": "Soll die Person {{name}} nicht mehr mitbetreuen? Ihre bisherigen Beiträge als {{name}} bleiben erhalten.",
"youCoCare": "Du betreust {{name}} mit.",
"caretakerLimits": "Löschen, Privatsphäre und Gedenkmodus verwaltet der Haupt-Besitzer.",
"leave": "Mitbetreuung beenden",
"leaveConfirm": "Möchtest du {{name}} nicht mehr mitbetreuen? Du kannst später wieder eingeladen werden.",
"unknownAccount": "einem anderen Konto",
"failed": "Das hat leider nicht geklappt.",
"codeLabel": "Einladungscode",
"codeHint": "Den Code bekommst du vom Haupt-Besitzer des Tiers. Er ist 7 Tage gültig.",
"redeem": "Einlösen",
"requestSent": "Anfrage gesendet – sobald sie freigegeben ist, findest du {{name}} unter „Meine Tiere“.",
"waiting": "Deine Anfrage für {{name}} wartet auf Freigabe.",
"stillWaiting": "Noch nicht freigegeben – schau gern später noch einmal.",
"checkAgain": "Erneut prüfen",
"withdraw": "Zurückziehen",
"redeemHeading": "Ich betreue ein Tier mit",
"onboardingSwitch": "Ich betreue ein Tier mit",
"onboardingTitle": "Ein Tier mitbetreuen",
"onboardingDesc": "Gib den Einladungscode ein, den du vom Haupt-Besitzer bekommen hast. Sobald er deine Anfrage freigibt, kannst du loslegen.",
"onboardingBack": "Doch ein eigenes Tier anlegen"
}
}
+40 -1
View File
@@ -217,7 +217,8 @@
"unknown": "New notification.",
"petDeceased": "has crossed the rainbow bridge. In loving memory 🤍",
"petLost": "is missing. Maybe you can help – tap for all details.",
"petFound": "is safely back home. Thanks for helping! 🎉"
"petFound": "is safely back home. Thanks for helping! 🎉",
"caretakerRequest": "Someone would like to co-care for your pet – please review the request."
},
"pets": {
"empty": "No pets added yet.",
@@ -566,5 +567,43 @@
"toProfile": "Go to profile",
"notActiveTitle": "No active missing report",
"notActiveText": "Maybe the pet is already safely back home. 🎉"
},
"caretaker": {
"heading": "Co-caretaker",
"intro": "Does someone else look after {{name}} too? Invite a second person – they use their own account and can post as {{name}}, answer messages and see the health records.",
"createInvite": "Invite a co-caretaker",
"inviteHint": "Share this code with that person. They enter it under “My pets” or during sign-up. You then decide whether to approve the request.",
"share": "Share code",
"shareMessage": "Would you help me co-care for {{name}} on PawFeed? Enter this code in the app under “My pets”: {{code}} – or open {{link}}",
"validUntil": "Valid until {{date}}",
"newCode": "Create new code",
"revokeInvite": "Withdraw invitation",
"requestFrom": "Co-care request from {{who}}",
"requestHint": "If you approve, this person can act as {{name}}. Deleting, privacy and memorial mode stay with you.",
"approve": "Approve",
"reject": "Decline",
"approved": "Approved – you now care for this pet together.",
"activeWith": "{{who}} co-cares for {{name}} with you.",
"remove": "Remove co-caretaker",
"removeConfirm": "Should this person stop co-caring for {{name}}? Their posts as {{name}} stay.",
"youCoCare": "You co-care for {{name}}.",
"caretakerLimits": "Deleting, privacy and memorial mode are managed by the main owner.",
"leave": "Stop co-caring",
"leaveConfirm": "Do you want to stop co-caring for {{name}}? You can be invited again later.",
"unknownAccount": "another account",
"failed": "Sorry, that didn't work.",
"codeLabel": "Invitation code",
"codeHint": "You get the code from the pet's main owner. It is valid for 7 days.",
"redeem": "Redeem",
"requestSent": "Request sent – once approved, you'll find {{name}} under “My pets”.",
"waiting": "Your request for {{name}} is waiting for approval.",
"stillWaiting": "Not approved yet – feel free to check again later.",
"checkAgain": "Check again",
"withdraw": "Withdraw",
"redeemHeading": "I co-care for a pet",
"onboardingSwitch": "I co-care for a pet",
"onboardingTitle": "Co-care for a pet",
"onboardingDesc": "Enter the invitation code you got from the main owner. As soon as they approve your request, you're all set.",
"onboardingBack": "Create my own pet instead"
}
}
@@ -27,6 +27,11 @@ describe("notificationRoute", () => {
expect(notificationRoute({ type: "PET_LOST" }, NOW)).toBeNull();
});
it("opens the own pet's edit screen for a co-care request", () => {
expect(notificationRoute({ type: "CARETAKER_REQUEST", recipientPetId: "me" }, NOW)).toBe("/pets/me/edit");
expect(notificationRoute({ type: "CARETAKER_REQUEST" }, NOW)).toBeNull();
});
it("opens the own pet's health page for birthdays, adoption days and vaccines", () => {
for (const type of ["BIRTHDAY", "ADOPTION_DAY", "VACCINE_DUE"]) {
expect(notificationRoute({ type, recipientPetId: "me" }, NOW)).toBe("/pets/me/health");
+3
View File
@@ -58,6 +58,9 @@ export function notificationRoute(target: NotificationTarget, now: number): stri
return actorPetId ? `/lost/${actorPetId}` : null;
case "REFERRAL_RANK_UP":
return "/invite";
// Mitbetreuungs-Anfrage → Freigeben/Ablehnen in „Profil bearbeiten“
case "CARETAKER_REQUEST":
return recipientPetId ? `/pets/${recipientPetId}/edit` : null;
case "BIRTHDAY":
case "ADOPTION_DAY":
case "VACCINE_DUE":
+4
View File
@@ -0,0 +1,4 @@
-- Mitbetreuer: neue Tabelle für den PostgREST-Zugriff sperren, wie alle anderen
-- Tabellen (siehe enable_rls.sql). Die App greift ausschließlich über Prisma
-- (Service-Rolle) zu. Nach dem prisma db push einmal ausführen.
ALTER TABLE public."PetCaretaker" ENABLE ROW LEVEL SECURITY;
+33
View File
@@ -35,6 +35,7 @@ model Owner {
contributions SponsorContribution[]
broadcastReceipts BroadcastReceipt[]
pushTokens PushToken[]
caretaking PetCaretaker[]
// Empfehlungsprogramm — Clerk-unabhängig: der Code wird erst nach der
// Registrierung im Onboarding eingelöst (src/lib/referrals.ts). invitedById
@@ -337,6 +338,7 @@ model Pet {
// Vermisst-Meldungen (aktive: foundAt = null, höchstens eine)
lostReports LostReport[]
caretaker PetCaretaker?
// Gitea #18 — legal AI-content disclosure. Set once at avatar upload,
// owner-immutable afterward; only a moderator can correct it (see
@@ -910,6 +912,7 @@ enum NotificationType {
PET_DECEASED // ein gefolgtes Tier ist verstorben (actorPetId = das Tier, kein Push)
PET_LOST // ein Tier aus dem Umfeld wird vermisst (actorPetId = das Tier, mit Push)
PET_FOUND // Entwarnung: das vermisste Tier ist wieder da (actorPetId = das Tier)
CARETAKER_REQUEST // jemand möchte das Tier mitbetreuen und wartet auf Freigabe (an das Tier selbst, nur Haupt-Besitzer handelt)
}
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
@@ -1179,3 +1182,33 @@ model PushToken {
@@index([ownerId, lastSeenAt(sort: Desc)])
}
// Mitbetreuer (Roadmap 05.10., Notiz „Gemeinsame Tierhalter – Machbarkeit“):
// höchstens EIN zweiter Mensch pro Tier (petId eindeutig). Ablauf:
// INVITED – Haupt-Besitzer hat einen Code erzeugt (ownerId noch leer)
// PENDING – jemand hat den Code eingelöst, wartet auf Freigabe des Haupt-Besitzers
// ACTIVE – darf als das Tier handeln (src/lib/assert-pet-ownership.ts)
// Nur der Haupt-Besitzer (Pet.ownerId) löscht das Tier, ändert die Privatsphäre,
// den Gedenkmodus und verwaltet den Mitbetreuer. Löscht er sein Konto, übernimmt
// ein aktiver Mitbetreuer das Tier (src/lib/delete-account.ts).
enum PetCaretakerStatus {
INVITED
PENDING
ACTIVE
}
model PetCaretaker {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String @unique
owner Owner? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String?
status PetCaretakerStatus @default(INVITED)
inviteCode String? @unique @db.VarChar(16)
inviteExpiresAt DateTime?
createdAt DateTime @default(now())
requestedAt DateTime?
approvedAt DateTime?
@@index([ownerId, status])
}
+239
View File
@@ -0,0 +1,239 @@
/**
* Mitbetreuer: Einladungscode, Router-Ablauf (einladen → einlösen → freigeben),
* Rechte (Haupt-Besitzer vs. Mitbetreuer) und Übernahme bei Kontolöschung.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
const mockDeleteUser = vi.fn().mockResolvedValue(undefined);
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
clerkClient: vi.fn().mockResolvedValue({
users: {
getUser: vi.fn().mockResolvedValue({
firstName: "Kim",
lastName: null,
username: null,
primaryEmailAddress: { emailAddress: "kim@example.com" },
emailAddresses: [],
}),
deleteUser: (...a: unknown[]) => mockDeleteUser(...a),
},
}),
}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/redis", () => ({ redis: {} }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ success: true }) }));
const mockNotify = vi.fn();
vi.mock("@/lib/notify", () => ({ notify: (...a: unknown[]) => mockNotify(...a) }));
vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: vi.fn().mockResolvedValue(undefined) }));
const mockDeletePet = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/delete-pet", () => ({ deletePetCompletely: (...a: unknown[]) => mockDeletePet(...a) }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { caretakersRouter, maskEmail } from "@/trpc/routers/caretakers";
import { generateCaretakerCode, normalizeCaretakerCode, CARETAKER_CODE_PATTERN } from "@/lib/caretaker-code";
import { petRoleOf, petsControlledBy, assertPetMainOwner } from "@/lib/assert-pet-ownership";
import { deleteOwnerAccount } from "@/lib/delete-account";
type Mock = ReturnType<typeof createMockPrisma>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const asDb = (m: Mock) => m as any;
const MAIN = "owner-1";
const CARE = "owner-2";
const FUTURE = new Date(Date.now() + 86_400_000);
function caller(userId: string, prisma: Mock) {
return caretakersRouter.createCaller({ userId, prisma: asDb(prisma), locale: "de", ip: "test" });
}
/** pet.findUnique, wie assertPetOwnership es sieht. */
function petWith(caretaker: { ownerId: string | null; status: string } | null) {
return { id: "pet-1", ownerId: MAIN, name: "Bello", caretaker };
}
beforeEach(() => {
mockNotify.mockClear();
mockDeletePet.mockClear();
});
describe("caretaker-code", () => {
it("generates codes in the 4-4 format without ambiguous characters", () => {
let i = 0;
const code = generateCaretakerCode(() => i++ % 32);
expect(code).toMatch(CARETAKER_CODE_PATTERN);
expect(code).not.toMatch(/[01IO]/);
});
it("normalizes user input and rejects garbage", () => {
expect(normalizeCaretakerCode(" 7k3m qx9a ")).toBe("7K3M-QX9A");
expect(normalizeCaretakerCode("7K3MQX9A")).toBe("7K3M-QX9A");
expect(normalizeCaretakerCode("BUBI-7K3M")).toBeNull(); // Empfehlungscode, kein Mitbetreuer-Code
expect(normalizeCaretakerCode("")).toBeNull();
});
});
describe("roles", () => {
it("knows main owner, active caretaker and everyone else", () => {
expect(petRoleOf(petWith(null), MAIN)).toBe("OWNER");
expect(petRoleOf(petWith({ ownerId: CARE, status: "ACTIVE" }), CARE)).toBe("CARETAKER");
expect(petRoleOf(petWith({ ownerId: CARE, status: "PENDING" }), CARE)).toBeNull();
expect(petRoleOf(petWith(null), "stranger")).toBeNull();
});
it("filters own and actively co-cared pets", () => {
expect(petsControlledBy(CARE)).toEqual({
OR: [{ ownerId: CARE }, { caretaker: { is: { ownerId: CARE, status: "ACTIVE" } } }],
});
});
it("refuses main-owner-only actions to an active caretaker", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue(petWith({ ownerId: CARE, status: "ACTIVE" }));
await expect(assertPetMainOwner(asDb(prisma), "pet-1", CARE)).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(assertPetMainOwner(asDb(prisma), "pet-1", MAIN)).resolves.toMatchObject({ role: "OWNER" });
});
});
describe("caretakersRouter", () => {
it("lets the main owner create a 7-day invite", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue(petWith(null));
const res = await caller(MAIN, prisma).createInvite({ petId: "pet-1" });
expect(res.inviteCode).toMatch(CARETAKER_CODE_PATTERN);
const days = (res.inviteExpiresAt.getTime() - Date.now()) / 86_400_000;
expect(days).toBeGreaterThan(6.9);
expect(prisma.petCaretaker.upsert).toHaveBeenCalled();
});
it("refuses a new invite while a caretaker is active (max. one)", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue(petWith({ ownerId: CARE, status: "ACTIVE" }));
prisma.petCaretaker.findUnique.mockResolvedValue({ status: "ACTIVE" });
await expect(caller(MAIN, prisma).createInvite({ petId: "pet-1" })).rejects.toMatchObject({ code: "CONFLICT" });
});
it("does not let a caretaker invite someone else", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue(petWith({ ownerId: CARE, status: "ACTIVE" }));
await expect(caller(CARE, prisma).createInvite({ petId: "pet-1" })).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("redeems a valid code into a PENDING request and notifies the pet", async () => {
const prisma = createMockPrisma();
prisma.petCaretaker.findUnique.mockResolvedValue({
id: "ct-1",
status: "INVITED",
inviteExpiresAt: FUTURE,
pet: { id: "pet-1", name: "Bello", ownerId: MAIN },
});
prisma.petCaretaker.updateMany.mockResolvedValue({ count: 1 });
const res = await caller(CARE, prisma).redeem({ code: "7k3m qx9a" });
expect(res).toEqual({ petId: "pet-1", petName: "Bello" });
expect(prisma.petCaretaker.updateMany).toHaveBeenCalledWith({
where: { id: "ct-1", status: "INVITED", inviteCode: "7K3M-QX9A" },
data: expect.objectContaining({ ownerId: CARE, status: "PENDING", inviteCode: null }),
});
expect(mockNotify).toHaveBeenCalledWith(expect.anything(), { recipientPetId: "pet-1", type: "CARETAKER_REQUEST" });
});
it("rejects expired codes and the owner's own code", async () => {
const prisma = createMockPrisma();
prisma.petCaretaker.findUnique.mockResolvedValue({
id: "ct-1",
status: "INVITED",
inviteExpiresAt: new Date(Date.now() - 1000),
pet: { id: "pet-1", name: "Bello", ownerId: MAIN },
});
await expect(caller(CARE, prisma).redeem({ code: "7K3M-QX9A" })).rejects.toMatchObject({ code: "NOT_FOUND" });
prisma.petCaretaker.findUnique.mockResolvedValue({
id: "ct-1",
status: "INVITED",
inviteExpiresAt: FUTURE,
pet: { id: "pet-1", name: "Bello", ownerId: MAIN },
});
await expect(caller(MAIN, prisma).redeem({ code: "7K3M-QX9A" })).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mockNotify).not.toHaveBeenCalled();
});
it("fails cleanly when the code was redeemed in parallel", async () => {
const prisma = createMockPrisma();
prisma.petCaretaker.findUnique.mockResolvedValue({
id: "ct-1",
status: "INVITED",
inviteExpiresAt: FUTURE,
pet: { id: "pet-1", name: "Bello", ownerId: MAIN },
});
prisma.petCaretaker.updateMany.mockResolvedValue({ count: 0 });
await expect(caller(CARE, prisma).redeem({ code: "7K3M-QX9A" })).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mockNotify).not.toHaveBeenCalled();
});
it("approves only a PENDING request, and only as main owner", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue(petWith({ ownerId: CARE, status: "PENDING" }));
prisma.petCaretaker.updateMany.mockResolvedValue({ count: 1 });
await caller(MAIN, prisma).approve({ petId: "pet-1" });
expect(prisma.petCaretaker.updateMany).toHaveBeenCalledWith({
where: { petId: "pet-1", status: "PENDING" },
data: expect.objectContaining({ status: "ACTIVE" }),
});
await expect(caller(CARE, prisma).approve({ petId: "pet-1" })).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("shows the main owner who asked, with a masked e-mail", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue(petWith({ ownerId: CARE, status: "PENDING" }));
prisma.petCaretaker.findUnique.mockResolvedValue({ status: "PENDING", ownerId: CARE, requestedAt: new Date() });
const res = await caller(MAIN, prisma).status({ petId: "pet-1" });
expect(res.caretaker?.account).toEqual({ name: "Kim", email: "k•••@example.com" });
});
it("lets a caretaker leave only their own row", async () => {
const prisma = createMockPrisma();
prisma.petCaretaker.deleteMany.mockResolvedValue({ count: 1 });
await caller(CARE, prisma).leave({ petId: "pet-1" });
expect(prisma.petCaretaker.deleteMany).toHaveBeenCalledWith({ where: { petId: "pet-1", ownerId: CARE } });
});
it("masks e-mail addresses", () => {
expect(maskEmail("daniel@example.com")).toBe("d•••@example.com");
expect(maskEmail("kaputt")).toBe("•••");
});
});
describe("deleteOwnerAccount with a co-caretaker", () => {
it("hands a co-cared pet over to the active caretaker instead of deleting it", async () => {
const prisma = createMockPrisma();
prisma.pet.findMany.mockResolvedValue([
{ id: "shared", caretaker: { ownerId: CARE, status: "ACTIVE" } },
{ id: "pending", caretaker: { ownerId: CARE, status: "PENDING" } },
{ id: "solo", caretaker: null },
]);
prisma.$transaction.mockResolvedValue([]);
prisma.owner.updateMany.mockResolvedValue({ count: 0 });
prisma.owner.delete.mockResolvedValue({});
await deleteOwnerAccount(asDb(prisma), MAIN);
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "shared" }, data: { ownerId: CARE } });
expect(prisma.petCaretaker.delete).toHaveBeenCalledWith({ where: { petId: "shared" } });
expect(mockDeletePet.mock.calls.map((c) => c[1])).toEqual(["pending", "solo"]);
});
});
+1 -1
View File
@@ -132,7 +132,7 @@ describe("health consent (Art. 9 Abs. 2 lit. a DSGVO)", () => {
const result = await caller.health.getConsentStatus({ petId: "my-pet" });
expect(result).toEqual({ hasConsented: false, consentedAt: null });
expect(result).toEqual({ hasConsented: false, consentedAt: null, canManageConsent: true });
});
it("blocks addWeightLog when consent was never given", async () => {
+10
View File
@@ -378,6 +378,7 @@ interface MockPrismaClient {
story: MockStoryClient;
storyMention: { createMany: MockFn; findMany: MockFn; deleteMany: MockFn };
lostReport: { findFirst: MockFn; findUnique: MockFn; findMany: MockFn; count: MockFn; create: MockFn; update: MockFn; delete: MockFn };
petCaretaker: { findUnique: MockFn; findMany: MockFn; upsert: MockFn; updateMany: MockFn; delete: MockFn; deleteMany: MockFn };
verificationRequest: { findFirst: MockFn; findMany: MockFn; count: MockFn };
pushToken: { upsert: MockFn; findMany: MockFn; deleteMany: MockFn };
message: { findFirst: MockFn; findMany: MockFn; count: MockFn };
@@ -528,6 +529,15 @@ export function createMockPrisma(): MockPrismaClient {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
// Mitbetreuer (max. einer pro Tier, petId eindeutig)
petCaretaker: {
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
upsert: vi.fn(),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
delete: vi.fn(),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
lostReport: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn(),
+62 -10
View File
@@ -33,7 +33,7 @@ describe("notify() push add-on", () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: null });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: null }]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" });
@@ -52,7 +52,7 @@ describe("notify() push add-on", () => {
await flush();
expect(sendExpoPushToTokens).not.toHaveBeenCalled();
expect(mockPrisma.owner.findUnique).not.toHaveBeenCalled();
expect(mockPrisma.owner.findMany).not.toHaveBeenCalled();
});
it("sends a personalized push with the actor pet's name when a token is registered", async () => {
@@ -61,7 +61,7 @@ describe("notify() push add-on", () => {
mockPrisma.pet.findUnique
.mockResolvedValueOnce({ ownerId: "owner-1" }) // recipient lookup
.mockResolvedValueOnce({ name: "Rex" }); // actor lookup
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[abc]" }]);
notify(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -80,7 +80,7 @@ describe("notify() push add-on", () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[abc]" }]);
notify(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -101,7 +101,7 @@ describe("notify() push add-on", () => {
mockPrisma.pet.findUnique
.mockResolvedValueOnce({ ownerId: "owner-1" }) // recipient lookup
.mockResolvedValueOnce({ name: "Luna" }); // actor (suggested pet) lookup
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[abc]" }]);
notify(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -121,7 +121,7 @@ describe("notify() push add-on", () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[abc]" }]);
expect(() =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -141,14 +141,14 @@ describe("notify() push to multiple devices", () => {
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValueOnce({ ownerId: "owner-1" }).mockResolvedValueOnce({ name: "Rex" });
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[phone]" }, { token: "ExponentPushToken[tablet]" }]);
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[phone]" });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[phone]" }]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" });
await flush();
expect(sendExpoPushToTokens).toHaveBeenCalledWith(["ExponentPushToken[phone]", "ExponentPushToken[tablet]"], expect.anything());
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith({ where: { ownerId: "owner-1" }, select: { token: true } });
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith({ where: { ownerId: { in: ["owner-1"] } }, select: { token: true } });
});
it("deletes tokens Expo reports as no longer registered, including the legacy one", async () => {
@@ -156,7 +156,7 @@ describe("notify() push to multiple devices", () => {
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValueOnce({ ownerId: "owner-1" }).mockResolvedValueOnce(null);
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[old]" }, { token: "ExponentPushToken[new]" }]);
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[old]" });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[old]" }]);
sendExpoPushToTokens.mockResolvedValueOnce({ invalidTokens: ["ExponentPushToken[old]"] });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -164,6 +164,58 @@ describe("notify() push to multiple devices", () => {
await flush();
expect(mockPrisma.pushToken.deleteMany).toHaveBeenCalledWith({ where: { token: { in: ["ExponentPushToken[old]"] } } });
expect(mockPrisma.owner.update).toHaveBeenCalledWith({ where: { id: "owner-1" }, data: { expoPushToken: null } });
expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({ where: { id: { in: ["owner-1"] } }, data: { expoPushToken: null } });
});
});
describe("notify() push to a co-caretaker (Mitbetreuer)", () => {
beforeEach(() => {
sendExpoPushToTokens.mockClear();
});
it("also pushes to the devices of an ACTIVE co-caretaker", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique
.mockResolvedValueOnce({ ownerId: "owner-1", caretaker: { ownerId: "owner-2", status: "ACTIVE" } })
.mockResolvedValueOnce({ name: "Rex" });
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[main]" }, { token: "ExponentPushToken[care]" }]);
mockPrisma.owner.findMany.mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" });
await flush();
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith({ where: { ownerId: { in: ["owner-1", "owner-2"] } }, select: { token: true } });
expect(sendExpoPushToTokens).toHaveBeenCalledWith(["ExponentPushToken[main]", "ExponentPushToken[care]"], expect.anything());
});
it("ignores a co-caretaker whose request is still PENDING", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValueOnce({ ownerId: "owner-1", caretaker: { ownerId: "owner-2", status: "PENDING" } });
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[main]" }]);
mockPrisma.owner.findMany.mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "BIRTHDAY" });
await flush();
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith({ where: { ownerId: { in: ["owner-1"] } }, select: { token: true } });
});
it("sends CARETAKER_REQUEST only to the main owner", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValueOnce({ ownerId: "owner-1", caretaker: { ownerId: "owner-2", status: "ACTIVE" } });
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[main]" }]);
mockPrisma.owner.findMany.mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "CARETAKER_REQUEST" });
await flush();
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith({ where: { ownerId: { in: ["owner-1"] } }, select: { token: true } });
expect(sendExpoPushToTokens).toHaveBeenCalledWith(["ExponentPushToken[main]"], expect.objectContaining({ title: "Anfrage zur Mitbetreuung" }));
});
});
+45
View File
@@ -353,3 +353,48 @@ describe("setDeceased: Gedenkmodus „In Erinnerung“", () => {
expect(mockPrisma.pet.update).not.toHaveBeenCalled();
});
});
describe("Mitbetreuer: Rechte im pets-Router", () => {
const CARETAKER_ID = "caretaker-3";
const SPECIES = { id: "species-dog", name: "Dog", translations: [] };
const SHARED_PET = { ...MOCK_PET, species: SPECIES, breed: null, caretaker: { ownerId: CARETAKER_ID, status: "ACTIVE" } };
let mockPrisma: ReturnType<typeof createMockPrisma>;
beforeEach(() => {
mockPrisma = createMockPrisma();
mockPrisma.pet.findUnique.mockResolvedValue(SHARED_PET);
mockDeletePetCompletely.mockClear();
});
it("lets an active caretaker edit the profile", async () => {
mockPrisma.pet.update.mockResolvedValue({ ...MOCK_PET, species: SPECIES, breed: null, bio: "Neu" });
await createTestCaller(CARETAKER_ID, mockPrisma).update({ petId: PET_ID, bio: "Neu" });
expect(mockPrisma.pet.update).toHaveBeenCalled();
});
it("keeps privacy settings with the main owner", async () => {
await expect(
createTestCaller(CARETAKER_ID, mockPrisma).update({ petId: PET_ID, dmPolicy: "FOLLOWERS_ONLY" })
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mockPrisma.pet.update).not.toHaveBeenCalled();
});
it("never lets a caretaker delete the pet", async () => {
await expect(createTestCaller(CARETAKER_ID, mockPrisma).delete({ petId: PET_ID })).rejects.toMatchObject({
code: "FORBIDDEN",
});
expect(mockDeletePetCompletely).not.toHaveBeenCalled();
});
it("keeps the memorial mode with the main owner", async () => {
await expect(
createTestCaller(CARETAKER_ID, mockPrisma).setDeceased({ petId: PET_ID, deceasedAt: null })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("returns the caller's role from byId", async () => {
const pet = await createTestCaller(CARETAKER_ID, mockPrisma).byId({ petId: PET_ID });
expect(pet.role).toBe("CARETAKER");
expect(pet).not.toHaveProperty("caretaker");
});
});
+1 -1
View File
@@ -40,7 +40,7 @@ beforeEach(() => {
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.vaccine.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[abc]" }]);
});
describe("GET /api/cron/vaccine-reminders", () => {
+8 -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, Syringe, PawPrint, Ribbon, Siren, PartyPopper } from "lucide-react";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award, Syringe, PawPrint, Ribbon, Siren, PartyPopper, Users } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
@@ -18,9 +18,9 @@ import { formatRelativeCompact } from "@/lib/format-relative-time";
import { RankRing } from "@/components/referrals/RankRing";
import { StoryViewer } from "@/components/stories/StoryViewer";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP" | "VACCINE_DUE" | "STORY_MENTION" | "STORY_COMMENT" | "STORY_REACTION" | "PET_DECEASED" | "PET_LOST" | "PET_FOUND";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP" | "VACCINE_DUE" | "STORY_MENTION" | "STORY_COMMENT" | "STORY_REACTION" | "PET_DECEASED" | "PET_LOST" | "PET_FOUND" | "CARETAKER_REQUEST";
const KNOWN_TYPES = new Set<string>(["FOLLOW", "REACTION", "COMMENT", "MESSAGE", "BIRTHDAY", "ADOPTION_DAY", "MENTION", "PAW_BACK", "PET_SUGGESTION", "REFERRAL_JOINED", "REFERRAL_RANK_UP", "VACCINE_DUE", "STORY_MENTION", "STORY_COMMENT", "STORY_REACTION", "PET_DECEASED", "PET_LOST", "PET_FOUND"]);
const KNOWN_TYPES = new Set<string>(["FOLLOW", "REACTION", "COMMENT", "MESSAGE", "BIRTHDAY", "ADOPTION_DAY", "MENTION", "PAW_BACK", "PET_SUGGESTION", "REFERRAL_JOINED", "REFERRAL_RANK_UP", "VACCINE_DUE", "STORY_MENTION", "STORY_COMMENT", "STORY_REACTION", "PET_DECEASED", "PET_LOST", "PET_FOUND", "CARETAKER_REQUEST"]);
function notificationIcon(type: NotificationType) {
switch (type) {
@@ -42,6 +42,7 @@ function notificationIcon(type: NotificationType) {
case "PET_DECEASED": return <Ribbon className="h-4 w-4 fill-foreground text-foreground" />;
case "PET_LOST": return <Siren className="h-4 w-4 text-red-600" />;
case "PET_FOUND": return <PartyPopper className="h-4 w-4 text-emerald-500" />;
case "CARETAKER_REQUEST": return <Users className="h-4 w-4 text-orange-500" />;
}
}
@@ -115,6 +116,9 @@ export default function NotificationsPage() {
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
case "REFERRAL_RANK_UP":
return "/einladen";
// Mitbetreuungs-Anfrage → Verwaltung im Tierprofil (Freigeben/Ablehnen)
case "CARETAKER_REQUEST":
return `/pets/${notif.recipientPetId}/edit#mitbetreuer`;
// Nachricht → direkt ins Gespräch (conversationId liefert notifications.list)
case "MESSAGE":
return notif.conversationId ? `/messages/${notif.conversationId}` : "/messages";
@@ -289,6 +293,7 @@ export default function NotificationsPage() {
{notif.type === "PET_DECEASED" && t("typePetDeceased")}
{notif.type === "PET_LOST" && t("typePetLost")}
{notif.type === "PET_FOUND" && t("typePetFound")}
{notif.type === "CARETAKER_REQUEST" && t("typeCaretakerRequest")}
{notif.type === "VACCINE_DUE" && notif.vaccine?.nextDueDate &&
t("typeVaccineDue", {
name: notif.vaccine.name,
+168 -102
View File
@@ -11,6 +11,7 @@ import { useActivePet } from "@/context/ActivePetContext";
import { PetForm } from "@/components/pet/PetForm";
import { MemorialSettings } from "@/components/pet/MemorialSettings";
import { LostReportSettings } from "@/components/lost/LostReportSettings";
import { CaretakerSettings } from "@/components/caretaker/CaretakerSettings";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -53,7 +54,9 @@ export default function EditPetPage() {
const queryClient = useQueryClient();
const { activePetId, setActivePet } = useActivePet();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [dmPolicy, setDmPolicy] = useState<"EVERYONE" | "FOLLOWERS_ONLY">("EVERYONE");
const [dmPolicy, setDmPolicy] = useState<"EVERYONE" | "FOLLOWERS_ONLY">(
"EVERYONE",
);
const [followerVisibility, setFollowerVisibility] = useState<
"EVERYONE" | "FOLLOWERS_ONLY" | "NOBODY"
>("EVERYONE");
@@ -68,7 +71,7 @@ export default function EditPetPage() {
});
const { data: verificationRequest } = useQuery(
trpc.pets.getVerificationRequest.queryOptions({ petId })
trpc.pets.getVerificationRequest.queryOptions({ petId }),
);
const requestVerificationMutation = useMutation({
@@ -85,13 +88,16 @@ export default function EditPetPage() {
// Sync dmPolicy from fetched pet
useEffect(() => {
if (pet?.dmPolicy) setDmPolicy(pet.dmPolicy as "EVERYONE" | "FOLLOWERS_ONLY");
if (pet?.dmPolicy)
setDmPolicy(pet.dmPolicy as "EVERYONE" | "FOLLOWERS_ONLY");
}, [pet?.dmPolicy]);
// Sync followerVisibility from fetched pet
useEffect(() => {
if (pet?.followerVisibility) {
setFollowerVisibility(pet.followerVisibility as "EVERYONE" | "FOLLOWERS_ONLY" | "NOBODY");
setFollowerVisibility(
pet.followerVisibility as "EVERYONE" | "FOLLOWERS_ONLY" | "NOBODY",
);
}
}, [pet?.followerVisibility]);
@@ -142,13 +148,21 @@ export default function EditPetPage() {
);
}
const isMainOwner = pet.role === "OWNER";
return (
<div className="container max-w-[512px] mx-auto px-6 py-8">
<h1 className="text-xl font-semibold mb-6">{t("title", { name: pet.name })}</h1>
<h1 className="text-xl font-semibold mb-6">
{t("title", { name: pet.name })}
</h1>
{/* Vermisst-Meldung ganz oben: im Ernstfall sofort auffindbar */}
<div className="mb-6">
<LostReportSettings petId={pet.id} petName={pet.name} isDeceased={pet.deceasedAt != null} />
<LostReportSettings
petId={pet.id}
petName={pet.name}
isDeceased={pet.deceasedAt != null}
/>
</div>
<PetForm
@@ -163,87 +177,119 @@ export default function EditPetPage() {
bio: pet.bio ?? null,
adoptionStory: pet.adoptionStory ?? null,
}}
initialBirthday={pet.birthday ? new Date(pet.birthday).toISOString().slice(0, 10) : null}
initialAdoptedAt={pet.adoptedAt ? new Date(pet.adoptedAt).toISOString().slice(0, 10) : null}
initialBirthday={
pet.birthday
? new Date(pet.birthday).toISOString().slice(0, 10)
: null
}
initialAdoptedAt={
pet.adoptedAt
? new Date(pet.adoptedAt).toISOString().slice(0, 10)
: null
}
onSuccess={() => router.push(`/pets/${pet.id}`)}
/>
{/* Privacy settings */}
<Separator className="my-8" />
<div className="space-y-4">
<h2 className="text-sm font-medium">{t("privacyHeading")}</h2>
<div className="space-y-2">
<Label htmlFor="dm-policy">{t("whoCanMessage", { name: pet.name })}</Label>
<div className="flex items-center gap-3">
<Select
value={dmPolicy}
onValueChange={(v) => setDmPolicy(v as "EVERYONE" | "FOLLOWERS_ONLY")}
>
<SelectTrigger id="dm-policy" className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVERYONE">{t("everyone")}</SelectItem>
<SelectItem value="FOLLOWERS_ONLY">{t("onlyFollowedPets")}</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={updateDmPolicyMutation.isPending || dmPolicy === pet.dmPolicy}
onClick={() =>
updateDmPolicyMutation.mutate({ petId: pet.id, dmPolicy })
}
>
{t("save")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{dmPolicy === "FOLLOWERS_ONLY" ? t("dmFollowersOnlyHint") : t("dmEveryoneHint")}
</p>
</div>
{/* Privatsphäre, Gedenkmodus, Löschen: nur der Haupt-Besitzer (Mitbetreuer sieht sie nicht) */}
{isMainOwner && (
<>
<Separator className="my-8" />
<div className="space-y-4">
<h2 className="text-sm font-medium">{t("privacyHeading")}</h2>
<div className="space-y-2">
<Label htmlFor="dm-policy">
{t("whoCanMessage", { name: pet.name })}
</Label>
<div className="flex items-center gap-3">
<Select
value={dmPolicy}
onValueChange={(v) =>
setDmPolicy(v as "EVERYONE" | "FOLLOWERS_ONLY")
}
>
<SelectTrigger id="dm-policy" className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVERYONE">{t("everyone")}</SelectItem>
<SelectItem value="FOLLOWERS_ONLY">
{t("onlyFollowedPets")}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={
updateDmPolicyMutation.isPending ||
dmPolicy === pet.dmPolicy
}
onClick={() =>
updateDmPolicyMutation.mutate({ petId: pet.id, dmPolicy })
}
>
{t("save")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{dmPolicy === "FOLLOWERS_ONLY"
? t("dmFollowersOnlyHint")
: t("dmEveryoneHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="follower-visibility">{t("whoCanSeeFollowers", { name: pet.name })}</Label>
<div className="flex items-center gap-3">
<Select
value={followerVisibility}
onValueChange={(v) =>
setFollowerVisibility(v as "EVERYONE" | "FOLLOWERS_ONLY" | "NOBODY")
}
>
<SelectTrigger id="follower-visibility" className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVERYONE">{t("everyone")}</SelectItem>
<SelectItem value="FOLLOWERS_ONLY">{t("onlyMyFollowers")}</SelectItem>
<SelectItem value="NOBODY">{t("onlyMe")}</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={
updateFollowerVisibilityMutation.isPending ||
followerVisibility === pet.followerVisibility
}
onClick={() =>
updateFollowerVisibilityMutation.mutate({ petId: pet.id, followerVisibility })
}
>
{t("save")}
</Button>
<div className="space-y-2">
<Label htmlFor="follower-visibility">
{t("whoCanSeeFollowers", { name: pet.name })}
</Label>
<div className="flex items-center gap-3">
<Select
value={followerVisibility}
onValueChange={(v) =>
setFollowerVisibility(
v as "EVERYONE" | "FOLLOWERS_ONLY" | "NOBODY",
)
}
>
<SelectTrigger id="follower-visibility" className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVERYONE">{t("everyone")}</SelectItem>
<SelectItem value="FOLLOWERS_ONLY">
{t("onlyMyFollowers")}
</SelectItem>
<SelectItem value="NOBODY">{t("onlyMe")}</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={
updateFollowerVisibilityMutation.isPending ||
followerVisibility === pet.followerVisibility
}
onClick={() =>
updateFollowerVisibilityMutation.mutate({
petId: pet.id,
followerVisibility,
})
}
>
{t("save")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{followerVisibility === "FOLLOWERS_ONLY"
? t("followerFollowersOnlyHint")
: followerVisibility === "NOBODY"
? t("followerNobodyHint")
: t("followerEveryoneHint")}
</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
{followerVisibility === "FOLLOWERS_ONLY"
? t("followerFollowersOnlyHint")
: followerVisibility === "NOBODY"
? t("followerNobodyHint")
: t("followerEveryoneHint")}
</p>
</div>
</div>
</>
)}
{/* Verification */}
<Separator className="my-8" />
@@ -258,11 +304,14 @@ export default function EditPetPage() {
<p className="text-sm text-muted-foreground">{t("pendingStatus")}</p>
) : (
<div className="space-y-2">
{verificationRequest?.status === "REJECTED" && verificationRequest.reviewNote && (
<p className="text-xs text-destructive">
{t("rejectedPrefix", { reason: verificationRequest.reviewNote })}
</p>
)}
{verificationRequest?.status === "REJECTED" &&
verificationRequest.reviewNote && (
<p className="text-xs text-destructive">
{t("rejectedPrefix", {
reason: verificationRequest.reviewNote,
})}
</p>
)}
<p className="text-xs text-muted-foreground">{t("criteriaHint")}</p>
<Textarea
placeholder={t("messagePlaceholder")}
@@ -272,7 +321,10 @@ export default function EditPetPage() {
<Button
variant="outline"
size="sm"
disabled={requestVerificationMutation.isPending || !verificationMessage.trim()}
disabled={
requestVerificationMutation.isPending ||
!verificationMessage.trim()
}
onClick={() =>
requestVerificationMutation.mutate({
petId: pet.id,
@@ -286,22 +338,36 @@ export default function EditPetPage() {
)}
</div>
{/* Gedenkmodus „In Erinnerung“ */}
{/* Mitbetreuer */}
<Separator className="my-8" />
<MemorialSettings petId={pet.id} petName={pet.name} deceasedAt={pet.deceasedAt} />
<CaretakerSettings petId={pet.id} petName={pet.name} />
{/* Destructive zone */}
<Separator className="my-8" />
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">{t("dangerZoneHeading")}</p>
<Button
variant="ghost"
className="text-destructive hover:text-destructive w-fit"
onClick={() => setDeleteDialogOpen(true)}
>
{t("deletePet")}
</Button>
</div>
{isMainOwner && (
<>
{/* Gedenkmodus „In Erinnerung“ */}
<Separator className="my-8" />
<MemorialSettings
petId={pet.id}
petName={pet.name}
deceasedAt={pet.deceasedAt}
/>
{/* Destructive zone */}
<Separator className="my-8" />
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
{t("dangerZoneHeading")}
</p>
<Button
variant="ghost"
className="text-destructive hover:text-destructive w-fit"
onClick={() => setDeleteDialogOpen(true)}
>
{t("deletePet")}
</Button>
</div>
</>
)}
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+3 -1
View File
@@ -20,7 +20,9 @@ export default async function HealthPage({ params }: PageProps) {
notFound();
}
if (!user || pet.ownerId !== user.id) {
// Haupt-Besitzer oder aktiver Mitbetreuer — byId wirft für alle anderen.
const canManage = user ? await trpc.pets.byId({ petId }).then(() => true, () => false) : false;
if (!canManage) {
redirect(`/pets/${petId}`);
}
+3 -1
View File
@@ -23,7 +23,9 @@ export default async function InsightsPage({ params }: PageProps) {
notFound();
}
if (!user || pet.ownerId !== user.id) {
// Haupt-Besitzer oder aktiver Mitbetreuer — byId wirft für alle anderen.
const canManage = user ? await trpc.pets.byId({ petId }).then(() => true, () => false) : false;
if (!canManage) {
redirect(`/pets/${petId}`);
}
+2 -1
View File
@@ -50,7 +50,8 @@ export default async function PetProfilePage({ params }: PageProps) {
if (!pet) notFound();
const user = await currentUser();
const isOwnPet = user?.id === pet.ownerId;
// Eigenes oder mitbetreutes Tier? byId wirft für alle anderen.
const isOwnPet = user ? await trpc.pets.byId({ petId }).then(() => true, () => false) : false;
// Only fetch Clerk username for own pets (we don't have other owners' Clerk profiles server-side)
const ownerUsername = isOwnPet
+7
View File
@@ -6,6 +6,7 @@ import { PetGrid } from "./PetGrid";
import { DeleteAccountDialog } from "@/components/account/DeleteAccountDialog";
import { HideSpidersToggle } from "@/components/account/HideSpidersToggle";
import { PetSuggestionsToggle } from "@/components/account/PetSuggestionsToggle";
import { CaretakerRedeemFromUrl } from "@/components/caretaker/CaretakerRedeem";
/**
* My Pets page — /pets
@@ -35,6 +36,12 @@ export default async function PetsPage({
<PetGrid pets={pets} />
{/* Mitbetreuer: Einladungscode eines anderen Accounts einlösen */}
<section className="mt-10 space-y-3 rounded-xl border p-4">
<h2 className="text-sm font-medium">{t("caretakerHeading")}</h2>
<CaretakerRedeemFromUrl />
</section>
<div className="mt-10 space-y-4">
<HideSpidersToggle />
<PetSuggestionsToggle />
+3 -1
View File
@@ -1,6 +1,7 @@
import { auth } from "@clerk/nextjs/server";
import { prisma } from "@/lib/prisma";
import { deleteOwnerAccount } from "@/lib/delete-account";
import { petsControlledBy } from "@/lib/assert-pet-ownership";
export async function POST(req: Request) {
const { userId } = await auth();
@@ -15,8 +16,9 @@ export async function POST(req: Request) {
return Response.json({ error: "Pet name required" }, { status: 400 });
}
// Auch der Name eines mitbetreuten Tiers bestätigt — ein reiner Mitbetreuer hat kein eigenes.
const pets = await prisma.pet.findMany({
where: { ownerId: userId },
where: petsControlledBy(userId),
select: { name: true },
});
+13
View File
@@ -102,6 +102,19 @@ export default function DatenschutzPage() {
Impfungen, Notfall-Tierarzt-Angabe, Fütterungsinfos, Health Card) automatisch und
unwiderruflich gelöscht.
</p>
<p className="mt-3">
<strong>Mitbetreuer:</strong> Der Haupt-Besitzer eines Haustierprofils kann eine weitere
Person mit eigenem Konto als Mitbetreuer einladen. Löst diese Person den Einladungscode
ein, sieht der Haupt-Besitzer zur Freigabe ihren Namen und eine gekürzte E-Mail-Adresse.
Nach der Freigabe kann der Mitbetreuer als das Haustier handeln und sieht dieselben
Inhalte wie der Haupt-Besitzer – einschließlich der Gesundheitsdaten des Tieres und
der Mitteilungen des Profils, die auch auf seine Geräte gesendet werden. Die
Einwilligung für Gesundheitsdaten gibt und widerruft weiterhin nur der Haupt-Besitzer.
Nach außen ist der Mitbetreuer nicht sichtbar. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b
DSGVO (Bereitstellung der gewünschten Funktion); beide Seiten können die Mitbetreuung
jederzeit beenden. Löscht der Haupt-Besitzer sein Konto, geht das Haustierprofil auf
den Mitbetreuer über.
</p>
</LegalSection>
<LegalSection number="4" title="Drittanbieter, Auftragsverarbeiter und Drittlandübermittlungen">
+23
View File
@@ -0,0 +1,23 @@
import { redirect } from "next/navigation";
import { createTRPCCaller } from "@/trpc/server";
import { normalizeCaretakerCode } from "@/lib/caretaker-code";
interface PageProps {
params: Promise<{ code: string }>;
}
/**
* Link aus „Mitbetreuer einladen“ (/mitbetreuen/<code>). Nicht öffentlich —
* proxy.ts schickt Abgemeldete zur Anmeldung und danach hierher zurück.
* Ohne eigenes Tier geht es ins Onboarding (Mitbetreuer-Weg), sonst zu „Meine
* Tiere“; der Code ist dort jeweils vorausgefüllt, eingelöst wird erst per Klick.
*/
export default async function CaretakerInvitePage({ params }: PageProps) {
const { code } = await params;
const normalized = normalizeCaretakerCode(decodeURIComponent(code));
const query = normalized ? `?mitbetreuen=${normalized}` : "";
const trpc = await createTRPCCaller();
const pets = await trpc.pets.list();
redirect(pets.length === 0 ? `/onboarding/pet${query}` : `/pets${query}`);
}
+31 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { PetForm } from "@/components/pet/PetForm";
import { AvatarUpload } from "@/components/pet/AvatarUpload";
@@ -15,6 +15,7 @@ import { AcquisitionStep } from "@/components/referrals/AcquisitionStep";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Loader2 } from "lucide-react";
import { CaretakerRedeemFromUrl } from "@/components/caretaker/CaretakerRedeem";
/**
* Onboarding pet page — /onboarding/pet
@@ -35,6 +36,15 @@ import { Loader2 } from "lucide-react";
* Lives OUTSIDE the (app) layout group to avoid redirect loop.
*/
export default function OnboardingPetPage() {
// useSearchParams (Mitbetreuer-Link) braucht eine Suspense-Grenze.
return (
<Suspense>
<OnboardingPetContent />
</Suspense>
);
}
function OnboardingPetContent() {
const router = useRouter();
const t = useTranslations("Onboarding");
const { setActivePet } = useActivePet();
@@ -43,6 +53,11 @@ export default function OnboardingPetPage() {
const [uploadConsentGiven, setUploadConsentGiven] = useState(false);
const [arachnophobiaStepDone, setArachnophobiaStepDone] = useState(false);
const [acquisitionStepDone, setAcquisitionStepDone] = useState(false);
// „Ich betreue ein Tier mit“ statt eigenes Tier anlegen (Mitbetreuer-Einladung).
// Über den Link /mitbetreuen/<code> gekommen → direkt den Mitbetreuer-Weg zeigen.
const hasInviteCode = useSearchParams().has("mitbetreuen");
const [caretakerChoice, setIsCaretakerPath] = useState<boolean | null>(null);
const isCaretakerPath = caretakerChoice ?? hasInviteCode;
const trpc = useTRPC();
// Erst nach dem Anlegen des Tiers abfragen — vorher existiert die Owner-Zeile ggf. noch nicht.
const { data: referralState } = useQuery({
@@ -93,6 +108,17 @@ export default function OnboardingPetPage() {
</div>
</div>
);
} else if (isCaretakerPath) {
content = (
<div className="w-full max-w-[512px] p-8">
<h1 className="text-xl font-semibold mb-2">{t("caretakerTitle")}</h1>
<p className="text-sm text-muted-foreground mb-8">{t("caretakerDesc")}</p>
<CaretakerRedeemFromUrl />
<Button variant="ghost" className="mt-6 w-full text-muted-foreground" onClick={() => setIsCaretakerPath(false)}>
{t("caretakerBack")}
</Button>
</div>
);
} else {
content = (
<div className="w-full max-w-[512px] p-8">
@@ -105,6 +131,9 @@ export default function OnboardingPetPage() {
setCreatedPetId(petId);
}}
/>
<Button variant="ghost" className="mt-6 w-full text-muted-foreground" onClick={() => setIsCaretakerPath(true)}>
{t("caretakerSwitch")}
</Button>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Clock } from "lucide-react";
import { useTranslations } from "next-intl";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/** Wie CaretakerRedeem, übernimmt aber `?mitbetreuen=<code>` (Link /mitbetreuen/<code>). */
export function CaretakerRedeemFromUrl() {
return (
<Suspense fallback={<CaretakerRedeem />}>
<WithUrlCode />
</Suspense>
);
}
function WithUrlCode() {
const code = useSearchParams().get("mitbetreuen") ?? "";
return <CaretakerRedeem initialCode={code} />;
}
/**
* „Ich betreue ein Tier mit“: Einladungscode einlösen (Onboarding und „Meine
* Tiere“) und offene Anfragen anzeigen, bis der Haupt-Besitzer freigibt.
*/
export function CaretakerRedeem({ initialCode = "" }: { initialCode?: string }) {
const t = useTranslations("Caretaker");
const trpc = useTRPC();
const queryClient = useQueryClient();
const router = useRouter();
const [code, setCode] = useState(initialCode);
const { data: requests } = useQuery(trpc.caretakers.myRequests.queryOptions());
const refresh = () => queryClient.invalidateQueries({ queryKey: trpc.caretakers.myRequests.queryKey() });
const redeem = useMutation({
...trpc.caretakers.redeem.mutationOptions(),
onSuccess: async ({ petName }) => {
toast.success(t("requestSent", { name: petName }));
setCode("");
await refresh();
},
onError: (err) => toast.error(err.message || t("failed")),
});
const withdraw = useMutation({
...trpc.caretakers.leave.mutationOptions(),
onSuccess: refresh,
onError: (err) => toast.error(err.message || t("failed")),
});
return (
<div className="space-y-4">
{requests?.map((r) => (
<div key={r.petId} className="flex items-start gap-3 rounded-xl border p-3">
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="flex-1 space-y-2">
<p className="text-sm">{t("waiting", { name: r.petName })}</p>
<div className="flex gap-2">
{/* Nach der Freigabe ist das Tier in „Meine Tiere“; sonst leitet das Layout zurück ins Onboarding. */}
<Button
size="sm"
variant="outline"
onClick={() => {
router.push("/pets");
router.refresh();
}}
>
{t("checkAgain")}
</Button>
<Button size="sm" variant="ghost" disabled={withdraw.isPending} onClick={() => withdraw.mutate({ petId: r.petId })}>
{t("withdraw")}
</Button>
</div>
</div>
</div>
))}
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
if (code.trim()) redeem.mutate({ code });
}}
>
<Label htmlFor="caretaker-code">{t("codeLabel")}</Label>
<div className="flex gap-2">
<Input
id="caretaker-code"
value={code}
maxLength={20}
autoCapitalize="characters"
autoComplete="off"
placeholder="7K3M-QX9A"
className="font-mono tracking-widest"
onChange={(e) => setCode(e.target.value)}
/>
<Button type="submit" disabled={redeem.isPending || !code.trim()}>
{t("redeem")}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("codeHint")}</p>
</form>
</div>
);
}
@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Copy, Users } from "lucide-react";
import { useFormatter, useTranslations } from "next-intl";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
type Props = { petId: string; petName: string };
/**
* Mitbetreuer im „Profil bearbeiten“ (Anker #mitbetreuer, Ziel der
* CARETAKER_REQUEST-Mitteilung). Haupt-Besitzer: einladen, freigeben, ablehnen,
* entfernen. Mitbetreuer: sieht seinen Stand und kann sich zurückziehen.
*/
export function CaretakerSettings({ petId, petName }: Props) {
const t = useTranslations("Caretaker");
const format = useFormatter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const router = useRouter();
const [confirmRemove, setConfirmRemove] = useState(false);
const { data, isPending } = useQuery(trpc.caretakers.status.queryOptions({ petId }));
const refresh = () => queryClient.invalidateQueries({ queryKey: trpc.caretakers.status.queryKey({ petId }) });
const onError = (err: { message: string }) => toast.error(err.message || t("failed"));
const createInvite = useMutation({ ...trpc.caretakers.createInvite.mutationOptions(), onSuccess: refresh, onError });
const approve = useMutation({
...trpc.caretakers.approve.mutationOptions(),
onSuccess: async () => {
toast.success(t("approved"));
await refresh();
},
onError,
});
const remove = useMutation({
...trpc.caretakers.remove.mutationOptions(),
onSuccess: async () => {
setConfirmRemove(false);
await refresh();
},
onError,
});
const leave = useMutation({
...trpc.caretakers.leave.mutationOptions(),
onSuccess: async () => {
toast.success(t("left", { name: petName }));
await queryClient.invalidateQueries();
router.push("/pets");
router.refresh();
},
onError,
});
if (isPending || !data) return null;
const c = data.caretaker;
if (data.role === "CARETAKER") {
return (
<Section>
<p className="text-sm text-muted-foreground">{t("youCoCare", { name: petName })}</p>
<p className="text-xs text-muted-foreground">{t("caretakerLimits")}</p>
{confirmRemove ? (
<ConfirmRow
text={t("leaveConfirm", { name: petName })}
confirmLabel={t("leave")}
cancelLabel={t("cancel")}
isBusy={leave.isPending}
onConfirm={() => leave.mutate({ petId })}
onCancel={() => setConfirmRemove(false)}
/>
) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setConfirmRemove(true)}>
{t("leave")}
</Button>
)}
</Section>
);
}
const copyCode = async (code: string) => {
try {
await navigator.clipboard.writeText(code);
toast.success(t("copied"));
} catch {
toast.error(t("failed"));
}
};
return (
<Section>
{!c && (
<>
<p className="text-sm text-muted-foreground">{t("intro", { name: petName })}</p>
<Button variant="outline" size="sm" className="w-fit" disabled={createInvite.isPending} onClick={() => createInvite.mutate({ petId })}>
{t("createInvite")}
</Button>
</>
)}
{c?.status === "INVITED" && c.inviteCode && (
<>
<p className="text-sm text-muted-foreground">{t("inviteHint", { name: petName })}</p>
<div className="flex flex-wrap items-center gap-2">
<code className="rounded-md border bg-muted px-3 py-1.5 font-mono text-base tracking-widest">{c.inviteCode}</code>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => copyCode(c.inviteCode!)}>
<Copy className="h-4 w-4" aria-hidden />
{t("copy")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => copyCode(`${window.location.origin}/mitbetreuen/${c.inviteCode}`)}
>
{t("copyLink")}
</Button>
</div>
{c.inviteExpiresAt && (
<p className="text-xs text-muted-foreground">
{t("validUntil", { date: format.dateTime(new Date(c.inviteExpiresAt), { dateStyle: "medium" }) })}
</p>
)}
<div className="flex gap-2">
<Button variant="ghost" size="sm" disabled={createInvite.isPending} onClick={() => createInvite.mutate({ petId })}>
{t("newCode")}
</Button>
<Button variant="ghost" size="sm" disabled={remove.isPending} onClick={() => remove.mutate({ petId })}>
{t("revokeInvite")}
</Button>
</div>
</>
)}
{c?.status === "PENDING" && (
<>
<p className="text-sm">{t("requestFrom", { who: accountLabel(c.account, t("unknownAccount")) })}</p>
<p className="text-xs text-muted-foreground">{t("requestHint", { name: petName })}</p>
<div className="flex gap-2">
<Button size="sm" disabled={approve.isPending} onClick={() => approve.mutate({ petId })}>
{t("approve")}
</Button>
<Button variant="outline" size="sm" disabled={remove.isPending} onClick={() => remove.mutate({ petId })}>
{t("reject")}
</Button>
</div>
</>
)}
{c?.status === "ACTIVE" && (
<>
<p className="text-sm">{t("activeWith", { who: accountLabel(c.account, t("unknownAccount")), name: petName })}</p>
{confirmRemove ? (
<ConfirmRow
text={t("removeConfirm", { name: petName })}
confirmLabel={t("remove")}
cancelLabel={t("cancel")}
isBusy={remove.isPending}
onConfirm={() => remove.mutate({ petId })}
onCancel={() => setConfirmRemove(false)}
/>
) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setConfirmRemove(true)}>
{t("remove")}
</Button>
)}
</>
)}
</Section>
);
}
function accountLabel(account: { name: string | null; email: string | null } | null, fallback: string): string {
if (!account) return fallback;
if (account.name && account.email) return `${account.name} (${account.email})`;
return account.name ?? account.email ?? fallback;
}
function Section({ children }: { children: React.ReactNode }) {
const t = useTranslations("Caretaker");
return (
<div id="mitbetreuer" className="scroll-mt-20 space-y-3">
<h2 className="flex items-center gap-1.5 text-sm font-medium">
<Users className="h-4 w-4 text-muted-foreground" aria-hidden />
{t("heading")}
</h2>
{children}
</div>
);
}
function ConfirmRow(props: {
text: string;
confirmLabel: string;
cancelLabel: string;
isBusy: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<div className="space-y-2 rounded-lg border p-3">
<p className="text-sm">{props.text}</p>
<div className="flex gap-2">
<Button variant="destructive" size="sm" disabled={props.isBusy} onClick={props.onConfirm}>
{props.confirmLabel}
</Button>
<Button variant="outline" size="sm" onClick={props.onCancel}>
{props.cancelLabel}
</Button>
</div>
</div>
);
}
+4 -3
View File
@@ -1,5 +1,6 @@
import "server-only";
import { TRPCError } from "@trpc/server";
import { petRoleOf, petsControlledBy } from "@/lib/assert-pet-ownership";
import type { PrismaClient } from "@prisma/client";
/**
@@ -16,10 +17,10 @@ export async function assertCanViewFollowerLists(
) {
const target = await prisma.pet.findUnique({
where: { id: targetPetId },
select: { ownerId: true, followerVisibility: true },
select: { ownerId: true, followerVisibility: true, caretaker: { select: { ownerId: true, status: true } } },
});
if (!target) throw new TRPCError({ code: "NOT_FOUND" });
if (target.ownerId === userId) return;
if (petRoleOf(target, userId)) return;
if (target.followerVisibility === "NOBODY") {
throw new TRPCError({ code: "FORBIDDEN", message: "This pet's follower lists are private." });
@@ -29,7 +30,7 @@ export async function assertCanViewFollowerLists(
const isFollower = await prisma.follow.findFirst({
where: {
followeePetId: targetPetId,
follower: { ownerId: userId },
follower: petsControlledBy(userId),
},
select: { followerPetId: true },
});
+47 -15
View File
@@ -1,23 +1,55 @@
import "server-only";
import { TRPCError } from "@trpc/server";
import type { PrismaClient } from "@prisma/client";
import type { Prisma, PrismaClient } from "@prisma/client";
/** Wer ein Tier steuert: der Haupt-Besitzer (Pet.ownerId) oder ein aktiver Mitbetreuer (PetCaretaker). */
export type PetRole = "OWNER" | "CARETAKER";
const forbidden = () =>
new TRPCError({ code: "FORBIDDEN", message: "You do not have permission to access this pet." });
/**
* Ownership assertion — verifies that a pet belongs to the requesting owner
* before any mutation or scoped query proceeds. Never trust a client-supplied
* petId without this check first.
* Darf der Account als dieses Tier handeln? Ja für den Haupt-Besitzer und einen
* AKTIVEN Mitbetreuer (eingeladen/angefragt reicht nicht). Gilt für alles, was
* „als das Tier“ passiert: posten, kommentieren, folgen, Nachrichten, Storys,
* Gesundheitsdaten, Profil bearbeiten, Vermisst. Nie einer petId vom Client
* ohne diese Prüfung trauen. Haupt-Besitzer-Rechte → assertPetMainOwner.
*/
export async function assertPetOwnership(
prisma: PrismaClient,
petId: string,
userId: string
) {
const pet = await prisma.pet.findUnique({ where: { id: petId } });
if (!pet || pet.ownerId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to access this pet.",
});
export async function assertPetOwnership(prisma: PrismaClient, petId: string, userId: string) {
const pet = await prisma.pet.findUnique({
where: { id: petId },
include: { caretaker: { select: { ownerId: true, status: true } } },
});
if (!pet) throw forbidden();
const role = petRoleOf(pet, userId);
if (!role) throw forbidden();
return { ...pet, role };
}
/**
* Nur der Haupt-Besitzer: Tier löschen, Privatsphäre, Gedenkmodus, Mitbetreuer
* verwalten, Einwilligung für Gesundheitsdaten. Ein Mitbetreuer bekommt FORBIDDEN
* mit eigener Meldung, damit die Oberfläche das erklären kann.
*/
export async function assertPetMainOwner(prisma: PrismaClient, petId: string, userId: string) {
const pet = await assertPetOwnership(prisma, petId, userId);
if (pet.role !== "OWNER") {
throw new TRPCError({ code: "FORBIDDEN", message: "Das darf nur der Haupt-Besitzer des Tiers." });
}
return pet;
}
/** Rolle eines Accounts für ein Tier, oder null. */
export function petRoleOf(
pet: { ownerId: string; caretaker?: { ownerId: string | null; status: string } | null },
userId: string
): PetRole | null {
if (pet.ownerId === userId) return "OWNER";
if (pet.caretaker?.status === "ACTIVE" && pet.caretaker.ownerId === userId) return "CARETAKER";
return null;
}
/** Prisma-Filter „Tiere, die dieser Account steuert“ — eigene und aktiv mitbetreute. */
export function petsControlledBy(userId: string): Prisma.PetWhereInput {
return { OR: [{ ownerId: userId }, { caretaker: { is: { ownerId: userId, status: "ACTIVE" } } }] };
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Einladungscodes für Mitbetreuer: 2×4 Zeichen, z. B. "7K3M-QX9A". Ohne
* verwechselbare Zeichen (0/1/I/O) und ohne Tiernamen-Präfix, damit sie sich
* nicht mit Empfehlungscodes ("BUBI-7K3M") verwechseln lassen.
* Pure, ohne Server-Abhängigkeiten, damit Web, App und Tests sie nutzen können.
*/
const CODE_CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const GROUP_LENGTH = 4;
export const CARETAKER_CODE_PATTERN = /^[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$/;
/** Wie lange ein Einladungscode gültig ist. */
export const CARETAKER_INVITE_DAYS = 7;
/** `randomIndex(n)` liefert eine ganze Zahl in [0, n) — auf dem Server crypto.randomInt. */
export function generateCaretakerCode(randomIndex: (n: number) => number): string {
const group = () =>
Array.from({ length: GROUP_LENGTH }, () => CODE_CHARS[randomIndex(CODE_CHARS.length)]).join("");
return `${group()}-${group()}`;
}
/** Normalisiert Eingaben (" 7k3m qx9a ", "7K3MQX9A") und gibt null zurück, wenn das Format nicht passt. */
export function normalizeCaretakerCode(input: string | null | undefined): string | null {
if (!input) return null;
const raw = input.trim().toUpperCase().replace(/[\s-]+/g, "");
if (raw.length !== GROUP_LENGTH * 2) return null;
const code = `${raw.slice(0, GROUP_LENGTH)}-${raw.slice(GROUP_LENGTH)}`;
return CARETAKER_CODE_PATTERN.test(code) ? code : null;
}
+16 -3
View File
@@ -12,10 +12,23 @@ import { deletePetCompletely } from "@/lib/delete-pet";
* action, so both paths stay in sync.
*/
export async function deleteOwnerAccount(prisma: PrismaClient, ownerId: string): Promise<void> {
// Jedes Tier vollständig: Dateien, Mux-Assets, Story-Aufrufe, fremde Paw-Backs,
// Zählerkorrektur an fremden Beiträgen — siehe deletePetCompletely().
const pets = await prisma.pet.findMany({ where: { ownerId }, select: { id: true } });
// Mitbetreute Tiere: hat ein Tier einen aktiven Mitbetreuer, übernimmt er es als
// Haupt-Besitzer (Beiträge, Follower bleiben). Entscheidung 05.10.
const pets = await prisma.pet.findMany({
where: { ownerId },
select: { id: true, caretaker: { select: { ownerId: true, status: true } } },
});
for (const pet of pets) {
const heir = pet.caretaker?.status === "ACTIVE" ? pet.caretaker.ownerId : null;
if (heir) {
await prisma.$transaction([
prisma.petCaretaker.delete({ where: { petId: pet.id } }),
prisma.pet.update({ where: { id: pet.id }, data: { ownerId: heir } }),
]);
continue;
}
// Sonst vollständig: Dateien, Mux-Assets, Story-Aufrufe, fremde Paw-Backs,
// Zählerkorrektur an fremden Beiträgen — siehe deletePetCompletely().
await deletePetCompletely(prisma, pet.id);
}
+23 -8
View File
@@ -64,6 +64,11 @@ const PUSH_COPY: Record<
genericBody: "Ein vermisstes Tier ist wieder sicher zu Hause.",
actorBody: (a) => `${a} ist wieder sicher zu Hause. Danke, dass du mitgeholfen hast!`,
},
CARETAKER_REQUEST: {
title: "Anfrage zur Mitbetreuung",
genericBody: "Jemand möchte dein Tier mitbetreuen – bitte prüfe die Anfrage.",
actorBody: (a) => `Jemand möchte ${a} mitbetreuen – bitte prüfe die Anfrage.`,
},
STORY_REACTION: { title: "Neue Pfote", genericBody: "Jemand hat deiner Story eine Pfote gegeben.", actorBody: (a) => `${a} hat deiner Story eine Pfote gegeben.` },
};
@@ -87,17 +92,26 @@ export async function sendPushForNotification(
): Promise<void> {
const recipientPet = await prisma.pet.findUnique({
where: { id: params.recipientPetId },
select: { ownerId: true },
select: { ownerId: true, caretaker: { select: { ownerId: true, status: true } } },
});
if (!recipientPet) return;
// Alle Geräte des Halters (PushToken) plus der alte Einzel-Token aus der Zeit
// Ein aktiver Mitbetreuer bekommt die Mitteilungen des Tiers ebenfalls aufs Handy.
// Ausnahme: die Mitbetreuungs-Anfrage selbst entscheidet nur der Haupt-Besitzer.
const caretakerId =
recipientPet.caretaker?.status === "ACTIVE" && params.type !== "CARETAKER_REQUEST"
? recipientPet.caretaker.ownerId
: null;
const ownerIds = caretakerId ? [recipientPet.ownerId, caretakerId] : [recipientPet.ownerId];
// Alle Geräte der Halter (PushToken) plus der alte Einzel-Token aus der Zeit
// vor „Push an mehrere Geräte“, bis jedes Gerät sich neu registriert hat.
const [devices, owner] = await Promise.all([
prisma.pushToken.findMany({ where: { ownerId: recipientPet.ownerId }, select: { token: true } }),
prisma.owner.findUnique({ where: { id: recipientPet.ownerId }, select: { expoPushToken: true } }),
const [devices, owners] = await Promise.all([
prisma.pushToken.findMany({ where: { ownerId: { in: ownerIds } }, select: { token: true } }),
prisma.owner.findMany({ where: { id: { in: ownerIds } }, select: { id: true, expoPushToken: true } }),
]);
const tokens = [...new Set([...devices.map((d) => d.token), ...(owner?.expoPushToken ? [owner.expoPushToken] : [])])];
const legacyTokens = owners.flatMap((o) => (o.expoPushToken ? [o.expoPushToken] : []));
const tokens = [...new Set([...devices.map((d) => d.token), ...legacyTokens])];
if (tokens.length === 0) return;
const actorPet = params.actorPetId
@@ -123,8 +137,9 @@ export async function sendPushForNotification(
// Deinstallierte Apps/abgelaufene Tokens aufräumen, damit sie nicht bei jeder Mitteilung erneut scheitern.
if (invalidTokens.length > 0) {
await prisma.pushToken.deleteMany({ where: { token: { in: invalidTokens } } });
if (owner?.expoPushToken && invalidTokens.includes(owner.expoPushToken)) {
await prisma.owner.update({ where: { id: recipientPet.ownerId }, data: { expoPushToken: null } });
const staleOwners = owners.filter((o) => o.expoPushToken && invalidTokens.includes(o.expoPushToken));
if (staleOwners.length > 0) {
await prisma.owner.updateMany({ where: { id: { in: staleOwners.map((o) => o.id) } }, data: { expoPushToken: null } });
}
}
}
+2
View File
@@ -29,6 +29,7 @@ import { referralsRouter } from "@/trpc/routers/referrals";
import { referralAdminRouter } from "@/trpc/routers/referral-admin";
import { curatorAdminRouter } from "@/trpc/routers/curator-admin";
import { lostReportsRouter } from "@/trpc/routers/lost-reports";
import { caretakersRouter } from "@/trpc/routers/caretakers";
import { lostReportAdminRouter } from "@/trpc/routers/lost-report-admin";
import { adminOverviewRouter } from "@/trpc/routers/admin-overview";
import { adminAuditRouter } from "@/trpc/routers/admin-audit";
@@ -66,6 +67,7 @@ export const appRouter = router({
referralAdmin: referralAdminRouter,
curatorAdmin: curatorAdminRouter,
lostReports: lostReportsRouter,
caretakers: caretakersRouter,
lostReportAdmin: lostReportAdminRouter,
adminOverview: adminOverviewRouter,
adminAudit: adminAuditRouter,
+152
View File
@@ -0,0 +1,152 @@
import "server-only";
import { randomInt } from "crypto";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { clerkClient } from "@clerk/nextjs/server";
import { router, protectedProcedure, rateLimited } from "@/trpc/init";
import { assertPetMainOwner, assertPetOwnership } from "@/lib/assert-pet-ownership";
import { CARETAKER_INVITE_DAYS, generateCaretakerCode, normalizeCaretakerCode } from "@/lib/caretaker-code";
import { notify } from "@/lib/notify";
const DAY_MS = 24 * 60 * 60 * 1000;
const petInput = z.object({ petId: z.string().min(1) });
/** "daniel@example.com" → "d•••@example.com" — genug zum Wiedererkennen, ohne die Adresse offenzulegen. */
export function maskEmail(email: string): string {
const [local, domain] = email.split("@");
if (!domain) return "•••";
return `${local.slice(0, 1)}•••@${domain}`;
}
/** Wer hinter einer Anfrage steckt — nur für die beiden Menschen des Tiers, nie öffentlich. */
async function describeAccount(ownerId: string): Promise<{ name: string | null; email: string | null }> {
try {
const user = await (await clerkClient()).users.getUser(ownerId);
const email = user.primaryEmailAddress?.emailAddress ?? user.emailAddresses[0]?.emailAddress ?? null;
const name = [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username || null;
return { name, email: email ? maskEmail(email) : null };
} catch {
return { name: null, email: null };
}
}
/**
* Mitbetreuer (Notiz „Gemeinsame Tierhalter – Machbarkeit“): höchstens ein zweiter
* Account pro Tier. Ablauf: Haupt-Besitzer erzeugt Code (INVITED) → der andere
* löst ihn ein (PENDING) → Haupt-Besitzer gibt frei (ACTIVE). Rechte prüft
* src/lib/assert-pet-ownership.ts.
*/
export const caretakersRouter = router({
/** Stand für die Verwaltung im Tierprofil — für Haupt-Besitzer und Mitbetreuer. */
status: protectedProcedure.input(petInput).query(async ({ ctx, input }) => {
const pet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const row = await ctx.prisma.petCaretaker.findUnique({ where: { petId: input.petId } });
const expired = row?.status === "INVITED" && !!row.inviteExpiresAt && row.inviteExpiresAt < new Date();
if (!row || expired) return { role: pet.role, caretaker: null };
// Der Mitbetreuer sieht nur seinen eigenen Stand, keine Codes.
if (pet.role === "CARETAKER") {
return { role: pet.role, caretaker: { status: row.status, approvedAt: row.approvedAt, inviteCode: null, inviteExpiresAt: null, requestedAt: null, account: null } };
}
return {
role: pet.role,
caretaker: {
status: row.status,
inviteCode: row.status === "INVITED" ? row.inviteCode : null,
inviteExpiresAt: row.inviteExpiresAt,
requestedAt: row.requestedAt,
approvedAt: row.approvedAt,
account: row.ownerId ? await describeAccount(row.ownerId) : null,
},
};
}),
/** Neuer Code (ersetzt einen offenen). Gibt es schon eine Anfrage oder einen Mitbetreuer → CONFLICT. */
createInvite: protectedProcedure
.use(rateLimited("caretaker-invite", 10, 60 * 60))
.input(petInput)
.mutation(async ({ ctx, input }) => {
await assertPetMainOwner(ctx.prisma, input.petId, ctx.userId);
const existing = await ctx.prisma.petCaretaker.findUnique({ where: { petId: input.petId } });
if (existing && existing.status !== "INVITED") {
throw new TRPCError({ code: "CONFLICT", message: "Dieses Tier hat schon einen Mitbetreuer oder eine offene Anfrage." });
}
const inviteCode = generateCaretakerCode(randomInt);
const inviteExpiresAt = new Date(Date.now() + CARETAKER_INVITE_DAYS * DAY_MS);
await ctx.prisma.petCaretaker.upsert({
where: { petId: input.petId },
create: { petId: input.petId, inviteCode, inviteExpiresAt },
update: { inviteCode, inviteExpiresAt, ownerId: null, requestedAt: null, approvedAt: null },
});
return { inviteCode, inviteExpiresAt };
}),
/** Code einlösen → Anfrage wartet auf Freigabe. Geht auch ohne eigenes Tier (Onboarding). */
redeem: protectedProcedure
.use(rateLimited("caretaker-redeem", 10, 60 * 60))
.input(z.object({ code: z.string().max(20) }))
.mutation(async ({ ctx, input }) => {
const code = normalizeCaretakerCode(input.code);
const invalid = () => new TRPCError({ code: "NOT_FOUND", message: "Dieser Code ist ungültig oder abgelaufen." });
if (!code) throw invalid();
const row = await ctx.prisma.petCaretaker.findUnique({
where: { inviteCode: code },
include: { pet: { select: { id: true, name: true, ownerId: true } } },
});
if (!row || row.status !== "INVITED" || !row.inviteExpiresAt || row.inviteExpiresAt < new Date()) throw invalid();
if (row.pet.ownerId === ctx.userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Das ist dein eigenes Tier." });
}
await ctx.prisma.owner.upsert({
where: { id: ctx.userId },
update: {},
create: { id: ctx.userId, registrationIp: ctx.ip },
});
// Bedingtes Update: wurde der Code parallel eingelöst, trifft es 0 Zeilen.
const { count } = await ctx.prisma.petCaretaker.updateMany({
where: { id: row.id, status: "INVITED", inviteCode: code },
data: { ownerId: ctx.userId, status: "PENDING", requestedAt: new Date(), inviteCode: null, inviteExpiresAt: null },
});
if (count === 0) throw invalid();
notify(ctx.prisma, { recipientPetId: row.pet.id, type: "CARETAKER_REQUEST" });
return { petId: row.pet.id, petName: row.pet.name };
}),
/** Haupt-Besitzer gibt die Anfrage frei. */
approve: protectedProcedure.input(petInput).mutation(async ({ ctx, input }) => {
await assertPetMainOwner(ctx.prisma, input.petId, ctx.userId);
const { count } = await ctx.prisma.petCaretaker.updateMany({
where: { petId: input.petId, status: "PENDING" },
data: { status: "ACTIVE", approvedAt: new Date() },
});
if (count === 0) throw new TRPCError({ code: "NOT_FOUND", message: "Keine offene Anfrage." });
return { ok: true };
}),
/** Haupt-Besitzer: Einladung zurückziehen, Anfrage ablehnen oder Mitbetreuer entfernen. */
remove: protectedProcedure.input(petInput).mutation(async ({ ctx, input }) => {
await assertPetMainOwner(ctx.prisma, input.petId, ctx.userId);
await ctx.prisma.petCaretaker.deleteMany({ where: { petId: input.petId } });
return { ok: true };
}),
/** Mitbetreuer (oder Anfragender) zieht sich selbst zurück. */
leave: protectedProcedure.input(petInput).mutation(async ({ ctx, input }) => {
const { count } = await ctx.prisma.petCaretaker.deleteMany({ where: { petId: input.petId, ownerId: ctx.userId } });
if (count === 0) throw new TRPCError({ code: "NOT_FOUND" });
return { ok: true };
}),
/** Eigene offene Anfragen — fürs Onboarding („wartet auf Freigabe“) und „Meine Tiere“. */
myRequests: protectedProcedure.query(async ({ ctx }) => {
const rows = await ctx.prisma.petCaretaker.findMany({
where: { ownerId: ctx.userId, status: "PENDING" },
select: { petId: true, requestedAt: true, pet: { select: { name: true, avatarKey: true } } },
orderBy: { requestedAt: "desc" },
});
return rows.map((r) => ({ petId: r.petId, petName: r.pet.name, avatarKey: r.pet.avatarKey, requestedAt: r.requestedAt }));
}),
});
+5 -3
View File
@@ -2,7 +2,7 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import type { Pet } from "@prisma/client";
import { router, protectedProcedure } from "@/trpc/init";
import { assertPetOwnership as assertOwner } from "@/lib/assert-pet-ownership";
import { assertPetMainOwner, assertPetOwnership as assertOwner } from "@/lib/assert-pet-ownership";
// Gates every data-bearing Health-Tracking endpoint behind the explicit,
// un-pre-checked opt-in required by Art. 9 Abs. 2 lit. a DSGVO (see
@@ -27,13 +27,15 @@ export const healthRouter = router({
return {
hasConsented: Boolean(pet.healthConsentGivenAt),
consentedAt: pet.healthConsentGivenAt,
// Einwilligung geben/widerrufen darf nur der Haupt-Besitzer, ein Mitbetreuer sieht die Daten nur.
canManageConsent: pet.role === "OWNER",
};
}),
giveConsent: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
await assertPetMainOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await ctx.prisma.pet.update({
where: { id: input.petId },
data: { healthConsentGivenAt: new Date(), healthConsentRevokedAt: null },
@@ -48,7 +50,7 @@ export const healthRouter = router({
revokeConsent: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
await assertPetMainOwner(ctx.prisma, input.petId, ctx.userId);
const { petId } = input;
await ctx.prisma.$transaction([
ctx.prisma.weightLog.deleteMany({ where: { petId } }),
+28 -11
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { assertPetMainOwner, assertPetOwnership, petRoleOf, petsControlledBy } from "@/lib/assert-pet-ownership";
import { pickLocalizedName } from "@/lib/localized-name";
import { petCardSelect, referralOwnerSelect } from "@/repositories/pet-repository";
import { welcomeNewPet } from "@/lib/new-pet-welcome";
@@ -56,21 +56,23 @@ export const petsRouter = router({
}),
/**
* List all pets belonging to the authenticated owner.
* Scoped by ownerId — never returns other owners' pets.
* „Meine Tiere“: eigene Tiere und aktiv mitbetreute (role = OWNER | CARETAKER).
* Nie Tiere anderer Accounts.
*/
list: protectedProcedure.query(async ({ ctx }) => {
const pets = await ctx.prisma.pet.findMany({
where: { ownerId: ctx.userId },
where: petsControlledBy(ctx.userId),
include: {
species: { include: { translations: { where: { locale: ctx.locale } } } },
breed: { include: { translations: { where: { locale: ctx.locale } } } },
owner: { select: referralOwnerSelect },
caretaker: { select: { ownerId: true, status: true } },
},
orderBy: { createdAt: "asc" },
});
return pets.map((pet) => ({
return pets.map(({ caretaker, ...pet }) => ({
...pet,
role: petRoleOf({ ownerId: pet.ownerId, caretaker }, ctx.userId) ?? "OWNER",
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
breed: pet.breed
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
@@ -85,15 +87,23 @@ export const petsRouter = router({
byId: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const pet = await ctx.prisma.pet.findUnique({
const found = await ctx.prisma.pet.findUnique({
where: { id: input.petId },
include: {
species: { include: { translations: { where: { locale: ctx.locale } } } },
breed: { include: { translations: { where: { locale: ctx.locale } } } },
caretaker: { select: { ownerId: true, status: true } },
},
});
if (!pet || pet.ownerId !== ctx.userId) {
if (!found) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to access this pet.",
});
}
const { caretaker, ...pet } = found;
const role = petRoleOf({ ownerId: pet.ownerId, caretaker }, ctx.userId);
if (!role) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to access this pet.",
@@ -102,6 +112,7 @@ export const petsRouter = router({
return {
...pet,
role,
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
breed: pet.breed
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
@@ -170,7 +181,8 @@ export const petsRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const pet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
// Gedenkmodus entscheidet nur der Haupt-Besitzer (Entscheidung 05.10.)
const pet = await assertPetMainOwner(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." });
@@ -213,7 +225,11 @@ export const petsRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const { role } = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
// Mitbetreuer dürfen das Profil bearbeiten, die Privatsphäre aber nur der Haupt-Besitzer.
if (role !== "OWNER" && (input.dmPolicy !== undefined || input.followerVisibility !== undefined)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Die Privatsphäre ändert nur der Haupt-Besitzer des Tiers." });
}
const { petId, ...data } = input;
@@ -257,7 +273,8 @@ export const petsRouter = router({
delete: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
// Löschen darf nur der Haupt-Besitzer, nie der Mitbetreuer (Entscheidung 04.10.)
await assertPetMainOwner(ctx.prisma, input.petId, ctx.userId);
// Vollständig inkl. Story-Aufrufe, fremder Paw-Backs, Zählerkorrektur und Dateien —
// ein nacktes pet.delete scheitert an den Fremdschlüsseln ohne Cascade (siehe delete-pet.ts).
+6 -5
View File
@@ -14,7 +14,7 @@ import type { PrismaClient } from "@prisma/client";
import { extractHashtags, saveHashtags } from "@/lib/hashtag-helpers";
import { checkAgainstBlacklist } from "@/lib/blacklist";
import { createMentionNotifications } from "@/lib/mention-helpers";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { assertPetOwnership, petRoleOf } from "@/lib/assert-pet-ownership";
import { petIdentitySelect } from "@/repositories/pet-repository";
import { maybeQualifyReferral } from "@/lib/referrals";
@@ -44,11 +44,11 @@ async function assertPostOwnership(
select: {
id: true,
petId: true,
pet: { select: { id: true, ownerId: true } },
pet: { select: { id: true, ownerId: true, caretaker: { select: { ownerId: true, status: true } } } },
},
});
if (!post || post.pet.ownerId !== userId) {
if (!post || !petRoleOf(post.pet, userId)) {
throw new TRPCError({ code: "FORBIDDEN" });
}
@@ -112,7 +112,7 @@ export const postsRouter = router({
)
.mutation(async ({ ctx, input }) => {
// Ownership assertion (T-02-30): posting as another pet blocked
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const actingPet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
// Create Post + PostImage[] in one nested write
const post = await ctx.prisma.post.create({
@@ -143,7 +143,8 @@ export const postsRouter = router({
);
// Empfehlungsprogramm: der erste eigene Beitrag macht eine Einladung gültig — never fails post creation
await maybeQualifyReferral(ctx.prisma, ctx.userId).catch((err) =>
// Zählt für den Haupt-Besitzer des Tiers, auch wenn ein Mitbetreuer postet (wie im Mux-Webhook).
await maybeQualifyReferral(ctx.prisma, actingPet.ownerId).catch((err) =>
console.error("[posts] referral qualification failed", err)
);
+7 -4
View File
@@ -12,14 +12,17 @@ import { STORY_RETENTION_DAYS, deleteStoryWithMedia } from "@/lib/story-cleanup"
const DAY_MS = 24 * 60 * 60 * 1000;
/** Besitzt der aufrufende Owner dieses Tier? Für die 7-Tage-Ansicht des Erstellers. */
/** Steuert der aufrufende Account dieses Tier (Besitzer oder Mitbetreuer)? Für die 7-Tage-Ansicht des Erstellers. */
async function isOwnPet(prisma: PrismaClient, petId: string, userId: string): Promise<boolean> {
const pet = await prisma.pet.findUnique({ where: { id: petId }, select: { ownerId: true } });
return pet?.ownerId === userId;
const pet = await prisma.pet.findUnique({
where: { id: petId },
select: { ownerId: true, caretaker: { select: { ownerId: true, status: true } } },
});
return !!pet && petRoleOf(pet, userId) !== null;
}
import { randomUUID } from "crypto";
import { petIdentitySelect } from "@/repositories/pet-repository";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { assertPetOwnership, petRoleOf } from "@/lib/assert-pet-ownership";
import { assertNotBlocked } from "@/lib/assert-not-blocked";
import { mux } from "@/lib/mux";
import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits";