diff --git a/README.md b/README.md index b4f9b4d..1bae460 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/messages/de.json b/messages/de.json index cbae96e..d2e5c32 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 0d3642a..df36ed8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/mobile/PENDING-NATIVE-BUILD.md b/mobile/PENDING-NATIVE-BUILD.md new file mode 100644 index 0000000..2329581 --- /dev/null +++ b/mobile/PENDING-NATIVE-BUILD.md @@ -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: + . . . +``` diff --git a/mobile/app/(app)/(tabs)/notifications.tsx b/mobile/app/(app)/(tabs)/notifications.tsx index 37ec830..2f46029 100644 --- a/mobile/app/(app)/(tabs)/notifications.tsx +++ b/mobile/app/(app)/(tabs)/notifications.tsx @@ -19,6 +19,7 @@ const ACTOR_MESSAGE_KEYS: Partial> = { 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": diff --git a/mobile/app/(app)/(tabs)/pets.tsx b/mobile/app/(app)/(tabs)/pets.tsx index bcb4258..50bfcc4 100644 --- a/mobile/app/(app)/(tabs)/pets.tsx +++ b/mobile/app/(app)/(tabs)/pets.tsx @@ -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() { <> + + + + } /> @@ -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 ( + + + + {t("pets.petSuggestionsTitle")} + {t("pets.petSuggestionsLabel")} + + setPreferenceMutation.mutate({ petSuggestionsEnabled: value })} + disabled={setPreferenceMutation.isPending} + trackColor={{ true: colors.marigoldOrange }} + /> + + + ); +} + /** 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 Footer-Fehler: {this.state.error.message}; + } + 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 + + + {isLoading ? ( +
+ +
+ ) : tracks.length === 0 ? ( +

Noch keine Tracks — oben hochladen

+ ) : ( +
+ {tracks.map((track) => ( +
+ + +
+
+

{track.title}

+ {track.active ? ( + Aktiv + ) : ( + Inaktiv + )} +
+

+ {track.artist} · {Math.round(track.durationSecs)}s +

+

{track.licenseNote}

+
+ +
+ + +
+
+ ))} +
+ )} + + {/* Create / Edit dialog */} + { if (!o) closeDialog(); }}> + + + {editingId ? "Track bearbeiten" : "Track hochladen"} + + +
+ {!editingId && ( +
+ + {form.storageKey ? ( +
+ + {Math.round(form.durationSecs)}s Audiodatei bereit + +
+ ) : ( + + )} +
+ )} + + setForm({ ...form, title: e.target.value })} + className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500" + /> + setForm({ ...form, artist: e.target.value })} + className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500" + /> +