# 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. Dogs, cats, and birds 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); dogs/cats/birds only in v1; short-form video only (no long-form). ## 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, invites, 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 `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.tsx` does a belt-and-suspenders `auth()` check, redirects to onboarding if the owner has zero pets, redirects banned owners to `/banned`, and consumes a pending invite cookie). - `/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 three concerns in one Clerk middleware: (1) invite gate — blocks `/sign-up` unless a `pf_invite` cookie is present, unless `INVITE_REQUIRED=false`; (2) admin secret gate — 404s `/p/*` unless the path segment matches `ADMIN_SECRET`; (3) `auth.protect()` for everything else not in the public route matcher. ### 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,invites}`. ### Invite system `InviteCode` model (word-prefix + 4-char code, e.g. `PAWS-7K3M`, generated in `src/lib/invite-codes.ts`) gates sign-up via a `pf_invite` cookie set by `/join` and consumed by `/api/auth/consume-invite` right after sign-up (the `(app)` layout redirects there if the cookie is still present). `INVITE_REQUIRED=false` turns the whole gate off for post-beta public launch. ### 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`). **There is no `git pull` on the server** — deploys are manual FTP uploads of changed files followed by `./start.sh`. Don't suggest Vercel-specific features (ISR revalidation webhooks, Vercel KV, edge config) without checking this still holds. `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 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/`.