- CSP: Report-Only -> enforced (nonce + strict-dynamic, Mozilla A+) - Add Stripe/SponsorContribution, Broadcast/BroadcastReceipt (missing from tech stack, model overview, admin panel description, shipped features) — both real, deployed features the README never mentioned - Species list: 3 examples -> the actual 16-category taxonomy - Known Issues: NPM origin-cert quirk, 401-vs-403 admin debugging note - New Glossary section: Owner/Pet/Post/Milestone/... relationships
486 lines
33 KiB
Markdown
486 lines
33 KiB
Markdown
# 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 Clerk UI) | 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`).
|
||
|
||
---
|
||
|
||
## 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 <repo-url>
|
||
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 both 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 — `vercel.json`'s `crons` array is inert on a self-hosted deployment. Trigger the two 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
|
||
```
|
||
|
||
### 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.
|
||
|
||
---
|
||
|
||
## 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 |
|
||
|
||
---
|
||
|
||
## 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).
|
||
|
||
### 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)
|
||
- **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
|
||
- **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 both 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. |
|