- ShareDialog: expiry selector (7/14/30/60/90 days, default 14), shows expiry date after generation, "Als PDF öffnen / drucken" button - WeightChart extracted to standalone component (WeightChart.tsx) so it can be shared between HealthDashboard and the public health card page - Health card page: renders WeightChart when 2+ weight entries exist - AutoPrint client component: auto-triggers window.print() when card is opened with ?print=1 (600ms delay for render to settle) - README: Phase 6 + Phase 7 marked complete with full feature summaries; project structure and router list updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
20 KiB
PawFeed
A social platform where pets are the stars. Dogs, cats, and birds get their own profiles — owners manage them, but the pet is the identity. 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.
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.
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 | 7.x |
| Media Storage | Supabase Storage (S3-compatible) | — |
| Cache / Feed | Upstash Redis (serverless) | — |
| Client Fetching | TanStack Query | 5.x |
| Video | Mux (upload, transcoding, HLS streaming) | — |
| Tests | Vitest | 4.x |
| Hosting | Vercel (target) | — |
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.getFeedreads 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. ThegetByPostIdquery self-heals in dev by polling the Mux API directly, so webhooks are not required during local development. - Health data is owner-private. The only publicly accessible health surface is the shareable health card (token URL with expiry), designed for vets, friends, and pet sitters.
Project Structure
src/
app/
(app)/ # Protected app shell (Clerk middleware)
feed/ # Home feed page + StoryTray
notifications/ # Notification center page
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)
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)
api/
trpc/ # tRPC HTTP handler
webhooks/
mux/ # Mux webhook handler (video.asset.ready etc.)
components/
feed/ # PostCard, MilestoneCard, RepostCard, FeedList, SkeletonCard
# PawButton (paw reaction), PawBackButton (repost), CommentSheet
health/ # HealthDashboard (tabs), WeightChart (SVG bezier), PrintButton, AutoPrint
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)
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)
context/
ActivePetContext.tsx # Global active pet state (pet switcher)
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
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 + getProfile (no ownership check)
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 via Redis + Prisma hydration)
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, 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)
prisma/
schema.prisma # Full DB schema (Owner, Pet, Post, Story, Follow, Block, Report,
# Reaction, Comment, Repost, Hashtag, Notification, VideoPost,
# Conversation, Message, WeightLog, VetVisit, Vaccine, HealthCard)
seed.ts # Species + breed seed (Dog/Cat/Bird)
supabase/
migrations/
20260613000000_enable_rls_all_tables.sql
.planning/ # GSD planning artefacts (phases, plans, summaries)
Setup
Prerequisites
- Node.js 20+
- A Supabase project (PostgreSQL + Storage)
- A Clerk application
- An Upstash Redis database
- A Mux account (for video posts)
1. Clone and install
git clone <repo-url>
cd pawfeed
npm install
2. Environment variables
Copy .env.example to .env.local and fill in all values:
cp .env.example .env.local
| 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 |
MUX_WEBHOOK_SECRET |
Mux Dashboard -> Settings -> Webhooks -> Signing secret |
SUPABASE_SERVICE_ROLE_KEYandMUX_TOKEN_SECRETare server-only secrets. They are imported exclusively inserver-onlymodules and never exposed to the client.
3. Database setup
Push the Prisma schema to Supabase:
npx prisma db push
npx prisma generate
Seed species and breeds:
npx prisma db seed
Enable Row Level Security on all tables (run once in Supabase SQL Editor):
-- 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 public bucket named pet-avatars in Supabase Storage (Dashboard -> Storage -> New bucket).
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_createdvideo.asset.readyvideo.asset.errored
In local development, the webhook is not needed.
videos.getByPostIdpolls the Mux API directly when the video is stillPROCESSING, so the status self-heals without a tunnel.
6. Run the dev server
npm run dev
Open http://localhost:3000. The app redirects unauthenticated users to Clerk sign-in.
Key Commands
| Command | What it does |
|---|---|
npm run dev |
Start Next.js dev server (Turbopack) |
npm run build |
Production build |
npm run lint |
ESLint |
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.
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):
getByPostIdchecks the Mux API directly when the status isPROCESSINGand updates the DB on the fly. No ngrok 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 })
└── ZRANGE feed:{petId} -> postIds
└── prisma.post.findMany({ where: { id: { in: postIds } } }) -> hydrated posts
└── Fallback (Redis unavailable): Postgres query on follows + own posts
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)
All triggers are wrapped in .catch(() => {}) — they never crash the parent mutation.
Health data privacy
All health data (weight logs, vet visits, vaccines) is owner-private. The only publicly accessible health surface is the HealthCard: a token URL with a user-selected expiry (7 / 14 / 30 / 60 / 90 days). The card page checks expiry server-side before rendering. Opening the card with ?print=1 auto-triggers window.print() for browser-native PDF export — no PDF library required.
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.
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.
Development Progress
Phase 1 — Foundation ✅
- 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)
Phase 2 — Content Core ✅
- 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 sheet 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
#tagsin captions navigate to hashtag feed
Phase 4 — Notifications ✅
Notificationmodel withFOLLOW | REACTION | COMMENT | MESSAGEtypes- Fire-and-forget triggers in follows, reactions, and comments routers
NotificationBellin Sidebar + MobileNav (orange badge, 30s polling, "99+" cap)/notificationspage: 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 ✅
VideoPostmodel +VideoStatusenum (PROCESSING | READY | ERROR)- Mux direct upload: client PUTs directly to Mux, bytes never pass through Next.js server
VideoCardcomponent pollsgetByPostIdevery 5s while PROCESSING; transitions toMuxPlayerwhen READYgetByPostIdself-heals in dev by calling Mux API directly (no webhook tunnel required)- Mux webhook handler at
/api/webhooks/muxfor 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+Messagemodels;DmPolicyenum (EVERYONE | FOLLOWERS_ONLY) on Pet- Pet-to-pet DMs: inbox sorted by last message, conversation view with read/unread tracking
readAttimestamp per message; unread count badge in Sidebar + MobileNav- DM policy enforced server-side: FOLLOWERS_ONLY pets reject messages from non-followers
- Conversation deduplication:
petAIdalways <petBId(sorted on create) — one row per pair
Phase 7 — Health & Vet Tracking ✅
WeightLog,VetVisit,Vaccine,HealthCardmodels — all owner-private- Health dashboard at
/pets/[petId]/healthwith three tabs: Weight / Tierarztbesuche / Impfungen - Weight trend chart: pure SVG bezier curve, no library, PawFeed orange-500 design, hover tooltips; shown when 2+ entries exist
WeightChartis an extracted, reusable client component (src/components/health/WeightChart.tsx)- Overdue vaccine badge (AlertTriangle + destructive variant) when
nextDueDateis in the past - Shareable health card at
/health-card/[token]— no login required- 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
- Weight trend chart included in the card view
- PDF export: "Als PDF öffnen / drucken" opens the card with
?print=1which auto-triggerswindow.print()— browser-native, no PDF library print:hiddenTailwind variant hides nav chrome during print
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.
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 / 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 | This is intentional (D-08): view analytics are retained 30 days. A separate cleanup job will 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. |