570 lines
30 KiB
Markdown
570 lines
30 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 |
|
||
| 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 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](https://supabase.com) project (PostgreSQL + Storage)
|
||
- A [Clerk](https://clerk.com) application
|
||
- An [Upstash](https://upstash.com) Redis database
|
||
- A [Mux](https://mux.com) account (for video posts)
|
||
|
||
### 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 |
|
||
| `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_KEY` and `MUX_TOKEN_SECRET` are server-only secrets — never exposed to the client.
|
||
|
||
### 3. Database setup
|
||
|
||
Push the Prisma schema to Supabase:
|
||
|
||
```bash
|
||
npx prisma db push
|
||
npx prisma generate
|
||
```
|
||
|
||
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. 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. 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
|
||
|
||
```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 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, 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]/health` redirects 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 `HealthCard` table
|
||
- 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=1` auto-triggers `window.print()` after 600ms via `AutoPrint` client component
|
||
- `print:hidden` Tailwind variant hides nav chrome during print
|
||
|
||
### 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 contributors` shown 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 `#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`, `EmergencyVet` models — all owner-private
|
||
- Health dashboard at `/pets/[petId]/health` with 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
|
||
- `WeightChart` is a standalone reusable client component — used in both HealthDashboard and the public health card
|
||
- Overdue vaccine badge (AlertTriangle + destructive variant) when `nextDueDate` is 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=1` which auto-triggers `window.print()` — browser-native, no PDF library
|
||
- `print:hidden` Tailwind variant hides nav chrome during print
|
||
|
||
### v2 Features — Feed & Engagement ✅
|
||
|
||
- **Algorithmic feed toggle** ("Für dich" / "Aktuell") in `FeedList`
|
||
- `feed.getFeed` accepts `mode: "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_DAY` notification types added to `NotificationType` enum
|
||
- Pet fields `birthday` and `adoptedAt` (`@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 centered `Dialog` with `showCloseButton={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`
|
||
|
||
---
|
||
|
||
## 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`.
|
||
|
||
```bash
|
||
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. |
|