found.md: Abweisen schickt dem Melder eine Team-Nachricht mit optionaler
Begruendung (auch im ModerationLog), Loeschen eines gemeldeten Beitrags
dankt jedem Melder einmal. Beides per Schalter im Admin-Dialog abschaltbar,
best-effort nach der Moderation. Die interne Loesch-Begruendung geht nicht
an den Melder.
Fix nebenbei: deleteReportedPost loescht jetzt die Reports des Beitrags
(targetPostId ist keine Relation, sie blieben bisher verwaist in der Liste).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Der Test in src/__tests__ importierte aus mobile/, das .dockerignore und
die Web-tsconfig ausschliessen -> next build brach beim Typecheck ab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Datum (found.md, iOS-Feedback): neue DateField-Komponente mit Ziffern-
tastatur und automatisch eingefügten Punkten (TT.MM.JJJJ) für Geburtstag,
Adoptionstag, Gewicht, Tierarzt und Impfungen. Parser akzeptiert weiter
"-" und "/" sowie einstellige Tage/Monate. Wheel-Picker bleibt in
PENDING-NATIVE-BUILD.md (natives Modul).
Impf-Erinnerung (found.md, iOS-Feedback): täglicher Cron
/api/cron/vaccine-reminders erinnert einmalig 30 Tage vor
Vaccine.nextDueDate per Mitteilung (VACCINE_DUE) + Push mit Impfname und
Datum. Schema: NotificationType.VACCINE_DUE, Notification.vaccineId
(Cascade), Vaccine.reminderSentAt (Claim gegen Doppelversand, wird bei
Fehler wieder freigegeben). Web + App zeigen die Mitteilung an, Tippen
öffnet die Gesundheitsseite. App: Schnellauswahl +1/+3 Jahre für den
Folgetermin plus Hinweis auf die Erinnerung.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bisher folgte nur "PawFeed Team" dem neuen Tier, der Feed des neuen
Nutzers startete leer. welcomeNewPet legt jetzt auch den Follow in
Gegenrichtung an und befüllt den Redis-Feed per backfillOnFollow mit den
letzten Team-Posts. Gleicher Admin-Schalter (autoFollowEnabled),
Beschriftung im Kurator-Panel angepasst.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- petAdmin.speciesDistribution: Anzahl pro Tierart (auch Arten ohne Tiere)
und pro Rasse, deutsche Namen, ohne das Kurator-Tier „PawFeed Team“
- Karte "Tierarten" auf dem Dashboard: sortiertes Balkendiagramm (eine
Reihe, Markenorange), Anzahl + Anteil als Text, Tooltip bei Hover/Fokus,
Klick klappt die Rassen der Art auf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admin /p/[secret]/pets:
- Liste mit Suche (Name, Spitzname, Empfehlungscode, ID), Filtern und
Sortierung; Detail-Panel mit Kennzahlen, Owner, Profiltexten, Beiträgen
- Verifiziert / Gründungsmitglied pro Tier umschalten
- Empfehlungsränge des Owners: dauerhaft entziehen (bis zur Freigabe),
freigeben oder manuell vergeben (besondere Leistung)
- Inhalte leeren (Profilbild, Bio, Adoptionsgeschichte, Spitzname) und Tier
löschen — jeweils mit Begründung, Owner per Team-DM informiert bzw. per
E-Mail, wenn es sein einziges Tier war
Ränge: ReferralRankAchievement bekommt revokedAt/revokeReason/
grantedManually; entzogene Ränge werden weder neu vergeben noch im Cache
berücksichtigt (awardRanks, recomputeReferralRanks, referrals.me, Export).
Fix: deletePetCompletely (src/lib/delete-pet.ts) für Owner-Löschen,
Kontolöschung und Admin — vorher konnte das Löschen an StoryView/Report/
Repost-Fremdschlüsseln ohne Cascade scheitern, und die Zähler an fremden
Beiträgen/Storys wurden nicht verringert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Android → Google Play, UTM-Werte als Play-referrer (Play Console)
- iPhone → App Store, sobald APP_STORE_URL gesetzt ist (D5); bis dahin Web
- sonst → Web-Startseite; ?ref=<Code> führt auf die Einladungsseite /r/<Code>
- nur sichere UTM-Werte werden übernommen, nie gecacht (302, no-store)
- Zählung pro Tag/Quelle/Gerät in Redis, Anzeige im Admin unter
Empfehlungen → Übersicht (30 Tage)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Pet.isFoundingMember: in pets.create per bewachter SQL-Anweisung für die
ersten 250 Tiere gesetzt, nie entzogen; Abzeichen (Spross) in Feed und
Profil, Web + App, unabhängig vom Empfehlungsprogramm
- Kurator "PawFeed Team": folgt jedem neuen Tier und schickt eine
Begrüßungs-Direktnachricht (DE/EN nach Sprache beim Anlegen), im
Hintergrund — blockiert das Onboarding nicht
- CuratorSettings + Admin-Seite /p/[secret]/curator: Schalter, Texte mit
Pflicht-Platzhalter {name} und Vorschau, Zähler der Gründungsmitglieder
- prisma/backfill_founding_members.sql, prisma/enable_rls_curator.sql
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- stories.listActive: der Owner bekommt seine Storys der letzten 7 Tage mit
isExpired, alle anderen weiterhin nur die aktiven 24 Stunden
- stories.hasActiveStory: optional includeOwnRecent → grauer Ring
(expiredOnly) nur in der Story-Leiste, nicht an Profilbildern im Feed
- Viewer: Hinweis "Abgelaufen – nur für dich sichtbar", Kommentare bei
abgelaufenen Storys nur lesbar, Pfote nur als Zähler; Löschen und
Zuschauerliste bleiben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- /api/cron/cleanup-stories (CRON_SECRET): löscht Storys älter als 7 Tage
samt Aufrufen, Pfoten, Kommentaren, Supabase-Bild und Mux-Asset; max. 200
pro Lauf, Fehler einzelner Storys brechen den Lauf nicht ab
- gemeinsame Funktion deleteStoryWithMedia (src/lib/story-cleanup.ts), auch
von stories.delete genutzt
- Datenschutzerklärung Abschnitt 6: Storys spätestens nach 7 Tagen gelöscht
- Datenexport: Story-Block heißt "stories" statt "activeStories"
- CLAUDE.md: Cron-Jobs laufen über die NAS-Crontab + docker/run-cron.sh
(der alte Hinweis "nichts ruft trim-feeds auf" war veraltet)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- stories.delete: nur Owner; Aufrufe, Story (Pfoten/Kommentare per Cascade),
danach Bild in Supabase Storage bzw. Mux-Asset best-effort
- Web: Papierkorb neben der Zuschauerzahl + Rückfrage, Timer pausiert,
gelöscht wird die beim Antippen gemerkte Story
- App: gleicher Papierkorb mit nativer Rückfrage, Timer pausiert
- found.md: Nutzer-Feedback "Story löschbar" erledigt, 7-Tage-Wunsch mit
Befund als offener Punkt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- App-Viewer: Pfote, Kommentar-Sheet (schreiben, eigene löschen) und für die
eigene Story die Zuschauerliste; Timer pausiert, solange ein Sheet offen ist
- stories.listActive: optionales viewerPetId → reactedByViewer, damit die
Pfote gefüllt startet (abwärtskompatibel für alte App-Builds)
- Web-StoryPawButton nutzt reactedByViewer ebenfalls und wird pro Story
neu initialisiert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Die Leiste zeigte nur Storys gefolgter Tiere — das eigene aktive Tier war
nie enthalten, man konnte die eigene Story nach dem Posten nicht ansehen.
Neuer Ring direkt nach der "+"-Kachel, solange stories.hasActiveStory für
das aktive Tier true ist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- App: post-new.tsx sortierte HEIC/HEIF still aus, obwohl resizeForUpload()
ohnehin in JPEG umwandelt — jetzt erlaubt, plus Hinweis bei übersprungenen
Fotos
- Web: neues src/lib/heic-to-jpeg.ts wandelt HEIC vor dem Upload im Browser
in JPEG um (Safari kann HEIC nativ dekodieren); eingebaut in Beiträge,
Storys, Meilensteine und Tierbild, accept um HEIC ergänzt. Browser ohne
HEIC-Dekodierung bekommen eine klare Meldung statt "falsches Format".
- Server-Whitelist (JPEG/PNG/WebP) bleibt unverändert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Die Avatar-Variante "lg" setzt per data-Attribut fest size-10 (40 px) und
überstimmte die eigene Größe — jetzt Standardvariante mit size-28 / sm:size-36
und dezentem Ring in Markenfarbe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Datenexport (Art. 15): neuer Block referralProgram mit eigenem Code,
Herkunftsangabe, "eingeladen von", gemachten Einladungen, erreichten Rängen,
aktuellen Vorteilen und den früheren Einmal-Codes. Andere Personen nur über
den Tiernamen — invitedById (Clerk-ID eines Dritten) fliegt aus dem Export.
- Kontolöschung: invitedById bei Eingeladenen und usedById bei Beta-Codes
werden geleert, damit keine Clerk-ID der gelöschten Person zurückbleibt.
Gezählte Einladungen bleiben gezählt.
- Einlösen sperrt jetzt auch über invitedAt, damit nach dem Leeren von
invitedById kein zweiter Code eingelöst werden kann.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Proxy-Sperre für /sign-up, INVITE_REQUIRED, set-invite/consume-invite,
invites-Router, Admin-Invite-Seite und -Prozeduren, Startcodes in
pets.create entfernt — Registrierung ist offen
- /join?code=… leitet auf /r/<code> um, /invite auf /einladen; alte geteilte
Links laufen nicht ins Leere
- InviteCode-Tabelle bleibt nur als Historie (Kontolöschung räumt sie weiter auf)
- Datenschutzerklärung 4b (Empfehlungsprogramm, Herkunftsangabe, pf_ref-Cookie)
und Nutzungsbedingungen 3b (Programmbedingungen, Bestandsschutz, Missbrauch)
- CLAUDE.md: Abschnitt "Referral program" statt "Invite system"
Build, Typecheck und alle Tests grün.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Persönliche, unbegrenzt nutzbare Empfehlungscodes (z. B. BUBI-7K3M), die erst
nach der Registrierung im Onboarding eingelöst werden — keine Kopplung an den
Clerk-Sign-up-Ablauf mehr. Registrierung ohne Code bleibt möglich.
- Schema (rein additiv): Owner-Felder für Code, Einladungsstatus, Zähler und
Rang-Cache, ReferralRank / ReferralRankAchievement / ReferralSettings,
AcquisitionSource, NotificationType REFERRAL_JOINED / REFERRAL_RANK_UP
- Kernlogik src/lib/referrals.ts: Einlösen (7-Tage-Fenster, kein eigener Code,
keine Kreis-Einladungen, gesperrte Einladende ungültig), gegenseitiges
Folgen, Qualifizierung erst nach Tier + erstem Beitrag (Foto oder fertiges
Video), Tageslimit mit Prüf-Markierung, Ränge mit Bestandsschutz
- Web: /einladen, öffentliche Seite /r/[code], Onboarding-Schritt
"Wie hast du von PawFeed erfahren?", Rang-Abzeichen, Profilring,
"Eingeladen von …", neue Benachrichtigungen
- Admin /p/[secret]/referrals: Ränge anlegen/bearbeiten/deaktivieren mit
Live-Vorschau, Einstellungen, Übersicht mit Prüfung und Aberkennung;
bevorzugte Verifizierung als Vorteil
- prisma/scripts/referral-setup.ts: Start-Ränge, Settings, Code-Backfill,
optional --credit-existing für Alt-Einladungen
Noch offen: Mobile-App, Abbau des alten InviteCode-Ablaufs, Rechtstexte,
db push + Deploy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Replace next/font/google with next/font/local + vendored Inter variable
woff2 (latin, @fontsource-variable/inter@5.3.0), so neither visitors nor
the Docker build contact Google's servers.
- Add PlayStoreBadge (official Google Play artwork, DE/EN by locale, served
locally from /public/badges) to the hero, a new "PawFeed in your pocket"
section, and the shared landing footer (also covers /ueber-uns).
- Centralize the Play Store URL in src/lib/app-links.ts (derived from the
android.package id org.pawfeed.app).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The story upload input's accept attribute listed explicit image+video
MIME subtypes (image/jpeg,...,video/mp4,...). Several mobile
browsers'/OS's native 'Photos & Videos' pickers only reliably offer
both media kinds when given the broad image/*,video/* wildcard
categories - the explicit mixed list was silently narrowing the picker
to photos only, matching the user's report ('nur Zugriff auf Fotos,
Videos werden gar nicht angezeigt').
The actual accept/reject decision is unchanged (still enforced in
handleFile() against the specific ALLOWED_IMAGE_TYPES/
ALLOWED_VIDEO_TYPES lists) - this only relaxes what the OS offers in
the picker dialog.
Code-reviewed before commit (WORKFLOW.md step 6) - fixed the one
finding that became newly reachable by this fix: videos were
previously never selectable at all, so a parameterized/codec-suffixed
MIME string (e.g. from some Android camera-roll
pickers) could never surface as a rejection; now strips codec
parameters before the ALLOWED_VIDEO_TYPES comparison. Documented two
other findings as accepted, not-fixed-here trade-offs in code comments
(iOS HEIC auto-transcode interaction, broader picker surfacing formats
that still get rejected post-selection) rather than silently ignoring
them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two independent root causes behind 'admin music upload fails':
1. Supabase bucket name mismatch — code expects 'music-tracks' (plural,
MUSIC_STORAGE_BUCKET), but the bucket was created as 'music-track'
(singular). Every upload failed with a bucket-not-found error from
Supabase Storage, surfaced only as a generic toast. Fixed by creating
the correctly-named bucket (public, 5MB limit, audio/mpeg|mp4|aac
allowed_mime_types matching the app's own constants) via the Supabase
Storage API; verified end-to-end with a signed-upload-url + PUT probe.
The old empty 'music-track' bucket is unused but still exists -
deleting it needs the user's own action (blocked here by the
auto-mode classifier's cloud-storage-delete guard).
2. Client-side validation trusted the browser-reported File.type, which
is sniffed from the OS's MIME registry and is inconsistent for
.m4a/.aac (e.g. Windows Chrome reports .m4a as audio/x-m4a, not
audio/mp4) - a perfectly valid file could get rejected as 'wrong
type'. Now derives the content-type from the file extension instead.
Code-reviewed before commit (WORKFLOW.md step 6) - fixed two of four
findings: the extension lookup now uses a Map instead of a plain object
(prototype-shadowing via a file named e.g. 'x.constructor' previously
bypassed the rejection), and requires an actual '.' before the
extension (a bare filename like 'mp3' no longer matches). Extracted the
allowed-types/size constants into a new client-safe src/lib/music-
content-types.ts (supabase-storage.ts re-exports them) so the client
form and server-side Zod schema share one source of truth instead of
two independently maintained copies. Left the trust-the-extension
trade-off as-is (documented in code): this panel sits behind
ADMIN_SECRET + a role check, not the public upload surface, and the
old File.type check was never real content-based validation either.
Full test suite green (one unrelated legal.test.ts timeout in the
full-suite run was confirmed flaky - passes cleanly in isolation).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
New public page at /kinderschutz — required by Google Play's Child
Safety Standards policy for apps with user-generated content/social
features (https://support.google.com/googleplay/android-developer/answer/14747720).
Registered as public + maintenance-exempt in proxy.ts, matching the
existing /impressum, /datenschutz, /nutzungsbedingungen pattern.
Content covers only mechanisms that actually exist in this codebase:
in-app reporting (ReportSheet + admin moderation review), a named
responsible contact (same identity as /impressum), and the German/EU
legal framework (StGB §184b/c, DSA, JuSchG) for law-enforcement
cooperation on confirmed CSAE content — no invented automated-
detection claims.
Cross-linked from the other legal pages' footers, the web /pets
footer, and the mobile app's My Pets legal-links section (new
pets.legalChildSafety i18n key, DE/EN).
- i18next + react-i18next + expo-localization: device-locale detection,
an MMKV-persisted DE/EN/Automatisch override (mirrors web's cookie-only
locale, no backend field needed), and a language switcher on the Pets
tab next to the theme toggle
- every screen and shared component converted from hardcoded German
strings to translation keys; de.json/en.json kept in exact key-parity
(218 keys each, verified programmatically)
- backend: src/trpc/init.ts now accepts an x-locale header as a fallback
locale source (cookie still wins, web unaffected) since mobile has no
cookie jar to set NEXT_LOCALE in; mobile's tRPC client sends the
current language on every request
- fixes a latent bug: species/breed pickers were hardcoded to locale
"de" while every other pet query resolved names via ctx.locale's
default (English, since mobile never sent a locale cookie) — both now
use the same current-language value
- expo-localization is a new native module (config plugin) and needs a
native rebuild to actually link; deviceLocale() falls back to English
instead of crashing when it isn't linked yet, so the app stays usable
before that rebuild happens
Needs a NAS deploy (src/trpc/init.ts is shared backend code) before the
x-locale fallback and the species/breed locale fix take effect for mobile.
Owner.expoPushToken + owner.registerPushToken/clearPushToken, plus a
best-effort Expo push send wired into notify() alongside the existing
in-app Notification row. Missing/expired token or unreachable Expo push
service silently no-ops — push delivery must never break the underlying
action it's attached to.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The admin panel (src/app/p/[secret]/layout.tsx) never wrapped its tree
in ClerkThemeProvider, unlike (app)/layout.tsx — a SpeedUp-Plan P0
optimization that deliberately kept Clerk's client bundle off routes
without a <UserButton/>. Consequence: no client-side session-token
refresh ran on admin pages. The initial page load stayed authenticated
(Clerk's server-side handshake reissues the token on full document
navigations), but subsequent tRPC XHRs — e.g. legal.publish — started
failing with UNAUTHORIZED/401 a few minutes in, since that handshake
only fires for navigations, not fetch calls. Confirmed live: a fresh
page reload always restored a working session, matching this exactly.
Mounting ClerkThemeProvider in the admin layout re-adds background
token refresh. Also gives the legal-publish error handler an
actionable message for UNAUTHORIZED specifically (distinct from other
failures), since a residual case can still occur after a real 7-day
session expiry.
Add a per-pet consent gate (Art. 9 Abs. 2 lit. a DSGVO) in front of the
whole Health-Tracking area (weight, vet visits, vaccines, emergency
vet, feeding, Health Card): an un-pre-checked checkbox must be
confirmed before any of it is used, enforced server-side in
health.ts (not just the UI) via a new assertHealthConsent check on
every data-bearing endpoint.
Revoking consent (health.revokeConsent) deletes every health record
for that pet in one transaction and is available at any time from the
Health page. Pet gains healthConsentGivenAt/healthConsentRevokedAt
(nullable, additive — db push already applied against the shared
Supabase instance).
Extends the Datenschutzerklärung (Abschnitt 3) with the Art. 9 legal
basis and the revoke/auto-delete mechanism, per counsel's follow-up.
Narrow the Art. 4 No. 1 GDPR non-personal-data claim to data that
permits no inference about the owner as a natural person, and call
out the case where it can (e.g. pet photos alongside a profile
picture) as personal data after all.
- clarify pet-only data (breed, weight, health records) is not
personal data under Art. 4 No. 1 GDPR since GDPR protects only
natural persons; still handled confidentially per policy
- switch Clerk/Cloudflare/Mux/Stripe from SCC framing to EU-U.S. DPF
certification (Art. 45 adequacy decision), strengthen the residual
SCC/Schrems-II fallback clause beyond TLS + access restrictions
- document 30-day retention and rationale for technical error reports
Verified live that adding Clerk's frontend API host to script-src does
NOT resolve the CSP violation in modern browsers: with 'strict-dynamic'
present, CSP3 ignores host-based entries in that directive entirely
(Chrome's own violation message confirms this). The console noise is a
Clerk SDK-side loading quirk, not something fixable here without
dropping 'strict-dynamic'. Keeping the host entry only as the same
spec-mandated pre-CSP3 fallback the existing 'unsafe-inline' entry
already relies on, with the comment corrected to say so.
Clerk's clerkMiddleware auto-adds its frontend API host to connect-src
but not script-src, so the initial (unversioned) clerk.browser.js/
ui.browser.js load was blocked by strict-dynamic before the 307 redirect
to the versioned URL — two CSP violations reported to /api/csp-report on
every page that renders Clerk UI, dragging the Best Practices Lighthouse
score down. Derives the host from the publishable key (same approach as
the existing Supabase/GlitchTip hostname helpers) instead of hardcoding it.
- README: note @clerk/localizations' deDE usage in the tech stack table
and shipped-features list.
- ClerkThemeProvider: comment explaining why deDE is needed (Clerk's own
UI defaults to English regardless of the app's next-intl locale).
- mobil.md: mark the Capacitor plan superseded by the React Native/Expo
scaffold decided this session (see mobile/README.md).
The sign-in/sign-up UI rendered in English despite the rest of the app
being German — @clerk/localizations ships an official deDE locale that
plugs directly into ClerkProvider's existing appearance config.
The post-card action row (comments, hide/unhide, pin/unpin, AI-disclosure
correction, delete) had no explanation of what each icon does — add a help
popover next to the "Posts" heading listing icon + action name.
Open reports and Verifications pending summarized a number but gave no way to
act on it — link each tile straight to /reports and /verification, with
hover/focus states and a chevron affordance so it reads as navigation, not
just a number.
The Overview card was a flat, uncategorized list of 10 stats with no visual
grouping. Split it into three labeled, color-identified groups so a moderator
can scan by kind of number instead of reading every label:
- Activity (emerald): owners, pets, posts, DAU, MAU
- Violations (rose): banned users, shadowbanned, blacklist entries — values
only pick up the color when actually > 0, same "act on this" convention as
the existing AttentionStat tiles
- Info (sky): active ads, verifications closed
Also switched the maintenance banner and AttentionStat tiles from border to
ring-1, matching the shared Card component's depth convention so the whole
dashboard uses one visual vocabulary instead of two.
Admin shell used hardcoded zinc-* classes instead of semantic design tokens and
a redundant "PawFeed Admin" kicker label. Dashboard was a wall of 12 identically
weighted stat cards with no hierarchy. Sidebar/mobile nav gave no indication of
the current page.
- layout.tsx/AdminMobileNav.tsx/GranularityToggle.tsx/TimeSeriesChart.tsx: replace
zinc-* with bg-background/text-foreground/border-border etc., force dark via
the `dark` class so the admin surface stays permanently dark regardless of
site-wide theme toggle
- New AdminSidebarNav.tsx: highlights the current page (desktop sidebar)
- AdminMobileNav.tsx: same active-state logic for the mobile drawer, topbar
shows the current page label instead of a static title
- page.tsx: replace the 12-card stat grid with 2 AttentionStat tiles (reports,
pending verifications — color means "act on this") plus a flat Overview list,
and convert the 5 chart containers to the shared Card component
Introduces PRODUCT.md/DESIGN.md (Impeccable skill) capturing PawFeed's
existing brand identity ("The Spotlight Porch") as durable reference, then
applies it:
- Landing hero: restrained "spotlight" glow instead of a full orange
gradient panel, tagline as the actual headline with a marigold accent on
"Stars"/"stars", real PawPrint mark fixed (was a mismatched hand-drawn
shape, also fixed a viewBox overflow clipping bug).
- Features section: one dominant mechanism (pet-as-identity) plus a quiet
supporting list, replacing the four-identical-cards grid craft-floor
refuses.
- Header: dropped the generic glass-blur sticky nav for a flat header with
a soft marigold gradient hairline; CTA button now matches DESIGN.md
(rounded-lg, no shadow, same orange as the hero CTA); logo gets a
paw-spring hover as a small on-brand moment; new active-page indicator
(LandingNavLinks) under the current nav link.
- Legal pages (Impressum/Datenschutz/Nutzungsbedingungen): replaced the
stacked numbered-card-per-section layout with a flowing typographic
document (hairline dividers, no boxes, no arbitrary section-number
badges) via the shared LegalLayout/LegalSection components.
- Nutzungsbedingungen: moved Sponsoring earlier (now 3a, right after
Nutzerinhalte & Urheberrecht) while keeping Salvatorische Klausel/Kontakt
last; existing section numbers left untouched to avoid breaking the
"gemäß Abschnitt 5" cross-reference in Haftungsausschluss.
Top-post and top-story cards in the pet insights dashboard now open the
real post (PostDetailDialog, same fetch-by-id pattern as the notifications
page) or story (StoryViewer, new initialStoryId prop to jump straight to
the right story instead of always starting at index 0).
New insights router aggregates the reactionCount/commentCount counters
(Post pre-existing, Story added in #29) into a small per-pet dashboard —
total paws/comments received and which post/story is resonating most.
Reachable from the owner's own pet profile ("Meine Tiere"), owner-only.
Story reactions/comments mirror the existing Post/Ad engagement models
(StoryReaction/StoryComment tables, denormalized counters on Story) with
a comment overlay rendered on top of the still-playing story rather than
pausing it. No notification wiring yet, matching the Advertisement
engagement model's precedent.
pets.byId is owner-only (throws FORBIDDEN for pets you don't own), but
followers/page.tsx and following/page.tsx used it just to grab the
pet's name for the heading/empty-state — any visitor viewing another
pet's followers or following list got a 404 (Gitea #33). Swapped to
pets.getProfile, the same public-safe procedure the actual profile
page already uses.
Also adds the requested privacy control: Pet.followerVisibility
(EVERYONE/FOLLOWERS_ONLY/NOBODY, default EVERYONE) gates
follows.listFollowers/listFollowing via a new assertCanViewFollowerLists
helper — FOLLOWERS_ONLY checks whether any pet the caller owns follows
the target pet; the owner can always see their own. Both pages now
render a "private" message instead of erroring when blocked. Editable
from the pet edit page's Privacy section, next to the existing DM
policy control.
Search input (w-64) + two status/role selects (w-36) sat in one
unwrappable row, and each user row's shrink-0 action-button cluster
(up to 6 buttons) never yielded width to the info block — both
squeezed everything together below md:. Filter bar now stacks
(search full-width, filters side by side below it), and user rows
stack info above actions on mobile, row layout from sm: up.
The permanent 224px sidebar in the admin layout had no responsive
handling at all, leaving almost no width for content on a phone
(Gitea #10). Sidebar is now hidden below md: and replaced by a
topbar + slide-in Sheet drawer (AdminMobileNav) with the same nav
links; content padding and the outer flex direction adapt per
breakpoint.
- New assertNotBlocked() helper (src/lib/assert-not-blocked.ts), mirroring
assertPetOwnership. comments.create, reactions.toggle (create path), and
reposts.create now reject direct interaction between blocked pets, not
just feed visibility (product decision: enforce, matching messages.ts's
existing DM block check).
- videos.updateCaption and stories.getViewers now route through the shared
assertPetOwnership helper instead of duplicating the inline ownership
check.
- ads.ts mutations (recordView, toggleReaction, addComment, toggleRepost)
gain the same rate limiting their post-side equivalents already have.
- follows.listFollowers/listFollowing/getPetsWithActiveStories now apply
getSpiderExclusionId(), matching the established per-query pattern in
feed.ts/explore.ts/search.ts.
Remaining findings from the review are addressed:
- timingSafeStringEqual's length short-circuit needs no fix (matches
Node's own crypto.timingSafeEqual behavior).
- GDPR export token (admin.exportUserData) and health-card token
(health.getOrCreateCard) now get an explicit crypto-random token
instead of falling back to Prisma's cuid() default, which is
timestamp/counter-derived and not meant for unguessable secrets.
- Add IP-based rate limiting to both public token endpoints
(/api/gdpr-export/[token], /health-card/[token]) to close the
brute-force window.
- videos.getByPostId now applies the same hiddenAt/shadowban
visibility filter posts.getById already uses, closing an IDOR that
let any authenticated user read Mux data for hidden/shadowbanned
video posts.
Found via a security-reviewer pass over the tRPC/API layer.
Broadcasts had no delete path (Phase 1 gap) — had to remove a test
message via a one-off script yesterday. New admin.deleteBroadcast
mutation mirrors hidePost/banOwner: SUPER_ADMIN-gated (same bar as
createBroadcast), reason required (min 1 char), wraps delete + a
DELETE_BROADCAST ModerationLog entry in one transaction.
BroadcastReceipt rows cascade via the existing onDelete: Cascade.
Frontend: trash icon per history item opens a confirm dialog with a
required reason input, mirroring the posts admin page's delete-confirm
pattern.
Cloudflare's brand guidelines flag a hand-written "Protected by
Cloudflare" text label as misleading — replace with their official
badge PNG (public/badges/cloudflare-protected.png), same fixed-size
<img> pattern already used for AiDisclosureBadge.