# PawFeed
A social platform where pets are the stars. Dogs, cats, birds, horses, rabbits, guinea pigs, hamsters, mice, rats, chinchillas, snakes, lizards, turtles, geckos, spiders, and aquariums all get their own profiles — owners manage them, but the pet is the identity. Think Instagram built from the ground up for animals: pets follow each other, post photos and videos, celebrate milestones, and track their health, all within a community that actually cares about animals. Species/breed is a fully data-driven taxonomy (`Species`/`Breed` + translation tables), so adding a new species is a seed-data change, not a code change.
**Core value:** Pets are first-class social identities — not just content on a human's feed — so every feature is built around the pet's life, not the owner's.
**Status:** Live in production at [pawfeed.org](https://pawfeed.org) (public, invite gate disabled since 2026-08-10).
---
## Tech Stack
| Layer | Technology | Version |
|---|---|---|
| Framework | Next.js | 16.x |
| UI | React + Tailwind CSS v4 + shadcn/ui (base-nova preset) | React 19 |
| Language | TypeScript | 6.x |
| API | tRPC | 11.x |
| ORM | Prisma | 7.x |
| Database | PostgreSQL via Supabase (managed) | 16 |
| Auth | Clerk (+ `@clerk/themes` for dark-mode-aware UI, `@clerk/localizations`' `deDE` for German auth UI text) | 7.x |
| Media Storage | Supabase Storage (S3-compatible, presigned uploads) | — |
| Cache / Feed / Rate Limiting | **Self-hosted Redis** (`ioredis`, Docker service — not Upstash) | 5.x |
| Client Fetching | TanStack Query | 5.x |
| Video | Mux (upload, transcoding, HLS streaming) | — |
| Payments | Stripe (voluntary sponsoring — one-time & monthly, **LIVE mode**, not Test) | — |
| i18n | next-intl (EN/DE message catalogs; legal pages fixed-German) | 4.x |
| Theming | next-themes (account-bound dark mode via `Owner.themePreference`) | — |
| Forms / Validation | react-hook-form + `@hookform/resolvers` + Zod | — |
| Email | Nodemailer (branded HTML transactional mail) | 9.x |
| Error Monitoring / Tracing | `@sentry/nextjs` SDK → **self-hosted GlitchTip** (`glitchtip.pawfeed.org`) | 10.x |
| Tests | Vitest | 4.x |
| Hosting | **Self-hosted Docker** on an OpenMediaVault NAS (behind Nginx Proxy Manager + Cloudflare) — not Vercel | — |
**Design decisions:**
- All data access goes through **Prisma → direct Postgres connection**, never through Supabase PostgREST. RLS is enabled on all tables with no policies, which closes the PostgREST surface entirely.
- Media (photos, avatars, stories) is stored in **Supabase Storage** via presigned PUT URLs — file bytes never pass through the Next.js API server.
- The social feed uses a **Redis fan-out-on-write** pattern: on each new post, a sorted set entry is written into every follower's feed inbox. `feed.getFeed` reads from Redis and hydrates with Prisma. Falls back to Postgres when Redis is unavailable.
- Authentication is handled by **Clerk** (owner accounts). Pet profiles are app-domain entities in Postgres, not Clerk users.
- Short-form video is handled by **Mux**: client uploads directly to a signed Mux URL → Mux transcodes and serves HLS via `mux-player-react`. The `getByPostId` query self-heals in dev by polling the Mux API directly, so webhooks are not required during local development.
- **Health data is strictly owner-private.** The only publicly accessible health surface is the shareable health card (token URL with expiry), designed for vets, friends, and pet sitters. The emergency vet contact is intentionally excluded from the shared card — it lives only in the private dashboard.
- **Deployment is self-hosted, not Vercel.** `next.config.ts` still sets `output: "standalone"`, but the app ships as a Docker image (`docker/Dockerfile`, `docker/docker-compose.yml`) to a NAS, run as **3 load-balanced replicas** (not 1 container, since 2026-08-16 — Next.js standalone is single-process, one container = one CPU core). `./start.sh` rebuilds the image and recreates all 3 replicas.
- **Content-Security-Policy is enforced** (`src/proxy.ts`, via Clerk's `contentSecurityPolicy` middleware option with `strict: true`) — a per-request nonce + `'strict-dynamic'` instead of broad `http:`/`https:` sources in `script-src`. Clerk forwards the generated CSP header (with nonce) on both the response and the downstream request, which is exactly what Next.js's own automatic nonce application reads during SSR, so framework scripts and page bundles pick it up for free; hand-written inline scripts (e.g. `next-themes`' FOUC-prevention script in `src/app/layout.tsx`) read the nonce from the `x-nonce` header explicitly. Mozilla Observatory: A+. `'unsafe-eval'` stays in `script-src` defensively for now (see Known Issues).
- **Admin/moderation panel** lives at a secret path (`/p/[ADMIN_SECRET]/...`) — wrong or missing secret returns a bare 404, not 403, so the panel's existence isn't revealed to unauthenticated probing. Role-gated at the tRPC layer on top of that (`SUPER_ADMIN` / `MODERATOR`).
---
## System Architecture
Web and mobile are two clients over the **same** Next.js/tRPC backend and the same Postgres database — there is no separate mobile API. Deployment is asymmetric: web ships via a self-hosted Docker rolling deploy on the NAS, mobile ships via either an EAS native build (Play Store) or an EAS Update OTA push (JS/UI-only changes, no store review).
```mermaid
flowchart TB
Dev["Dev Machine
git push"]
subgraph Clients["Clients"]
WebApp["Web App
Next.js, browser (pawfeed.org)"]
MobileApp["Mobile App
Expo / React Native, Android"]
end
Clerk[("Clerk
Owner auth")]
subgraph Server["Next.js Server (shared backend)"]
TRPC["tRPC Routers
~20 domain routers"]
CronRoutes["/api/cron/*"]
MuxWebhook["/api/webhooks/mux"]
AdminPanel["/p/[secret]/*
Admin Panel"]
end
subgraph Data["Data & Media"]
Postgres[("Postgres
Supabase, via Prisma")]
Redis[("Redis
feed fan-out + rate limit")]
Storage["Supabase Storage
pet-avatars, music-tracks"]
Mux["Mux
video transcode / HLS"]
end
subgraph NAS["Self-hosted NAS (OpenMediaVault)"]
Gitea["Gitea
git remote, :2222"]
DeployCheckout["/Dockers/PawFeed
working tree"]
Replicas["pawfeed-1/2/3
Docker, load-balanced"]
NPM["Nginx Proxy Manager
+ Cloudflare"]
CronJobs["crontab
run-cron.sh, backup-db.sh"]
end
subgraph MobileDelivery["Mobile Delivery"]
EASBuild["EAS Build
native binary"]
EASUpdate["EAS Update
OTA, JS/UI only"]
PlayStore["Google Play
Closed Testing"]
end
WebApp --> TRPC
MobileApp --> TRPC
WebApp --> Clerk
MobileApp --> Clerk
TRPC --> Postgres
TRPC --> Redis
TRPC --> Storage
TRPC --> Mux
Mux --> MuxWebhook --> TRPC
CronJobs -->|"bearer token"| CronRoutes --> TRPC
CronJobs -->|"pg_dump, 30d retention"| Postgres
Dev -->|"git push"| Gitea -->|"git pull + rolling-deploy.sh"| DeployCheckout --> Replicas --> NPM --> WebApp
Dev -->|"eas update"| EASUpdate -->|"OTA JS bundle"| MobileApp
Dev -->|"eas build + submit"| EASBuild --> PlayStore -->|"native install/update"| MobileApp
```
Notable asymmetries worth remembering:
- Mobile has **no video-story support and no music-overlay support yet** (both web-only today) — see `mobile/PENDING-NATIVE-BUILD.md`.
- `backup-db.sh` talks to Postgres **directly** (`pg_dump` via a throwaway container), bypassing the tRPC layer entirely — it's the one path that isn't "go through the API."
- `AdminPanel` sits inside the same Next.js server/tRPC layer as the public app — gated by a secret path segment *and* a role check, not a separate deployment.
---
## Project Structure
```
src/
app/
(app)/ # Protected app shell (Clerk auth via src/proxy.ts)
feed/ # Home feed page + StoryTray
notifications/ # Notification center page
pets/ # Pet list ("my pets" switcher)
pets/[petId]/ # Pet profile page + ProfileTabs
pets/[petId]/edit/
pets/[petId]/followers/
pets/[petId]/following/
pets/[petId]/health/ # Owner-only health dashboard (weight, vet visits, vaccines, emergency vet)
explore/ # Species/breed-filtered discovery feed
search/ # Pet + People + Hashtag search
hashtag/[tag]/ # Hashtag feed
messages/ # DM inbox list
messages/[conversationId]/
invite/ # In-app "invite a friend" surface
onboarding/pet/ # First-pet creation onboarding
(auth)/ # Clerk-rendered sign-in / sign-up / password reset / email verify
p/[secret]/ # Admin & moderation panel — path segment IS the secret (ADMIN_SECRET)
users/ moderators/ posts/ reports/ ads/ invites/ verification/ blacklist/ messages/ log/ legal/ broadcasts/
unterstuetzen/ # Stripe sponsoring — one-time or monthly, grants a Sponsor badge
health-card/[token]/ # Public shareable health card (no login required)
join/ # Invite-code landing page, sets the pf_invite cookie
banned/ # Shown to banned owners (proxy.ts redirect target)
impressum/ datenschutz/ nutzungsbedingungen/ # German legal pages, fixed-language, not under (app)
api/
trpc/[trpc]/ # tRPC HTTP handler
auth/
set-invite/ consume-invite/ # Invite-cookie lifecycle around sign-up
account/delete/ # Self-service account deletion
gdpr-export/[token]/ # Tokenized personal-data export (GDPR)
csp-report/ # Receives CSP Report-Only violations, forwards to GlitchTip
cron/
anniversaries/ # Birthday + adoption-day notifications, daily 08:00
trim-feeds/ # Trims each pet's Redis feed sorted set to a 90-day window
webhooks/
mux/ # Mux webhook handler (video.asset.ready etc.)
stripe/ # Stripe webhook handler (checkout.session.completed etc.) — LIVE mode
components/ # feed/ health/ layout/ notifications/ pet/ post-creation/
# profile/ safety/ social/ stories/ video/ content/ (AI-disclosure UI)
# providers/ (ThemeProvider, ClerkThemeProvider) ui/ (shadcn copies)
context/
ActivePetContext.tsx # Global active pet state (pet switcher), SSR-initialized
i18n/
request.ts # next-intl config — check here before assuming a routing scheme
lib/
supabase-storage.ts # Supabase Storage presigned URL helpers (server-only)
redis.ts # ioredis client (self-hosted, lazyConnect)
rate-limit.ts # Redis-backed rate limiting middleware
mail.ts / mail-template.ts # Nodemailer + branded HTML email template
mux.ts # Mux Node SDK singleton
assert-pet-ownership.ts # Shared pet-ownership check used across routers
timing-safe-compare.ts # Constant-time string compare for admin/cron secrets
invite-codes.ts # InviteCode generation (word-prefix + 4-char code)
trpc/
init.ts # tRPC context ({ userId, prisma }); protectedProcedure
server.ts # Server-side caller for RSC prefetching
query-client.ts
routers/ # ~24 domain routers aggregated in _app.ts:
# pets, posts, stories, milestones, follows, blocks, reports,
# feed, reactions, comments, reposts, explore, search,
# notifications, videos, messages, health, admin, invites,
# ads, owner, legal
__tests__/ # One file per domain router; Prisma fully mocked (prisma-mock.ts)
proxy.ts # Next.js middleware (renamed from middleware.ts in Next 16) —
# invite gate + admin-secret gate + Clerk auth.protect() + CSP headers
prisma/
schema.prisma # Full DB schema — see Schema section below
seed.ts # Species + breed seed (Dog/Cat/Bird), with translations
supabase/
migrations/ # RLS-enablement SQL (Prisma bypasses RLS via service_role)
docker/
Dockerfile / docker-compose.yml / start.sh # Self-hosted deployment
```
---
## Prisma Schema — Model Overview
```
Owner — Clerk userId, owns n Pets; themePreference (dark mode)
Pet — Social identity: name, bio, adoptionStory, avatarKey, dmPolicy,
birthday, adoptedAt, species, breed, aiDisclosure
Species / Breed — Taxonomy, each with a *Translation table (i18n, not per-language rows)
Post / PostImage — PHOTO | MILESTONE | REPOST | VIDEO; denormalized reaction/comment/repost counts
Milestone — 1:1 with a Post (MilestoneType enum)
Story / StoryView — 24h ephemeral photos; views retained 30 days
Follow / Block — Pet ↔ Pet social graph (no owner columns)
Report — Post or Pet report (ReportReason enum), unique-constrained against spam
Reaction / Comment / Repost
Hashtag / PostHashtag
VideoPost — Mux upload/asset/playback IDs + VideoStatus
Conversation / Message — Pet ↔ Pet DMs; petAId always < petBId
WeightLog / VetVisit / Vaccine / EmergencyVet / HealthCard — owner-private health data
DataExportToken — GDPR export token lifecycle
Notification — FOLLOW | REACTION | COMMENT | MESSAGE | BIRTHDAY | ADOPTION_DAY
InviteCode — Beta invite-gate codes (word-prefix + 4-char code)
AdminRole — SUPER_ADMIN | MODERATOR, layered on top of ADMIN_OWNER_ID bootstrap
VerificationRequest — "verified pet" application flow with a required checklist
Warning / UserBan / Shadowban / BlacklistEntry — moderation actions
Advertisement / AdReaction / AdComment / AdRepost — sponsored posts with species/breed targeting
ModerationLog — audit trail for every admin/moderator action
LegalDocumentVersion — versioned legal-doc content + update-banner trigger
Broadcast / BroadcastReceipt — system-wide admin announcements ("Mitteilungen"), owner banner + per-owner read receipts
SponsorContribution — Stripe sponsoring records (ONE_TIME | SUBSCRIPTION), grants the Sponsor badge
```
---
## Setup
### Prerequisites
- Node.js 20+
- A [Supabase](https://supabase.com) project (PostgreSQL + Storage)
- A [Clerk](https://clerk.com) application
- A Redis instance (self-hosted, e.g. `docker run redis` — the app talks to it via `REDIS_URL`; no Upstash-specific code)
- A [Mux](https://mux.com) account (for video posts)
- Optional: a [GlitchTip](https://glitchtip.com) instance (self-hosted or hosted) or Sentry project, for error monitoring
### 1. Clone and install
```bash
git clone
cd pawfeed
npm install
```
### 2. Environment variables
Copy `.env.example` to `.env.local` and fill in all values — it documents every variable, including the optional GlitchTip/Sentry ones (`SENTRY_DSN`, `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`) and the invite gate (`INVITE_REQUIRED`).
```bash
cp .env.example .env.local
```
Key variables (see `.env.example` for the full annotated list):
| Variable | Where to find it |
|---|---|
| `DATABASE_URL` | Supabase -> Project Settings -> Database -> Connection string (pooled, port 6543) |
| `DIRECT_URL` | Supabase -> Project Settings -> Database -> Connection string (direct, port 5432) |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` / `CLERK_SECRET_KEY` | Clerk Dashboard -> API Keys |
| `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` | Supabase -> Project Settings -> API |
| `REDIS_URL` | Your Redis instance connection string (e.g. `redis://:password@localhost:6379`) |
| `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET` | Mux Dashboard -> Settings -> API Access Tokens |
| `MUX_WEBHOOK_SECRET` | Mux Dashboard -> Settings -> Webhooks -> Signing secret |
| `CRON_SECRET` | Generate a random string; used by all cron routes |
| `ADMIN_SECRET` | UUID that gates the `/p/[secret]/` admin panel path |
| `ADMIN_OWNER_ID` | Clerk user ID bootstrapped as SUPER_ADMIN |
> `SUPABASE_SERVICE_ROLE_KEY` and `MUX_TOKEN_SECRET` are server-only secrets — never exposed to the client.
### 3. Database setup
Push the Prisma schema to Supabase (this project uses `db push`, not migration files — local dev and production share one database, so a migration drift/reset would be destructive):
```bash
npx prisma db push
npx prisma generate
```
Seed species and breeds:
```bash
npx prisma db seed
```
Enable Row Level Security on all tables (run once in Supabase SQL Editor):
```sql
-- File: supabase/migrations/20260613000000_enable_rls_all_tables.sql
-- Paste the contents of this file into the Supabase SQL Editor and run.
```
> This closes the PostgREST surface. Prisma (direct connection with service_role) is unaffected.
### 4. Supabase Storage
Create a bucket named `pet-avatars` in Supabase Storage (Dashboard -> Storage -> New bucket), with `fileSizeLimit`/`allowedMimeTypes` restricted appropriately.
### 5. Mux webhook (production only)
In Mux Dashboard -> Settings -> Webhooks, add an endpoint pointing to `https://your-domain.com/api/webhooks/mux`. Subscribe to:
- `video.upload.asset_created`
- `video.asset.ready`
- `video.asset.errored`
> In local development, the webhook is not needed. `videos.getByPostId` polls the Mux API directly when the video is still `PROCESSING`, so the status self-heals without a tunnel.
### 6. Cron jobs (production only)
There is no serverless cron here (and no `vercel.json`). Trigger the cron routes externally (e.g. a NAS-side crontab hitting them with a bearer token):
```bash
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/anniversaries # daily 08:00
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/trim-feeds # daily
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/pet-suggestions # weekly, Monday 09:00
```
### 7. Run the dev server
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000). The app redirects unauthenticated users to Clerk sign-in.
### 8. Docker deployment (production)
```bash
cd docker
cp .env.example .env # fill in production values, not committed to git
docker compose build
./start.sh
```
`docker-compose.yml` passes the `NEXT_PUBLIC_*` vars and the Sentry/GlitchTip build-time vars (`SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`) as Docker build args — everything else is injected at runtime via `env_file`.
**As of 2026-08-16 this runs as 3 replicas (`pawfeed-1/2/3`), not 1 container** — Next.js standalone output is single-process, so a single container can't use more than one CPU core; there's no single-container path anymore. Nginx Proxy Manager load-balances across them via a `least_conn` upstream (`pawfeed_upstream`) — see `docker/SCALING-ROADMAP.md` for the full rationale and the NPM-side config location. For deploys against the live site, run `./docker/rolling-deploy.sh` instead of `docker compose up -d --build` — it rebuilds the image once and restarts the 3 replicas one at a time (gated on `GET /api/health`), verified zero-downtime. `docker compose up -d --build` still works but restarts all 3 at once.
### 9. Database backups & disaster recovery (production only)
There's no admin-panel UI for this — it's deliberately SSH-only, since a one-click restore in a browser is a much bigger blast radius than a command someone has to type deliberately. Three scripts live in `docker/`, all NAS-side (not run from a local dev machine), all working against `DIRECT_URL` (the non-pooled port-5432 connection — `pg_dump`/`pg_restore` against the pgbouncer-fronted `DATABASE_URL` are unreliable) via a throwaway `postgres:17-alpine` container, since the NAS host has no `postgresql-client` in its `PATH` (same reason Node/Prisma tooling there also runs in a throwaway container — see `CLAUDE.md`).
**Backup** (`docker/backup-db.sh`) — runs daily at 03:00 via the NAS crontab, no arguments. Dumps in `pg_dump -Fc` (custom, compressed) format to:
```
/srv/dev-disk-by-uuid-ad295d7f-a870-4f70-9de2-dfded1fabf7f/BACKUPALL/pawfeed/pawfeed-.dump
```
30-day retention (older `.dump` files are deleted after each successful run). Every run is logged to `docker/backup.log` — there's no local mail delivery configured on this NAS for cron failure notifications, so the log is the only place a failure would show up.
To back up on demand:
```bash
ssh daniel@192.168.1.222 "/Dockers/PawFeed/docker/backup-db.sh"
```
**Restore** (`docker/restore-db.sh`) — manual/emergency use only, **not** wired into any crontab.
```bash
# List available backups
ssh daniel@192.168.1.222 "/Dockers/PawFeed/docker/restore-db.sh"
# Restore one (prompts: type RESTORE to confirm — this drops and recreates
# live objects via pg_restore --clean --if-exists)
ssh daniel@192.168.1.222 "/Dockers/PawFeed/docker/restore-db.sh pawfeed-2026-09-18_19-34-22.dump"
# Non-interactive (scripted use only — skips the confirmation prompt)
ssh daniel@192.168.1.222 "FORCE=1 /Dockers/PawFeed/docker/restore-db.sh pawfeed-2026-09-18_19-34-22.dump"
```
Runs `pg_restore --clean --if-exists --no-owner --no-privileges -j 4` against `DIRECT_URL`. `--no-owner --no-privileges` avoids `ALTER OWNER`/`GRANT` errors if the connecting role doesn't exactly match the dump's original roles; `-j 4` parallelizes the restore (only possible with custom-format dumps). Logged to `docker/restore.log`.
**Caveat:** the dump is the *entire* database, including Supabase-internal schemas (`auth`, `storage`, `extensions`, ...). This app uses Clerk for identity (not Supabase Auth), so `auth` is normally empty/unused here. `storage` holds Supabase Storage's *metadata* rows (bucket/object records) — the actual file bytes live in Supabase's object store separately and aren't part of this dump, so a restore can leave `storage.objects` rows pointing at files deleted since the backup, or miss rows for files uploaded after it. Treat a restore as "the app is back up," not "storage state is bit-for-bit consistent" — spot-check media after restoring.
---
## Key Commands
| Command | What it does |
|---|---|
| `npm run dev` | Start Next.js dev server (Turbopack) |
| `npm run build` | Production build (`output: "standalone"`) |
| `npm run lint` | ESLint (flat config) |
| `npx vitest run` | Run full test suite |
| `npx vitest run src/__tests__/posts.test.ts` | Run a single test file |
| `npx tsc --noEmit` | Type check without emitting |
| `npx prisma db push` | Sync schema to DB (no migration files) |
| `npx prisma generate` | Regenerate Prisma client after schema change |
| `npx prisma studio` | Open Prisma Studio (DB browser) |
| `npx prisma db seed` | Re-seed species + breeds |
---
## AI-Assisted Development Workflow
This project is developed with Claude Code as the primary contributor. The full, living version of this workflow — including the gap analysis against the formal `.claude/rules/ecc/**` process and the open discussion points — lives in `WORKFLOW.md` at the repo root. This section is the durable, contributor-facing summary; update both files together when the process changes.
**Per change (bugfix / small task):**
1. Load context (`CLAUDE.md`, `.claude/rules/ecc/**`, stored memories, git status) — automatic, not a manual step.
2. Classify the request (bug report / implementation task / question / deploy action / docs).
3. Root-cause investigation before touching code — read the actual code and, where relevant, the actual production data (DB query, logs) rather than assuming. Search for and **fix twin instances of the same bug pattern automatically**, not just the one reported (e.g. one dark-mode fix in `post-new.tsx` surfaced and fixed the same bug in `story-new.tsx`/`search.tsx` in the same pass) — unless that would itself grow into a new feature, in which case it's flagged instead.
4. Implement, matching the closest existing pattern already in this codebase rather than generic best-practice defaults.
5. Verify: `tsc --noEmit`, `eslint` on changed files, tests where a suite exists for that area. A `git stash`/tool-rerun/`git stash pop` cycle separates pre-existing warnings from newly introduced ones before either is claimed.
6. **Code review before commit** — the `code-review` skill runs against the diff (CRITICAL/HIGH findings get fixed before committing; this is the actually-installed equivalent of the `code-reviewer` agent named in `.claude/rules/ecc/common/agents.md`, which isn't present in this environment's agent roster).
7. Commit with exact paths (never `git add -A` — this repo carries long-lived unrelated uncommitted work from other in-flight sessions) and push.
8. **Automatic deploy/publish, no confirmation needed, once 5 and 6 are clean:**
- Web change (`src/**`) → `./docker/rolling-deploy.sh` on the NAS
- Mobile change (`mobile/**`), pure JS/UI, no new native module → `eas update --branch production --environment production --platform android`
- Anything touching a native module, app version, or `app.json` permission → **not** an OTA case; flagged for a real native build instead
9. Document: `mobile/found.md` gets the root-cause/fix write-up under the reported item; `README.md` gets anything structurally reusable (new scripts, recurring gotchas) so the next session doesn't re-derive it.
**Per large mobile feature** (new schema + tRPC + UI spanning multiple files — not a single-file fix, even one that touches twin instances):
1. **Plan first** — via the `Plan` agent / `EnterPlanMode` before any code (the installed equivalent of the `planner` agent referenced in the formal rules).
2. **Own feature branch** (`git checkout -b feature/`), not directly on `main`. Verification and code review (steps 5–6 above) still run per commit on the branch, but the automatic deploy/OTA in step 8 does **not** fire until the branch is merged into `main`.
3. Merge to `main` only once the feature is complete and every commit on the branch is verified + reviewed; the normal automatic-deploy step then applies to the merge commit.
**Still requires explicit confirmation, no exceptions today:** DB restore/backup execution against the real production database, new NAS infrastructure/cron entries before they go live, and any native mobile build/store submission.
---
## Architecture Notes
### Identity model
```
Clerk User (owner)
└── owns 1..n Pet profiles (Postgres)
└── Pet is the social identity (posts, follows, stories, milestones,
reactions, comments, reposts, notifications, videos, DMs, health data)
```
An owner can switch between their pets via `ActivePetContext`. All tRPC mutations assert `pet.ownerId === ctx.userId` before writing (via the shared `assertPetOwnership()` helper).
### Middleware — `src/proxy.ts`, not `middleware.ts`
Next.js 16 renamed `middleware.ts` to `proxy.ts` — this **is** the middleware. It layers four 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 not in the public route matcher; (4) Report-Only Content-Security-Policy headers via Clerk's `contentSecurityPolicy` option.
### Media pipeline — Photos
```
Client -> tRPC getPresignedUrl -> Supabase Storage signed URL
Client -> XHR PUT directly to signed URL (bytes never hit Next.js server)
Client -> tRPC create/confirmUpload (writes storage key to DB)
Feed -> getMediaUrl(storageKey) -> Supabase public CDN URL
```
### Media pipeline — Video (Mux)
```
Client -> tRPC videos.createUpload -> Mux direct upload URL + PROCESSING Post row
Client -> XHR PUT directly to Mux URL (bytes never hit Next.js server)
Mux -> transcodes -> fires webhook -> /api/webhooks/mux -> updates VideoPost (READY + playbackId)
VideoCard polls videos.getByPostId every 5s -> transitions from spinner to MuxPlayer
```
> In dev (no webhook): `getByPostId` checks the Mux API directly when the status is `PROCESSING` and updates the DB on the fly. No tunnel required.
### Feed architecture
```
Post created
└── fanOutPost(redis, postId, petId, createdAt, prisma)
└── for each follower: ZADD feed:{followerPetId} score=timestamp member=postId
feed.getFeed({ petId, cursor, mode: "chrono" | "algo" })
└── ZRANGE feed:{petId} -> postIds
└── prisma.post.findMany({ where: { id: { in: postIds } } }) -> hydrated posts
└── chrono mode: preserves Redis sorted-set order (newest first)
└── algo mode: client-side re-sort by algoScore()
algoScore = reactions×3 + comments×5 + originalReposts×4 + recency bonus
└── Fallback (Redis unavailable): Postgres query on follows + own posts
```
A daily cron (`/api/cron/trim-feeds`) trims each pet's Redis feed sorted set to a 90-day retention window.
### Admin & moderation panel
Gated by path secret (`ADMIN_SECRET`, see `proxy.ts`) **and** by role at the tRPC layer: `admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-bootstrapped super-admin) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Every action is written to `ModerationLog`. Covers users, moderators, posts, reports, ads, invites, verification requests, blacklist entries, and system-wide broadcast announcements (`createBroadcast`/`deleteBroadcast`, both `SUPER_ADMIN`-gated, deletion requires a reason for the audit trail).
The panel is permanently dark (forced via a literal `dark` class on the shell, independent of the site-wide theme toggle) with its own active-page nav highlighting (`AdminSidebarNav`, mobile drawer mirrors it). The dashboard groups its overview stats by category — Activity (emerald), Violations (rose, only lights up when actually > 0), Info (sky) — instead of one flat wall of identical cards; only Phase 1+2 of the visual pass are done (shell tokens + dashboard), Phase 3/4 (shared `AdminCard`/`AdminStat` primitives applied to the remaining 11 pages) are deferred.
### Invite system
`InviteCode` (word-prefix + 4-char code, e.g. `PAWS-7K3M`) gates sign-up via a `pf_invite` cookie set by `/join` and consumed by `/api/auth/consume-invite` right after sign-up. `INVITE_REQUIRED=false` turns the whole gate off (the current production setting, since 2026-08-10).
### Error monitoring & CSP
`@sentry/nextjs` reports both errors and performance traces to a self-hosted GlitchTip instance. CSP is enforced (nonce + `'strict-dynamic'`, see Design decisions above); violations still get reported to `/api/csp-report` → GlitchTip (`report-uri` + Reporting API, including `lineNumber`/`columnNumber`/`sample` for fast diagnosis) even though the policy is no longer report-only, so regressions surface immediately without blocking users first.
### Health data privacy
All health data (weight logs, vet visits, vaccines, emergency vet) is **owner-private**:
- `/pets/[petId]/health` redirects any non-owner to the public profile
- All tRPC health procedures call `assertOwner()` before any DB access
- The only public surface is the **HealthCard** at `/health-card/[token]` (UUID token, user-selected expiry, PDF export via `?print=1`)
- Does NOT show: emergency vet contact (stays off the shared card)
### Notification triggers (fire-and-forget)
```
follows.follow -> FOLLOW notification to followeePet
reactions.toggle -> REACTION notification to post owner (skip if own post)
comments.create -> COMMENT notification to post owner (skip if own post)
/api/cron/anniversaries (daily 08:00)
-> BIRTHDAY / ADOPTION_DAY notification, 1-year minimum guard
```
All in-app triggers are wrapped in `.catch(() => {})` — they never crash the parent mutation.
### PawRing (story indicator)
Instead of a standard circular ring, active stories are indicated by a paw-print SVG outline around the avatar: orange (unseen), gray (seen), no ring (none). Identity comes from `ActivePetContext` inside `PawRing` itself.
---
## Glossary
The load-bearing concept in this codebase: **the pet, not the owner, is the social identity.** Every domain term below is defined in relation to that split.
```
Owner (Clerk account, auth only)
└── owns 1..n Pet(s)
└── Pet IS the social identity — profile URL, posts, followers, DMs
├── Post (PHOTO | MILESTONE | REPOST | VIDEO)
│ ├── Milestone — 1:1 metadata row when Post.type = MILESTONE
│ ├── PostImage — 1:n photos on a PHOTO post
│ ├── VideoPost — Mux upload/asset state when Post.type = VIDEO
│ ├── Reaction / Comment / Repost — engagement on the Post
│ └── Hashtag (via PostHashtag)
├── Story — 24h ephemeral photo, separate from Post
├── Follow — Pet → Pet edge (the whole social graph is Pet-to-Pet)
├── Conversation / Message — Pet ↔ Pet DMs (petAId always < petBId)
├── WeightLog / VetVisit / Vaccine / EmergencyVet — owner-private health data
│ └── HealthCard — the one shareable, tokenized window into that data
└── VerificationRequest — "verified pet" application (blue check)
```
| Term | What it is | Relates to |
|---|---|---|
| **Owner** | A Clerk-authenticated human account. `Owner.id` **is** the Clerk `userId` — no separate mapping table. Owns pets, manages settings, is never itself a follower/poster/commenter. | 1:n **Pet**; the human behind one or more pets |
| **Pet** | The actual social identity — has its own profile URL, avatar, bio, follower count, posts, DMs. Every social action (`Post`, `Follow`, `Reaction`, `Comment`, `Message`, ...) is scoped by `petId`, never `ownerId`. | Belongs to one **Owner**; everything social hangs off this |
| **Species / Breed** | Fully data-driven taxonomy (with i18n translation tables) — 16 species categories from dogs to aquariums; `Breed` is the predominant fish species for an aquarium `Pet`, not a per-fish profile. | Attributes on **Pet**; drives ad targeting and the spider-exclusion filter |
| **Post** | The core content unit. `type` is one of `PHOTO`, `MILESTONE`, `REPOST`, `VIDEO`. Carries denormalized `reactionCount`/`commentCount`/`repostCount` (maintained transactionally, not computed on read). | Authored by a **Pet**; fans out to followers' Redis feed on create |
| **Milestone** | A 1:1 sidecar row on a `Post` when `type = MILESTONE` (e.g. "500 followers", "adoption anniversary"). Not a separate feed item — it's a `Post` with extra metadata and different card styling. | 1:1 with **Post** |
| **Story** | A 24-hour ephemeral photo, structurally separate from `Post` (own model, own view-tracking with 30-day retention). Not part of the main feed. | Authored by a **Pet** |
| **Follow** | A directed edge from one **Pet** to another. The entire social graph (`Follow`, `Block`, `Reaction`, ...) has zero owner columns — resolving "who can see this" never touches `Owner`. | Pet → Pet, no Owner involvement |
| **Reaction / Comment / Repost** | Engagement primitives on a `Post` (or `Advertisement`, which has its own parallel `AdReaction`/`AdComment`/`AdRepost` set). Each create/delete transactionally updates the parent's denormalized counter. | Attach to **Post** (or `Advertisement`) |
| **Notification** | Fire-and-forget row created on `FOLLOW`/`REACTION`/`COMMENT`/`MESSAGE`/`BIRTHDAY`/`ADOPTION_DAY` events, always wrapped in `.catch(() => {})` so a notification failure can never break the triggering mutation. | Targets a **Pet** (the recipient's active pet) |
| **Conversation / Message** | DMs. `petAId`/`petBId` are canonically ordered (`petAId < petBId`) so a conversation between two pets has exactly one row regardless of who initiated it. | Pet ↔ Pet |
| **HealthCard** | The *only* public window into a pet's otherwise strictly owner-private health data (`WeightLog`, `VetVisit`, `Vaccine`, `EmergencyVet`) — a tokenized, expiring URL meant for vets/sitters, deliberately excluding the emergency vet contact. | Derived view over health data owned by a **Pet** |
| **InviteCode** | Beta sign-up gate (word-prefix + 4-char code, e.g. `PAWS-7K3M`), consumed once via a `pf_invite` cookie. Currently disabled (`INVITE_REQUIRED=false`) for public launch — the model still exists for a possible future re-gate. | Gates **Owner** account creation, not pet creation |
| **AdminRole / ModerationLog** | `AdminRole` is a 3-tier permission row (`SUPPORT` < `MODERATOR` < `SUPER_ADMIN`) checked by every `admin.*` tRPC procedure via `assertAdmin()`; `ADMIN_OWNER_ID` bootstraps one super-admin without needing a row. Every admin action writes a `ModerationLog` entry (action, target, reason, moderator, IP). | Applies to an **Owner** acting as an admin; targets any entity |
| **Warning / UserBan / Shadowban / BlacklistEntry** | Moderation actions, all applied to an **Owner** (the account), not a specific **Pet** — banning stops the human, not just one of their pets. | Target an **Owner** |
| **Broadcast / BroadcastReceipt** | Admin-authored system-wide announcements (`SUPER_ADMIN`-gated create *and* delete, both audited). `BroadcastReceipt` is one row per `(broadcast, owner)`, created lazily the first time an owner's banner query surfaces it — no fan-out at send time. | Sent by an admin **Owner**; received by every **Owner** (not per-pet) |
| **SponsorContribution** | A voluntary Stripe payment (`ONE_TIME` or `SUBSCRIPTION`, **LIVE mode**) that grants the Sponsor badge, shown on the contributing owner's pet profile(s) and posts. | Made by an **Owner**; the badge displays on their **Pet**(s) |
| **VerificationRequest** | An owner-submitted application (with a required checklist) for the "verified pet" blue check, reviewed by an admin. | Requested for a **Pet**, reviewed by an admin **Owner** |
| **LegalDocumentVersion** | Versioned Impressum/Datenschutz/AGB content — a version bump triggers an in-app update banner every owner must acknowledge. | Acknowledged by an **Owner** (account-level, not per-pet) |
| **DataExportToken** | A short-lived token backing the GDPR self-service data export flow. | Issued to an **Owner** |
---
## What's Shipped
Foundation → content core → engagement → notifications → video → messaging → health tracking are all live, plus a full production layer built after MVP:
- **i18n** — next-intl EN/DE, species/breed translation tables (legal pages stay fixed-German); Clerk's own sign-in/sign-up UI is localized to German via `@clerk/localizations`' `deDE` (2026-09-01)
- **Dark mode** — account-bound (`Owner.themePreference`), synced across Clerk's own UI
- **Admin/moderation panel** — users, posts, reports, ads, invites, verification, audit log, 3-tier role matrix
- **Invite system** — beta gate, now disabled for public launch
- **Legal** — Impressum/Datenschutz/AGB, versioned with an update-banner trigger, AI-content disclosure badges on uploads
- **GDPR** — tokenized personal-data export, self-service account deletion
- **Ads** — sponsored posts with species/breed targeting, ad-specific reactions/comments/reposts
- **Sponsoring** — voluntary Stripe payments (one-time/monthly, LIVE mode) grant a Sponsor badge shown on the pet profile and every post
- **Broadcasts** — admin-authored system-wide announcements with an owner-facing banner, per-owner delivered/read receipts, and audited creation/deletion
- **Story engagement** — paw reactions and comments on Stories (`StoryReaction`/`StoryComment`, mirroring the Post engagement model), comments render as a translucent overlay on the *running* story, not a pause-and-read sheet
- **Per-pet Insights dashboard** — owner-facing analytics at `/pets/[petId]/insights` (total reactions/comments, top post/story by each metric), spotlight cards open the real post/story
- **Design system** — `PRODUCT.md`/`DESIGN.md` ("The Spotlight Porch": one marigold-orange accent, ring-defined surfaces, no drop shadows at rest) captured from the existing brand and used to rebuild the landing page, header, and all 3 legal pages
- **Security hardening** — Redis-backed rate limiting, enforced CSP (nonce + `strict-dynamic`, Mozilla Observatory A+), HSTS/security headers, constant-time secret comparison, self-hosted error monitoring
- **Self-hosted deployment** — Docker on an OpenMediaVault NAS behind Nginx Proxy Manager + Cloudflare, 3 load-balanced replicas, zero-downtime rolling deploys
### Open / next up
| Item | Notes |
|---|---|
| Remove `'unsafe-eval'` from CSP `script-src` | Kept defensively after 6 isolated eval violations (2026-08-15, never recurred) that live reproduction couldn't pin on app code — likely a browser extension. Revisit once confirmed clean over a longer window. |
| Story video support | Stories currently photo-only; `StoryViewer` already has a progress timer, needs video playback |
| Follower milestone auto-notifications | `MilestoneType` has `FOLLOWERS_500` etc. but no trigger wired yet |
| Google / Apple OAuth | Supported natively by Clerk — deliberately deferred, no code change required |
| Saved posts | Bookmarking to a private collection — no schema yet |
| Comment replies | Flat comments only today; 1-level threading would need a `replyToId` FK |
| React Native app | Web-first for v1, listed as a future milestone |
| Supabase Realtime | Replace polling (NotificationBell, conversation view) with channel subscriptions |
---
## Testing
Tests are unit tests only (no DB, no network). Prisma is fully mocked per-model via `createMockPrisma()` in `src/__tests__/helpers/prisma-mock.ts`.
```bash
npx vitest run # full suite
npx vitest # watch mode
```
---
## Known Issues / Debugging Notes
| Issue | Notes |
|---|---|
| `DATABASE_URL` must use the **pooled** connection (port 6543, `?pgbouncer=true`) | Prisma uses this at runtime. `DIRECT_URL` (port 5432) is only for `prisma db push`. |
| Prisma schema changes always use `db push`, never `migrate dev`/`reset` | Local dev and production share a single Supabase DB — a migration drift/reset would delete production data. |
| After a Prisma schema change, **always restart the dev server** | The Next.js dev server caches the Prisma client in memory. |
| Supabase service_role key is never client-facing | All files that import `supabase-storage.ts` must be `server-only`. |
| `asChild` prop is not available in the base-nova shadcn preset | Style the trigger element directly instead. |
| Local dev cannot exercise authenticated routes | Clerk runs with production keys and rejects non-`pawfeed.org` origins; only public pages are testable against `npm run dev`. |
| Mux webhook not required in local dev | `videos.getByPostId` polls Mux directly when status is `PROCESSING`. |
| `CRON_SECRET` must be set for all cron routes | Without it, the routes return 401 and nothing fires — there is no serverless scheduler here, an external trigger (NAS crontab) is required. |
| Server-side requests to the app's own domain must target the DDNS hostname, not `pawfeed.org` | Nginx Proxy Manager's origin TLS certificate is only valid for `pawfeed.neodk.ipv64.de` — `pawfeed.org` works for browsers only because Cloudflare terminates TLS at the edge and doesn't strictly verify the origin cert. A plain `curl https://pawfeed.org/...` from the NAS itself (e.g. `docker/run-cron.sh`) fails with an SSL hostname mismatch; use `curl --resolve pawfeed.neodk.ipv64.de:443:127.0.0.1 https://pawfeed.neodk.ipv64.de/...` instead. |
| A 401 (not 403) from an admin tRPC procedure usually means a stale Clerk session, not a permissions bug | `protectedProcedure` throws `UNAUTHORIZED` (401) when Clerk's session is missing/expired; `assertAdmin()` throws `FORBIDDEN` (403) when the session is valid but the role is too low. A browser tab left open and backgrounded for hours can let Clerk's silent token refresh lapse — reload the page before suspecting a code regression. |
| `eas update` must be run with `--platform android` (`mobile/`), not `--platform all` | This app has no web target — `react-native-web` isn't installed — so the default `--platform all` fails bundling with `Unable to resolve module react-native-web/dist/index`. Previously published updates were all Android-only too (check `eas channel:view ` to confirm before assuming this changed). |
| `mobile/node_modules` can end up silently corrupted (missing nested files) after an interrupted/partial install, without `npm install` re-detecting or fixing it | Symptom: Metro bundling fails with `Unable to resolve module ./some/nested/path.js` inside an already-installed package (seen with `@expo/cli`'s `utils/telemetry`, then `@sentry/browser` and `@sentry-internal/browser-utils` in the same session) — `npm install` reports "up to date" and does nothing. Fix: `rm -rf node_modules && npm install` (a full reinstall), not a targeted one; deleting just the one broken package's folder can just surface the *next* corrupted package one bundle-failure at a time. |
| Mobile Google SSO can break with `redirect url ... does not match an authorized redirect URI` even though `GoogleSignInButton.tsx`/`app.json`'s scheme are unchanged | Clerk keeps its authorized-redirect-URL allowlist server-side per instance (`GET`/`POST https://api.clerk.com/v1/redirect_urls`, auth via `CLERK_SECRET_KEY`), entirely outside this repo — it can be missing or reset independent of any code change. Check that list before debugging app code; add the missing entry (`{"url":"pawfeed://sso-callback"}` for this app's `"scheme": "pawfeed"`) via `POST /v1/redirect_urls` — takes effect immediately, no app deploy/rebuild needed. |
| Admin music-track upload can fail even with a genuinely valid MP3/M4A/AAC file | Two independent causes, both hit in the same incident: (1) the Supabase bucket must be named **exactly** `music-tracks` (plural) — it was once created as `music-track` (singular), so every upload failed with a bucket-not-found error surfaced only as a generic "Upload fehlgeschlagen" toast; verify via `GET {SUPABASE_URL}/storage/v1/bucket` with the service-role key. (2) The upload form used to validate against the browser-reported `File.type`, which is sniffed from the OS's own MIME registry and is inconsistent for `.m4a`/`.aac` (e.g. Windows Chrome reports `.m4a` as `audio/x-m4a`, not `audio/mp4`) — it now derives the content-type from the file extension instead (`src/lib/music-content-types.ts`), matching what `MUSIC_ALLOWED_CONTENT_TYPES` (server-side, `supabase-storage.ts`) actually expects. |