docs: bring README up to date with current tech stack and architecture

Tech stack table was describing the original MVP plan (Vercel, Upstash
Redis, middleware.ts) rather than what's actually running: self-hosted
Docker deployment, self-hosted Redis, next-intl i18n, dark mode,
GlitchTip/Sentry monitoring, CSP, and the admin/moderation panel built
since. Also refreshes the project structure tree, Prisma schema
overview, and setup instructions to match the current codebase.
This commit is contained in:
admin committed 2026-08-13 21:17:42 +02:00
1 parent b469389654
commit e958f38575
1 file changed
+190 -331
+190 -331
View File
@@ -4,6 +4,8 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own
**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
@@ -16,13 +18,18 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own
| API | tRPC | 11.x |
| ORM | Prisma | 7.x |
| Database | PostgreSQL via Supabase (managed) | 16 |
| Auth | Clerk | 7.x |
| Media Storage | Supabase Storage (S3-compatible) | — |
| Cache / Feed | Upstash Redis (serverless) | — |
| 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) | — |
| 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 | Vercel (target) | — |
| 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.
@@ -31,6 +38,9 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own
- 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. `./start.sh` rebuilds and restarts the container after pulling changes.
- **Content-Security-Policy runs in Report-Only mode** (`src/proxy.ts`, via Clerk's own `contentSecurityPolicy` middleware option) while directive gaps get closed from real violation reports collected in GlitchTip, before switching to enforced.
- **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`).
---
@@ -39,102 +49,76 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own
```
src/
app/
(app)/ # Protected app shell (Clerk middleware)
feed/ # Home feed page + StoryTray
notifications/ # Notification center page
pets/[petId]/ # Pet profile page + ProfileTabs
(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)
search/ # Pet + People + Hashtag search
messages/ # DM inbox list
messages/[conversationId]/ # Conversation view
onboarding/ # Pet creation onboarding
(auth)/ # Clerk-rendered sign-in / sign-up
health-card/[token]/ # Public shareable health card (no login required)
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/
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 HTTP handler
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/ # Vercel cron: fires birthday + adoption-day notifications daily at 08:00
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.)
components/
feed/ # PostCard, MilestoneCard, RepostCard, FeedList, SkeletonCard
# PawButton (paw reaction), PawBackButton (repost), CommentSheet
# WelcomeCard (new-user onboarding card, localStorage-dismissed)
health/ # HealthDashboard (tabs), WeightChart (SVG bezier), PrintButton, AutoPrint
# EmergencyVetSection (Nominatim search + OSM map, owner-only)
layout/ # Sidebar (with NotificationBell), MobileNav
notifications/ # NotificationBell (badge + count)
pet/ # AvatarUpload, PetForm, PetListItem
post-creation/ # PostTypeSheet, PhotoPostForm, StoryForm, MilestoneForm,
# MultiImageUpload, VideoUploadForm (+ Mux direct upload)
profile/ # ProfileTabs (Posts grid + Milestones list) — post detail as centered Dialog
safety/ # BlockDialog, ReportSheet
social/ # FollowButton
stories/ # PawRing, StoryViewer, StoryTray, StoryForm
ui/ # shadcn component copies (button, dialog, sheet, etc.)
video/ # VideoCard (polls PROCESSING -> plays READY via MuxPlayer)
mux/ # Mux webhook handler (video.asset.ready etc.)
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)
ActivePetContext.tsx # Global active pet state (pet switcher), SSR-initialized
i18n/
request.ts # next-intl config — check here before assuming a routing scheme
lib/
avatar-url.ts # CDN URL construction for avatars
feed-helpers.ts # fanOutPost, backfillOnFollow (Redis)
hashtag-helpers.ts # extractHashtags, saveHashtags
media-url.ts # CDN URL construction for post/story media
milestone-meta.ts # MilestoneType -> Lucide icon + label map
milestone-thresholds.ts # Follower count thresholds for auto-milestones
mux.ts # Mux Node SDK singleton + MUX_WEBHOOK_SECRET
parse-caption.ts # Tokenises captions into text + #hashtag segments
prisma.ts # Prisma singleton with PgBouncer adapter
redis.ts # Upstash Redis client
supabase-storage.ts # Supabase Storage presigned URL helpers (server-only)
utils.ts # cn() helper
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)
server.ts # Server-side caller for RSC
query-client.ts # TanStack Query client factory
routers/
_app.ts # Root router
pets.ts # CRUD for pet profiles (birthday + adoptedAt fields included)
posts.ts # Photo posts (create, updateCaption, delete, byPetId)
stories.ts # 24h stories (create, listActive, hasActiveStory, recordView, getViewers)
milestones.ts # Milestone posts (create, listByPet)
feed.ts # Home feed (getFeed with mode: "chrono" | "algo")
follows.ts # Pet-to-pet follow graph
reactions.ts # Paw reactions (toggle, count per post)
comments.ts # Flat comments (create, delete, listByPost)
reposts.ts # Paw-Back repost with attribution
explore.ts # Explore page (species/breed filter)
search.ts # Pet name/breed search + owner username search + hashtag search
notifications.ts # In-app notifications (list, unreadCount, markRead, markAllRead)
videos.ts # Mux video (createUpload, getByPostId with Mux self-heal, updateCaption)
blocks.ts # Block a pet
reports.ts # Report a post or pet
media.ts # Avatar presigned upload (getPresignedUrl, confirmUpload)
messages.ts # DM conversations (create, list, send, markRead)
health.ts # Weight logs, vet visits, vaccines, emergency vet, shareable health card
__tests__/
helpers/
prisma-mock.ts # Typed mock PrismaClient factory
auth.test.ts
feed.test.ts
follows.test.ts
blocks.test.ts
reports.test.ts
posts.test.ts
stories.test.ts
milestones.test.ts
middleware.ts # Next.js middleware (Clerk auth guard)
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)
schema.prisma # Full DB schema — see Schema section below
seed.ts # Species + breed seed (Dog/Cat/Bird), with translations
supabase/
migrations/
20260613000000_enable_rls_all_tables.sql
vercel.json # Vercel cron config (anniversaries at 08:00 daily)
.planning/ # GSD planning artefacts (phases, plans, summaries)
migrations/ # RLS-enablement SQL (Prisma bypasses RLS via service_role)
docker/
Dockerfile / docker-compose.yml / start.sh # Self-hosted deployment
```
---
@@ -142,31 +126,29 @@ vercel.json # Vercel cron config (anniversaries at 08:00 daily)
## Prisma Schema — Model Overview
```
Owner — Clerk userId, owns n Pets
Pet — Social identity. Fields: name, bio, adoptionStory, avatarKey,
dmPolicy, birthday (Date), adoptedAt (Date), species, breed
Post — PHOTO | MILESTONE | REPOST | VIDEO
PostImage — image storage keys (0-based position for carousel)
Milestone — linked 1:1 to a Post (MilestoneType enum)
Story — 24h ephemeral photos; expiresAt indexed
StoryView — viewer tracking; retained 30 days
Follow — Pet → Pet; (followerPetId, followeePetId) PK
Block — Pet → Pet
Report — Post or Pet report with ReportReason enum
Reaction — 1 per (post, pet); unique constraint enforces toggle
Comment — flat, 500-char VarChar
Repost — PostType.REPOST; references originalPost
Hashtag — unique tag string
PostHashtag — Post ↔ Hashtag join
VideoPost — Mux upload/asset/playback IDs + VideoStatus
Conversation — Pet ↔ Pet DM pair; petAId always < petBId
Message — body + readAt + senderPetId
WeightLog — date (Date), weightGrams (Int), notes
VetVisit — date (Date), reason, vetName, notes
Vaccine — name, dateGiven, nextDueDate (Date, optional), notes
EmergencyVet — 1:1 with Pet (unique petId); name*, address, phone, website, lat, lng
HealthCard — 1:1 with Pet; token (uuid), expiresAt (nullable)
Notification — type: FOLLOW|REACTION|COMMENT|MESSAGE|BIRTHDAY|ADOPTION_DAY
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
```
---
@@ -178,8 +160,9 @@ Notification — type: FOLLOW|REACTION|COMMENT|MESSAGE|BIRTHDAY|ADOPTION_DAY
- Node.js 20+
- A [Supabase](https://supabase.com) project (PostgreSQL + Storage)
- A [Clerk](https://clerk.com) application
- An [Upstash](https://upstash.com) Redis database
- 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
@@ -191,32 +174,32 @@ npm install
### 2. Environment variables
Copy `.env.example` to `.env.local` and fill in all values:
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 Dashboard -> API Keys |
| `CLERK_SECRET_KEY` | Clerk Dashboard -> API Keys |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase -> Project Settings -> API -> Project URL |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase -> Project Settings -> API -> service_role key |
| `UPSTASH_REDIS_REST_URL` | Upstash Console -> Redis database -> REST URL |
| `UPSTASH_REDIS_REST_TOKEN` | Upstash Console -> Redis database -> REST Token |
| `MUX_TOKEN_ID` | Mux Dashboard -> Settings -> API Access Tokens |
| `MUX_TOKEN_SECRET` | Mux Dashboard -> Settings -> API Access Tokens |
| `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; paste same value in Vercel env vars |
| `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:
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
@@ -240,7 +223,7 @@ Enable Row Level Security on all tables (run once in Supabase SQL Editor):
### 4. Supabase Storage
Create a public bucket named `pet-avatars` in Supabase Storage (Dashboard -> Storage -> New bucket).
Create a bucket named `pet-avatars` in Supabase Storage (Dashboard -> Storage -> New bucket), with `fileSizeLimit`/`allowedMimeTypes` restricted appropriately.
### 5. Mux webhook (production only)
@@ -251,9 +234,14 @@ In Mux Dashboard -> Settings -> Webhooks, add an endpoint pointing to `https://y
> 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. Vercel cron (production only)
### 6. Cron jobs (production only)
`vercel.json` is already configured. In Vercel project settings, add the `CRON_SECRET` environment variable (same value as in `.env.local`). The cron fires daily at 08:00 UTC at `/api/cron/anniversaries`.
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
@@ -263,6 +251,17 @@ 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`.
---
## Key Commands
@@ -270,8 +269,8 @@ Open [http://localhost:3000](http://localhost:3000). The app redirects unauthent
| Command | What it does |
|---|---|
| `npm run dev` | Start Next.js dev server (Turbopack) |
| `npm run build` | Production build |
| `npm run lint` | ESLint |
| `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 |
@@ -293,7 +292,11 @@ Clerk User (owner)
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.
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
@@ -313,7 +316,7 @@ Mux -> transcodes -> fires webhook -> /api/webhooks/mux -> updates VideoPost (RE
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 ngrok required.
> 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
@@ -328,13 +331,30 @@ feed.getFeed({ petId, cursor, mode: "chrono" | "algo" })
└── 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
recency bonus: +10 (<2h), +5 (<6h), +2 (<24h)
└── Fallback (Redis unavailable): Postgres query on follows + own posts
```
### Feed toggle (FeedList)
A daily cron (`/api/cron/trim-feeds`) trims each pet's Redis feed sorted set to a 90-day retention window.
`FeedList` shows a pill toggle at the top: **Für dich** (Sparkles, algo) / **Aktuell** (Clock, chrono). The preference is persisted to `localStorage` under `pf_feed_mode`. The mode is passed to `trpc.feed.getFeed.infiniteQueryOptions({ mode })`.
### 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, and blacklist entries.
### 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 currently runs in `reportOnly` mode; violations land at `/api/csp-report`, get forwarded to GlitchTip, and directives get tightened as real traffic surfaces gaps — before switching to enforced.
### 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)
@@ -342,209 +362,50 @@ feed.getFeed({ petId, cursor, mode: "chrono" | "algo" })
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 UTC)
-> BIRTHDAY notification if EXTRACT(MONTH/DAY FROM birthday) = today AND birthday <= NOW()-1year
-> ADOPTION_DAY notification if EXTRACT(MONTH/DAY FROM adoptedAt) = today AND adoptedAt <= NOW()-1year
/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. Anniversary notifications require 1+ year minimum before the first trigger.
### 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]`
- Token is a UUID stored in the `HealthCard` table
- Expiry is user-selected: 7 / 14 / 30 / 60 / 90 days; checked server-side before rendering
- Shows: vaccines, weight trend chart, vet visits
- Does NOT show: emergency vet (private contact data stays off the shared card)
- PDF export: `?print=1` auto-triggers `window.print()` after 600ms via `AutoPrint` client component
- `print:hidden` Tailwind variant hides nav chrome during print
### EmergencyVet (private, owner-only)
Stored in `EmergencyVet` table (1:1 with Pet). Managed via `EmergencyVetSection.tsx` inside `HealthDashboard`. Features:
- **Nominatim search** (OpenStreetMap geocoding, free, no API key): debounced 500ms, `Accept-Language: de`, dropdown with address suggestions
- **OSM iframe map**: `openstreetmap.org/export/embed.html?bbox=...&marker=lat,lng`
- Manual fields: name (required), address, phone, website
- Clicking a result fills coords + address; manual override always possible
- Attribution: `© OpenStreetMap contributors` shown below map
### Post detail overlay
Opening a post from the profile grid or notifications renders the full `PostCard` inside a **centered Dialog** (`Dialog` + `DialogContent`, `showCloseButton={false}`). The PostCard's own close button (X, top-right) and the 3-dots menu handle dismissal. The previous implementation used a bottom-anchored `Sheet`, which caused shadcn's auto-injected X to overlap the 3-dots button.
### Welcome card (new users)
`WelcomeCard` appears at the top of `FeedList` when a pet has no posts yet and hasn't dismissed it. It lists 5 features (Posts, Stories, Milestones, DMs, Health) with icons. Dismissed state is stored in `localStorage` as `pf_welcome_seen_{petId}` and is per-pet. Both the X button and "Verstanden" button dismiss it.
### Story expiry (lazy-delete)
Stories are never deleted in real time. All queries filter `expiresAt > NOW()`. A weekly cleanup job purges expired rows. Story views are retained 30 days for analytics.
All in-app triggers are wrapped in `.catch(() => {})` — they never crash the parent mutation.
### PawRing (story indicator)
Instead of a standard circular ring (Instagram-style), active stories are indicated by a paw-print SVG outline around the avatar: a circular main pad ring with four toe-bean circles above it.
- **Orange** — pet has unseen stories
- **Gray** — pet has stories but viewer has seen them all
- **No ring** — no active stories
The viewer identity comes from `ActivePetContext` inside `PawRing` itself — callers don't need to pass it.
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.
---
## Development Progress
## What's Shipped
### Phase 1 — Foundation ✅
Foundation → content core → engagement → notifications → video → messaging → health tracking are all live, plus a full production layer built after MVP:
- Next.js 16 + Tailwind v4 + shadcn/ui (base-nova) scaffold
- Clerk auth with middleware, protected app shell, onboarding flow
- Prisma 7 + Supabase PostgreSQL (pooled via PgBouncer)
- tRPC 11 + TanStack Query 5 wired end-to-end
- Upstash Redis client
- Supabase Storage presigned upload pipeline
- Pet CRUD (create, edit, avatar upload)
- ActivePetContext (pet switcher)
- Responsive layout (Sidebar + MobileNav)
- **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
- **Security hardening** — Redis-backed rate limiting, CSP (Report-Only), HSTS/security headers, constant-time secret comparison, self-hosted error monitoring
- **Self-hosted deployment** — Docker on an OpenMediaVault NAS behind Nginx Proxy Manager + Cloudflare
### Phase 2 — Content Core ✅
### Open / next up
- **Social graph:** pet-to-pet follow/unfollow, followers/following pages
- **Safety:** block a pet, report a post or profile
- **Feed:** Redis fan-out-on-write, infinite scroll FeedList, PostCard
- **Photo posts:** 1–10 image carousel (Embla), caption edit/delete, presigned upload
- **Stories:** 24h photo stories, paw-print ring indicator (orange/gray), StoryTray, StoryViewer with progress bar + viewer list
- **Milestones:** 5 structured milestone types (Birthday, Adoption Day, First Outing, Vet Visit, New Litter), distinct orange card, PostTypeSheet fully wired for all 3 post types
- **Profile:** Posts 3-col grid (opens full PostCard in Dialog with original-format image) + Milestones tab
### Phase 3 — Engagement & Discovery ✅
- **Paw reactions:** single reaction per post, toggle on/off, live count on PostCard
- **Comments:** flat thread, create/delete own, CommentSheet drawer
- **Paw-Back (Repost):** repost with attribution header in feed (PostType.REPOST)
- **Explore page:** species tabs + breed filter, random fresh content
- **Search:** pet name/breed search, owner username search (via Clerk), hashtag search with 3-col grid
- **Hashtags:** extracted on post create, clickable `#tags` in captions navigate to hashtag feed
### Phase 4 — Notifications ✅
- `Notification` model with `FOLLOW | REACTION | COMMENT | MESSAGE` types
- Fire-and-forget triggers in follows, reactions, and comments routers
- `NotificationBell` in Sidebar + MobileNav (orange badge, 30s polling, "99+" cap)
- `/notifications` page: list with actor avatar, type icon, relative time, post thumbnail
- Clicking a notification navigates to the actor's profile (FOLLOW) or the relevant pet's profile (REACTION/COMMENT)
- Auto-marks all read on page visit
### Phase 5 — Video ✅
- `VideoPost` model + `VideoStatus` enum (`PROCESSING | READY | ERROR`)
- Mux direct upload: client PUTs directly to Mux, bytes never pass through Next.js server
- `VideoCard` component polls `getByPostId` every 5s while PROCESSING; transitions to `MuxPlayer` when READY
- `getByPostId` self-heals in dev by calling Mux API directly (no webhook tunnel required)
- Mux webhook handler at `/api/webhooks/mux` for production (`video.upload.asset_created`, `video.asset.ready`, `video.asset.errored`)
- Video posts appear in feed, profile grid, and notifications alongside photo posts
### Phase 6 — Messaging ✅
- `Conversation` + `Message` models; `DmPolicy` enum (`EVERYONE | FOLLOWERS_ONLY`) on Pet
- Pet-to-pet DMs: inbox sorted by last message, conversation view with read/unread tracking
- `readAt` timestamp per message; unread count badge in Sidebar + MobileNav
- DM policy enforced server-side: FOLLOWERS_ONLY pets reject messages from non-followers
- Conversation deduplication: `petAId` always < `petBId` (sorted on create) — one row per pair
### Phase 7 — Health & Vet Tracking ✅
- `WeightLog`, `VetVisit`, `Vaccine`, `HealthCard`, `EmergencyVet` models — all owner-private
- Health dashboard at `/pets/[petId]/health` with three tabs: Gewicht / Tierarztbesuche / Impfungen
- Weight trend chart: pure SVG bezier curve, no library, PawFeed orange-500 design, hover tooltips; shown when 2+ entries exist
- `WeightChart` is a standalone reusable client component — used in both HealthDashboard and the public health card
- Overdue vaccine badge (AlertTriangle + destructive variant) when `nextDueDate` is in the past
- **Emergency vet** (private, owner dashboard only): Nominatim search → OSM map; tRPC procedures with `assertOwner`
- **Shareable health card** at `/health-card/[token]`:
- Expiry selectable per share: 7 / 14 / 30 / 60 / 90 days (default 14)
- Expired links show a German-language expiry message with the exact date
- Link can be regenerated (old token invalidated) at any time
- Shows: vaccines, weight trend chart, vet visits — **not** emergency vet (private contact data)
- PDF export: "Als PDF öffnen / drucken" opens the card with `?print=1` which auto-triggers `window.print()` — browser-native, no PDF library
- `print:hidden` Tailwind variant hides nav chrome during print
### v2 Features — Feed & Engagement ✅
- **Algorithmic feed toggle** ("Für dich" / "Aktuell") in `FeedList`
- `feed.getFeed` accepts `mode: "chrono" | "algo"` (default `"chrono"`)
- Algo scoring: `reactions×3 + comments×5 + originalReposts×4 + recency bonus`
- Preference persisted in `localStorage` (`pf_feed_mode`)
- **Anniversary notifications** via Vercel cron (`/api/cron/anniversaries`, daily 08:00 UTC)
- `BIRTHDAY` + `ADOPTION_DAY` notification types added to `NotificationType` enum
- Pet fields `birthday` and `adoptedAt` (`@db.Date`) added to schema + pet edit form
- Raw SQL with `EXTRACT(MONTH/DAY FROM ...)` to match today's day/month across all years
- 1-year minimum guard on both types — no notification in the first year
- Bearer token auth (`Authorization: Bearer CRON_SECRET`) for the route
- Notification page: Cake icon (pink-500) for birthdays, Home icon (emerald-500) for adoption days; link navigates to `/pets/{id}/health`
- **Welcome card for new users** (`WelcomeCard`)
- Appears at top of feed when pet has 0 posts and hasn't dismissed it
- Lists 5 features with icons: Camera (Posts), Flame (Stories), Heart (Milestones), MessageCircle (DMs), Activity (Health)
- Orange gradient background (`from-orange-50/60 to-background`)
- Dismissed per-pet via `localStorage` (`pf_welcome_seen_{petId}`)
- X button + "Verstanden" button both dismiss
- **Post detail overlay fix**
- Changed from `Sheet side="bottom"` to centered `Dialog` with `showCloseButton={false}`
- Prevents shadcn's auto-injected close button from overlapping PostCard's 3-dots menu
- `max-w-[560px] max-h-[90dvh] overflow-y-auto rounded-xl p-0`
---
## What's Next (continue here tomorrow)
These are the natural next features. None are started — all are clean slates.
### High priority
| Feature | Notes |
| Item | Notes |
|---|---|
| **Explore page improvements** | Currently shows random content. Could show trending posts (by reaction count in last 24h), top hashtags, featured pets. Add "Trending" tab alongside species tabs. |
| **Story video support** | Stories currently only support photos. Add short video stories (Mux or direct upload). StoryViewer already has a progress timer — just needs video playback instead of `<img>`. |
| **Follower milestone auto-notifications** | `MilestoneType` has `FOLLOWERS_500, FOLLOWERS_1K` etc. but no trigger exists yet. Should fire a milestone post automatically when follower count crosses these thresholds. Hook into `follows.follow`. |
| **Google / Apple OAuth** | Clerk supports it natively — just enable in Clerk Dashboard. No code change required. User deliberately deferred this. |
### Medium priority
| Feature | Notes |
|---|---|
| **Pet bio / adoption story on profile** | Fields exist in schema (`bio`, `adoptionStory`) but are not displayed on the profile page. Add them to the profile header. |
| **Story reactions** | Stories currently only track views. Add a quick emoji reaction (🐾❤️😂) that appears as a floating animation. |
| **Saved posts** | Bookmarking posts to a private collection. New `SavedPost` table, saved indicator on PostCard, saved-posts tab on profile. |
| **Comment replies** | Flat comments work. Add 1-level reply threading (replyToId FK on Comment). |
| **Admin dashboard** | Basic moderation view: list of reports, pet account overview, manual suspension. Could be a `/admin` route behind a `role` flag on Owner. |
### Low priority / post-MVP
| Feature | Notes |
|---|---|
| **i18n (multi-language)** | EN/DE/ES/IT/FR planned. Species and breed names need translation tables — don't duplicate rows per language. Use a locale + key structure. |
| **React Native app** | Listed in CLAUDE.md as a future milestone. Web-first for v1. |
| **Supabase Realtime** | Feed live updates and DM delivery without polling. Replace `setInterval` polling in NotificationBell and conversation view with Supabase channel subscriptions on the relevant tables. |
| **Cloudflare R2 + Images** | Currently using Supabase Storage. R2 has zero egress fees — migration makes sense at scale. |
### Open bugs / cleanup
| Issue | Notes |
|---|---|
| Feed Redis cleanup on post delete | When a post is deleted, its ID is NOT removed from Redis sorted sets. `feed.getFeed` silently skips missing IDs (Prisma returns nothing for them) — functionally correct but wastes Redis space. Add cleanup on `posts.delete`. |
| Story views cleanup | `StoryView` records are never deleted. A weekly cron should purge rows where `viewedAt < NOW() - 30 days`. |
| People search with no username | `search.byOwner` via Clerk falls back to first/last name when no Clerk username is set. Works but inconsistent UX. |
| Welcome card logic | Currently checks "pet has 0 posts" but the posts count comes from the feed data, not a dedicated query. Refactor to a `posts.countByPet` call in the health router or a dedicated RSC check. |
| CSP enforced mode | Waiting on a clean Report-Only period in GlitchTip before flipping `reportOnly: false` |
| 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 and Redis are mocked per file using `createMockPrisma()` from `src/__tests__/helpers/prisma-mock.ts`.
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
@@ -557,13 +418,11 @@ npx vitest # watch mode
| 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` / migrations. |
| Prisma on Windows requires `DIRECT_URL` set in `.env.local` | The Prisma CLI reads `.env.local` via a custom `prisma.config.ts`. If `npx prisma db push` hangs, check `DIRECT_URL` is set. |
| After a Prisma schema change, **always restart the dev server** | The Next.js dev server caches the Prisma client in memory. A running server started before `prisma generate` will not see new models. |
| Supabase service_role key is never client-facing | All files that import `supabase-storage.ts` must be `server-only`. The client only ever receives signed URLs. |
| `asChild` prop is not available in the base-nova shadcn preset | Do not use `asChild` on any shadcn component. This affects `PopoverTrigger`, `DropdownMenuItem`, etc. Style the trigger element directly. |
| StoryView records are NOT cascade-deleted when a story expires | Intentional: view analytics are retained 30 days. A separate cleanup cron should purge old views. |
| Feed cleanup on post delete is best-effort | When a post is deleted, its ID is not removed from Redis sorted sets. `feed.getFeed` silently skips IDs that Prisma doesn't return. |
| Mux webhook not required in local dev | `videos.getByPostId` polls Mux directly when status is `PROCESSING`. The DB is updated on the next poll cycle. In production, the webhook updates the DB faster (within seconds of transcode completion). |
| People search requires Clerk username to be set | `search.byOwner` queries Clerk's `getUserList`. Users who signed up without a username will match by first/last name instead. |
| `CRON_SECRET` must be set in Vercel env vars | The anniversary cron route checks `Authorization: Bearer CRON_SECRET`. Without it, the route returns 401 and no notifications fire. |
| `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. |