Schema:
- Add NotificationType enum (FOLLOW, REACTION, COMMENT, MESSAGE)
- Add Notification model with recipientPet, actorPet, post, comment
relations; index on [recipientPetId, read, createdAt desc]
Backend:
- notifications router: list (cursor-paginated), unreadCount,
markRead, markAllRead
- follows.create: fire-and-forget FOLLOW notification to followee
- reactions.toggle: REACTION notification on create (skip self)
- comments.create: COMMENT notification with commentId (skip self)
All triggers use .catch(()=>{}) to never fail the main action
UI:
- NotificationBell component with orange badge, polls every 30s
- /notifications page: list with avatar, type icon, text, thumbnail,
read/unread dot; auto-marks all read on visit
- Sidebar: Notifications link activated with NotificationBell
- MobileNav: Bell replaces Search tab (Search still at /search)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 |
| 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. - Authentication is handled by Clerk (owner accounts). Pet profiles are app-domain entities in Postgres, not Clerk users.
Project Structure
src/
app/
(app)/ # Protected app shell (Clerk middleware)
feed/ # Home feed page + StoryTray
pets/[petId]/ # Pet profile page + ProfileTabs
pets/[petId]/edit/
pets/[petId]/followers/
pets/[petId]/following/
onboarding/ # Pet creation onboarding
(auth)/ # Clerk-rendered sign-in / sign-up
components/
feed/ # PostCard, MilestoneCard, FeedList, SkeletonCard
layout/ # Sidebar, MobileNav
pet/ # AvatarUpload, PetForm, PetListItem
post-creation/ # PostTypeSheet, PhotoPostForm, StoryForm, MilestoneForm, MultiImageUpload
profile/ # ProfileTabs (Posts grid + Milestones list)
safety/ # BlockDialog, ReportSheet
stories/ # PawRing, StoryViewer, StoryTray, StoryForm
ui/ # shadcn component copies (button, dialog, sheet, etc.)
context/
ActivePetContext.tsx # Global active pet state (pet switcher)
hooks/
useActivePet.ts
lib/
auth.ts # Clerk server helper
avatar-url.ts # CDN URL construction for avatars
feed-helpers.ts # fanOutPost, backfillOnFollow (Redis)
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
prisma.ts # Prisma singleton with PgBouncer adapter
r2.ts # AWS SDK S3 client for Cloudflare R2 (Phase 5+)
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
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
blocks.ts # Block a pet
reports.ts # Report a post or pet
media.ts # Avatar presigned upload (getPresignedUrl, confirmUpload)
__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
proxy.ts # Next.js middleware (Clerk auth guard)
prisma/
schema.prisma # Full DB schema
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
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 |
SUPABASE_SERVICE_ROLE_KEYis a server-only secret. It is imported exclusively inserver-onlymodules and never exposed to the client.
3. Database setup
Push the Prisma schema to Supabase:
npx prisma db push
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. 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 studio |
Open Prisma Studio (DB browser) |
npx prisma db seed |
Re-seed species + breeds |
npx prisma generate |
Regenerate Prisma client after schema change |
Architecture Notes
Identity model
Clerk User (owner)
└── owns 1..n Pet profiles (Postgres)
└── Pet is the social identity (posts, follows, stories, milestones)
An owner can switch between their pets via ActivePetContext. All tRPC mutations assert pet.ownerId === ctx.userId before writing.
Media pipeline
Client → tRPC getPresignedUrl → Supabase Storage signed URL
Client → XHR PUT directly to signed URL (file bytes never hit Next.js server)
Client → tRPC create/confirmUpload (writes storage key to DB)
Feed → getMediaUrl(storageKey) → Supabase public CDN URL
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
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, all in orange-500.
Development Progress
Phase 1 — Foundation (complete)
- 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 (complete)
- 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, 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 + Milestones tab
Phase 3 — Engagement and Discovery (in progress — plan 1/6 done)
Schema foundation complete (2026-06-13):
- DB schema:
Reaction,Comment,Repost,Hashtag,PostHashtagmodels;PostType.REPOSTenum - tRPC stubs:
reactions,comments,reposts,explore,searchrouters registered - Test scaffolds: 5 files, 14 failing RED tests covering ENGM-01–04, EXPL-01, SRCH-01–02
Remaining plans:
- 03-02: Paw reactions (single 🐾 per post, toggle on/off, count on PostCard)
- 03-03: Flat comments (create/delete own, CommentSheet drawer)
- 03-04: Paw-Back / repost with attribution header (PostType.REPOST in feed)
- 03-05: Explore page (species tabs + breed filter) + pet name/breed search
- 03-06: Hashtag extraction on post create, hashtag search, clickable
#tagsin captions
Phase 4 — Notifications (planned)
In-app notification center: follows, paw reactions, comments, DMs.
Phase 5 — Video (planned)
Short-form video posts via Mux (signed upload → HLS adaptive streaming).
Phase 6 — Messaging (planned)
Direct messages between pet owners via Supabase Realtime.
Phase 7 — Health (planned)
Vet records, vaccination logs, weight tracking, shareable health card.
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 (~4s)
npx vitest # watch mode
Current state: 53 passing (Phase 1+2), 14 failing RED (Phase 3 — NOT_IMPLEMENTED stubs, intentional), 1 pre-existing failure.
src/__tests__/media-upload.test.tscurrently fails due to a missing Supabase URL in the test environment. This is a pre-existing issue from Phase 1 and does not affect Phase 2 or Phase 3 tests.
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. |
| 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 in Phase 2 | When a post is deleted, its ID is not removed from Redis sorted sets. feed.getFeed silently skips IDs that Prisma doesn't return. Full cleanup (ZREM) is scheduled for Phase 4. |