Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fc7503c08 |
No files matched your search
@@ -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") }} />
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, Linking, StyleSheet } from "react-native";
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, StyleSheet } from "react-native";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
@@ -18,7 +18,6 @@ import { useBlockPet } from "../../../../src/hooks/useBlockPet";
|
||||
import { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds";
|
||||
import { formatPetName } from "../../../../src/lib/format";
|
||||
import { isoToDisplayDate } from "../../../../src/lib/date-format";
|
||||
import { env } from "../../../../src/lib/env";
|
||||
import { getAvatarUrl } from "../../../../src/lib/media-url";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||
@@ -153,7 +152,7 @@ export default function PetProfileScreen() {
|
||||
<Pressable
|
||||
style={styles.lostBanner}
|
||||
accessibilityRole="link"
|
||||
onPress={() => void Linking.openURL(`${env.apiUrl}/vermisst/${pet.lostReport?.code}`)}
|
||||
onPress={() => router.push(`/lost/${pet.id}`)}
|
||||
>
|
||||
<View style={styles.lostTitleRow}>
|
||||
<Siren size={15} color="#dc2626" />
|
||||
|
||||
@@ -337,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",
|
||||
@@ -544,5 +545,23 @@
|
||||
"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. 🎉"
|
||||
}
|
||||
}
|
||||
@@ -337,7 +337,8 @@
|
||||
"newStory": "New story",
|
||||
"newMilestone": "New milestone",
|
||||
"deleteAccount": "Delete account",
|
||||
"invite": "Invite friends"
|
||||
"invite": "Invite friends",
|
||||
"lost": "Missing"
|
||||
},
|
||||
"block": {
|
||||
"menuItem": "Block {{petName}}",
|
||||
@@ -544,5 +545,23 @@
|
||||
"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. 🎉"
|
||||
}
|
||||
}
|
||||
@@ -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", "PET_LOST", "PET_FOUND"]) {
|
||||
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");
|
||||
|
||||
@@ -52,9 +52,10 @@ export function notificationRoute(target: NotificationTarget, now: number): stri
|
||||
case "PET_SUGGESTION":
|
||||
case "REFERRAL_JOINED":
|
||||
case "PET_DECEASED":
|
||||
case "PET_LOST":
|
||||
case "PET_FOUND":
|
||||
return actorPetId ? `/pets/${actorPetId}` : null;
|
||||
case "PET_LOST":
|
||||
return actorPetId ? `/lost/${actorPetId}` : null;
|
||||
case "REFERRAL_RANK_UP":
|
||||
return "/invite";
|
||||
case "BIRTHDAY":
|
||||
|
||||
@@ -1073,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)
|
||||
|
||||
@@ -262,6 +262,7 @@ interface MockNotificationClient {
|
||||
count: MockFn;
|
||||
updateMany: MockFn;
|
||||
createMany: MockFn;
|
||||
groupBy: MockFn;
|
||||
}
|
||||
|
||||
interface MockWeightLogClient {
|
||||
@@ -374,7 +375,7 @@ interface MockPrismaClient {
|
||||
milestone: MockMilestoneClient;
|
||||
story: MockStoryClient;
|
||||
storyMention: { createMany: MockFn; findMany: MockFn; deleteMany: MockFn };
|
||||
lostReport: { findFirst: MockFn; findUnique: MockFn; create: MockFn; update: MockFn };
|
||||
lostReport: { findFirst: MockFn; findUnique: MockFn; findMany: MockFn; count: MockFn; create: MockFn; update: MockFn; delete: MockFn };
|
||||
storyView: MockStoryViewClient;
|
||||
follow: MockFollowClient;
|
||||
block: MockBlockClient;
|
||||
@@ -510,8 +511,11 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
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 }),
|
||||
@@ -651,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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { pickLocalizedName } from "@/lib/localized-name";
|
||||
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";
|
||||
@@ -17,35 +17,14 @@ import { SharePageButton } from "@/components/lost/SharePageButton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LOOKUPS_PER_MINUTE = 30;
|
||||
const PHOTO_COUNT = 6;
|
||||
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) => {
|
||||
if (!CODE_PATTERN.test(code)) return null;
|
||||
return prisma.lostReport.findUnique({
|
||||
where: { code },
|
||||
include: {
|
||||
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: PHOTO_COUNT,
|
||||
select: { id: true, images: { orderBy: { position: "asc" }, take: 1, select: { storageKey: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
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;
|
||||
@@ -57,7 +36,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
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.pet.posts[0]?.images[0]?.storageKey);
|
||||
const image = getAvatarUrl(report.pet.avatarKey) ?? getMediaUrl(report.photos[0]?.storageKey);
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
@@ -95,11 +74,10 @@ export default async function LostPetPage({ params }: PageProps) {
|
||||
|
||||
const { pet } = report;
|
||||
const avatarUrl = getAvatarUrl(pet.avatarKey);
|
||||
const photos = pet.posts
|
||||
.map((p) => ({ id: p.id, url: getMediaUrl(p.images[0]?.storageKey) }))
|
||||
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 = pickLocalizedName(pet.species.name, pet.species.translations, locale);
|
||||
const breedName = pet.breed ? pickLocalizedName(pet.breed.name, pet.breed.translations, locale) : null;
|
||||
const { speciesName, breedName } = pet;
|
||||
|
||||
if (report.foundAt) {
|
||||
return (
|
||||
|
||||
@@ -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>>>;
|
||||
@@ -29,6 +29,7 @@ import { referralsRouter } from "@/trpc/routers/referrals";
|
||||
import { referralAdminRouter } from "@/trpc/routers/referral-admin";
|
||||
import { curatorAdminRouter } from "@/trpc/routers/curator-admin";
|
||||
import { lostReportsRouter } from "@/trpc/routers/lost-reports";
|
||||
import { lostReportAdminRouter } from "@/trpc/routers/lost-report-admin";
|
||||
import { petAdminRouter } from "@/trpc/routers/pet-admin";
|
||||
|
||||
export const appRouter = router({
|
||||
@@ -63,6 +64,7 @@ export const appRouter = router({
|
||||
referralAdmin: referralAdminRouter,
|
||||
curatorAdmin: curatorAdminRouter,
|
||||
lostReports: lostReportsRouter,
|
||||
lostReportAdmin: lostReportAdminRouter,
|
||||
petAdmin: petAdminRouter,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -57,6 +58,14 @@ const ownerReportSelect = {
|
||||
* 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);
|
||||
|
||||
Reference in new issue
Block a user