, 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 (
+
+
+
+ );
+ }
+
+ if (!report) {
+ return (
+
+ {t("lostView.notActiveTitle")}
+ {t("lostView.notActiveText")}
+
+ );
+ }
+
+ 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 (
+
+
+
+
+ {t("lostView.badge")}
+
+ {t("lostView.heading", { name: pet.name })}
+
+ {pet.speciesName}
+ {pet.breedName ? ` · ${pet.breedName}` : ""}
+
+
+
+ {heroUrl ? : null}
+
+
+ } label={t("lostView.lastSeenPlace")} value={report.lastSeenPlace} styles={styles} />
+ } label={t("lostView.lastSeenAt")} value={formatDateTime(report.lastSeenAt)} styles={styles} />
+ {report.description ? (
+ } label={t("lostView.details")} value={report.description} styles={styles} />
+ ) : null}
+
+
+ {!isOwnPet && (
+
+ {t("lostView.contactHeading", { name: pet.name })}
+ {report.contactPhone ? (
+ }
+ label={report.contactPhone}
+ onPress={() => void Linking.openURL(`tel:${report.contactPhone!.replace(/[^+0-9]/g, "")}`)}
+ styles={styles}
+ />
+ ) : null}
+ {report.contactEmail ? (
+ }
+ label={report.contactEmail}
+ onPress={() =>
+ void Linking.openURL(
+ `mailto:${report.contactEmail}?subject=${encodeURIComponent(t("lostView.mailSubject", { name: pet.name }))}`
+ )
+ }
+ styles={styles}
+ />
+ ) : null}
+ {activePetId ? (
+ }
+ label={t("lostView.message")}
+ onPress={() => openConversation.mutate({ senderPetId: activePetId, recipientPetId: pet.id })}
+ styles={styles}
+ />
+ ) : null}
+ {t("lostView.approachHint")}
+
+ )}
+
+ {photoUrls.length > 0 && (
+
+ {t("lostView.photosHeading")}
+
+ {photoUrls.map((url, index) => (
+
+ router.push({ pathname: "/image-viewer", params: { images: JSON.stringify(photoUrls), index: String(index) } })
+ }
+ >
+
+
+ ))}
+
+
+ )}
+
+ void Share.share({ message: `${t("lostView.shareText", { name: pet.name })} ${publicUrl}` })}
+ >
+
+ {t("lostView.share")}
+
+
+ );
+}
+
+type Styles = ReturnType;
+
+function Fact({ icon, label, value, styles }: { icon: React.ReactNode; label: string; value: string; styles: Styles }) {
+ return (
+
+ {icon}
+
+ {label}
+ {value}
+
+
+ );
+}
+
+function ContactButton({ icon, label, onPress, styles }: { icon: React.ReactNode; label: string; onPress: () => void; styles: Styles }) {
+ return (
+
+ {icon}
+
+ {label}
+
+
+ );
+}
+
+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 },
+ });
+}
diff --git a/mobile/app/(app)/pets/[petId]/index.tsx b/mobile/app/(app)/pets/[petId]/index.tsx
index bcc043a..34432c9 100644
--- a/mobile/app/(app)/pets/[petId]/index.tsx
+++ b/mobile/app/(app)/pets/[petId]/index.tsx
@@ -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() {
void Linking.openURL(`${env.apiUrl}/vermisst/${pet.lostReport?.code}`)}
+ onPress={() => router.push(`/lost/${pet.id}`)}
>
diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json
index 5b79e0a..d573c88 100644
--- a/mobile/src/i18n/locales/de.json
+++ b/mobile/src/i18n/locales/de.json
@@ -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. 🎉"
}
}
diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json
index 6def943..ae1cf52 100644
--- a/mobile/src/i18n/locales/en.json
+++ b/mobile/src/i18n/locales/en.json
@@ -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. 🎉"
}
}
diff --git a/mobile/src/lib/notification-route.test.ts b/mobile/src/lib/notification-route.test.ts
index 3873c00..05d8e75 100644
--- a/mobile/src/lib/notification-route.test.ts
+++ b/mobile/src/lib/notification-route.test.ts
@@ -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");
diff --git a/mobile/src/lib/notification-route.ts b/mobile/src/lib/notification-route.ts
index 0f59bc8..b7c37ac 100644
--- a/mobile/src/lib/notification-route.ts
+++ b/mobile/src/lib/notification-route.ts
@@ -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":
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 9c6c751..549ba95 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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)
diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts
index d91ca5b..e34b44f 100644
--- a/src/__tests__/helpers/prisma-mock.ts
+++ b/src/__tests__/helpers/prisma-mock.ts
@@ -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(),
diff --git a/src/__tests__/lost-report-admin.test.ts b/src/__tests__/lost-report-admin.test.ts
new file mode 100644
index 0000000..a28f93a
--- /dev/null
+++ b/src/__tests__/lost-report-admin.test.ts
@@ -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;
+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();
+ });
+});
diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx
index 8fae6b4..89ef7fd 100644
--- a/src/app/p/[secret]/layout.tsx
+++ b/src/app/p/[secret]/layout.tsx
@@ -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 },
diff --git a/src/app/p/[secret]/lost/page.tsx b/src/app/p/[secret]/lost/page.tsx
new file mode 100644
index 0000000..1bebda4
--- /dev/null
+++ b/src/app/p/[secret]/lost/page.tsx
@@ -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 = { 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("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 (
+
+
+
+
+
+ Vermisst
+
+
+ {data ? `${data.activeCount} aktive Meldung${data.activeCount === 1 ? "" : "en"}` : "Vermisst-Meldungen der Nutzer"}
+
+
+
+ {(Object.keys(STATUS_LABEL) as Status[]).map((s) => (
+
+ ))}
+
+
+
+ {isLoading ? (
+
+ ) : !data?.reports.length ? (
+
+
+ Keine Meldungen in dieser Ansicht.
+
+ ) : (
+
+ {data.reports.map((r) => {
+ const isActive = r.foundAt == null;
+ return (
+ -
+
+
+
+ {r.pet.name.slice(0, 2).toUpperCase()}
+
+
+
+ {r.pet.name}
+
+ gemeldet {DATE_FORMAT.format(new Date(r.createdAt))}
+
+ {isActive ? (
+ Aktiv
+ ) : (
+ Wieder da · {DATE_FORMAT.format(new Date(r.foundAt!))}
+ )}
+
+
+
+
+
+ {r.lastSeenPlace} · {DATE_FORMAT.format(new Date(r.lastSeenAt))}
+
+ {r.description && {r.description}
}
+
+
+
+
+
+ {r.notifiedCount} benachrichtigt
+
+ {r.contactPhone && (
+
+
+ Telefon
+
+ )}
+ {r.contactEmail && (
+
+
+ E-Mail
+
+ )}
+ {r.allowIndexing ? "Suchmaschinen erlaubt" : "Nur per Link"}
+
+
+
+
+ Öffentliche Seite
+
+
+ {isActive && (
+
+ )}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/vermisst/[code]/page.tsx b/src/app/vermisst/[code]/page.tsx
index 10d0d4f..0529992 100644
--- a/src/app/vermisst/[code]/page.tsx
+++ b/src/app/vermisst/[code]/page.tsx
@@ -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 {
const { code } = await params;
@@ -57,7 +36,7 @@ export async function generateMetadata({ params }: PageProps): Promise
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 (
diff --git a/src/lib/lost-reports-view.ts b/src/lib/lost-reports-view.ts
new file mode 100644
index 0000000..f550d70
--- /dev/null
+++ b/src/lib/lost-reports-view.ts
@@ -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/ 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,
+ 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>>;
diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts
index 8f2315c..5a0870f 100644
--- a/src/trpc/routers/_app.ts
+++ b/src/trpc/routers/_app.ts
@@ -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,
});
diff --git a/src/trpc/routers/lost-report-admin.ts b/src/trpc/routers/lost-report-admin.ts
new file mode 100644
index 0000000..cc5ad6c
--- /dev/null
+++ b/src/trpc/routers/lost-report-admin.ts
@@ -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 };
+ }),
+});
diff --git a/src/trpc/routers/lost-reports.ts b/src/trpc/routers/lost-reports.ts
index 0f579e0..fa72dde 100644
--- a/src/trpc/routers/lost-reports.ts
+++ b/src/trpc/routers/lost-reports.ts
@@ -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/ 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);