- 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>
16 KiB
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 Clerkauth());protectedProcedurethrowsUNAUTHORIZEDifuserIdis null and narrows it to non-null inctxfor 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 insrc/trpc/routers/.src/trpc/server.tsexposes a server-side caller (createTRPCCaller()) used inside Server Components/layouts to prefetch without an HTTP round-trip (seesrc/app/(app)/layout.tsx).src/trpc/client.tsx+query-client.tswire up the browser-side TanStack Query client.- Pet-ownership checks go through the shared
assertPetOwnership(prisma, petId, userId)helper insrc/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 admin.ts moderation deletes) 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.tsxdoes a belt-and-suspendersauth()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_SECRETenv var); wrong or missing secret returns a bare404, not403, 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 anext-intlproject 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: src/trpc/routers/admin.ts's assertAdmin() allows either ADMIN_OWNER_ID (env-configured super-admin bootstrap) or an AdminRole row (SUPER_ADMIN / MODERATOR). Actions are written to ModerationLog. Pages live under src/app/p/[secret]/{users,moderators,posts,reports,ads,referrals,…}.
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. 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.
src/app/api/cron/trim-feeds/route.ts trims each pet's Redis feed sorted set to the 90-day retention window and expects a CRON_SECRET bearer token. vercel.json's crons array that points at it is inert on this self-hosted deployment — nothing calls this route yet. It needs an external trigger (e.g. a NAS-side cron job hitting the route with the secret header) before it actually runs.
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 itsagents/,commands/,db/as part of the app.scaffold-tmp/— leftovercreate-next-appscaffold from initial bootstrap (has its ownpackage.json,node_modules, generic starter page). Not part of the running app, which lives undersrc/.
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.