feat: pet-suggestion notifications, story music overlay, auth UX fixes

Three pieces of work from this session:

- Pet-suggestion notifications: a weekly cron (species/breed affinity
  heuristic) suggests pets to follow, with an owner opt-out and web/mobile
  notification UI wiring.
- Story music overlay: admin-curated royalty-free track catalog
  (/p/[secret]/music), attachable to stories on web (StoryForm/StoryViewer),
  with mute-original support for video stories. Mobile UI is deferred to
  the next native build (needs an audio-playback module) — tracked in
  mobile/PENDING-NATIVE-BUILD.md.
- Auth UX fixes: sign-in/sign-up now use KeyboardAvoidingView + ScrollView
  and a shared PasswordField with a show/hide toggle (mobile). Web's
  ActivePetContext gained clearGactivePetId(), wired to sign-out via a new
  SignOutCleanup component, fixing a false "you do not have permission to
  access this pet" error after switching accounts on the same browser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-14 21:02:49 +02:00
1 parent af0cea6d3f
commit e9bad092f2
35 files changed
+1655 -148

No files matched your search

+6 -5
View File
@@ -196,7 +196,7 @@ Key variables (see `.env.example` for the full annotated list):
| `REDIS_URL` | Your Redis instance connection string (e.g. `redis://:password@localhost:6379`) |
| `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET` | Mux Dashboard -> Settings -> API Access Tokens |
| `MUX_WEBHOOK_SECRET` | Mux Dashboard -> Settings -> Webhooks -> Signing secret |
| `CRON_SECRET` | Generate a random string; used by both cron routes |
| `CRON_SECRET` | Generate a random string; used by all cron routes |
| `ADMIN_SECRET` | UUID that gates the `/p/[secret]/` admin panel path |
| `ADMIN_OWNER_ID` | Clerk user ID bootstrapped as SUPER_ADMIN |
@@ -241,11 +241,12 @@ In Mux Dashboard -> Settings -> Webhooks, add an endpoint pointing to `https://y
### 6. Cron jobs (production only)
There is no serverless cron here — `vercel.json`'s `crons` array is inert on a self-hosted deployment. Trigger the two cron routes externally (e.g. a NAS-side crontab hitting them with a bearer token):
There is no serverless cron here — `vercel.json`'s `crons` array is inert on a self-hosted deployment. Trigger the cron routes externally (e.g. a NAS-side crontab hitting them with a bearer token):
```bash
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/anniversaries # daily 08:00
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/trim-feeds # daily
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/anniversaries # daily 08:00
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/trim-feeds # daily
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/pet-suggestions # weekly, Monday 09:00
```
### 7. Run the dev server
@@ -485,6 +486,6 @@ npx vitest # watch mode
| `asChild` prop is not available in the base-nova shadcn preset | Style the trigger element directly instead. |
| Local dev cannot exercise authenticated routes | Clerk runs with production keys and rejects non-`pawfeed.org` origins; only public pages are testable against `npm run dev`. |
| Mux webhook not required in local dev | `videos.getByPostId` polls Mux directly when status is `PROCESSING`. |
| `CRON_SECRET` must be set for both cron routes | Without it, the routes return 401 and nothing fires — there is no serverless scheduler here, an external trigger (NAS crontab) is required. |
| `CRON_SECRET` must be set for all cron routes | Without it, the routes return 401 and nothing fires — there is no serverless scheduler here, an external trigger (NAS crontab) is required. |
| Server-side requests to the app's own domain must target the DDNS hostname, not `pawfeed.org` | Nginx Proxy Manager's origin TLS certificate is only valid for `pawfeed.neodk.ipv64.de` — `pawfeed.org` works for browsers only because Cloudflare terminates TLS at the edge and doesn't strictly verify the origin cert. A plain `curl https://pawfeed.org/...` from the NAS itself (e.g. `docker/run-cron.sh`) fails with an SSL hostname mismatch; use `curl --resolve pawfeed.neodk.ipv64.de:443:127.0.0.1 https://pawfeed.neodk.ipv64.de/...` instead. |
| A 401 (not 403) from an admin tRPC procedure usually means a stale Clerk session, not a permissions bug | `protectedProcedure` throws `UNAUTHORIZED` (401) when Clerk's session is missing/expired; `assertAdmin()` throws `FORBIDDEN` (403) when the session is valid but the role is too low. A browser tab left open and backgrounded for hours can let Clerk's silent token refresh lapse — reload the page before suspecting a code regression. |
+9 -2
View File
@@ -476,7 +476,8 @@
"typeMention": "hat dich in einem Kommentar erwähnt",
"typePawBack": "hat deinen Post geteilt (Paw-Back)",
"typeBirthday": "🎂 Alles Gute zum Geburtstag! Erstelle einen Jubiläums-Post.",
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag."
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag.",
"typePetSuggestion": "könnte dir gefallen"
},
"PhotoPostForm": {
"captionPlaceholder": "Bildunterschrift schreiben…",
@@ -504,7 +505,9 @@
"videoLengthHint": "Video max. {max} Sekunden",
"videoTooLarge": "Video muss unter 500 MB sein.",
"videoTooLong": "Story-Videos dürfen höchstens {max} Sekunden lang sein.",
"videoUnreadable": "Videolänge konnte nicht gelesen werden — bitte versuch eine andere Datei."
"videoUnreadable": "Videolänge konnte nicht gelesen werden — bitte versuch eine andere Datei.",
"musicLabel": "Musik hinzufügen (optional)",
"muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen"
},
"MilestoneForm": {
"questionLabel": "Was ist der Meilenstein?",
@@ -610,6 +613,10 @@
"label": "Spinnen-Inhalte ausblenden",
"description": "Blendet Pets und Beiträge der Tierart Spinne im Feed, bei Explore und in der Suche aus."
},
"PetSuggestionsToggle": {
"label": "Tier-Vorschläge",
"description": "Erhalte gelegentlich eine Benachrichtigung zu einem Tier, das dir gefallen könnte."
},
"PetsPage": {
"title": "Meine Tiere",
"addPet": "Tier anlegen",
+9 -2
View File
@@ -476,7 +476,8 @@
"typeMention": "mentioned you in a comment",
"typePawBack": "shared your post (Paw-Back)",
"typeBirthday": "🎂 Happy Birthday! Create an anniversary post.",
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary."
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary.",
"typePetSuggestion": "might be a good match"
},
"PhotoPostForm": {
"captionPlaceholder": "Write a caption…",
@@ -504,7 +505,9 @@
"videoLengthHint": "Video up to {max} seconds",
"videoTooLarge": "Video must be under 500 MB.",
"videoTooLong": "Story videos can be at most {max} seconds long.",
"videoUnreadable": "Couldn't read the video length — please try a different file."
"videoUnreadable": "Couldn't read the video length — please try a different file.",
"musicLabel": "Add music (optional)",
"muteOriginalLabel": "Mute original audio, play this track instead"
},
"MilestoneForm": {
"questionLabel": "What's the milestone?",
@@ -610,6 +613,10 @@
"label": "Hide spider content",
"description": "Hides pets and posts of the species Spider from the feed, Explore, and search."
},
"PetSuggestionsToggle": {
"label": "Pet suggestions",
"description": "Occasionally get notified about a pet you might like."
},
"PetsPage": {
"title": "My Pets",
"addPet": "Add Pet",
+57
View File
@@ -0,0 +1,57 @@
# Pending native-build changes
Sammelstelle für Fixes/Features, die ein neues natives Modul brauchen (also
**kein** `eas update`-OTA möglich) — siehe `found.md`s Odyssee rund um
1.0.3–1.0.6, wo jedes einzeln ausgelieferte native Modul einen eigenen
Store-Build+Review-Zyklus ausgelöst hat. Statt bei jedem Fund sofort einen
Build anzustoßen, landet er hier und wird erst beim nächsten ohnehin
anstehenden Build mitgenommen.
Ein Eintrag verlässt diese Liste erst, wenn er in einem tatsächlich in der
Play Console veröffentlichten Build enthalten war (nicht schon beim
Implementieren) — `found.md` zeigt, dass ein fertig gebauter, aber nicht
hochgeladener Build (1.0.4) sonst fälschlich als "erledigt" durchgehen kann.
## Offen
- **Datums-Wheel-Picker für Geburtstag/Adoptionsdatum/Health-Formulare**
(`found.md` Neu7, gemeldet 2026-09-13). Betrifft:
`app/(app)/pets/[petId]/edit.tsx` (Geburtstag, Adoptionsdatum),
`src/components/health/WeightLogSection.tsx`,
`src/components/health/VaccineSection.tsx` (2 Datumsfelder),
`src/components/health/VetVisitSection.tsx` — aktuell alle als freies
`TextField` mit Tastatureingabe, die auf kleinen Screens vom Keyboard
verdeckt wird. Geplanter Ansatz: `@react-native-community/datetimepicker`
(natives Modul → braucht diesen Build-Slot), Konvertierung weiterhin über
`src/lib/date-format.ts` ins ISO-Format fürs Backend.
→ Der Trennzeichen-Teil des Feedbacks ("-" statt ".") ist NICHT
build-pflichtig (reine Anzeige-/Placeholder-Änderung in `date-format.ts`
+ i18n-Strings) und kann jederzeit separat per OTA raus, unabhängig von
dieser Liste.
- **Story-Musik-Overlay auf Mobile** (Feature-2-Plan "Story-Musik-Overlay",
umgesetzt 2026-09-14 für Web/Backend). Betrifft: `story-new.tsx`
(Track-Picker + Vorschau beim Erstellen), `story-viewer.tsx` (Wiedergabe
synchron zum bestehenden `STORY_DURATION_MS = 5000`-Timer). Backend
(`stories.listMusicTracks`, `stories.create`s `musicTrackId`-Feld,
Admin-Katalog unter `/p/[secret]/music`) ist bereits live und
plattformunabhängig — nur die Mobile-UI fehlt noch. Aktuell existiert
**keine** Audio-Playback-Library in `mobile/package.json`
(`expo-av`/`expo-audio` fehlen komplett) → natives Modul nötig, kein
OTA-Update möglich. Vor Implementierung `mobile/AGENTS.md`s Vorgabe
befolgen (aktuelle SDK-57-Doku prüfen, welches der beiden Pakete gerade
empfohlen wird — `expo-av` ist zugunsten von `expo-audio` deprecated).
→ Zusätzlicher Scope-Unterschied zu Web: Mobile hat aktuell nur
Bild-Stories (`story-new.tsx`/`story-viewer.tsx` sind explizit
"photo-only", siehe deren Datei-Kommentare) — Video-Stories mit
Musik+Stummschaltung (wie auf Web umgesetzt) sind auf Mobile erst möglich,
sobald Mobile eigenständig Video-Story-Erstellung/-Wiedergabe nachzieht
(bestehende, feature-unabhängige Lücke, siehe `README.md`s "What's not
built yet"). Bis dahin: Mobile-Musik-Overlay nur für Bild-Stories.
## Muster für neue Einträge
```
- **Kurzbeschreibung** (`found.md`-Referenz, gemeldet YYYY-MM-DD). Betrifft:
<Dateien>. <Warum ein natives Modul nötig ist>. <Geplanter Ansatz>.
```
@@ -19,6 +19,7 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
MESSAGE: "notifications.message",
MENTION: "notifications.mention",
PAW_BACK: "notifications.pawBack",
PET_SUGGESTION: "notifications.petSuggestion",
};
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
@@ -54,6 +55,7 @@ function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
function getNotificationRoute(item: NotificationItem): string | null {
switch (item.type) {
case "FOLLOW":
case "PET_SUGGESTION":
return item.actorPet ? `/pets/${item.actorPet.id}` : null;
case "BIRTHDAY":
case "ADOPTION_DAY":
+121 -2
View File
@@ -1,9 +1,13 @@
import { useMemo } from "react";
import { View, Text, FlatList, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native";
import { Component, useMemo, type ReactNode } from "react";
import { View, Text, FlatList, Pressable, ActivityIndicator, Linking, Alert, StyleSheet, Switch } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Constants from "expo-constants";
import * as Sentry from "@sentry/react-native";
import { useAuth } from "@clerk/expo";
import { Check, Sun, Moon, SunMoon } from "lucide-react-native";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { useTheme } from "../../../src/context/ThemeContext";
import { env } from "../../../src/lib/env";
@@ -11,6 +15,7 @@ import { PetAvatar } from "../../../src/components/PetAvatar";
import { PetIdentityBadges } from "../../../src/components/PetIdentityBadges";
import { Card } from "../../../src/components/Card";
import { LanguageSwitcher } from "../../../src/components/LanguageSwitcher";
import { Button } from "../../../src/components/Button";
import { formatPetName } from "../../../src/lib/format";
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
import type { Pet } from "../../../src/api/types";
@@ -64,7 +69,11 @@ export default function PetsScreen() {
<>
<AppearanceSection />
<LanguageSection />
<PetSuggestionsSection />
<LegalSection />
<FooterErrorBoundary>
<LogoutSection />
</FooterErrorBoundary>
</>
}
/>
@@ -164,6 +173,49 @@ function LanguageSection() {
);
}
/**
* Opt-out for the PET_SUGGESTION cron notification
* (src/app/api/cron/pet-suggestions on the backend) — on by default, always
* re-toggleable here. No dedicated Switch component exists in this app yet
* (web uses shadcn's Switch), so this uses React Native's built-in one.
*/
function PetSuggestionsSection() {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const qc = useQueryClient();
const { data } = useQuery(trpc.owner.getPetSuggestionsPreference.queryOptions());
const setPreferenceMutation = useMutation(
trpc.owner.setPetSuggestionsPreference.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.owner.getPetSuggestionsPreference.queryKey() });
},
})
);
const petSuggestionsEnabled = data?.petSuggestionsEnabled ?? true;
return (
<View style={styles.appearanceSection}>
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
<Text style={styles.appearanceTitle}>{t("pets.petSuggestionsTitle")}</Text>
<Text style={styles.toggleLabel}>{t("pets.petSuggestionsLabel")}</Text>
</View>
<Switch
value={petSuggestionsEnabled}
onValueChange={(value) => setPreferenceMutation.mutate({ petSuggestionsEnabled: value })}
disabled={setPreferenceMutation.isPending}
trackColor={{ true: colors.marigoldOrange }}
/>
</View>
</View>
);
}
/** Links to the fixed-language legal pages (see root CLAUDE.md: not localized, always German) plus the installed app version, for anyone who needs to reach them without going through the web app. */
function LegalSection() {
const { t } = useTranslation();
@@ -190,6 +242,69 @@ function LegalSection() {
);
}
/**
* Local safety net for LogoutSection. Sentry.wrap(RootLayout) in
* app/_layout.tsx is the only other error boundary in the app, at the very
* root — a render crash here would otherwise take down the entire screen
* with no visible detail. Cheap enough to leave in permanently.
*/
class FooterErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error) {
Sentry.captureException(error);
}
render() {
if (this.state.error) {
return <Text style={{ color: "#dc2626", padding: 16 }}>Footer-Fehler: {this.state.error.message}</Text>;
}
return this.props.children;
}
}
/**
* Sign-out was previously only reachable by uninstalling the app or clearing
* app data — no way to switch accounts or log back in as someone else
* (found.md). `useProtectedRoute()` in app/_layout.tsx already redirects to
* sign-in as soon as `isSignedIn` flips false after signOut() resolves, so
* no manual navigation is needed here.
*
* Uses the shared `Button` component (outline variant) instead of a
* hand-rolled Pressable+icon — a first pass copied edit.tsx's bespoke
* red delete-pet row, which read as unpolished ("dev button": thin
* border, no fill, off typography) once actually seen on a device.
* Sign-out is also reversible and non-destructive, unlike deleting a
* pet, so the neutral outline style fits better than alarm red anyway.
*/
function LogoutSection() {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const { signOut } = useAuth();
const { clearActivePetId } = useActivePet();
const confirmLogout = () => {
Alert.alert(t("pets.logoutConfirmTitle"), t("pets.logoutConfirmMessage"), [
{ text: t("common.cancel"), style: "cancel" },
{
text: t("pets.logoutButton"),
style: "destructive",
onPress: () => {
clearActivePetId();
void signOut();
},
},
]);
};
return <Button title={t("pets.logoutButton")} variant="outline" onPress={confirmLogout} style={styles.logoutButton} />;
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
@@ -211,6 +326,9 @@ function makeStyles(colors: ColorTokens) {
},
appearanceSection: { marginTop: 28 },
appearanceTitle: { ...typography.label, textTransform: "none", letterSpacing: 0, marginBottom: 10 },
toggleRow: { flexDirection: "row", alignItems: "center", gap: 12 },
toggleText: { flex: 1 },
toggleLabel: { ...typography.body, fontSize: 12, color: colors.quietGray, marginTop: 2 },
appearanceRow: { flexDirection: "row", gap: 8 },
appearanceOption: {
flex: 1,
@@ -230,5 +348,6 @@ function makeStyles(colors: ColorTokens) {
legalSection: { marginTop: 28, gap: 12, alignItems: "center" },
legalLink: { ...typography.body, fontSize: 13, color: colors.marigoldDeep },
versionText: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
logoutButton: { marginTop: 28 },
});
}
+65 -44
View File
@@ -1,17 +1,18 @@
import { useMemo, useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, ScrollView, Platform } from "react-native";
import { useSignIn } from "@clerk/expo";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Button } from "../../src/components/Button";
import { TextField } from "../../src/components/TextField";
import { PasswordField } from "../../src/components/PasswordField";
import { LanguageSwitcher } from "../../src/components/LanguageSwitcher";
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
import { useTheme } from "../../src/context/ThemeContext";
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
/**
* Minimal email/password sign-in. Social sign-in is still follow-up work;
* password sign-up now exists at sign-up.tsx.
* Email/password sign-in, plus Google SSO via GoogleSignInButton.
*
* Uses Clerk's newer "Futures" API (signIn.password()/signIn.finalize()) —
* the classic signIn.create()/setActive() pattern from older Clerk docs
@@ -76,63 +77,83 @@ export default function SignInScreen() {
if (step === "verify-device") {
return (
<View style={styles.container}>
<Text style={styles.title}>{t("auth.verifyDeviceTitle")}</Text>
<Text style={styles.hint}>{t("auth.verifyDeviceHint", { email: emailAddress })}</Text>
<TextField
autoCapitalize="none"
keyboardType="number-pad"
placeholder={t("auth.codePlaceholder")}
value={code}
onChangeText={setCode}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title={t("auth.confirmButton")} disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
</View>
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
<View style={styles.form}>
<Text style={styles.title}>{t("auth.verifyDeviceTitle")}</Text>
<Text style={styles.hint}>{t("auth.verifyDeviceHint", { email: emailAddress })}</Text>
<TextField
autoCapitalize="none"
keyboardType="number-pad"
placeholder={t("auth.codePlaceholder")}
value={code}
onChangeText={setCode}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title={t("auth.confirmButton")} disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
return (
<View style={styles.container}>
<LanguageSwitcher />
<Text style={styles.title}>{t("auth.signInTitle")}</Text>
<TextField
autoCapitalize="none"
keyboardType="email-address"
placeholder={t("auth.emailPlaceholder")}
value={emailAddress}
onChangeText={setEmailAddress}
/>
<TextField
placeholder={t("auth.passwordPlaceholder")}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={t("auth.signInButton")}
disabled={fetchStatus === "fetching"}
onPress={onSignInPress}
/>
<Pressable onPress={() => router.replace("/(auth)/sign-up")} hitSlop={8}>
<Text style={styles.link}>{t("auth.noAccountLink")}</Text>
</Pressable>
</View>
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
<View style={styles.form}>
<LanguageSwitcher />
<Text style={styles.title}>{t("auth.signInTitle")}</Text>
<GoogleSignInButton
onError={setError}
onNeedsConsent={() => setError(t("auth.googleNeedsSignUpError"))}
/>
<Text style={styles.divider}>{t("auth.orDivider")}</Text>
<TextField
autoCapitalize="none"
keyboardType="email-address"
placeholder={t("auth.emailPlaceholder")}
value={emailAddress}
onChangeText={setEmailAddress}
/>
<PasswordField
placeholder={t("auth.passwordPlaceholder")}
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={t("auth.signInButton")}
disabled={fetchStatus === "fetching"}
onPress={onSignInPress}
/>
<Pressable onPress={() => router.replace("/(auth)/sign-up")} hitSlop={8}>
<Text style={styles.link}>{t("auth.noAccountLink")}</Text>
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.porchWhite },
container: {
flex: 1,
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
gap: 12,
backgroundColor: colors.porchWhite,
},
// Capped so the form stays a readable phone-width column instead of
// stretching full-bleed edge-to-edge on tablets (reported: "die gesamte
// App sieht sehr großgezogen aus" on a tablet login screenshot) — the
// container above had no maxWidth, so flex "stretch" filled the entire
// screen width on large devices.
form: { width: "100%", maxWidth: 420, gap: 12 },
title: { ...typography.headline, marginBottom: 16 },
divider: { ...typography.label, textAlign: "center", textTransform: "none" as const },
hint: { ...typography.body, color: colors.quietGray, marginBottom: 8 },
error: { ...typography.body, color: colors.alertRed },
link: { ...typography.body, color: colors.marigoldDeep, textAlign: "center", marginTop: 8 },
+86 -63
View File
@@ -1,12 +1,14 @@
import { useMemo, useState } from "react";
import { View, Text, Pressable, Linking, StyleSheet } from "react-native";
import { View, Text, Pressable, Linking, StyleSheet, KeyboardAvoidingView, ScrollView, Platform } from "react-native";
import { useSignUp } from "@clerk/expo";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Check } from "lucide-react-native";
import { Button } from "../../src/components/Button";
import { TextField } from "../../src/components/TextField";
import { PasswordField } from "../../src/components/PasswordField";
import { LanguageSwitcher } from "../../src/components/LanguageSwitcher";
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
import { useTheme } from "../../src/context/ThemeContext";
import { env } from "../../src/lib/env";
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
@@ -98,87 +100,108 @@ export default function SignUpScreen() {
if (step === "verify-email") {
return (
<View style={styles.container}>
<Text style={styles.title}>{t("auth.verifyEmailTitle")}</Text>
<Text style={styles.hint}>{t("auth.verifyEmailHint", { email: emailAddress })}</Text>
<TextField
autoCapitalize="none"
keyboardType="number-pad"
placeholder={t("auth.codePlaceholder")}
value={code}
onChangeText={setCode}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title={t("auth.confirmButton")} disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
</View>
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
<View style={styles.form}>
<Text style={styles.title}>{t("auth.verifyEmailTitle")}</Text>
<Text style={styles.hint}>{t("auth.verifyEmailHint", { email: emailAddress })}</Text>
<TextField
autoCapitalize="none"
keyboardType="number-pad"
placeholder={t("auth.codePlaceholder")}
value={code}
onChangeText={setCode}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title={t("auth.confirmButton")} disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
return (
<View style={styles.container}>
<LanguageSwitcher />
<Text style={styles.title}>{t("auth.signUpTitle")}</Text>
<TextField
placeholder={t("auth.firstNamePlaceholder")}
value={firstName}
onChangeText={setFirstName}
/>
<TextField
placeholder={t("auth.lastNamePlaceholder")}
value={lastName}
onChangeText={setLastName}
/>
<TextField
autoCapitalize="none"
keyboardType="email-address"
placeholder={t("auth.emailPlaceholder")}
value={emailAddress}
onChangeText={setEmailAddress}
/>
<TextField
placeholder={t("auth.passwordPlaceholder")}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<Pressable style={styles.checkboxRow} onPress={() => setLegalAccepted((v) => !v)}>
<View style={[styles.checkbox, legalAccepted && styles.checkboxChecked]}>
{legalAccepted && <Check color={colors.pureWhite} size={14} />}
</View>
<Text style={styles.checkboxLabel}>{t("auth.legalConsentLabel")}</Text>
</Pressable>
<View style={styles.legalLinksRow}>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/nutzungsbedingungen`)}>
<Text style={styles.link}>{t("pets.legalTerms")}</Text>
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
<View style={styles.form}>
<LanguageSwitcher />
<Text style={styles.title}>{t("auth.signUpTitle")}</Text>
<GoogleSignInButton onError={setError} legalAccepted={legalAccepted} disabled={!legalAccepted} />
<Text style={styles.divider}>{t("auth.orDivider")}</Text>
<TextField
placeholder={t("auth.firstNamePlaceholder")}
value={firstName}
onChangeText={setFirstName}
/>
<TextField
placeholder={t("auth.lastNamePlaceholder")}
value={lastName}
onChangeText={setLastName}
/>
<TextField
autoCapitalize="none"
keyboardType="email-address"
placeholder={t("auth.emailPlaceholder")}
value={emailAddress}
onChangeText={setEmailAddress}
/>
<PasswordField
placeholder={t("auth.passwordPlaceholder")}
value={password}
onChangeText={setPassword}
/>
<Pressable style={styles.checkboxRow} onPress={() => setLegalAccepted((v) => !v)}>
<View style={[styles.checkbox, legalAccepted && styles.checkboxChecked]}>
{legalAccepted && <Check color={colors.pureWhite} size={14} />}
</View>
<Text style={styles.checkboxLabel}>{t("auth.legalConsentLabel")}</Text>
</Pressable>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
<Text style={styles.link}>{t("pets.legalPrivacy")}</Text>
<View style={styles.legalLinksRow}>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/nutzungsbedingungen`)}>
<Text style={styles.link}>{t("pets.legalTerms")}</Text>
</Pressable>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
<Text style={styles.link}>{t("pets.legalPrivacy")}</Text>
</Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={t("auth.signUpButton")}
disabled={fetchStatus === "fetching" || !canSubmit}
onPress={onSignUpPress}
/>
<Pressable onPress={() => router.replace("/(auth)/sign-in")} hitSlop={8}>
<Text style={styles.link}>{t("auth.hasAccountLink")}</Text>
</Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={t("auth.signUpButton")}
disabled={fetchStatus === "fetching" || !canSubmit}
onPress={onSignUpPress}
/>
<Pressable onPress={() => router.replace("/(auth)/sign-in")} hitSlop={8}>
<Text style={styles.link}>{t("auth.hasAccountLink")}</Text>
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.porchWhite },
// flexGrow (not flex) on a ScrollView's contentContainerStyle — still
// centers short content but lets the view actually scroll once the
// keyboard pushes content taller than the screen (found.md: password
// field and submit button were getting hidden behind the keyboard).
container: {
flex: 1,
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
gap: 12,
backgroundColor: colors.porchWhite,
},
// Capped so the form stays a readable phone-width column instead of
// stretching full-bleed edge-to-edge on tablets (reported: "die gesamte
// App sieht sehr großgezogen aus" on a tablet login screenshot) — the
// container above had no maxWidth, so flex "stretch" filled the entire
// screen width on large devices.
form: { width: "100%", maxWidth: 420, gap: 12 },
title: { ...typography.headline, marginBottom: 16 },
divider: { ...typography.label, textAlign: "center", textTransform: "none" as const },
hint: { ...typography.body, color: colors.quietGray, marginBottom: 8 },
error: { ...typography.body, color: colors.alertRed },
link: { ...typography.body, color: colors.marigoldDeep, textAlign: "center", marginTop: 8 },
+67 -1
View File
@@ -53,4 +53,70 @@ TODO Neu3:
- [x] Sowie kann man beim Tier kein Geburtstag hinterlegen oder Adoptionstag wie es im Web vorhanden ist.
→ Beides war nur eine Mobile-UI-Lücke, das Backend (`pets.update`) unterstützt `breedId`/`birthday`/`adoptedAt` bereits (Web nutzt es längst). Rassen-Dropdown aus PetCreateForm.tsx in eine geteilte `BreedPicker`-Komponente ausgelagert (DRY) und in edit.tsx eingebaut (Tierart selbst bleibt fix, nur die Rasse ist neu wählbar). Geburtstag/Adoptionstag als TT-MM-JJJJ-Textfelder ergänzt, gleiches Validierungs-/Konvertierungsmuster wie bei den Health-Formularen (`date-format.ts`). Da BreedPicker wie zuvor ein eigenes ScrollView verschachtelt, wurde edit.tsx aus demselben Grund wie onboarding/pet.tsx von `ScrollView` auf `FlatList` umgestellt. Typecheck + Lint sauber (ein vorbestehender, nicht selbst verursachter `set-state-in-effect`-Lint-Hinweis in der bereits vorhandenen Effect besteht unverändert fort), i18n-Parität 247/247. Nicht live getestet (kein Emulator in dieser Session).
- [x] Meilensteine sind nicht im Darkmode verfügbar
→ milestones.tsx (Liste) und milestone-new.tsx (neuer Meilenstein) nutzten noch die statischen (Hell-only) `colors`/`typography` statt `useTheme()` — auf das etablierte `makeStyles(colors)`-Muster umgestellt. Typecheck sauber, Lint zeigt nur vorbestehende, nicht selbst verursachte Warnungen (fehlendes `t`-Dep, fehlendes `alt`). Nicht live getestet (kein Emulator in dieser Session).
→ milestones.tsx (Liste) und milestone-new.tsx (neuer Meilenstein) nutzten noch die statischen (Hell-only) `colors`/`typography` statt `useTheme()` — auf das etablierte `makeStyles(colors)`-Muster umgestellt. Typecheck sauber, Lint zeigt nur vorbestehende, nicht selbst verursachte Warnungen (fehlendes `t`-Dep, fehlendes `alt`). Nicht live getestet (kein Emulator in dieser Session).
TODO Neu4 (Tester-Feedback aus der Google Group, Closed Testing):
- [x] Im Darkmode hatte ein User nach dem Login "alles in Schwarz" gesehen (kein Gerät bekannt).
→ Zwei konkrete Lücken gefunden und behoben: (1) Der `LoadingScreen` in `app/_layout.tsx` (Anzeige während Fonts/Clerk-Auth laden, u.a. direkt nach dem Login) war fest auf helle Farben verdrahtet, da er *vor* dem ThemeProvider rendert und `useTheme()` dort nicht zur Verfügung steht — jetzt löst `resolveInitialColors()` (neu in ThemeContext.tsx) dieselbe Preference-oder-System-Vermutung auf, die der ThemeProvider selbst für seinen ersten Frame nutzt. (2) Der Splash-Screen (`app.json`) hatte nur eine helle `backgroundColor`, keine Dark-Variante — im expo-splash-screen-Plugin per Source-Code verifiziert: ohne `dark.backgroundColor` bleibt der Splash in jedem System-Modus hell (kein automatisches Schwarz, aber eben auch kein Dark-Modus) — jetzt mit `dark.backgroundColor: "#111111"` (identisch zu `darkColors.porchWhite`) ergänzt, gleiches Icon (verifiziert: transparenter Alpha-Kanal, funktioniert auf beiden Hintergründen). Speculativ, da ohne Repro/Gerätedaten nicht 100% zu verifizieren — sollte den wahrscheinlichsten Kandidaten (helle Screens/Splash im dunklen Kontext) aber abdecken. Nicht live getestet (kein Emulator in dieser Session).
- [x] Tablet-Login-Screenshot: komplette App sieht "sehr großgezogen" aus.
→ Ursache (ohne den Screenshot selbst gesehen zu haben, aber strukturell bestätigt): sign-in.tsx/sign-up.tsx hatten keine Breitenbegrenzung — das äußere `View` war `flex:1` ohne `maxWidth`, wodurch Inputs/Button per Default-`alignItems:"stretch"` auf die volle Bildschirmbreite gezogen wurden. Auf einem Phone (~350dp) unauffällig, auf einem Tablet (~800-1200dp) wirkt das wie randlos aufgeblasene Formularfelder. Fix: Inhalt in einen zentrierten `form`-Container mit `maxWidth: 420` gepackt (beide Screens). Scope bewusst auf die gemeldeten Auth-Screens begrenzt — falls der Effekt laut nächstem Feedback app-weit auftritt, braucht es einen gemeinsamen Screen-Wrapper statt Einzelfixes. Nicht live auf einem Tablet getestet (kein Emulator/Tablet in dieser Session).
- [x] Ein User wünscht sich SSO mit Google.
→ Umgesetzt und live getestet (funktioniert). `expo-auth-session`+`expo-web-browser` installiert (native Module, kein OTA-Update möglich), neuer `GoogleSignInButton` (eigenes SVG-„G"-Icon) auf sign-in.tsx + sign-up.tsx, `useSSO()` aus `@clerk/expo` (stable, nicht `/experimental` — konsistent mit dem Rest der App), `WebBrowser.maybeCompleteAuthSession()` im Root-Layout + `useWarmUpBrowser`-Hook.
Zwei echte Blocker unterwegs gefunden und gelöst: (1) Google-Connection war im Clerk-Dashboard zunächst nicht aktiviert (per öffentlichem `/v1/environment`-Endpoint live verifiziert) — vom User nachgeholt. (2) `pawfeed://sso-callback` war nicht als erlaubte Redirect-URL in Clerk eingetragen → Clerk fiel auf einen `home_url`+`/sign-in`-Fallback zurück, der auf dieser App nicht existiert (404, da die Web-Route `/log-in` heißt) — vom User im Dashboard eingetragen, jetzt behoben.
Zusätzlich behandelt: Diese Clerk-Instanz verlangt `legal_consent_enabled: true`, aber Clerks SSO-Transfer-Flow übergibt kein Consent-Feld — hätte sonst bei neuen Google-Konten im selben `missing_requirements`-Hänger geendet wie früher der Passwort-Flow. Fix: sign-up.tsx sperrt den Google-Button bis die bestehende Checkbox angehakt ist und ruft danach `signUp.update({ legalAccepted: true })` nach; sign-in.tsx hat keine Consent-UI und zeigt bei einem noch nicht existierenden Account stattdessen eine Fehlermeldung, die auf „Registrieren" verweist, statt still ein Konto ohne Zustimmung anzulegen.
Vorab noch ein reines EAS Update (Runtime 1.0.2, Branch `production`, Update-Gruppe `f1f3a381`) mit nur den Dark-Mode-/Tablet-Fixes an die bestehenden Nutzer ausgeliefert, bevor der SSO-Code dazukam — die SSO-Verdrahtung ruft `expo-web-browser` beim App-Start auf (natives Modul, im installierten 1.0.2-Build nicht vorhanden); ein OTA-Update mit dieser Verdrahtung hätte den bestehenden Build zum Absturz gebracht. Dafür wurde `app.json`/`app/_layout.tsx`/sign-in.tsx/sign-up.tsx temporär auf den Stand ohne SSO zurückgesetzt, das Update veröffentlicht, danach alles 1:1 wiederhergestellt (Typecheck+Lint nach beiden Schritten sauber).
TODO Neu5 (weiteres Tester-Feedback):
- [x] Erster Content-Load fühlt sich für Nutzer zu langsam an — Frage nach CDN-loser Beschleunigung.
→ Query-Cache (MMKV-persistiert, 24h gcTime), DB-Indizes und die Redis-gestützte Feed-Rangliste waren schon solide, das war nicht die Ursache. Eigentlicher Flaschenhals: Uploads liefen nur mit JPEG-Kompression (`quality: 0.8`), aber ohne Auflösungs-Downscale — ein Kamerafoto blieb bei 3000-4000px, und jeder Feed-Betrachter lud dieses Vollbild nur um es auf Kartenbreite zu skalieren. User hat parallel geprüft: Supabase Image Transform API (serverseitiges Resize) ist nur im Pro-Plan verfügbar, den wir nicht haben — daher Fix am Schreib- statt Lesezeitpunkt.
Neue `resizeForUpload()`-Utility (`src/lib/resize-image.ts`, nutzt `expo-image-manipulator`s aktuelle `ImageManipulator.manipulate(uri).resize().renderAsync()`-Chain-API, nicht das deprecated `manipulateAsync`) — skaliert auf max. 1600px lange Kante (nur falls nötig, kein Hochskalieren) und re-encoded immer auf JPEG @ 0.8 Kompression, unabhängig vom Ursprungsformat. In allen 4 Upload-Stellen eingebaut: AvatarStep.tsx, post-new.tsx, story-new.tsx, milestone-new.tsx. `expo-image-manipulator` installiert (natives Modul, kein OTA-Update möglich — wird zusammen mit dem SSO-Build 1.0.3 ausgeliefert). Typecheck + Lint sauber (nur vorbestehende `t`-Dep-Warnungen). Nicht live getestet (kein Emulator in dieser Session) — insbesondere `File(uri).size` ist in den TS-Typdefs des installierten expo-file-system nicht direkt sichtbar, aber im nativen iOS-Quellcode (`Property("size")`) bestätigt.
- [x] Auf einem Android 17 - Pixel 10 Pro XL: App erneut gestartet (zweiter Tag) und dauerhafter Ladescreen. Frage nach Log-Einsicht.
→ Kein Log-Zugriff möglich: Mobile hatte bislang kein Crash-/Error-Tracking, kein Gerät/Emulator in dieser Session verfügbar. Verdacht: `app/_layout.tsx`s allererster Ladescreen wartet auf Clerks `isLoaded` — beim Wiederöffnen nach längerer Pause ist der gespeicherte Session-Token abgelaufen, Clerk muss dann still über Netzwerk erneuern; hängt dieser Refresh (schlechte Verbindung o.ä.), blieb `isLoaded` für immer `false` und der Spinner drehte endlos, ohne Fehleranzeige oder Retry-Möglichkeit.
Zwei Fixes: (1) **Sentry/GlitchTip für Mobile eingerichtet** (`@sentry/react-native`, self-hosted GlitchTip-Instanz wie bei Web, eigener DSN über `EXPO_PUBLIC_SENTRY_DSN`). `Sentry.init()` nur wenn DSN gesetzt (No-op sonst), `Sentry.wrap(RootLayout)` als Error Boundary für sonst unbemerkte Render-Fehler. Bewusst ohne Sourcemap-Upload aus dem JS-Code heraus verdrahtet (schlägt bei dieser GlitchTip-Instanz für Web schon mit CSRF 403 fehl, siehe CLAUDE.md) — trotzdem versuchte der native Android-Build automatisch, Sourcemaps hochzuladen (Sentrys Expo-Config-Plugin hängt das standardmäßig als Gradle-Task ein), was den ersten 1.0.4-Build mit `error: An organization ID or slug is required (provide with --org)` hat scheitern lassen. Fix: `SENTRY_DISABLE_AUTO_UPLOAD: "true"` in `eas.json` (preview/production `env`) ergänzt — das ist die von `@sentry/react-native`s `sentry.gradle` selbst geprüfte Umgebungsvariable, um den Upload-Task zu überspringen. (2) **10-Sekunden-Timeout für die Auth-Ladephase**: neuer `useTimedOut()`-Hook + `AuthTimeoutScreen` — zeigt nach 10s Wartezeit auf `isLoaded` statt des endlosen Spinners einen Hinweistext + Retry-Button (`Updates.reloadAsync()`, nutzt das schon vorhandene EAS-Update-Runtime). Sentry-Warnmeldung wird sowohl beim Erscheinen des Timeout-Screens als auch beim Retry-Tap abgesetzt, damit ein künftiger Vorfall diesmal nachvollziehbar ist. `expo-updates`/`expo-web-browser` bereits vorhanden, `@sentry/react-native` ist ein neues natives Modul (Config-Plugin automatisch in app.json ergänzt) — kein OTA-Update möglich, geht mit dem 1.0.4-Build raus. Typecheck + Lint sauber, i18n-Parität 252/252. Nicht live getestet (kein Emulator in dieser Session).
- [x] Bitte prüfen, ob das Rassen-Dropdown-Scroll-Problem bei "Tier erstellen" theoretisch erneut auftreten kann — ein Nutzer hat das bemängelt.
→ Struktur-Vergleich: onboarding/pet.tsx, edit.tsx und explore.tsx nutzen alle exakt dasselbe Muster (äußeres FlatList + OptionPickers inneres ScrollView mit `nestedScrollEnabled`) — keine Inkonsistenz, kein Regressions-Bug zwischen den dreien. Das eigentliche Problem: `nestedScrollEnabled` ist auf Android seit Jahren bekanntermaßen unzuverlässig bei verschachtelten gleichachsigen Scrollbereichen (FlatList/VirtualizedList + ScrollView) — kann je nach Android-Version/Listenlänge/Touch-Timing mal funktionieren, mal nicht (der Tester war auf Android 17, einer brandneuen Version).
Strukturelle statt kosmetische Lösung: `OptionPicker.tsx` auf `Modal` umgebaut — das Dropdown rendert jetzt in einer eigenen nativen Ebene komplett außerhalb der äußeren Scroll-Hierarchie, damit ist die ganze Klasse von Nested-Scroll-Bugs eliminiert statt nur umschifft. Button-Position wird per `measureInWindow()` gemessen, damit das Dropdown weiterhin optisch wie ein Inline-Dropdown direkt unter dem Button erscheint (nicht als Vollbild-Sheet). Wirkt automatisch für alle 3 Verbraucher (Explore, Tier erstellen, Profil bearbeiten), keine Änderung an deren Props nötig. Aufräumen als Folge: explore.tsx's `headerWrapper`-zIndex-Hack (war nur für das alte absolut-positionierte Overlay nötig) entfernt; veraltete Kommentare in onboarding/pet.tsx + edit.tsx aktualisiert (FlatList bleibt dort bestehen, ist aber nicht mehr zwingend aus diesem Grund nötig). Kein neues natives Modul (Modal ist RN-Core) — theoretisch auch per OTA an 1.0.2-Nutzer ausliefbar, geht aber ohnehin mit dem 1.0.3-Build raus. Typecheck + Lint sauber. Nicht live getestet (kein Emulator/Gerät in dieser Session) — größtes Restrisiko: die Positions-Messung (`measureInWindow`) könnte auf einem echten Gerät leicht daneben liegen (z.B. bei Statusbar-Offsets), bitte beim nächsten Test gezielt prüfen.
TODO Neu6:
- [x] Ein User hat ein Redirect-Problem beim Google-Login gemeldet, nicht reproduzierbar. Wann wurde die Redirect-Fix-Änderung gemacht?
→ Die `pawfeed://sso-callback`-Whitelist wurde im Clerk-Dashboard noch vor dem 1.0.3-Build eingetragen (reine Backend-Config, kein App-Code) — wirkt serverseitig sofort für jede App-Version mit SSO-Code (1.0.3+), nicht an eine Build-Nummer gebunden. Ein Redirect-Problem danach wäre also entweder ein Tester auf einer Version vor 1.0.3 (kein SSO-Code, "Redirect-Problem" eher ein Missverständnis) oder ein bisher unbekannter Android-/Browser-Edge-Case. Echter Fund dabei: `GoogleSignInButton.tsx`s catch-Block meldete Fehler bisher nur lokal (Bildschirmtext), nicht an Sentry — nachgerüstet (`Sentry.captureException(err)`), damit ein künftiger Fall tatsächlich Logs hinterlässt. Per EAS Update (Runtime 1.0.4, Branch `production`, Update-Gruppe `eea1524b`) ausgeliefert — kein neuer Build nötig, reiner JS-Fix auf bereits vorhandenem `@sentry/react-native`.
- [x] Im Explorer gibt es beim Breed-Dropdown jetzt auch das Scroll-Problem.
→ War NICHT nur ein Altlast-Feedback von vor 1.0.4 — User hat nach dem ersten Fix-Versuch (EAS Update, s.o.) bestätigt: immer noch nicht scrollbar. Erster Fix-Versuch (Pressable→View bei der Dropdown-Karte) war unvollständig: die Karte steckte immer noch als Kind-Element im vollflächigen Backdrop-`Pressable`, und ein `Pressable` IRGENDWO oberhalb einer `ScrollView` im Baum ist immer noch ein konkurrierender Responder-Kandidat für die Scroll-Geste. Nach erneuter Bestätigung durch den User ("neu gestartet, weiterhin defekt") das eigentliche Problem gefunden: Backdrop und Dropdown-Liste müssen **Geschwister** sein, nicht Eltern-Kind — jetzt so umgebaut, dass ein Tap innerhalb der Liste direkt in deren eigenen Teilbaum trifft und den Backdrop-Pressable darunter nie erreicht. Zweimal per EAS Update (Runtime 1.0.4) ausgeliefert, kein neuer Build nötig. Wichtige Erkenntnis für künftige Vorfälle: **nach einem OTA-Update muss die App komplett geschlossen und neu geöffnet werden** (nicht nur in den Hintergrund) — `expo-updates` wendet ein heruntergeladenes Update erst beim übernächsten Start an, der erste Start nach Download läuft noch mit dem alten Bundle. Typecheck + Lint sauber. Nicht live getestet (kein Emulator in dieser Session) — bitte diesmal wirklich explizit bestätigen lassen.
- [x] Kein Logout-Button in der App vorhanden — Neu-Anmeldung mit anderem Konto nicht möglich.
→ Echte Funktionslücke, nicht vorher gebaut. Neue `LogoutSection` unter „Meine Tiere" (ganz unten, nach den rechtlichen Links), mit Bestätigungsdialog (analog zum bestehenden "Tier löschen"-Muster in edit.tsx). Ruft Clerks `signOut()` (`useAuth()` aus `@clerk/expo`) plus `clearActivePetId()` auf, damit nach einem Konto-Wechsel keine alte Pet-ID aus dem vorherigen Konto hängen bleibt — die Weiterleitung zum Login übernimmt automatisch `useProtectedRoute()` in app/_layout.tsx, sobald `isSignedIn` auf `false` kippt. Kein neues natives Modul, per EAS Update (Runtime 1.0.4) ausgeliefert. Typecheck + Lint sauber, i18n-Parität 255/255. Nicht live getestet (kein Emulator in dieser Session).
**Wichtiger Kontext im Nachhinein (User-Hinweis):** Der 1.0.4-Build wurde zwar erstellt, aber **nie in die Play Console hochgeladen** — die Tester/Neuinstallationen liefen die ganze Zeit noch auf der vorherigen Version (vermutlich 1.0.2, ohne SSO-Code). Das erklärt rückwirkend, warum der Google-Redirect-Fehler und der Dropdown-Bug auch nach mehreren EAS Updates (die alle auf Runtime 1.0.4 zielten) und einer Neuinstallation weiter auftraten — die Updates konnten ein Gerät, das nie 1.0.4 installiert hatte, gar nicht erreichen. Die Analysen/Fixes selbst bleiben gültig, waren aber bis zum echten 1.0.4-Rollout nicht wirklich verifizierbar. User lädt 1.0.4 jetzt nach — sobald das live ist, sollten alle drei EAS-Update-Runden (Sentry-Fix, erster Dropdown-Versuch, finaler Sibling-Fix + Logout) automatisch beim übernächsten App-Start greifen, ohne weiteren Build.
**Fortsetzung — echtes 1.0.4 live, EAS Updates kommen trotzdem nie an:** Nachdem 1.0.4 wirklich in der Play Console lag, blieb der Logout-Button trotzdem unsichtbar. Diagnose-Werkzeug gebaut (`UpdateDiagnosticsSection` + `FooterErrorBoundary` in pets.tsx, temporär, per EAS Update ausgeliefert) — aber selbst DAS kam nie an, auch nach 20 Neustarts und 2 kompletten Neuinstallationen auf zwei komplett unterschiedlichen Geräten (Pixel 10 Pro XL extern, Xiaomi 13 Pro beim User selbst). Netzwerk-Check ausgeschlossen: `https://u.expo.dev/<projectId>` ist vom Testgerät aus erreichbar (erwartete Header-Fehlermeldung, kein Timeout/Blockade). Build-Metadaten (`eas build:view`) bestätigten Channel/Runtime-Version korrekt eingebettet — kein Konfigurationsfehler bei den Publishes selbst.
**Ursache gefunden:** `app.json`s Plugin-Liste enthielt `expo-dev-client` — und zwar ungefiltert für **alle** Build-Profile inkl. `production`. Dieses Paket ist laut eigener Doku nur für Debug-/Development-Builds gedacht und bringt eine eigene Update-/Branch-Auswahl-Logik mit, die den normalen automatischen `expo-updates`-Check verdrängen kann — passt exakt zum Symptom (Updates kommen strukturell nie an, geräteunabhängig). Der lokale Dev-Client war laut früheren Sessions ohnehin schon kaputt, kein Verlust durchs Entfernen. Fix: `expo-dev-client` aus der gemeinsamen `plugins`-Liste entfernt (Paket bleibt in package.json, `eas.json`s `development`-Profil hat sein eigenes `developmentClient: true`, unabhängig von dieser Liste). **Braucht einen neuen nativen Build** (Plugin-Änderungen sind nicht OTA-fähig) — 1.0.5/versionCode 6 angestoßen. Falls das die Ursache war, sollte ab diesem Build der normale automatische Update-Check endlich greifen.
**Notiz für einen künftigen Build (nicht dringend, rein vorsorglich):** Beim 1.0.5-Build meldete EAS Patch-Versions-Abweichungen bei etlichen Expo-Paketen (`expo`, `expo-router`, `expo-updates`, `expo-image`, u.v.m. — installiert liegt jeweils einen Patch hinter dem für SDK 57 aktuell erwarteten Stand). Nur Patch-Ebene, kein Build-Blocker, aber beim nächsten ohnehin anstehenden Build sollte vorher `npx expo install --fix` laufen, um das aufzuräumen.
**Bestätigt und abgeschlossen:** 1.0.5 freigegeben und getestet — Logout-Button UND das Diagnose-Tool waren sichtbar, `expo-dev-client` war also tatsächlich die Ursache. EAS Update funktioniert damit ab 1.0.5 wieder normal. Feedback: Logout-Button sah "wie ein Dev-Button" aus — lag am direkt danebenstehenden, absichtlich roh gehaltenen Diagnose-Block ("Update-Diagnose (temporär)" + Monospace-Dump + Debug-Button). Diagnose-Tool war ohnehin nur zur Fehlersuche gedacht und jetzt entfernt (`UpdateDiagnosticsSection` + zugehörige Styles raus aus pets.tsx); `FooterErrorBoundary` um `LogoutSection` bleibt als dauerhaftes, günstiges Sicherheitsnetz bestehen. Per EAS Update (Runtime 1.0.5, Gruppe `105f39e3`) ausgeliefert, kein neuer Build nötig. Typecheck + Lint sauber.
**Kleiner Nachklapp:** Das Cleanup-Update (`105f39e3`) wurde von `checkForUpdateAsync()` als `updatePreviouslyFailed` gemeldet — vermutlich wurde der Download/Start bei einem der vielen Testneustarts unterbrochen, bevor die App sich als erfolgreich gestartet melden konnte; `expo-updates` blockiert eine einmal fehlgeschlagene Update-ID danach dauerhaft, unabhängig davon ob der Code eigentlich intakt ist (war er hier: Typecheck sauber, keine verwaisten Referenzen). Per `eas update:republish` mit frischer Update-ID (`fdba4e10`) umgangen — sollte jetzt ohne die Blockade ankommen.
**Doch kein Interrupt-Artefakt — echter Absturz bestätigt:** Der Republish (`fdba4e10`) zeigte im Expo-Dashboard ebenfalls "known launches: 1, known crashes: 1", identisch zum Original. Das widerlegt die Interrupt-Theorie (eine frische Update-ID hätte keinen alten Absturz "erben" können) — der Absturz ist real und reproduzierbar. GlitchTip zeigte dazu **nichts** (nur 2 alte, unabhängige Meldungen) — d.h. der Crash passiert unterhalb der JS-Ebene, bevor `Sentry.wrap()` überhaupt greifen kann, exakt beim Umschalten auf das heruntergeladene Bundle. Das eingebettete Bundle (im APK selbst) läuft dagegen einwandfrei — jedes bisher unter Runtime 1.0.5 versuchte OTA-Update war das allererste OTA-Update überhaupt auf diesem (Dev-Client-befreiten) nativen Build, und es scheiterte konsistent.
**Verdacht:** `expo-updates` lag laut `expo install --check`-Warnung beim 1.0.5-Build einen Patch hinter (`57.0.21` statt erwartet `~57.0.22`) — genau solche Patches beheben oft subtile Update-Apply-Bugs. Mit `npx expo install --fix` alle Expo-Pakete auf den erwarteten Stand gebracht (u.a. `expo-updates` → `57.0.22`, `expo` → `57.0.22`, `expo-router` → `57.0.21`). Typecheck + volles Lint danach unverändert sauber (identische 39 vorbestehende Probleme wie zu Sessionbeginn, nichts Neues). **Neuer nativer Build 1.0.6/versionCode 7 nötig** (Dependency-Update wirkt nur bei einem frischen nativen Build) — bewusst neue Runtime-Version (nicht 1.0.5 wiederverwendet), um jede Verwechslung mit dem bekannt kaputten 1.0.5-Runtime-Zustand auszuschließen. Falls das die Ursache war, sollte OTA ab 1.0.6 tatsächlich funktionieren — sonst brauchen wir echte native Logs vom Gerät (Sentry kann diesen Absturz nicht sehen).
**1.0.6 installiert, Diagnose-Text weg, Logout-Button da — aber "DEV-Optik".** Nachfrage ergab: zu schlicht/unfertig (dünner Rahmen, kein Füllhintergrund), Farbe/Kontrast falsch, UND Icon/Schrift passen nicht — alle drei zugleich. Ursache: der Button war komplett handgestylt (kopiert von edit.tsx's Delete-Pet-Muster: roter Rahmen, `LogOut`-Icon, eigene Styles) statt die etablierte, überall sonst genutzte `Button`-Komponente zu verwenden. Logout ist zudem nicht destruktiv/irreversibel wie Löschen — die neutrale `outline`-Variante (heller Hintergrund, Ink-Text, Hairline-Rahmen) passt semantisch besser als Alarmrot. Fix: `LogoutSection` nutzt jetzt `<Button variant="outline">` statt eigenem Pressable+Icon+Styles; `LogOut`-Icon-Import und `radii`-Import (nicht mehr gebraucht) entfernt. Per EAS Update (Runtime 1.0.6, Gruppe `cb97d058`) ausgeliefert.
**DIESES Update hing wieder (60+ Sekunden Splash-Screen, kein ANR-Dialog) — `expo-updates`-Patch-Fix hat NICHT geholfen.** Echte Gerätelogs geholt (`adb logcat` vom per USB verbundenen Xiaomi während eines Reproduktionsversuchs) und ausgewertet. Der `dev.expo.updates`-Log-Tag zeigte den kompletten Update-Lifecycle strukturiert als JSON — und darin die eigentliche Ursache:
```
ErrorRecovery: exception encountered: Error: Missing required env var EXPO_PUBLIC_API_URL — check mobile/.env
```
**Echte Root Cause gefunden — `expo-dev-client` war eine Ablenkung, nicht die Ursache:** `eas update --environment production` lädt Umgebungsvariablen aus einem **serverseitigen EAS-Umgebungsspeicher** (`eas env:list`/`eas env:set`), komplett getrennt von `eas.json`s `build.production.env`-Block (der nur für `eas build` gilt). Dieser Server-Speicher war die ganze Session über leer ("No environment variables... found" stand in JEDEM Update-Log, wurde aber als harmlos übersehen) — jedes OTA-Bundle dieser Session lief also ohne `EXPO_PUBLIC_*`-Variablen, und `env.ts`s `requireEnv()`-Check wirft dann sofort beim Laden (importiert von praktisch jeder Datei). Das erklärt auch rückwirkend, warum alle bisherigen "Updates kommen an"-Bestätigungen (Diagnose-Tool + Logout-Button sichtbar in 1.0.5) vermutlich **nie ein echtes OTA-Update waren**, sondern nur das beim `eas build`-Zeitpunkt eingebettete Bundle (das die Env-Vars korrekt aus `eas.json` bekommt) — `expo-dev-client` könnte für das ursprüngliche 1.0.4-Problem trotzdem ein Faktor gewesen sein, war aber nicht die vollständige Erklärung.
**Fix:** `eas env:set production --name EXPO_PUBLIC_API_URL --value "..." --visibility plaintext` (und ebenso für `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`, `EXPO_PUBLIC_SUPABASE_URL`, `EXPO_PUBLIC_SENTRY_DSN`) für die Environments `production` und `preview` gesetzt. Test-Update (Gruppe `6eb8b061`) zeigte danach im Log **"Environment variables... loaded from the 'production' environment on EAS: ..."** statt "No environment variables... found" — Variablen kommen jetzt nachweislich mit.
**✅ Am Gerät bestätigt: Logout-Button erscheint, funktioniert.** Das OTA-Update kommt jetzt tatsächlich an und startet erfolgreich — die fehlenden EAS-Server-Env-Vars waren die echte, vollständige Ursache der gesamten Update-Odyssee dieser Session. `expo-dev-client`-Entfernung bleibt trotzdem sinnvoll (gehört nicht in Production), war aber nicht der entscheidende Fix. **TODO Neu6 damit abgeschlossen.**
Neu 7:
- [ ] Datums eingabe unüblich mit - getrennt statt mit . ansicht am Handy wird meist durch die tastatur überblendet. können wir für die mobile eine data wheel picker nutzen ?
→ Analyse erledigt, betrifft 4 Dateien (edit.tsx Geburtstag/Adoptionsdatum + 3 Health-Sektionen). Wheel-Picker braucht ein natives Modul (`@react-native-community/datetimepicker`) → kein einzelner Build nur dafür, stattdessen auf `PENDING-NATIVE-BUILD.md` verschoben und wird mit dem nächsten ohnehin anstehenden nativen Build mitgenommen. Der reine Trennzeichen-Fix ("-" → ".") ist davon unabhängig und OTA-fähig, sobald gewünscht.
- [x] Ein User bemängelt, das die Regisrierung gut ist, aber beim eingeben des Passworts diese hinter der "Bildschrimtastatur" verschwindet, ausserdem gibt es keine Möglichkeit für den User die korrekte eingabe nochmal zu prüfen.
→ Beides im Code bestätigt (`app/(auth)/sign-up.tsx`, gleiches Muster auch in `sign-in.tsx` gefunden und mitbehoben): (1) Der Screen nutzte nur ein einfaches `View` als äußeren Container, kein `ScrollView`/`KeyboardAvoidingView` — auf kleinen Screens schob sich die Tastatur einfach über die unteren Felder. Fix: äußerer Container jetzt `KeyboardAvoidingView` (iOS: `padding`) + `ScrollView` (`keyboardShouldPersistTaps="handled"`, `contentContainerStyle` mit `flexGrow` statt `flex`, damit weiterhin zentriert bleibt aber scrollbar wird). (2) Neue geteilte `PasswordField`-Komponente (`src/components/PasswordField.tsx`) mit Auge/Auge-durchgestrichen-Toggle (lucide `Eye`/`EyeOff`) ersetzt das nackte `secureTextEntry`-`TextField` in beiden Screens. Kein natives Modul, OTA-fähig. Typecheck sauber (Mobile).
- [ ] Ein anderer User, meinte wäre es nicht schöner und aufgeräumter wenn die App Settings nicht unter MyPets stehen würden sondern einen eigenen Tab erhalten.
→ Nachvollziehbarer Punkt (Darstellung/Sprache/Tier-Vorschläge/Rechtliches/Logout stecken aktuell alle im Footer von "Meine Tiere", `app/(app)/(tabs)/pets.tsx`) — reine IA-Entscheidung, kein Bug. Würde einen neuen Tab in `(tabs)/_layout.tsx` plus Verschieben der bestehenden Footer-Sections in einen neuen `settings.tsx`-Screen bedeuten. Noch nicht umgesetzt, auf Rückmeldung ob gewünscht.
- [x] Ein user hat die meldung erhalten, "you do not have premission to access this pet" - kannst du mir erklären woher das kommen könnte ?
→ Ursache im Code gefunden (Web-seitig, nicht Mobile): Der Fehlertext kommt exakt aus `assertPetOwnership` (`src/lib/assert-pet-ownership.ts:19`), dem gemeinsamen Ownership-Check, den praktisch jede pet-bezogene Mutation zuerst durchläuft. Web speicherte die "aktive Tier"-ID in `localStorage["active_pet_id"]` (`src/context/ActivePetContext.tsx`) — anders als Mobile (siehe TODO Neu6: `LogoutSection` ruft dort bewusst `clearActivePetId()` auf) wurde dieser Wert auf Web **nirgends geleert**, auch nicht beim Sign-out über Clerks `<UserButton />`. `ActivePetInitializer` setzt nur dann einen Wert, wenn `activePetId` leer ist — er prüfte nie, ob die gespeicherte ID überhaupt noch zum aktuell eingeloggten Owner gehört. Konkretes Szenario: Nutzer A meldet sich ab, Nutzer B meldet sich im selben Browser an (gemeinsamer Rechner, oder A erstellt ein zweites Konto) — B's Session lud weiterhin A's alte `active_pet_id`, und jede Aktion schickte diese fremde Pet-ID ans Backend, das sie zu Recht mit genau dieser Fehlermeldung ablehnte. Gleiche Bug-Klasse wie der bereits auf Mobile behobene Logout-Fund, hier aber nie für Web nachgezogen.
Fix: `clearActivePetId()` in `src/context/ActivePetContext.tsx` ergänzt (Pendant zu Mobile). Da `ActivePetProvider` im Root-Layout **außerhalb** von `ClerkThemeProvider` sitzt (Clerk-Hooks dort nicht verfügbar — bewusste Architektur laut `(app)/layout.tsx`-Kommentar, um den Clerk-Bundle aus öffentlichen Routen rauszuhalten), kein direkter `useAuth()`-Zugriff im Context selbst möglich. Stattdessen neue `SignOutCleanup`-Komponente (`src/components/layout/SignOutCleanup.tsx`), mounted in `(app)/layout.tsx` neben `ActivePetInitializer` — beobachtet `useAuth().isSignedIn` und ruft `clearActivePetId()` beim Wechsel von `true` auf `false`, statt einen Klick-Handler an Clerks `<UserButton/>` zu hängen (die bietet dafür keinen sauberen Hook-Punkt). Deckt damit jeden Sign-out ab, unabhängig vom Auslöser. Typecheck + voller Testlauf (209/210, 1 skip wie zuvor) sauber, keine neuen Lint-Fehler.
+37
View File
@@ -0,0 +1,37 @@
import { useState } from "react";
import { View, Pressable, StyleSheet, type TextInputProps } from "react-native";
import { Eye, EyeOff } from "lucide-react-native";
import { TextField } from "./TextField";
import { useTheme } from "../context/ThemeContext";
/**
* TextField + a show/hide toggle, so a user typing on a phone keyboard has
* some way to verify what they entered (found.md: registration feedback —
* secureTextEntry alone gives no way to check the password before
* submitting). Kept as its own component since sign-in.tsx and sign-up.tsx
* both need the identical behavior.
*/
export function PasswordField(props: TextInputProps) {
const { colors } = useTheme();
const [visible, setVisible] = useState(false);
return (
<View style={styles.container}>
<TextField {...props} secureTextEntry={!visible} style={[styles.input, props.style]} />
<Pressable
onPress={() => setVisible((v) => !v)}
hitSlop={12}
style={styles.toggle}
accessibilityRole="button"
>
{visible ? <EyeOff color={colors.quietGray} size={18} /> : <Eye color={colors.quietGray} size={18} />}
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { justifyContent: "center" },
input: { paddingRight: 44 },
toggle: { position: "absolute", right: 14 },
});
+14 -3
View File
@@ -20,7 +20,8 @@
"done": "Fertig",
"optional": "optional",
"noResultsFound": "Keine Einträge gefunden.",
"edit": "Bearbeiten"
"edit": "Bearbeiten",
"authLoadTimeout": "Das dauert gerade länger als erwartet. Bitte erneut versuchen."
},
"report": {
"title": "Melden",
@@ -189,6 +190,7 @@
"pawBack": "hat zurückgepfotet.",
"birthday": "🎂 Alles Gute zum Geburtstag!",
"adoptionDay": "🏠 Heute ist der Adoptionstag!",
"petSuggestion": "könnte dir gefallen!",
"empty": "Keine Benachrichtigungen."
},
"pets": {
@@ -205,7 +207,12 @@
"legalPrivacy": "Datenschutzerklärung",
"legalTerms": "Nutzungsbedingungen",
"legalChildSafety": "Kinderschutz",
"versionLabel": "Version {{version}}"
"petSuggestionsTitle": "Tier-Vorschläge",
"petSuggestionsLabel": "Erhalte gelegentlich eine Benachrichtigung zu einem Tier, das dir gefallen könnte.",
"versionLabel": "Version {{version}}",
"logoutButton": "Abmelden",
"logoutConfirmTitle": "Abmelden?",
"logoutConfirmMessage": "Du wirst von deinem Konto abgemeldet und zum Login zurückgeschickt."
},
"auth": {
"signInTitle": "PawFeed Login",
@@ -226,7 +233,11 @@
"verifyEmailTitle": "E-Mail bestätigen",
"verifyEmailHint": "Wir haben einen Code an {{email}} geschickt, um deine E-Mail-Adresse zu bestätigen.",
"signInIncompleteError": "Anmeldung unvollständig — Status: {{status}}",
"signUpIncompleteError": "Registrierung unvollständig — Status: {{status}}"
"signUpIncompleteError": "Registrierung unvollständig — Status: {{status}}",
"googleButton": "Mit Google anmelden",
"orDivider": "oder",
"googleSignInError": "Anmeldung mit Google fehlgeschlagen. Bitte versuche es erneut.",
"googleNeedsSignUpError": "Für dieses Google-Konto existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen."
},
"onboarding": {
"addPetTitle": "Tier hinzufügen",
+14 -3
View File
@@ -20,7 +20,8 @@
"done": "Done",
"optional": "optional",
"noResultsFound": "No entries found.",
"edit": "Edit"
"edit": "Edit",
"authLoadTimeout": "This is taking longer than expected. Please try again."
},
"report": {
"title": "Report",
@@ -189,6 +190,7 @@
"pawBack": "paw-backed your post.",
"birthday": "🎂 Happy birthday!",
"adoptionDay": "🏠 Today is adoption day!",
"petSuggestion": "might be a good match!",
"empty": "No notifications."
},
"pets": {
@@ -205,7 +207,12 @@
"legalPrivacy": "Privacy Policy",
"legalTerms": "Terms of Service",
"legalChildSafety": "Child Safety Standards",
"versionLabel": "Version {{version}}"
"petSuggestionsTitle": "Pet suggestions",
"petSuggestionsLabel": "Occasionally get notified about a pet you might like.",
"versionLabel": "Version {{version}}",
"logoutButton": "Log out",
"logoutConfirmTitle": "Log out?",
"logoutConfirmMessage": "You'll be signed out of your account and sent back to the login screen."
},
"auth": {
"signInTitle": "PawFeed Login",
@@ -226,7 +233,11 @@
"verifyEmailTitle": "Confirm email",
"verifyEmailHint": "We sent a code to {{email}} to confirm your email address.",
"signInIncompleteError": "Sign-in incomplete — status: {{status}}",
"signUpIncompleteError": "Sign-up incomplete — status: {{status}}"
"signUpIncompleteError": "Sign-up incomplete — status: {{status}}",
"googleButton": "Continue with Google",
"orDivider": "or",
"googleSignInError": "Google sign-in failed. Please try again.",
"googleNeedsSignUpError": "There's no PawFeed account for this Google account yet. Please continue via \"Sign up\" to accept the Terms of Service."
},
"onboarding": {
"addPetTitle": "Add pet",
+95
View File
@@ -25,6 +25,9 @@ vi.mock("@/lib/supabase-storage", () => ({
createSignedUploadUrl: vi.fn(),
ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"],
MAX_FILE_SIZE: 10 * 1024 * 1024,
MUSIC_STORAGE_BUCKET: "music-tracks",
MUSIC_ALLOWED_CONTENT_TYPES: ["audio/mpeg", "audio/mp4", "audio/aac"],
MAX_MUSIC_FILE_SIZE: 5 * 1024 * 1024,
}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
@@ -767,3 +770,95 @@ describe("admin.createBroadcast / admin.listBroadcasts (Gitea #10 Phase 1)", ()
expect(mockPrisma.broadcast.delete).not.toHaveBeenCalled();
});
});
describe("admin music-track catalog", () => {
it("listMusicTracks returns the catalog for a MODERATOR caller", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.musicTrack.findMany.mockResolvedValue([{ id: "track-1", title: "Sunny Day" }]);
const result = await caller.admin.listMusicTracks(undefined);
expect(result).toEqual([{ id: "track-1", title: "Sunny Day" }]);
});
it("getMusicTrackUploadUrl requires SUPER_ADMIN, not just MODERATOR", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "someone-else");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.adminRole.findUnique.mockResolvedValue({ role: "MODERATOR" });
await expect(
caller.admin.getMusicTrackUploadUrl({ contentType: "audio/mpeg", fileSize: 1000 })
).rejects.toThrow(TRPCError);
});
it("createMusicTrack writes the track + a CREATE_MUSIC_TRACK moderation log in a transaction", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.musicTrack.create.mockResolvedValue({ id: "track-1", title: "Sunny Day" });
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
const result = await caller.admin.createMusicTrack({
title: "Sunny Day",
artist: "Test Artist",
licenseNote: "Royalty-free library, CC0",
storageKey: "tracks/abc.mp3",
durationSecs: 12.5,
});
expect(mockPrisma.musicTrack.create).toHaveBeenCalledWith({
data: expect.objectContaining({ title: "Sunny Day", createdBy: "admin-1" }),
});
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "CREATE_MUSIC_TRACK", targetType: "MUSIC_TRACK" }),
});
expect(result).toEqual({ id: "track-1", title: "Sunny Day" });
});
it("createMusicTrack throws FORBIDDEN for a non-admin caller", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "someone-else");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.adminRole.findUnique.mockResolvedValue(null);
await expect(
caller.admin.createMusicTrack({
title: "Sunny Day",
artist: "Test Artist",
licenseNote: "Royalty-free library, CC0",
storageKey: "tracks/abc.mp3",
durationSecs: 12.5,
})
).rejects.toThrow(TRPCError);
expect(mockPrisma.musicTrack.create).not.toHaveBeenCalled();
});
it("updateMusicTrack can toggle active off without touching other fields", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.musicTrack.update.mockResolvedValue({ id: "track-1", active: false });
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
const result = await caller.admin.updateMusicTrack({ id: "track-1", active: false });
expect(mockPrisma.musicTrack.update).toHaveBeenCalledWith({
where: { id: "track-1" },
data: { active: false },
});
expect(result).toEqual({ id: "track-1", active: false });
});
it("deleteMusicTrack wraps delete + log in a transaction", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.musicTrack.delete.mockResolvedValue({ id: "track-1" });
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
const result = await caller.admin.deleteMusicTrack({ id: "track-1" });
expect(mockPrisma.musicTrack.delete).toHaveBeenCalledWith({ where: { id: "track-1" } });
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "DELETE_MUSIC_TRACK", targetType: "MUSIC_TRACK", targetId: "track-1" }),
});
expect(result).toEqual({ success: true });
});
});
+16
View File
@@ -317,6 +317,14 @@ interface MockBroadcastReceiptClient {
groupBy: MockFn;
}
interface MockMusicTrackClient {
create: MockFn;
findMany: MockFn;
findUnique: MockFn;
update: MockFn;
delete: MockFn;
}
interface MockPrismaClient {
pet: MockPetClient;
owner: MockOwnerClient;
@@ -362,6 +370,7 @@ interface MockPrismaClient {
conversation: MockConversationClient;
broadcast: MockBroadcastClient;
broadcastReceipt: MockBroadcastReceiptClient;
musicTrack: MockMusicTrackClient;
$transaction: MockFn;
$disconnect: MockFn;
}
@@ -632,6 +641,13 @@ export function createMockPrisma(): MockPrismaClient {
updateMany: vi.fn(),
groupBy: vi.fn(),
},
musicTrack: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
};
+21
View File
@@ -95,6 +95,27 @@ describe("notify() push add-on", () => {
);
});
it("sends the PET_SUGGESTION copy with the suggested pet's name", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique
.mockResolvedValueOnce({ ownerId: "owner-1" }) // recipient lookup
.mockResolvedValueOnce({ name: "Luna" }); // actor (suggested pet) lookup
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" });
notify(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPrisma as any,
{ recipientPetId: "pet-1", type: "PET_SUGGESTION", actorPetId: "pet-2" }
);
await flush();
expect(sendExpoPushNotification).toHaveBeenCalledWith(
"ExponentPushToken[abc]",
expect.objectContaining({ title: "Vorschlag für dich", body: "Luna könnte dir gefallen!" })
);
});
it("never throws from notify() even if the push request itself rejects", async () => {
sendExpoPushNotification.mockRejectedValueOnce(new Error("Expo push down"));
const mockPrisma = createMockPrisma();
+36
View File
@@ -126,6 +126,42 @@ describe("owner.setHideSpidersPreference", () => {
});
});
describe("owner.getPetSuggestionsPreference", () => {
it("defaults to true when the Owner row does not exist yet", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.findUnique.mockResolvedValue(null);
const result = await caller.owner.getPetSuggestionsPreference();
expect(result).toEqual({ petSuggestionsEnabled: true });
});
it("returns the stored preference when set", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.findUnique.mockResolvedValue({ petSuggestionsEnabled: false });
const result = await caller.owner.getPetSuggestionsPreference();
expect(result).toEqual({ petSuggestionsEnabled: false });
});
});
describe("owner.setPetSuggestionsPreference", () => {
it("upserts the preference on the calling owner (works even before the Owner row exists)", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.upsert.mockResolvedValue({ id: "owner-1" });
const result = await caller.owner.setPetSuggestionsPreference({ petSuggestionsEnabled: false });
expect(mockPrisma.owner.upsert).toHaveBeenCalledWith({
where: { id: "owner-1" },
create: { id: "owner-1", petSuggestionsEnabled: false },
update: { petSuggestionsEnabled: false },
});
expect(result).toEqual({ ok: true });
});
});
describe("owner.registerPushToken", () => {
it("upserts the Expo push token on the calling owner", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
+50
View File
@@ -89,6 +89,56 @@ describe("stories.create", () => {
caller.create({ petId: "pet-2", storageKey: "stories/owner-1/pet-1/uuid.jpg" })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("attaches a musicTrackId + musicOffsetSecs when the track is active", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
mockPrisma.musicTrack.findUnique.mockResolvedValue({ active: true });
mockPrisma.story.create.mockResolvedValue({ id: "story-1" });
await caller.create({
petId: "pet-1",
storageKey: "stories/owner-1/pet-1/uuid.jpg",
musicTrackId: "track-1",
musicOffsetSecs: 3,
});
expect(mockPrisma.story.create).toHaveBeenCalledWith({
data: expect.objectContaining({ musicTrackId: "track-1", musicOffsetSecs: 3 }),
});
});
it("rejects an inactive music track with BAD_REQUEST", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
mockPrisma.musicTrack.findUnique.mockResolvedValue({ active: false });
await expect(
caller.create({
petId: "pet-1",
storageKey: "stories/owner-1/pet-1/uuid.jpg",
musicTrackId: "track-1",
})
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mockPrisma.story.create).not.toHaveBeenCalled();
});
it("rejects a musicTrackId that doesn't exist with BAD_REQUEST", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
mockPrisma.musicTrack.findUnique.mockResolvedValue(null);
await expect(
caller.create({
petId: "pet-1",
storageKey: "stories/owner-1/pet-1/uuid.jpg",
musicTrackId: "does-not-exist",
})
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
describe("stories.hasActiveStory", () => {
+2
View File
@@ -5,6 +5,7 @@ import { createTRPCCaller } from "@/trpc/server";
import { Sidebar } from "@/components/layout/Sidebar";
import { MobileNav } from "@/components/layout/MobileNav";
import { ActivePetInitializer } from "@/components/layout/ActivePetInitializer";
import { SignOutCleanup } from "@/components/layout/SignOutCleanup";
import { ThemeInitializer } from "@/components/layout/ThemeInitializer";
import { LegalUpdateBanner } from "@/components/legal/LegalUpdateBanner";
import { BroadcastBanner } from "@/components/broadcast/BroadcastBanner";
@@ -77,6 +78,7 @@ export default async function AppLayout({
<ClerkThemeProvider>
<div className="flex min-h-screen">
<ActivePetInitializer firstPetId={pets[0].id} />
<SignOutCleanup />
<ThemeInitializer dbTheme={dbTheme} />
<LegalUpdateBanner />
<BroadcastBanner />
+13 -10
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 } from "lucide-react";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
@@ -16,18 +16,19 @@ import type { PostCardPost } from "@/components/feed/PostCard";
import { useTranslations } from "next-intl";
import { formatRelativeCompact } from "@/lib/format-relative-time";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION";
function notificationIcon(type: NotificationType) {
switch (type) {
case "FOLLOW": return <UserPlus className="h-4 w-4 text-orange-500" />;
case "REACTION": return <Heart className="h-4 w-4 text-orange-500" />;
case "COMMENT": return <MessageCircle className="h-4 w-4 text-orange-500" />;
case "MESSAGE": return <MessageCircle className="h-4 w-4 text-blue-500" />;
case "BIRTHDAY": return <Cake className="h-4 w-4 text-pink-500" />;
case "ADOPTION_DAY": return <Home className="h-4 w-4 text-emerald-500" />;
case "MENTION": return <AtSign className="h-4 w-4 text-violet-500" />;
case "PAW_BACK": return <Repeat2 className="h-4 w-4 text-orange-500" />;
case "FOLLOW": return <UserPlus className="h-4 w-4 text-orange-500" />;
case "REACTION": return <Heart className="h-4 w-4 text-orange-500" />;
case "COMMENT": return <MessageCircle className="h-4 w-4 text-orange-500" />;
case "MESSAGE": return <MessageCircle className="h-4 w-4 text-blue-500" />;
case "BIRTHDAY": return <Cake className="h-4 w-4 text-pink-500" />;
case "ADOPTION_DAY": return <Home className="h-4 w-4 text-emerald-500" />;
case "MENTION": return <AtSign className="h-4 w-4 text-violet-500" />;
case "PAW_BACK": return <Repeat2 className="h-4 w-4 text-orange-500" />;
case "PET_SUGGESTION": return <Sparkles className="h-4 w-4 text-amber-500" />;
}
}
@@ -89,6 +90,7 @@ export default function NotificationsPage() {
function getHref(notif: (typeof notifications)[number]): string | null {
switch (notif.type) {
case "FOLLOW":
case "PET_SUGGESTION":
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
case "BIRTHDAY":
case "ADOPTION_DAY":
@@ -223,6 +225,7 @@ export default function NotificationsPage() {
{notif.type === "PAW_BACK" && t("typePawBack")}
{notif.type === "BIRTHDAY" && t("typeBirthday")}
{notif.type === "ADOPTION_DAY" && t("typeAdoptionDay")}
{notif.type === "PET_SUGGESTION" && t("typePetSuggestion")}
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
+3 -1
View File
@@ -5,6 +5,7 @@ import { buttonVariants } from "@/components/ui/button";
import { PetGrid } from "./PetGrid";
import { DeleteAccountDialog } from "@/components/account/DeleteAccountDialog";
import { HideSpidersToggle } from "@/components/account/HideSpidersToggle";
import { PetSuggestionsToggle } from "@/components/account/PetSuggestionsToggle";
/**
* My Pets page — /pets
@@ -34,8 +35,9 @@ export default async function PetsPage({
<PetGrid pets={pets} />
<div className="mt-10">
<div className="mt-10 space-y-4">
<HideSpidersToggle />
<PetSuggestionsToggle />
</div>
<div className="mt-6 pt-4 border-t flex items-center justify-center gap-4 text-xs text-muted-foreground">
+121
View File
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { timingSafeStringEqual } from "@/lib/timing-safe-compare";
import { getSpiderExclusionId } from "@/lib/spider-exclusion";
import { sendPushForNotification } from "@/lib/notify";
// Same auth pattern as src/app/api/cron/anniversaries/route.ts — Vercel-style
// cron trigger is inert on this self-hosted deployment, needs an external
// (NAS crontab) trigger sending `Authorization: Bearer <CRON_SECRET>`.
function authorized(req: Request): boolean {
const secret = process.env.CRON_SECRET;
if (!secret) return false;
return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`);
}
const SUGGESTION_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // don't repeat a suggestion within 30 days
const CANDIDATE_POOL_SIZE = 20;
function mostCommonId(ids: (string | null)[]): string | null {
const counts = new Map<string, number>();
for (const id of ids) {
if (!id) continue;
counts.set(id, (counts.get(id) ?? 0) + 1);
}
let best: string | null = null;
let bestCount = 0;
for (const [id, count] of counts) {
if (count > bestCount) {
best = id;
bestCount = count;
}
}
return best;
}
/**
* v1 "pet suggestion" heuristic — no location/interest data exists (see root
* CLAUDE.md), so this works purely off Species/Breed affinity inferred from
* the pet's own Follow graph, falling back to the pet's own species/breed for
* a pet that doesn't follow anyone yet. One query pass per pet: fine at this
* app's current scale, revisit if the pet count grows large.
*/
export async function GET(req: Request) {
if (!authorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const suggestionCooldownStart = new Date(Date.now() - SUGGESTION_COOLDOWN_MS);
const pets = await prisma.pet.findMany({
where: { owner: { petSuggestionsEnabled: true } },
select: { id: true, speciesId: true, breedId: true, ownerId: true },
});
let created = 0;
for (const pet of pets) {
const spiderExclusionId = await getSpiderExclusionId(prisma, pet.ownerId);
const follows = await prisma.follow.findMany({
where: { followerPetId: pet.id },
select: { followeePetId: true, followee: { select: { speciesId: true, breedId: true } } },
});
const targetSpeciesId =
follows.length > 0 ? (mostCommonId(follows.map((f) => f.followee.speciesId)) ?? pet.speciesId) : pet.speciesId;
const targetBreedId =
follows.length > 0 ? mostCommonId(follows.map((f) => f.followee.breedId)) : pet.breedId;
const blocks = await prisma.block.findMany({
where: { OR: [{ blockerPetId: pet.id }, { blockedPetId: pet.id }] },
select: { blockerPetId: true, blockedPetId: true },
});
const recentSuggestions = await prisma.notification.findMany({
where: { recipientPetId: pet.id, type: "PET_SUGGESTION", createdAt: { gte: suggestionCooldownStart } },
select: { actorPetId: true },
});
const excludedPetIds = new Set<string>([
pet.id,
...follows.map((f) => f.followeePetId),
...blocks.flatMap((b) => [b.blockerPetId, b.blockedPetId]),
...recentSuggestions.map((n) => n.actorPetId).filter((id): id is string => !!id),
]);
const candidates = await prisma.pet.findMany({
where: {
id: { notIn: Array.from(excludedPetIds) },
speciesId: targetSpeciesId,
owner: { shadowban: null },
...(spiderExclusionId ? { NOT: { speciesId: spiderExclusionId } } : {}),
},
select: { id: true, breedId: true, _count: { select: { followers: true } } },
take: CANDIDATE_POOL_SIZE,
orderBy: { createdAt: "desc" },
});
const best = candidates.sort((a, b) => {
const aBreedMatch = targetBreedId && a.breedId === targetBreedId ? 1 : 0;
const bBreedMatch = targetBreedId && b.breedId === targetBreedId ? 1 : 0;
if (aBreedMatch !== bBreedMatch) return bBreedMatch - aBreedMatch;
return b._count.followers - a._count.followers;
})[0];
if (!best) continue;
await prisma.notification.create({
data: { recipientPetId: pet.id, type: "PET_SUGGESTION", actorPetId: best.id },
});
created++;
await sendPushForNotification(prisma, {
recipientPetId: pet.id,
type: "PET_SUGGESTION",
actorPetId: best.id,
}).catch((err) => console.error("[cron/pet-suggestions] push failed", err));
}
return NextResponse.json({ ok: true, petsScanned: pets.length, notificationsCreated: created });
}
+2
View File
@@ -20,6 +20,7 @@ import {
MessageCircle,
ArrowLeft,
Radio,
Music,
} from "lucide-react";
interface AdminLayoutProps {
@@ -62,6 +63,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
{ href: `${base}/users`, label: "Users", Icon: Users },
{ href: `${base}/messages`, label: "Messages", Icon: MessageCircle },
{ href: `${base}/ads`, label: "Ads", Icon: Megaphone },
{ href: `${base}/music`, label: "Musik-Katalog", Icon: Music },
{ href: `${base}/moderators`, label: "Moderators", Icon: ShieldCheck },
{ href: `${base}/legal`, label: "Legal", Icon: Scale },
...(isModeratorOrAbove
+370
View File
@@ -0,0 +1,370 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { getMusicUrl } from "@/lib/music-url";
import { Plus, Pencil, Trash2, Loader2, Check, X, Music, Play, Pause } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
type TrackForm = {
title: string;
artist: string;
licenseNote: string;
storageKey: string;
durationSecs: number;
};
const emptyForm: TrackForm = { title: "", artist: "", licenseNote: "", storageKey: "", durationSecs: 0 };
const ALLOWED_TYPES = ["audio/mpeg", "audio/mp4", "audio/aac"] as const;
type AllowedType = (typeof ALLOWED_TYPES)[number];
const MAX_SIZE = 5 * 1024 * 1024;
/** Reads an audio file's duration client-side via a detached <audio> element. */
function readAudioDuration(file: File): Promise<number> {
return new Promise((resolve, reject) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener("loadedmetadata", () => {
URL.revokeObjectURL(url);
resolve(audio.duration);
});
audio.addEventListener("error", () => {
URL.revokeObjectURL(url);
reject(new Error("Could not read audio duration"));
});
audio.src = url;
});
}
export default function AdminMusicPage() {
const trpc = useTRPC();
const qc = useQueryClient();
const [form, setForm] = useState<TrackForm>(emptyForm);
const [editingId, setEditingId] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [playingId, setPlayingId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { data: rawTracks, isLoading } = useQuery(trpc.admin.listMusicTracks.queryOptions(undefined));
const tracks = rawTracks ?? [];
const invalidate = () => void qc.invalidateQueries({ queryKey: trpc.admin.listMusicTracks.queryKey() });
const getUploadUrl = useMutation(trpc.admin.getMusicTrackUploadUrl.mutationOptions());
const createTrack = useMutation(
trpc.admin.createMusicTrack.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } })
);
const updateTrack = useMutation(
trpc.admin.updateMusicTrack.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } })
);
const deleteTrack = useMutation(
trpc.admin.deleteMusicTrack.mutationOptions({ onSuccess: () => { invalidate(); setDeleteId(null); } })
);
function openCreate() {
setForm(emptyForm);
setEditingId(null);
setDialogOpen(true);
}
function openEdit(track: (typeof tracks)[number]) {
setForm({
title: track.title,
artist: track.artist,
licenseNote: track.licenseNote,
storageKey: track.storageKey,
durationSecs: track.durationSecs,
});
setEditingId(track.id);
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingId(null);
setForm(emptyForm);
}
function submitForm() {
if (editingId) {
updateTrack.mutate({
id: editingId,
title: form.title,
artist: form.artist,
licenseNote: form.licenseNote,
});
} else {
createTrack.mutate(form);
}
}
const uploadFile = useCallback(
async (file: File) => {
if (file.size > MAX_SIZE) {
toast.error("Audiodatei muss unter 5 MB sein.");
return;
}
if (!ALLOWED_TYPES.includes(file.type as AllowedType)) {
toast.error("Nur MP3, M4A oder AAC erlaubt.");
return;
}
setUploading(true);
try {
const durationSecs = await readAudioDuration(file);
const { presignedUrl, key } = await getUploadUrl.mutateAsync({
contentType: file.type as AllowedType,
fileSize: file.size,
});
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`));
xhr.onerror = () => reject(new Error("Network error"));
xhr.open("PUT", presignedUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.send(file);
});
setForm((f) => ({ ...f, storageKey: key, durationSecs }));
toast.success("Track hochgeladen");
} catch {
toast.error("Upload fehlgeschlagen — bitte erneut versuchen");
} finally {
setUploading(false);
}
},
[getUploadUrl]
);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) void uploadFile(file);
e.target.value = "";
}
function togglePlay(track: (typeof tracks)[number]) {
if (playingId === track.id) {
audioRef.current?.pause();
setPlayingId(null);
return;
}
audioRef.current?.pause();
const audio = new Audio(getMusicUrl(track.storageKey) ?? "");
audio.play();
audio.addEventListener("ended", () => setPlayingId(null));
audioRef.current = audio;
setPlayingId(track.id);
}
const isPending = createTrack.isPending || updateTrack.isPending;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-zinc-100">Musik-Katalog</h1>
<Button size="sm" onClick={openCreate} className="bg-orange-500 hover:bg-orange-600 text-white">
<Plus className="h-4 w-4 mr-1.5" />
Neuer Track
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : tracks.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-12">Noch keine Tracks — oben hochladen</p>
) : (
<div className="space-y-2">
{tracks.map((track) => (
<div
key={track.id}
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
>
<Button
size="sm"
variant="ghost"
className="h-10 w-10 rounded-full bg-zinc-800 text-zinc-300 shrink-0 p-0"
onClick={() => togglePlay(track)}
>
{playingId === track.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-zinc-100 truncate">{track.title}</p>
{track.active ? (
<Badge className="bg-green-700 text-green-100 text-[10px]">Aktiv</Badge>
) : (
<Badge variant="secondary" className="text-[10px]">Inaktiv</Badge>
)}
</div>
<p className="text-xs text-zinc-500 truncate mt-0.5">
{track.artist} · {Math.round(track.durationSecs)}s
</p>
<p className="text-xs text-zinc-600 truncate">{track.licenseNote}</p>
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="ghost" className="text-zinc-400 hover:text-zinc-100" onClick={() => openEdit(track)}>
<Pencil className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="text-red-400 hover:text-red-300" onClick={() => setDeleteId(track.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* Create / Edit dialog */}
<Dialog open={dialogOpen} onOpenChange={(o) => { if (!o) closeDialog(); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-md">
<DialogHeader>
<DialogTitle>{editingId ? "Track bearbeiten" : "Track hochladen"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{!editingId && (
<div>
<input
ref={fileInputRef}
type="file"
accept="audio/mpeg,audio/mp4,audio/aac"
className="hidden"
onChange={handleFileChange}
/>
{form.storageKey ? (
<div className="flex items-center gap-2 rounded-xl border border-zinc-700 px-3 py-2 text-sm text-zinc-300">
<Music className="h-4 w-4 text-orange-500" />
{Math.round(form.durationSecs)}s Audiodatei bereit
<button
type="button"
onClick={() => setForm((f) => ({ ...f, storageKey: "", durationSecs: 0 }))}
className="ml-auto h-5 w-5 rounded-full bg-zinc-800 flex items-center justify-center hover:bg-zinc-700"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="w-full h-20 rounded-xl border-2 border-dashed border-zinc-700 hover:border-zinc-500 flex flex-col items-center justify-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<>
<Music className="h-6 w-6" />
<span className="text-xs">Audio hochladen (MP3, M4A, AAC · max 5 MB)</span>
</>
)}
</button>
)}
</div>
)}
<Input
placeholder="Titel *"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<Input
placeholder="Künstler *"
value={form.artist}
onChange={(e) => setForm({ ...form, artist: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<Textarea
placeholder="Lizenz/Quelle * (z.B. Name der Musikbibliothek, Lizenztyp, Link)"
value={form.licenseNote}
onChange={(e) => setForm({ ...form, licenseNote: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 resize-none"
rows={3}
/>
{editingId && (
<div className="flex items-center gap-3">
<Switch
checked={tracks.find((t) => t.id === editingId)?.active ?? true}
onCheckedChange={(v: boolean) => updateTrack.mutate({ id: editingId, active: v })}
/>
<span className="text-sm text-zinc-300">Aktiv (im Story-Picker sichtbar)</span>
</div>
)}
</div>
<DialogFooter className="gap-2 mt-2">
<Button variant="ghost" className="text-zinc-400" onClick={closeDialog}>
<X className="h-4 w-4 mr-1.5" />
Abbrechen
</Button>
<Button
onClick={submitForm}
disabled={
isPending ||
uploading ||
!form.title.trim() ||
!form.artist.trim() ||
!form.licenseNote.trim() ||
(!editingId && !form.storageKey)
}
className="bg-orange-500 hover:bg-orange-600 text-white"
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Check className="h-4 w-4 mr-1.5" />
)}
{editingId ? "Speichern" : "Erstellen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog open={!!deleteId} onOpenChange={(o) => { if (!o) setDeleteId(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
<DialogTitle>Track löschen</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
Diesen Track dauerhaft löschen? Stories, die ihn bereits verwenden, behalten ihn (Referenz wird nur
entfernt) — falls unsicher, lieber oben auf &bdquo;Inaktiv&ldquo; schalten statt löschen.
</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteId(null)}>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={() => deleteTrack.mutate({ id: deleteId! })}
disabled={deleteTrack.isPending}
>
{deleteTrack.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Löschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import { useTranslations } from "next-intl";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Switch } from "@/components/ui/switch";
/**
* Opt-out for the PET_SUGGESTION cron notification
* (src/app/api/cron/pet-suggestions) — on by default, always re-toggleable
* on /pets. Mirrors HideSpidersToggle.tsx's structure exactly.
*/
export function PetSuggestionsToggle() {
const t = useTranslations("PetSuggestionsToggle");
const trpc = useTRPC();
const qc = useQueryClient();
const { data } = useQuery(trpc.owner.getPetSuggestionsPreference.queryOptions());
const setPreferenceMutation = useMutation(
trpc.owner.setPetSuggestionsPreference.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.owner.getPetSuggestionsPreference.queryKey() });
},
})
);
const petSuggestionsEnabled = data?.petSuggestionsEnabled ?? true;
return (
<div className="flex items-center justify-between gap-4 rounded-xl border bg-card p-4">
<div>
<p className="text-sm font-medium text-foreground">{t("label")}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t("description")}</p>
</div>
<Switch
checked={petSuggestionsEnabled}
onCheckedChange={(checked: boolean) => setPreferenceMutation.mutate({ petSuggestionsEnabled: checked })}
disabled={setPreferenceMutation.isPending}
aria-label={t("label")}
/>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect, useRef } from "react";
import { useAuth } from "@clerk/nextjs";
import { useActivePet } from "@/context/ActivePetContext";
/**
* Mounts invisibly in the app shell (inside ClerkThemeProvider, alongside
* ActivePetInitializer). Clears the persisted activePetId as soon as
* isSignedIn flips to false, so the next owner to sign in on this browser
* (shared device, or the same person switching Clerk accounts) doesn't
* inherit a foreign petId from localStorage — every pet-scoped mutation
* would otherwise fail with assertPetOwnership's FORBIDDEN error (found.md:
* "you do not have permission to access this pet"). Mirrors mobile's
* LogoutSection, which calls the same clearActivePetId() explicitly on its
* own sign-out button — web has no custom sign-out button (Clerk's
* <UserButton/> handles it internally), so this watches the auth state
* itself instead of hooking a click handler.
*/
export function SignOutCleanup() {
const { isSignedIn } = useAuth();
const { clearActivePetId } = useActivePet();
const wasSignedIn = useRef(isSignedIn);
useEffect(() => {
if (wasSignedIn.current && isSignedIn === false) {
clearActivePetId();
}
wasSignedIn.current = isSignedIn;
}, [isSignedIn, clearActivePetId]);
return null;
}
+86 -2
View File
@@ -1,16 +1,18 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Camera, Loader2, ImagePlus, Video } from "lucide-react";
import { Camera, Loader2, ImagePlus, Video, Music, Play, Pause, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
import { computeBlurhash } from "@/lib/blurhash";
import { uploadFileWithProgress } from "@/lib/upload-xhr";
import { getMusicUrl } from "@/lib/music-url";
import { AiDisclosureSelect, type AiDisclosureValue } from "@/components/content/AiDisclosureSelect";
import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits";
@@ -81,6 +83,26 @@ export function StoryForm({ onClose }: StoryFormProps) {
const [uploadedBlurhash, setUploadedBlurhash] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [aiDisclosure, setAiDisclosure] = useState<AiDisclosureValue>("NONE");
const [musicTrackId, setMusicTrackId] = useState<string | null>(null);
const [musicMuteOriginal, setMusicMuteOriginal] = useState(false);
const [previewingTrackId, setPreviewingTrackId] = useState<string | null>(null);
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
const { data: musicTracks } = useQuery(trpc.stories.listMusicTracks.queryOptions());
const togglePreview = useCallback((trackId: string, storageKey: string) => {
if (previewingTrackId === trackId) {
previewAudioRef.current?.pause();
setPreviewingTrackId(null);
return;
}
previewAudioRef.current?.pause();
const audio = new Audio(getMusicUrl(storageKey) ?? "");
audio.addEventListener("ended", () => setPreviewingTrackId(null));
void audio.play();
previewAudioRef.current = audio;
setPreviewingTrackId(trackId);
}, [previewingTrackId]);
const getPresignedUrlMutation = useMutation(
trpc.stories.getPresignedUrl.mutationOptions()
@@ -234,6 +256,7 @@ export function StoryForm({ onClose }: StoryFormProps) {
storageKey: uploadedKey,
blurhash: uploadedBlurhash,
aiDisclosure,
musicTrackId: musicTrackId ?? undefined,
});
return;
}
@@ -244,6 +267,8 @@ export function StoryForm({ onClose }: StoryFormProps) {
const { uploadUrl } = await createVideoUploadMutation.mutateAsync({
petId: activePetId,
aiDisclosure,
musicTrackId: musicTrackId ?? undefined,
musicMuteOriginal: musicTrackId ? musicMuteOriginal : false,
});
await uploadFileWithProgress(uploadUrl ?? "", selectedFile, {
onProgress: setUploadProgress,
@@ -262,6 +287,8 @@ export function StoryForm({ onClose }: StoryFormProps) {
uploadedKey,
uploadedBlurhash,
aiDisclosure,
musicTrackId,
musicMuteOriginal,
selectedFile,
createStoryMutation,
createVideoUploadMutation,
@@ -360,6 +387,63 @@ export function StoryForm({ onClose }: StoryFormProps) {
<AiDisclosureSelect value={aiDisclosure} onChange={setAiDisclosure} />
)}
{uploadState === "ready" && musicTracks && musicTracks.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-sm font-medium">
<Music className="h-4 w-4" aria-hidden="true" />
{t("musicLabel")}
</div>
<div className="flex flex-wrap gap-2">
{musicTracks.map((track) => (
<button
key={track.id}
type="button"
onClick={() => setMusicTrackId((id) => (id === track.id ? null : track.id))}
className={`flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors
${musicTrackId === track.id ? "border-orange-500 bg-orange-500/10 text-orange-600" : "border-muted text-muted-foreground hover:border-muted-foreground/50"}`}
>
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
togglePreview(track.id, track.storageKey);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
togglePreview(track.id, track.storageKey);
}
}}
>
{previewingTrackId === track.id ? (
<Pause className="h-3 w-3" aria-hidden="true" />
) : (
<Play className="h-3 w-3" aria-hidden="true" />
)}
</span>
{track.title}
{musicTrackId === track.id && (
<X
className="h-3 w-3"
onClick={(e) => {
e.stopPropagation();
setMusicTrackId(null);
}}
/>
)}
</button>
))}
</div>
{mediaKind === "video" && musicTrackId && (
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch checked={musicMuteOriginal} onCheckedChange={setMusicMuteOriginal} />
{t("muteOriginalLabel")}
</label>
)}
</div>
)}
<p className="text-xs text-muted-foreground text-center">
{t("disappearsNote")}
</p>
+52 -1
View File
@@ -17,6 +17,7 @@ import {
} from "@/components/ui/sheet";
import { getAvatarUrl } from "@/lib/avatar-url";
import { getMediaUrl } from "@/lib/media-url";
import { getMusicUrl } from "@/lib/music-url";
import { BlurImage } from "@/components/ui/blur-image";
import { AiDisclosureBadge } from "@/components/content/AiDisclosureBadge";
import { StoryPawButton } from "@/components/stories/StoryPawButton";
@@ -71,6 +72,7 @@ export function StoryViewer({
const [commentsOpen, setCommentsOpen] = useState(false);
const [isMuted, setIsMuted] = useState(true);
const advancedRef = useRef(false);
const musicAudioRef = useRef<HTMLAudioElement>(null);
// Portal target must wait for mount (document isn't available during SSR).
const [mounted, setMounted] = useState(false);
@@ -85,6 +87,11 @@ export function StoryViewer({
const currentStory = stories[currentIndex];
const isOwnPet = activePetId === petId;
const isVideo = currentStory?.mediaType === "VIDEO";
const musicUrl = currentStory?.musicTrack ? getMusicUrl(currentStory.musicTrack.storageKey) : null;
// For a VIDEO story, the overlay track only plays if musicMuteOriginal was
// chosen at creation time (see StoryForm.tsx) — otherwise the video keeps
// its own baked-in Mux audio and the track is ignored, even if attached.
const shouldPlayMusic = !!musicUrl && open && (!isVideo || currentStory?.musicMuteOriginal);
// Poll while a video story is still processing — same self-heal pattern
// as VideoCard, scoped to just the story currently on screen.
@@ -162,6 +169,30 @@ export function StoryViewer({
}
}, [isVideo, currentStory?.id]);
// Music overlay — (re)start whenever the current story changes, stop when
// this story has none (or music playback isn't applicable, see
// shouldPlayMusic above). Not tightly synced to video currentTime — a
// simple "start together, stop on advance" is the accepted v1 tradeoff
// for VIDEO stories (see the Feature-2 plan's music-overlay section).
useEffect(() => {
const audio = musicAudioRef.current;
if (!audio) return;
if (!shouldPlayMusic) {
audio.pause();
return;
}
audio.currentTime = currentStory?.musicOffsetSecs ?? 0;
audio.muted = isMuted;
void audio.play();
return () => audio.pause();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldPlayMusic, currentStory?.id]);
// Mute toggle also controls the music overlay, not just the video's own track.
useEffect(() => {
if (musicAudioRef.current) musicAudioRef.current.muted = isMuted;
}, [isMuted]);
const handleVideoTimeUpdate = useCallback((e: Event) => {
// mux-player's events are raw DOM Events, not React SyntheticEvents —
// e.target is the <mux-player> element, which exposes currentTime/
@@ -273,7 +304,11 @@ export function StoryViewer({
playbackId={videoPlaybackId}
streamType="on-demand"
autoPlay
muted={isMuted}
// musicMuteOriginal forces the video's own track off
// permanently — the mute button then controls the
// overlay track instead (see the music-overlay effects
// above), not this player's muted state.
muted={currentStory.musicMuteOriginal || isMuted}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
onTimeUpdate={handleVideoTimeUpdate}
onEnded={handleVideoEnded}
@@ -320,10 +355,26 @@ export function StoryViewer({
value={currentStory.aiDisclosure}
className="top-auto left-auto bottom-3 right-3 h-10"
/>
{musicUrl && (
<button
className="absolute bottom-3 right-3 flex h-10 w-10 items-center justify-center rounded-full bg-black/40 text-white hover:bg-black/60"
onClick={() => setIsMuted((m) => !m)}
aria-label={isMuted ? t("unmute") : t("mute")}
>
{isMuted ? (
<VolumeX className="h-4 w-4" aria-hidden="true" />
) : (
<Volume2 className="h-4 w-4" aria-hidden="true" />
)}
</button>
)}
</div>
)
)}
{/* Music overlay track — hidden, controlled entirely via musicAudioRef (see effects above). */}
{musicUrl && <audio ref={musicAudioRef} src={musicUrl} loop={!isVideo} />}
{/* Pet info — bottom left */}
<div className="absolute bottom-6 left-4 flex items-center gap-2">
<Avatar className="w-8 h-8 border border-white/50">
+13 -1
View File
@@ -18,6 +18,13 @@ import { createContext, useContext, useState } from "react";
type ActivePetContextType = {
activePetId: string | null;
setActivePet: (id: string) => void;
/** Call on sign-out — otherwise the next owner to sign in on this browser
* (shared device, or the same person switching Clerk accounts) inherits
* the previous owner's activePetId from localStorage, and every
* pet-scoped mutation fails with assertPetOwnership's FORBIDDEN error
* (found.md: "you do not have permission to access this pet"). Mirrors
* mobile's ActivePetContext.tsx clearActivePetId(). */
clearActivePetId: () => void;
};
const ActivePetContext = createContext<ActivePetContextType | null>(null);
@@ -44,8 +51,13 @@ export function ActivePetProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem(STORAGE_KEY, id);
};
const clearActivePetId = () => {
setActivePetId(null);
localStorage.removeItem(STORAGE_KEY);
};
return (
<ActivePetContext.Provider value={{ activePetId, setActivePet }}>
<ActivePetContext.Provider value={{ activePetId, setActivePet, clearActivePetId }}>
{children}
</ActivePetContext.Provider>
);
+9
View File
@@ -0,0 +1,9 @@
/**
* Constructs the public CDN URL for a track in the music-tracks bucket.
* Mirrors getMediaUrl's pattern (src/lib/media-url.ts) but for the separate
* MUSIC_STORAGE_BUCKET — safe to use in both server and client components.
*/
export function getMusicUrl(key: string | null | undefined): string | null {
if (!key) return null;
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/music-tracks/${key}`;
}
+6 -1
View File
@@ -38,6 +38,7 @@ const PUSH_COPY: Record<
ADOPTION_DAY: { title: "Adoptionstag", genericBody: "Ein Adoptionstag steht an.", actorBody: (a) => `${a} feiert heute den Adoptionstag!` },
MENTION: { title: "Erwähnung", genericBody: "Du wurdest in einem Beitrag erwähnt.", actorBody: (a) => `${a} hat dich erwähnt.` },
PAW_BACK: { title: "Paw-Back", genericBody: "Jemand hat deinen Beitrag geteilt.", actorBody: (a) => `${a} hat deinen Beitrag geteilt.` },
PET_SUGGESTION: { title: "Vorschlag für dich", genericBody: "Ein Tier könnte dir gefallen.", actorBody: (a) => `${a} könnte dir gefallen!` },
};
/**
@@ -45,8 +46,12 @@ const PUSH_COPY: Record<
* Separate from the in-app Notification row above — a missing/expired push
* token, or the owner never having opened the mobile app, is the normal case
* and must silently no-op rather than error.
*
* Exported (not just used via notify() above) so a batch cron job that needs
* an awaited, counted prisma.notification.create (see notify()'s own doc
* comment) can still trigger the matching push afterward.
*/
async function sendPushForNotification(
export async function sendPushForNotification(
prisma: Pick<PrismaClient, "pet" | "owner">,
params: NotifyParams
): Promise<void> {
+13 -6
View File
@@ -20,13 +20,20 @@ export const STORAGE_BUCKET = "pet-avatars";
export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export const ALLOWED_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
// Music-track catalog (admin-curated, see admin.ts's music-track procedures)
// — a separate bucket since it holds audio, not images. Must be created once
// in the Supabase dashboard; this code assumes it already exists.
export const MUSIC_STORAGE_BUCKET = "music-tracks";
export const MAX_MUSIC_FILE_SIZE = 5 * 1024 * 1024; // 5 MB — short clips only
export const MUSIC_ALLOWED_CONTENT_TYPES = ["audio/mpeg", "audio/mp4", "audio/aac"] as const;
/**
* Creates a signed upload URL for direct client-to-Supabase uploads.
* The client PUTs the file directly to this URL — bytes never pass through the API server.
*/
export async function createSignedUploadUrl(key: string) {
export async function createSignedUploadUrl(key: string, bucket: string = STORAGE_BUCKET) {
const { data, error } = await getClient().storage
.from(STORAGE_BUCKET)
.from(bucket)
.createSignedUploadUrl(key);
if (error) throw new Error(`Storage error: ${error.message}`);
@@ -36,8 +43,8 @@ export async function createSignedUploadUrl(key: string) {
/**
* Returns the public CDN URL for a stored object.
*/
export function getPublicUrl(key: string): string {
const { data } = getClient().storage.from(STORAGE_BUCKET).getPublicUrl(key);
export function getPublicUrl(key: string, bucket: string = STORAGE_BUCKET): string {
const { data } = getClient().storage.from(bucket).getPublicUrl(key);
return data.publicUrl;
}
@@ -45,9 +52,9 @@ export function getPublicUrl(key: string): string {
* Deletes a batch of storage objects by key. Non-throwing — storage cleanup
* failure is logged but must never block account deletion.
*/
export async function deleteFiles(keys: string[]): Promise<void> {
export async function deleteFiles(keys: string[], bucket: string = STORAGE_BUCKET): Promise<void> {
if (keys.length === 0) return;
const { error } = await getClient().storage.from(STORAGE_BUCKET).remove(keys);
const { error } = await getClient().storage.from(bucket).remove(keys);
if (error) {
console.error("[storage] deleteFiles partial failure:", error.message);
}
+115
View File
@@ -10,6 +10,9 @@ import {
createSignedUploadUrl,
ALLOWED_CONTENT_TYPES,
MAX_FILE_SIZE,
MUSIC_STORAGE_BUCKET,
MUSIC_ALLOWED_CONTENT_TYPES,
MAX_MUSIC_FILE_SIZE,
} from "@/lib/supabase-storage";
import { buildDataExport } from "@/lib/gdpr-export";
import { uploadExport } from "@/lib/data-export-storage";
@@ -1505,6 +1508,118 @@ export const adminRouter = router({
});
}),
// ─── Music track catalog (Story music overlay) ────────────────────────────────
// Admin-curated only — no user upload path, so copyright liability for each
// track stays with the admin who uploaded it (licenseNote is required).
getMusicTrackUploadUrl: protectedProcedure
.input(
z.object({
contentType: z.enum(MUSIC_ALLOWED_CONTENT_TYPES),
fileSize: z.number().max(MAX_MUSIC_FILE_SIZE, "Audio file must be under 5 MB"),
})
)
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
const ext = input.contentType.split("/")[1]!.replace("mpeg", "mp3");
const key = `tracks/${randomUUID()}.${ext}`;
const { signedUrl } = await createSignedUploadUrl(key, MUSIC_STORAGE_BUCKET);
return { presignedUrl: signedUrl, key };
}),
listMusicTracks: protectedProcedure
.input(z.object({ activeOnly: z.boolean().default(false) }).optional())
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
return ctx.prisma.musicTrack.findMany({
where: input?.activeOnly ? { active: true } : undefined,
orderBy: { createdAt: "desc" },
});
}),
createMusicTrack: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(100),
artist: z.string().min(1).max(100),
licenseNote: z.string().min(1).max(500),
storageKey: z.string().min(1),
durationSecs: z.number().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
return ctx.prisma.$transaction(async (tx) => {
const track = await tx.musicTrack.create({
data: { ...input, createdBy: ctx.userId },
});
await tx.moderationLog.create({
data: {
action: "CREATE_MUSIC_TRACK",
targetType: "MUSIC_TRACK",
targetId: track.id,
reason: input.title,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
});
return track;
});
}),
updateMusicTrack: protectedProcedure
.input(
z.object({
id: z.string().min(1),
title: z.string().min(1).max(100).optional(),
artist: z.string().min(1).max(100).optional(),
licenseNote: z.string().min(1).max(500).optional(),
active: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
const { id, ...rest } = input;
const [track] = await ctx.prisma.$transaction([
ctx.prisma.musicTrack.update({ where: { id }, data: rest }),
ctx.prisma.moderationLog.create({
data: {
action: "UPDATE_MUSIC_TRACK",
targetType: "MUSIC_TRACK",
targetId: id,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return track;
}),
// Hard delete — only safe once no Story references the track any more
// (Story.musicTrackId is onDelete: SetNull, so this wouldn't corrupt old
// stories either way, but updateMusicTrack's active:false is the preferred
// way to retire a track without breaking its own audit trail).
deleteMusicTrack: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
await ctx.prisma.$transaction([
ctx.prisma.musicTrack.delete({ where: { id: input.id } }),
ctx.prisma.moderationLog.create({
data: {
action: "DELETE_MUSIC_TRACK",
targetType: "MUSIC_TRACK",
targetId: input.id,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return { success: true };
}),
// ─── Moderator management (grant/revoke stay super-admin only) ───────────────
listModerators: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
+21
View File
@@ -46,6 +46,27 @@ export const ownerRouter = router({
return { ok: true };
}),
// Opt-out for the PET_SUGGESTION cron notification
// (src/app/api/cron/pet-suggestions) — on by default.
getPetSuggestionsPreference: protectedProcedure.query(async ({ ctx }) => {
const owner = await ctx.prisma.owner.findUnique({
where: { id: ctx.userId },
select: { petSuggestionsEnabled: true },
});
return { petSuggestionsEnabled: owner?.petSuggestionsEnabled ?? true };
}),
setPetSuggestionsPreference: protectedProcedure
.input(z.object({ petSuggestionsEnabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.owner.upsert({
where: { id: ctx.userId },
create: { id: ctx.userId, petSuggestionsEnabled: input.petSuggestionsEnabled, registrationIp: ctx.ip },
update: { petSuggestionsEnabled: input.petSuggestionsEnabled },
});
return { ok: true };
}),
// Registers this device's Expo push token for the signed-in owner. Single
// device only (overwrites any previous token) — good enough until a device
// actually needs the app on two phones at once.
+43 -1
View File
@@ -1,6 +1,7 @@
import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import type { PrismaClient } from "@prisma/client";
import { router, protectedProcedure, rateLimited } from "@/trpc/init";
import {
createSignedUploadUrl,
@@ -25,7 +26,30 @@ import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits";
* - All queries filter expiresAt > now — expired stories never returned (T-02-44)
*/
/** Shared by create/createVideoUpload — a story can only attach a live, admin-curated track. */
async function assertMusicTrackActive(prisma: PrismaClient, musicTrackId: string): Promise<void> {
const track = await prisma.musicTrack.findUnique({
where: { id: musicTrackId },
select: { active: true },
});
if (!track || !track.active) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Selected music track is not available." });
}
}
export const storiesRouter = router({
/**
* Active tracks from the admin-curated music catalog, for the story
* creation music picker. No user-upload path exists (see admin.ts's
* music-track procedures) — this is read-only.
*/
listMusicTracks: protectedProcedure.query(async ({ ctx }) => {
return ctx.prisma.musicTrack.findMany({
where: { active: true },
orderBy: { createdAt: "desc" },
});
}),
/**
* Generate a presigned PUT URL for direct upload to Supabase Storage.
* Key scoped to stories/{ownerId}/{petId}/{uuid}.ext (T-02-41).
@@ -58,10 +82,13 @@ export const storiesRouter = router({
storageKey: z.string().startsWith("stories/"),
blurhash: z.string().max(100).nullable().optional(),
aiDisclosure: z.enum(["NONE", "AI_MODIFIED", "AI_GENERATED"]).default("NONE"),
musicTrackId: z.string().min(1).optional(),
musicOffsetSecs: z.number().min(0).optional(),
})
)
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
if (input.musicTrackId) await assertMusicTrackActive(ctx.prisma, input.musicTrackId);
// Pitfall 5: use explicit UTC arithmetic — avoids timezone issues
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
@@ -72,6 +99,8 @@ export const storiesRouter = router({
storageKey: input.storageKey,
blurhash: input.blurhash ?? null,
aiDisclosure: input.aiDisclosure,
musicTrackId: input.musicTrackId ?? null,
musicOffsetSecs: input.musicOffsetSecs ?? 0,
expiresAt,
},
});
@@ -85,9 +114,18 @@ export const storiesRouter = router({
* server-side once the asset's real duration is known.
*/
createVideoUpload: protectedProcedure
.input(z.object({ petId: z.string().min(1), aiDisclosure: z.enum(["NONE", "AI_MODIFIED", "AI_GENERATED"]).default("NONE") }))
.input(
z.object({
petId: z.string().min(1),
aiDisclosure: z.enum(["NONE", "AI_MODIFIED", "AI_GENERATED"]).default("NONE"),
musicTrackId: z.string().min(1).optional(),
musicOffsetSecs: z.number().min(0).optional(),
musicMuteOriginal: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
if (input.musicTrackId) await assertMusicTrackActive(ctx.prisma, input.musicTrackId);
if (!process.env.MUX_TOKEN_ID || !process.env.MUX_TOKEN_SECRET) {
throw new TRPCError({
@@ -114,6 +152,9 @@ export const storiesRouter = router({
muxUploadId: upload.id,
videoStatus: "PROCESSING",
aiDisclosure: input.aiDisclosure,
musicTrackId: input.musicTrackId ?? null,
musicOffsetSecs: input.musicOffsetSecs ?? 0,
musicMuteOriginal: input.musicMuteOriginal,
expiresAt,
},
});
@@ -181,6 +222,7 @@ export const storiesRouter = router({
.query(async ({ ctx, input }) => {
return ctx.prisma.story.findMany({
where: { petId: input.petId, expiresAt: { gt: new Date() } },
include: { musicTrack: true },
orderBy: { createdAt: "asc" },
take: 50,
});
+4
View File
@@ -7,6 +7,10 @@
{
"path": "/api/cron/trim-feeds",
"schedule": "0 4 * * *"
},
{
"path": "/api/cron/pet-suggestions",
"schedule": "0 9 * * 1"
}
]
}