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:
1 parent
55e5ab747a
commit
cccda31525
44 files changed
+1851
-200
No files matched your search
+44
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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" } } }] };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
}),
|
||||
});
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user