314 lines
12 KiB
Markdown
314 lines
12 KiB
Markdown
# 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.getFeed` reads 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
|
||
|
||
- Node.js 20+
|
||
- A [Supabase](https://supabase.com) project (PostgreSQL + Storage)
|
||
- A [Clerk](https://clerk.com) application
|
||
- An [Upstash](https://upstash.com) Redis database
|
||
|
||
### 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:
|
||
|
||
```bash
|
||
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_KEY` is a server-only secret. It is imported exclusively in `server-only` modules and never exposed to the client.
|
||
|
||
### 3. Database setup
|
||
|
||
Push the Prisma schema to Supabase:
|
||
|
||
```bash
|
||
npx prisma db push
|
||
```
|
||
|
||
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 public bucket named `pet-avatars` in Supabase Storage (Dashboard → Storage → New bucket).
|
||
|
||
### 5. Run the dev server
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Open [http://localhost:3000](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 (planned)
|
||
|
||
Likes, comments, reactions, notifications, explore/search.
|
||
|
||
### Phase 4 — Growth (planned)
|
||
|
||
Follower-milestone auto-trigger, push notifications, pet discovery.
|
||
|
||
### Phase 5 — Video (planned)
|
||
|
||
Short-form video stories and posts via Mux (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.
|
||
|
||
---
|
||
|
||
## 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`.
|
||
|
||
```bash
|
||
npx vitest run # full suite (~3-4s)
|
||
npx vitest # watch mode
|
||
```
|
||
|
||
> `src/__tests__/media-upload.test.ts` currently fails due to a missing Supabase URL in the test environment. This is a pre-existing issue from Phase 1 and does not affect any Phase 2 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. |
|