Files
petfeed/README.md
T
adminandClaude Sonnet 4.6 80559c3b3f feat(health): health card share link, PDF export, weight chart in card
- 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>
2026-06-15 06:06:28 +02:00

20 KiB
Raw Blame History

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.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 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_KEY and MUX_TOKEN_SECRET are server-only secrets. They are imported exclusively in server-only modules 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_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. 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): getByPostId checks the Mux API directly when the status is PROCESSING and 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 #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 models — all owner-private
  • Health dashboard at /pets/[petId]/health with 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
  • WeightChart is an extracted, reusable client component (src/components/health/WeightChart.tsx)
  • Overdue vaccine badge (AlertTriangle + destructive variant) when nextDueDate is 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=1 which auto-triggers window.print() — browser-native, no PDF library
    • print:hidden Tailwind 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.