30 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 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.
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, 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)
api/
trpc/ # tRPC HTTP handler
cron/
anniversaries/ # Vercel cron: fires birthday + adoption-day notifications daily at 08:00
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)
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 (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)
prisma/
schema.prisma # Full DB schema — see Schema section below
seed.ts # Species + breed seed (Dog/Cat/Bird)
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)
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
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 |
CRON_SECRET |
Generate a random string; paste same value in Vercel env vars |
SUPABASE_SERVICE_ROLE_KEYandMUX_TOKEN_SECRETare server-only secrets — 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. Vercel cron (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.
7. 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, mode: "chrono" | "algo" })
└── ZRANGE feed:{petId} -> postIds
└── prisma.post.findMany({ where: { id: { in: postIds } } }) -> hydrated posts
└── 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)
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 }).
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)
/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
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]/healthredirects 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
HealthCardtable - 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=1auto-triggerswindow.print()after 600ms viaAutoPrintclient component print:hiddenTailwind variant hides nav chrome during print
- Token is a UUID stored in the
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 contributorsshown 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.
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 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
#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,HealthCard,EmergencyVetmodels — all owner-private- Health dashboard at
/pets/[petId]/healthwith 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
WeightChartis a standalone reusable client component — used in both HealthDashboard and the public health card- Overdue vaccine badge (AlertTriangle + destructive variant) when
nextDueDateis 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=1which auto-triggerswindow.print()— browser-native, no PDF library print:hiddenTailwind variant hides nav chrome during print
v2 Features — Feed & Engagement ✅
-
Algorithmic feed toggle ("Für dich" / "Aktuell") in
FeedListfeed.getFeedacceptsmode: "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_DAYnotification types added toNotificationTypeenum- Pet fields
birthdayandadoptedAt(@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 centeredDialogwithshowCloseButton={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
- Changed from
What's Next (continue here tomorrow)
These are the natural next features. None are started — all are clean slates.
High priority
| Feature | 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. |
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 | 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. |