- 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>
- /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>
- 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>
- 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>
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>
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>
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.
- 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).
Story gains mediaType (IMAGE/VIDEO) plus the same Mux fields as
VideoPost (muxUploadId/AssetId/PlaybackId, videoStatus, durationSecs,
aspectRatio); storageKey becomes optional since video stories don't
use Supabase Storage.
- 30s duration cap (deliberately shorter than the 180s normal-post
cap) enforced client-side before upload (reads the file's own
duration) and again server-side in the mux webhook and
stories.getVideoStatus's self-heal path, so a bypassed client check
or a direct API call can't produce an oversized story video.
- MAX_STORY_VIDEO_DURATION_SECS lives in a new client-safe
src/lib/story-video-limits.ts rather than stories.ts, which is
server-only — importing from a server-only module into StoryForm
(a client component) pulled the pg driver into the client bundle
and broke the build.
- Mux webhook now resolves an asset to either a VideoPost or a Story;
video stories never fan out to the feed (mirrors the normal-post
webhook branch, minus fanOutPost).
- StoryViewer: video stories autoplay muted (tap to unmute — browsers
block autoplay-with-sound past the story that was directly clicked
open) with a real playback-time progress bar (image stories keep
the fixed 5s bar), rendered via object-contain to correctly handle
the ~9:16 portrait ratio typical of phone-shot story videos instead
of a fixed 16:9 box.
Next.js standalone is single-process, so one container only used one
CPU core. A load test hit a hard ceiling at ~60-65 req/sec with one
core pegged at 133% while 3 cores sat idle. Now runs as pawfeed-1/2/3
behind an NPM least_conn upstream, verified via load test showing CPU
spread evenly across all three replicas.
Also caps the Prisma pg pool per-replica via DB_POOL_MAX so 3 replicas
stay well under the Supabase pooler's connection limit.
Adds Pet.nickname (nullable, max 30 chars) and a shared formatPetName()
helper so the display format ("Rex (Rexi)") stays consistent across
feed, admin panel, messages, sidebar/nav, and mentions instead of
duplicating the formatting logic at each read site.
Add Horse, Rabbit, Guinea Pig, Hamster, Mouse, Rat, Snake, Lizard, Turtle,
Gecko, and Spider as Species (with breed taxonomies + DE translations),
alongside the existing Dog/Cat/Bird. The species/breed system was already
fully data-driven (pet creation, onboarding, ad targeting all read
pets.listSpecies/listBreeds dynamically), so this is mostly seed data.
Spiders get dedicated handling: an opt-in Owner.hideSpiders preference
(offered once during onboarding via ArachnophobiaOptIn, always
re-toggleable on /pets via HideSpidersToggle) fully excludes Species=Spider
pets/posts from feed, explore, and search when enabled.
New public /ueber-uns page — deliberately doesn't preview or link into the
platform itself (no phone mockup, no post previews), just the founding
story plus live member/pet/sponsor counts for transparency. Fully
bilingual via the existing LanguageSwitcher.
- src/trpc/routers/stats.ts: new public stats.getPublicStats procedure,
Redis-cached (5min TTL), sponsor count includes lapsed subscriptions
(not just currently-active badges) per discussion on the issue.
- src/proxy.ts: /ueber-uns registered as a public route, exempt from
maintenance mode (doesn't touch the platform, so no reason to gate it).
- Extracted LandingHeader/LandingFooter from src/app/page.tsx into shared
components under src/components/landing/ — this page reuses the same
navbar, and duplicating it risked the responsive header fixes from
earlier today drifting out of sync between two copies. The "Bald
verfügbar" placeholder nav link now points at the real page.
- src/lib/stripe.ts: made the Stripe client a lazy singleton instead of
constructing it at module scope. Eager construction crashed the entire
tRPC appRouter (and anything importing it, e.g. createTRPCCaller() on
any public page) whenever STRIPE_SECRET_KEY is unset — invisible until
now because no public page had used createTRPCCaller() before.
- stats.getPublicStats wraps its Redis cache read/write in .catch() so a
Redis outage degrades to uncached rather than breaking the page,
matching the existing pattern in src/lib/maintenance.ts.
Verified locally via dev server + Chrome DevTools MCP emulation (DE/EN,
mobile 375px, desktop) — this is a public unauthenticated page so, unlike
most of today's other fixes, testable outside of Clerk auth.
Replaces the (never-launched) ad business direction with a freiwilliger
Beitrag/Community-Abo model: owners can support PawFeed via Stripe with
a one-time payment or a monthly SEPA/card subscription (2 EUR minimum),
declared as Sponsoring/Community-Abo, not a tax-deductible donation. The
Sponsor badge (only perk, no feature unlocks) is Owner-scoped and shows
on every post of every one of their pets, plus their profile.
- Schema: Owner.stripeCustomerId/sponsorSince/sponsorPeriodEnd/
sponsorSubscriptionId/sponsorSubscriptionStatus, new
SponsorContribution model (payment history)
- src/lib/stripe.ts (singleton client, mux.ts pattern) + src/lib/sponsor.ts
(isActiveSponsor: one-time givers keep the badge permanently, subscribers
through sponsorPeriodEnd even after cancelling -- no mid-period cliff)
- src/trpc/routers/sponsor.ts: getStatus, createCheckoutSession (SEPA +
card for subscriptions, card for one-time, price_data since amounts are
user-chosen), cancelSubscription (cancel_at_period_end, no hard stop)
- src/app/api/webhooks/stripe: checkout.session.completed, invoice.paid,
customer.subscription.updated/deleted (mux webhook route pattern)
- src/app/(app)/unterstuetzen: amount chips (3/5/10 EUR) prefilling an
editable field, one-time/subscription toggle, status + contribution
history, in-app cancel button (no Stripe-hosted portal, per requirement)
- Badge surfaced in feed/explore postInclude + PostCard, and on the pet
profile page next to the existing isVerified badge
- Nutzungsbedingungen Sec. 12 (draft, needs sign-off before launch) +
Stripe added to the Datenschutzerklaerung's Auftragsverarbeiter list
- src/__tests__/sponsor.test.ts (7 tests), Stripe client fully mocked
STRIPE_WEBHOOK_SECRET intentionally not yet configured -- the webhook
endpoint can only be registered in the Stripe dashboard once this is
deployed and reachable at https://pawfeed.org/api/webhooks/stripe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three of the five remaining Phase 4 admin-dashboard items from Gitea #10:
- Maintenance mode: Redis-backed global flag, gate in proxy.ts (exempts
/p/*, /api/*, legal pages, and sign-in/Clerk routes so a locked-out
admin can always get back in), SUPER_ADMIN-only dashboard toggle,
fails open on Redis errors.
- Registration IP: Owner.registrationIp captured defensively at every
owner.upsert create branch (consume-invite, legal.acknowledge,
legal.acknowledgeUploadConsent, pets.create, owner.setThemePreference)
since no single onboarding step is a code-guaranteed first touchpoint
across all invite/legal-doc states. Datenschutzerklärung updated and
user-approved.
- Trends & Promotion: Post.pinnedAt field, admin.pinPost/unpinPost
(mirrors the existing hidePost/unhidePost pattern), explore.getTrending
now surfaces pinned posts first regardless of the 48h window (with
explicit nulls:"last" ordering -- Postgres sorts NULL first on DESC by
default), trending-hashtags panel + pin/unpin button on the admin
posts page.
Password/2FA-reset admin trigger (item 5) was dropped -- Clerk's own
self-service reset already covers it. Broadcast/system messaging (item 2)
stays deferred pending a shared design session.
Reviewed via /code-review; findings addressed: the registration-IP capture
point, Postgres NULL ordering, and the maintenance-mode admin lockout gap
listed above were all fixes applied after that review, not part of the
original draft.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The 🐾 emoji rendered inconsistently across mail clients/OS fonts and
looked off-brand. Replaces it with the exact lucide-react "paw-print"
mark (same path data as the landing page's filled paw icon) inlined
as raw SVG in the email header.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GDPR export emails were unstyled HTML with a bare "Hallo," greeting.
Adds a landing-page-branded email shell (orange gradient header, paw
wordmark) and pulls the owner's first name from their Clerk profile
for the salutation, falling back to "Hallo," when unavailable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MAIL_FROM is a bare address with no display name, so mail clients fell
back to showing the local-part as the sender. Wrap it in a proper
display name in code instead of depending on env var formatting.
Rate-limiting on messages.send, reports.create, reactions.toggle,
follows.create, reposts.create. Unique constraints on Report to stop
report-flooding. Mux webhook now fails hard in production without a
signing secret instead of skipping verification. Admin-panel outline
buttons fixed on 4 more pages (same white-on-dark bug as the Users page).
Timing-safe comparison for ADMIN_SECRET/CRON_SECRET. Fixed duplicate HSTS
header (Cloudflare's stronger one was being shadowed by the app's own).
tRPC now returns clean JSON 401s instead of HTML redirects when
unauthenticated, and no longer blocks public procedures for logged-out
visitors. 14 previously-silent catch blocks now log to GlitchTip.
invite/page.tsx and EmergencyVetSection.tsx fully localized (were 100%
hardcoded German). Stale TODO comments removed, auth.test.ts scaffold
rewritten to match the actual lazy-upsert pattern instead of a Clerk
webhook flow that was never built.
Redis-backed visit tracking (src/lib/visit-tracking.ts): daily SADD
sets keyed "visits:YYYY-MM-DD", same fail-open idiom as the existing
rate-limit.ts. Recorded once per authenticated (app) layout render —
SADD is idempotent so no client-side dedup is needed. DAU = today's
SCARD, MAU = SUNION across the trailing 30 daily sets.
Dashboard additions:
- 6 new stat cards: DAU, MAU, shadowban count, blacklist entry count,
verifications open/closed.
- 3 new time-series charts (reusing the existing TimeSeriesChart/
GranularityToggle components): visits, comments, and reactions
over time. Comments/reactions are plain Postgres bucket queries
cloned from the existing getPostsOverTime pattern; visits reroutes
through the new Redis helpers per bucket.
This closes out Gitea #10 Phase 3 (hide-without-delete, shadowban,
blacklist, and now analytics — all four parts shipped).
Adds a BlacklistEntry model (type WORD|LINK|HASHTAG, unique per
type+value) with a new admin module at /p/[secret]/blacklist —
add/list/delete entries, filterable by type.
Enforcement, per explicit spec from this session:
- New posts are checked at creation time (posts.create) against the
current blacklist (whole-word regex match for WORD, domain match
incl. subdomains for LINK, exact match for HASHTAG) and auto-hidden
immediately via the existing hiddenAt mechanism if matched — never
blocks creation, just hides right after, reversible via
admin.unhidePost. Logged as HIDE_POST with moderatorId "SYSTEM".
- Adding a new entry retroactively scans existing non-hidden posts
(capped at 5000 for safety) and hides matches too, logged per-post
under the admin who added the entry.
- Removing an entry does NOT auto-unhide already-hidden posts —
that stays a manual review step via the existing hide/unhide UI.
Matching lives in src/lib/blacklist.ts: word matching uses \b word
boundaries (a blacklisted "ass" won't match "assist"), link matching
extracts URLs and compares hostnames including subdomains.
Scope: post captions/hashtags only, not comment bodies — comments
don't have a hiddenAt field yet, so this is a natural follow-up once
they do, using the same pattern.
Gitea #10
Adds Post.hiddenAt (nullable, db push applied) plus MODERATOR+ admin
mutations hidePost/unhidePost, each logging HIDE_POST/UNHIDE_POST to
ModerationLog with the standard moderatorRole+ipAddress stamp.
hidePost requires a non-empty reason (visible to nobody but audit
trail); unhidePost is a plain reversal. The admin posts grid shows a
"Hidden" overlay badge and swaps the delete-adjacent action button
between hide/unhide based on hiddenAt.
Every reader-facing post query now filters hiddenAt: null so a hidden
post disappears everywhere without touching its row: feed (discovery
candidates, Postgres fallback, and the Redis-postId lookup — the same
place blocked-pet filtering already happens, so an already-fanned-out
hidden post silently drops out at read time), feed backfill on new
follows, explore trending, profile grid, hashtag search, and the
milestones tab.
Admin-facing queries (listPosts, analytics bucketing) are deliberately
NOT filtered — moderators need to see hidden posts to unhide them, and
volume analytics shouldn't be skewed by moderation actions.
Gitea #10
Adds an AvatarCropDialog step (react-easy-crop, cropShape="round")
between file selection and the presigned-URL upload in AvatarUpload:
the user crops/zooms a circular region, which is re-encoded via canvas
to the original file's mime type before the existing upload flow runs.
File-size/type validation moved from uploadFile into the new
selectFile gate that opens the dialog.
Gitea #13
Adds a PetAvatar wrapper (hash(name) mod 360 -> HSL background) and
migrates the four most-visible avatar call sites (sidebar, mobile nav,
post card, post detail incl. comments) to use it instead of duplicated
inline initials logic.
Desktop sidebar can now collapse to an icon-only 76px rail, persisted
in localStorage (same client-init pattern as ActivePetContext). All
nav rows, the new-post button, and the owner row adapt; secondary
sections (language switcher, legal footer) hide when collapsed.
Gitea #12, #14
Gitea #10 Option B:
- new VerificationRequest model — owner submits a message via the pet
edit page, moderator reviews against a fixed 4-item checklist
(authenticity/impersonation risk, complete profile, good standing,
established presence). All four met -> approved, Pet.isVerified flips
on; any unmet -> auto-rejected with an itemized note of what's missing
- new admin queue /p/[secret]/verification (Moderator+ only, same gate
as the audit log), status filter, review dialog enforces the checklist
- owner is notified automatically either way via a "PawFeed Team" DM —
extracted the team-pet lookup/create logic (previously only inline in
startTeamConversation) into src/lib/team-pet.ts, adding sendTeamMessage
which reuses the normal Conversation/Message + MESSAGE notification
- review decisions log to ModerationLog with role+IP, same as every
other Gitea #10 action this session
Gitea #10 Phase 2:
- Warning model (append-only, no delete — Owner history), issueWarning +
listWarnings, surfaced on the Users page with a badge + dialog
- Pet.isVerified checkmark (lives on Pet, not Owner — pets are the social
identity per CLAUDE.md), toggleable from the Users page, shown on the
public profile page
- listReports now derives a HIGH/MEDIUM/LOW severity from ReportReason
and sorts severity-first; Reports page shows the derived badge
- Admin-triggered GDPR Art. 17 erasure: extracted the self-service
/api/account/delete logic into a shared deleteOwnerAccount() helper,
new admin.deleteUserAccount (SUPER_ADMIN only, requires typing the
exact ownerId to confirm, blocks self-deletion) reuses it — Delete
button only renders for SUPER_ADMIN/bootstrap callers
- all four new/changed mutations log to ModerationLog with role+IP
Replaces the emailed Supabase signed URL with a bespoke DataExportToken
DB record, delivered via a new /api/gdpr-export/[token] route that
streams the file server-side. The storage backend is now an internal
implementation detail; every link the user sees reads pawfeed.org.
Aggregates everything held about an Owner (gdpr-export.ts: pets, posts,
stories, comments, reactions, reposts, follows/blocks, health records,
messages, notifications, reports, ban/consent history, Clerk profile)
into a single JSON bundle, uploads it to a private Supabase Storage
bucket (data-export-storage.ts — separate from the public pet-avatars
bucket used for media), and emails the owner a 7-day signed download
link via the new SMTP module. Triggered from a new export button per
user in /p/[secret]/users, logged to ModerationLog.
Deliberately not a zip with embedded media bytes: avoids new archiving
dependencies, the risk of a slow synchronous request for owners with
many posts, and the fact that Mux video assets aren't trivially
re-downloadable — media is referenced by its existing CDN URL instead.
Ad interactions and issued invite codes are not yet included, flagged
in the export's own scope note rather than silently omitted.
Verified end-to-end against the live shared DB before committing:
built a real export, uploaded it, generated a signed URL, and sent a
real test email — all succeeded.
Thin nodemailer wrapper reading SMTP config from env vars. Uses a
provider-hosted mailbox (Strato, same as the domain host) rather than
a self-hosted relay from the NAS's residential/dynamic IP, which would
get spam-flagged by most receiving servers regardless of DKIM/SPF.
Foundation for the GDPR Art. 15 data-export feature (Plan B: expiring
download link + email notification) — no caller wired up yet. Verified
end-to-end with a real test send before committing.
Gitea #1: prevents blank image flashes while photos load. Blurhash is
computed client-side at upload time (canvas downsampling + the
blurhash package) for posts, milestones, and stories, stored alongside
each PostImage/Story row, and decoded into a smooth color placeholder
via the new BlurImage component that cross-fades to the loaded photo.
Wired into every content-photo surface: feed cards, post detail zoom,
reposts, milestone cards, story viewer, explore/search grids,
notification thumbnails, profile grid, and the admin posts/reports
panels. Pet avatars and ad creatives intentionally excluded — separate
content pipelines with disproportionate effort for the payoff.
Public go-live hardening: Redis-backed rate limiting on the previously
invite-gated public surface (explore browsing, post/comment creation,
invite-cookie routes) plus CSP, HSTS, and standard security headers on
every response, now that the invite gate no longer filters traffic.
User found "Cat" showing English on a German-locale profile page. Root
cause: Species.name/Breed.name are the canonical English values; a
SpeciesTranslation/BreedTranslation table + seed data (Hund/Katze/Vogel
etc.) already existed in the schema, and PetForm's species/breed picker
already used it correctly via an explicit locale input param — but every
*display* surface (pet profile, PetCard, Sidebar/MobileNav, search,
explore, followers/following lists, mention @-search, health-card) just
read the raw untranslated name.
Adds `locale` to the tRPC context (createTRPCContext reads the
NEXT_LOCALE cookie the same way i18n/request.ts does), so read
procedures can resolve translations without every caller needing to
pass a locale param. New src/lib/localized-name.ts::pickLocalizedName()
does the lookup with a same-shape fallback to the raw name.
Updated: pets.ts (list/byId/getProfile/update), search.ts (pets/byOwner),
explore.ts (listPets), follows.ts (listFollowers/listFollowing).
health-card/[token]/page.tsx (public, unauthenticated, intentionally
all-German) resolves with a fixed "de" locale instead of ctx.locale,
consistent with its existing hardcoded WeightChart locale.
Verified the seed data is actually present in the production DB
(SpeciesTranslation has Dog→Hund, Cat→Katze, Bird→Vogel) — this fix is
immediately effective on deploy, not blocked on a missing seed run.
- consume-invite: check-then-act race on single-use invite redemption —
two concurrent redemptions could both pass the pre-check. Now guarded
with an atomic updateMany(where: {code, usedById: null, revoked: false}).
- schema.prisma: added missing index on Report.targetPostId (used by
admin.ts's groupBy/filter).
- RepostCard.tsx: reposter link used a raw <a> (full page reload) instead
of next/link; the original poster's header had no link at all.
- Sidebar.tsx/MobileNav.tsx: removed the duplicated "set active pet on
first load" effect — ActivePetInitializer already does this centrally
and is mounted alongside both in the app layout.
- prisma.ts: removed the dead Vercel/Prisma Accelerate code path
(PRISMA_ACCELERATE_URL is never set — this is a self-hosted Docker
deployment) and the now-unused @prisma/extension-accelerate dependency.
- .env.example: corrected the NEXT_PUBLIC_APP_URL comment (Mux cors_origin
is intentionally "*", not wired to it) and documented CRON_SECRET,
which was missing despite both cron routes requiring it.
- reactions.ts: removed hasReacted/getCount — dead code with no frontend
callers; getCount also duplicated the denormalized Post.reactionCount.
- feed.ts: Postgres fallback path (used when Redis is down) never
filtered blocked pets, unlike the primary Redis path — blocked pets'
posts could reappear during a Redis hiccup.
- feed-helpers.ts/feed.ts: getFeedPage always paginated by a hardcoded
PAGE_SIZE=20, silently ignoring the client's requested (zod-validated
1-50) limit whenever Redis was up.
- milestones.ts: create accepted any MilestoneType including the
follower-threshold values (FOLLOWERS_500..FOLLOWERS_1M), which are
meant to be exclusively auto-awarded — restriction existed only in
the MilestoneForm picker, not server-side. Now validated against the
existing USER_INITIATED_MILESTONE_TYPES constant.
- admin.ts: deletePost/deleteReportedPost didn't decrement the original
post's repostCount when the deleted post was itself a repost, unlike
reposts.ts's user-facing delete — counter drifted upward permanently.
- PawButton.tsx: count was optimistically guessed off a `reacted` flag
that can be stale (callers pass initialReacted={false} unconditionally),
permanently corrupting the displayed count on click. Count is now
computed from the server's actual toggle result instead of guessed.
- PostDetailDialog.tsx: Report/Block dropdown items had no onClick and
neither ReportSheet nor BlockDialog were rendered — wired up to match
PostCard.tsx's existing pattern.
- VideoCard.tsx: invalidated the entire query cache on every video-ready
transition; scoped to feed/posts/explore router queries instead.
getAvatarUrl and getMediaUrl were byte-identical bucket URL builders in
two separate files. getAvatarUrl is now a re-export of getMediaUrl —
all ~34 call sites keep working unchanged.
AvatarUpload, MultiImageUpload, and VideoUploadForm each duplicated the
same XHR PUT-with-progress Promise wrapper. The three flows differ too
much (single vs. multi-file, with/without a confirm step) for a single
useFileUpload hook to be a clean fit, so only the truly identical piece
— the XHR PUT itself — was extracted into uploadFileWithProgress().
6 fire-and-forget notification.create() call sites (follows, reactions,
comments, reposts, messages, mention-helpers) duplicated the same
{recipientPetId, type, actorPetId?, postId?, commentId?} + .catch(() => {})
shape — consolidated into src/lib/notify.ts.
admin.ts's getActiveBan reimplemented the "is this user an admin" check
inline instead of reusing assertAdmin's logic; extracted a shared
isAdmin() helper so both paths stay in sync. assertAdmin itself is
untouched.
Bundles the 2026-07-12 code-audit session (Clusters A/B/C/D partial/E/F):
denormalized Post/Advertisement reaction/comment/repost counters synced
transactionally instead of live _count queries; real cursor-based pagination
for followers/following/blocks lists; assertPetOwnership + formatRelativeTime
centralized; dead r2.ts + AWS SDK deps removed; missing DB indexes added;
account-deletion flow, mention notifications, pull-to-refresh feed, and
mobile UI/i18n fixes from the surrounding sessions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- NotificationBell: refetchInterval 30000->10000, refetchOnWindowFocus, i18n label
- ProfileTabs: Mux thumbnail for VIDEO posts in grid, play icon overlay
- parse-caption: add mention segment type for @word parsing
- MentionTextarea: new component with @-autocomplete via search.pets
- PostCard: render @mentions as blue links to /search
- CommentSheet: parse comment bodies for mentions, use MentionTextarea
- PhotoPostForm: use MentionTextarea for caption input