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>
9.3 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. 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
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, invites, 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, 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_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 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 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/.