Author SHA1 Message Date
adminandClaude Opus 5.5 9216f0c702 feat: Gedenkmodus „In Erinnerung“ für verstorbene Tiere
- Pet.deceasedAt + NotificationType.PET_DECEASED (additiv)
- pets.setDeceased: nur Besitzer, kein Datum in der Zukunft, rückgängig
  machbar; beim ersten Setzen einmalig stille Mitteilung (ohne Push) an
  Follower anderer Besitzer
- Verstorbene Tiere bekommen keine Geburtstags-/Adoptionstag-Mitteilungen,
  Impf-Erinnerungen und Tier-Vorschläge mehr und werden nicht vorgeschlagen
- Abzeichen „In Erinnerung“ (Ribbon) überall neben dem Namen, Gedenk-Banner
  im Profil, Bereich „Gedenkmodus“ in „Profil bearbeiten“ (Web + App)
- Mitteilung führt zum Profil des Tiers (Liste + Push-Tap)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:24:08 +02:00
adminandClaude Opus 5.5 b244ef4c10 docs(mobile): found.md — Mitteilungen fuehren direkt zum Ziel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:56:34 +02:00
adminandClaude Opus 5.5 4cf28213ff feat: Jede Mitteilung fuehrt direkt zum Ziel (Liste + Push)
Tester-Wunsch 04.10.: „Benachrichtigung bringt dich auf kuerzestem Weg
dorthin, wo es wichtig ist“.
- Nachrichten: notifications.list liefert die conversationId (ueber das
  sortierte Tier-Paar), App und Web oeffnen direkt das Gespraech.
- Push-Mitteilungen: Antippen fuehrte bisher nur in die App. Jetzt
  useNotificationTapRouting (useLastNotificationResponse, auch Kaltstart),
  wechselt bei Bedarf aufs betroffene eigene Tier. Push-Daten tragen dafuer
  petId, actorPetId, storyId, conversationId.
- Eine gemeinsame Zielregel fuer Liste und Push (mobile/src/lib/
  notification-route.ts, getestet): Nachricht → Gespraech, Kommentar/Pfote/
  Erwaehnung/Paw-Back → Beitrag, Story → Story, Follow/Vorschlag → Profil,
  Geburtstag/Adoption/Impfung → Gesundheit, Rang → Einladen.
- Bei Nachrichten und Storys fuehrt auch der Name zum Ziel; Story-Viewer
  laedt Name/Bild nach, wenn er per Push geoeffnet wird.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:52:23 +02:00
adminandClaude Opus 5.5 9cf8369307 docs(mobile): found.md — Story-Mitteilung oeffnet Story
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:32:31 +02:00
adminandClaude Opus 5.5 8aca1bc28e fix: Story-Mitteilung oeffnet direkt die Story, nie mehr Code-Namen als Text
Tester-Feedback 04.10. (STORY_MENTION):
- Web: Klick oeffnet die Story als Overlay an der richtigen Stelle
  (initialStoryId) statt ueber das Tierprofil; abgelaufen → Profil.
- App: ganzer Eintrag inkl. Name oeffnet die Story, Viewer springt per
  storyId direkt zur markierten Story.
- Unbekannte Mitteilungstypen zeigen einen neutralen Text statt des
  Code-Namens (der rohe Text kam von einem Geraet mit aelterem App-Stand).
- Keine Markier-Mitteilung an eigene Tiere des Erstellers (petsOfOtherOwners).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:28:15 +02:00
adminandClaude Opus 5.5 53d1382ac0 docs(mobile): found.md — Story-Editor iOS/Android-Fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:23:34 +02:00
adminandClaude Opus 5.5 6d3136700b fix(mobile): Story-Editor als eigener Vollbild-Screen (iOS) + Safe-Area (Android)
Tester-Feedback 04.10.:
- iOS: Text-Story liess sich nicht oeffnen, beim Foto aus der Galerie
  verschwand alles und nur das X leuchtete auf. Ursache: der Editor lief im
  Auswahl-Screen, der auf iOS ein Sheet ist; Kopfzeile umschalten und ein
  weiteres natives Modal waehrend der Praesentation scheiterten. Jetzt eigener
  Screen story-editor (fullScreenModal, ohne Kopfzeile, ohne Wisch-zum-
  Schliessen); Texteditor und Auswahlfenster sind Ebenen (EditorLayer) statt
  nativer Modals, Android-Zurueck schliesst sie.
- Android: Farbreihe im Texteditor lag hinter der Gestenleiste → echte
  Safe-Area-Abstaende oben/unten.
- Tippen auf eine freie Stelle der Story oeffnet den Texteditor.

Am Android-Emulator durchgeklickt (Text-Story, Texteditor, Verschieben,
GIF-Suche live, Sticker, X zurueck). iOS nicht testbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:20:23 +02:00
adminandClaude Opus 5.5 84a430a92a docs(mobile): found.md — Story-Editor live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:57:46 +02:00
adminandClaude Opus 5.5 a907654de5 docs(mobile): found.md — Research gemeinsame Tierhalter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:48:36 +02:00
48 changed files with 1246 additions and 281 deletions

No files matched your search

+22 -1
View File
@@ -486,7 +486,9 @@
"typeVaccineDue": "💉 Die Impfung „{name}“ ist am {date} fällig – Zeit, einen Termin zu vereinbaren.",
"typeStoryMention": "hat dich in einer Story markiert.",
"typeStoryComment": "hat deine Story kommentiert.",
"typeStoryReaction": "hat deiner Story eine Pfote gegeben."
"typeStoryReaction": "hat deiner Story eine Pfote gegeben.",
"typeUnknown": "hat eine neue Mitteilung für dich.",
"typePetDeceased": "ist leider verstorben. In Erinnerung 🕯️"
},
"PhotoPostForm": {
"captionPlaceholder": "Bildunterschrift schreiben…",
@@ -858,5 +860,24 @@
"FoundingMember": {
"label": "Gründungsmitglied",
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
},
"InMemory": {
"label": "In Erinnerung",
"tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen",
"banner": "In 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",
"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",
"cancel": "Abbrechen",
"saved": "Gedenkmodus gespeichert",
"removed": "Gedenkmodus beendet",
"saveFailed": "Konnte nicht gespeichert werden"
}
}
+22 -1
View File
@@ -486,7 +486,9 @@
"typeVaccineDue": "💉 The \"{name}\" vaccination is due on {date} – time to book an appointment.",
"typeStoryMention": "tagged you in a story.",
"typeStoryComment": "commented on your story.",
"typeStoryReaction": "gave your story a paw."
"typeStoryReaction": "gave your story a paw.",
"typeUnknown": "has a new notification for you.",
"typePetDeceased": "has sadly passed away. In memory 🕯️"
},
"PhotoPostForm": {
"captionPlaceholder": "Write a caption…",
@@ -858,5 +860,24 @@
"FoundingMember": {
"label": "Founding member",
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
},
"InMemory": {
"label": "In memory",
"tooltip": "In memory – this pet has crossed the rainbow bridge",
"banner": "In 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",
"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"
}
}
+39 -44
View File
@@ -10,6 +10,7 @@ import { useTheme } from "../../../src/context/ThemeContext";
import { PetAvatar } from "../../../src/components/PetAvatar";
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
import { isoToDisplayDate } from "../../../src/lib/date-format";
import { notificationRoute } from "../../../src/lib/notification-route";
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
import type { NotificationItem } from "../../../src/api/types";
@@ -25,6 +26,7 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
STORY_MENTION: "notifications.storyMention",
STORY_COMMENT: "notifications.storyComment",
STORY_REACTION: "notifications.storyReaction",
PET_DECEASED: "notifications.petDeceased",
};
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
@@ -50,7 +52,8 @@ function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
if (selfKey) return t(selfKey);
const actorKey = ACTOR_MESSAGE_KEYS[item.type];
if (actorKey && item.actorPet) return t(actorKey);
return item.type;
// Nie den Code-Namen zeigen (z. B. neuer Typ vom Server, den diese App-Version noch nicht kennt).
return t("notifications.unknown");
}
const SELF_ICONS: Partial<Record<NotificationItem["type"], string>> = {
@@ -59,52 +62,32 @@ const SELF_ICONS: Partial<Record<NotificationItem["type"], string>> = {
};
/**
* Port of web's notifications/page.tsx getHref()/getPostId(): FOLLOW goes to
* the actor's profile, BIRTHDAY/ADOPTION_DAY to this pet's own health page
* (the anniversary is about *this* pet, there's no other actor to visit),
* and REACTION/COMMENT/MENTION/PAW_BACK go to the post. Web opens the post
* in an inline dialog; mobile has no such dialog, so this goes to the
* comments screen instead — the closest existing route to "the post".
* MESSAGE has no target here either, matching web (it doesn't handle it
* there so this doesn't try to be smarter about it).
* Ziel einer Mitteilung — gemeinsame Regel mit dem Antippen von Push-Mitteilungen
* (src/lib/notification-route.ts): Nachricht → Gespräch, Kommentar/Pfote/Erwähnung
* → Beitrag, Story-Mitteilungen → die Story, Follow/Vorschlag → Profil,
* Geburtstag/Impfung → Gesundheit. Nur in Event-Handlern aufrufen (Date.now()).
*/
function getNotificationRoute(item: NotificationItem): string | null {
// Story-Mitteilungen: Story öffnen, solange sie läuft (der Besitzer sieht
// eigene Storys 7 Tage), sonst das Tierprofil.
if (item.story && (item.type === "STORY_MENTION" || item.type === "STORY_COMMENT" || item.type === "STORY_REACTION")) {
const isOwnStory = item.story.petId === item.recipientPetId;
const isRunning = new Date(item.story.expiresAt).getTime() > Date.now();
if (isRunning || isOwnStory) {
const params = new URLSearchParams({
petId: item.story.petId,
petName: item.story.pet.name,
petAvatarKey: item.story.pet.avatarKey ?? "",
});
return `/story-viewer?${params.toString()}`;
}
return `/pets/${item.story.petId}`;
}
switch (item.type) {
case "FOLLOW":
case "PET_SUGGESTION":
case "REFERRAL_JOINED":
return item.actorPet ? `/pets/${item.actorPet.id}` : null;
case "REFERRAL_RANK_UP":
return "/invite";
case "BIRTHDAY":
case "ADOPTION_DAY":
case "VACCINE_DUE":
return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null;
case "REACTION":
case "COMMENT":
case "MENTION":
case "PAW_BACK":
return item.post?.id ? `/posts/${item.post.id}/comments` : null;
default:
return null;
}
return notificationRoute(
{
type: item.type,
recipientPetId: item.recipientPetId,
actorPetId: item.actorPet?.id ?? null,
postId: item.post?.id ?? null,
conversationId: item.conversationId,
storyId: item.story?.id ?? null,
storyPetId: item.story?.petId ?? null,
storyPetName: item.story?.pet.name ?? null,
storyPetAvatarKey: item.story?.pet.avatarKey ?? null,
storyExpiresAt: item.story?.expiresAt ?? null,
},
Date.now()
);
}
/** Bei diesen Typen führt auch der Name direkt zum Ziel (nicht zum Profil-Umweg). */
const NAME_OPENS_TARGET = new Set(["MESSAGE", "STORY_MENTION", "STORY_COMMENT", "STORY_REACTION"]);
/** Port of web's notifications list — tapping a row marks it read and navigates to its target (post/pet). */
export default function NotificationsScreen() {
const { t } = useTranslation();
@@ -196,7 +179,19 @@ export default function NotificationsScreen() {
<View style={styles.rowText}>
<Text style={styles.bodyText}>
{item.actorPet && (
<Text style={styles.actorName} onPress={() => router.push(`/pets/${item.actorPet!.id}`)}>
<Text
style={styles.actorName}
onPress={() => {
// Bei Nachrichten und Storys führt auch der Name direkt zum Ziel.
const targetRoute = NAME_OPENS_TARGET.has(item.type) ? getNotificationRoute(item) : null;
if (targetRoute) {
if (!item.read) markRead.mutate({ notificationId: item.id });
router.push(targetRoute);
} else {
router.push(`/pets/${item.actorPet!.id}`);
}
}}
>
{formatPetName(item.actorPet)}{" "}
</Text>
)}
+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 isVerified={pet.isVerified} />
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} />
</View>
<Text style={styles.breed}>{pet.breed?.name ?? pet.species.name}</Text>
</View>
+8
View File
@@ -4,6 +4,7 @@ import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useTheme } from "../../src/context/ThemeContext";
import { fontFamily } from "../../src/theme/tokens";
import { useNotificationTapRouting } from "../../src/hooks/useNotificationTapRouting";
function CloseModalButton() {
const router = useRouter();
@@ -17,6 +18,8 @@ function CloseModalButton() {
export default function AppLayout() {
const { t } = useTranslation();
// Push-Mitteilung angetippt → direkt zum Ziel (Gespräch, Beitrag, Story …)
useNotificationTapRouting();
const { colors } = useTheme();
// Plain prop objects, not StyleSheet.create — safe to recompute on every
// render, unlike the module-level StyleSheet.create calls most other
@@ -70,6 +73,11 @@ export default function AppLayout() {
headerLeft: () => <CloseModalButton />,
}}
/>
{/* Story-Editor: eigener Vollbild-Screen, ohne Wisch-zum-Schließen (Ziehgesten der Overlays). */}
<Stack.Screen
name="story-editor"
options={{ headerShown: false, presentation: "fullScreenModal", animation: "fade", gestureEnabled: false }}
/>
<Stack.Screen
name="milestone-new"
options={{
+3
View File
@@ -12,6 +12,7 @@ import { DateField } from "../../../../src/components/DateField";
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 { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
import { displayDateToIso, isoToDisplayDate } from "../../../../src/lib/date-format";
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
@@ -177,6 +178,8 @@ export default function EditPetScreen() {
followerVisibility={pet.followerVisibility}
/>
<MemorialSettings petId={pet.id} petName={pet.name} deceasedAt={pet.deceasedAt} />
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
<Trash2 color={colors.alertRed} size={18} />
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
+26 -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 } from "lucide-react-native";
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal, Ribbon } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { useTheme } from "../../../../src/context/ThemeContext";
@@ -17,6 +17,7 @@ import { ReportSheet } from "../../../../src/components/ReportSheet";
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 { getAvatarUrl } from "../../../../src/lib/media-url";
import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
@@ -145,8 +146,19 @@ export default function PetProfileScreen() {
</ReferralAvatarRing>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
</View>
{pet.deceasedAt && (
<View style={styles.memorialBanner}>
<Ribbon size={13} color={colors.ink} fill={colors.ink} />
<Text style={styles.memorialText}>
{t("inMemory.banner", {
name: pet.name,
date: isoToDisplayDate(new Date(pet.deceasedAt).toISOString().slice(0, 10)),
})}
</Text>
</View>
)}
{pet.isFoundingMember && <Text style={styles.foundingHint}>{t("foundingMember.tooltip")}</Text>}
<Text style={styles.species}>{pet.species.name}</Text>
{pet.invitedByPet && (
@@ -230,6 +242,18 @@ function makeStyles(colors: ColorTokens) {
species: { ...typography.body, color: colors.quietGray },
invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray },
foundingHint: { ...typography.body, fontSize: 12, color: "#059669" },
memorialBanner: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 999,
borderWidth: 1,
borderColor: colors.hairlineGray,
backgroundColor: colors.whisperGray,
},
memorialText: { ...typography.body, fontSize: 12, color: colors.quietGray },
invitedByName: { color: colors.ink, fontWeight: "600" },
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
stat: { alignItems: "center" },
+183
View File
@@ -0,0 +1,183 @@
import { useState, useCallback, useMemo } from "react";
import { View, Text, Image as RNImage, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload";
import { resizeForUpload } from "../../src/lib/resize-image";
import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor";
import { fontFamily } from "../../src/theme/tokens";
type Params = { kind: "image" | "video" | "text"; uri?: string; width?: string; height?: string; mimeType?: string };
/** Echte Pixelmaße der (verkleinerten) Datei — der Picker meldet bei gedrehten Handyfotos teils vertauschte Werte. */
function imageSize(uri: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => RNImage.getSize(uri, (width, height) => resolve({ width, height }), reject));
}
function mediaFromParams(params: Params): EditorMedia | null {
if (params.kind === "text") return { kind: "text" };
const width = Number(params.width);
const height = Number(params.height);
if (!params.uri || !width || !height) return null;
return params.kind === "video"
? { kind: "video", uri: params.uri, width, height, mimeType: params.mimeType || undefined }
: { kind: "image", uri: params.uri, width, height };
}
/**
* Story-Editor als eigener Vollbild-Screen (fullScreenModal, ohne Kopfzeile,
* ohne Wisch-zum-Schließen). Vorher lief er innerhalb des Auswahl-Screens
* story-new, der auf iOS ein Sheet ist — dort das Umschalten der Kopfzeile und
* ein weiteres Modal führten dazu, dass alles verschwand und nur das X kurz
* aufleuchtete (Tester-Feedback 04.10.). Das Medium kommt als Route-Parameter.
*
* Upload wie bisher: Foto verkleinert zu Supabase, Video direkt zu Mux
* (createVideoUpload legt die PROCESSING-Story an), Text-Story ohne Datei.
*/
export default function StoryEditorScreen() {
const params = useLocalSearchParams<Params>();
const { t } = useTranslation();
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const media = useMemo(() => mediaFromParams(params), [params]);
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
const createMutation = useMutation(trpc.stories.create.mutationOptions());
const createTextMutation = useMutation(trpc.stories.createText.mutationOptions());
const createVideoUploadMutation = useMutation(trpc.stories.createVideoUpload.mutationOptions());
const finish = useCallback(async () => {
await queryClient.invalidateQueries(trpc.follows.pathFilter());
await queryClient.invalidateQueries(trpc.stories.pathFilter());
// Editor + Auswahl-Screen schließen → zurück dorthin, wo die Story gestartet wurde.
router.dismissAll();
}, [queryClient, trpc, router]);
const handleSubmit = useCallback(
async ({ overlays, background }: EditorResult) => {
if (!media || !activePetId) return;
setError(null);
const editorFields = overlays.length > 0 ? { overlays } : {};
try {
if (media.kind === "text") {
await createTextMutation.mutateAsync({ petId: activePetId, background: background ?? "sunset", overlays });
await finish();
return;
}
if (media.kind === "video") {
const { uploadUrl } = await createVideoUploadMutation.mutateAsync({
petId: activePetId,
aiDisclosure: "NONE",
...editorFields,
mediaWidth: media.width,
mediaHeight: media.height,
});
setIsUploading(true);
setUploadPercent(0);
await uploadFileToUrl(media.uri, uploadUrl ?? "", media.mimeType ?? "video/mp4", (fraction) => {
const pct = Math.round(fraction * 100);
setUploadPercent((prev) => (prev === pct ? prev : pct));
});
await finish();
return;
}
setIsUploading(true);
const resized = await resizeForUpload(media.uri, media.width, media.height);
const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height }));
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId: activePetId,
contentType: resized.mimeType,
fileSize: resized.fileSize,
});
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
await createMutation.mutateAsync({
petId: activePetId,
storageKey: key,
blurhash: null,
...editorFields,
mediaWidth: size.width,
mediaHeight: size.height,
});
await finish();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t("onboarding.uploadError"));
} finally {
setIsUploading(false);
setUploadPercent(null);
}
},
[media, activePetId, createTextMutation, createVideoUploadMutation, getPresignedUrlMutation, createMutation, finish, t]
);
const isSubmitting =
isUploading ||
getPresignedUrlMutation.isPending ||
createMutation.isPending ||
createTextMutation.isPending ||
createVideoUploadMutation.isPending;
if (!media || !activePetId) {
return (
<View style={styles.missing}>
<Text style={styles.missingText}>{t("onboarding.uploadError")}</Text>
</View>
);
}
return (
<StoryEditor
media={media}
petId={activePetId}
toolsEnabled={flags?.storyEditor ?? false}
isSubmitting={isSubmitting}
submitLabel={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
onSubmit={handleSubmit}
onCancel={() => {
if (!isSubmitting) router.back();
}}
progress={
<>
{error ? <Text style={styles.error}>{error}</Text> : null}
{isSubmitting && media.kind === "video" ? <UploadProgress percent={uploadPercent} /> : null}
</>
}
/>
);
}
/** Video-Upload zu Mux: erst „wird vorbereitet“, dann echter Fortschritt (expo-file-system onProgress). */
function UploadProgress({ percent }: { percent: number | null }) {
const { t } = useTranslation();
const label = percent === null ? t("story.preparingUpload") : t("story.uploadingVideo", { percent });
return (
<View style={styles.progressWrap} accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: 100, now: percent ?? 0 }} accessibilityLabel={label}>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${percent ?? 0}%` }]} />
</View>
<Text style={styles.progressLabel}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
missing: { flex: 1, backgroundColor: "#111111", alignItems: "center", justifyContent: "center", padding: 24 },
missingText: { color: "#ffffff", fontFamily: fontFamily.regular, fontSize: 15, textAlign: "center" },
error: { color: "#f87171", fontFamily: fontFamily.regular, fontSize: 14, textAlign: "center" },
progressWrap: { gap: 6 },
progressTrack: { height: 6, borderRadius: 3, backgroundColor: "rgba(255,255,255,0.2)", overflow: "hidden" },
progressFill: { height: "100%", borderRadius: 3, backgroundColor: "#f97316" },
progressLabel: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 12, textAlign: "center" },
});
+37 -174
View File
@@ -1,39 +1,27 @@
import { useState, useCallback, useMemo, useLayoutEffect } from "react";
import { View, Text, Image as RNImage, Pressable, StyleSheet, Alert } from "react-native";
import { useNavigation, useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { View, Text, Pressable, StyleSheet, Alert } from "react-native";
import { useRouter } from "expo-router";
import * as ImagePicker from "expo-image-picker";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { ImagePlus, Camera, Type } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
import { useTheme } from "../../src/context/ThemeContext";
import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload";
import { resizeForUpload } from "../../src/lib/resize-image";
import { MAX_STORY_VIDEO_DURATION_SECS } from "../../src/lib/story-video-limits";
import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor";
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
/** Echte Pixelmaße der (verkleinerten) Datei — der Picker meldet bei gedrehten Handyfotos teils vertauschte Werte. */
function imageSize(uri: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => RNImage.getSize(uri, (width, height) => resolve({ width, height }), reject));
}
/**
* Story erstellen: Foto, kurzes Video oder reine Text-Story (Farbverlauf),
* danach der Story-Editor (Text, @-Markierungen, GIFs, Sticker — siehe
* src/components/story-editor/). Overlays gehen als Daten mit, nicht ins Bild
* gerechnet. Der Video-Pfad entspricht web's StoryForm.tsx: createVideoUpload
* legt die (PROCESSING) Story an, die Bytes gehen direkt zu Mux. Ist der
* Notschalter (Admin) aus, gibt es nur Vorschau + Teilen wie früher.
* Story erstellen — Schritt 1: Foto, kurzes Video oder Text-Story wählen.
* Bearbeiten und Teilen passieren im eigenen Vollbild-Screen story-editor.tsx
* (dieser Screen ist auf iOS ein Sheet; den Editor darin einzublenden führte
* dazu, dass alles verschwand — siehe story-editor.tsx). Ist der Notschalter
* (Admin) aus, gibt es keine Text-Story und der Editor zeigt nur Vorschau + Teilen.
*/
export default function NewStoryScreen() {
const { t } = useTranslation();
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
@@ -41,28 +29,21 @@ export default function NewStoryScreen() {
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
const editorEnabled = flags?.storyEditor ?? false;
const [media, setMedia] = useState<EditorMedia | null>(null);
const navigation = useNavigation();
// Im Editor gehört der ganze Bildschirm der Story — Kopfzeile ausblenden.
useLayoutEffect(() => {
navigation.setOptions({ headerShown: media === null });
}, [navigation, media]);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Video-Upload in Prozent (0–100), null solange kein Video hochgeladen wird.
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
const createMutation = useMutation(trpc.stories.create.mutationOptions());
const createTextMutation = useMutation(trpc.stories.createText.mutationOptions());
const createVideoUploadMutation = useMutation(trpc.stories.createVideoUpload.mutationOptions());
const finish = useCallback(async () => {
await queryClient.invalidateQueries(trpc.follows.pathFilter());
await queryClient.invalidateQueries(trpc.stories.pathFilter());
router.back();
}, [queryClient, trpc, router]);
const openEditor = useCallback(
(params: { kind: "image" | "video" | "text"; uri?: string; width?: number; height?: number; mimeType?: string | null }) => {
router.push({
pathname: "/story-editor",
params: {
kind: params.kind,
uri: params.uri ?? "",
width: String(params.width ?? 0),
height: String(params.height ?? 0),
mimeType: params.mimeType ?? "",
},
});
},
[router]
);
const pickFromGallery = useCallback(async () => {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -75,16 +56,18 @@ export default function NewStoryScreen() {
// expo-image-picker meldet `duration` (ms) nur bei Videos — verlässlicher als ein MIME-Typ.
const picked = result.assets[0];
if (picked.duration != null) {
if (picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
return;
}
setMedia({ kind: "video", uri: picked.uri, width: picked.width, height: picked.height, mimeType: picked.mimeType ?? undefined });
if (picked.duration != null && picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
return;
}
setMedia({ kind: "image", uri: picked.uri, width: picked.width, height: picked.height });
}, [t]);
openEditor({
kind: picked.duration != null ? "video" : "image",
uri: picked.uri,
width: picked.width,
height: picked.height,
mimeType: picked.mimeType,
});
}, [t, openEditor]);
const takePhoto = useCallback(async () => {
const permission = await ImagePicker.requestCameraPermissionsAsync();
@@ -95,100 +78,9 @@ export default function NewStoryScreen() {
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
if (!result.canceled) {
const photo = result.assets[0];
setMedia({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height });
openEditor({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height });
}
}, [t]);
const handleSubmit = useCallback(
async ({ overlays, background }: EditorResult) => {
if (!media || !activePetId) return;
setError(null);
const editorFields = overlays.length > 0 ? { overlays } : {};
try {
if (media.kind === "text") {
await createTextMutation.mutateAsync({ petId: activePetId, background: background ?? "sunset", overlays });
await finish();
return;
}
if (media.kind === "video") {
const { uploadUrl } = await createVideoUploadMutation.mutateAsync({
petId: activePetId,
aiDisclosure: "NONE",
...editorFields,
...(media.width > 0 && media.height > 0 ? { mediaWidth: media.width, mediaHeight: media.height } : {}),
});
setIsUploading(true);
setUploadPercent(0);
await uploadFileToUrl(media.uri, uploadUrl ?? "", media.mimeType ?? "video/mp4", (fraction) => {
const pct = Math.round(fraction * 100);
setUploadPercent((prev) => (prev === pct ? prev : pct));
});
// Story existiert schon (PROCESSING) — story-viewer.tsx fragt den Status ab.
await finish();
return;
}
setIsUploading(true);
const resized = await resizeForUpload(media.uri, media.width, media.height);
const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height }));
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId: activePetId,
contentType: resized.mimeType,
fileSize: resized.fileSize,
});
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
await createMutation.mutateAsync({
petId: activePetId,
storageKey: key,
blurhash: null,
...editorFields,
mediaWidth: size.width,
mediaHeight: size.height,
});
await finish();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t("onboarding.uploadError"));
} finally {
setIsUploading(false);
setUploadPercent(null);
}
},
[media, activePetId, createTextMutation, createVideoUploadMutation, getPresignedUrlMutation, createMutation, finish, t]
);
const isSubmitting =
isUploading ||
getPresignedUrlMutation.isPending ||
createMutation.isPending ||
createTextMutation.isPending ||
createVideoUploadMutation.isPending;
if (media && activePetId) {
return (
<StoryEditor
media={media}
petId={activePetId}
toolsEnabled={editorEnabled}
isSubmitting={isSubmitting}
submitLabel={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
onSubmit={handleSubmit}
onCancel={() => {
if (!isSubmitting) {
setMedia(null);
setError(null);
}
}}
progress={
<>
{error ? <Text style={styles.errorOnDark}>{error}</Text> : null}
{isSubmitting && media.kind === "video" ? <UploadProgress percent={uploadPercent} styles={styles} /> : null}
</>
}
/>
);
}
}, [t, openEditor]);
return (
<View style={styles.container}>
@@ -203,7 +95,7 @@ export default function NewStoryScreen() {
</Pressable>
</View>
{editorEnabled && (
<Pressable style={styles.textStoryButton} onPress={() => setMedia({ kind: "text" })}>
<Pressable style={styles.textStoryButton} onPress={() => openEditor({ kind: "text" })}>
<Type color={colors.pureWhite} size={22} />
<Text style={styles.textStoryText}>{t("storyEditor.textStory")}</Text>
</Pressable>
@@ -212,30 +104,6 @@ export default function NewStoryScreen() {
);
}
/**
* Fortschritt beim Video-Upload zu Mux. `percent === null` = Upload wird noch
* vorbereitet (createVideoUpload läuft), dann echter Fortschritt aus
* expo-file-system `onProgress`.
*/
function UploadProgress({ percent, styles }: { percent: number | null; styles: ReturnType<typeof makeStyles> }) {
const { t } = useTranslation();
const label = percent === null ? t("story.preparingUpload") : t("story.uploadingVideo", { percent });
return (
<View
style={styles.progressWrap}
accessibilityRole="progressbar"
accessibilityValue={{ min: 0, max: 100, now: percent ?? 0 }}
accessibilityLabel={label}
>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${percent ?? 0}%` }]} />
</View>
<Text style={styles.progressLabel}>{label}</Text>
</View>
);
}
function makeStyles(colors: ColorTokens, bottomInset: number) {
const typography = getTypography(colors);
return StyleSheet.create({
@@ -269,10 +137,5 @@ function makeStyles(colors: ColorTokens, bottomInset: number) {
backgroundColor: colors.marigoldOrange,
},
textStoryText: { ...typography.body, color: colors.pureWhite, fontFamily: typography.title.fontFamily },
errorOnDark: { ...typography.body, color: "#f87171", textAlign: "center" },
progressWrap: { gap: 6 },
progressTrack: { height: 6, borderRadius: 3, backgroundColor: "rgba(255,255,255,0.2)", overflow: "hidden" },
progressFill: { height: "100%", borderRadius: 3, backgroundColor: colors.marigoldOrange },
progressLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", color: "#ffffff" },
});
}
+17 -1
View File
@@ -34,10 +34,12 @@ type Sheet = "comments" | "viewers" | "confirm-delete" | "away" | null;
* die Lösch-Rückfrage offen ist, hält der Timer an.
*/
export default function StoryViewerScreen() {
const { petId, petName, petAvatarKey } = useLocalSearchParams<{
const { petId, petName: petNameParam, petAvatarKey: petAvatarKeyParam, storyId: initialStoryId } = useLocalSearchParams<{
petId: string;
petName: string;
petAvatarKey?: string;
/** Aus einer Mitteilung: direkt diese Story zeigen statt der ersten. */
storyId?: string;
}>();
const { t } = useTranslation();
const router = useRouter();
@@ -46,10 +48,15 @@ export default function StoryViewerScreen() {
const { activePetId } = useActivePet();
const insets = useSafeAreaInsets();
const isOwnStory = activePetId === petId;
// Über eine Push-Mitteilung geöffnet → nur petId bekannt, Name/Bild nachladen.
const { data: storyPet } = useQuery({ ...trpc.pets.getProfile.queryOptions({ petId }), enabled: !petNameParam });
const petName = petNameParam || storyPet?.name || "";
const petAvatarKey = petAvatarKeyParam || storyPet?.avatarKey || undefined;
const listQuery = trpc.stories.listActive.queryOptions({ petId, viewerPetId: activePetId ?? undefined });
const { data: stories = [], isSuccess: storiesLoaded } = useQuery(listQuery);
const [index, setIndex] = useState(0);
const jumpedToInitialRef = useRef(false);
const [progress, setProgress] = useState(0);
const [openSheet, setOpenSheet] = useState<Sheet>(null);
const advancedRef = useRef(false);
@@ -190,6 +197,15 @@ export default function StoryViewerScreen() {
return () => cancelAnimationFrame(rafId);
}, [currentStory, isVideo, isPaused, advance]);
// Aus einer Mitteilung geöffnet → einmalig zur genannten Story springen.
useEffect(() => {
if (jumpedToInitialRef.current || !initialStoryId || stories.length === 0) return;
jumpedToInitialRef.current = true;
const target = stories.findIndex((s) => s.id === initialStoryId);
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmaliger Sprung, sobald die Liste da ist
if (target > 0) setIndex(target);
}, [initialStoryId, stories]);
// Keine (laufende) Story mehr, z. B. über eine alte Mitteilung geöffnet → zurück statt schwarzem Bildschirm.
useEffect(() => {
if (storiesLoaded && stories.length === 0) router.back();
+14 -2
View File
@@ -179,13 +179,15 @@ Neu 7:
→ **Behoben (04.10.):** Der Screen hatte keinen Tastatur-Ausgleich (nur `FlatList`, kein `KeyboardAvoidingView`). Neu: `automaticallyAdjustKeyboardInsets` (iOS: unterer Inset in Tastaturhöhe **und** automatisches Scrollen zum aktiven Feld, im RN-Quellcode `RCTScrollViewComponentView.mm` geprüft) plus `keyboardShouldPersistTaps="handled"`. Gleiche Lücke auch in Gesundheit (Gewicht/Impfung/Tierarzt), Neuer Beitrag, Neuer Meilenstein, Tier anlegen/Onboarding und Einladen gefunden und mitbehoben. Android unverändert (Prop gilt nur für iOS, dort nicht gemeldet). Typecheck sauber. Commit `333ba16`, OTA nur iOS Runtime 1.0.6 `718ede5a-91c4-4e4e-b444-174ab3af66a2`. Nicht auf einem Gerät getestet.
- [x] Feedback iOS - Rassen suche ist als Dropdown, ohne Suchfunktion. Bei Hunden und Katzen ist die Liste lang, die Suche würde verkürzt werden, wenn man eintippen könnte um das Dropdown zu kürzen.
→ **Umgesetzt (04.10.):** `OptionPicker` zeigt ab 10 Einträgen oben ein Suchfeld („Suchen …“), das beim Tippen filtert — ohne Groß-/Kleinschreibung und Akzente („frise“ findet „Bichon Frisé“, `src/lib/filter-options.ts` + Tests). Öffnet sich die Tastatur, rückt die Liste nach oben, damit sie nicht verdeckt wird. Wirkt in Tier anlegen, Profil bearbeiten und Explore-Filter; die kurze Tierarten-Liste bleibt ohne Suchfeld. Typecheck sauber. Commit `01459c7`, OTA Runtime 1.0.6: Android `02f677cc-406b-4475-9601-e61b8a4b963a`, iOS `708be8d6-9786-44ad-a3c9-c305a3f93dd2`. Nicht auf einem Gerät getestet — bitte besonders das Hochrücken über der Tastatur prüfen.
- Feature: Storie-Edit Modus, mittels Overlays eigene Storys hübsch machen, durch Texteingaben in das Bild oder Effekte oder Gifs. (Analog zu Instagram) ->Branch
- [x] Feature: Storie-Edit Modus, mittels Overlays eigene Storys hübsch machen, durch Texteingaben in das Bild oder Effekte oder Gifs. (Analog zu Instagram) ->Branch
→ **Umgesetzt und live (04.10.)** als Story-Editor (Branch `feature/story-editor`, nach `main` gemergt): In der App nach Foto/Video oder als **Text-Story auf Farbverlauf**: Text (3 Stile, 8 Farben, 2 Schriften), **@-Markierungen** anderer Tiere, **GIFs** (GIPHY, nur jugendfrei), **8 Sticker** — verschieben, mit zwei Fingern zoomen/drehen, zum Löschen auf den Papierkorb ziehen. Markieren richtet sich nach „Wer darf die Follower sehen?“ (Alle / nur Follower / niemand), markierte Tiere und Story-Besitzer bekommen Mitteilungen (Markierung, Kommentar, Pfote). Web zeigt alles an, erstellt aber nicht. Overlays sind Daten über dem Bild (nicht eingerechnet), daher alles per OTA, kein Store-Build. **Notschalter** im Admin-Dashboard („Story-Editor“), blendet Editor und Overlays ohne Update aus. 20 neue Tests, alle 376 grün, Produktions-Build ok, gerenderte Vorschau geprüft. db push + RLS, Rolling-Deploy, OTA Runtime 1.0.6: Android `8a1a12cb-d61a-4d14-91ab-391396781d87`, iOS `fe114163-abea-4ccc-ad19-cd1f8ccc7532`. **Nicht am Gerät getestet** — besonders Zwei-Finger-Gesten und Tastatur im Texteditor prüfen.
- [x] Admin-Dashboard -> Zurückgewiesene Reports, als Meldung an den Melder mit einer Begründung warum abgewiesen wurde. Sowie Meldung bei bestätigter Report löschung, z.b mit Danksagung für die Meldung.
→ **Umgesetzt und live (04.10.), als Team-Nachricht.** Admin → Reports: „Report ignorieren“ hat jetzt ein Feld „Begründung für den Melder“; der Melder bekommt vom PawFeed-Team „Danke für deine Meldung zum Beitrag/Profil von X … kein Verstoß … Begründung: …“ (Begründung steht zusätzlich im Moderations-Log). „Post löschen“ dankt jedem Melder des Beitrags einmal („… Inhalt entfernt. Danke, dass du hilfst …“); die interne Lösch-Begründung geht bewusst nicht an den Melder. Beides per Schalter im Dialog abschaltbar, Versand erst nach erfolgreicher Moderation und ohne sie bei einem Fehler zu kippen. **Nebenbei behoben:** Beim Löschen eines gemeldeten Beitrags blieben dessen Reports verwaist in der Liste (`targetPostId` ist keine Relation) — werden jetzt mitgelöscht. 7 neue Tests (`admin-report-feedback.test.ts`), alle 336 Tests grün. Commit `f51b158`, Rolling-Deploy auf dem NAS. Kein App-Update nötig. Im Live-Admin noch nicht mit einem echten Report ausprobiert.
- Feedback - iOS & Andriod, Story-Bilder und Videos mit Overlays bearbeiten direkt in der App. Text hinzufügen, Gifs einbinden, Markierungen auf andere Pets setzten (Analog zu Instagram) bitte als neues Branch anlegen.
- [x] Feedback - iOS & Andriod, Story-Bilder und Videos mit Overlays bearbeiten direkt in der App. Text hinzufügen, Gifs einbinden, Markierungen auf andere Pets setzten (Analog zu Instagram) bitte als neues Branch anlegen.
→ **Umgesetzt und live (04.10.)** als Story-Editor (Branch `feature/story-editor`, nach `main` gemergt): In der App nach Foto/Video oder als **Text-Story auf Farbverlauf**: Text (3 Stile, 8 Farben, 2 Schriften), **@-Markierungen** anderer Tiere, **GIFs** (GIPHY, nur jugendfrei), **8 Sticker** — verschieben, mit zwei Fingern zoomen/drehen, zum Löschen auf den Papierkorb ziehen. Markieren richtet sich nach „Wer darf die Follower sehen?“ (Alle / nur Follower / niemand), markierte Tiere und Story-Besitzer bekommen Mitteilungen (Markierung, Kommentar, Pfote). Web zeigt alles an, erstellt aber nicht. Overlays sind Daten über dem Bild (nicht eingerechnet), daher alles per OTA, kein Store-Build. **Notschalter** im Admin-Dashboard („Story-Editor“), blendet Editor und Overlays ohne Update aus. 20 neue Tests, alle 376 grün, Produktions-Build ok, gerenderte Vorschau geprüft. db push + RLS, Rolling-Deploy, OTA Runtime 1.0.6: Android `8a1a12cb-d61a-4d14-91ab-391396781d87`, iOS `fe114163-abea-4ccc-ad19-cd1f8ccc7532`. **Nicht am Gerät getestet** — besonders Zwei-Finger-Gesten und Tastatur im Texteditor prüfen.
- [x] Admin-Dashboard - Tiere zeigt aktuell neben dem Namen nur einen Rang an, zur besseren übersicht sollten alle erlangten Ränge angezeigt werden in der Liste.
→ **Umgesetzt und live (04.10.):** Die Liste zeigte nur den höchsten Rang (Cache `referralRankId`) und nur mit Vorteil NAME_BADGE. Jetzt lädt `petAdmin.list` alle Rang-Einträge des Besitzers (nach Schwelle sortiert) und zeigt jedes Rangsymbol (`AchievedRankBadges`), unabhängig von NAME_BADGE. Entzogene Ränge erscheinen ausgegraut, Tooltip mit „manuell vergeben“ / „entzogen“. Commit `dbc416c`, Rolling-Deploy.
@@ -196,4 +198,14 @@ Neu 7:
- [x] Testuser hat Goldene Pfote als Rang erhalten, im Rang habe ich Profil_RING mit eingesetzt damit sich der Profil-Ring mit färbt. aber das passiert nicht.
→ **Ursache:** Kein Datenfehler — der Testnutzer („Krypton“) hatte PROFILE_RING korrekt im Cache. Der Ring war bewusst **nur im Tierprofil oben** zu sehen, nicht im Feed oder an Storys. **Auf Wunsch erweitert und live (04.10.):** (1) Ring in Rangfarbe jetzt **überall am Tierbild** — Feed, Kommentare, Suche, Explore, Nachrichten, Mitteilungen, Follower-Listen, Meilenstein-/Repost-Karten, Story-Kommentare/-Zuschauer (Web + App). Dafür liefert die gemeinsame Tier-Abfrage (`petIdentitySelect`) die Rangdaten jetzt mit. Der Ring liegt außen um das Bild, Listen verrutschen nicht. (2) **Story-Ring:** Bei ungesehener Story leuchtet die Pfote in der Rangfarbe statt Orange; ohne Story gibt es einen schlichten Ring in Rangfarbe; gesehene Storys bleiben grau. 6 neue Tests, alle 353 Tests grün. Commit `c7c1473`, Rolling-Deploy + OTA (wie oben). Nicht auf einem Gerät getestet.
- [x] Prüfung ob es möglich wäre, wenn ein Haushalt mit 2 Accounts, aber dem gleichen Tier. sich beide um das gleich tier kümmern können??? reiner research auf machbarkeit. pro / kontra liste erstellen
→ **Research erledigt (04.10.)** — ausführlich in Obsidian: `PawFeed/Punkte/Gemeinsame Tierhalter – Machbarkeit`. Kurz: Heute hat ein Tier genau einen Account (`Pet.ownerId`). Möglich wären (A) gemeinsamer Login — geht schon, aber Push nur aufs zuletzt angemeldete Handy; (B) echte Mitbetreuer mit eigenen Accounts — passt gut zum tierzentrierten Konzept, ist aber ein großer Umbau (zentrale Rechteprüfung + ~22 Dateien, Einladungsablauf, Regeln für Empfehlungsrang, Gesundheitsdaten/DSGVO, Konto-Löschung), grob 4–6 Tage, ohne Store-Build; (C) Tier übertragen — klein, löst die Frage aber nicht. Empfehlung: erst „Push an mehrere Geräte“ bauen (½–1 Tag, hilft sofort), B nur bei häufigerem Wunsch und nach Klärung der offenen Regeln.
- [x] Tester-Feedback 04.10. zum Story-Editor: **Android** — Farbwahl im Texteditor liegt im Gesten-/Schutzbereich. **iOS** — „Text-Story“ lässt sich nicht öffnen (stattdessen „klickt es auf das X“), Foto aus der Galerie: alles verschwindet, das X leuchtet kurz auf.
→ **Behoben (04.10.):** Ursache iOS: Der Editor lief im Auswahl-Screen „Neue Story“, der auf iOS ein Sheet ist — beim Öffnen wurde dessen Kopfzeile ausgeblendet (das aufleuchtende X) und zusätzlich ein natives Fenster (Texteditor) eingeblendet, was iOS während einer laufenden Präsentation verweigert. Jetzt eigener Vollbild-Screen `story-editor` (ohne Kopfzeile, ohne Wisch-zum-Schließen), Texteditor und Auswahlfenster sind Ebenen statt nativer Fenster (`EditorLayer`, Android-Zurück schließt sie). Android: echte Safe-Area-Abstände oben/unten. Neu: Tippen auf eine freie Stelle öffnet den Texteditor. **Am Android-Emulator durchgeklickt** (Text-Story, Texteditor, Verschieben, GIF-Suche live, Sticker, X zurück) — iOS hier nicht testbar, bitte am iPhone prüfen. Commit `6d31367`, OTA Runtime 1.0.6: Android `da1a187e-5032-4e4e-8401-43ba167e9fd5`, iOS `c90c270f-8c89-42fa-ae11-5202163f3f80`.
- [x] Tester-Feedback 04.10.: Mitteilung „STORY_MENTION“ — anklickbar, aber man landet auf dem Profil statt auf der Story; der Text ist nicht korrekt.
→ **Behoben (04.10.):** Web öffnet die Story jetzt direkt als Overlay an der markierten Story (vorher bewusst Profil — unnötiger Umweg), die App öffnet sie über den ganzen Eintrag inkl. Namen und springt im Viewer direkt zur richtigen Story. Der rohe Text „STORY_MENTION“ kam von einem Gerät mit älterem App-Stand, der den neuen Typ noch nicht kannte — künftig zeigen App und Web für unbekannte Typen einen neutralen Text statt des Code-Namens. Zusätzlich: keine Markier-Mitteilung mehr an eigene Tiere. Commit `8aca1bc`, Rolling-Deploy, OTA Runtime 1.0.6: Android `da0b5a30-6a00-47f7-8c2f-91274f2629ec`, iOS `18681e15-dadf-46f4-b3cf-b44aab78fe42`. Bitte die App zweimal komplett neu starten.
- [x] Wunsch 04.10.: Eine Nachrichten-Mitteilung soll beim Antippen direkt zur Nachricht führen (wie bei Kommentaren) — für alle Funktionen: „Benachrichtigung bringt dich auf kürzestem Weg dorthin, wo es wichtig ist.“
→ **Umgesetzt (04.10.):** Gemeinsame Zielregel für Mitteilungsliste und Push (`mobile/src/lib/notification-route.ts`, mit Tests): **Nachricht → Gespräch**, Kommentar/Pfote/Erwähnung/Paw-Back → Beitrag, Story-Mitteilungen → die Story, Follow/Vorschlag/Einladung → Profil, Rang → Einladen, Geburtstag/Adoptionstag/Impfung → Gesundheit. Neu: **Antippen einer Push-Mitteilung** führt direkt dorthin (vorher öffnete sich nur die App), auch beim Kaltstart; gilt sie für ein anderes eigenes Tier, wird vorher umgeschaltet. Bei Nachrichten und Storys führt auch der Name zum Ziel. Web: Nachrichten öffnen das Gespräch. Server liefert dafür das Gespräch zur Mitteilung und erweiterte Push-Daten. 386 Tests grün. Commit `4cf2821`, Rolling-Deploy, OTA Runtime 1.0.6: Android `7ea956bf-b519-4a57-a9f0-4008aa212899`, iOS `b8f7c983-fc18-43d7-aea3-10fa4315d4c6`. Nicht am Gerät getestet; Push-Ziele greifen für Mitteilungen ab jetzt (ältere Pushes haben die neuen Daten noch nicht).
+8 -3
View File
@@ -1,8 +1,9 @@
import { View, StyleSheet } from "react-native";
import { BadgeCheck, Sparkles, Sprout } from "lucide-react-native";
import { BadgeCheck, Ribbon, Sparkles, Sprout } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { isActiveSponsor } from "../lib/sponsor";
import { colors } from "../theme/tokens";
import { useTheme } from "../context/ThemeContext";
import { ReferralIcon } from "../lib/referral-icons";
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
@@ -10,6 +11,8 @@ type Props = {
isVerified?: boolean;
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
isFoundingMember?: boolean;
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
isDeceased?: boolean;
sponsor?: { sponsorSince?: Date | string | null; sponsorPeriodEnd?: Date | string | null } | null;
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
referral?: ReferralOwnerFields | null;
@@ -18,14 +21,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, sponsor, referral }: Props) {
export function PetIdentityBadges({ isVerified, isFoundingMember, isDeceased, sponsor, referral }: Props) {
const { t } = useTranslation();
const { colors: theme } = useTheme();
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
if (!isVerified && !isFoundingMember && !showSponsor && !rank) return null;
if (!isDeceased && !isVerified && !isFoundingMember && !showSponsor && !rank) return null;
return (
<View style={styles.row}>
{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} />}
{isFoundingMember && <Sprout size={14} color={FOUNDING_COLOR} accessibilityLabel={t("foundingMember.label")} />}
+2 -1
View File
@@ -60,6 +60,7 @@ export type PostCardPost = {
nickname?: string | null;
avatarKey?: string | null;
isVerified?: boolean;
deceasedAt?: 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.
@@ -152,7 +153,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 isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
<PetIdentityBadges 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,115 @@
import { useMemo, useState } from "react";
import { View, Text, Alert, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Ribbon } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { DateField } from "../DateField";
import { Button } from "../Button";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { displayDateToIso, isoToDisplayDate, todayAsDisplayDate } from "../../lib/date-format";
type Props = {
petId: string;
petName: string;
deceasedAt: Date | string | null;
};
/** YYYY-MM-DD eines @db.Date-Werts (kommt als UTC-Mitternacht). */
function toIso(value: Date | string | null): string | null {
return value ? new Date(value).toISOString().slice(0, 10) : null;
}
/**
* Gedenkmodus „In Erinnerung“ — Port von web src/components/pet/MemorialSettings.tsx.
* Datum setzen (mit Bestätigung, Follower werden einmalig still informiert),
* korrigieren oder beenden. Server: pets.setDeceased.
*/
export function MemorialSettings({ petId, petName, deceasedAt }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const savedIso = toIso(deceasedAt);
const isDeceased = savedIso != null;
const [date, setDate] = useState(() => (savedIso ? isoToDisplayDate(savedIso) : todayAsDisplayDate()));
const [error, setError] = useState<string | null>(null);
const mutation = useMutation(
trpc.pets.setDeceased.mutationOptions({
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries();
Alert.alert(variables.deceasedAt ? t("inMemory.saved") : t("inMemory.removed"));
},
onError: () => Alert.alert(t("inMemory.saveFailed")),
})
);
/** Gültiges, nicht zukünftiges Datum als YYYY-MM-DD — sonst Fehlermeldung. */
function validIso(): string | null {
const iso = displayDateToIso(date);
const todayIso = displayDateToIso(todayAsDisplayDate());
if (!iso || (todayIso && iso > todayIso)) {
setError(t("inMemory.invalidDate"));
return null;
}
setError(null);
return iso;
}
function activate() {
const iso = validIso();
if (!iso) return;
Alert.alert(t("inMemory.confirmTitle", { name: petName }), t("inMemory.confirmDescription"), [
{ text: t("inMemory.cancel"), style: "cancel" },
{ text: t("inMemory.confirm"), onPress: () => mutation.mutate({ petId, deceasedAt: iso }) },
]);
}
function saveDate() {
const iso = validIso();
if (iso && iso !== savedIso) mutation.mutate({ petId, deceasedAt: iso });
}
return (
<View style={styles.section}>
<View style={styles.headingRow}>
<Ribbon size={16} color={colors.ink} />
<Text style={styles.heading}>{t("inMemory.settingsHeading")}</Text>
</View>
<Text style={styles.hint}>
{isDeceased ? t("inMemory.activeHint", { name: petName }) : t("inMemory.settingsHint")}
</Text>
<Text style={styles.label}>{t("inMemory.dateLabel")}</Text>
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
{error ? <Text style={styles.error}>{error}</Text> : null}
{isDeceased ? (
<>
<Button title={t("inMemory.saveDate")} variant="outline" disabled={mutation.isPending} onPress={saveDate} />
<Button
title={t("inMemory.remove")}
variant="outline"
disabled={mutation.isPending}
onPress={() => mutation.mutate({ petId, deceasedAt: null })}
/>
</>
) : (
<Button title={t("inMemory.activate")} variant="outline" disabled={mutation.isPending} onPress={activate} />
)}
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
section: { marginTop: 24, gap: 8 },
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
heading: { ...typography.title, fontSize: 16 },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
error: { ...typography.body, color: colors.alertRed },
});
}
@@ -0,0 +1,45 @@
import { useEffect } from "react";
import { BackHandler, KeyboardAvoidingView, Platform, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
/**
* Ebene innerhalb des Story-Editors (statt eines nativen <Modal>): Der Editor
* liegt selbst in einem modal präsentierten Screen. iOS verweigert ein weiteres
* Modal, solange noch eine Präsentation läuft (z. B. direkt beim Öffnen einer
* Text-Story) — der Texteditor erschien dann nicht und man traf nur das X.
* Als normale Ebene gibt es diesen Konflikt nicht; Safe-Area-Abstände setzt der
* Inhalt selbst. Android-Zurück-Taste schließt die Ebene.
*/
export function EditorLayer({
visible,
onRequestClose,
style,
children,
}: {
visible: boolean;
onRequestClose: () => void;
style?: StyleProp<ViewStyle>;
children: React.ReactNode;
}) {
useEffect(() => {
if (!visible) return;
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
onRequestClose();
return true;
});
return () => sub.remove();
}, [visible, onRequestClose]);
if (!visible) return null;
return (
<KeyboardAvoidingView
style={[StyleSheet.absoluteFill, styles.layer, style]}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{children}
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
layer: { zIndex: 20, elevation: 20 },
});
@@ -1,14 +1,14 @@
import { Modal, View, Text, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "lucide-react-native";
import { fontFamily } from "../../theme/tokens";
import { EditorLayer } from "./EditorLayer";
/** Dunkles Bottom-Sheet für die Auswahlfenster des Story-Editors (Markieren, GIF, Sticker). */
/** Dunkles Bottom-Sheet für die Auswahlfenster des Story-Editors (Markieren, GIF, Sticker) — als Ebene, kein natives Modal (siehe EditorLayer). */
export function PickerSheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: React.ReactNode }) {
const insets = useSafeAreaInsets();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<EditorLayer visible={visible} onRequestClose={onClose} style={styles.backdrop}>
<Pressable style={styles.dismiss} onPress={onClose} accessibilityLabel={title} />
<View style={[styles.sheet, { paddingBottom: insets.bottom + 12 }]}>
<View style={styles.header}>
@@ -19,13 +19,12 @@ export function PickerSheet({ visible, title, onClose, children }: { visible: bo
</View>
{children}
</View>
</KeyboardAvoidingView>
</Modal>
</EditorLayer>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, justifyContent: "flex-end" },
backdrop: { justifyContent: "flex-end", backgroundColor: "rgba(0,0,0,0.35)" },
dismiss: { flex: 1 },
sheet: { backgroundColor: "#1c1c1c", borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: 12, paddingHorizontal: 16, maxHeight: "75%", gap: 12 },
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
@@ -143,6 +143,16 @@ export function StoryEditor({
</View>
)}
{/* Tippen auf eine freie Stelle der Story öffnet den Texteditor (wie Instagram). */}
{toolsEnabled && rect.width > 0 && (
<Pressable
style={{ position: "absolute", left: rect.x, top: rect.y, width: rect.width, height: rect.height }}
onPress={() => setPicker("text")}
accessibilityRole="button"
accessibilityLabel={t("storyEditor.addText")}
/>
)}
{overlays.map((overlay, index) => (
<EditableOverlay
key={index}
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import { Modal, View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { fontFamily } from "../../theme/tokens";
import { MAX_TEXT_LENGTH, TEXT_COLORS, TEXT_STYLES, type TextColor, type TextFont, type TextStyle } from "../../lib/story-overlays";
import { textAppearance } from "./OverlayContent";
import { EditorLayer } from "./EditorLayer";
export type TextDraft = { text: string; color: TextColor; style: TextStyle; font: TextFont };
@@ -26,6 +28,7 @@ export function TextEditorModal({
onCancel: () => void;
}) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [draft, setDraft] = useState<TextDraft>(initial ?? DEFAULT_DRAFT);
// Beim Öffnen mit dem bearbeiteten Overlay bzw. leer starten.
@@ -38,8 +41,7 @@ export function TextEditorModal({
const nextStyle = TEXT_STYLES[(TEXT_STYLES.indexOf(draft.style) + 1) % TEXT_STYLES.length];
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel} statusBarTranslucent>
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<EditorLayer visible={visible} onRequestClose={onCancel} style={[styles.backdrop, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 12 }]}>
<View style={styles.topBar}>
<Pressable onPress={onCancel} hitSlop={12} accessibilityRole="button">
<Text style={styles.topButton}>{t("common.cancel")}</Text>
@@ -98,13 +100,12 @@ export function TextEditorModal({
/>
))}
</View>
</KeyboardAvoidingView>
</Modal>
</EditorLayer>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.7)", paddingTop: 48, paddingBottom: 16 },
backdrop: { backgroundColor: "rgba(0,0,0,0.75)" },
topBar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16 },
topButton: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 16 },
done: { color: "#f97316" },
@@ -0,0 +1,39 @@
import { useEffect, useRef } from "react";
import * as Notifications from "expo-notifications";
import { useRouter } from "expo-router";
import { useActivePet } from "../context/ActivePetContext";
import { notificationRoute, targetFromPushData } from "../lib/notification-route";
/**
* Antippen einer Push-Mitteilung führt direkt zum Ziel (Gespräch, Beitrag,
* Story, Profil, Gesundheit) — vorher öffnete ein Tipp nur die App.
* useLastNotificationResponse deckt laufende App und Kaltstart ab.
*
* Gilt die Mitteilung für ein anderes eigenes Tier, wird vorher dorthin
* gewechselt (Nachrichten und Gesundheit hängen am aktiven Tier).
* Läuft im (app)-Layout, also erst nach Login und mit geladenen Tieren.
*/
export function useNotificationTapRouting(): void {
const response = Notifications.useLastNotificationResponse();
const router = useRouter();
const { activePetId, setActivePetId, pets, isPending } = useActivePet();
const handledId = useRef<string | null>(null);
useEffect(() => {
if (!response || isPending) return;
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
const id = response.notification.request.identifier;
if (handledId.current === id) return;
handledId.current = id;
const target = targetFromPushData(response.notification.request.content.data as Record<string, unknown> | undefined);
Notifications.clearLastNotificationResponse();
if (!target) return;
if (target.recipientPetId && target.recipientPetId !== activePetId && pets.some((p) => p.id === target.recipientPetId)) {
setActivePetId(target.recipientPetId);
}
const route = notificationRoute(target, Date.now());
if (route) router.push(route);
}, [response, isPending, activePetId, pets, setActivePetId, router]);
}
+23 -1
View File
@@ -213,7 +213,9 @@
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!",
"storyMention": "hat dich in einer Story markiert.",
"storyComment": "hat deine Story kommentiert.",
"storyReaction": "hat deiner Story eine Pfote gegeben."
"storyReaction": "hat deiner Story eine Pfote gegeben.",
"unknown": "Neue Mitteilung.",
"petDeceased": "ist leider verstorben. In Erinnerung 🕯️"
},
"pets": {
"empty": "Noch keine Tiere angelegt.",
@@ -484,5 +486,25 @@
"limitOverlays": "Höchstens {{max}} Elemente pro Story.",
"limitMentions": "Höchstens {{max}} Tiere pro Story markieren.",
"limitGifs": "Höchstens {{max}} GIFs pro Story."
},
"inMemory": {
"label": "In Erinnerung",
"tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen",
"banner": "In 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",
"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",
"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."
}
}
+23 -1
View File
@@ -213,7 +213,9 @@
"referralRankUp": "You reached a new referral rank!",
"storyMention": "tagged you in a story.",
"storyComment": "commented on your story.",
"storyReaction": "gave your story a paw."
"storyReaction": "gave your story a paw.",
"unknown": "New notification.",
"petDeceased": "has sadly passed away. In memory 🕯️"
},
"pets": {
"empty": "No pets added yet.",
@@ -484,5 +486,25 @@
"limitOverlays": "At most {{max}} items per story.",
"limitMentions": "Tag at most {{max}} pets per story.",
"limitGifs": "At most {{max}} GIFs per story."
},
"inMemory": {
"label": "In memory",
"tooltip": "In memory – this pet has crossed the rainbow bridge",
"banner": "In 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",
"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",
"invalidDate": "Please enter a valid date that is not in the future."
}
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { notificationRoute, targetFromPushData } from "./notification-route";
const NOW = new Date("2026-10-04T12:00:00Z").getTime();
describe("notificationRoute", () => {
it("opens the conversation for messages", () => {
expect(notificationRoute({ type: "MESSAGE", conversationId: "c1" }, NOW)).toBe("/messages/c1");
expect(notificationRoute({ type: "MESSAGE" }, NOW)).toBe("/messages");
});
it("opens the post for reactions, comments, mentions and paw-backs", () => {
for (const type of ["REACTION", "COMMENT", "MENTION", "PAW_BACK"]) {
expect(notificationRoute({ type, postId: "p1" }, NOW)).toBe("/posts/p1/comments");
}
});
it("opens the actor's profile for follows, suggestions, referrals and memorials", () => {
for (const type of ["FOLLOW", "PET_SUGGESTION", "REFERRAL_JOINED", "PET_DECEASED"]) {
expect(notificationRoute({ type, actorPetId: "a1" }, NOW)).toBe("/pets/a1");
}
expect(notificationRoute({ type: "REFERRAL_RANK_UP" }, NOW)).toBe("/invite");
});
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");
}
});
it("opens the tagged story directly, at the right story", () => {
const route = notificationRoute({ type: "STORY_MENTION", recipientPetId: "me", actorPetId: "tagger", storyId: "s1" }, NOW)!;
expect(route.startsWith("/story-viewer?")).toBe(true);
const params = new URLSearchParams(route.split("?")[1]);
expect(params.get("petId")).toBe("tagger");
expect(params.get("storyId")).toBe("s1");
});
it("comments and paws on the own story open the own story", () => {
const route = notificationRoute({ type: "STORY_COMMENT", recipientPetId: "me", actorPetId: "fan", storyId: "s2" }, NOW)!;
expect(new URLSearchParams(route.split("?")[1]).get("petId")).toBe("me");
});
it("falls back to the profile when someone else's story has expired", () => {
expect(
notificationRoute(
{ type: "STORY_MENTION", recipientPetId: "me", storyId: "s1", storyPetId: "tagger", storyExpiresAt: "2026-10-03T00:00:00Z" },
NOW
)
).toBe("/pets/tagger");
});
it("returns null for unknown types", () => {
expect(notificationRoute({ type: "SOMETHING_NEW" }, NOW)).toBeNull();
});
});
describe("targetFromPushData", () => {
it("reads the server's push payload and treats empty strings as missing", () => {
expect(targetFromPushData({ type: "MESSAGE", petId: "me", actorPetId: "a", postId: "", storyId: "", conversationId: "c1" })).toEqual({
type: "MESSAGE",
recipientPetId: "me",
actorPetId: "a",
postId: null,
storyId: null,
conversationId: "c1",
});
expect(targetFromPushData(undefined)).toBeNull();
expect(targetFromPushData({ foo: 1 })).toBeNull();
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* Wohin führt eine Mitteilung? Eine Regel für die Mitteilungsliste und für das
* Antippen einer Push-Mitteilung (Tester-Wunsch 04.10.: „Benachrichtigung bringt
* dich auf kürzestem Weg dorthin, wo es wichtig ist“).
*
* Push-Daten kommen von src/lib/notify.ts (Server): type, petId (Empfänger),
* actorPetId, postId, storyId, conversationId — fehlende Felder als "".
*/
export type NotificationTarget = {
type: string;
/** Empfänger-Tier (eigenes Tier) */
recipientPetId?: string | null;
actorPetId?: string | null;
postId?: string | null;
conversationId?: string | null;
storyId?: string | null;
/** Tier, dem die Story gehört — aus der Liste bekannt, beim Push hergeleitet */
storyPetId?: string | null;
storyPetName?: string | null;
storyPetAvatarKey?: string | null;
/** Ablauf der Story, falls bekannt (Liste); beim Push unbekannt → Viewer prüft selbst */
storyExpiresAt?: string | Date | null;
};
const STORY_TYPES = new Set(["STORY_MENTION", "STORY_COMMENT", "STORY_REACTION"]);
export function notificationRoute(target: NotificationTarget, now: number): string | null {
const { type, recipientPetId, actorPetId, postId, conversationId } = target;
if (STORY_TYPES.has(type) && target.storyId) {
// Markierung: Story gehört dem markierenden Tier; Kommentar/Pfote: der eigenen.
const storyPetId = target.storyPetId || (type === "STORY_MENTION" ? actorPetId : recipientPetId);
if (!storyPetId) return null;
const isOwnStory = storyPetId === recipientPetId;
const expired = target.storyExpiresAt ? new Date(target.storyExpiresAt).getTime() <= now : false;
if (expired && !isOwnStory) return `/pets/${storyPetId}`;
const params = new URLSearchParams({ petId: storyPetId, storyId: target.storyId });
if (target.storyPetName) params.set("petName", target.storyPetName);
if (target.storyPetAvatarKey) params.set("petAvatarKey", target.storyPetAvatarKey);
return `/story-viewer?${params.toString()}`;
}
switch (type) {
case "MESSAGE":
return conversationId ? `/messages/${conversationId}` : "/messages";
case "REACTION":
case "COMMENT":
case "MENTION":
case "PAW_BACK":
return postId ? `/posts/${postId}/comments` : null;
case "FOLLOW":
case "PET_SUGGESTION":
case "REFERRAL_JOINED":
case "PET_DECEASED":
return actorPetId ? `/pets/${actorPetId}` : null;
case "REFERRAL_RANK_UP":
return "/invite";
case "BIRTHDAY":
case "ADOPTION_DAY":
case "VACCINE_DUE":
return recipientPetId ? `/pets/${recipientPetId}/health` : null;
default:
return null;
}
}
/** Push-Daten (alles Strings, leer = fehlt) → Ziel. */
export function targetFromPushData(data: Record<string, unknown> | null | undefined): NotificationTarget | null {
if (!data || typeof data.type !== "string") return null;
const str = (key: string) => (typeof data[key] === "string" && data[key] ? (data[key] as string) : null);
return {
type: data.type,
recipientPetId: str("petId"),
actorPetId: str("actorPetId"),
postId: str("postId"),
storyId: str("storyId"),
conversationId: str("conversationId"),
};
}
+5
View File
@@ -270,6 +270,10 @@ model Pet {
followerVisibility FollowerVisibility @default(EVERYONE) // who can view this pet's followers/following lists (Gitea #33)
birthday DateTime? @db.Date // actual birthday for anniversary notifications
adoptedAt DateTime? @db.Date // adoption date for anniversary notifications
// Gedenkmodus „In Erinnerung“ (Roadmap 04.10.): vom Besitzer gesetzt, rückgängig
// machbar. Verstorbene Tiere bekommen keine Geburtstags-/Impf-Mitteilungen und
// werden nicht vorgeschlagen; Abzeichen + Banner in Web und App.
deceasedAt DateTime? @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -875,6 +879,7 @@ enum NotificationType {
STORY_MENTION // Tier wurde in einer Story markiert (storyId gesetzt)
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)
}
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
+2
View File
@@ -261,6 +261,7 @@ interface MockNotificationClient {
findUnique: MockFn;
count: MockFn;
updateMany: MockFn;
createMany: MockFn;
}
interface MockWeightLogClient {
@@ -642,6 +643,7 @@ export function createMockPrisma(): MockPrismaClient {
findUnique: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
weightLog: {
findMany: vi.fn(),
+19
View File
@@ -61,6 +61,25 @@ describe("notifications.list", () => {
expect(result).toEqual({ notifications: [], nextCursor: null });
});
it("adds the conversation to message notifications so a tap opens the chat", async () => {
const { caller, mockPrisma } = await createNotificationsCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "my-pet", ownerId: "owner-1" });
mockPrisma.notification.findMany.mockResolvedValue([
{ id: "n1", type: "MESSAGE", recipientPetId: "my-pet", actorPetId: "a-friend", createdAt: new Date() },
{ id: "n2", type: "FOLLOW", recipientPetId: "my-pet", actorPetId: "a-friend", createdAt: new Date() },
]);
// Gespräch ist sortiert gespeichert (petAId < petBId)
mockPrisma.conversation.findMany.mockResolvedValue([{ id: "conv-1", petAId: "a-friend", petBId: "my-pet" }]);
const result = await caller.notifications.list({ petId: "my-pet" });
expect(result.notifications.map((n: { id: string; conversationId: string | null }) => [n.id, n.conversationId])).toEqual([
["n1", "conv-1"],
["n2", null],
]);
expect(mockPrisma.conversation.findMany.mock.calls[0][0].where.OR).toEqual([{ petAId: "a-friend", petBId: "my-pet" }]);
});
});
describe("notifications.unreadCount", () => {
+57
View File
@@ -296,3 +296,60 @@ describe("Ownership assertion — non-owner cannot update", () => {
expect(mockPrisma.pet.update).not.toHaveBeenCalled();
});
});
describe("setDeceased: Gedenkmodus „In Erinnerung“", () => {
let mockPrisma: ReturnType<typeof createMockPrisma>;
beforeEach(() => {
mockPrisma = createMockPrisma();
vi.clearAllMocks();
});
it("sets the date and quietly notifies follower pets of other owners once", async () => {
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, deceasedAt: null });
mockPrisma.pet.update.mockResolvedValue({});
mockPrisma.follow.findMany.mockResolvedValue([{ followerPetId: "f1" }, { followerPetId: "f2" }]);
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
await caller.setDeceased({ petId: PET_ID, deceasedAt: "2026-10-01" });
expect(mockPrisma.pet.update).toHaveBeenCalledWith({ where: { id: PET_ID }, data: { deceasedAt: new Date("2026-10-01") } });
expect(mockPrisma.follow.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { followeePetId: PET_ID, follower: { ownerId: { not: TEST_OWNER_ID } } } })
);
expect(mockPrisma.notification.createMany).toHaveBeenCalledWith({
data: [
{ recipientPetId: "f1", type: "PET_DECEASED", actorPetId: PET_ID },
{ recipientPetId: "f2", type: "PET_DECEASED", actorPetId: PET_ID },
],
});
});
it("does not notify again when only the date is corrected", async () => {
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, deceasedAt: new Date("2026-10-01") });
mockPrisma.pet.update.mockResolvedValue({});
await createTestCaller(TEST_OWNER_ID, mockPrisma).setDeceased({ petId: PET_ID, deceasedAt: "2026-09-30" });
expect(mockPrisma.notification.createMany).not.toHaveBeenCalled();
});
it("can be undone without notifications", async () => {
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, deceasedAt: new Date("2026-10-01") });
mockPrisma.pet.update.mockResolvedValue({});
await createTestCaller(TEST_OWNER_ID, mockPrisma).setDeceased({ petId: PET_ID, deceasedAt: null });
expect(mockPrisma.pet.update).toHaveBeenCalledWith({ where: { id: PET_ID }, data: { deceasedAt: null } });
expect(mockPrisma.notification.createMany).not.toHaveBeenCalled();
});
it("rejects non-owners and future dates", async () => {
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, ownerId: OTHER_OWNER_ID, deceasedAt: null });
await expect(createTestCaller(TEST_OWNER_ID, mockPrisma).setDeceased({ petId: PET_ID, deceasedAt: "2026-10-01" })).rejects.toThrow(TRPCError);
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, deceasedAt: null });
await expect(createTestCaller(TEST_OWNER_ID, mockPrisma).setDeceased({ petId: PET_ID, deceasedAt: "2999-01-01" })).rejects.toThrow(/Zukunft/);
expect(mockPrisma.pet.update).not.toHaveBeenCalled();
});
});
+14 -1
View File
@@ -45,7 +45,7 @@ import {
storyMediaSize,
storyOverlaysSchema,
} from "@/lib/story-overlays";
import { filterMentionablePetIds } from "@/lib/story-mentions";
import { filterMentionablePetIds, petsOfOtherOwners } from "@/lib/story-mentions";
import { isStoryEditorEnabled, setStoryEditorEnabled } from "@/lib/feature-flags";
function createCaller() {
@@ -152,6 +152,19 @@ describe("filterMentionablePetIds — „Wer darf die Follower sehen?“ regelt
});
});
describe("petsOfOtherOwners", () => {
it("skips the creator's own pets when notifying", async () => {
const p = createMockPrisma();
p.pet.findMany.mockResolvedValue([
{ id: "me", ownerId: "owner-1" },
{ id: "my-other-pet", ownerId: "owner-1" },
{ id: "friend", ownerId: "owner-2" },
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(await petsOfOtherOwners(p as any, "me", ["my-other-pet", "friend"])).toEqual(["friend"]);
});
});
describe("stories.create with overlays", () => {
it("stores overlays + media size, drops disallowed mentions and notifies only allowed pets", async () => {
const { caller, mockPrisma } = createCaller();
+55 -11
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 } from "lucide-react";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award, Syringe, PawPrint, Ribbon } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
@@ -16,8 +16,11 @@ import type { PostCardPost } from "@/components/feed/PostCard";
import { useLocale, useTranslations } from "next-intl";
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";
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";
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"]);
function notificationIcon(type: NotificationType) {
switch (type) {
@@ -36,6 +39,7 @@ function notificationIcon(type: NotificationType) {
case "STORY_MENTION": return <AtSign className="h-4 w-4 text-orange-500" />;
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" />;
}
}
@@ -49,7 +53,7 @@ export default function NotificationsPage() {
const locale = useLocale();
const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt);
const { data, isLoading } = useQuery({
const { data, isLoading, dataUpdatedAt } = useQuery({
...trpc.notifications.list.queryOptions({ petId: activePetId ?? "", limit: 30 }),
enabled: !!activePetId,
});
@@ -59,6 +63,9 @@ export default function NotificationsPage() {
// swapped out the notifications page in the background, so closing the
// dialog stranded the user on that profile instead of back on this list.
const [openPostId, setOpenPostId] = useState<string | null>(null);
// Story-Mitteilungen öffnen die Story direkt hier als Overlay (an der richtigen Story),
// statt über das Tierprofil — Tester-Feedback 04.10.
const [openStory, setOpenStory] = useState<{ petId: string; petName: string; petAvatarKey: string | null; storyId: string } | null>(null);
const [selectedPost, setSelectedPost] = useState<PostCardPost | null>(null);
const { data: fetchedPost } = useQuery({
@@ -100,14 +107,19 @@ export default function NotificationsPage() {
case "FOLLOW":
case "PET_SUGGESTION":
case "REFERRAL_JOINED":
case "PET_DECEASED":
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
case "REFERRAL_RANK_UP":
return "/einladen";
// Story-Mitteilungen → Tierprofil (dort läuft die Story über den Ring)
// Nachricht → direkt ins Gespräch (conversationId liefert notifications.list)
case "MESSAGE":
return notif.conversationId ? `/messages/${notif.conversationId}` : "/messages";
// Story-Mitteilungen: läuft die Story noch (oder ist es die eigene, 7 Tage sichtbar),
// öffnet getStoryTarget() sie direkt; sonst bleibt nur das Tierprofil.
case "STORY_MENTION":
case "STORY_COMMENT":
case "STORY_REACTION":
return notif.story ? `/pets/${notif.story.petId}` : null;
return notif.story && !getStoryTarget(notif) ? `/pets/${notif.story.petId}` : null;
case "BIRTHDAY":
case "ADOPTION_DAY":
case "VACCINE_DUE":
@@ -117,6 +129,21 @@ export default function NotificationsPage() {
}
}
function getStoryTarget(notif: (typeof notifications)[number]) {
if (!notif.story) return null;
if (notif.type !== "STORY_MENTION" && notif.type !== "STORY_COMMENT" && notif.type !== "STORY_REACTION") return null;
const isOwnStory = notif.story.petId === notif.recipientPetId;
// Bezugszeit = Ladezeitpunkt der Liste (kein Date.now() im Rendern)
const isRunning = new Date(notif.story.expiresAt).getTime() > dataUpdatedAt;
if (!isRunning && !isOwnStory) return null;
return {
petId: notif.story.petId,
petName: notif.story.pet.name,
petAvatarKey: notif.story.pet.avatarKey,
storyId: notif.story.id,
};
}
// Post-related notifications — opened in-place via PostDetailDialog instead.
function getPostId(notif: (typeof notifications)[number]): string | null {
switch (notif.type) {
@@ -171,17 +198,20 @@ export default function NotificationsPage() {
: null;
const href = getHref(notif);
const postId = getPostId(notif);
const isClickable = !!href || !!postId;
const storyTarget = getStoryTarget(notif);
const isClickable = !!href || !!postId || !!storyTarget;
return (
<li
key={notif.id}
onClick={
href
? () => router.push(href)
: postId
? () => setOpenPostId(postId)
: undefined
storyTarget
? () => setOpenStory(storyTarget)
: href
? () => router.push(href)
: postId
? () => setOpenPostId(postId)
: undefined
}
className={`flex items-start gap-3 p-3 rounded-lg transition-colors ${
!notif.read ? "bg-orange-500/5" : "hover:bg-muted/50"
@@ -248,9 +278,11 @@ export default function NotificationsPage() {
{notif.type === "PET_SUGGESTION" && t("typePetSuggestion")}
{notif.type === "REFERRAL_JOINED" && t("typeReferralJoined")}
{notif.type === "REFERRAL_RANK_UP" && t("typeReferralRankUp")}
{!KNOWN_TYPES.has(notif.type) && t("typeUnknown")}
{notif.type === "STORY_MENTION" && t("typeStoryMention")}
{notif.type === "STORY_COMMENT" && t("typeStoryComment")}
{notif.type === "STORY_REACTION" && t("typeStoryReaction")}
{notif.type === "PET_DECEASED" && t("typePetDeceased")}
{notif.type === "VACCINE_DUE" && notif.vaccine?.nextDueDate &&
t("typeVaccineDue", {
name: notif.vaccine.name,
@@ -293,6 +325,18 @@ export default function NotificationsPage() {
onClose={closePostDialog}
onDeleted={closePostDialog}
/>
{openStory && (
<StoryViewer
petId={openStory.petId}
petName={openStory.petName}
petAvatarKey={openStory.petAvatarKey}
initialStoryId={openStory.storyId}
open
onOpenChange={(isOpen) => {
if (!isOpen) setOpenStory(null);
}}
/>
)}
</>
);
}
+5
View File
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { PetForm } from "@/components/pet/PetForm";
import { MemorialSettings } from "@/components/pet/MemorialSettings";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -279,6 +280,10 @@ export default function EditPetPage() {
)}
</div>
{/* Gedenkmodus „In Erinnerung“ */}
<Separator className="my-8" />
<MemorialSettings petId={pet.id} petName={pet.name} deceasedAt={pet.deceasedAt} />
{/* Destructive zone */}
<Separator className="my-8" />
<div className="flex flex-col gap-2">
+14 -1
View File
@@ -2,7 +2,8 @@ import { notFound } from "next/navigation";
import { formatPetName } from "@/lib/pet-display-name";
import Link from "next/link";
import { currentUser } from "@clerk/nextjs/server";
import { getTranslations } from "next-intl/server";
import { getFormatter, getTranslations } from "next-intl/server";
import { Ribbon } from "lucide-react";
import { createTRPCCaller } from "@/trpc/server";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
@@ -34,6 +35,8 @@ export default async function PetProfilePage({ params }: PageProps) {
const t = await getTranslations("PetProfile");
const tFounding = await getTranslations("FoundingMember");
const tMemory = await getTranslations("InMemory");
const format = await getFormatter();
const trpc = await createTRPCCaller();
let pet;
@@ -77,6 +80,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}
isVerified={pet.isVerified}
isFoundingMember={pet.isFoundingMember}
sponsor={pet.owner}
@@ -85,6 +89,15 @@ export default async function PetProfilePage({ params }: PageProps) {
verifiedLabel={t("verified")}
/>
</h1>
{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 />
{tMemory("banner", {
name: pet.name,
date: format.dateTime(new Date(pet.deceasedAt), { dateStyle: "long", timeZone: "UTC" }),
})}
</p>
)}
{pet.isFoundingMember && (
<p className="text-xs text-emerald-700 dark:text-emerald-400">{tFounding("tooltip")}</p>
)}
+2
View File
@@ -26,6 +26,7 @@ export async function GET(req: Request) {
AND EXTRACT(MONTH FROM birthday) = ${month}
AND EXTRACT(DAY FROM birthday) = ${day}
AND birthday <= NOW() - INTERVAL '1 year'
AND "deceasedAt" IS NULL
`;
// Find all pets whose adoptedAt matches today and was at least 1 year ago
@@ -35,6 +36,7 @@ export async function GET(req: Request) {
AND EXTRACT(MONTH FROM "adoptedAt") = ${month}
AND EXTRACT(DAY FROM "adoptedAt") = ${day}
AND "adoptedAt" <= NOW() - INTERVAL '1 year'
AND "deceasedAt" IS NULL
`;
let created = 0;
+2 -1
View File
@@ -48,7 +48,7 @@ export async function GET(req: Request) {
const suggestionCooldownStart = new Date(Date.now() - SUGGESTION_COOLDOWN_MS);
const pets = await prisma.pet.findMany({
where: { owner: { petSuggestionsEnabled: true } },
where: { owner: { petSuggestionsEnabled: true }, deceasedAt: null },
select: { id: true, speciesId: true, breedId: true, ownerId: true },
});
@@ -89,6 +89,7 @@ export async function GET(req: Request) {
id: { notIn: Array.from(excludedPetIds) },
speciesId: targetSpeciesId,
owner: { shadowban: null },
deceasedAt: null, // verstorbene Tiere nicht vorschlagen
...(spiderExclusionId ? { NOT: { speciesId: spiderExclusionId } } : {}),
},
select: { id: true, breedId: true, _count: { select: { followers: true } } },
+3 -2
View File
@@ -5,7 +5,7 @@ import { redis } from "@/lib/redis";
import { fanOutPost } from "@/lib/feed-helpers";
import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits";
import { maybeQualifyReferral } from "@/lib/referrals";
import { notifyStoryMentions } from "@/lib/story-mentions";
import { notifyStoryMentions, petsOfOtherOwners } from "@/lib/story-mentions";
/**
* POST /api/webhooks/mux
@@ -130,7 +130,8 @@ export async function POST(req: NextRequest) {
// jetzt ist die Story sichtbar → jetzt benachrichtigen.
if (!overStoryLimit) {
const mentions = await prisma.storyMention.findMany({ where: { storyId: story.id }, select: { petId: true } });
notifyStoryMentions(prisma, story.id, story.petId, mentions.map((m) => m.petId));
const recipients = await petsOfOtherOwners(prisma, story.petId, mentions.map((m) => m.petId));
notifyStoryMentions(prisma, story.id, story.petId, recipients);
}
break;
}
+2 -1
View File
@@ -81,6 +81,7 @@ type PostPet = {
name: string;
avatarKey: string | null;
isVerified?: boolean;
deceasedAt?: Date | string | null;
isFoundingMember?: boolean;
// Sponsor fields only come with petCardSelect (feed/explore); posts.byPetId
// uses petIdentitySelect, whose owner carries just the referral rank.
@@ -256,7 +257,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 isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
<PetIdentityBadges 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
@@ -37,6 +37,7 @@ type Pet = {
name: string;
avatarKey: string | null;
isVerified: boolean;
deceasedAt?: Date | string | null;
species: { name: string };
};
@@ -110,7 +111,7 @@ export function MobileNav({ pets }: MobileNavProps) {
/>
<span className="font-medium text-foreground truncate max-w-[120px] flex items-center gap-1">
{formatPetName(activePet)}
<PetIdentityBadges isVerified={activePet.isVerified} size="sm" />
<PetIdentityBadges isDeceased={!!activePet.deceasedAt} isVerified={activePet.isVerified} size="sm" />
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</DropdownMenuTrigger>
@@ -128,7 +129,7 @@ export function MobileNav({ pets }: MobileNavProps) {
/>
<span className="flex-1 text-sm truncate flex items-center gap-1">
{formatPetName(pet)}
<PetIdentityBadges isVerified={pet.isVerified} size="sm" />
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} size="sm" />
</span>
<Badge variant="secondary" className="text-xs shrink-0">
{pet.species.name}
+3 -2
View File
@@ -42,6 +42,7 @@ type Pet = {
name: string;
avatarKey: string | null;
isVerified: boolean;
deceasedAt?: Date | string | null;
species: { name: string };
};
@@ -152,7 +153,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 isVerified={activePet.isVerified} />
<PetIdentityBadges isDeceased={!!activePet.deceasedAt} isVerified={activePet.isVerified} />
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
</>
@@ -172,7 +173,7 @@ export function Sidebar({ pets }: SidebarProps) {
/>
<span className="flex-1 text-sm truncate flex items-center gap-1">
{formatPetName(pet)}
<PetIdentityBadges isVerified={pet.isVerified} size="sm" />
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} size="sm" />
</span>
<Badge variant="secondary" className="text-xs shrink-0">
{pet.species.name}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Ribbon } 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
type Props = {
petId: string;
petName: string;
deceasedAt: Date | string | null;
};
/** YYYY-MM-DD eines @db.Date-Werts (kommt als UTC-Mitternacht). */
function toDateInput(value: Date | string | null): string {
return value ? new Date(value).toISOString().slice(0, 10) : "";
}
/**
* Gedenkmodus „In Erinnerung“ in „Profil bearbeiten“: Datum setzen (mit
* Bestätigung, Follower werden einmalig still informiert), korrigieren oder
* zurücknehmen. Server: pets.setDeceased.
*/
export function MemorialSettings({ petId, petName, deceasedAt }: Props) {
const t = useTranslations("InMemory");
const trpc = useTRPC();
const queryClient = useQueryClient();
const router = useRouter();
const isDeceased = deceasedAt != null;
const [today] = useState(() => new Date().toISOString().slice(0, 10));
const [date, setDate] = useState(() => toDateInput(deceasedAt) || today);
const [confirmOpen, setConfirmOpen] = useState(false);
const mutation = useMutation({
...trpc.pets.setDeceased.mutationOptions(),
onSuccess: async (_data, variables) => {
setConfirmOpen(false);
toast.success(variables.deceasedAt ? t("saved") : t("removed"));
await queryClient.invalidateQueries();
router.refresh();
},
onError: () => toast.error(t("saveFailed")),
});
return (
<div className="space-y-3">
<h2 className="flex items-center gap-1.5 text-sm font-medium">
<Ribbon className="h-4 w-4" aria-hidden />
{t("settingsHeading")}
</h2>
<p className="text-xs text-muted-foreground">
{isDeceased ? t("activeHint", { name: petName }) : t("settingsHint")}
</p>
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label htmlFor="deceased-at" className="text-xs">
{t("dateLabel")}
</Label>
<Input
id="deceased-at"
type="date"
max={today}
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-44"
/>
</div>
{isDeceased ? (
<>
<Button
variant="outline"
size="sm"
disabled={mutation.isPending || !date || date === toDateInput(deceasedAt)}
onClick={() => mutation.mutate({ petId, deceasedAt: date })}
>
{t("saveDate")}
</Button>
<Button
variant="ghost"
size="sm"
disabled={mutation.isPending}
onClick={() => mutation.mutate({ petId, deceasedAt: null })}
>
{t("remove")}
</Button>
</>
) : (
<Button variant="outline" size="sm" disabled={!date} onClick={() => setConfirmOpen(true)}>
{t("activate")}
</Button>
)}
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-base font-semibold">{t("confirmTitle", { name: petName })}</DialogTitle>
<DialogDescription>{t("confirmDescription")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="outline" onClick={() => setConfirmOpen(false)} />}>
{t("cancel")}
</DialogClose>
<Button disabled={mutation.isPending} onClick={() => mutation.mutate({ petId, deceasedAt: date })}>
{t("confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { Ribbon } 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;
/** Gedenkmodus „In Erinnerung“ — das Tier ist verstorben (Pet.deceasedAt). */
export function InMemoryBadge({ size = "md" }: { size?: keyof typeof ICON_SIZE }) {
const t = useTranslations("InMemory");
return (
<span title={t("tooltip")} className="inline-flex shrink-0">
<Ribbon className={cn(ICON_SIZE[size], "fill-foreground text-foreground")} aria-label={t("label")} />
</span>
);
}
+6 -1
View File
@@ -3,6 +3,7 @@ import { isActiveSponsor } from "@/lib/sponsor";
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";
type SponsorWindow = {
// Optional: a pet identity's owner (petIdentitySelect) carries only the referral rank.
@@ -14,6 +15,8 @@ type Props = {
isVerified?: boolean;
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
isFoundingMember?: boolean;
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
isDeceased?: 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. */
@@ -44,6 +47,7 @@ function isSponsorActive(sponsor: SponsorWindow): boolean {
export function PetIdentityBadges({
isVerified,
isFoundingMember,
isDeceased,
sponsor,
referral,
size = "md",
@@ -53,10 +57,11 @@ export function PetIdentityBadges({
const iconClass = ICON_SIZE[size];
const showSponsor = sponsor != null && isSponsorActive(sponsor);
if (!isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null;
if (!isDeceased && !isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null;
return (
<>
{isDeceased && <InMemoryBadge size={size} />}
{isVerified && (
<BadgeCheck
className={cn(iconClass, "shrink-0 fill-blue-500 text-background")}
+22 -4
View File
@@ -12,6 +12,12 @@ interface NotifyParams {
storyId?: string;
}
/**
* Nur für die Push-Daten (nicht in der Notification-Tabelle): Ziele, die die
* App beim Antippen der Push-Mitteilung braucht, z. B. das Gespräch einer Nachricht.
*/
export type PushExtras = { conversationId?: string };
/**
* Fire-and-forget notification create — never blocks or fails the caller.
* Use for user-triggered notifications (follow, reaction, comment, repost,
@@ -22,10 +28,11 @@ interface NotifyParams {
*/
export function notify(
prisma: Pick<PrismaClient, "notification" | "pet" | "owner">,
params: NotifyParams
params: NotifyParams,
extras?: PushExtras
): void {
prisma.notification.create({ data: params }).catch((err) => console.error("[notify] failed", err));
sendPushForNotification(prisma, params).catch((err) => console.error("[notify] push failed", err));
sendPushForNotification(prisma, params, undefined, extras).catch((err) => console.error("[notify] push failed", err));
}
const PUSH_COPY: Record<
@@ -46,6 +53,7 @@ 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.` },
STORY_REACTION: { title: "Neue Pfote", genericBody: "Jemand hat deiner Story eine Pfote gegeben.", actorBody: (a) => `${a} hat deiner Story eine Pfote gegeben.` },
};
@@ -64,7 +72,8 @@ const PUSH_COPY: Record<
export async function sendPushForNotification(
prisma: Pick<PrismaClient, "pet" | "owner">,
params: NotifyParams,
bodyOverride?: string
bodyOverride?: string,
extras?: PushExtras
): Promise<void> {
const recipientPet = await prisma.pet.findUnique({
where: { id: params.recipientPetId },
@@ -86,6 +95,15 @@ export async function sendPushForNotification(
await sendExpoPushNotification(owner.expoPushToken, {
title: copy.title,
body: bodyOverride ?? (actorPet ? copy.actorBody(actorPet.name) : copy.genericBody),
data: { type: params.type, postId: params.postId ?? "", commentId: params.commentId ?? "" },
// Ziele für das Antippen der Push-Mitteilung (App: src/lib/notification-route.ts).
data: {
type: params.type,
postId: params.postId ?? "",
commentId: params.commentId ?? "",
petId: params.recipientPetId,
actorPetId: params.actorPetId ?? "",
storyId: params.storyId ?? "",
conversationId: extras?.conversationId ?? "",
},
});
}
+19 -1
View File
@@ -72,7 +72,25 @@ export async function saveStoryMentions(
data: petIds.map((petId) => ({ storyId, petId })),
skipDuplicates: true,
});
if (sendNotifications) notifyStoryMentions(prisma, storyId, actorPetId, petIds);
if (sendNotifications) notifyStoryMentions(prisma, storyId, actorPetId, await petsOfOtherOwners(prisma, actorPetId, petIds));
}
/** Eigene Tiere des Erstellers markieren ist erlaubt, aber eine Mitteilung an sich selbst ist sinnlos. */
export async function petsOfOtherOwners(
prisma: Pick<PrismaClient, "pet">,
actorPetId: string,
petIds: readonly string[]
): Promise<string[]> {
if (petIds.length === 0) return [];
const pets = await prisma.pet.findMany({
where: { id: { in: [actorPetId, ...petIds] } },
select: { id: true, ownerId: true },
});
const actorOwner = pets.find((p) => p.id === actorPetId)?.ownerId;
return petIds.filter((id) => {
const owner = pets.find((p) => p.id === id)?.ownerId;
return owner !== undefined && owner !== actorOwner;
});
}
/** Fire-and-forget wie notify() selbst — darf das Erstellen der Story nie scheitern lassen. */
+1 -1
View File
@@ -83,5 +83,5 @@ export async function sendTeamMessage(
}),
]);
notify(prisma, { recipientPetId: targetPetId, type: "MESSAGE", actorPetId: teamPet.id });
notify(prisma, { recipientPetId: targetPetId, type: "MESSAGE", actorPetId: teamPet.id }, { conversationId: conversation.id });
}
+2 -1
View File
@@ -42,7 +42,8 @@ export async function sendVaccineReminders(prisma: ReminderPrisma, now = new Dat
const horizon = new Date(today.getTime() + VACCINE_REMINDER_LEAD_DAYS * DAY_MS);
const dueVaccines = await prisma.vaccine.findMany({
where: { reminderSentAt: null, nextDueDate: { gte: today, lte: horizon } },
// Verstorbene Tiere („In Erinnerung“) bekommen keine Impf-Erinnerungen mehr.
where: { reminderSentAt: null, nextDueDate: { gte: today, lte: horizon }, pet: { deceasedAt: null } },
select: { id: true, petId: true, name: true, nextDueDate: true, pet: { select: { name: true } } },
orderBy: { nextDueDate: "asc" },
take: MAX_REMINDERS_PER_RUN,
+2
View File
@@ -22,6 +22,8 @@ export const petIdentitySelect = {
name: true,
nickname: true,
avatarKey: true,
// „In Erinnerung“-Abzeichen überall neben dem Namen
deceasedAt: true,
owner: { select: referralOwnerSelect },
} satisfies Prisma.PetSelect;
+5 -5
View File
@@ -248,11 +248,11 @@ export const messagesRouter = router({
]);
// MESSAGE notification — fire-and-forget
notify(ctx.prisma, {
recipientPetId,
type: "MESSAGE",
actorPetId: input.senderPetId,
});
notify(
ctx.prisma,
{ recipientPetId, type: "MESSAGE", actorPetId: input.senderPetId },
{ conversationId: input.conversationId }
);
return message;
}),
+23 -2
View File
@@ -36,8 +36,29 @@ export const notificationsRouter = router({
});
const hasNextPage = notifications.length > input.limit;
const items = hasNextPage ? notifications.slice(0, input.limit) : notifications;
const nextCursor = hasNextPage ? items[items.length - 1].createdAt.toISOString() : null;
const page = hasNextPage ? notifications.slice(0, input.limit) : notifications;
const nextCursor = hasNextPage ? page[page.length - 1].createdAt.toISOString() : null;
// Nachrichten-Mitteilungen führen direkt ins Gespräch: Notification hat
// keine conversationId, das Gespräch ist aber über das Tier-Paar eindeutig
// (Conversation.petAId < petBId, sortiert wie in messages.getOrCreate).
const pairs = page
.filter((n) => n.type === "MESSAGE" && n.actorPetId)
.map((n) => [n.recipientPetId, n.actorPetId as string].sort());
const conversations = pairs.length
? await ctx.prisma.conversation.findMany({
where: { OR: pairs.map(([petAId, petBId]) => ({ petAId, petBId })) },
select: { id: true, petAId: true, petBId: true },
})
: [];
const conversationByPair = new Map(conversations.map((c) => [`${c.petAId}|${c.petBId}`, c.id]));
const items = page.map((n) => ({
...n,
conversationId:
n.type === "MESSAGE" && n.actorPetId
? (conversationByPair.get([n.recipientPetId, n.actorPetId].sort().join("|")) ?? null)
: null,
}));
return { notifications: items, nextCursor };
}),
+36
View File
@@ -153,6 +153,42 @@ export const petsRouter = router({
* 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
* Follower einmalig eine stille Mitteilung (PET_DECEASED, bewusst ohne Push),
* eigene Tiere des Besitzers nicht.
*/
setDeceased: protectedProcedure
.input(
z.object({
petId: z.string().min(1),
deceasedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const pet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const deceasedAt = input.deceasedAt ? new Date(input.deceasedAt) : null;
if (deceasedAt && deceasedAt.getTime() > Date.now()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Das Datum liegt in der Zukunft." });
}
await ctx.prisma.pet.update({ where: { id: input.petId }, data: { deceasedAt } });
if (deceasedAt && !pet.deceasedAt) {
const followers = await ctx.prisma.follow.findMany({
where: { followeePetId: input.petId, follower: { ownerId: { not: pet.ownerId } } },
select: { followerPetId: true },
});
if (followers.length > 0) {
await ctx.prisma.notification.createMany({
data: followers.map((f) => ({ recipientPetId: f.followerPetId, type: "PET_DECEASED" as const, actorPetId: input.petId })),
});
}
}
return { deceasedAt };
}),
update: protectedProcedure
.input(
z.object({