# 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 ```bash 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=` → `/r/`. Logic is pure in `src/lib/app-redirect.ts`; clicks are counted per day/source/platform in Redis (`appredirect:` 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/`, 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.