Author SHA1 Message Date
adminandClaude Opus 5.5 0fc7503c08 feat: Vermisst — Admin-Übersicht und Ansicht direkt in der App
- Admin „Vermisst“ (/p/[secret]/lost): alle Meldungen mit Status, Ort/Zeit,
  Reichweite (benachrichtigte Tiere), freigegebenen Kontaktwegen; Moderatoren
  schließen vergessene Meldungen (ohne Entwarnung) oder löschen Missbrauch,
  beides im Audit-Log (CLOSE_/DELETE_LOST_REPORT)
- App: eigener Vermisst-Bildschirm /lost/[petId] mit Foto, Merkmalen,
  Kontakt (Anrufen, E-Mail, Nachricht in PawFeed), Fotos und Teilen —
  Profil-Banner und Push-Tipp auf PET_LOST führen dorthin statt in den Browser
- lostReports.byPet + gemeinsamer Lader loadLostReportView für App und
  öffentliche Seite

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:17:04 +02:00
adminandClaude Opus 5.5 9b52a20a15 feat: Vermisst-Meldung mit öffentlicher Teilen-Seite
- LostReport (eine aktive Meldung je Tier) + Pet.lostSince als Cache fürs
  Abzeichen, NotificationType PET_LOST/PET_FOUND
- lostReports.create/update/markFound/mine: nur Besitzer, nicht im
  Gedenkmodus, höchstens eine Meldung pro 24 h, Zeitpunkt nicht in der Zukunft
- Mitteilung + Push an Follower und gefolgte Tiere, je Besitzer einmal, eigene
  Tiere nie; optionale Entwarnung bei „Wieder da!“
- Öffentliche Seite /vermisst/<code> ohne Login: Foto, Merkmale, zuletzt
  gesehen, Kontakt nach Wahl des Besitzers (Telefon/E-Mail/PawFeed),
  Fotos aus dem Feed, Vorschaubild; Suchmaschinen nur wenn erlaubt;
  nach dem Finden „Wieder sicher zu Hause“ ohne Kontaktdaten
- Web + App: Melden ganz oben in „Profil bearbeiten“, Vermisst-Banner im
  Profil, Abzeichen neben dem Namen, Mitteilungen führen zum Profil

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:53:31 +02:00
adminandClaude Opus 5.5 1ea3a7172d fix: Gedenkmodus-Texte behutsamer formuliert
„Über die Regenbogenbrücke gegangen“ statt „verstorben“, Anteilnahme im
Bestätigungsdialog, „Tag des Abschieds“, „Profil als Erinnerung bewahren“ —
Web, App, Mitteilung und Push-Text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:41:24 +02:00
44 changed files with 2276 additions and 85 deletions

No files matched your search

+70 -13
View File
@@ -488,7 +488,9 @@
"typeStoryComment": "hat deine Story kommentiert.",
"typeStoryReaction": "hat deiner Story eine Pfote gegeben.",
"typeUnknown": "hat eine neue Mitteilung für dich.",
"typePetDeceased": "ist leider verstorben. In Erinnerung 🕯️"
"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! 🎉"
},
"PhotoPostForm": {
"captionPlaceholder": "Bildunterschrift schreiben…",
@@ -863,21 +865,76 @@
},
"InMemory": {
"label": "In Erinnerung",
"tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen",
"banner": "In Erinnerung an {name} · {date}",
"tooltip": "In liebevoller Erinnerung",
"banner": "In liebevoller Erinnerung an {name} · {date}",
"settingsHeading": "Gedenkmodus",
"settingsHint": "Wenn dein Tier verstorben ist, kannst du sein Profil als Erinnerung erhalten. Es bekommt das Abzeichen „In Erinnerung“, Beiträge bleiben sichtbar und du kannst weiter Erinnerungen teilen. Geburtstags-Mitteilungen, Impf-Erinnerungen und Vorschläge enden.",
"activeHint": "{name} ist im Gedenkmodus. Du kannst das Datum korrigieren oder den Modus beenden.",
"dateLabel": "Verstorben am",
"activate": "Gedenkmodus aktivieren",
"settingsHint": "Wenn dein Tier über die Regenbogenbrücke gegangen ist, kannst du sein Profil als liebevolle Erinnerung bewahren. Alle Beiträge bleiben erhalten, und du kannst weiterhin Erinnerungen teilen. Geburtstags- und Impf-Erinnerungen pausieren dann, damit dich nichts unvermittelt trifft.",
"activeHint": "Das Profil von {name} wird als Erinnerung bewahrt. Du kannst das Datum jederzeit anpassen oder den Gedenkmodus wieder beenden.",
"dateLabel": "Tag des Abschieds",
"activate": "Profil als Erinnerung bewahren",
"saveDate": "Datum speichern",
"remove": "Gedenkmodus beenden",
"confirmTitle": "{name} in Erinnerung halten?",
"confirmDescription": "Das Profil bekommt das Abzeichen „In Erinnerung“. Wer deinem Tier folgt, erhält einmalig eine stille Mitteilung (ohne Push). Du kannst das jederzeit zurücknehmen.",
"confirm": "Ja, aktivieren",
"confirmTitle": "Das Profil von {name} als Erinnerung bewahren?",
"confirmDescription": "Es tut uns sehr leid. Das Profil von {name} bekommt eine Erinnerungsschleife, und alles, was ihr geteilt habt, bleibt erhalten. Wer {name} folgt, erfährt es einmal ganz in Ruhe über die Mitteilungen – ohne Push. Du kannst das jederzeit rückgängig machen.",
"confirm": "Ja, bewahren",
"cancel": "Noch nicht",
"saved": "Das Profil wird nun als Erinnerung bewahrt.",
"removed": "Gedenkmodus beendet.",
"saveFailed": "Das hat leider nicht geklappt. Bitte versuch es noch einmal."
},
"LostPublic": {
"badge": "Vermisst",
"heading": "Hast du {name} gesehen?",
"metaTitle": "Vermisst: {name} – hast du dieses Tier gesehen?",
"metaDescription": "Zuletzt gesehen: {place}. Jeder Hinweis hilft – bitte teilen.",
"lastSeenPlace": "Zuletzt gesehen",
"lastSeenAt": "Wann",
"details": "Erkennungsmerkmale & Hinweise",
"contactHeading": "Bitte melde dich, wenn du {name} siehst",
"messageInPawFeed": "Nachricht über PawFeed schreiben",
"mailSubject": "Hinweis zu {name}",
"approachHint": "Bitte das Tier nicht verfolgen oder einfangen wollen – verängstigte Tiere laufen sonst oft weiter weg. Ruhig beobachten, Ort und Zeit merken und Bescheid geben.",
"photosHeading": "Weitere Fotos",
"photoAlt": "Foto von {name}",
"share": "Seite teilen – jede Weitergabe hilft",
"copied": "Link kopiert",
"foundTitle": "{name} ist wieder sicher zu Hause 🎉",
"foundText": "Danke an alle, die geteilt und mitgesucht haben!",
"openInPawFeed": "{name} auf PawFeed besuchen",
"rateLimited": "Zu viele Anfragen – bitte versuch es in einer Minute erneut."
},
"LostReport": {
"badge": "Wird vermisst",
"teaser": "Ist {name} entlaufen? Hier kannst du sofort eine Vermisst-Meldung starten.",
"open": "Vermisst melden",
"formHeading": "{name} als vermisst melden",
"formHint": "Wir drücken dir fest die Daumen. Alle, die {name} folgen oder denen {name} folgt, bekommen sofort eine Push-Mitteilung, und du erhältst eine Seite zum Teilen – auch für Menschen ohne PawFeed.",
"activeHeading": "{name} wird vermisst",
"activeHint": "Teile den Link, so oft du kannst – zum Beispiel in WhatsApp-Gruppen aus deiner Nachbarschaft. Du kannst die Angaben jederzeit ergänzen.",
"lastSeenAt": "Zuletzt gesehen am",
"lastSeenPlace": "Wo zuletzt gesehen?",
"lastSeenPlacePlaceholder": "z. B. Stadtpark Nord, Nähe Spielplatz",
"description": "Erkennungsmerkmale & Hinweise",
"descriptionPlaceholder": "z. B. rotes Halsband, gechippt, eher scheu – bitte nicht rufen",
"contactHint": "Kontaktdaten sind freiwillig und erscheinen nur auf der Teilen-Seite dieser Meldung. Eine Nachricht über PawFeed ist immer möglich.",
"contactPhone": "Telefonnummer (optional)",
"contactEmail": "E-Mail (optional)",
"allowIndexing": "Seite darf über Suchmaschinen gefunden werden",
"allowIndexingHint": "Mehr Reichweite, aber auch Fremde finden die Seite. Ohne Haken ist sie nur über den Link erreichbar.",
"submit": "Vermisst-Meldung starten",
"cancel": "Abbrechen",
"saved": "Gedenkmodus gespeichert",
"removed": "Gedenkmodus beendet",
"saveFailed": "Konnte nicht gespeichert werden"
"saveChanges": "Änderungen speichern",
"notifyFound": "Allen Bescheid geben, dass {name} wieder da ist",
"markFound": "{name} ist wieder da!",
"share": "Link teilen",
"shareTitle": "Vermisst: {name}",
"copied": "Link kopiert",
"created": "Die Meldung ist raus. Wir drücken die Daumen, dass {name} schnell nach Hause findet.",
"updated": "Angaben gespeichert.",
"foundToast": "Wie schön – willkommen zurück, {name}! 🎉",
"saveFailed": "Das hat leider nicht geklappt. Bitte versuch es noch einmal.",
"bannerTitle": "{name} wird vermisst",
"bannerText": "Zuletzt gesehen: {place} · {date}",
"bannerLink": "Alle Infos & Seite teilen"
}
}
+70 -13
View File
@@ -488,7 +488,9 @@
"typeStoryComment": "commented on your story.",
"typeStoryReaction": "gave your story a paw.",
"typeUnknown": "has a new notification for you.",
"typePetDeceased": "has sadly passed away. In memory 🕯️"
"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! 🎉"
},
"PhotoPostForm": {
"captionPlaceholder": "Write a caption…",
@@ -863,21 +865,76 @@
},
"InMemory": {
"label": "In memory",
"tooltip": "In memory – this pet has crossed the rainbow bridge",
"banner": "In memory of {name} · {date}",
"tooltip": "In loving memory",
"banner": "In loving memory of {name} · {date}",
"settingsHeading": "Memorial mode",
"settingsHint": "If your pet has passed away, you can keep their profile as a memorial. It gets the “In memory” badge, posts stay visible and you can keep sharing memories. Birthday notifications, vaccine reminders and suggestions stop.",
"activeHint": "{name} is in memorial mode. You can correct the date or end the mode.",
"dateLabel": "Passed away on",
"activate": "Turn on memorial mode",
"settingsHint": "If your pet has crossed the rainbow bridge, you can keep their profile as a loving memorial. All posts stay, and you can keep sharing memories. Birthday and vaccine reminders pause so nothing catches you off guard.",
"activeHint": "{name}'s profile is kept as a memorial. You can adjust the date or end memorial mode at any time.",
"dateLabel": "Day of farewell",
"activate": "Keep profile as a memorial",
"saveDate": "Save date",
"remove": "End memorial mode",
"confirmTitle": "Keep {name} in memory?",
"confirmDescription": "The profile gets the “In memory” badge. Everyone following your pet receives a single quiet notification (no push). You can undo this at any time.",
"confirm": "Yes, turn on",
"confirmTitle": "Keep {name}'s profile as a memorial?",
"confirmDescription": "We're so sorry. {name}'s profile gets a memorial ribbon, and everything you've shared stays. Those who follow {name} will be told once, quietly in their notifications – no push. You can undo this at any time.",
"confirm": "Yes, keep it",
"cancel": "Not yet",
"saved": "The profile is now kept as a memorial.",
"removed": "Memorial mode ended.",
"saveFailed": "That didn't work, sorry. Please try again."
},
"LostPublic": {
"badge": "Missing",
"heading": "Have you seen {name}?",
"metaTitle": "Missing: {name} – have you seen this pet?",
"metaDescription": "Last seen: {place}. Every tip helps – please share.",
"lastSeenPlace": "Last seen",
"lastSeenAt": "When",
"details": "Distinguishing features & notes",
"contactHeading": "Please get in touch if you see {name}",
"messageInPawFeed": "Send a message on PawFeed",
"mailSubject": "A tip about {name}",
"approachHint": "Please don't chase or try to catch the pet – frightened animals often run further away. Watch calmly, note the place and time and let us know.",
"photosHeading": "More photos",
"photoAlt": "Photo of {name}",
"share": "Share this page – every share helps",
"copied": "Link copied",
"foundTitle": "{name} is safely back home 🎉",
"foundText": "Thank you to everyone who shared and helped search!",
"openInPawFeed": "Visit {name} on PawFeed",
"rateLimited": "Too many requests – please try again in a minute."
},
"LostReport": {
"badge": "Missing",
"teaser": "Has {name} run away? Start a missing report right here.",
"open": "Report missing",
"formHeading": "Report {name} as missing",
"formHint": "We're keeping our fingers crossed. Everyone who follows {name} or whom {name} follows gets a push notification right away, and you get a page to share – even with people who aren't on PawFeed.",
"activeHeading": "{name} is missing",
"activeHint": "Share the link as often as you can – for example in neighbourhood WhatsApp groups. You can add details at any time.",
"lastSeenAt": "Last seen on",
"lastSeenPlace": "Where last seen?",
"lastSeenPlacePlaceholder": "e.g. North City Park, near the playground",
"description": "Distinguishing features & notes",
"descriptionPlaceholder": "e.g. red collar, microchipped, rather shy – please don't call out",
"contactHint": "Contact details are optional and only appear on this report's share page. A message via PawFeed is always possible.",
"contactPhone": "Phone number (optional)",
"contactEmail": "Email (optional)",
"allowIndexing": "Allow search engines to find this page",
"allowIndexingHint": "More reach, but strangers can find it too. Unchecked, the page is only reachable via the link.",
"submit": "Start missing report",
"cancel": "Cancel",
"saved": "Memorial mode saved",
"removed": "Memorial mode ended",
"saveFailed": "Could not be saved"
"saveChanges": "Save changes",
"notifyFound": "Let everyone know {name} is back",
"markFound": "{name} is back!",
"share": "Share link",
"shareTitle": "Missing: {name}",
"copied": "Link copied",
"created": "Your report is out. We're keeping our fingers crossed that {name} finds the way home soon.",
"updated": "Details saved.",
"foundToast": "How wonderful – welcome back, {name}! 🎉",
"saveFailed": "That didn't work, sorry. Please try again.",
"bannerTitle": "{name} is missing",
"bannerText": "Last seen: {place} · {date}",
"bannerLink": "All details & share page"
}
}
@@ -27,6 +27,8 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
STORY_COMMENT: "notifications.storyComment",
STORY_REACTION: "notifications.storyReaction",
PET_DECEASED: "notifications.petDeceased",
PET_LOST: "notifications.petLost",
PET_FOUND: "notifications.petFound",
};
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
+1 -1
View File
@@ -84,7 +84,7 @@ function PetRow({
<View style={styles.rowText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} />
<PetIdentityBadges isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} />
</View>
<Text style={styles.breed}>{pet.breed?.name ?? pet.species.name}</Text>
</View>
+1
View File
@@ -49,6 +49,7 @@ export default function AppLayout() {
<Stack.Screen name="pets/[petId]/following" options={{ ...headerOptions, title: t("screens.following") }} />
<Stack.Screen name="pets/[petId]/health/index" options={{ ...headerOptions, title: t("screens.health") }} />
<Stack.Screen name="pets/[petId]/milestones" options={{ ...headerOptions, title: t("screens.milestones") }} />
<Stack.Screen name="lost/[petId]" options={{ ...headerOptions, title: t("screens.lost") }} />
<Stack.Screen name="posts/[postId]/comments" options={{ ...headerOptions, title: t("screens.comments") }} />
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
+242
View File
@@ -0,0 +1,242 @@
import { useMemo } from "react";
import { View, Text, ScrollView, Pressable, Image, ActivityIndicator, Linking, Share, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Clock, Info, Mail, MapPin, MessageCircle, Phone, Share2, Siren } from "lucide-react-native";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { useTheme } from "../../../src/context/ThemeContext";
import { Button } from "../../../src/components/Button";
import { env } from "../../../src/lib/env";
import { getMediaUrl, getAvatarUrl } from "../../../src/lib/media-url";
import { isoToDisplayDate } from "../../../src/lib/date-format";
import { useBottomSafePadding } from "../../../src/hooks/useBottomSafePadding";
import { radii, getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
const RED = "#dc2626";
function pad(n: number): string {
return String(n).padStart(2, "0");
}
/** „04.10.2026, 14:30“ in Ortszeit des Geräts. */
function formatDateTime(value: Date | string): string {
const d = new Date(value);
const iso = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return `${isoToDisplayDate(iso)}, ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* Vermisst-Ansicht in der App — dieselben Angaben wie die öffentliche Seite
* /vermisst/<code>, ohne die App zu verlassen. Ziel des Profil-Banners und des
* Antippens einer PET_LOST-Mitteilung (src/lib/notification-route.ts).
*/
export default function LostPetScreen() {
const { petId } = useLocalSearchParams<{ petId: string }>();
const { t } = useTranslation();
const trpc = useTRPC();
const router = useRouter();
const { activePetId, pets } = useActivePet();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const { data: report, isLoading } = useQuery(trpc.lostReports.byPet.queryOptions({ petId }));
const openConversation = useMutation(
trpc.conversations.getOrCreate.mutationOptions({
onSuccess: (conversation) => router.push(`/messages/${conversation.id}`),
})
);
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
if (!report) {
return (
<View style={styles.center}>
<Text style={styles.emptyTitle}>{t("lostView.notActiveTitle")}</Text>
<Text style={styles.muted}>{t("lostView.notActiveText")}</Text>
<Button title={t("lostView.toProfile")} variant="outline" onPress={() => router.replace(`/pets/${petId}`)} />
</View>
);
}
const { pet } = report;
const isOwnPet = pets.some((p) => p.id === pet.id);
const heroUrl = getAvatarUrl(pet.avatarKey) ?? (report.photos[0] ? getMediaUrl(report.photos[0].storageKey) : undefined);
const photoUrls = report.photos.map((p) => getMediaUrl(p.storageKey));
const publicUrl = `${env.apiUrl}/vermisst/${report.code}`;
return (
<ScrollView contentContainerStyle={[styles.container, bottomPadding]}>
<View style={styles.header}>
<View style={styles.badge}>
<Siren size={13} color="#fff" />
<Text style={styles.badgeText}>{t("lostView.badge")}</Text>
</View>
<Text style={styles.heading}>{t("lostView.heading", { name: pet.name })}</Text>
<Text style={styles.muted}>
{pet.speciesName}
{pet.breedName ? ` · ${pet.breedName}` : ""}
</Text>
</View>
{heroUrl ? <Image source={{ uri: heroUrl }} style={styles.hero} alt={pet.name} /> : null}
<View style={styles.card}>
<Fact icon={<MapPin size={16} color={colors.quietGray} />} label={t("lostView.lastSeenPlace")} value={report.lastSeenPlace} styles={styles} />
<Fact icon={<Clock size={16} color={colors.quietGray} />} label={t("lostView.lastSeenAt")} value={formatDateTime(report.lastSeenAt)} styles={styles} />
{report.description ? (
<Fact icon={<Info size={16} color={colors.quietGray} />} label={t("lostView.details")} value={report.description} styles={styles} />
) : null}
</View>
{!isOwnPet && (
<View style={styles.contactCard}>
<Text style={styles.contactHeading}>{t("lostView.contactHeading", { name: pet.name })}</Text>
{report.contactPhone ? (
<ContactButton
icon={<Phone size={18} color={colors.ink} />}
label={report.contactPhone}
onPress={() => void Linking.openURL(`tel:${report.contactPhone!.replace(/[^+0-9]/g, "")}`)}
styles={styles}
/>
) : null}
{report.contactEmail ? (
<ContactButton
icon={<Mail size={18} color={colors.ink} />}
label={report.contactEmail}
onPress={() =>
void Linking.openURL(
`mailto:${report.contactEmail}?subject=${encodeURIComponent(t("lostView.mailSubject", { name: pet.name }))}`
)
}
styles={styles}
/>
) : null}
{activePetId ? (
<ContactButton
icon={<MessageCircle size={18} color={colors.ink} />}
label={t("lostView.message")}
onPress={() => openConversation.mutate({ senderPetId: activePetId, recipientPetId: pet.id })}
styles={styles}
/>
) : null}
<Text style={styles.hint}>{t("lostView.approachHint")}</Text>
</View>
)}
{photoUrls.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("lostView.photosHeading")}</Text>
<View style={styles.grid}>
{photoUrls.map((url, index) => (
<Pressable
key={report.photos[index].postId}
style={styles.gridItem}
onPress={() =>
router.push({ pathname: "/image-viewer", params: { images: JSON.stringify(photoUrls), index: String(index) } })
}
>
<Image source={{ uri: url }} style={styles.gridImage} alt={t("lostView.photoAlt", { name: pet.name })} />
</Pressable>
))}
</View>
</View>
)}
<Pressable
style={styles.shareButton}
accessibilityRole="button"
onPress={() => void Share.share({ message: `${t("lostView.shareText", { name: pet.name })} ${publicUrl}` })}
>
<Share2 size={18} color={colors.ink} />
<Text style={styles.shareText}>{t("lostView.share")}</Text>
</Pressable>
<Button title={t("lostView.toProfile")} variant="outline" onPress={() => router.push(`/pets/${pet.id}`)} />
</ScrollView>
);
}
type Styles = ReturnType<typeof makeStyles>;
function Fact({ icon, label, value, styles }: { icon: React.ReactNode; label: string; value: string; styles: Styles }) {
return (
<View style={styles.fact}>
<View style={styles.factIcon}>{icon}</View>
<View style={styles.flex}>
<Text style={styles.factLabel}>{label}</Text>
<Text style={styles.factValue}>{value}</Text>
</View>
</View>
);
}
function ContactButton({ icon, label, onPress, styles }: { icon: React.ReactNode; label: string; onPress: () => void; styles: Styles }) {
return (
<Pressable style={styles.contactButton} onPress={onPress} accessibilityRole="button">
{icon}
<Text style={styles.contactButtonText} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
container: { padding: 16, gap: 16, backgroundColor: colors.porchWhite, flexGrow: 1 },
center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12, padding: 24, backgroundColor: colors.porchWhite },
emptyTitle: { ...typography.title, fontSize: 18, textAlign: "center" },
header: { alignItems: "center", gap: 6 },
badge: { flexDirection: "row", alignItems: "center", gap: 4, backgroundColor: RED, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4 },
badgeText: { ...typography.label, color: "#fff", letterSpacing: 0.6 },
heading: { ...typography.title, fontSize: 24, textAlign: "center" },
muted: { ...typography.body, color: colors.quietGray, textAlign: "center" },
hero: { width: "100%", aspectRatio: 1, borderRadius: radii.lg, backgroundColor: colors.whisperGray },
card: { gap: 12, padding: 14, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite },
fact: { flexDirection: "row", gap: 10 },
factIcon: { paddingTop: 2 },
flex: { flex: 1 },
factLabel: { ...typography.label, textTransform: "none", letterSpacing: 0 },
factValue: { ...typography.body },
contactCard: { gap: 8, padding: 14, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.alertRedTint, backgroundColor: colors.alertRedTint },
contactHeading: { ...typography.title, fontSize: 16 },
contactButton: {
flexDirection: "row",
alignItems: "center",
gap: 10,
height: 48,
paddingHorizontal: 14,
borderRadius: radii.lg,
backgroundColor: colors.pureWhite,
borderWidth: 1,
borderColor: colors.hairlineGray,
},
contactButtonText: { ...typography.body, fontFamily: typography.title.fontFamily, flexShrink: 1 },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
section: { gap: 8 },
sectionTitle: { ...typography.title, fontSize: 15 },
grid: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
gridItem: { width: "32%", aspectRatio: 1 },
gridImage: { width: "100%", height: "100%", borderRadius: 8, backgroundColor: colors.whisperGray },
shareButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
height: 48,
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.hairlineGray,
},
shareText: { ...typography.body, fontFamily: typography.title.fontFamily },
});
}
+4
View File
@@ -13,6 +13,7 @@ import { Button } from "../../../../src/components/Button";
import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
import { PrivacySettings } from "../../../../src/components/pet/PrivacySettings";
import { MemorialSettings } from "../../../../src/components/pet/MemorialSettings";
import { LostReportSettings } from "../../../../src/components/lost/LostReportSettings";
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";
@@ -133,6 +134,9 @@ export default function EditPetScreen() {
keyboardShouldPersistTaps="handled"
ListHeaderComponent={
<>
{/* Vermisst-Meldung ganz oben: im Ernstfall sofort auffindbar */}
<LostReportSettings petId={pet.id} petName={pet.name} isDeceased={pet.deceasedAt != null} />
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
<TextField value={name} onChangeText={setName} maxLength={30} />
+35 -2
View File
@@ -3,7 +3,7 @@ import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, StyleSheet }
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation } from "@tanstack/react-query";
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal, Ribbon } from "lucide-react-native";
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal, Ribbon, Siren } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { useTheme } from "../../../../src/context/ThemeContext";
@@ -146,8 +146,27 @@ export default function PetProfileScreen() {
</ReferralAvatarRing>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
<PetIdentityBadges isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
</View>
{pet.lostReport && (
<Pressable
style={styles.lostBanner}
accessibilityRole="link"
onPress={() => router.push(`/lost/${pet.id}`)}
>
<View style={styles.lostTitleRow}>
<Siren size={15} color="#dc2626" />
<Text style={styles.lostTitle}>{t("lostReport.bannerTitle", { name: pet.name })}</Text>
</View>
<Text style={styles.lostText}>
{t("lostReport.bannerText", {
place: pet.lostReport.lastSeenPlace,
date: isoToDisplayDate(new Date(pet.lostReport.lastSeenAt).toISOString().slice(0, 10)),
})}
</Text>
<Text style={styles.lostLink}>{t("lostReport.bannerLink")}</Text>
</Pressable>
)}
{pet.deceasedAt && (
<View style={styles.memorialBanner}>
<Ribbon size={13} color={colors.ink} fill={colors.ink} />
@@ -254,6 +273,20 @@ function makeStyles(colors: ColorTokens) {
backgroundColor: colors.whisperGray,
},
memorialText: { ...typography.body, fontSize: 12, color: colors.quietGray },
lostBanner: {
alignSelf: "stretch",
alignItems: "center",
gap: 2,
padding: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.alertRedTint,
backgroundColor: colors.alertRedTint,
},
lostTitleRow: { flexDirection: "row", alignItems: "center", gap: 6 },
lostTitle: { ...typography.body, fontFamily: typography.title.fontFamily, color: "#b91c1c" },
lostText: { ...typography.body, fontSize: 13, color: colors.ink, textAlign: "center" },
lostLink: { ...typography.body, fontSize: 12, textDecorationLine: "underline", color: colors.ink },
invitedByName: { color: colors.ink, fontWeight: "600" },
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
stat: { alignItems: "center" },
+6 -3
View File
@@ -1,5 +1,5 @@
import { View, StyleSheet } from "react-native";
import { BadgeCheck, Ribbon, Sparkles, Sprout } from "lucide-react-native";
import { BadgeCheck, Ribbon, Siren, Sparkles, Sprout } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { isActiveSponsor } from "../lib/sponsor";
import { colors } from "../theme/tokens";
@@ -13,6 +13,8 @@ type Props = {
isFoundingMember?: boolean;
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
isDeceased?: boolean;
/** Aktive Vermisst-Meldung (Pet.lostSince gesetzt). */
isLost?: boolean;
sponsor?: { sponsorSince?: Date | string | null; sponsorPeriodEnd?: Date | string | null } | null;
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
referral?: ReferralOwnerFields | null;
@@ -21,15 +23,16 @@ type Props = {
/** Port of web's src/components/ui/pet-identity-badges.tsx. */
const FOUNDING_COLOR = "#059669"; // emerald-600, wie im Web
export function PetIdentityBadges({ isVerified, isFoundingMember, isDeceased, sponsor, referral }: Props) {
export function PetIdentityBadges({ isVerified, isFoundingMember, isDeceased, isLost, sponsor, referral }: Props) {
const { t } = useTranslation();
const { colors: theme } = useTheme();
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
if (!isDeceased && !isVerified && !isFoundingMember && !showSponsor && !rank) return null;
if (!isDeceased && !isLost && !isVerified && !isFoundingMember && !showSponsor && !rank) return null;
return (
<View style={styles.row}>
{isLost && <Siren size={14} color="#dc2626" accessibilityLabel={t("lostReport.badge")} />}
{isDeceased && <Ribbon size={14} color={theme.ink} fill={theme.ink} accessibilityLabel={t("inMemory.label")} />}
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
+2 -1
View File
@@ -61,6 +61,7 @@ export type PostCardPost = {
avatarKey?: string | null;
isVerified?: boolean;
deceasedAt?: Date | string | null;
lostSince?: Date | string | null;
isFoundingMember?: boolean;
// Sponsor fields only come with petCardSelect (feed/explore); a plain pet
// identity's owner (posts.byPetId, comments) carries just the referral rank.
@@ -153,7 +154,7 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
<View style={styles.headerText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
<PetIdentityBadges isDeceased={!!post.pet.deceasedAt} isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
<PetIdentityBadges isLost={!!post.pet.lostSince} isDeceased={!!post.pet.deceasedAt} isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
{isTrending && (
<View style={styles.trendingBadge}>
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
@@ -0,0 +1,292 @@
import { useMemo, useState } from "react";
import { View, Text, Pressable, Switch, Share, Linking, Alert, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Siren } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { TextField } from "../TextField";
import { DateField } from "../DateField";
import { Button } from "../Button";
import { env } from "../../lib/env";
import { displayDateToIso, isoToDisplayDate, todayAsDisplayDate } from "../../lib/date-format";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
type Props = { petId: string; petName: string; isDeceased: boolean };
type Form = {
date: string; // DD.MM.YYYY
time: string; // HH:MM
lastSeenPlace: string;
description: string;
contactPhone: string;
contactEmail: string;
allowIndexing: boolean;
};
const TIME_PATTERN = /^([01]?\d|2[0-3]):[0-5]\d$/;
const RED = "#dc2626";
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function formFromDate(date: Date): Pick<Form, "date" | "time"> {
const iso = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return { date: isoToDisplayDate(iso), time: `${pad(date.getHours())}:${pad(date.getMinutes())}` };
}
/** Datum + Uhrzeit (Ortszeit) → ISO-Zeitpunkt, oder null bei ungültiger Eingabe. */
function toIsoTimestamp(form: Form): string | null {
const iso = displayDateToIso(form.date);
if (!iso || !TIME_PATTERN.test(form.time)) return null;
const [h, m] = form.time.split(":");
const date = new Date(`${iso}T${pad(Number(h))}:${m}:00`);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
/**
* Vermisst-Meldung in „Profil bearbeiten“ — Port von web
* src/components/lost/LostReportSettings.tsx. Melden (Push an Follower und
* gefolgte Tiere), Angaben ergänzen, Link teilen, „Wieder da!“.
*/
export function LostReportSettings({ petId, petName, isDeceased }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const { data: report, isPending } = useQuery(trpc.lostReports.mine.queryOptions({ petId }));
const [isOpen, setIsOpen] = useState(false);
const [notifyFound, setNotifyFound] = useState(true);
const [form, setForm] = useState<Form | null>(null);
const [error, setError] = useState<string | null>(null);
const done = (message: string) => {
setForm(null);
setError(null);
void queryClient.invalidateQueries();
Alert.alert(message);
};
const onError = (err: { message: string }) => Alert.alert(err.message || t("lostReport.saveFailed"));
const create = useMutation(
trpc.lostReports.create.mutationOptions({
onSuccess: () => {
setIsOpen(false);
done(t("lostReport.created", { name: petName }));
},
onError,
})
);
const update = useMutation(trpc.lostReports.update.mutationOptions({ onSuccess: () => done(t("lostReport.updated")), onError }));
const markFound = useMutation(
trpc.lostReports.markFound.mutationOptions({ onSuccess: () => done(t("lostReport.foundToast", { name: petName })), onError })
);
if (isDeceased || isPending) return null;
const current: Form =
form ??
(report
? {
...formFromDate(new Date(report.lastSeenAt)),
lastSeenPlace: report.lastSeenPlace,
description: report.description ?? "",
contactPhone: report.contactPhone ?? "",
contactEmail: report.contactEmail ?? "",
allowIndexing: report.allowIndexing,
}
: {
date: todayAsDisplayDate(),
time: "",
lastSeenPlace: "",
description: "",
contactPhone: "",
contactEmail: "",
allowIndexing: false,
});
const set = (patch: Partial<Form>) => setForm({ ...current, ...patch });
const publicUrl = report ? `${env.apiUrl}/vermisst/${report.code}` : "";
function payload() {
const lastSeenAt = toIsoTimestamp(current);
if (!lastSeenAt || !current.lastSeenPlace.trim()) {
setError(t("lostReport.invalidForm"));
return null;
}
setError(null);
return {
petId,
lastSeenAt,
lastSeenPlace: current.lastSeenPlace,
description: current.description,
contactPhone: current.contactPhone,
contactEmail: current.contactEmail,
allowIndexing: current.allowIndexing,
};
}
if (!report && !isOpen) {
return (
<View style={styles.teaser}>
<Text style={styles.teaserText}>{t("lostReport.teaser", { name: petName })}</Text>
<Pressable style={styles.teaserButton} onPress={() => setIsOpen(true)} accessibilityRole="button">
<Siren size={16} color={RED} />
<Text style={styles.teaserButtonText}>{t("lostReport.open")}</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.card}>
<View style={styles.headingRow}>
<Siren size={18} color={RED} />
<Text style={styles.heading}>
{report ? t("lostReport.activeHeading", { name: petName }) : t("lostReport.formHeading", { name: petName })}
</Text>
</View>
<Text style={styles.hint}>{report ? t("lostReport.activeHint") : t("lostReport.formHint", { name: petName })}</Text>
{report && (
<>
<Text style={styles.link} onPress={() => void Linking.openURL(publicUrl)}>
{publicUrl}
</Text>
<Button
title={t("lostReport.share")}
variant="outline"
onPress={() => void Share.share({ message: `${t("lostReport.shareTitle", { name: petName })} ${publicUrl}` })}
/>
</>
)}
<Text style={styles.label}>{t("lostReport.lastSeenAt")}</Text>
<View style={styles.row}>
<View style={styles.flex}>
<DateField placeholder={t("health.weightDatePlaceholder")} value={current.date} onChangeText={(date) => set({ date })} />
</View>
<TextField
style={styles.time}
placeholder={t("lostReport.timePlaceholder")}
keyboardType="numbers-and-punctuation"
maxLength={5}
value={current.time}
onChangeText={(time) => set({ time })}
/>
</View>
<Text style={styles.label}>{t("lostReport.lastSeenPlace")}</Text>
<TextField
maxLength={120}
placeholder={t("lostReport.lastSeenPlacePlaceholder")}
value={current.lastSeenPlace}
onChangeText={(lastSeenPlace) => set({ lastSeenPlace })}
/>
<Text style={styles.label}>{t("lostReport.description")}</Text>
<TextField
multiline
maxLength={1000}
style={styles.multiline}
placeholder={t("lostReport.descriptionPlaceholder")}
value={current.description}
onChangeText={(description) => set({ description })}
/>
<Text style={styles.hint}>{t("lostReport.contactHint")}</Text>
<Text style={styles.label}>{t("lostReport.contactPhone")}</Text>
<TextField keyboardType="phone-pad" maxLength={40} value={current.contactPhone} onChangeText={(contactPhone) => set({ contactPhone })} />
<Text style={styles.label}>{t("lostReport.contactEmail")}</Text>
<TextField
keyboardType="email-address"
autoCapitalize="none"
maxLength={120}
value={current.contactEmail}
onChangeText={(contactEmail) => set({ contactEmail })}
/>
<View style={styles.switchRow}>
<View style={styles.flex}>
<Text style={styles.switchLabel}>{t("lostReport.allowIndexing")}</Text>
<Text style={styles.hint}>{t("lostReport.allowIndexingHint")}</Text>
</View>
<Switch value={current.allowIndexing} onValueChange={(allowIndexing) => set({ allowIndexing })} />
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
{report ? (
<>
<Button
title={t("lostReport.saveChanges")}
variant="outline"
disabled={update.isPending || !form}
onPress={() => {
const p = payload();
if (p) update.mutate(p);
}}
/>
<View style={styles.divider} />
<View style={styles.switchRow}>
<Text style={[styles.switchLabel, styles.flex]}>{t("lostReport.notifyFound", { name: petName })}</Text>
<Switch value={notifyFound} onValueChange={setNotifyFound} />
</View>
<Button
title={t("lostReport.markFound", { name: petName })}
disabled={markFound.isPending}
onPress={() => markFound.mutate({ petId, notifyAudience: notifyFound })}
/>
</>
) : (
<>
<Button
title={t("lostReport.submit")}
disabled={create.isPending}
onPress={() => {
const p = payload();
if (p) create.mutate(p);
}}
/>
<Button
title={t("lostReport.cancel")}
variant="outline"
onPress={() => {
setIsOpen(false);
setForm(null);
setError(null);
}}
/>
</>
)}
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
teaser: { gap: 8, padding: 12, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.hairlineGray, marginBottom: 8 },
teaserText: { ...typography.label, textTransform: "none", letterSpacing: 0 },
teaserButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
height: 44,
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.alertRedTint,
},
teaserButtonText: { ...typography.body, color: RED, fontFamily: typography.title.fontFamily },
card: { gap: 8, padding: 12, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.alertRedTint, marginBottom: 8 },
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
heading: { ...typography.title, fontSize: 16, flexShrink: 1 },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
link: { ...typography.body, textDecorationLine: "underline" },
row: { flexDirection: "row", gap: 8 },
flex: { flex: 1 },
time: { width: 88 },
multiline: { height: 88, textAlignVertical: "top", paddingTop: 12 },
switchRow: { flexDirection: "row", alignItems: "center", gap: 12, marginTop: 4 },
switchLabel: { ...typography.body },
divider: { height: 1, backgroundColor: colors.hairlineGray, marginVertical: 8 },
error: { ...typography.body, color: colors.alertRed },
});
}
@@ -62,7 +62,7 @@ export function MemorialSettings({ petId, petName, deceasedAt }: Props) {
function activate() {
const iso = validIso();
if (!iso) return;
Alert.alert(t("inMemory.confirmTitle", { name: petName }), t("inMemory.confirmDescription"), [
Alert.alert(t("inMemory.confirmTitle", { name: petName }), t("inMemory.confirmDescription", { name: petName }), [
{ text: t("inMemory.cancel"), style: "cancel" },
{ text: t("inMemory.confirm"), onPress: () => mutation.mutate({ petId, deceasedAt: iso }) },
]);
+72 -15
View File
@@ -215,7 +215,9 @@
"storyComment": "hat deine Story kommentiert.",
"storyReaction": "hat deiner Story eine Pfote gegeben.",
"unknown": "Neue Mitteilung.",
"petDeceased": "ist leider verstorben. In Erinnerung 🕯️"
"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! 🎉"
},
"pets": {
"empty": "Noch keine Tiere angelegt.",
@@ -335,7 +337,8 @@
"newStory": "Neue Story",
"newMilestone": "Neuer Meilenstein",
"deleteAccount": "Konto löschen",
"invite": "Freunde einladen"
"invite": "Freunde einladen",
"lost": "Vermisst"
},
"block": {
"menuItem": "{{petName}} blockieren",
@@ -489,22 +492,76 @@
},
"inMemory": {
"label": "In Erinnerung",
"tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen",
"banner": "In Erinnerung an {{name}} · {{date}}",
"tooltip": "In liebevoller Erinnerung",
"banner": "In liebevoller Erinnerung an {{name}} · {{date}}",
"settingsHeading": "Gedenkmodus",
"settingsHint": "Wenn dein Tier verstorben ist, kannst du sein Profil als Erinnerung erhalten. Es bekommt das Abzeichen „In Erinnerung“, Beiträge bleiben sichtbar und du kannst weiter Erinnerungen teilen. Geburtstags-Mitteilungen, Impf-Erinnerungen und Vorschläge enden.",
"activeHint": "{{name}} ist im Gedenkmodus. Du kannst das Datum korrigieren oder den Modus beenden.",
"dateLabel": "Verstorben am",
"activate": "Gedenkmodus aktivieren",
"settingsHint": "Wenn dein Tier über die Regenbogenbrücke gegangen ist, kannst du sein Profil als liebevolle Erinnerung bewahren. Alle Beiträge bleiben erhalten, und du kannst weiterhin Erinnerungen teilen. Geburtstags- und Impf-Erinnerungen pausieren dann, damit dich nichts unvermittelt trifft.",
"activeHint": "Das Profil von {{name}} wird als Erinnerung bewahrt. Du kannst das Datum jederzeit anpassen oder den Gedenkmodus wieder beenden.",
"dateLabel": "Tag des Abschieds",
"activate": "Profil als Erinnerung bewahren",
"saveDate": "Datum speichern",
"remove": "Gedenkmodus beenden",
"confirmTitle": "{{name}} in Erinnerung halten?",
"confirmDescription": "Das Profil bekommt das Abzeichen „In Erinnerung“. Wer deinem Tier folgt, erhält einmalig eine stille Mitteilung (ohne Push). Du kannst das jederzeit zurücknehmen.",
"confirm": "Ja, aktivieren",
"confirmTitle": "Das Profil von {{name}} als Erinnerung bewahren?",
"confirmDescription": "Es tut uns sehr leid. Das Profil von {{name}} bekommt eine Erinnerungsschleife, und alles, was ihr geteilt habt, bleibt erhalten. Wer {{name}} folgt, erfährt es einmal ganz in Ruhe über die Mitteilungen – ohne Push. Du kannst das jederzeit rückgängig machen.",
"confirm": "Ja, bewahren",
"cancel": "Noch nicht",
"saved": "Das Profil wird nun als Erinnerung bewahrt.",
"removed": "Gedenkmodus beendet.",
"saveFailed": "Das hat leider nicht geklappt. Bitte versuch es noch einmal.",
"invalidDate": "Bitte gib ein gültiges Datum ein, das nicht in der Zukunft liegt."
},
"lostReport": {
"badge": "Wird vermisst",
"teaser": "Ist {{name}} entlaufen? Hier kannst du sofort eine Vermisst-Meldung starten.",
"open": "Vermisst melden",
"formHeading": "{{name}} als vermisst melden",
"formHint": "Wir drücken dir fest die Daumen. Alle, die {{name}} folgen oder denen {{name}} folgt, bekommen sofort eine Push-Mitteilung, und du erhältst eine Seite zum Teilen – auch für Menschen ohne PawFeed.",
"activeHeading": "{{name}} wird vermisst",
"activeHint": "Teile den Link, so oft du kannst – zum Beispiel in WhatsApp-Gruppen aus deiner Nachbarschaft. Du kannst die Angaben jederzeit ergänzen.",
"lastSeenAt": "Zuletzt gesehen am",
"lastSeenPlace": "Wo zuletzt gesehen?",
"lastSeenPlacePlaceholder": "z. B. Stadtpark Nord, Nähe Spielplatz",
"description": "Erkennungsmerkmale & Hinweise",
"descriptionPlaceholder": "z. B. rotes Halsband, gechippt, eher scheu – bitte nicht rufen",
"contactHint": "Kontaktdaten sind freiwillig und erscheinen nur auf der Teilen-Seite dieser Meldung. Eine Nachricht über PawFeed ist immer möglich.",
"contactPhone": "Telefonnummer (optional)",
"contactEmail": "E-Mail (optional)",
"allowIndexing": "Seite darf über Suchmaschinen gefunden werden",
"allowIndexingHint": "Mehr Reichweite, aber auch Fremde finden die Seite. Ohne Haken ist sie nur über den Link erreichbar.",
"submit": "Vermisst-Meldung starten",
"cancel": "Abbrechen",
"saved": "Gedenkmodus gespeichert",
"removed": "Gedenkmodus beendet",
"saveFailed": "Konnte nicht gespeichert werden",
"invalidDate": "Bitte ein gültiges Datum eingeben, das nicht in der Zukunft liegt."
"saveChanges": "Änderungen speichern",
"notifyFound": "Allen Bescheid geben, dass {{name}} wieder da ist",
"markFound": "{{name}} ist wieder da!",
"share": "Link teilen",
"shareTitle": "Vermisst: {{name}}",
"copied": "Link kopiert",
"created": "Die Meldung ist raus. Wir drücken die Daumen, dass {{name}} schnell nach Hause findet.",
"updated": "Angaben gespeichert.",
"foundToast": "Wie schön – willkommen zurück, {{name}}! 🎉",
"saveFailed": "Das hat leider nicht geklappt. Bitte versuch es noch einmal.",
"bannerTitle": "{{name}} wird vermisst",
"bannerText": "Zuletzt gesehen: {{place}} · {{date}}",
"bannerLink": "Alle Infos & Seite teilen",
"timePlaceholder": "HH:MM",
"invalidForm": "Bitte gib Datum, Uhrzeit (z. B. 14:30) und den Ort an, wo du dein Tier zuletzt gesehen hast."
},
"lostView": {
"badge": "VERMISST",
"heading": "Hast du {{name}} gesehen?",
"lastSeenPlace": "Zuletzt gesehen",
"lastSeenAt": "Wann",
"details": "Erkennungsmerkmale & Hinweise",
"contactHeading": "Bitte melde dich, wenn du {{name}} siehst",
"message": "Nachricht schreiben",
"mailSubject": "Hinweis zu {{name}}",
"approachHint": "Bitte das Tier nicht verfolgen oder einfangen wollen – verängstigte Tiere laufen sonst oft weiter weg. Ruhig beobachten, Ort und Zeit merken und Bescheid geben.",
"photosHeading": "Weitere Fotos",
"photoAlt": "Foto von {{name}}",
"share": "Meldung teilen – jede Weitergabe hilft",
"shareText": "Vermisst: {{name}} – hast du dieses Tier gesehen?",
"toProfile": "Zum Profil",
"notActiveTitle": "Keine aktive Vermisst-Meldung",
"notActiveText": "Vielleicht ist das Tier schon wieder sicher zu Hause. 🎉"
}
}
+72 -15
View File
@@ -215,7 +215,9 @@
"storyComment": "commented on your story.",
"storyReaction": "gave your story a paw.",
"unknown": "New notification.",
"petDeceased": "has sadly passed away. In memory 🕯️"
"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! 🎉"
},
"pets": {
"empty": "No pets added yet.",
@@ -335,7 +337,8 @@
"newStory": "New story",
"newMilestone": "New milestone",
"deleteAccount": "Delete account",
"invite": "Invite friends"
"invite": "Invite friends",
"lost": "Missing"
},
"block": {
"menuItem": "Block {{petName}}",
@@ -489,22 +492,76 @@
},
"inMemory": {
"label": "In memory",
"tooltip": "In memory – this pet has crossed the rainbow bridge",
"banner": "In memory of {{name}} · {{date}}",
"tooltip": "In loving memory",
"banner": "In loving memory of {{name}} · {{date}}",
"settingsHeading": "Memorial mode",
"settingsHint": "If your pet has passed away, you can keep their profile as a memorial. It gets the “In memory” badge, posts stay visible and you can keep sharing memories. Birthday notifications, vaccine reminders and suggestions stop.",
"activeHint": "{{name}} is in memorial mode. You can correct the date or end the mode.",
"dateLabel": "Passed away on",
"activate": "Turn on memorial mode",
"settingsHint": "If your pet has crossed the rainbow bridge, you can keep their profile as a loving memorial. All posts stay, and you can keep sharing memories. Birthday and vaccine reminders pause so nothing catches you off guard.",
"activeHint": "{{name}}'s profile is kept as a memorial. You can adjust the date or end memorial mode at any time.",
"dateLabel": "Day of farewell",
"activate": "Keep profile as a memorial",
"saveDate": "Save date",
"remove": "End memorial mode",
"confirmTitle": "Keep {{name}} in memory?",
"confirmDescription": "The profile gets the “In memory” badge. Everyone following your pet receives a single quiet notification (no push). You can undo this at any time.",
"confirm": "Yes, turn on",
"cancel": "Cancel",
"saved": "Memorial mode saved",
"removed": "Memorial mode ended",
"saveFailed": "Could not be saved",
"confirmTitle": "Keep {{name}}'s profile as a memorial?",
"confirmDescription": "We're so sorry. {{name}}'s profile gets a memorial ribbon, and everything you've shared stays. Those who follow {{name}} will be told once, quietly in their notifications – no push. You can undo this at any time.",
"confirm": "Yes, keep it",
"cancel": "Not yet",
"saved": "The profile is now kept as a memorial.",
"removed": "Memorial mode ended.",
"saveFailed": "That didn't work, sorry. Please try again.",
"invalidDate": "Please enter a valid date that is not in the future."
},
"lostReport": {
"badge": "Missing",
"teaser": "Has {{name}} run away? Start a missing report right here.",
"open": "Report missing",
"formHeading": "Report {{name}} as missing",
"formHint": "We're keeping our fingers crossed. Everyone who follows {{name}} or whom {{name}} follows gets a push notification right away, and you get a page to share – even with people who aren't on PawFeed.",
"activeHeading": "{{name}} is missing",
"activeHint": "Share the link as often as you can – for example in neighbourhood WhatsApp groups. You can add details at any time.",
"lastSeenAt": "Last seen on",
"lastSeenPlace": "Where last seen?",
"lastSeenPlacePlaceholder": "e.g. North City Park, near the playground",
"description": "Distinguishing features & notes",
"descriptionPlaceholder": "e.g. red collar, microchipped, rather shy – please don't call out",
"contactHint": "Contact details are optional and only appear on this report's share page. A message via PawFeed is always possible.",
"contactPhone": "Phone number (optional)",
"contactEmail": "Email (optional)",
"allowIndexing": "Allow search engines to find this page",
"allowIndexingHint": "More reach, but strangers can find it too. Unchecked, the page is only reachable via the link.",
"submit": "Start missing report",
"cancel": "Cancel",
"saveChanges": "Save changes",
"notifyFound": "Let everyone know {{name}} is back",
"markFound": "{{name}} is back!",
"share": "Share link",
"shareTitle": "Missing: {{name}}",
"copied": "Link copied",
"created": "Your report is out. We're keeping our fingers crossed that {{name}} finds the way home soon.",
"updated": "Details saved.",
"foundToast": "How wonderful – welcome back, {{name}}! 🎉",
"saveFailed": "That didn't work, sorry. Please try again.",
"bannerTitle": "{{name}} is missing",
"bannerText": "Last seen: {{place}} · {{date}}",
"bannerLink": "All details & share page",
"timePlaceholder": "HH:MM",
"invalidForm": "Please enter the date, time (e.g. 14:30) and the place where you last saw your pet."
},
"lostView": {
"badge": "MISSING",
"heading": "Have you seen {{name}}?",
"lastSeenPlace": "Last seen",
"lastSeenAt": "When",
"details": "Distinguishing features & notes",
"contactHeading": "Please get in touch if you see {{name}}",
"message": "Send a message",
"mailSubject": "A tip about {{name}}",
"approachHint": "Please don't chase or try to catch the pet – frightened animals often run further away. Watch calmly, note the place and time and let us know.",
"photosHeading": "More photos",
"photoAlt": "Photo of {{name}}",
"share": "Share report – every share helps",
"shareText": "Missing: {{name}} – have you seen this pet?",
"toProfile": "Go to profile",
"notActiveTitle": "No active missing report",
"notActiveText": "Maybe the pet is already safely back home. 🎉"
}
}
+6 -1
View File
@@ -16,12 +16,17 @@ describe("notificationRoute", () => {
});
it("opens the actor's profile for follows, suggestions, referrals and memorials", () => {
for (const type of ["FOLLOW", "PET_SUGGESTION", "REFERRAL_JOINED", "PET_DECEASED"]) {
for (const type of ["FOLLOW", "PET_SUGGESTION", "REFERRAL_JOINED", "PET_DECEASED", "PET_FOUND"]) {
expect(notificationRoute({ type, actorPetId: "a1" }, NOW)).toBe("/pets/a1");
}
expect(notificationRoute({ type: "REFERRAL_RANK_UP" }, NOW)).toBe("/invite");
});
it("opens the in-app missing view for a missing pet", () => {
expect(notificationRoute({ type: "PET_LOST", actorPetId: "lost1" }, NOW)).toBe("/lost/lost1");
expect(notificationRoute({ type: "PET_LOST" }, NOW)).toBeNull();
});
it("opens the own pet's health page for birthdays, adoption days and vaccines", () => {
for (const type of ["BIRTHDAY", "ADOPTION_DAY", "VACCINE_DUE"]) {
expect(notificationRoute({ type, recipientPetId: "me" }, NOW)).toBe("/pets/me/health");
+3
View File
@@ -52,7 +52,10 @@ export function notificationRoute(target: NotificationTarget, now: number): stri
case "PET_SUGGESTION":
case "REFERRAL_JOINED":
case "PET_DECEASED":
case "PET_FOUND":
return actorPetId ? `/pets/${actorPetId}` : null;
case "PET_LOST":
return actorPetId ? `/lost/${actorPetId}` : null;
case "REFERRAL_RANK_UP":
return "/invite";
case "BIRTHDAY":
+5
View File
@@ -0,0 +1,5 @@
-- Vermisst-Meldung (feature/vermisst): 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."LostReport" ENABLE ROW LEVEL SECURITY;
+30 -1
View File
@@ -274,6 +274,10 @@ model Pet {
// machbar. Verstorbene Tiere bekommen keine Geburtstags-/Impf-Mitteilungen und
// werden nicht vorgeschlagen; Abzeichen + Banner in Web und App.
deceasedAt DateTime? @db.Date
// Vermisst-Meldung (Roadmap 04.10.): Zeitpunkt der aktiven Meldung, sonst null.
// Cache von LostReport (foundAt = null) für das Abzeichen in Feed/Profil —
// nur src/trpc/routers/lost-reports.ts schreibt es.
lostSince DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -330,6 +334,9 @@ model Pet {
// Story-Editor: Storys, in denen dieses Tier markiert wurde
storyMentions StoryMention[] @relation("StoryMentions")
// Vermisst-Meldungen (aktive: foundAt = null, höchstens eine)
lostReports LostReport[]
// Gitea #18 — legal AI-content disclosure. Set once at avatar upload,
// owner-immutable afterward; only a moderator can correct it (see
// admin.setAiDisclosure). Overlay-badge only, never baked into pixels.
@@ -822,6 +829,26 @@ model EmergencyVet {
updatedAt DateTime @updatedAt
}
// Vermisst-Meldung (Roadmap 04.10.). Öffentliche Teilen-Seite /vermisst/<code>
// ohne Login; Kontaktdaten und Auffindbarkeit wählt der Besitzer je Meldung.
// Nach dem Finden bleibt die Zeile (Seite zeigt „Wieder zu Hause“, ohne Kontakt).
model LostReport {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
code String @unique
lastSeenAt DateTime
lastSeenPlace String @db.VarChar(120)
description String? @db.VarChar(1000)
contactPhone String? @db.VarChar(40)
contactEmail String? @db.VarChar(120)
allowIndexing Boolean @default(false) // Suchmaschinen dürfen die Seite finden
createdAt DateTime @default(now())
foundAt DateTime?
@@index([petId, createdAt])
}
model HealthCard {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
@@ -880,6 +907,8 @@ enum NotificationType {
STORY_COMMENT // Kommentar auf eine eigene Story (storyId gesetzt)
STORY_REACTION // Pfote auf eine eigene Story (storyId gesetzt)
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)
}
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
@@ -1044,7 +1073,7 @@ model AdRepost {
model ModerationLog {
id String @id @default(cuid())
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK | UPDATE_CURATOR_SETTINGS | GRANT_REFERRAL_RANK | RESTORE_REFERRAL_RANK | DELETE_PET | SET_FOUNDING_MEMBER | CLEAR_PET_CONTENT | ENABLE_STORY_EDITOR | DISABLE_STORY_EDITOR
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK | UPDATE_CURATOR_SETTINGS | GRANT_REFERRAL_RANK | RESTORE_REFERRAL_RANK | DELETE_PET | SET_FOUNDING_MEMBER | CLEAR_PET_CONTENT | ENABLE_STORY_EDITOR | DISABLE_STORY_EDITOR | CLOSE_LOST_REPORT | DELETE_LOST_REPORT
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM | MUSIC_TRACK
targetId String
reason String? @db.VarChar(500)
+12
View File
@@ -262,6 +262,7 @@ interface MockNotificationClient {
count: MockFn;
updateMany: MockFn;
createMany: MockFn;
groupBy: MockFn;
}
interface MockWeightLogClient {
@@ -374,6 +375,7 @@ interface MockPrismaClient {
milestone: MockMilestoneClient;
story: MockStoryClient;
storyMention: { createMany: MockFn; findMany: MockFn; deleteMany: MockFn };
lostReport: { findFirst: MockFn; findUnique: MockFn; findMany: MockFn; count: MockFn; create: MockFn; update: MockFn; delete: MockFn };
storyView: MockStoryViewClient;
follow: MockFollowClient;
block: MockBlockClient;
@@ -506,6 +508,15 @@ export function createMockPrisma(): MockPrismaClient {
deleteMany: vi.fn(),
aggregate: vi.fn(),
},
lostReport: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
storyMention: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
findMany: vi.fn().mockResolvedValue([]),
@@ -644,6 +655,7 @@ export function createMockPrisma(): MockPrismaClient {
count: vi.fn(),
updateMany: vi.fn(),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
groupBy: vi.fn().mockResolvedValue([]),
},
weightLog: {
findMany: vi.fn(),
+107
View File
@@ -0,0 +1,107 @@
/**
* Admin-Seite „Vermisst“ (lost-report-admin.ts) und die Vermisst-Ansicht der
* App (lostReports.byPet → loadLostReportView).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "admin-1" }) }));
vi.mock("@/lib/notify", () => ({ sendPushForNotification: vi.fn() }));
const mockAssertAdmin = vi.fn();
vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...a: unknown[]) => mockAssertAdmin(...a) }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { lostReportAdminRouter } from "@/trpc/routers/lost-report-admin";
import { lostReportsRouter } from "@/trpc/routers/lost-reports";
type MockPrisma = ReturnType<typeof createMockPrisma>;
const ctx = (prisma: MockPrisma) => ({ userId: "admin-1", prisma: prisma as never, locale: "de", ip: "127.0.0.1" });
describe("lostReportAdmin", () => {
let prisma: MockPrisma;
beforeEach(() => {
vi.clearAllMocks();
mockAssertAdmin.mockResolvedValue("MODERATOR");
prisma = createMockPrisma();
prisma.$transaction.mockImplementation((ops: unknown[]) => Promise.all(ops));
prisma.notification.groupBy.mockResolvedValue([{ actorPetId: "pet-1", _count: { _all: 4 } }]);
});
it("lists reports with their reach and filters active ones", async () => {
prisma.lostReport.findMany.mockResolvedValue([{ id: "r1", pet: { id: "pet-1" } }, { id: "r2", pet: { id: "pet-2" } }]);
prisma.lostReport.count.mockResolvedValue(1);
const result = await lostReportAdminRouter.createCaller(ctx(prisma)).list({ status: "ACTIVE" });
expect(prisma.lostReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { foundAt: null } }));
expect(result.activeCount).toBe(1);
expect(result.reports.map((r) => r.notifiedCount)).toEqual([4, 0]);
});
it("is moderator-only", async () => {
mockAssertAdmin.mockRejectedValue(new TRPCError({ code: "FORBIDDEN" }));
await expect(lostReportAdminRouter.createCaller(ctx(prisma)).list({ status: "ALL" })).rejects.toThrow(TRPCError);
});
it("closes an active report without an all-clear, clears the badge and logs it", async () => {
prisma.lostReport.findUnique.mockResolvedValue({ id: "r1", petId: "pet-1", foundAt: null });
await lostReportAdminRouter.createCaller(ctx(prisma)).moderate({ reportId: "r1", mode: "CLOSE", reason: "vergessen" });
expect(prisma.lostReport.update).toHaveBeenCalledWith({ where: { id: "r1" }, data: { foundAt: expect.any(Date) } });
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { lostSince: null } });
expect(prisma.moderationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "CLOSE_LOST_REPORT", targetId: "pet-1", reason: "vergessen", moderatorRole: "MODERATOR" }),
});
expect(prisma.notification.createMany).not.toHaveBeenCalled();
});
it("deletes an abusive report; an already closed one leaves the pet untouched", async () => {
prisma.lostReport.findUnique.mockResolvedValue({ id: "r1", petId: "pet-1", foundAt: new Date() });
await lostReportAdminRouter.createCaller(ctx(prisma)).moderate({ reportId: "r1", mode: "DELETE" });
expect(prisma.lostReport.delete).toHaveBeenCalledWith({ where: { id: "r1" } });
expect(prisma.pet.update).not.toHaveBeenCalled();
expect(prisma.moderationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "DELETE_LOST_REPORT" }) });
});
});
describe("lostReports.byPet", () => {
it("returns the active report for the in-app view and hides nothing the owner published", async () => {
const prisma = createMockPrisma();
prisma.lostReport.findFirst.mockResolvedValue({
code: "abcdefgh",
lastSeenAt: new Date(),
lastSeenPlace: "Park",
description: null,
contactPhone: "0171 123",
contactEmail: null,
allowIndexing: false,
foundAt: null,
pet: {
id: "pet-1",
name: "Bello",
avatarKey: null,
species: { name: "Hund", translations: [] },
breed: null,
posts: [{ id: "p1", images: [{ storageKey: "posts/a.jpg" }] }, { id: "p2", images: [] }],
},
});
const view = await lostReportsRouter.createCaller(ctx(prisma)).byPet({ petId: "pet-1" });
expect(prisma.lostReport.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { petId: "pet-1", foundAt: null } }));
expect(view?.contactPhone).toBe("0171 123");
expect(view?.pet).toMatchObject({ name: "Bello", speciesName: "Hund", breedName: null });
expect(view?.photos).toEqual([{ postId: "p1", storageKey: "posts/a.jpg" }]);
});
it("is null when the pet is not missing", async () => {
const prisma = createMockPrisma();
expect(await lostReportsRouter.createCaller(ctx(prisma)).byPet({ petId: "pet-1" })).toBeNull();
});
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { TRPCError } from "@trpc/server";
import { lostReportsRouter } from "@/trpc/routers/lost-reports";
import { pickRecipientsPerOwner, notifyLostReportAudience } from "@/lib/lost-reports";
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
vi.mock("server-only", () => ({}));
vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }) }));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockPush = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/notify", () => ({ sendPushForNotification: (...args: unknown[]) => mockPush(...args) }));
const OWNER = "owner-1";
const PET = { id: "pet-1", ownerId: OWNER, name: "Bello", deceasedAt: null };
const DETAILS = {
lastSeenAt: "2026-10-04T10:00:00Z",
lastSeenPlace: "Stadtpark Nord",
description: "rotes Halsband",
contactPhone: "",
contactEmail: "",
allowIndexing: false,
};
type Mock = ReturnType<typeof createMockPrisma>;
function caller(userId: string, prisma: Mock) {
return lostReportsRouter.createCaller({
userId,
prisma: prisma as unknown as import("@prisma/client").PrismaClient,
locale: "de",
ip: "test",
});
}
describe("pickRecipientsPerOwner", () => {
it("takes followers and followed pets, one pet per owner, never the own owner", () => {
const followers = [
{ id: "f1", ownerId: "o2" },
{ id: "f2", ownerId: "o2" },
{ id: "own", ownerId: OWNER },
];
const following = [
{ id: "g1", ownerId: "o2" },
{ id: "g2", ownerId: "o3" },
];
expect(pickRecipientsPerOwner(OWNER, followers, following)).toEqual(["f1", "g2"]);
});
});
describe("notifyLostReportAudience", () => {
it("creates notifications and pushes for every recipient", async () => {
const prisma = createMockPrisma();
prisma.follow.findMany
.mockResolvedValueOnce([{ follower: { id: "f1", ownerId: "o2" } }])
.mockResolvedValueOnce([{ followee: { id: "g1", ownerId: "o3" } }]);
const count = await notifyLostReportAudience(prisma as never, PET, "PET_LOST");
expect(count).toBe(2);
expect(prisma.notification.createMany).toHaveBeenCalledWith({
data: [
{ recipientPetId: "f1", type: "PET_LOST", actorPetId: "pet-1" },
{ recipientPetId: "g1", type: "PET_LOST", actorPetId: "pet-1" },
],
});
expect(mockPush).toHaveBeenCalledTimes(2);
});
it("does nothing without an audience", async () => {
const prisma = createMockPrisma();
prisma.follow.findMany.mockResolvedValue([]);
expect(await notifyLostReportAudience(prisma as never, PET, "PET_FOUND")).toBe(0);
expect(prisma.notification.createMany).not.toHaveBeenCalled();
});
});
describe("lostReports router", () => {
let prisma: Mock;
beforeEach(() => {
vi.clearAllMocks();
prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue(PET);
prisma.follow.findMany.mockResolvedValue([]);
prisma.$transaction.mockImplementation((ops: unknown[]) => Promise.all(ops));
prisma.lostReport.create.mockResolvedValue({ id: "r1", code: "abc" });
prisma.pet.update.mockResolvedValue({});
prisma.lostReport.update.mockResolvedValue({});
});
it("creates a report, stores empty contacts as null and marks the pet as lost", async () => {
const report = await caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS });
expect(report).toEqual({ id: "r1", code: "abc" });
const data = prisma.lostReport.create.mock.calls[0][0].data;
expect(data).toMatchObject({ petId: "pet-1", lastSeenPlace: "Stadtpark Nord", contactPhone: null, contactEmail: null });
expect(data.code).toMatch(/^[A-Za-z0-9_-]{8,}$/);
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { lostSince: expect.any(Date) } });
});
it("refuses a second active report and a new one within 24 hours", async () => {
prisma.lostReport.findFirst.mockResolvedValueOnce({ createdAt: new Date(Date.now() - 1000), foundAt: null });
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS })).rejects.toThrow(/aktive/);
prisma.lostReport.findFirst.mockResolvedValueOnce({ createdAt: new Date(Date.now() - 1000), foundAt: new Date() });
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS })).rejects.toThrow(/pro Tag/);
expect(prisma.lostReport.create).not.toHaveBeenCalled();
});
it("refuses non-owners, pets in memorial mode, future times and invalid contacts", async () => {
prisma.pet.findUnique.mockResolvedValueOnce({ ...PET, ownerId: "someone-else" });
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS })).rejects.toThrow(TRPCError);
prisma.pet.findUnique.mockResolvedValueOnce({ ...PET, deceasedAt: new Date() });
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS })).rejects.toThrow(/Gedenkmodus/);
await expect(
caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS, lastSeenAt: "2999-01-01T00:00:00Z" })
).rejects.toThrow(/Zukunft/);
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS, contactEmail: "kein-mail" })).rejects.toThrow();
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS, contactPhone: "<script>" })).rejects.toThrow();
});
it("marks the pet as found and sends the all-clear only when asked", async () => {
prisma.lostReport.findFirst.mockResolvedValue({ id: "r1" });
await caller(OWNER, prisma).markFound({ petId: "pet-1", notifyAudience: false });
expect(prisma.lostReport.update).toHaveBeenCalledWith({ where: { id: "r1" }, data: { foundAt: expect.any(Date) } });
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { lostSince: null } });
expect(prisma.follow.findMany).not.toHaveBeenCalled();
await caller(OWNER, prisma).markFound({ petId: "pet-1", notifyAudience: true });
expect(prisma.follow.findMany).toHaveBeenCalled();
});
it("markFound without an active report is NOT_FOUND", async () => {
await expect(caller(OWNER, prisma).markFound({ petId: "pet-1", notifyAudience: true })).rejects.toThrow(TRPCError);
});
});
+9 -3
View File
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award, Syringe, PawPrint, Ribbon } from "lucide-react";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award, Syringe, PawPrint, Ribbon, Siren, PartyPopper } 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";
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";
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"]);
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"]);
function notificationIcon(type: NotificationType) {
switch (type) {
@@ -40,6 +40,8 @@ function notificationIcon(type: NotificationType) {
case "STORY_COMMENT": return <MessageCircle className="h-4 w-4 text-orange-500" />;
case "STORY_REACTION": return <PawPrint className="h-4 w-4 text-orange-500" />;
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" />;
}
}
@@ -108,6 +110,8 @@ export default function NotificationsPage() {
case "PET_SUGGESTION":
case "REFERRAL_JOINED":
case "PET_DECEASED":
case "PET_LOST":
case "PET_FOUND":
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
case "REFERRAL_RANK_UP":
return "/einladen";
@@ -283,6 +287,8 @@ export default function NotificationsPage() {
{notif.type === "STORY_COMMENT" && t("typeStoryComment")}
{notif.type === "STORY_REACTION" && t("typeStoryReaction")}
{notif.type === "PET_DECEASED" && t("typePetDeceased")}
{notif.type === "PET_LOST" && t("typePetLost")}
{notif.type === "PET_FOUND" && t("typePetFound")}
{notif.type === "VACCINE_DUE" && notif.vaccine?.nextDueDate &&
t("typeVaccineDue", {
name: notif.vaccine.name,
+6
View File
@@ -10,6 +10,7 @@ import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { PetForm } from "@/components/pet/PetForm";
import { MemorialSettings } from "@/components/pet/MemorialSettings";
import { LostReportSettings } from "@/components/lost/LostReportSettings";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -145,6 +146,11 @@ export default function EditPetPage() {
<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>
{/* Vermisst-Meldung ganz oben: im Ernstfall sofort auffindbar */}
<div className="mb-6">
<LostReportSettings petId={pet.id} petName={pet.name} isDeceased={pet.deceasedAt != null} />
</div>
<PetForm
mode="edit"
petId={pet.id}
+21 -2
View File
@@ -3,7 +3,7 @@ import { formatPetName } from "@/lib/pet-display-name";
import Link from "next/link";
import { currentUser } from "@clerk/nextjs/server";
import { getFormatter, getTranslations } from "next-intl/server";
import { Ribbon } from "lucide-react";
import { Ribbon, Siren } from "lucide-react";
import { createTRPCCaller } from "@/trpc/server";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
@@ -36,6 +36,7 @@ export default async function PetProfilePage({ params }: PageProps) {
const t = await getTranslations("PetProfile");
const tFounding = await getTranslations("FoundingMember");
const tMemory = await getTranslations("InMemory");
const tLost = await getTranslations("LostReport");
const format = await getFormatter();
const trpc = await createTRPCCaller();
@@ -80,7 +81,7 @@ export default async function PetProfilePage({ params }: PageProps) {
<h1 className="flex items-center gap-1.5 text-xl font-semibold text-center">
{formatPetName(pet)}
<PetIdentityBadges
isDeceased={!!pet.deceasedAt}
isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt}
isVerified={pet.isVerified}
isFoundingMember={pet.isFoundingMember}
sponsor={pet.owner}
@@ -89,6 +90,24 @@ export default async function PetProfilePage({ params }: PageProps) {
verifiedLabel={t("verified")}
/>
</h1>
{pet.lostReport && (
<Link
href={`/vermisst/${pet.lostReport.code}`}
className="flex w-full flex-col items-center gap-1 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-center text-sm hover:bg-red-100 dark:border-red-900 dark:bg-red-950/40 dark:hover:bg-red-950/60"
>
<span className="flex items-center gap-1.5 font-semibold text-red-700 dark:text-red-400">
<Siren className="h-4 w-4" aria-hidden />
{tLost("bannerTitle", { name: pet.name })}
</span>
<span className="text-muted-foreground">
{tLost("bannerText", {
place: pet.lostReport.lastSeenPlace,
date: format.dateTime(new Date(pet.lostReport.lastSeenAt), { dateStyle: "medium", timeStyle: "short", timeZone: "Europe/Berlin" }),
})}
</span>
<span className="text-xs font-medium underline">{tLost("bannerLink")}</span>
</Link>
)}
{pet.deceasedAt && (
<p className="flex items-center gap-1.5 rounded-full border border-border bg-muted px-3 py-1 text-xs text-muted-foreground">
<Ribbon className="h-3.5 w-3.5 fill-foreground text-foreground" aria-hidden />
+2
View File
@@ -24,6 +24,7 @@ import {
Gift,
Sprout,
PawPrint,
Siren,
} from "lucide-react";
interface AdminLayoutProps {
@@ -75,6 +76,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
{ href: `${base}/broadcasts`, label: "Mitteilungen", Icon: Radio },
{ href: `${base}/log`, label: "Audit Log", Icon: ScrollText },
{ href: `${base}/verification`, label: "Verification", Icon: BadgeCheck },
{ href: `${base}/lost`, label: "Vermisst", Icon: Siren },
{ href: `${base}/referrals`, label: "Empfehlungen", Icon: Gift },
{ href: `${base}/curator`, label: "Begrüßung", Icon: Sprout },
{ href: `${base}/blacklist`, label: "Blacklist", Icon: Ban },
+190
View File
@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, Loader2, Mail, MapPin, Phone, Search, Siren, Users } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { getAvatarUrl } from "@/lib/avatar-url";
type Status = "ACTIVE" | "FOUND" | "ALL";
type Mode = "CLOSE" | "DELETE";
const STATUS_LABEL: Record<Status, string> = { ACTIVE: "Aktiv", FOUND: "Wieder da", ALL: "Alle" };
const DATE_FORMAT = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short", timeZone: "Europe/Berlin" });
/**
* Admin „Vermisst“ — alle Vermisst-Meldungen. Moderatoren schließen vergessene
* Meldungen (ohne Entwarnung) oder löschen Missbrauch; beides im Audit-Log.
*/
export default function AdminLostReportsPage() {
const trpc = useTRPC();
const qc = useQueryClient();
const [status, setStatus] = useState<Status>("ACTIVE");
const [target, setTarget] = useState<{ id: string; name: string; mode: Mode } | null>(null);
const [reason, setReason] = useState("");
const { data, isLoading } = useQuery(trpc.lostReportAdmin.list.queryOptions({ status }));
const moderate = useMutation(
trpc.lostReportAdmin.moderate.mutationOptions({
onSuccess: () => {
toast.success(target?.mode === "DELETE" ? "Meldung gelöscht" : "Meldung geschlossen");
setTarget(null);
setReason("");
void qc.invalidateQueries({ queryKey: trpc.lostReportAdmin.list.queryKey() });
},
onError: (err) => toast.error(err.message),
})
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-xl font-semibold">
<Siren className="h-5 w-5 text-red-600" />
Vermisst
</h1>
<p className="text-sm text-muted-foreground">
{data ? `${data.activeCount} aktive Meldung${data.activeCount === 1 ? "" : "en"}` : "Vermisst-Meldungen der Nutzer"}
</p>
</div>
<div className="flex gap-1 rounded-lg border p-1">
{(Object.keys(STATUS_LABEL) as Status[]).map((s) => (
<Button key={s} size="sm" variant={status === s ? "default" : "ghost"} onClick={() => setStatus(s)}>
{STATUS_LABEL[s]}
</Button>
))}
</div>
</div>
{isLoading ? (
<Loader2 className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
) : !data?.reports.length ? (
<div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
<Search className="mx-auto mb-2 h-5 w-5" />
Keine Meldungen in dieser Ansicht.
</div>
) : (
<ul className="grid gap-3 md:grid-cols-2">
{data.reports.map((r) => {
const isActive = r.foundAt == null;
return (
<li
key={r.id}
className={`space-y-3 rounded-xl border p-4 ${isActive ? "border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20" : ""}`}
>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={getAvatarUrl(r.pet.avatarKey) ?? undefined} />
<AvatarFallback>{r.pet.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<Link href={`/pets/${r.pet.id}`} className="font-medium hover:underline">
{r.pet.name}
</Link>
<p className="text-xs text-muted-foreground">gemeldet {DATE_FORMAT.format(new Date(r.createdAt))}</p>
</div>
{isActive ? (
<Badge className="bg-red-600 text-white">Aktiv</Badge>
) : (
<Badge variant="secondary">Wieder da · {DATE_FORMAT.format(new Date(r.foundAt!))}</Badge>
)}
</div>
<div className="space-y-1 text-sm">
<p className="flex items-start gap-1.5">
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{r.lastSeenPlace} · {DATE_FORMAT.format(new Date(r.lastSeenAt))}
</p>
{r.description && <p className="whitespace-pre-line text-muted-foreground">{r.description}</p>}
</div>
<div className="flex flex-wrap gap-1.5 text-xs">
<Badge variant="outline" className="gap-1">
<Users className="h-3 w-3" />
{r.notifiedCount} benachrichtigt
</Badge>
{r.contactPhone && (
<Badge variant="outline" className="gap-1">
<Phone className="h-3 w-3" />
Telefon
</Badge>
)}
{r.contactEmail && (
<Badge variant="outline" className="gap-1">
<Mail className="h-3 w-3" />
E-Mail
</Badge>
)}
<Badge variant="outline">{r.allowIndexing ? "Suchmaschinen erlaubt" : "Nur per Link"}</Badge>
</div>
<div className="flex flex-wrap gap-2 border-t pt-3">
<a
href={`/vermisst/${r.code}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
Öffentliche Seite <ExternalLink className="h-3.5 w-3.5" />
</a>
<div className="ml-auto flex gap-2">
{isActive && (
<Button size="sm" variant="outline" onClick={() => setTarget({ id: r.id, name: r.pet.name, mode: "CLOSE" })}>
Schließen
</Button>
)}
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setTarget({ id: r.id, name: r.pet.name, mode: "DELETE" })}
>
Löschen
</Button>
</div>
</div>
</li>
);
})}
</ul>
)}
<Dialog open={target != null} onOpenChange={(open) => !open && setTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{target?.mode === "DELETE" ? `Meldung zu ${target?.name} löschen?` : `Meldung zu ${target?.name} schließen?`}
</DialogTitle>
<DialogDescription>
{target?.mode === "DELETE"
? "Für Missbrauch oder Fake-Meldungen. Die öffentliche Seite ist danach nicht mehr erreichbar. Es geht keine Mitteilung raus."
: "Für vergessene Meldungen. Das Abzeichen verschwindet, die öffentliche Seite zeigt „wieder zu Hause“. Es geht keine Entwarnung raus."}
</DialogDescription>
</DialogHeader>
<Textarea placeholder="Grund (optional, fürs Audit-Log)" maxLength={500} value={reason} onChange={(e) => setReason(e.target.value)} />
<DialogFooter>
<Button variant="outline" onClick={() => setTarget(null)}>
Abbrechen
</Button>
<Button
variant={target?.mode === "DELETE" ? "destructive" : "default"}
disabled={moderate.isPending}
onClick={() => target && moderate.mutate({ reportId: target.id, mode: target.mode, reason: reason || undefined })}
>
{target?.mode === "DELETE" ? "Löschen" : "Schließen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
import type { Metadata } from "next";
import { cache } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import { getFormatter, getLocale, getTranslations } from "next-intl/server";
import { Clock, Mail, MapPin, MessageCircle, Phone, Siren, Info } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAvatarUrl } from "@/lib/avatar-url";
import { getMediaUrl } from "@/lib/media-url";
import { loadLostReportView } from "@/lib/lost-reports-view";
import { buttonVariants } from "@/components/ui/button";
import { LandingHeader } from "@/components/landing/LandingHeader";
import { LandingFooter } from "@/components/landing/LandingFooter";
import { SharePageButton } from "@/components/lost/SharePageButton";
import { cn } from "@/lib/utils";
const LOOKUPS_PER_MINUTE = 30;
const CODE_PATTERN = /^[A-Za-z0-9_-]{8,24}$/;
type PageProps = { params: Promise<{ code: string }> };
/** Eine Abfrage für Metadaten und Seite (React cache dedupliziert pro Request). */
const loadReport = cache(async (code: string, locale: string) =>
CODE_PATTERN.test(code) ? loadLostReportView(prisma, { code }, locale) : null
);
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { code } = await params;
const locale = await getLocale();
const t = await getTranslations("LostPublic");
const report = await loadReport(code, locale);
if (!report) return { title: "PawFeed", robots: { index: false, follow: false } };
const isActive = report.foundAt == null;
const title = isActive ? t("metaTitle", { name: report.pet.name }) : t("foundTitle", { name: report.pet.name });
const description = isActive ? t("metaDescription", { place: report.lastSeenPlace }) : t("foundText");
const image = getAvatarUrl(report.pet.avatarKey) ?? getMediaUrl(report.photos[0]?.storageKey);
return {
title,
description,
// Suchmaschinen nur, wenn der Besitzer es erlaubt hat und das Tier noch vermisst wird.
robots: isActive && report.allowIndexing ? { index: true, follow: false } : { index: false, follow: false },
openGraph: { title, description, type: "article", images: image ? [{ url: image }] : undefined },
twitter: { card: image ? "summary_large_image" : "summary", title, description },
};
}
/**
* Öffentliche Vermisst-Seite (/vermisst/<code>) — ohne Login, zum Teilen per
* WhatsApp/Facebook. Kontaktdaten zeigt sie nur, wenn der Besitzer sie für
* diese Meldung eingetragen hat; nach „Wieder da“ verschwinden sie.
*/
export default async function LostPetPage({ params }: PageProps) {
const { code } = await params;
const locale = await getLocale();
const t = await getTranslations("LostPublic");
const format = await getFormatter();
const headerList = await headers();
const ip = headerList.get("cf-connecting-ip") ?? headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const { success } = await checkRateLimit({ key: `lost-page:${ip}`, limit: LOOKUPS_PER_MINUTE, windowSeconds: 60 });
if (!success) {
return (
<PageShell>
<p className="text-center text-sm text-muted-foreground">{t("rateLimited")}</p>
</PageShell>
);
}
const report = await loadReport(code, locale);
if (!report) notFound();
const { pet } = report;
const avatarUrl = getAvatarUrl(pet.avatarKey);
const photos = report.photos
.map((p) => ({ id: p.postId, url: getMediaUrl(p.storageKey) }))
.filter((p): p is { id: string; url: string } => p.url != null);
const { speciesName, breedName } = pet;
if (report.foundAt) {
return (
<PageShell>
<div className="space-y-4 text-center">
<HeroImage url={avatarUrl ?? photos[0]?.url} name={pet.name} />
<h1 className="text-2xl font-semibold">{t("foundTitle", { name: pet.name })}</h1>
<p className="text-muted-foreground">{t("foundText")}</p>
<Link href={`/pets/${pet.id}`} className={buttonVariants({ variant: "outline" })}>
{t("openInPawFeed", { name: pet.name })}
</Link>
</div>
</PageShell>
);
}
const lastSeen = format.dateTime(report.lastSeenAt, { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" });
return (
<PageShell>
<div className="space-y-6">
<div className="space-y-3 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full bg-red-600 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
<Siren className="h-3.5 w-3.5" aria-hidden />
{t("badge")}
</span>
<h1 className="text-3xl font-semibold leading-tight">{t("heading", { name: pet.name })}</h1>
<p className="text-sm text-muted-foreground">
{speciesName}
{breedName ? ` · ${breedName}` : ""}
</p>
</div>
<HeroImage url={avatarUrl ?? photos[0]?.url} name={pet.name} />
<dl className="space-y-3 rounded-xl border bg-card p-4">
<Fact icon={<MapPin className="h-4 w-4" />} label={t("lastSeenPlace")} value={report.lastSeenPlace} />
<Fact icon={<Clock className="h-4 w-4" />} label={t("lastSeenAt")} value={lastSeen} />
{report.description && <Fact icon={<Info className="h-4 w-4" />} label={t("details")} value={report.description} />}
</dl>
<section className="space-y-3 rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
<h2 className="font-semibold">{t("contactHeading", { name: pet.name })}</h2>
<div className="flex flex-col gap-2">
{report.contactPhone && (
<a href={`tel:${report.contactPhone.replace(/[^+0-9]/g, "")}`} className={cn(buttonVariants(), "justify-start gap-2")}>
<Phone className="h-4 w-4" />
{report.contactPhone}
</a>
)}
{report.contactEmail && (
<a
href={`mailto:${report.contactEmail}?subject=${encodeURIComponent(t("mailSubject", { name: pet.name }))}`}
className={cn(buttonVariants({ variant: "outline" }), "justify-start gap-2")}
>
<Mail className="h-4 w-4" />
{report.contactEmail}
</a>
)}
<Link href={`/pets/${pet.id}`} className={cn(buttonVariants({ variant: "outline" }), "justify-start gap-2")}>
<MessageCircle className="h-4 w-4" />
{t("messageInPawFeed")}
</Link>
</div>
<p className="text-xs text-muted-foreground">{t("approachHint")}</p>
</section>
{photos.length > 0 && (
<section className="space-y-2">
<h2 className="text-sm font-medium">{t("photosHeading")}</h2>
<div className="grid grid-cols-3 gap-1.5">
{photos.map((photo) => (
// eslint-disable-next-line @next/next/no-img-element -- öffentliche Supabase-URLs, wie im Feed
<img
key={photo.id}
src={photo.url}
alt={t("photoAlt", { name: pet.name })}
loading="lazy"
className="aspect-square w-full rounded-md object-cover"
/>
))}
</div>
</section>
)}
<SharePageButton title={t("metaTitle", { name: pet.name })} label={t("share")} copiedLabel={t("copied")} />
</div>
</PageShell>
);
}
function PageShell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col bg-background">
<LandingHeader />
<main className="mx-auto w-full max-w-md flex-1 px-4 py-8">{children}</main>
<LandingFooter />
</div>
);
}
function HeroImage({ url, name }: { url: string | null | undefined; name: string }) {
if (!url) return null;
return (
// eslint-disable-next-line @next/next/no-img-element -- öffentliche Supabase-URL, Vorschaubild der Seite
<img src={url} alt={name} className="mx-auto aspect-square w-full max-w-sm rounded-2xl object-cover shadow-md" />
);
}
function Fact({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="flex gap-3">
<span className="mt-0.5 text-muted-foreground">{icon}</span>
<div>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="whitespace-pre-line text-sm">{value}</dd>
</div>
</div>
);
}
+2 -1
View File
@@ -82,6 +82,7 @@ type PostPet = {
avatarKey: string | null;
isVerified?: boolean;
deceasedAt?: Date | string | null;
lostSince?: Date | string | null;
isFoundingMember?: boolean;
// Sponsor fields only come with petCardSelect (feed/explore); posts.byPetId
// uses petIdentitySelect, whose owner carries just the referral rank.
@@ -257,7 +258,7 @@ function PostCardImpl({ post, onDeleted, variant = "feed" }: PostCardProps) {
<div className="flex-1 min-w-0">
<p className="flex items-center gap-1 text-base font-semibold text-foreground leading-tight truncate">
{formatPetName(pet)}
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
<PetIdentityBadges isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
</p>
<p className="text-xs text-muted-foreground">
{formatRelativeTime(createdAt)}
+3 -2
View File
@@ -38,6 +38,7 @@ type Pet = {
avatarKey: string | null;
isVerified: boolean;
deceasedAt?: Date | string | null;
lostSince?: Date | string | null;
species: { name: string };
};
@@ -111,7 +112,7 @@ export function MobileNav({ pets }: MobileNavProps) {
/>
<span className="font-medium text-foreground truncate max-w-[120px] flex items-center gap-1">
{formatPetName(activePet)}
<PetIdentityBadges isDeceased={!!activePet.deceasedAt} isVerified={activePet.isVerified} size="sm" />
<PetIdentityBadges isLost={!!activePet.lostSince} isDeceased={!!activePet.deceasedAt} isVerified={activePet.isVerified} size="sm" />
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</DropdownMenuTrigger>
@@ -129,7 +130,7 @@ export function MobileNav({ pets }: MobileNavProps) {
/>
<span className="flex-1 text-sm truncate flex items-center gap-1">
{formatPetName(pet)}
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} size="sm" />
<PetIdentityBadges isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} size="sm" />
</span>
<Badge variant="secondary" className="text-xs shrink-0">
{pet.species.name}
+3 -2
View File
@@ -43,6 +43,7 @@ type Pet = {
avatarKey: string | null;
isVerified: boolean;
deceasedAt?: Date | string | null;
lostSince?: Date | string | null;
species: { name: string };
};
@@ -153,7 +154,7 @@ export function Sidebar({ pets }: SidebarProps) {
<>
<span className="text-sm font-medium text-foreground flex-1 truncate flex items-center gap-1">
{formatPetName(activePet)}
<PetIdentityBadges isDeceased={!!activePet.deceasedAt} isVerified={activePet.isVerified} />
<PetIdentityBadges isLost={!!activePet.lostSince} isDeceased={!!activePet.deceasedAt} isVerified={activePet.isVerified} />
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
</>
@@ -173,7 +174,7 @@ export function Sidebar({ pets }: SidebarProps) {
/>
<span className="flex-1 text-sm truncate flex items-center gap-1">
{formatPetName(pet)}
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} size="sm" />
<PetIdentityBadges isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} size="sm" />
</span>
<Badge variant="secondary" className="text-xs shrink-0">
{pet.species.name}
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Siren } 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";
import { Textarea } from "@/components/ui/textarea";
import { SharePageButton } from "@/components/lost/SharePageButton";
type Props = { petId: string; petName: string; isDeceased: boolean };
type Form = {
lastSeenAt: string;
lastSeenPlace: string;
description: string;
contactPhone: string;
contactEmail: string;
allowIndexing: boolean;
};
/** Date → Wert für <input type="datetime-local"> in Ortszeit. */
function toLocalInput(date: Date): string {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
/**
* Vermisst-Meldung in „Profil bearbeiten“: melden (Follower und gefolgte Tiere
* bekommen eine Push-Mitteilung), Details ändern, Link teilen, „Wieder da!“.
* Server: lostReports.*; öffentliche Seite: /vermisst/<code>.
*/
export function LostReportSettings({ petId, petName, isDeceased }: Props) {
const t = useTranslations("LostReport");
const trpc = useTRPC();
const queryClient = useQueryClient();
const router = useRouter();
const { data: report, isPending } = useQuery(trpc.lostReports.mine.queryOptions({ petId }));
const [isOpen, setIsOpen] = useState(false);
const [notifyFound, setNotifyFound] = useState(true);
const [form, setForm] = useState<Form | null>(null);
const refresh = async () => {
await queryClient.invalidateQueries();
router.refresh();
};
const onError = (err: { message: string }) => toast.error(err.message || t("saveFailed"));
const create = useMutation({
...trpc.lostReports.create.mutationOptions(),
onSuccess: async () => {
toast.success(t("created", { name: petName }));
setForm(null);
setIsOpen(false);
await refresh();
},
onError,
});
const update = useMutation({
...trpc.lostReports.update.mutationOptions(),
onSuccess: async () => {
toast.success(t("updated"));
setForm(null);
await refresh();
},
onError,
});
const markFound = useMutation({
...trpc.lostReports.markFound.mutationOptions(),
onSuccess: async () => {
toast.success(t("foundToast", { name: petName }));
await refresh();
},
onError,
});
if (isDeceased || isPending) return null;
const current: Form =
form ??
(report
? {
lastSeenAt: toLocalInput(new Date(report.lastSeenAt)),
lastSeenPlace: report.lastSeenPlace,
description: report.description ?? "",
contactPhone: report.contactPhone ?? "",
contactEmail: report.contactEmail ?? "",
allowIndexing: report.allowIndexing,
}
: { lastSeenAt: toLocalInput(new Date()), lastSeenPlace: "", description: "", contactPhone: "", contactEmail: "", allowIndexing: false });
const set = (patch: Partial<Form>) => setForm({ ...current, ...patch });
const payload = { petId, ...current, lastSeenAt: new Date(current.lastSeenAt).toISOString() };
const publicUrl = report ? `${typeof window === "undefined" ? "" : window.location.origin}/vermisst/${report.code}` : "";
if (!report && !isOpen) {
return (
<div className="flex items-center justify-between gap-3 rounded-xl border p-3">
<p className="text-sm text-muted-foreground">{t("teaser", { name: petName })}</p>
<Button variant="outline" size="sm" className="shrink-0 gap-1.5" onClick={() => setIsOpen(true)}>
<Siren className="h-4 w-4 text-red-600" />
{t("open")}
</Button>
</div>
);
}
return (
<div className="space-y-4 rounded-xl border border-red-200 bg-red-50/60 p-4 dark:border-red-900 dark:bg-red-950/30">
<h2 className="flex items-center gap-1.5 text-sm font-semibold">
<Siren className="h-4 w-4 text-red-600" aria-hidden />
{report ? t("activeHeading", { name: petName }) : t("formHeading", { name: petName })}
</h2>
<p className="text-xs text-muted-foreground">{report ? t("activeHint") : t("formHint", { name: petName })}</p>
{report && (
<div className="space-y-2">
<a href={`/vermisst/${report.code}`} target="_blank" rel="noopener noreferrer" className="block break-all text-sm font-medium underline">
{publicUrl}
</a>
<SharePageButton title={t("shareTitle", { name: petName })} label={t("share")} copiedLabel={t("copied")} url={publicUrl} />
</div>
)}
<div className="grid gap-3">
<Field id="lost-at" label={t("lastSeenAt")}>
<Input id="lost-at" type="datetime-local" value={current.lastSeenAt} onChange={(e) => set({ lastSeenAt: e.target.value })} />
</Field>
<Field id="lost-place" label={t("lastSeenPlace")}>
<Input id="lost-place" maxLength={120} placeholder={t("lastSeenPlacePlaceholder")} value={current.lastSeenPlace} onChange={(e) => set({ lastSeenPlace: e.target.value })} />
</Field>
<Field id="lost-desc" label={t("description")}>
<Textarea id="lost-desc" maxLength={1000} placeholder={t("descriptionPlaceholder")} value={current.description} onChange={(e) => set({ description: e.target.value })} />
</Field>
<p className="text-xs text-muted-foreground">{t("contactHint")}</p>
<Field id="lost-phone" label={t("contactPhone")}>
<Input id="lost-phone" type="tel" maxLength={40} value={current.contactPhone} onChange={(e) => set({ contactPhone: e.target.value })} />
</Field>
<Field id="lost-mail" label={t("contactEmail")}>
<Input id="lost-mail" type="email" maxLength={120} value={current.contactEmail} onChange={(e) => set({ contactEmail: e.target.value })} />
</Field>
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" className="mt-1" checked={current.allowIndexing} onChange={(e) => set({ allowIndexing: e.target.checked })} />
<span>
{t("allowIndexing")}
<span className="block text-xs text-muted-foreground">{t("allowIndexingHint")}</span>
</span>
</label>
</div>
{report ? (
<div className="space-y-3">
<Button variant="outline" size="sm" disabled={update.isPending || !form || !current.lastSeenPlace.trim()} onClick={() => update.mutate(payload)}>
{t("saveChanges")}
</Button>
<div className="space-y-2 border-t pt-3">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={notifyFound} onChange={(e) => setNotifyFound(e.target.checked)} />
{t("notifyFound", { name: petName })}
</label>
<Button disabled={markFound.isPending} onClick={() => markFound.mutate({ petId, notifyAudience: notifyFound })}>
{t("markFound", { name: petName })}
</Button>
</div>
</div>
) : (
<div className="flex gap-2">
<Button disabled={create.isPending || !current.lastSeenPlace.trim()} onClick={() => create.mutate(payload)}>
{t("submit")}
</Button>
<Button variant="ghost" onClick={() => { setIsOpen(false); setForm(null); }}>
{t("cancel")}
</Button>
</div>
)}
</div>
);
}
function Field({ id, label, children }: { id: string; label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<Label htmlFor={id} className="text-xs">
{label}
</Label>
{children}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
type Props = { title: string; label: string; copiedLabel: string; url?: string };
/** Teilen per System-Dialog (Handy) oder Link kopieren (Desktop). Ohne url: die aktuelle Seite. */
export function SharePageButton({ title, label, copiedLabel, url }: Props) {
const [copied, setCopied] = useState(false);
async function share() {
const link = url ?? window.location.href;
if (navigator.share) {
try {
await navigator.share({ title, url: link });
return;
} catch (err) {
// abgebrochen: nichts tun; nicht erlaubt: auf Kopieren ausweichen
if (err instanceof DOMException && err.name === "AbortError") return;
}
}
await navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Button variant="outline" className="w-full gap-2" onClick={share}>
<Share2 className="h-4 w-4" />
{copied ? copiedLabel : label}
</Button>
);
}
+1 -1
View File
@@ -110,7 +110,7 @@ export function MemorialSettings({ petId, petName, deceasedAt }: Props) {
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-base font-semibold">{t("confirmTitle", { name: petName })}</DialogTitle>
<DialogDescription>{t("confirmDescription")}</DialogDescription>
<DialogDescription>{t("confirmDescription", { name: petName })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="outline" onClick={() => setConfirmOpen(false)} />}>
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { Siren } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
const ICON_SIZE = { sm: "h-3 w-3", md: "h-3.5 w-3.5", lg: "h-4 w-4" } as const;
/** „Wird vermisst“ — aktive Vermisst-Meldung (Pet.lostSince). */
export function LostBadge({ size = "md" }: { size?: keyof typeof ICON_SIZE }) {
const t = useTranslations("LostReport");
return (
<span title={t("badge")} className="inline-flex shrink-0">
<Siren className={cn(ICON_SIZE[size], "text-red-600 dark:text-red-500")} aria-label={t("badge")} />
</span>
);
}
+6 -1
View File
@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
import { ReferralRankBadge, type ReferralOwnerFields } from "@/components/referrals/ReferralRankBadge";
import { FoundingMemberBadge } from "@/components/ui/founding-member-badge";
import { InMemoryBadge } from "@/components/ui/in-memory-badge";
import { LostBadge } from "@/components/ui/lost-badge";
type SponsorWindow = {
// Optional: a pet identity's owner (petIdentitySelect) carries only the referral rank.
@@ -17,6 +18,8 @@ type Props = {
isFoundingMember?: boolean;
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
isDeceased?: boolean;
/** Aktive Vermisst-Meldung (Pet.lostSince gesetzt). */
isLost?: boolean;
/** Omit entirely (rather than passing null) when the caller has no owner/sponsor data to show. */
sponsor?: SponsorWindow | null;
/** Owner-Felder referralRankId/referralPerks (petCardSelect.owner) — Rang-Abzeichen des Empfehlungsprogramms. */
@@ -48,6 +51,7 @@ export function PetIdentityBadges({
isVerified,
isFoundingMember,
isDeceased,
isLost,
sponsor,
referral,
size = "md",
@@ -57,10 +61,11 @@ export function PetIdentityBadges({
const iconClass = ICON_SIZE[size];
const showSponsor = sponsor != null && isSponsorActive(sponsor);
if (!isDeceased && !isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null;
if (!isDeceased && !isLost && !isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null;
return (
<>
{isLost && <LostBadge size={size} />}
{isDeceased && <InMemoryBadge size={size} />}
{isVerified && (
<BadgeCheck
+69
View File
@@ -0,0 +1,69 @@
import "server-only";
import type { Prisma, PrismaClient } from "@prisma/client";
import { pickLocalizedName } from "@/lib/localized-name";
/** Wie viele Fotos aus dem Feed die Vermisst-Ansicht zeigt. */
export const LOST_VIEW_PHOTO_COUNT = 6;
/**
* Was Finder sehen dürfen — öffentliche Seite /vermisst/<code> und der
* Vermisst-Bildschirm der App teilen sich diese Abfrage. Kontaktdaten sind die,
* die der Besitzer für genau diese Meldung freigegeben hat.
*/
export async function loadLostReportView(
prisma: Pick<PrismaClient, "lostReport">,
where: Prisma.LostReportWhereInput,
locale: string
) {
const report = await prisma.lostReport.findFirst({
where,
orderBy: { createdAt: "desc" },
select: {
code: true,
lastSeenAt: true,
lastSeenPlace: true,
description: true,
contactPhone: true,
contactEmail: true,
allowIndexing: true,
foundAt: true,
pet: {
select: {
id: true,
name: true,
avatarKey: true,
species: { include: { translations: { where: { locale } } } },
breed: { include: { translations: { where: { locale } } } },
posts: {
where: { hiddenAt: null, images: { some: {} } },
orderBy: { createdAt: "desc" },
take: LOST_VIEW_PHOTO_COUNT,
select: { id: true, images: { orderBy: { position: "asc" }, take: 1, select: { storageKey: true } } },
},
},
},
},
});
if (!report) return null;
const { pet, ...rest } = report;
const isFound = rest.foundAt != null;
return {
...rest,
// Nach dem Finden keine Kontaktdaten mehr zeigen.
contactPhone: isFound ? null : rest.contactPhone,
contactEmail: isFound ? null : rest.contactEmail,
pet: {
id: pet.id,
name: pet.name,
avatarKey: pet.avatarKey,
speciesName: pickLocalizedName(pet.species.name, pet.species.translations, locale),
breedName: pet.breed ? pickLocalizedName(pet.breed.name, pet.breed.translations, locale) : null,
},
photos: pet.posts
.map((p) => ({ postId: p.id, storageKey: p.images[0]?.storageKey }))
.filter((p): p is { postId: string; storageKey: string } => Boolean(p.storageKey)),
};
}
export type LostReportView = NonNullable<Awaited<ReturnType<typeof loadLostReportView>>>;
+68
View File
@@ -0,0 +1,68 @@
import "server-only";
import { randomBytes } from "node:crypto";
import type { PrismaClient } from "@prisma/client";
import { sendPushForNotification } from "@/lib/notify";
/** Wie viele Pushes gleichzeitig rausgehen (Expo-Requests nacheinander in kleinen Stapeln). */
const PUSH_BATCH_SIZE = 10;
/** Kurzer, nicht erratbarer Code für den öffentlichen Link /vermisst/<code>. */
export function generateLostReportCode(): string {
return randomBytes(8).toString("base64url");
}
type PetRef = { id: string; ownerId: string };
/**
* Wer erfährt von der Vermisst-Meldung? Follower UND die Tiere, denen das
* vermisste Tier folgt (Entscheidung 04.10.) — je Besitzer nur ein Tier, eigene
* Tiere des Besitzers nie. Follower haben Vorrang (sie kennen das Tier sicher).
*/
export function pickRecipientsPerOwner(ownerId: string, followers: PetRef[], following: PetRef[]): string[] {
const byOwner = new Map<string, string>();
for (const pet of [...followers, ...following]) {
if (pet.ownerId === ownerId || byOwner.has(pet.ownerId)) continue;
byOwner.set(pet.ownerId, pet.id);
}
return [...byOwner.values()];
}
/**
* Mitteilung + Push an das Umfeld des Tiers (PET_LOST bei der Meldung,
* PET_FOUND als Entwarnung). Läuft im Hintergrund — darf die Meldung nie
* blockieren oder scheitern lassen. Gibt die Zahl der Empfänger zurück.
*/
export async function notifyLostReportAudience(
prisma: Pick<PrismaClient, "follow" | "notification" | "pet" | "owner">,
pet: PetRef,
type: "PET_LOST" | "PET_FOUND"
): Promise<number> {
const [followers, following] = await Promise.all([
prisma.follow.findMany({
where: { followeePetId: pet.id },
select: { follower: { select: { id: true, ownerId: true } } },
}),
prisma.follow.findMany({
where: { followerPetId: pet.id },
select: { followee: { select: { id: true, ownerId: true } } },
}),
]);
const recipients = pickRecipientsPerOwner(
pet.ownerId,
followers.map((f) => f.follower),
following.map((f) => f.followee)
);
if (recipients.length === 0) return 0;
await prisma.notification.createMany({
data: recipients.map((recipientPetId) => ({ recipientPetId, type, actorPetId: pet.id })),
});
for (let i = 0; i < recipients.length; i += PUSH_BATCH_SIZE) {
await Promise.allSettled(
recipients
.slice(i, i + PUSH_BATCH_SIZE)
.map((recipientPetId) => sendPushForNotification(prisma, { recipientPetId, type, actorPetId: pet.id }))
);
}
return recipients.length;
}
+11 -1
View File
@@ -53,7 +53,17 @@ const PUSH_COPY: Record<
VACCINE_DUE: { title: "Impfung bald fällig", genericBody: "Eine Impfung wird bald fällig.", actorBody: () => "Eine Impfung wird bald fällig." },
STORY_MENTION: { title: "In einer Story markiert", genericBody: "Du wurdest in einer Story markiert.", actorBody: (a) => `${a} hat dich in einer Story markiert.` },
STORY_COMMENT: { title: "Neuer Story-Kommentar", genericBody: "Jemand hat deine Story kommentiert.", actorBody: (a) => `${a} hat deine Story kommentiert.` },
PET_DECEASED: { title: "In Erinnerung", genericBody: "Ein Tier, dem du folgst, ist verstorben.", actorBody: (a) => `${a} ist leider verstorben.` },
PET_DECEASED: { title: "In liebevoller Erinnerung", genericBody: "Ein Tier, dem du folgst, ist über die Regenbogenbrücke gegangen.", actorBody: (a) => `${a} ist über die Regenbogenbrücke gegangen.` },
PET_LOST: {
title: "Bitte halte die Augen offen 🐾",
genericBody: "Ein Tier, das du kennst, wird vermisst. Vielleicht kannst du helfen.",
actorBody: (a) => `${a} wird vermisst. Vielleicht kannst du helfen – tippe für alle Infos.`,
},
PET_FOUND: {
title: "Gute Nachrichten 🎉",
genericBody: "Ein vermisstes Tier ist wieder sicher zu Hause.",
actorBody: (a) => `${a} ist wieder sicher zu Hause. Danke, dass du mitgeholfen hast!`,
},
STORY_REACTION: { title: "Neue Pfote", genericBody: "Jemand hat deiner Story eine Pfote gegeben.", actorBody: (a) => `${a} hat deiner Story eine Pfote gegeben.` },
};
+1
View File
@@ -29,6 +29,7 @@ const isPublicRoute = createRouteMatcher([
"/kinderschutz",
"/join(.*)",
"/r/(.*)", // öffentliche Einladungsseite des Empfehlungsprogramms
"/vermisst/(.*)", // öffentliche Vermisst-Seite zum Teilen (src/app/vermisst/[code])
"/app", // Download-Weiterleitung für Flyer/QR-Codes (src/app/app/route.ts)
"/api/webhooks/(.*)",
"/api/auth/(.*)",
+2
View File
@@ -24,6 +24,8 @@ export const petIdentitySelect = {
avatarKey: true,
// „In Erinnerung“-Abzeichen überall neben dem Namen
deceasedAt: true,
// „Vermisst“-Abzeichen (aktive Vermisst-Meldung)
lostSince: true,
owner: { select: referralOwnerSelect },
} satisfies Prisma.PetSelect;
+4
View File
@@ -28,6 +28,8 @@ import { insightsRouter } from "@/trpc/routers/insights";
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 { lostReportAdminRouter } from "@/trpc/routers/lost-report-admin";
import { petAdminRouter } from "@/trpc/routers/pet-admin";
export const appRouter = router({
@@ -61,6 +63,8 @@ export const appRouter = router({
referrals: referralsRouter,
referralAdmin: referralAdminRouter,
curatorAdmin: curatorAdminRouter,
lostReports: lostReportsRouter,
lostReportAdmin: lostReportAdminRouter,
petAdmin: petAdminRouter,
});
+97
View File
@@ -0,0 +1,97 @@
import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { AdminRoleType, type Prisma } from "@prisma/client";
import { router, protectedProcedure } from "@/trpc/init";
import { assertAdmin } from "@/trpc/routers/admin";
const PAGE_SIZE = 50;
/**
* Admin-Seite „Vermisst“ (/p/[secret]/lost): alle Vermisst-Meldungen mit Status,
* Reichweite und Moderation. Schließen (ohne Entwarnung) oder bei Missbrauch
* löschen — beides ab MODERATOR, beides im Audit-Log.
*/
export const lostReportAdminRouter = router({
list: protectedProcedure
.input(z.object({ status: z.enum(["ACTIVE", "FOUND", "ALL"]) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const where: Prisma.LostReportWhereInput =
input.status === "ACTIVE" ? { foundAt: null } : input.status === "FOUND" ? { foundAt: { not: null } } : {};
const [reports, activeCount] = await Promise.all([
ctx.prisma.lostReport.findMany({
where,
orderBy: { createdAt: "desc" },
take: PAGE_SIZE,
select: {
id: true,
code: true,
lastSeenAt: true,
lastSeenPlace: true,
description: true,
contactPhone: true,
contactEmail: true,
allowIndexing: true,
createdAt: true,
foundAt: true,
pet: { select: { id: true, name: true, avatarKey: true, ownerId: true } },
},
}),
ctx.prisma.lostReport.count({ where: { foundAt: null } }),
]);
// Reichweite: wie viele Tiere eine PET_LOST-Mitteilung zu diesem Tier bekommen haben.
const petIds = [...new Set(reports.map((r) => r.pet.id))];
const audience = petIds.length
? await ctx.prisma.notification.groupBy({
by: ["actorPetId"],
where: { type: "PET_LOST", actorPetId: { in: petIds } },
_count: { _all: true },
})
: [];
const audienceByPet = new Map(audience.map((a) => [a.actorPetId, a._count._all]));
return {
activeCount,
reports: reports.map((r) => ({ ...r, notifiedCount: audienceByPet.get(r.pet.id) ?? 0 })),
};
}),
/**
* CLOSE: als erledigt schließen (z. B. Besitzer hat vergessen, „Wieder da“ zu
* drücken) — keine Entwarnung. DELETE: Missbrauch/Fake — Meldung entfernen,
* die öffentliche Seite ist danach nicht mehr erreichbar.
*/
moderate: protectedProcedure
.input(z.object({ reportId: z.string().min(1), mode: z.enum(["CLOSE", "DELETE"]), reason: z.string().trim().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const report = await ctx.prisma.lostReport.findUnique({
where: { id: input.reportId },
select: { id: true, petId: true, foundAt: true },
});
if (!report) throw new TRPCError({ code: "NOT_FOUND" });
await ctx.prisma.$transaction([
input.mode === "DELETE"
? ctx.prisma.lostReport.delete({ where: { id: report.id } })
: ctx.prisma.lostReport.update({ where: { id: report.id }, data: { foundAt: report.foundAt ?? new Date() } }),
// Abzeichen nur entfernen, wenn es diese Meldung war (aktive Meldung).
...(report.foundAt ? [] : [ctx.prisma.pet.update({ where: { id: report.petId }, data: { lostSince: null } })]),
ctx.prisma.moderationLog.create({
data: {
action: input.mode === "DELETE" ? "DELETE_LOST_REPORT" : "CLOSE_LOST_REPORT",
targetType: "PET",
targetId: report.petId,
reason: input.reason || null,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return { success: true };
}),
});
+151
View File
@@ -0,0 +1,151 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { generateLostReportCode, notifyLostReportAudience } from "@/lib/lost-reports";
import { loadLostReportView } from "@/lib/lost-reports-view";
/** Höchstens eine neue Meldung pro Tier in diesem Zeitraum (gegen Fehlbedienung/Spam). */
const REPORT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
/** Kleine Toleranz für abweichende Geräte-Uhren bei „zuletzt gesehen“. */
const CLOCK_SKEW_MS = 10 * 60 * 1000;
const detailsInput = z.object({
lastSeenAt: z.coerce.date(),
lastSeenPlace: z.string().trim().min(1).max(120),
description: z.string().trim().max(1000).optional().nullable(),
contactPhone: z
.string()
.trim()
.max(40)
.regex(/^[+0-9 ()/-]*$/)
.optional()
.nullable(),
contactEmail: z.string().trim().max(120).email().optional().nullable().or(z.literal("")),
allowIndexing: z.boolean(),
});
/** Leere Eingaben als „nicht angegeben“ speichern. */
function normalizeDetails(input: z.infer<typeof detailsInput>) {
if (input.lastSeenAt.getTime() > Date.now() + CLOCK_SKEW_MS) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Der Zeitpunkt liegt in der Zukunft." });
}
return {
lastSeenAt: input.lastSeenAt,
lastSeenPlace: input.lastSeenPlace,
description: input.description || null,
contactPhone: input.contactPhone || null,
contactEmail: input.contactEmail || null,
allowIndexing: input.allowIndexing,
};
}
const ownerReportSelect = {
id: true,
code: true,
lastSeenAt: true,
lastSeenPlace: true,
description: true,
contactPhone: true,
contactEmail: true,
allowIndexing: true,
createdAt: true,
} as const;
/**
* Vermisst-Meldung (Roadmap 04.10.). Nur der Besitzer meldet, bearbeitet und
* gibt Entwarnung; Pet.lostSince ist der Cache für das Abzeichen. Die öffentliche
* Seite /vermisst/<code> liest direkt über Prisma (src/app/vermisst/[code]).
*/
export const lostReportsRouter = router({
/**
* Vermisst-Ansicht in der App (Banner, Push-Tipp): die aktive Meldung eines
* Tiers mit denselben Angaben wie die öffentliche Seite — null, wenn keine.
*/
byPet: protectedProcedure.input(z.object({ petId: z.string().min(1) })).query(({ ctx, input }) =>
loadLostReportView(ctx.prisma, { petId: input.petId, foundAt: null }, ctx.locale)
),
/** Aktive Meldung mit allen Feldern — nur für den Besitzer (Verwaltung). */
mine: protectedProcedure.input(z.object({ petId: z.string().min(1) })).query(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
return ctx.prisma.lostReport.findFirst({
where: { petId: input.petId, foundAt: null },
select: ownerReportSelect,
});
}),
create: protectedProcedure
.input(detailsInput.extend({ petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const pet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
if (pet.deceasedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Das Tier ist im Gedenkmodus." });
const latest = await ctx.prisma.lostReport.findFirst({
where: { petId: input.petId },
orderBy: { createdAt: "desc" },
select: { createdAt: true, foundAt: true },
});
if (latest && !latest.foundAt) {
throw new TRPCError({ code: "CONFLICT", message: "Es gibt schon eine aktive Vermisst-Meldung." });
}
if (latest && Date.now() - latest.createdAt.getTime() < REPORT_COOLDOWN_MS) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Höchstens eine Vermisst-Meldung pro Tag." });
}
const now = new Date();
const [report] = await ctx.prisma.$transaction([
ctx.prisma.lostReport.create({
data: { petId: input.petId, code: generateLostReportCode(), ...normalizeDetails(input) },
select: ownerReportSelect,
}),
ctx.prisma.pet.update({ where: { id: input.petId }, data: { lostSince: now } }),
]);
notifyLostReportAudience(ctx.prisma, pet, "PET_LOST").catch((err) =>
console.error("[lostReports.create] notify failed", err)
);
return report;
}),
/** Details der aktiven Meldung ändern (z. B. Telefonnummer nachtragen) — ohne neue Mitteilung. */
update: protectedProcedure
.input(detailsInput.extend({ petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const active = await ctx.prisma.lostReport.findFirst({
where: { petId: input.petId, foundAt: null },
select: { id: true },
});
if (!active) throw new TRPCError({ code: "NOT_FOUND" });
return ctx.prisma.lostReport.update({
where: { id: active.id },
data: normalizeDetails(input),
select: ownerReportSelect,
});
}),
/** „Wieder da!“ — Meldung schließen, optional Entwarnung an dieselben Empfänger. */
markFound: protectedProcedure
.input(z.object({ petId: z.string().min(1), notifyAudience: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const pet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const active = await ctx.prisma.lostReport.findFirst({
where: { petId: input.petId, foundAt: null },
select: { id: true },
});
if (!active) throw new TRPCError({ code: "NOT_FOUND" });
await ctx.prisma.$transaction([
ctx.prisma.lostReport.update({ where: { id: active.id }, data: { foundAt: new Date() } }),
ctx.prisma.pet.update({ where: { id: input.petId }, data: { lostSince: null } }),
]);
if (input.notifyAudience) {
notifyLostReportAudience(ctx.prisma, pet, "PET_FOUND").catch((err) =>
console.error("[lostReports.markFound] notify failed", err)
);
}
return { ok: true };
}),
});
+14 -6
View File
@@ -123,12 +123,19 @@ export const petsRouter = router({
species: { include: { translations: { where: { locale: ctx.locale } } } },
breed: { include: { translations: { where: { locale: ctx.locale } } } },
owner: { select: { ...petCardSelect.owner.select, invitedById: true } },
// Vermisst-Banner: nur öffentliche Felder (Kontaktdaten stehen auf /vermisst/<code>)
lostReports: {
where: { foundAt: null },
take: 1,
select: { code: true, lastSeenAt: true, lastSeenPlace: true },
},
},
});
if (!pet) throw new TRPCError({ code: "NOT_FOUND" });
// Empfehlungsprogramm: "Eingeladen von <Tier>" — nur das Tier verlassen den Server, nie die Clerk-ID.
const { invitedById, ...owner } = pet.owner;
const { lostReports, ...petFields } = pet;
const invitedByPet = invitedById
? await ctx.prisma.pet.findFirst({
where: { ownerId: invitedById },
@@ -138,9 +145,10 @@ export const petsRouter = router({
: null;
return {
...pet,
...petFields,
owner,
invitedByPet,
lostReport: lostReports[0] ?? null,
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) }
@@ -148,11 +156,6 @@ export const petsRouter = router({
};
}),
/**
* Update a pet's details.
* Asserts ownership before any DB write (T-3-01).
* T-3-02: Zod enforces character limits server-side.
*/
/**
* Gedenkmodus „In Erinnerung“: Besitzer markiert sein Tier als verstorben
* (Datum) oder nimmt das zurück (null). Beim ersten Setzen bekommen die
@@ -189,6 +192,11 @@ export const petsRouter = router({
return { deceasedAt };
}),
/**
* Update a pet's details.
* Asserts ownership before any DB write (T-3-01).
* T-3-02: Zod enforces character limits server-side.
*/
update: protectedProcedure
.input(
z.object({