Files
petfeed/CLAUDE.md
T
adminandClaude Opus 5.5 c474cd64ed feat: App-Store-Link und -Badge (iOS-App live)
APP_STORE_URL gesetzt (/app leitet iPhones jetzt in den App Store),
offizielles Apple-Badge neben Google Play auf Landingpage, /r/[code]
und im Footer; Texte nennen iPhone und Android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:46:41 +02:00

20 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

PawFeed — a social platform where pets are the stars. Domestic/companion animals get their own profiles; owners manage them, but the pet is the identity (profile URL, posts, follows — all pet-scoped, not owner-scoped). Photos, short-form video, stories, milestones, DMs, and health tracking, all within a single pet-first social graph.

Core value: Pets are first-class social identities, not content on a human's feed. Every feature is built around the pet's life.

Constraints: Web-first (mobile-responsive); short-form video only (no long-form).

Species scope: Dogs, cats, birds, horses, rabbits, guinea pigs, hamsters, mice, rats, chinchillas, snakes, lizards, turtles, geckos, spiders, and aquariums (the Species is the tank/aquarium as a whole, not one profile per fish — Breed is the predominant fish species kept, e.g. Betta, Guppy, Discus) — see Species/Breed in prisma/schema.prisma and the taxonomy in prisma/seed.ts. The species/breed system is fully data-driven (i18n translation tables, dynamic pets.listSpecies/pets.listBreeds queries) — every consumer (pet creation, onboarding, ad targeting) already reads from these tables, so adding a new species is a seed-data change, not a code change. The one exception: spiders are treated specially — an owner-level "Arachnophobie-Helfer" preference (Owner.hideSpiders, toggled during onboarding via ArachnophobiaOptIn or later on /pets via HideSpidersToggle) fully excludes Species="Spider" pets/posts from feed/explore/search when enabled (src/lib/spider-exclusion.ts). This exclusion is applied per-query at each read site (feed.ts, explore.ts, search.ts) — there is no central chokepoint, so a new pet/post-listing query must add the same getSpiderExclusionId() check to stay consistent.

Commands

npm run dev              # next dev (Turbopack)
npm run build             # next build (output: "standalone")
npm run start             # next start
npm run lint               # eslint (flat config, eslint.config.mjs)

npx tsc --noEmit          # type check (no separate package.json script)

npx vitest run                                    # run full test suite
npx vitest run src/__tests__/posts.test.ts         # run a single test file
npx vitest                                          # watch mode

npx prisma generate        # regenerate Prisma client after schema.prisma changes
npx prisma migrate dev     # create + apply a migration locally
npx prisma db seed         # runs prisma/seed.ts via tsx (breed/species taxonomy)

There is no npm test script — invoke vitest directly via npx.

Architecture

Two-layer identity (the load-bearing invariant)

Clerk manages Owner accounts (auth only — Owner.id is literally the Clerk userId). Pets are app-domain entities, not auth principals. Every social action — posts, follows, reactions, comments, DMs — is scoped by petId, never by ownerId. The social graph (Follow, Block, Reaction, etc.) has no owner columns at all. When adding a feature, figure out which pet is acting, not which owner is logged in; ownership checks (pet.ownerId === ctx.userId) happen in the tRPC procedure, not in the schema.

The client-side "which pet is currently active" state lives in src/context/ActivePetContext.tsx (React Context + localStorage, initialized server-side by ActivePetInitializer with the owner's first pet). The localStorage/SSR mismatch on first paint is expected, not a bug.

tRPC layer

  • src/trpc/init.ts — context is { userId, prisma } (userId from Clerk auth()); protectedProcedure throws UNAUTHORIZED if userId is null and narrows it to non-null in ctx for everything downstream.
  • src/trpc/routers/_app.ts — aggregates ~20 domain routers (pets, posts, stories, milestones, follows, blocks, reports, feed, reactions, comments, reposts, explore, search, notifications, videos, conversations/messages, health, admin, referrals, referralAdmin, ads). One router file per domain in src/trpc/routers/.
  • src/trpc/server.ts exposes a server-side caller (createTRPCCaller()) used inside Server Components/layouts to prefetch without an HTTP round-trip (see src/app/(app)/layout.tsx). src/trpc/client.tsx + query-client.ts wire up the browser-side TanStack Query client.
  • Pet-ownership checks go through the shared assertPetOwnership(prisma, petId, userId) helper in src/lib/assert-pet-ownership.ts — don't reintroduce a local copy of this check in a new router.

Denormalized engagement counters

Post.reactionCount/commentCount/repostCount and Advertisement.adReactionCount/adCommentCount/adRepostCount are maintained transactionally at every create/delete site (reactions, comments, reposts, ads routers, and the admin moderation deletes in admin-procedures/) instead of computed via live _count/count() on read. Feed/explore/posts/admin reads use these columns directly. If you add a new mutation that creates or deletes a reaction/comment/repost, you must increment/decrement the matching counter in the same transaction — there is no background reconciliation job.

Routing structure

  • (auth) route group — sign-up/log-in/password reset, unauthenticated.
  • (app) route group — the authenticated shell (layout.tsx does a belt-and-suspenders auth() check, redirects to onboarding if the owner has zero pets, and redirects banned owners to /banned).
  • /p/[secret]/... — the admin/moderation panel. The path segment itself is the secret (ADMIN_SECRET env var); wrong or missing secret returns a bare 404, not 403, so the panel's existence isn't revealed.
  • /health-card/[token] — public, tokenized, no auth (shareable vet record).
  • Root-level /impressum, /datenschutz, /nutzungsbedingungen — German legal pages, not under (app), not localized (this is a next-intl project but these three routes are fixed-language).

src/proxy.ts (not middleware.ts)

Next.js 16 renamed middleware.ts to proxy.ts — this is the middleware; don't go looking for a middleware.ts that doesn't exist. It layers two concerns in one Clerk middleware: (1) admin secret gate — 404s /p/* unless the path segment matches ADMIN_SECRET; (2) auth.protect() for everything else not in the public route matcher. (The old invite gate on /sign-up — pf_invite cookie, INVITE_REQUIRED — was removed with the referral program; sign-up is open.)

Media & storage — code has diverged from the original stack plan

The actual, current image/avatar storage path is Supabase Storage (src/lib/supabase-storage.ts, bucket pet-avatars, createSignedUploadUrl + getPublicUrl), used by posts, stories, media, and admin routers. src/lib/r2.ts (Cloudflare R2 via presigned S3 PUT) was dead code and has been deleted (2026-07-12), along with the @aws-sdk/* dependencies — Supabase Storage is the only storage path now.

All uploads are presigned/direct-to-storage — media bytes never pass through the Next.js server.

Video (Mux)

src/lib/mux.ts is a thin singleton client. Video is async: a VideoPost row is created in PROCESSING state with a muxUploadId; the /api/webhooks/mux route handler advances it to READY (with muxAssetId, muxPlaybackId, duration, aspect ratio) or ERROR as Mux webhooks arrive. Never assume a video is playable synchronously after upload.

Redis — self-hosted, not Upstash

src/lib/redis.ts is a plain ioredis client (lazyConnect: true so module evaluation during build doesn't attempt a TCP connect) pointed at REDIS_URL. In production this is the redis service in docker-compose.yml, not Upstash serverless — despite what older planning docs in .planning/ say.

i18n

next-intl, config at src/i18n/request.ts, message catalogs at messages/en.json and messages/de.json. No locale-prefixed routing — check request.ts before assuming a routing scheme when adding a new locale.

Admin / moderation panel

Gated by path secret (see proxy.ts above) and by role at the tRPC layer: assertAdmin() (src/trpc/routers/admin-procedures/guards.ts, re-exported from admin.ts) allows either ADMIN_OWNER_ID (env-configured super-admin bootstrap) or an AdminRole row (SUPER_ADMIN / MODERATOR). Actions are written to ModerationLog via modLog() (src/lib/admin/mod-log.ts). trpc.admin.* is assembled in src/trpc/routers/admin.ts from per-area procedure objects in src/trpc/routers/admin-procedures/ (posts, users, reports, ads, …) — add new admin procedures there, not in admin.ts. Every admin page reloads its visible data after any successful mutation (AdminAutoRefresh in the admin layout), so admin pages need no manual invalidateQueries. Pages live under src/app/p/[secret]/{users,pets,moderators,posts,reports,ads,referrals,curator,…}. The dashboard has a collapsible "Tierarten" card (SpeciesDistributionCard, petAdmin.speciesDistribution: pets per species incl. empty species and per breed, curator pet excluded). The "Tiere" page (pets, router pet-admin.ts) manages single pets: verified/founding toggles, clearing profile content, deleting a pet (reason is sent to the owner as a team DM on another of their pets, or by e-mail if it was their only pet) and the owner's referral ranks.

Report statistics (ReportDecision, src/lib/report-decisions.ts): every moderator decision on a report is stored as an append-only row (UPHELD/DISMISSED) before the Report itself is deleted — shown on the users page, the pet detail sheet and the "Auswertung" tab of /p/[secret]/reports. A new path that resolves reports by moderator decision must run reportDecisionOps() in the same transaction (current: dismissReport, upholdReport, deletePostAsModerator in admin-procedures/, petAdmin.delete). Rows have no FKs: deletePetCompletely nulls the pet's ids, deleteOwnerAccount anonymizes the owner's ids — never delete the rows, other owners' counts depend on them.

Never delete a pet with a bare prisma.pet.delete — use deletePetCompletely() (src/lib/delete-pet.ts). StoryView (D-08), Report.reporter and Repost.originalPost have no cascade and would block the delete, and the denormalized counters on other pets' posts/stories must drop by this pet's paws/comments/paw-backs. pets.delete, deleteOwnerAccount and the admin delete all go through it.

Download redirect /app

src/app/app/route.ts (public in proxy.ts) is the target for flyers/QR codes: Android → Play Store with sanitized utm_* passed as Play referrer, iOS → APP_STORE_URL in src/lib/app-links.ts (App Store, live since 2026-10-08), everything else → web start page; ?ref=<code> → /r/<code>. Logic is pure in src/lib/app-redirect.ts; clicks are counted per day/source/platform in Redis (appredirect:<date> hashes, src/lib/app-redirect-stats.ts) and shown in the admin referral overview. Redis on the NAS is the pawfeed-redis container (not glitchtip-redis), password in docker/.env REDIS_PASSWORD.

New-pet welcome & founding members

pets.create fires welcomeNewPet() (src/lib/new-pet-welcome.ts) in the background — it must never block pet creation. It (1) sets Pet.isFoundingMember via one guarded SQL update for the first 250 pets by createdAt (never revoked; badge = Sprout icon next to the name, web + mobile), (2) lets the curator pet "PawFeed Team" (ensureTeamPet in src/lib/team-pet.ts) follow the new pet, and (3) sends a welcome DM via sendTeamMessage. (2) and (3) are switchable and the DE/EN text (mandatory {name} placeholder) is editable in the admin panel under "Begrüßung" (/p/[secret]/curator, CuratorSettings id = 1, curator-admin.ts). Replies to the welcome DM land in the admin "Messages" team inbox.

Referral program (Empfehlungsprogramm)

Registration is open; invites are a Clerk-independent referral layer on top. Every owner has one permanent, unlimited Owner.referralCode (e.g. BUBI-7K3M, prefix from the first pet's name — src/lib/referral-code.ts). A code is never part of the Clerk sign-up: it is redeemed after sign-up, in the onboarding step "Wie hast du von PawFeed erfahren?" (AcquisitionStep, web + mobile) or later on /einladen (mobile: /invite) within ReferralSettings.redeemWindowDays. /r/[code] is the public landing page (sets a pf_ref cookie only to prefill the web onboarding); old /join?code= links redirect there. Logic lives in src/lib/referrals.ts: redeem sets Owner.invitedById and makes both first pets follow each other; an invite counts (referralQualifiedAt, inviter's denormalized qualifiedReferralCount +1) only once the invitee has a pet and a first real post — maybeQualifyReferral() is called from posts.create and the Mux webhook on READY; a new post-creating mutation must call it too.

Ranks (ReferralRank) are data, fully editable in the admin panel (/p/[secret]/referrals, referral-admin.ts); only the perk catalog (ReferralPerk, IMPLEMENTED_REFERRAL_PERKS in src/lib/referral-ranks.ts) needs code. Perks are cumulative. Reached ranks are grandfathered via ReferralRankAchievement — raising a threshold never removes a rank. An admin can revoke a rank permanently (revokedAt on the achievement row — the row stays so award/recompute never re-grant it; it is ignored for badges/perks until "Freigeben") or grant one manually (grantedManually). Owner.referralRankId/referralPerks are a cache maintained only by src/lib/referrals.ts and the set-based recomputeReferralRanks() (src/lib/referral-recompute.ts, run after every admin rank change); feed cards read them via petCardSelect. The legacy InviteCode table is history only — nothing writes to it anymore. One-time setup after the schema push: npx tsx prisma/scripts/referral-setup.ts (+ prisma/enable_rls_referrals.sql).

Testing

Vitest + jsdom. Prisma is fully mocked per-model via createMockPrisma() in src/__tests__/helpers/prisma-mock.ts — unit tests never touch a real database; when a router gains a new Prisma call, add the corresponding mock method there. Tests are organized one file per domain router in src/__tests__/ (e.g. posts.test.ts, stories.test.ts, follows.test.ts), not colocated with source.

Deployment — self-hosted Docker, not Vercel

Despite next.config.ts's output: "standalone" and the original stack research recommending Vercel, this app is deployed to a self-hosted OpenMediaVault NAS via Docker (docker/Dockerfile, docker/docker-compose.yml, docker/start.sh). Don't suggest Vercel-specific features (ISR revalidation webhooks, Vercel KV, edge config) without checking this still holds.

Deploy sync is now git pull on the server, not manual FTP (confirmed 2026-09-07 — supersedes the old FTP-based process; update this note again if it changes further). The NAS at /Dockers/PawFeed is its own git checkout with origin pointing at the self-hosted Gitea instance (http://192.168.1.222:3666/admin/petfeed.git, same repo git push in this dev environment goes to over SSH on port 2222). To deploy: ssh daniel@192.168.1.222 (standard port 22, key-based — a different credential from the port-2222 Gitea git-shell key used for git push/git pull from a dev machine), then on the NAS: cd /Dockers/PawFeed && git pull origin main, then cd docker && ./rolling-deploy.sh (or sh ./rolling-deploy.sh if it's not executable). mobile/ changes land in this same working tree too (it's one repo) but don't need a deploy — .dockerignore excludes it from the image build.

Runs as 3 load-balanced replicas, not a single container (since 2026-08-16, Gitea #26). docker/docker-compose.yml defines pawfeed-1/pawfeed-2/pawfeed-3 (via the x-pawfeed-common YAML anchor) instead of one pawfeed service — Next.js standalone output is single-process (node server.js), so one container only ever used one CPU core regardless of how many the NAS has. A load test on 2026-08-15 hit a hard ceiling at ~60–65 req/sec with one core pegged at 133% while 3 cores sat idle; splitting into 3 replicas fixed it (confirmed via a follow-up load test showing ~100% CPU spread evenly across all three). Nginx Proxy Manager (container nginx-app-1 on this NAS, admin UI at 192.168.1.222:81) load-balances across them via a least_conn upstream block — that block lives in /Docker-Data/nginxproxymanager/data/nginx/custom/http_top.conf on the NAS host (an nginx upstream {} is only valid in the http{} context, which NPM's per-host "Custom Nginx Configuration" field can't reach — that field only writes into the server{}/location{} context, and holds the proxy_pass http://pawfeed_upstream; override for the pawfeed.org Proxy Host instead). Full runbook, decisions, and risk notes: docker/SCALING-ROADMAP.md. Deploying a config or code change means rebuilding all 3 replicas — there is no separate single-container path anymore, and NPM's proxy target is pawfeed_upstream, not a bare pawfeed:3000 hostname. Use ./docker/rolling-deploy.sh for deploys against the live site — it rebuilds the shared pawfeed:latest image once, then restarts pawfeed-1/2/3 one at a time, gated on each becoming healthy per the Docker healthcheck (GET /api/health, a dependency-free liveness check — see src/app/api/health/route.ts) before moving to the next, so at least 2 replicas always serve traffic. Verified zero-downtime on 2026-08-16 (continuous curl monitoring showed unbroken HTTP 200 through a full rolling deploy). docker compose up -d --build (start.sh) still works but restarts all 3 at once — only use it for first-time setup or when a brief outage is acceptable. src/lib/prisma.ts's Prisma pg pool size is capped per-replica via DB_POOL_MAX (compose sets 10) so 3 replicas stay well under the Supabase pooler's client-connection limit.

Cron jobs run from the NAS crontab (user daniel, crontab -l), (there is no vercel.json — it was removed as inert). Every cron route checks the bearer token via isCronAuthorized() (src/lib/timing-safe-compare.ts). Each entry calls docker/run-cron.sh /api/cron/<name>, which reads CRON_SECRET from docker/.env, hits the route with the bearer token via the DDNS host (--resolve to localhost — plain pawfeed.org fails TLS from the NAS) and logs every run with status + body to docker/cron.log. Current schedule: trim-feeds 04:00 (Redis feeds → 90 days), cleanup-stories 04:30 (deletes stories older than 7 days incl. views, paws, comments, Supabase image and Mux asset — src/lib/story-cleanup.ts, max 200 per run), anniversaries 08:00, vaccine-reminders 08:15 (one-time reminder + push 30 days before Vaccine.nextDueDate, src/lib/vaccine-reminders.ts, guarded by Vaccine.reminderSentAt), pet-suggestions Mondays 09:00, plus docker/backup-db.sh 03:00. A new cron route needs a new crontab line on the NAS — it does not run otherwise.

Directories that are not app code

  • pentest-ai-agents/ — a separately-cloned plugin repo (has its own .git) sitting inside this project root. Unrelated to PawFeed; don't treat its agents/, commands/, db/ as part of the app.
  • scaffold-tmp/ — leftover create-next-app scaffold from initial bootstrap (has its own package.json, node_modules, generic starter page). Not part of the running app, which lives under src/.

This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.