diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 9091028..b815c45 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -96,7 +96,20 @@ Decimal phases appear between their surrounding integers in numeric order.
4. Owner can browse and filter pets by breed and species on an Explore page backed by the breed taxonomy
5. Owner can search posts by hashtag and search for pets by name or breed
-**Plans**: TBD
+**Plans**: 6 plans
+
+**Wave 1** *(schema foundation, no dependencies)*
+- [ ] 03-01-PLAN.md — Schema (Reaction, Comment, Repost, Hashtag, PostHashtag, PostType.REPOST), 5 router stubs, 5 test scaffolds, [BLOCKING] db push
+- [ ] 03-05-PLAN.md — Explore page (EXPL-01) + pet search (SRCH-02): explore.listPets, search.pets, /explore page, /search page, nav additions *(can run parallel with Wave 2)*
+
+**Wave 2** *(blocked on 03-01)*
+- [ ] 03-02-PLAN.md — Paw reactions (ENGM-01): reactions router, PawButton on PostCard
+- [ ] 03-03-PLAN.md — Comments (ENGM-02, ENGM-03): comments router, CommentSheet
+- [ ] 03-04-PLAN.md — Paw-Back/Repost (ENGM-04): reposts router, RepostCard, Paw-Back button
+
+**Wave 3** *(blocked on 03-05)*
+- [ ] 03-06-PLAN.md — Hashtag search (SRCH-01): hashtag extraction on post.create, search.byHashtag, /hashtag/[tag] page, clickable captions
+
**UI hint**: yes
### Phase 4: Notifications
@@ -167,9 +180,9 @@ Phases execute in numeric order. Phase 5 depends only on Phase 2 (not Phase 4) s
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
-| 1. Foundation | 2/4 | In progress | - |
-| 2. Content Core | 0/6 | Planned | - |
-| 3. Engagement and Discovery | 0/TBD | Not started | - |
+| 1. Foundation | 4/4 | Complete | 2026-06-06 |
+| 2. Content Core | 6/6 | Complete | 2026-06-13 |
+| 3. Engagement and Discovery | 0/6 | Executing | - |
| 4. Notifications | 0/TBD | Not started | - |
| 5. Video | 0/TBD | Not started | - |
| 6. Direct Messages | 0/TBD | Not started | - |
diff --git a/.planning/STATE.md b/.planning/STATE.md
index aef5889..9d3277b 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -2,16 +2,16 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
-status: verifying
-stopped_at: Phase 2 context gathered
-last_updated: "2026-06-07T10:13:21.244Z"
-last_activity: 2026-06-06 -- Phase 1 fully verified; media storage switched from R2 to Supabase Storage; avatar CDN display confirmed working
+status: executing
+stopped_at: Phase 3 plan 01 starting
+last_updated: "2026-06-13T00:00:00.000Z"
+last_activity: 2026-06-13 -- Phase 2 complete (6/6); Phase 3 planning done; ready to execute 03-01
progress:
total_phases: 7
- completed_phases: 1
- total_plans: 4
- completed_plans: 5
- percent: 14
+ completed_phases: 2
+ total_plans: 16
+ completed_plans: 10
+ percent: 29
---
# Project State
@@ -21,16 +21,30 @@ progress:
See: .planning/PROJECT.md (updated 2026-06-05)
**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.
-**Current focus:** Phase 2 — Content Core (next)
+**Current focus:** Phase 3 — Engagement and Discovery
## Current Position
-Phase: 1 (Foundation) — COMPLETE
-Plan: 4 of 4 complete
-Status: All Phase 1 success criteria met; walking skeleton verified end-to-end
-Last activity: 2026-06-06 -- Phase 1 fully verified; media storage switched from R2 to Supabase Storage; avatar CDN display confirmed working
+Phase: 3 (Engagement and Discovery) — READY TO EXECUTE
+Plan: 1 of 6 — 03-01 (Schema Foundation + Stubs)
+Status: Planning complete; execution starting
+Last activity: 2026-06-13 -- Phase 3 planning artifacts created (CONTEXT, RESEARCH, UI-SPEC, VALIDATION, 6 PLANs)
-Progress: [██░░░░░░░░] 7%
+Progress: [███░░░░░░░] 29%
+
+## Phase 2 — Completed 2026-06-13
+
+All 6 plans complete:
+- 02-01: Schema + stubs (Prisma, Redis, router stubs, test scaffolds)
+- 02-02: Social graph (follow/unfollow, block, report)
+- 02-03: Redis fan-out feed, /feed page, nav
+- 02-04: Photo posts (carousel, PostCard, creation flow)
+- 02-05: Stories (24h, PawRing SVG, StoryTray, StoryViewer)
+- 02-06: Milestone posts (MilestoneCard, ProfileTabs)
+
+Test suite: 50 passing, 1 pre-existing failure (media-upload.test.ts — missing Supabase URL in test env).
+TypeScript: clean (npx tsc --noEmit exits 0).
+Gitea backup: pushed (master, commit 273d412).
## Performance Metrics
diff --git a/.planning/phases/03-engagement-discovery/03-01-PLAN.md b/.planning/phases/03-engagement-discovery/03-01-PLAN.md
new file mode 100644
index 0000000..a9f273a
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-01-PLAN.md
@@ -0,0 +1,186 @@
+---
+phase: 03-engagement-discovery
+plan: 01
+type: execute
+wave: 0
+depends_on: []
+files_modified:
+ - prisma/schema.prisma
+ - src/trpc/routers/reactions.ts
+ - src/trpc/routers/comments.ts
+ - src/trpc/routers/reposts.ts
+ - src/trpc/routers/explore.ts
+ - src/trpc/routers/search.ts
+ - src/trpc/routers/_app.ts
+ - src/__tests__/reactions.test.ts
+ - src/__tests__/comments.test.ts
+ - src/__tests__/reposts.test.ts
+ - src/__tests__/explore.test.ts
+ - src/__tests__/search.test.ts
+ - src/__tests__/helpers/prisma-mock.ts
+autonomous: false
+requirements: [ENGM-01, ENGM-02, ENGM-03, ENGM-04, EXPL-01, SRCH-01, SRCH-02]
+must_haves:
+ truths:
+ - "5 new Prisma models exist in the DB (Reaction, Comment, Repost, Hashtag, PostHashtag)"
+ - "PostType enum includes REPOST"
+ - "5 new tRPC routers registered in _app.ts and type-check"
+ - "5 test scaffold files exist with failing tests for every Phase 3 requirement"
+ artifacts:
+ - path: "prisma/schema.prisma"
+ provides: "Reaction, Comment, Repost, Hashtag, PostHashtag + PostType.REPOST"
+ contains: "model Reaction"
+ - path: "src/trpc/routers/_app.ts"
+ provides: "All 5 new routers registered"
+ contains: "reactions:"
+ - path: "src/__tests__/reactions.test.ts"
+ provides: "ENGM-01 test scaffold"
+ min_lines: 10
+---
+
+
+Establish the complete data and contract foundation for Phase 3: 5 new Prisma models + PostType.REPOST enum addition, 5 new tRPC router stubs registered and type-checking, extended Prisma mock helper, and 5 failing test scaffolds covering all Phase 3 requirements.
+
+This is the interface-first wave — all downstream plans (03-02 through 03-06) build against the contracts defined here. [BLOCKING] db push must succeed before any other plan can run.
+
+
+
+@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
+@T:/CC-Projekte/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/phases/03-engagement-discovery/03-RESEARCH.md
+@.planning/phases/03-engagement-discovery/03-VALIDATION.md
+@prisma/schema.prisma
+@src/trpc/routers/_app.ts
+@src/trpc/routers/pets.ts
+@src/trpc/init.ts
+@src/__tests__/helpers/prisma-mock.ts
+
+
+
+
+
+ Task 1: Prisma schema — 5 new models, PostType.REPOST, back-relations, [BLOCKING] db push
+ prisma/schema.prisma
+
+ - prisma/schema.prisma (current Phase 2 schema — understand existing Post, Pet, PostType)
+ - .planning/phases/03-engagement-discovery/03-RESEARCH.md (Pattern 1 — exact model definitions)
+
+
+ 1. Add REPOST to the existing PostType enum.
+ 2. Append 5 new models exactly per RESEARCH.md Pattern 1: Reaction, Comment, Repost, Hashtag, PostHashtag.
+ 3. Add back-relations to Post: reactions @relation("PostReactions"), comments @relation("PostComments"), repost @relation("RepostEntry"), originalReposts @relation("OriginalReposts"), hashtags @relation("PostHashtags").
+ 4. Add back-relations to Pet: reactions @relation("PetReactions"), comments @relation("PetComments"), reposts @relation("PetReposts").
+ 5. Run `npx prisma validate` then `npx prisma generate`.
+ 6. [BLOCKING] Run `npx prisma db push` — must succeed before any other task.
+
+
+ npx prisma validate && npx prisma generate
+
+
+ - `npx prisma validate` exits 0 with no ambiguous-relation errors
+ - `npx prisma db push` reports 5 new tables created
+ - PostType enum contains PHOTO, MILESTONE, REPOST
+ - Repost model has both @unique on postId AND @@id([reposterPetId, originalPostId])
+
+ 5 models in DB; Prisma client regenerated; schema validates.
+
+
+
+ Task 2: 5 tRPC router stubs + register in _app.ts
+ src/trpc/routers/reactions.ts, src/trpc/routers/comments.ts, src/trpc/routers/reposts.ts, src/trpc/routers/explore.ts, src/trpc/routers/search.ts, src/trpc/routers/_app.ts, src/trpc/init.ts
+
+ - src/trpc/routers/pets.ts (assertPetOwnership pattern + protectedProcedure usage)
+ - src/trpc/init.ts (check if publicProcedure exists — needed for explore)
+ - src/trpc/routers/_app.ts (current router registrations)
+ - .planning/phases/03-engagement-discovery/03-CONTEXT.md (D-10: explore is public)
+
+
+ Check src/trpc/init.ts for publicProcedure. If absent, add it (follows same pattern as protectedProcedure but without auth check).
+
+ Create 5 router stubs, each with `import "server-only"`:
+ - reactions.ts: procedures toggle, hasReacted, getCount — each stubbed as NOT_IMPLEMENTED placeholder
+ - comments.ts: procedures create, listByPost, delete — stubbed
+ - reposts.ts: procedures create, delete, isReposted — stubbed
+ - explore.ts: listPets — stubbed (uses publicProcedure not protectedProcedure)
+ - search.ts: procedures pets, byHashtag — stubbed
+
+ Register all 5 in _app.ts alongside existing routers.
+
+
+ npx tsc --noEmit
+
+
+ - `npx tsc --noEmit` exits 0
+ - _app.ts registers reactions, comments, reposts, explore, search alongside existing routers
+ - publicProcedure exists in src/trpc/init.ts
+
+ 5 router stubs registered and type-checking.
+
+
+
+ Task 3: Prisma mock extension + 5 failing test scaffolds
+ src/__tests__/helpers/prisma-mock.ts, src/__tests__/reactions.test.ts, src/__tests__/comments.test.ts, src/__tests__/reposts.test.ts, src/__tests__/explore.test.ts, src/__tests__/search.test.ts
+
+ - src/__tests__/helpers/prisma-mock.ts (current mock factory — extend with new models)
+ - src/__tests__/posts.test.ts (test style reference)
+ - .planning/phases/03-engagement-discovery/03-VALIDATION.md (Requirements → Test Map)
+
+
+ Extend prisma-mock.ts with mock entries for: reaction (findUnique, create, delete), comment (create, findMany, findUnique, delete), repost (findUnique, create), hashtag (findUnique, upsert), postHashtag (upsert).
+
+ Create 5 test files with failing `it` stubs per the validation test map:
+ - reactions.test.ts: "toggle creates reaction" (ENGM-01), "toggle removes if exists" (ENGM-01), "FORBIDDEN non-owner" (ENGM-01)
+ - comments.test.ts: "create comment returns body" (ENGM-02), "delete own comment ok" (ENGM-03), "delete other comment FORBIDDEN" (ENGM-03)
+ - reposts.test.ts: "create repost creates REPOST post" (ENGM-04), "repost-of-repost FORBIDDEN" (ENGM-04), "idempotent second repost" (ENGM-04)
+ - explore.test.ts: "listPets filters by speciesId" (EXPL-01), "cursor pagination" (EXPL-01)
+ - search.test.ts: "byHashtag returns posts" (SRCH-01), "unknown tag returns empty" (SRCH-01), "pets matches name" (SRCH-02), "pets matches breed" (SRCH-02)
+
+ Use `it.todo(...)` or failing stubs (NOT_IMPLEMENTED throws) — suite must be runnable with no import errors.
+
+
+ npx vitest run src/__tests__/reactions.test.ts src/__tests__/comments.test.ts src/__tests__/reposts.test.ts src/__tests__/explore.test.ts src/__tests__/search.test.ts
+
+
+ - All 5 test files load without import errors (todo/skipped allowed; no crash)
+ - prisma-mock.ts exports mock for reaction, comment, repost, hashtag, postHashtag
+
+ 5 test scaffolds runnable; mock helper extended.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| client → tRPC mutation | Untrusted petId/postId crosses into server procedures |
+
+## STRIDE
+
+| ID | Category | Mitigation |
+|----|----------|-----------|
+| T-03-01 | Tampering | All mutations assert petOwnership before writing — enforced in every router |
+| T-03-02 | Tampering | Repost-of-repost blocked at router level (verify originalPost.type !== REPOST) |
+
+
+
+- `npx prisma validate` exits 0; `npx prisma db push` created 5 tables
+- `npx tsc --noEmit` exits 0
+- All 5 test scaffolds run without import errors
+- `npx vitest run` (full suite) stays green
+
+
+
+- 5 new Prisma models in DB (Reaction, Comment, Repost, Hashtag, PostHashtag)
+- PostType.REPOST added
+- 5 tRPC routers registered and type-checking
+- 5 test scaffolds runnable
+
+
+
diff --git a/.planning/phases/03-engagement-discovery/03-01-SUMMARY.md b/.planning/phases/03-engagement-discovery/03-01-SUMMARY.md
new file mode 100644
index 0000000..0f6b6c5
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-01-SUMMARY.md
@@ -0,0 +1,60 @@
+# Plan 03-01 Summary — Schema Foundation + Stubs
+
+**Completed:** 2026-06-13
+**Phase:** 03-engagement-discovery
+**Plan:** 01 of 06
+**Wave:** 0
+
+## What was done
+
+### Task 1: Prisma Schema — 5 new models + PostType.REPOST
+- Added `REPOST` to `PostType` enum
+- Added 5 new models: `Reaction`, `Comment`, `Repost`, `Hashtag`, `PostHashtag`
+- Added Phase 3 back-relations to `Post`: reactions, comments, repost, originalReposts, hashtags
+- Added Phase 3 back-relations to `Pet`: reactions, comments, reposts
+- `npx prisma validate` + `npx prisma generate` + `npx prisma db push` all succeeded
+- 5 new tables created in Supabase DB
+
+Key schema decisions implemented:
+- `Reaction.@@unique([postId, petId])` — one paw per pet per post
+- `Repost.postId @unique` (1-to-1 with Post) + `@@id([reposterPetId, originalPostId])` (dedup)
+- Named relations on all multi-path models (PostReactions/PetReactions, etc.) — avoids Prisma "ambiguous relation" error
+
+### Task 2: 5 tRPC Router Stubs
+All procedures annotated with explicit return types to avoid TypeScript `never` inference:
+- `reactions.ts`: toggle, hasReacted, getCount
+- `comments.ts`: create, listByPost, delete
+- `reposts.ts`: create, delete, isReposted
+- `explore.ts`: listPets (publicProcedure — auth-free for explore page)
+- `search.ts`: pets, byHashtag
+
+All 5 routers registered in `_app.ts`. `publicProcedure` already existed in `init.ts`.
+
+### Task 3: Prisma Mock + 5 Test Scaffolds
+Extended `prisma-mock.ts` with: reaction, comment, repost, hashtag, postHashtag.
+
+Created 5 test files:
+- `reactions.test.ts` — 4 tests (3 failing NOT_IMPLEMENTED, 1 FORBIDDEN passing)
+- `comments.test.ts` — 3 tests (2 failing, 1 FORBIDDEN passing)
+- `reposts.test.ts` — 3 tests (2 failing, 1 FORBIDDEN passing)
+- `explore.test.ts` — 3 tests (all failing)
+- `search.test.ts` — 4 tests (all failing)
+
+## Verification
+
+```
+npx prisma validate → ✓ valid
+npx prisma db push → ✓ 5 tables created
+npx tsc --noEmit → ✓ exit 0
+npx vitest run → 53 passing (Phase 2 untouched) + 14 failing RED (Phase 3) + 1 pre-existing
+```
+
+## Next Plans
+
+Wave 2 can start in parallel:
+- **03-02**: Paw Reactions (ENGM-01) — GREEN reactions router + PawButton
+- **03-03**: Comments (ENGM-02/03) — GREEN comments router + CommentSheet
+- **03-04**: Paw-Back/Repost (ENGM-04) — GREEN reposts router + RepostCard
+
+Wave 1 parallel:
+- **03-05**: Explore + Pet Search (EXPL-01, SRCH-02) — no dependency on Wave 2
diff --git a/.planning/phases/03-engagement-discovery/03-02-PLAN.md b/.planning/phases/03-engagement-discovery/03-02-PLAN.md
new file mode 100644
index 0000000..bfb1485
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-02-PLAN.md
@@ -0,0 +1,133 @@
+---
+phase: 03-engagement-discovery
+plan: 02
+type: execute
+wave: 1
+depends_on: [03-01]
+files_modified:
+ - src/trpc/routers/reactions.ts
+ - src/__tests__/reactions.test.ts
+ - src/components/feed/PostCard.tsx
+ - src/components/feed/PawButton.tsx
+autonomous: false
+requirements: [ENGM-01]
+must_haves:
+ truths:
+ - "reactions.toggle creates a Reaction if none exists, deletes it if it does (toggle)"
+ - "reactions.hasReacted returns boolean for petId+postId"
+ - "reactions.getCount returns number"
+ - "reactions.toggle throws FORBIDDEN for non-owner petId"
+ - "PostCard renders a PawButton with count that updates after toggle"
+---
+
+
+Implement Paw reactions end-to-end: GREEN the reactions router (toggle, hasReacted, getCount), then add the PawButton to the PostCard action row. The paw button shows filled/outline state and count; count invalidates after toggle.
+
+TDD cycle: update reactions.test.ts to fail first (RED), then implement router (GREEN), then wire UI.
+
+
+
+@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
+
+
+
+@.planning/phases/03-engagement-discovery/03-RESEARCH.md
+@.planning/phases/03-engagement-discovery/03-UI-SPEC.md
+@src/trpc/routers/reactions.ts
+@src/trpc/routers/pets.ts
+@src/__tests__/reactions.test.ts
+@src/components/feed/PostCard.tsx
+
+
+
+
+
+ Task 1: RED — failing reactions tests
+ src/__tests__/reactions.test.ts
+
+ Replace it.todo stubs with proper failing tests:
+ - "toggle creates reaction" — mock prisma.reaction.findUnique returns null, create returns a reaction; assert result.reacted === true
+ - "toggle removes if exists" — mock findUnique returns existing reaction; assert result.reacted === false
+ - "FORBIDDEN non-owner" — mock pet.ownerId !== ctx.userId; expect TRPCError code FORBIDDEN
+ Tests must fail (NOT_IMPLEMENTED error) at this point.
+
+
+ npx vitest run src/__tests__/reactions.test.ts
+
+ Tests run but fail with NOT_IMPLEMENTED or wrong assertions.
+ RED: 3 failing tests.
+
+
+
+ Task 2: GREEN — implement reactions router
+ src/trpc/routers/reactions.ts
+
+ - .planning/phases/03-engagement-discovery/03-RESEARCH.md (Pattern 3: Paw Reaction Toggle)
+ - src/trpc/routers/pets.ts (assertPetOwnership helper)
+
+
+ Replace stubs in reactions.ts with full implementations per RESEARCH Pattern 3:
+ - toggle({ postId, petId }): assertPetOwnership → findUnique by postId_petId → if exists delete+return{reacted:false}, else create+return{reacted:true}
+ - hasReacted({ postId, petId }): return boolean (findUnique !== null)
+ - getCount({ postId }): return count (prisma.reaction.count({ where: { postId } }))
+
+
+ npx vitest run src/__tests__/reactions.test.ts
+
+ All 3 reaction tests pass. `npx tsc --noEmit` exits 0.
+ GREEN: reactions router fully implemented.
+
+
+
+ Task 3: PawButton component + PostCard action row
+ src/components/feed/PawButton.tsx, src/components/feed/PostCard.tsx
+
+ - .planning/phases/03-engagement-discovery/03-UI-SPEC.md (Section 1: PostCard Action Row)
+ - src/components/feed/PostCard.tsx (current structure — find caption render location)
+ - src/trpc/routers/feed.ts (check if _count is already included in feed hydration)
+
+
+ Create PawButton.tsx:
+ - Props: postId, petId, initialCount (number), initialReacted (boolean)
+ - State: reacted, count (local, toggled on click)
+ - On click: call trpc.reactions.toggle.mutationOptions(); update local state immediately (optimistic); on error revert
+ - Render: PawPrint icon (filled+orange-500 if reacted, outline+muted if not), count if >0
+ - Aria-label: "Paw this post" / "Remove paw"
+
+ Update PostCard.tsx:
+ - Add action row div below caption (per UI-SPEC Section 1)
+ - Include PawButton with initialCount and initialReacted from post data
+ - Include comment count chip (MessageCircle icon + count) — clicking opens CommentSheet (stub for now, CommentSheet implemented in plan 03-03)
+ - Include Paw-Back button (Repeat2 icon) — stub handler for now, implemented in plan 03-04
+ - Hide Paw-Back on PostType.REPOST posts
+
+ Update feed.ts and posts.ts hydration to include `_count: { select: { reactions: true, comments: true } }` in the Prisma include.
+
+
+ npx tsc --noEmit
+
+
+ - `npx tsc --noEmit` exits 0
+ - PostCard renders action row with paw count, comment count, repost button
+ - PawButton toggles visual state on click
+ - Feed hydration includes _count reactions + comments
+
+ PawButton live on PostCard; reactions end-to-end.
+
+
+
+
+
+- `npx vitest run src/__tests__/reactions.test.ts` — all pass
+- `npx tsc --noEmit` exits 0
+- `npx vitest run` — full suite green
+
+
+
+- reactions.toggle, hasReacted, getCount implemented and tested
+- PostCard shows paw count + button that toggles
+
+
+
diff --git a/.planning/phases/03-engagement-discovery/03-03-PLAN.md b/.planning/phases/03-engagement-discovery/03-03-PLAN.md
new file mode 100644
index 0000000..d7d7316
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-03-PLAN.md
@@ -0,0 +1,122 @@
+---
+phase: 03-engagement-discovery
+plan: 03
+type: execute
+wave: 2
+depends_on: [03-02]
+files_modified:
+ - src/trpc/routers/comments.ts
+ - src/__tests__/comments.test.ts
+ - src/components/feed/CommentSheet.tsx
+ - src/components/feed/PostCard.tsx
+autonomous: false
+requirements: [ENGM-02, ENGM-03]
+must_haves:
+ truths:
+ - "comments.create persists a comment and returns it with pet info"
+ - "comments.listByPost returns comments sorted createdAt ASC with cursor pagination"
+ - "comments.delete succeeds for own comment, throws FORBIDDEN for other's comment"
+ - "PostCard comment count chip opens CommentSheet"
+ - "CommentSheet renders existing comments + input + delete on own comments"
+---
+
+
+Implement flat comments end-to-end: GREEN the comments router (create, listByPost, delete), then build CommentSheet and wire the comment count chip on PostCard.
+
+TDD cycle: RED tests first, then GREEN router, then UI.
+
+
+
+@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
+
+
+
+@.planning/phases/03-engagement-discovery/03-RESEARCH.md
+@.planning/phases/03-engagement-discovery/03-UI-SPEC.md
+@src/trpc/routers/comments.ts
+@src/__tests__/comments.test.ts
+@src/components/feed/PostCard.tsx
+
+
+
+
+
+ Task 1: RED — failing comments tests
+ src/__tests__/comments.test.ts
+
+ Replace it.todo stubs with failing tests:
+ - "create comment returns body" — mock create returns comment with body; assert body matches input
+ - "delete own comment ok" — mock findUnique returns comment where pet.ownerId = ctx.userId; assert no error
+ - "delete other comment FORBIDDEN" — mock findUnique returns comment owned by different userId; expect FORBIDDEN
+
+ npx vitest run src/__tests__/comments.test.ts
+ 3 tests run and fail (NOT_IMPLEMENTED).
+ RED: 3 failing comments tests.
+
+
+
+ Task 2: GREEN — implement comments router
+ src/trpc/routers/comments.ts
+
+ - src/trpc/routers/pets.ts (assertPetOwnership pattern)
+ - .planning/phases/03-engagement-discovery/03-RESEARCH.md (Pitfall 6: use id not createdAt as cursor)
+ - .planning/phases/03-engagement-discovery/03-CONTEXT.md (D-05: delete own only; D-03: flat, createdAt ASC)
+
+
+ Implement comments router:
+ - create({ postId, petId, body: z.string().min(1).max(500) }): assertPetOwnership → prisma.comment.create with pet include (id, name, avatarKey) → return comment
+ - listByPost({ postId, limit=20, cursor? }): findMany where postId, take limit+1, orderBy createdAt ASC, include pet (id, name, avatarKey), cursor-based (skip 1 if cursor, id lt cursor from RESEARCH Pitfall 6 — actually for ASC order use id gt cursor), return { comments, nextCursor }
+ - delete({ commentId, petId }): assertPetOwnership(petId) → findUnique comment → verify comment.petId === petId else FORBIDDEN → delete
+
+ npx vitest run src/__tests__/comments.test.ts
+ All 3 comment tests pass. `npx tsc --noEmit` exits 0.
+ GREEN: comments router implemented.
+
+
+
+ Task 3: CommentSheet + wire comment count chip on PostCard
+ src/components/feed/CommentSheet.tsx, src/components/feed/PostCard.tsx
+
+ - .planning/phases/03-engagement-discovery/03-UI-SPEC.md (Section 2: CommentSheet)
+ - src/components/feed/PostCard.tsx (current action row structure from plan 03-02)
+ - src/components/ui/sheet.tsx (existing shadcn Sheet component)
+
+
+ Create CommentSheet.tsx:
+ - Props: postId, petId (active pet), open, onOpenChange
+ - Fetch comments via trpc.comments.listByPost (useQuery)
+ - Render ScrollArea with CommentItem per comment (avatar, pet name, relative time, body, trash icon if comment.petId === petId)
+ - Delete handler: useMutation(trpc.comments.delete), invalidate listByPost on success
+ - Sticky CommentInput at bottom: Textarea + "Post" button
+ - Create handler: useMutation(trpc.comments.create), clear input + invalidate on success, toast on error
+
+ Update PostCard.tsx:
+ - Add useState for commentSheetOpen
+ - Comment chip button onClick sets commentSheetOpen(true)
+ - Render
+
+ npx tsc --noEmit
+
+ - `npx tsc --noEmit` exits 0
+ - CommentSheet opens from PostCard comment chip
+ - Comments load, new comment submits and appears, own comment deletable
+
+ CommentSheet live; comments end-to-end.
+
+
+
+
+
+- `npx vitest run src/__tests__/comments.test.ts` — all pass
+- `npx tsc --noEmit` exits 0
+- `npx vitest run` — full suite green
+
+
+
+- comments.create, listByPost, delete implemented and tested
+- CommentSheet works from PostCard
+
+
+
diff --git a/.planning/phases/03-engagement-discovery/03-04-PLAN.md b/.planning/phases/03-engagement-discovery/03-04-PLAN.md
new file mode 100644
index 0000000..6ed379c
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-04-PLAN.md
@@ -0,0 +1,138 @@
+---
+phase: 03-engagement-discovery
+plan: 04
+type: execute
+wave: 2
+depends_on: [03-02]
+files_modified:
+ - src/trpc/routers/reposts.ts
+ - src/__tests__/reposts.test.ts
+ - src/components/feed/RepostCard.tsx
+ - src/components/feed/PostCard.tsx
+autonomous: false
+requirements: [ENGM-04]
+must_haves:
+ truths:
+ - "reposts.create creates Post(type=REPOST) + Repost record + calls fanOutPost"
+ - "reposts.create throws FORBIDDEN if originalPost.type === REPOST"
+ - "reposts.create is idempotent (second call returns existing postId)"
+ - "reposts.delete removes the repost Post and Repost record"
+ - "PostCard shows Paw-Back button that triggers repost"
+ - "RepostCard renders attribution header + original post content"
+---
+
+
+Implement Paw-Back (repost) end-to-end: GREEN reposts router, build RepostCard, wire Paw-Back button on PostCard. Reposts fan out to followers via the existing Redis pipeline.
+
+TDD cycle: RED → GREEN → UI.
+
+
+
+@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
+
+
+
+@.planning/phases/03-engagement-discovery/03-RESEARCH.md
+@.planning/phases/03-engagement-discovery/03-UI-SPEC.md
+@src/trpc/routers/reposts.ts
+@src/__tests__/reposts.test.ts
+@src/trpc/routers/posts.ts
+@src/lib/feed-helpers.ts
+@src/components/feed/PostCard.tsx
+
+
+
+
+
+ Task 1: RED — failing reposts tests
+ src/__tests__/reposts.test.ts
+
+ Replace it.todo stubs:
+ - "create repost creates REPOST post" — mock originalPost.type=PHOTO; assert post.create called with type REPOST; assert repost.create called; assert fanOutPost called
+ - "repost-of-repost FORBIDDEN" — mock originalPost.type=REPOST; expect TRPCError FORBIDDEN
+ - "idempotent second repost" — mock repost.findUnique returns existing; assert no duplicate post.create; returns existing postId
+
+ npx vitest run src/__tests__/reposts.test.ts
+ 3 tests run and fail.
+ RED: 3 failing repost tests.
+
+
+
+ Task 2: GREEN — implement reposts router
+ src/trpc/routers/reposts.ts
+
+ - .planning/phases/03-engagement-discovery/03-RESEARCH.md (Pattern 4: Repost Create, Pitfall 4)
+ - src/trpc/routers/posts.ts (fanOutPost call pattern + how Post is created)
+ - src/lib/feed-helpers.ts (fanOutPost signature)
+
+
+ Implement reposts router:
+ - create({ originalPostId, reposterPetId }):
+ 1. assertPetOwnership(reposterPetId)
+ 2. Fetch originalPost — verify type !== REPOST (throw FORBIDDEN if REPOST)
+ 3. Check for existing repost (idempotency) — return { postId: existing.postId } if found
+ 4. prisma.post.create({ data: { petId: reposterPetId, type: "REPOST", repost: { create: { originalPostId, reposterPetId } } } })
+ 5. fanOutPost(redis, post.id, reposterPetId, post.createdAt, ctx.prisma)
+ 6. Return { postId: post.id }
+ - delete({ originalPostId, reposterPetId }):
+ 1. assertPetOwnership(reposterPetId)
+ 2. Find repost — throw NOT_FOUND if absent
+ 3. Delete the Post record (cascades to Repost via onDelete: Cascade)
+ - isReposted({ originalPostId, petId }): findUnique by composite; return boolean
+
+ npx vitest run src/__tests__/reposts.test.ts
+ All 3 repost tests pass. `npx tsc --noEmit` exits 0.
+ GREEN: reposts router implemented.
+
+
+
+ Task 3: RepostCard + Paw-Back button on PostCard
+ src/components/feed/RepostCard.tsx, src/components/feed/PostCard.tsx
+
+ - .planning/phases/03-engagement-discovery/03-UI-SPEC.md (Section 3: RepostCard, Section 1: action row)
+ - src/components/feed/PostCard.tsx (current structure + action row from 03-02)
+ - src/trpc/routers/feed.ts (check if repost include is in hydration — add if not)
+
+
+ Update feed.ts and posts.ts hydration to include `repost: { include: { originalPost: { include: { pet: true, images: { orderBy: { position: "asc" } }, milestone: true } } } }` when post.type === REPOST.
+
+ Create RepostCard.tsx:
+ - Props: post (PostCardPost with repost.originalPost included)
+ - Header: PawPrint icon + "Pawed-back by @{post.pet.name}" in text-xs text-muted-foreground
+ - Below header: Separator, then original post content (pet avatar + name, image carousel if any, caption with hashtag rendering)
+ - No action row on original post inside RepostCard (no nested interactions)
+
+ Update PostCard.tsx:
+ - At the top, if post.type === "REPOST", return
+ - Wire the Paw-Back button stub from plan 03-02:
+ - useMutation(trpc.reposts.create / reposts.delete) based on isReposted state
+ - useQuery(trpc.reposts.isReposted) to get initial state
+ - Toggle: if not reposted → create; if reposted → delete (with confirmation toast "Paw-Back removed")
+ - Repeat2 icon, orange-500 when reposted
+ - Disabled during mutation pending
+
+ npx tsc --noEmit
+
+ - RepostCard renders correctly for type=REPOST posts
+ - Paw-Back button toggles repost state
+ - `npx tsc --noEmit` exits 0
+
+ Paw-Back end-to-end; RepostCard in feed.
+
+
+
+
+
+- `npx vitest run src/__tests__/reposts.test.ts` — all pass
+- `npx tsc --noEmit` exits 0
+- `npx vitest run` — full suite green
+
+
+
+- reposts.create, delete, isReposted implemented and tested
+- RepostCard renders in feed; Paw-Back button toggles
+
+
+
diff --git a/.planning/phases/03-engagement-discovery/03-05-PLAN.md b/.planning/phases/03-engagement-discovery/03-05-PLAN.md
new file mode 100644
index 0000000..90b5f1d
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-05-PLAN.md
@@ -0,0 +1,170 @@
+---
+phase: 03-engagement-discovery
+plan: 05
+type: execute
+wave: 3
+depends_on: [03-01]
+files_modified:
+ - src/trpc/routers/explore.ts
+ - src/trpc/routers/search.ts
+ - src/__tests__/explore.test.ts
+ - src/__tests__/search.test.ts
+ - src/app/(app)/explore/page.tsx
+ - src/app/(app)/search/page.tsx
+ - src/components/explore/ExploreCard.tsx
+ - src/components/layout/Sidebar.tsx
+ - src/components/layout/MobileNav.tsx
+autonomous: false
+requirements: [EXPL-01, SRCH-02]
+must_haves:
+ truths:
+ - "explore.listPets returns pets filtered by speciesId and/or breedId"
+ - "explore.listPets uses cursor pagination"
+ - "search.pets returns pets matching name or breed ILIKE query"
+ - "/explore page renders species tabs + pet grid"
+ - "/search page renders search input + Pets tab with results"
+ - "Explore and Search links in Sidebar and MobileNav"
+---
+
+
+Implement the Explore page (EXPL-01) and pet search (SRCH-02): explore and search router procedures, the /explore and /search pages, ExploreCard component, and nav additions.
+
+Hashtag search (SRCH-01) is implemented in plan 03-06 which extends the search page.
+TDD cycle: RED → GREEN → UI.
+
+
+
+@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
+
+
+
+@.planning/phases/03-engagement-discovery/03-RESEARCH.md
+@.planning/phases/03-engagement-discovery/03-UI-SPEC.md
+@src/trpc/routers/explore.ts
+@src/trpc/routers/search.ts
+@src/__tests__/explore.test.ts
+@src/__tests__/search.test.ts
+@src/components/layout/Sidebar.tsx
+@src/components/layout/MobileNav.tsx
+
+
+
+
+
+ Task 1: RED — failing explore + search (pets) tests
+ src/__tests__/explore.test.ts, src/__tests__/search.test.ts
+
+ explore.test.ts:
+ - "listPets filters by speciesId" — mock findMany returns 2 pets with speciesId filter; assert where.speciesId === input
+ - "cursor pagination" — mock findMany returns limit+1 pets; assert nextCursor !== null
+
+ search.test.ts (pets only, byHashtag stays todo for plan 03-06):
+ - "pets matches name" — mock findMany returns pets where name ilike query; assert pets.length > 0
+ - "pets matches breed" — mock with breed join where breed.name ilike; assert returned
+
+ npx vitest run src/__tests__/explore.test.ts src/__tests__/search.test.ts
+ Tests run and fail (NOT_IMPLEMENTED).
+ RED established.
+
+
+
+ Task 2: GREEN — explore.listPets + search.pets router
+ src/trpc/routers/explore.ts, src/trpc/routers/search.ts
+
+ - .planning/phases/03-engagement-discovery/03-RESEARCH.md (Pattern 5: Explore Pet Query)
+ - .planning/phases/03-engagement-discovery/03-CONTEXT.md (D-10: public; D-12: ILIKE)
+ - src/trpc/init.ts (verify publicProcedure exists from plan 03-01)
+
+
+ explore.ts — implement listPets per RESEARCH Pattern 5:
+ - publicProcedure (no auth required)
+ - input: speciesId?, breedId?, limit (1-40, default 20), cursor? (petId)
+ - where filter + cursor-based pagination (skip 1 + cursor filter)
+ - include: species, breed, _count: { select: { followers: true } }
+ - return { pets, nextCursor }
+
+ search.ts — implement pets procedure:
+ - protectedProcedure (logged-in users only)
+ - input: query z.string().min(1).max(100), limit default 20, cursor?
+ - Prisma: findMany where OR [{ name: { contains: query, mode: "insensitive" } }, { breed: { name: { contains: query, mode: "insensitive" } } }]
+ - include: species, breed
+ - return { pets, nextCursor }
+
+ npx vitest run src/__tests__/explore.test.ts src/__tests__/search.test.ts
+ explore + search pets tests pass. `npx tsc --noEmit` exits 0.
+ GREEN: explore + search.pets implemented.
+
+
+
+ Task 3: ExploreCard component
+ src/components/explore/ExploreCard.tsx
+
+ - .planning/phases/03-engagement-discovery/03-UI-SPEC.md (Section 4: ExploreCard, Section 5: Explore Page)
+ - src/components/feed/PostCard.tsx (Avatar, Badge import patterns)
+
+
+ Create ExploreCard.tsx per UI-SPEC Section 4:
+ - Props: pet (id, name, avatarKey, species, breed, _count.followers), isOwnPet: boolean
+ - Avatar 56px, pet name (truncated), breed badge (breed.name or species.name fallback), follower count
+ - If !isOwnPet: FollowButton (import existing FollowButton component)
+ - If isOwnPet: "Your pet" muted caption instead
+
+ npx tsc --noEmit
+ `npx tsc --noEmit` exits 0. ExploreCard renders without errors.
+ ExploreCard created.
+
+
+
+ Task 4: /explore and /search pages + nav additions
+ src/app/(app)/explore/page.tsx, src/app/(app)/search/page.tsx, src/components/layout/Sidebar.tsx, src/components/layout/MobileNav.tsx
+
+ - .planning/phases/03-engagement-discovery/03-UI-SPEC.md (Section 5: Explore Page, Section 6: Search Page, Section 8: Nav additions)
+ - src/app/(app)/feed/page.tsx (page layout pattern)
+ - src/components/layout/Sidebar.tsx (existing nav item pattern)
+ - src/components/layout/MobileNav.tsx (existing bottom bar pattern)
+
+
+ Create /explore/page.tsx (client component):
+ - Fetch species list via trpc.pets.listSpecies (or direct prisma if RSC — check if species query exists)
+ - Species tabs: All + one per species (dogs/cats/birds)
+ - Breed filter: Select/Combobox when non-All tab active, fetches breeds for active speciesId
+ - Pet grid: useInfiniteQuery(explore.listPets) — render ExploreCard per pet
+ - Load More button (react-intersection-observer for auto-load)
+
+ Create /search/page.tsx (client component):
+ - Controlled input for query (debounced 300ms)
+ - Tabs: "Pets" | "Tags" (Tags tab rendered in plan 03-06, stub here)
+ - Pets tab: useQuery(search.pets) when query.length >= 2 — render PetSearchResultItem (Avatar 40px + name + breed badge + FollowButton)
+ - Empty states per UI-SPEC
+
+ Update Sidebar.tsx: add Explore (Compass icon) + Search (Search icon) nav items after Home
+ Update MobileNav.tsx: replace placeholder slot with Explore + Search per UI-SPEC Section 8
+
+ npx tsc --noEmit
+
+ - `npx tsc --noEmit` exits 0
+ - /explore and /search routes exist and type-check
+ - Sidebar + MobileNav include Explore + Search links
+
+ Explore and Search pages live; nav updated.
+
+
+
+
+
+- `npx vitest run src/__tests__/explore.test.ts` — passes
+- `npx vitest run src/__tests__/search.test.ts` — passes (pets tests; hashtag still todo)
+- `npx tsc --noEmit` exits 0
+- `npx vitest run` — full suite green
+
+
+
+- explore.listPets + search.pets implemented and tested
+- /explore page with species tabs + breed filter + pet grid
+- /search page with pet search
+- Explore + Search in nav
+
+
+
diff --git a/.planning/phases/03-engagement-discovery/03-06-PLAN.md b/.planning/phases/03-engagement-discovery/03-06-PLAN.md
new file mode 100644
index 0000000..46ea446
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-06-PLAN.md
@@ -0,0 +1,167 @@
+---
+phase: 03-engagement-discovery
+plan: 06
+type: execute
+wave: 3
+depends_on: [03-05]
+files_modified:
+ - src/trpc/routers/posts.ts
+ - src/trpc/routers/reposts.ts
+ - src/trpc/routers/search.ts
+ - src/__tests__/search.test.ts
+ - src/lib/hashtag-helpers.ts
+ - src/lib/parse-caption.ts
+ - src/app/(app)/hashtag/[tag]/page.tsx
+ - src/app/(app)/search/page.tsx
+ - src/components/feed/PostCard.tsx
+autonomous: false
+requirements: [SRCH-01]
+must_haves:
+ truths:
+ - "Hashtags are extracted from caption on posts.create and reposts.create"
+ - "search.byHashtag returns posts tagged with the given hashtag"
+ - "search.byHashtag returns empty for unknown tags"
+ - "/hashtag/[tag] page renders a post grid"
+ - "Hashtag tokens in PostCard captions are clickable links to /hashtag/[tag]"
+---
+
+
+Implement hashtag indexing and search (SRCH-01): extract hashtags on post create, implement search.byHashtag, build the /hashtag/[tag] page, render clickable hashtags in PostCard captions, and add the "Tags" tab to /search.
+
+TDD cycle: RED → GREEN → UI.
+
+
+
+@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
+
+
+
+@.planning/phases/03-engagement-discovery/03-RESEARCH.md
+@.planning/phases/03-engagement-discovery/03-UI-SPEC.md
+@src/trpc/routers/posts.ts
+@src/trpc/routers/search.ts
+@src/__tests__/search.test.ts
+@src/components/feed/PostCard.tsx
+
+
+
+
+
+ Task 1: Hashtag helper libs
+ src/lib/hashtag-helpers.ts, src/lib/parse-caption.ts
+
+ - .planning/phases/03-engagement-discovery/03-RESEARCH.md (Pattern 2: Hashtag Extraction, Pattern 7: Caption Hashtag Rendering, Pitfall 3: regex lastIndex)
+
+
+ Create src/lib/hashtag-helpers.ts (server-only):
+ - import "server-only"
+ - extractHashtags(text): per RESEARCH Pattern 2 — regex /#(\w{1,50})/g, lowercase, deduplicate, max 30
+ - saveHashtags(prisma, postId, tags): per RESEARCH Pattern 2 — upsert hashtag per tag, upsert PostHashtag junction
+ - Wrap in try/catch so hashtag extraction never fails a post.create
+
+ Create src/lib/parse-caption.ts (no server-only — used client-side too):
+ - parseCaption(caption): per RESEARCH Pattern 7 — splits on /#\w{1,50}/g, returns CaptionSegment[]
+
+ npx tsc --noEmit
+ `npx tsc --noEmit` exits 0. Both helpers export correctly.
+ Hashtag helper libs created.
+
+
+
+ Task 2: RED — failing search.byHashtag tests
+ src/__tests__/search.test.ts
+
+ Replace byHashtag it.todo stubs with failing tests:
+ - "byHashtag returns posts" — mock hashtag.findUnique returns hashtag; mock postHashtag.findMany returns rows with posts; assert posts.length > 0
+ - "unknown tag returns empty" — mock hashtag.findUnique returns null; assert { posts: [], nextCursor: null }
+
+ npx vitest run src/__tests__/search.test.ts
+ 2 hashtag tests run and fail.
+ RED: 2 failing byHashtag tests.
+
+
+
+ Task 3: GREEN — implement search.byHashtag + wire extraction into posts.create
+ src/trpc/routers/search.ts, src/trpc/routers/posts.ts, src/trpc/routers/reposts.ts
+
+ - .planning/phases/03-engagement-discovery/03-RESEARCH.md (Pattern 6: Hashtag Search)
+ - src/trpc/routers/posts.ts (posts.create — add hashtag extraction after post is created)
+ - src/trpc/routers/reposts.ts (reposts.create — add hashtag extraction after repost post created)
+
+
+ search.ts — implement byHashtag per RESEARCH Pattern 6:
+ - Input: tag (strip # + lowercase), limit, cursor
+ - hashtag.findUnique → if null return { posts: [], nextCursor: null }
+ - postHashtag.findMany with post include (pet + first image) + cursor pagination
+ - return { posts, nextCursor }
+
+ posts.ts — in posts.create, after the post is created, call:
+ ```
+ const tags = extractHashtags(input.caption);
+ await saveHashtags(ctx.prisma, post.id, tags).catch(() => {}); // never fail post creation
+ ```
+
+ reposts.ts — same: extract hashtags from originalPost.caption after repost created. Note: the repost itself has no caption; use originalPost.caption.
+
+ npx vitest run src/__tests__/search.test.ts
+ All search tests pass (both pets + hashtag). `npx tsc --noEmit` exits 0.
+ GREEN: byHashtag implemented; hashtags extracted on post create.
+
+
+
+ Task 4: /hashtag/[tag] page + Tags tab in /search + clickable captions
+ src/app/(app)/hashtag/[tag]/page.tsx, src/app/(app)/search/page.tsx, src/components/feed/PostCard.tsx
+
+ - .planning/phases/03-engagement-discovery/03-UI-SPEC.md (Section 7: Hashtag Page, Section 6: Search Page Tags tab)
+ - src/app/(app)/feed/page.tsx (page layout pattern)
+ - src/components/profile/ProfileTabs.tsx (3-col post grid pattern to reuse)
+ - src/components/feed/PostCard.tsx (current caption render — replace with parseCaption)
+
+
+ Create /hashtag/[tag]/page.tsx (client component):
+ - Read [tag] from params; normalize (lowercase, strip #)
+ - Fetch post count via search.byHashtag (approximate: count from first page or add a count procedure)
+ - Render "#{tag}" heading + post count subtitle
+ - 3-col post grid: same grid/img/Layers2 pattern as ProfileTabs Posts grid
+ - Cursor-based load more (IntersectionObserver)
+ - Empty state: "No posts tagged #{tag} yet"
+
+ Update /search/page.tsx:
+ - Wire Tags tab: input normalizes to tag (strip #); useQuery(search.byHashtag) when tag.length >= 1
+ - Show same 3-col post grid in Tags tab results
+
+ Update PostCard.tsx caption render:
+ - Import parseCaption from @/lib/parse-caption
+ - Replace raw caption string render with parseCaption(post.caption).map(seg => seg.type === "hashtag" ? : )
+ - Hashtag links: text-orange-500 hover:underline, route to /hashtag/[tag]
+
+ npx tsc --noEmit
+
+ - `npx tsc --noEmit` exits 0
+ - /hashtag/[tag] route exists and renders post grid
+ - /search Tags tab shows hashtag results
+ - Hashtags in PostCard captions are clickable orange links
+
+ Hashtag search end-to-end; Phase 3 complete.
+
+
+
+
+
+- `npx vitest run src/__tests__/search.test.ts` — all pass (pets + hashtag)
+- `npx tsc --noEmit` exits 0
+- `npx vitest run` — full suite green (50+ tests passing)
+
+
+
+- Hashtags extracted on post.create + repost.create
+- search.byHashtag implemented and tested
+- /hashtag/[tag] page renders post grid
+- /search Tags tab works
+- PostCard captions render clickable hashtag links
+
+
+
diff --git a/.planning/phases/03-engagement-discovery/03-CONTEXT.md b/.planning/phases/03-engagement-discovery/03-CONTEXT.md
new file mode 100644
index 0000000..0f05656
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-CONTEXT.md
@@ -0,0 +1,132 @@
+# Phase 3: Engagement and Discovery — Context
+
+**Gathered:** 2026-06-13
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+Phase 3 delivers engagement depth and discovery for PawFeed. Pets can receive Paw reactions and comments on their posts. Owners can paw-back (repost) another pet's post with attribution. An Explore page lets owners browse pets by species and breed. A Search page finds pets by name/breed and posts by hashtag. Phase 3 completes the engagement loop that Phase 2's content core requires to be socially meaningful.
+
+
+
+
+## Implementation Decisions
+
+### Paw Reactions (ENGM-01)
+- **D-01:** Single "Paw" reaction type only (🐾). No multi-emoji variant. One paw per pet per post (enforced by `@@unique([postId, petId])` on the `Reaction` model). Toggle: if the pet has already pawed, clicking again removes the reaction.
+- **D-02:** Reaction count is shown on every PostCard in the action row. Count is included in the feed hydration query (`_count: { select: { reactions: true } }`) — no separate per-post query.
+
+### Comments (ENGM-02, ENGM-03)
+- **D-03:** Comments are flat (no threaded replies in Phase 3). Sorted `createdAt ASC`. Max 500 characters per comment (VarChar(500)).
+- **D-04:** Comment input lives in a bottom Sheet on PostCard — same pattern as PostTypeSheet. No separate page. The comment count chip in the action row opens the Sheet.
+- **D-05:** Comment delete is owner-of-commenting-pet only. Post owner cannot delete others' comments in Phase 3 (moderation is Phase 4+). Ownership assertion: `comment.petId === activePetId` and `pet.ownerId === ctx.userId`.
+
+### Paw-Back / Repost (ENGM-04)
+- **D-06:** Paw-Back creates a new `Post(type=REPOST)` for the reposterPet, which is fanned out via `fanOutPost` (enters followers' feeds normally). A companion `Repost` model stores attribution: `originalPostId → reposterPetId`. The feed renders `PostType.REPOST` posts with a "🐾 Pawed-back by @petName" header above the original post content.
+- **D-07:** One repost per pet per original post (composite primary key on `[reposterPetId, originalPostId]`). Attempting to repost again is a no-op (idempotent).
+- **D-08:** Paw-Back button is only shown for posts whose type is `PHOTO` or `MILESTONE` (not REPOST — no repost-of-a-repost).
+
+### Explore Page (EXPL-01)
+- **D-09:** Explore page lives at `/explore`. Species tabs: All / Dogs / Cats / Birds. Within each tab, an optional breed filter dropdown. Pet cards show: avatar, name, species badge (if "All" tab), breed, follower count, Follow button (hidden for own pets).
+- **D-10:** Explore query is paginated with cursor. Default sort: most followers descending. No auth required to view the Explore page — it is public.
+
+### Search (SRCH-01, SRCH-02)
+- **D-11:** Search lives at `/search`. Single text input at the top. Two result tabs: "Pets" (SRCH-02) and "Tags" (SRCH-01). Tab switches without clearing the input.
+- **D-12:** Pet search uses Postgres `ILIKE '%query%'` on `pet.name` and joins `breed.name ILIKE '%query%'`. Max 20 results per page. No full-text index needed at v1 scale.
+- **D-13:** Hashtag search: tag input is normalized (lowercase, strip `#`) before lookup. Results are posts tagged with that hashtag, sorted `createdAt DESC` with cursor pagination.
+
+### Hashtag Extraction (SRCH-01 prerequisite)
+- **D-14:** Hashtags are extracted server-side on every `posts.create` and `reposts.create` call using regex `/(?<=#)\w{1,50}/g` on the caption. Extraction is upsert-based: `hashtag.upsert({ where: { tag }, create: { tag }, update: {} })`. Junction rows `PostHashtag` link posts to hashtags. Max 30 hashtags per post (excess silently truncated).
+- **D-15:** Hashtag text in PostCard captions renders as clickable orange spans routing to `/hashtag/[tag]`. A shared `parseCaption(text)` utility splits caption into text segments and `#tag` segments.
+
+### Navigation
+- **D-16:** Explore link is added to the Sidebar (desktop) and MobileNav (bottom bar) alongside Home. Icon: `Compass` from lucide-react.
+- **D-17:** Search is accessible via a `Search` icon in the Sidebar and MobileNav, or via `/search` page directly.
+
+### Claude's Discretion
+- Exact PostCard action row layout (order of paw / comment / repost buttons)
+- Repost card visual treatment (header height, attribution text truncation)
+- Explore page grid columns (3-col mobile, 4-col desktop)
+- Cursor pagination implementation for comments (cursor = comment.createdAt ISO string)
+
+
+
+
+## Canonical References
+
+**Downstream agents MUST read these before planning or implementing.**
+
+### Phase 2 Patterns (must reuse)
+- `src/trpc/routers/pets.ts` — `assertPetOwnership` helper — replicate for all Phase 3 mutations
+- `src/trpc/routers/posts.ts` — `posts.create` pattern (Prisma nested write + fanOutPost) — repost.create follows the same structure
+- `src/lib/feed-helpers.ts` — `fanOutPost` — call on repost.create
+- `src/lib/media-url.ts` — `getMediaUrl` — use for post images in RepostCard
+- `src/__tests__/helpers/prisma-mock.ts` — mock factory — extend with Phase 3 models
+
+### Design Contract
+- `.planning/phases/03-engagement-discovery/03-UI-SPEC.md` — PostCard action row, CommentSheet, RepostCard, ExploreCard visual specs
+
+### Requirements
+- `.planning/REQUIREMENTS.md` — Phase 3 requirements: ENGM-01–04, EXPL-01, SRCH-01–02
+
+### Existing components
+- `src/components/feed/PostCard.tsx` — primary modification target for action row
+- `src/components/ui/sheet.tsx` — use for CommentSheet (existing shadcn component)
+- `src/app/(app)/feed/page.tsx` — StoryTray pattern for new nav additions
+
+
+
+
+## Existing Code Insights
+
+### PostCard action row seam
+PostCard currently renders a caption and options menu but has no action row (no like/comment/repost buttons). Phase 3 adds an action row at the bottom of each PostCard below the caption area.
+
+### Feed hydration
+`feed.getFeed` uses `prisma.post.findMany({ where: { id: { in: postIds } } })` for hydration. Add `_count: { select: { reactions: true, comments: true } }` to this include to get counts without extra queries.
+
+### assertPetOwnership pattern
+```typescript
+async function assertPetOwnership(prisma, petId, userId) {
+ const pet = await prisma.pet.findUnique({ where: { id: petId } });
+ if (!pet || pet.ownerId !== userId) throw new TRPCError({ code: "FORBIDDEN" });
+ return pet;
+}
+```
+All Phase 3 mutations assert pet ownership before writing.
+
+### Existing shadcn components available
+Sheet, Popover, Tabs, ScrollArea — already installed in Phase 2.
+
+### Key file: src/trpc/routers/_app.ts
+Every new router (reactions, comments, reposts, explore, search) must be registered here.
+
+
+
+
+## Specific Notes
+
+- Comment count on PostCard: show as number with 💬 icon; 0 is hidden (show only when >0, same for reaction count)
+- Paw button: filled paw icon when reacted, outline when not — use `fill-orange-500` vs `stroke-orange-500`
+- Repost card: shows a subtle "🐾 Pawed back by @petName" header in `text-xs text-muted-foreground`, then renders the original post content (images, caption) without the action row (no nested reposts)
+- Explore page empty state per species/breed: "No [breed] pets yet — be the first!"
+- `/hashtag/[tag]` page: same 3-col grid as ProfileTabs Posts grid
+
+
+
+
+## Deferred to Later Phases
+
+- **Optimistic UI** for reactions/comments — v1 just invalidates queries on mutation success; optimistic updates are Phase 4 polish
+- **Notification triggers** for reactions/comments — wired in Phase 4
+- **Comment moderation** (post owner deletes others' comments) — Phase 4
+- **Trending hashtags** section on Explore — Phase 4 / algorithmic feed
+- **Explore page for logged-out users** — Phase 4 (public discovery)
+
+
+
+---
+
+*Phase: 03-engagement-discovery*
+*Context gathered: 2026-06-13*
diff --git a/.planning/phases/03-engagement-discovery/03-RESEARCH.md b/.planning/phases/03-engagement-discovery/03-RESEARCH.md
new file mode 100644
index 0000000..275d822
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-RESEARCH.md
@@ -0,0 +1,359 @@
+# Phase 3: Engagement and Discovery — Research
+
+**Gathered:** 2026-06-13
+**Purpose:** Patterns, pitfalls, and code blueprints for Phase 3 implementation.
+
+---
+
+## Pattern 1: Prisma Schema Additions
+
+### New models to add to prisma/schema.prisma
+
+```prisma
+// ─── Phase 3 Enums ───────────────────────────────────────────────────────────
+
+// PostType: add REPOST (extend existing enum)
+// enum PostType {
+// PHOTO
+// MILESTONE
+// REPOST ← NEW
+// }
+
+// ─── Phase 3 Models ──────────────────────────────────────────────────────────
+
+model Reaction {
+ id String @id @default(cuid())
+ post Post @relation("PostReactions", fields: [postId], references: [id], onDelete: Cascade)
+ postId String
+ pet Pet @relation("PetReactions", fields: [petId], references: [id], onDelete: Cascade)
+ petId String
+ createdAt DateTime @default(now())
+
+ @@unique([postId, petId]) // one paw per pet per post
+ @@index([postId])
+}
+
+model Comment {
+ id String @id @default(cuid())
+ post Post @relation("PostComments", fields: [postId], references: [id], onDelete: Cascade)
+ postId String
+ pet Pet @relation("PetComments", fields: [petId], references: [id], onDelete: Cascade)
+ petId String
+ body String @db.VarChar(500)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([postId, createdAt(sort: Asc)])
+ @@index([petId])
+}
+
+model Repost {
+ post Post @relation("RepostEntry", fields: [postId], references: [id], onDelete: Cascade)
+ postId String @unique // the Post(type=REPOST) record; @unique for 1-to-1 with Post
+ originalPost Post @relation("OriginalReposts", fields: [originalPostId], references: [id])
+ originalPostId String
+ reposterPet Pet @relation("PetReposts", fields: [reposterPetId], references: [id], onDelete: Cascade)
+ reposterPetId String
+ createdAt DateTime @default(now())
+
+ @@id([reposterPetId, originalPostId]) // one repost per pet per original
+ @@index([originalPostId])
+}
+
+model Hashtag {
+ id String @id @default(cuid())
+ tag String @unique // lowercase, no # prefix, e.g. "goldenretriever"
+ posts PostHashtag[]
+
+ @@index([tag])
+}
+
+model PostHashtag {
+ post Post @relation("PostHashtags", fields: [postId], references: [id], onDelete: Cascade)
+ postId String
+ hashtag Hashtag @relation(fields: [hashtagId], references: [id], onDelete: Cascade)
+ hashtagId String
+
+ @@id([postId, hashtagId])
+ @@index([hashtagId])
+}
+```
+
+### Back-relations to add to existing models
+
+On `Post`:
+```prisma
+reactions Reaction[] @relation("PostReactions")
+comments Comment[] @relation("PostComments")
+repost Repost? @relation("RepostEntry") // this Post IS a repost
+originalReposts Repost[] @relation("OriginalReposts") // reposts of this post
+hashtags PostHashtag[] @relation("PostHashtags")
+```
+
+On `Pet`:
+```prisma
+reactions Reaction[] @relation("PetReactions")
+comments Comment[] @relation("PetComments")
+reposts Repost[] @relation("PetReposts")
+```
+
+**Pitfall 1 — Named relations required:** Post has TWO relations to Repost (`repost` and `originalReposts`). Prisma requires explicit named relations on all sides when there are multiple FK paths between two models. Without names you get "Ambiguous relation" error.
+
+**Pitfall 2 — REPOST in PostType enum:** Prisma enums are additive — adding REPOST to PostType requires `prisma db push` or a migration. No destructive change.
+
+---
+
+## Pattern 2: Hashtag Extraction
+
+```typescript
+// src/lib/hashtag-helpers.ts (server-only)
+import "server-only";
+
+const HASHTAG_REGEX = /#(\w{1,50})/g;
+const MAX_HASHTAGS = 30;
+
+export function extractHashtags(text: string | null | undefined): string[] {
+ if (!text) return [];
+ const matches: string[] = [];
+ let match: RegExpExecArray | null;
+ while ((match = HASHTAG_REGEX.exec(text)) !== null) {
+ matches.push(match[1]!.toLowerCase());
+ if (matches.length >= MAX_HASHTAGS) break;
+ }
+ // Reset lastIndex after manual exec loop
+ HASHTAG_REGEX.lastIndex = 0;
+ return [...new Set(matches)]; // deduplicate
+}
+
+export async function saveHashtags(
+ prisma: PrismaClient,
+ postId: string,
+ tags: string[]
+): Promise {
+ if (tags.length === 0) return;
+ await Promise.all(
+ tags.map(async (tag) => {
+ const hashtag = await prisma.hashtag.upsert({
+ where: { tag },
+ create: { tag },
+ update: {},
+ });
+ await prisma.postHashtag.upsert({
+ where: { postId_hashtagId: { postId, hashtagId: hashtag.id } },
+ create: { postId, hashtagId: hashtag.id },
+ update: {},
+ });
+ })
+ );
+}
+```
+
+**Pitfall 3 — Regex lastIndex:** When using a regex with the `g` flag and `exec()` in a loop, the `lastIndex` is stateful. Reset it after the loop or use `String.matchAll()` instead to avoid infinite loops on repeated calls.
+
+**Note:** Hashtag extraction runs AFTER the post is created (in the same tRPC mutation, not a separate step). If extraction fails it should NOT fail the post creation — wrap in try/catch and log.
+
+---
+
+## Pattern 3: Paw Reaction Toggle
+
+```typescript
+// reactions router
+toggle: protectedProcedure
+ .input(z.object({ postId: z.string().min(1), petId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
+ const existing = await ctx.prisma.reaction.findUnique({
+ where: { postId_petId: { postId: input.postId, petId: input.petId } },
+ });
+ if (existing) {
+ await ctx.prisma.reaction.delete({
+ where: { postId_petId: { postId: input.postId, petId: input.petId } },
+ });
+ return { reacted: false };
+ }
+ await ctx.prisma.reaction.create({ data: { postId: input.postId, petId: input.petId } });
+ return { reacted: true };
+ }),
+```
+
+**Note:** The `@@unique([postId, petId])` on the Reaction model generates a Prisma compound unique name `postId_petId`. Use this in `findUnique({ where: { postId_petId: { ... } } })`.
+
+---
+
+## Pattern 4: Repost Create
+
+```typescript
+// reposts router
+create: protectedProcedure
+ .input(z.object({ originalPostId: z.string().min(1), reposterPetId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ await assertPetOwnership(ctx.prisma, input.reposterPetId, ctx.userId);
+
+ // Idempotency: return early if already reposted
+ const existing = await ctx.prisma.repost.findUnique({
+ where: { reposterPetId_originalPostId: { reposterPetId: input.reposterPetId, originalPostId: input.originalPostId } },
+ });
+ if (existing) return { postId: existing.postId };
+
+ // Create the repost Post + Repost record in one transaction
+ const post = await ctx.prisma.post.create({
+ data: {
+ petId: input.reposterPetId,
+ type: "REPOST",
+ repost: {
+ create: {
+ originalPostId: input.originalPostId,
+ reposterPetId: input.reposterPetId,
+ },
+ },
+ },
+ });
+
+ // Fan out to followers
+ await fanOutPost(redis, post.id, input.reposterPetId, post.createdAt, ctx.prisma);
+ return { postId: post.id };
+ }),
+```
+
+**Pitfall 4 — No fanout for repost-of-repost:** Before creating, verify the original post is type PHOTO or MILESTONE (never REPOST). Throw FORBIDDEN if someone tries to repost a repost.
+
+---
+
+## Pattern 5: Explore Pet Query
+
+```typescript
+// explore router
+listPets: publicProcedure
+ .input(z.object({
+ speciesId: z.string().optional(),
+ breedId: z.string().optional(),
+ limit: z.number().int().min(1).max(40).default(20),
+ cursor: z.string().optional(), // petId cursor
+ }))
+ .query(async ({ ctx, input }) => {
+ const where: Prisma.PetWhereInput = {};
+ if (input.speciesId) where.speciesId = input.speciesId;
+ if (input.breedId) where.breedId = input.breedId;
+ if (input.cursor) where.id = { lt: input.cursor }; // cursor-based, ordered by id desc as proxy
+
+ const pets = await ctx.prisma.pet.findMany({
+ where,
+ take: input.limit + 1,
+ orderBy: { createdAt: "desc" },
+ include: {
+ species: true,
+ breed: true,
+ _count: { select: { followers: true } },
+ },
+ cursor: input.cursor ? { id: input.cursor } : undefined,
+ skip: input.cursor ? 1 : 0,
+ });
+
+ const hasNextPage = pets.length > input.limit;
+ if (hasNextPage) pets.pop();
+ return { pets, nextCursor: hasNextPage ? pets[pets.length - 1]!.id : null };
+ }),
+```
+
+**Note:** `publicProcedure` for explore is consistent with the decision (D-10) that explore is accessible without auth. Add `publicProcedure` to `src/trpc/init.ts` if not already present (check — it may exist from Phase 1 for breed queries).
+
+---
+
+## Pattern 6: Hashtag Search
+
+```typescript
+// search router
+byHashtag: protectedProcedure
+ .input(z.object({
+ tag: z.string().min(1).max(50).transform((s) => s.replace(/^#/, "").toLowerCase()),
+ limit: z.number().int().min(1).max(30).default(20),
+ cursor: z.string().optional(), // postId cursor
+ }))
+ .query(async ({ ctx, input }) => {
+ const hashtag = await ctx.prisma.hashtag.findUnique({ where: { tag: input.tag } });
+ if (!hashtag) return { posts: [], nextCursor: null };
+
+ const rows = await ctx.prisma.postHashtag.findMany({
+ where: {
+ hashtagId: hashtag.id,
+ ...(input.cursor ? { postId: { lt: input.cursor } } : {}),
+ },
+ take: input.limit + 1,
+ orderBy: { post: { createdAt: "desc" } },
+ include: {
+ post: {
+ include: {
+ pet: { select: { id: true, name: true, avatarKey: true } },
+ images: { orderBy: { position: "asc" }, take: 1 },
+ },
+ },
+ },
+ });
+
+ const hasNextPage = rows.length > input.limit;
+ if (hasNextPage) rows.pop();
+ const posts = rows.map((r) => r.post);
+ return { posts, nextCursor: hasNextPage ? posts[posts.length - 1]!.id : null };
+ }),
+```
+
+---
+
+## Pattern 7: Caption Hashtag Rendering
+
+```typescript
+// src/lib/parse-caption.ts (client-safe, no server-only)
+type CaptionSegment = { type: "text"; text: string } | { type: "hashtag"; tag: string };
+
+export function parseCaption(caption: string): CaptionSegment[] {
+ const segments: CaptionSegment[] = [];
+ const parts = caption.split(/(#\w{1,50})/g);
+ for (const part of parts) {
+ if (part.startsWith("#")) {
+ segments.push({ type: "hashtag", tag: part.slice(1).toLowerCase() });
+ } else if (part) {
+ segments.push({ type: "text", text: part });
+ }
+ }
+ return segments;
+}
+```
+
+Rendered in PostCard:
+```tsx
+{parseCaption(post.caption).map((seg, i) =>
+ seg.type === "hashtag" ? (
+
+ #{seg.tag}
+
+ ) : (
+ {seg.text}
+ )
+)}
+```
+
+---
+
+## Pitfall Summary
+
+| # | Pitfall | Fix |
+|---|---------|-----|
+| 1 | Multiple FK paths between Post and Repost cause "Ambiguous relation" | Use explicit named relations on both sides |
+| 2 | `@unique` on Repost.postId coexists with `@@id([reposterPetId, originalPostId])` | Prisma allows this — postId is an additional unique constraint |
+| 3 | Regex `g` flag + `exec()` in a loop: `lastIndex` is stateful | Reset `lastIndex = 0` after loop, or use `String.matchAll()` |
+| 4 | Repost-of-a-repost: `type=REPOST` should not be repostable | Verify `originalPost.type !== "REPOST"` before creating |
+| 5 | `publicProcedure` may not exist in `src/trpc/init.ts` | Check init.ts; add if absent — needed for explore.listPets |
+| 6 | Comment cursor: ISO string timestamps are not perfectly monotonic at millisecond granularity | Use `id` (cuid, lexicographically sortable) as cursor instead of `createdAt` |
+
+---
+
+## Package Legitimacy Audit
+
+No new npm packages required in Phase 3. All features use existing deps:
+- Prisma 7 for DB access ✓
+- tRPC 11 for API ✓
+- TanStack Query 5 for client data ✓
+- shadcn/ui Sheet, Tabs already installed ✓
+- Next.js Link for hashtag routing ✓
+
+No new installs needed in Plan 03-01.
diff --git a/.planning/phases/03-engagement-discovery/03-UI-SPEC.md b/.planning/phases/03-engagement-discovery/03-UI-SPEC.md
new file mode 100644
index 0000000..8f522bf
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-UI-SPEC.md
@@ -0,0 +1,211 @@
+# Phase 3: Engagement and Discovery — UI Spec
+
+**Authored:** 2026-06-13
+**Stack:** Next.js 15, React 19, Tailwind CSS v4, shadcn/ui (base-nova preset)
+**Constraint:** `asChild` prop is NOT available on any shadcn component in base-nova. Style triggers directly.
+
+---
+
+## 1. PostCard Action Row
+
+Appended below the caption area in `PostCard.tsx`. Replaces the current empty space after the caption.
+
+```
+[ 🐾 42 ] [ 💬 7 ] [ ↩ Paw-Back ]
+```
+
+Layout:
+- `flex items-center gap-4 px-4 py-2`
+- Paw button: `flex items-center gap-1.5 text-sm text-muted-foreground`
+ - Icon: `PawPrint` from lucide-react, `h-4 w-4`
+ - Reacted state: `fill-orange-500 text-orange-500`
+ - Default state: outline, `text-muted-foreground hover:text-orange-500 transition-colors`
+- Comment button: `flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground`
+ - Icon: `MessageCircle` from lucide-react, `h-4 w-4`
+ - Clicking opens CommentSheet
+- Paw-Back button: `ml-auto text-sm text-muted-foreground hover:text-foreground`
+ - Icon: `Repeat2` from lucide-react, `h-4 w-4`
+ - Reposted state: `text-orange-500`
+ - Hidden on `PostType.REPOST` posts (no repost-of-repost)
+- Counts: hide if 0 (`{count > 0 ? count : null}`)
+
+---
+
+## 2. CommentSheet
+
+Bottom sheet (`Sheet` from shadcn/ui) triggered by the comment button.
+
+**Structure:**
+```
+SheetContent side="bottom" className="h-[80vh] flex flex-col"
+ SheetHeader
+ SheetTitle: "Comments" (left-aligned)
+ ScrollArea className="flex-1 px-4"
+ [CommentItem × n]
+ {comments.length === 0 && "No comments yet — be the first!"}
+ Separator
+ CommentInput (sticky at bottom)
+```
+
+**CommentItem:**
+```
+div className="flex gap-3 py-3"
+ Avatar size 32px
+ div
+ span className="text-sm font-medium" — pet name
+ span className="text-xs text-muted-foreground ml-2" — relative time
+ p className="text-sm mt-0.5" — body
+ {isOwn && TrashButton}
+```
+
+**CommentInput:**
+```
+div className="flex gap-2 px-4 py-3 bg-background"
+ Textarea rows=1 maxLength=500 placeholder="Add a comment…" className="flex-1 resize-none min-h-[40px]"
+ Button size="sm" bg-orange-500 disabled={body.trim().length === 0 || isPending}
+ "Post" or
+```
+
+---
+
+## 3. RepostCard
+
+When `post.type === "REPOST"`, PostCard renders `` instead of the normal card layout.
+
+**Structure:**
+```
+Card className="rounded-2xl overflow-hidden"
+ div className="flex items-center gap-2 px-4 pt-3 pb-1"
+ PawPrint icon h-3.5 w-3.5 text-orange-400
+ Link href="/pets/[reposterPetId]" className="text-xs text-muted-foreground hover:underline"
+ "Pawed-back by @{reposterPetName}"
+ Separator className="mx-4"
+ [Original post content: pet header, image carousel, caption — no action row]
+```
+
+**Key detail:** RepostCard shows the **original** post content (original pet name, original images, original caption). The reposter is shown only in the header. No action row on the nested original — reactions/comments belong on the original post, not the repost wrapper.
+
+---
+
+## 4. ExploreCard
+
+Used in the `/explore` pet grid.
+
+```
+div className="flex flex-col items-center gap-2 p-3 rounded-xl border bg-card hover:bg-muted/50 transition-colors"
+ Avatar 56px
+ p className="text-sm font-medium text-center truncate max-w-full" — pet name
+ Badge variant="secondary" className="text-xs" — breed name (or species if no breed)
+ p className="text-xs text-muted-foreground" — "{n} followers"
+ FollowButton (existing component) or "Your pet" if own
+```
+
+Grid: `grid grid-cols-3 gap-3 sm:grid-cols-4`
+
+---
+
+## 5. Explore Page `/explore`
+
+```
+page layout: max-w-[640px] mx-auto px-4 py-6
+
+h1: "Explore" text-xl font-semibold mb-4
+
+Species tabs (shadcn Tabs):
+ TabsList: "All" | "Dogs" | "Cats" | "Birds"
+ active tab: orange-500 underline (same as ProfileTabs pattern)
+
+Breed filter (within active species tab):
+ Select / Popover with breed list for the active species
+ Shows only when a species tab other than "All" is active
+ Cleared when switching species tabs
+
+Pet grid: ExploreCard ×n, 3-col
+
+Load More button: "Load more" bg-muted centered, or infinite scroll via IntersectionObserver (existing react-intersection-observer dep)
+
+Empty state:
+ text-sm text-muted-foreground text-center py-10
+ "No [breed/species] pets yet"
+```
+
+---
+
+## 6. Search Page `/search`
+
+```
+page layout: max-w-[640px] mx-auto px-4 py-6
+
+Search input:
+ div className="relative"
+ Search icon absolute left-3 top-3 h-4 w-4 text-muted-foreground
+ Input placeholder="Search pets or hashtags…" className="pl-9"
+
+Result tabs (shadcn Tabs):
+ "Pets" | "Tags"
+ default: "Pets"
+
+Pets tab:
+ PetSearchResultItem × n:
+ Avatar 40px + pet name + breed badge + "Follow" button
+ No results: "No pets found for '{query}'"
+
+Tags tab:
+ Input updates the query; tag is auto-normalized (#stripped, lowercase)
+ Results: same 3-col post grid as ProfileTabs Posts grid
+ No results: "No posts tagged #{tag}"
+```
+
+---
+
+## 7. Hashtag Page `/hashtag/[tag]`
+
+```
+page layout: max-w-[640px] mx-auto px-4 py-6
+
+h1: "#{tag}" text-xl font-semibold mb-1
+p className="text-sm text-muted-foreground mb-6": "{n} posts"
+
+3-col post grid (same as ProfileTabs Posts grid):
+ aspect-square overflow-hidden bg-muted
+ img object-cover
+ Layers2 overlay on carousels
+
+Empty state:
+ "No posts tagged #{tag} yet"
+```
+
+---
+
+## 8. Nav additions
+
+**Sidebar (desktop):**
+```
+NavItem href="/explore" icon=Compass label="Explore" — add after "Feed"
+NavItem href="/search" icon=Search label="Search" — add after "Explore"
+```
+
+**MobileNav (bottom bar):**
+```
+Current order: Home | Pets | + | [placeholder] | Profile
+New order: Home | Explore | + | Search | Profile
+```
+
+---
+
+## Copy Strings
+
+| Location | String |
+|---|---|
+| Paw button aria-label (not reacted) | "Paw this post" |
+| Paw button aria-label (reacted) | "Remove paw" |
+| Comment button aria-label | "View comments" |
+| Paw-Back button aria-label | "Paw-Back this post" |
+| CommentSheet title | "Comments" |
+| Comment input placeholder | "Add a comment…" |
+| Comment submit button | "Post" |
+| Repost header | "Pawed-back by @{name}" |
+| Explore page title | "Explore" |
+| Search placeholder | "Search pets or hashtags…" |
+| Explore empty state | "No {breed/species} pets yet" |
+| Hashtag page: no posts | "No posts tagged #{tag} yet" |
diff --git a/.planning/phases/03-engagement-discovery/03-VALIDATION.md b/.planning/phases/03-engagement-discovery/03-VALIDATION.md
new file mode 100644
index 0000000..4dfcb08
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-VALIDATION.md
@@ -0,0 +1,54 @@
+# Phase 3: Engagement and Discovery — Validation Architecture
+
+**Authored:** 2026-06-13
+
+## Test Strategy
+
+Phase 3 follows the same TDD pattern as Phase 2: RED commit (failing tests) first, then GREEN commit (implementation). All tests are unit tests — Prisma and Redis mocked via `createMockPrisma()`, no real DB or network calls.
+
+## Test Files
+
+| File | Tests |
+|---|---|
+| `src/__tests__/reactions.test.ts` | ENGM-01: toggle creates/deletes, toggle forbidden non-owner, duplicate paw is idempotent (delete path), count query |
+| `src/__tests__/comments.test.ts` | ENGM-02: create comment, ENGM-03: delete own comment succeeds, delete other's comment FORBIDDEN |
+| `src/__tests__/reposts.test.ts` | ENGM-04: create repost (creates Post + Repost + fanout), repost-of-repost FORBIDDEN, second repost idempotent |
+| `src/__tests__/explore.test.ts` | EXPL-01: listPets returns all, listPets filters by speciesId, cursor pagination |
+| `src/__tests__/search.test.ts` | SRCH-01: byHashtag returns posts, unknown tag returns empty, SRCH-02: pets search by name, pets search by breed |
+
+## Per-Plan Verification Commands
+
+### Plan 03-01 (Schema + Stubs)
+```bash
+npx prisma validate
+npx prisma generate
+npx tsc --noEmit
+npx vitest run src/__tests__/reactions.test.ts src/__tests__/comments.test.ts src/__tests__/reposts.test.ts src/__tests__/explore.test.ts src/__tests__/search.test.ts
+```
+
+### Plans 03-02 through 03-06 (Implementation)
+```bash
+npx tsc --noEmit
+npx vitest run # full suite — must stay green
+```
+
+## Requirements → Test Map
+
+| Requirement | Test |
+|---|---|
+| ENGM-01 (paw react) | reactions.test.ts: "toggle creates reaction", "toggle removes if exists", "FORBIDDEN non-owner" |
+| ENGM-02 (comment) | comments.test.ts: "create comment returns body", "count increments" |
+| ENGM-03 (delete own comment) | comments.test.ts: "delete own comment ok", "delete other comment FORBIDDEN" |
+| ENGM-04 (paw-back) | reposts.test.ts: "create repost creates REPOST post", "repost-of-repost FORBIDDEN", "idempotent second repost" |
+| EXPL-01 (explore) | explore.test.ts: "listPets filters by speciesId", "cursor pagination" |
+| SRCH-01 (hashtag search) | search.test.ts: "byHashtag returns posts", "unknown tag returns empty array" |
+| SRCH-02 (pet search) | search.test.ts: "pets matches name", "pets matches breed name" |
+
+## Prisma Mock Extensions (Plan 03-01)
+
+`src/__tests__/helpers/prisma-mock.ts` must be extended with:
+- `reaction` — findUnique, create, delete
+- `comment` — create, findMany, findUnique, delete
+- `repost` — findUnique, create
+- `hashtag` — findUnique, upsert
+- `postHashtag` — upsert
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 7e6468a..22c33b3 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -54,6 +54,11 @@ model Pet {
blockedBy Block[] @relation("Blocked")
reports Report[] @relation("Reporter")
+ // Phase 3 back-relations
+ reactions Reaction[] @relation("PetReactions")
+ comments Comment[] @relation("PetComments")
+ reposts Repost[] @relation("PetReposts")
+
@@index([ownerId])
}
@@ -62,6 +67,7 @@ model Pet {
enum PostType {
PHOTO
MILESTONE
+ REPOST
}
enum MilestoneType {
@@ -104,6 +110,13 @@ model Post {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ // Phase 3 back-relations
+ reactions Reaction[] @relation("PostReactions")
+ comments Comment[] @relation("PostComments")
+ repost Repost? @relation("RepostEntry")
+ originalReposts Repost[] @relation("OriginalReposts")
+ hashtags PostHashtag[] @relation("PostHashtags")
+
@@index([petId, createdAt(sort: Desc)])
}
@@ -186,3 +199,62 @@ model Report {
@@index([reporterPetId])
}
+
+// ─── Phase 3 Models ──────────────────────────────────────────────────────────
+
+model Reaction {
+ id String @id @default(cuid())
+ post Post @relation("PostReactions", fields: [postId], references: [id], onDelete: Cascade)
+ postId String
+ pet Pet @relation("PetReactions", fields: [petId], references: [id], onDelete: Cascade)
+ petId String
+ createdAt DateTime @default(now())
+
+ @@unique([postId, petId])
+ @@index([postId])
+}
+
+model Comment {
+ id String @id @default(cuid())
+ post Post @relation("PostComments", fields: [postId], references: [id], onDelete: Cascade)
+ postId String
+ pet Pet @relation("PetComments", fields: [petId], references: [id], onDelete: Cascade)
+ petId String
+ body String @db.VarChar(500)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([postId, createdAt(sort: Asc)])
+ @@index([petId])
+}
+
+model Repost {
+ post Post @relation("RepostEntry", fields: [postId], references: [id], onDelete: Cascade)
+ postId String @unique
+ originalPost Post @relation("OriginalReposts", fields: [originalPostId], references: [id])
+ originalPostId String
+ reposterPet Pet @relation("PetReposts", fields: [reposterPetId], references: [id], onDelete: Cascade)
+ reposterPetId String
+ createdAt DateTime @default(now())
+
+ @@id([reposterPetId, originalPostId])
+ @@index([originalPostId])
+}
+
+model Hashtag {
+ id String @id @default(cuid())
+ tag String @unique
+ posts PostHashtag[]
+
+ @@index([tag])
+}
+
+model PostHashtag {
+ post Post @relation("PostHashtags", fields: [postId], references: [id], onDelete: Cascade)
+ postId String
+ hashtag Hashtag @relation(fields: [hashtagId], references: [id], onDelete: Cascade)
+ hashtagId String
+
+ @@id([postId, hashtagId])
+ @@index([hashtagId])
+}
diff --git a/src/__tests__/comments.test.ts b/src/__tests__/comments.test.ts
new file mode 100644
index 0000000..ec59448
--- /dev/null
+++ b/src/__tests__/comments.test.ts
@@ -0,0 +1,125 @@
+/**
+ * Comments Unit Tests — Phase 3 Plan 03-03
+ *
+ * Covers: ENGM-02 (comment on post), ENGM-03 (delete own comment)
+ *
+ * Tests:
+ * - create returns comment with body
+ * - delete own comment succeeds
+ * - delete other's comment throws FORBIDDEN
+ */
+
+import { describe, it, expect, vi } from "vitest";
+import { TRPCError } from "@trpc/server";
+
+vi.mock("server-only", () => ({}));
+vi.mock("@clerk/nextjs/server", () => ({
+ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
+}));
+vi.mock("@/lib/redis", () => ({ redis: {} }));
+vi.mock("@/lib/feed-helpers", () => ({
+ fanOutPost: vi.fn().mockResolvedValue(undefined),
+ backfillOnFollow: vi.fn().mockResolvedValue(undefined),
+ feedKey: vi.fn((id: string) => `feed:${id}`),
+ getFeedPage: vi.fn(),
+ trimFeed: vi.fn(),
+ PAGE_SIZE: 20,
+ FEED_RETENTION_MS: 90 * 24 * 60 * 60 * 1000,
+}));
+vi.mock("@/lib/prisma", () => ({ prisma: {} }));
+
+import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
+
+async function createCommentsCaller(overrides?: {
+ userId?: string | null;
+ prisma?: ReturnType;
+}) {
+ const { commentsRouter } = await import("@/trpc/routers/comments");
+ const { initTRPC } = await import("@trpc/server");
+
+ const mockPrisma = overrides?.prisma ?? createMockPrisma();
+ const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1";
+
+ const t = initTRPC
+ .context<{ userId: string | null; prisma: typeof mockPrisma }>()
+ .create();
+
+ const testRouter = t.router({ comments: commentsRouter });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const caller = testRouter.createCaller({ userId, prisma: mockPrisma as any });
+ return { caller, mockPrisma };
+}
+
+// ─── comments.create ─────────────────────────────────────────────────────────
+
+describe("comments.create", () => {
+ it("ENGM-02 — create returns comment with correct body", async () => {
+ const { caller, mockPrisma } = await createCommentsCaller();
+
+ const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" };
+ mockPrisma.pet.findUnique.mockResolvedValue(mockPet);
+ mockPrisma.comment.create.mockResolvedValue({
+ id: "cmt-1",
+ postId: "post-1",
+ petId: "pet-1",
+ body: "Cute pup!",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ pet: { id: "pet-1", name: "Buddy", avatarKey: null },
+ });
+
+ const result = await caller.comments.create({
+ postId: "post-1",
+ petId: "pet-1",
+ body: "Cute pup!",
+ });
+
+ expect(mockPrisma.comment.create).toHaveBeenCalled();
+ expect(result).toMatchObject({ body: "Cute pup!", petId: "pet-1" });
+ });
+});
+
+// ─── comments.delete ─────────────────────────────────────────────────────────
+
+describe("comments.delete", () => {
+ it("ENGM-03 — delete own comment succeeds", async () => {
+ const { caller, mockPrisma } = await createCommentsCaller();
+
+ mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1", name: "Buddy" });
+ mockPrisma.comment.findUnique.mockResolvedValue({
+ id: "cmt-1",
+ postId: "post-1",
+ petId: "pet-1",
+ body: "Cute pup!",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ mockPrisma.comment.delete.mockResolvedValue({ id: "cmt-1" });
+
+ await expect(
+ caller.comments.delete({ commentId: "cmt-1", petId: "pet-1" })
+ ).resolves.not.toThrow();
+
+ expect(mockPrisma.comment.delete).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { id: "cmt-1" } })
+ );
+ });
+
+ it("ENGM-03 — delete other's comment throws FORBIDDEN", async () => {
+ const { caller, mockPrisma } = await createCommentsCaller();
+
+ mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1", name: "Buddy" });
+ mockPrisma.comment.findUnique.mockResolvedValue({
+ id: "cmt-99",
+ postId: "post-1",
+ petId: "pet-2", // different pet
+ body: "Someone else's comment",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+
+ await expect(
+ caller.comments.delete({ commentId: "cmt-99", petId: "pet-1" })
+ ).rejects.toThrow(TRPCError);
+ });
+});
diff --git a/src/__tests__/explore.test.ts b/src/__tests__/explore.test.ts
new file mode 100644
index 0000000..5db77bd
--- /dev/null
+++ b/src/__tests__/explore.test.ts
@@ -0,0 +1,106 @@
+/**
+ * Explore Unit Tests — Phase 3 Plan 03-05
+ *
+ * Covers: EXPL-01 (explore page — browse pets by species/breed)
+ *
+ * Tests:
+ * - listPets returns all pets when no filter
+ * - listPets filters by speciesId
+ * - cursor pagination: returns nextCursor when more results exist
+ */
+
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("server-only", () => ({}));
+vi.mock("@clerk/nextjs/server", () => ({
+ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
+}));
+vi.mock("@/lib/redis", () => ({ redis: {} }));
+vi.mock("@/lib/feed-helpers", () => ({
+ fanOutPost: vi.fn().mockResolvedValue(undefined),
+ backfillOnFollow: vi.fn().mockResolvedValue(undefined),
+ feedKey: vi.fn((id: string) => `feed:${id}`),
+ getFeedPage: vi.fn(),
+ trimFeed: vi.fn(),
+ PAGE_SIZE: 20,
+ FEED_RETENTION_MS: 90 * 24 * 60 * 60 * 1000,
+}));
+vi.mock("@/lib/prisma", () => ({ prisma: {} }));
+
+import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
+
+async function createExploreCaller(overrides?: {
+ prisma?: ReturnType;
+}) {
+ const { exploreRouter } = await import("@/trpc/routers/explore");
+ const { initTRPC } = await import("@trpc/server");
+
+ const mockPrisma = overrides?.prisma ?? createMockPrisma();
+
+ const t = initTRPC
+ .context<{ userId: string | null; prisma: typeof mockPrisma }>()
+ .create();
+
+ const testRouter = t.router({ explore: exploreRouter });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const caller = testRouter.createCaller({ userId: "owner-1", prisma: mockPrisma as any });
+ return { caller, mockPrisma };
+}
+
+const makePet = (id: string, speciesId: string) => ({
+ id,
+ name: `Pet ${id}`,
+ speciesId,
+ breedId: null,
+ avatarKey: null,
+ ownerId: "owner-1",
+ createdAt: new Date(),
+ species: { id: speciesId, name: "Dog" },
+ breed: null,
+ _count: { followers: 10 },
+});
+
+// ─── explore.listPets ────────────────────────────────────────────────────────
+
+describe("explore.listPets", () => {
+ it("EXPL-01 — returns all pets when no filter applied", async () => {
+ const { caller, mockPrisma } = await createExploreCaller();
+
+ const pets = [makePet("pet-1", "species-dog"), makePet("pet-2", "species-cat")];
+ mockPrisma.pet.findMany.mockResolvedValue(pets);
+
+ const result = await caller.explore.listPets({ limit: 20 });
+
+ expect(result.pets).toHaveLength(2);
+ expect(result.nextCursor).toBeNull();
+ });
+
+ it("EXPL-01 — filters by speciesId when provided", async () => {
+ const { caller, mockPrisma } = await createExploreCaller();
+
+ const pets = [makePet("pet-1", "species-dog")];
+ mockPrisma.pet.findMany.mockResolvedValue(pets);
+
+ const result = await caller.explore.listPets({ speciesId: "species-dog", limit: 20 });
+
+ expect(mockPrisma.pet.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ speciesId: "species-dog" }),
+ })
+ );
+ expect(result.pets).toHaveLength(1);
+ });
+
+ it("EXPL-01 — cursor pagination: returns nextCursor when more results exist", async () => {
+ const { caller, mockPrisma } = await createExploreCaller();
+
+ // Return limit+1 pets to trigger nextCursor
+ const pets = Array.from({ length: 21 }, (_, i) => makePet(`pet-${i}`, "species-dog"));
+ mockPrisma.pet.findMany.mockResolvedValue(pets);
+
+ const result = await caller.explore.listPets({ limit: 20 });
+
+ expect(result.pets).toHaveLength(20);
+ expect(result.nextCursor).not.toBeNull();
+ });
+});
diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts
index 66189ac..1d98652 100644
--- a/src/__tests__/helpers/prisma-mock.ts
+++ b/src/__tests__/helpers/prisma-mock.ts
@@ -113,6 +113,39 @@ interface MockReportClient {
count: MockFn;
}
+// ─── Phase 3 mock interfaces ──────────────────────────────────────────────────
+
+interface MockReactionClient {
+ findUnique: MockFn;
+ create: MockFn;
+ delete: MockFn;
+ count: MockFn;
+}
+
+interface MockCommentClient {
+ create: MockFn;
+ findMany: MockFn;
+ findUnique: MockFn;
+ delete: MockFn;
+ count: MockFn;
+}
+
+interface MockRepostClient {
+ findUnique: MockFn;
+ create: MockFn;
+ delete: MockFn;
+}
+
+interface MockHashtagClient {
+ findUnique: MockFn;
+ upsert: MockFn;
+}
+
+interface MockPostHashtagClient {
+ upsert: MockFn;
+ findMany: MockFn;
+}
+
interface MockPrismaClient {
pet: MockPetClient;
owner: MockOwnerClient;
@@ -127,6 +160,12 @@ interface MockPrismaClient {
follow: MockFollowClient;
block: MockBlockClient;
report: MockReportClient;
+ // Phase 3
+ reaction: MockReactionClient;
+ comment: MockCommentClient;
+ repost: MockRepostClient;
+ hashtag: MockHashtagClient;
+ postHashtag: MockPostHashtagClient;
$transaction: MockFn;
$disconnect: MockFn;
}
@@ -219,6 +258,33 @@ export function createMockPrisma(): MockPrismaClient {
findUnique: vi.fn(),
count: vi.fn(),
},
+ // Phase 3 mocks
+ reaction: {
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ delete: vi.fn(),
+ count: vi.fn(),
+ },
+ comment: {
+ create: vi.fn(),
+ findMany: vi.fn(),
+ findUnique: vi.fn(),
+ delete: vi.fn(),
+ count: vi.fn(),
+ },
+ repost: {
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ delete: vi.fn(),
+ },
+ hashtag: {
+ findUnique: vi.fn(),
+ upsert: vi.fn(),
+ },
+ postHashtag: {
+ upsert: vi.fn(),
+ findMany: vi.fn(),
+ },
$transaction: vi.fn(),
$disconnect: vi.fn(),
};
diff --git a/src/__tests__/reactions.test.ts b/src/__tests__/reactions.test.ts
new file mode 100644
index 0000000..5fb64c1
--- /dev/null
+++ b/src/__tests__/reactions.test.ts
@@ -0,0 +1,125 @@
+/**
+ * Reactions Unit Tests — Phase 3 Plan 03-02
+ *
+ * Covers: ENGM-01 (paw reactions)
+ *
+ * Tests:
+ * - toggle creates reaction when none exists → reacted: true
+ * - toggle removes reaction when already exists → reacted: false
+ * - FORBIDDEN for non-owner petId
+ * - getCount returns reaction count for a post
+ */
+
+import { describe, it, expect, vi } from "vitest";
+import { TRPCError } from "@trpc/server";
+
+vi.mock("server-only", () => ({}));
+vi.mock("@clerk/nextjs/server", () => ({
+ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
+}));
+vi.mock("@/lib/redis", () => ({ redis: {} }));
+vi.mock("@/lib/feed-helpers", () => ({
+ fanOutPost: vi.fn().mockResolvedValue(undefined),
+ backfillOnFollow: vi.fn().mockResolvedValue(undefined),
+ feedKey: vi.fn((id: string) => `feed:${id}`),
+ getFeedPage: vi.fn(),
+ trimFeed: vi.fn(),
+ PAGE_SIZE: 20,
+ FEED_RETENTION_MS: 90 * 24 * 60 * 60 * 1000,
+}));
+vi.mock("@/lib/prisma", () => ({ prisma: {} }));
+
+import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
+
+async function createReactionsCaller(overrides?: {
+ userId?: string | null;
+ prisma?: ReturnType;
+}) {
+ const { reactionsRouter } = await import("@/trpc/routers/reactions");
+ const { initTRPC } = await import("@trpc/server");
+
+ const mockPrisma = overrides?.prisma ?? createMockPrisma();
+ const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1";
+
+ const t = initTRPC
+ .context<{ userId: string | null; prisma: typeof mockPrisma }>()
+ .create();
+
+ const testRouter = t.router({ reactions: reactionsRouter });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const caller = testRouter.createCaller({ userId, prisma: mockPrisma as any });
+ return { caller, mockPrisma };
+}
+
+// ─── reactions.toggle ────────────────────────────────────────────────────────
+
+describe("reactions.toggle", () => {
+ it("ENGM-01 — toggle creates reaction when none exists → reacted: true", async () => {
+ const { caller, mockPrisma } = await createReactionsCaller();
+
+ mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1", name: "Buddy" });
+ mockPrisma.reaction.findUnique.mockResolvedValue(null);
+ mockPrisma.reaction.create.mockResolvedValue({
+ id: "rxn-1",
+ postId: "post-1",
+ petId: "pet-1",
+ createdAt: new Date(),
+ });
+
+ const result = await caller.reactions.toggle({ postId: "post-1", petId: "pet-1" });
+
+ expect(mockPrisma.reaction.create).toHaveBeenCalledWith(
+ expect.objectContaining({ data: { postId: "post-1", petId: "pet-1" } })
+ );
+ expect(result).toMatchObject({ reacted: true });
+ });
+
+ it("ENGM-01 — toggle removes reaction when already exists → reacted: false", async () => {
+ const { caller, mockPrisma } = await createReactionsCaller();
+
+ mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1", name: "Buddy" });
+ mockPrisma.reaction.findUnique.mockResolvedValue({
+ id: "rxn-1",
+ postId: "post-1",
+ petId: "pet-1",
+ createdAt: new Date(),
+ });
+ mockPrisma.reaction.delete.mockResolvedValue({ id: "rxn-1" });
+
+ const result = await caller.reactions.toggle({ postId: "post-1", petId: "pet-1" });
+
+ expect(mockPrisma.reaction.delete).toHaveBeenCalled();
+ expect(result).toMatchObject({ reacted: false });
+ });
+
+ it("ENGM-01 — FORBIDDEN when petId not owned by caller", async () => {
+ const { caller, mockPrisma } = await createReactionsCaller();
+
+ mockPrisma.pet.findUnique.mockResolvedValue({
+ id: "pet-99",
+ ownerId: "other-owner",
+ name: "Other",
+ });
+
+ await expect(
+ caller.reactions.toggle({ postId: "post-1", petId: "pet-99" })
+ ).rejects.toThrow(TRPCError);
+ });
+});
+
+// ─── reactions.getCount ───────────────────────────────────────────────────────
+
+describe("reactions.getCount", () => {
+ it("ENGM-01 — getCount returns the reaction count for a post", async () => {
+ const { caller, mockPrisma } = await createReactionsCaller();
+
+ mockPrisma.reaction.count.mockResolvedValue(7);
+
+ const count = await caller.reactions.getCount({ postId: "post-1" });
+
+ expect(mockPrisma.reaction.count).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { postId: "post-1" } })
+ );
+ expect(count).toBe(7);
+ });
+});
diff --git a/src/__tests__/reposts.test.ts b/src/__tests__/reposts.test.ts
new file mode 100644
index 0000000..d12eda1
--- /dev/null
+++ b/src/__tests__/reposts.test.ts
@@ -0,0 +1,117 @@
+/**
+ * Reposts Unit Tests — Phase 3 Plan 03-04
+ *
+ * Covers: ENGM-04 (paw-back / repost)
+ *
+ * Tests:
+ * - create repost creates Post(type=REPOST) + Repost + fanOut
+ * - repost-of-repost is FORBIDDEN
+ * - second repost is idempotent (returns existing postId)
+ */
+
+import { describe, it, expect, vi } from "vitest";
+import { TRPCError } from "@trpc/server";
+
+vi.mock("server-only", () => ({}));
+vi.mock("@clerk/nextjs/server", () => ({
+ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
+}));
+vi.mock("@/lib/redis", () => ({ redis: {} }));
+
+const mockFanOutPost = vi.fn().mockResolvedValue(undefined);
+vi.mock("@/lib/feed-helpers", () => ({
+ fanOutPost: mockFanOutPost,
+ backfillOnFollow: vi.fn().mockResolvedValue(undefined),
+ feedKey: vi.fn((id: string) => `feed:${id}`),
+ getFeedPage: vi.fn(),
+ trimFeed: vi.fn(),
+ PAGE_SIZE: 20,
+ FEED_RETENTION_MS: 90 * 24 * 60 * 60 * 1000,
+}));
+vi.mock("@/lib/prisma", () => ({ prisma: {} }));
+
+import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
+
+async function createRepostsCaller(overrides?: {
+ userId?: string | null;
+ prisma?: ReturnType;
+}) {
+ const { repostsRouter } = await import("@/trpc/routers/reposts");
+ const { initTRPC } = await import("@trpc/server");
+
+ const mockPrisma = overrides?.prisma ?? createMockPrisma();
+ const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1";
+
+ const t = initTRPC
+ .context<{ userId: string | null; prisma: typeof mockPrisma }>()
+ .create();
+
+ const testRouter = t.router({ reposts: repostsRouter });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const caller = testRouter.createCaller({ userId, prisma: mockPrisma as any });
+ return { caller, mockPrisma };
+}
+
+// ─── reposts.create ──────────────────────────────────────────────────────────
+
+describe("reposts.create", () => {
+ it("ENGM-04 — create repost creates Post(type=REPOST) and calls fanOutPost", async () => {
+ const { caller, mockPrisma } = await createRepostsCaller();
+
+ mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1", name: "Buddy" });
+ mockPrisma.post.findUnique.mockResolvedValue({ id: "orig-1", type: "PHOTO", petId: "pet-2" });
+ mockPrisma.repost.findUnique.mockResolvedValue(null); // no existing repost
+ const createdAt = new Date();
+ mockPrisma.post.create.mockResolvedValue({
+ id: "repost-1",
+ petId: "pet-1",
+ type: "REPOST",
+ createdAt,
+ });
+
+ const result = await caller.reposts.create({
+ originalPostId: "orig-1",
+ reposterPetId: "pet-1",
+ });
+
+ expect(mockPrisma.post.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ type: "REPOST", petId: "pet-1" }),
+ })
+ );
+ expect(mockFanOutPost).toHaveBeenCalled();
+ expect(result).toMatchObject({ postId: "repost-1" });
+ });
+
+ it("ENGM-04 — repost-of-repost is FORBIDDEN", async () => {
+ const { caller, mockPrisma } = await createRepostsCaller();
+
+ mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1", name: "Buddy" });
+ mockPrisma.post.findUnique.mockResolvedValue({ id: "orig-1", type: "REPOST", petId: "pet-2" });
+
+ await expect(
+ caller.reposts.create({ originalPostId: "orig-1", reposterPetId: "pet-1" })
+ ).rejects.toThrow(TRPCError);
+ });
+
+ it("ENGM-04 — second repost is idempotent: returns existing postId without duplicate create", async () => {
+ const { caller, mockPrisma } = await createRepostsCaller();
+
+ mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1", name: "Buddy" });
+ mockPrisma.post.findUnique.mockResolvedValue({ id: "orig-1", type: "PHOTO", petId: "pet-2" });
+ mockPrisma.repost.findUnique.mockResolvedValue({
+ postId: "existing-repost-1",
+ originalPostId: "orig-1",
+ reposterPetId: "pet-1",
+ createdAt: new Date(),
+ });
+
+ const result = await caller.reposts.create({
+ originalPostId: "orig-1",
+ reposterPetId: "pet-1",
+ });
+
+ expect(mockPrisma.post.create).not.toHaveBeenCalled();
+ expect(result).toMatchObject({ postId: "existing-repost-1" });
+ });
+});
diff --git a/src/__tests__/search.test.ts b/src/__tests__/search.test.ts
new file mode 100644
index 0000000..610d26d
--- /dev/null
+++ b/src/__tests__/search.test.ts
@@ -0,0 +1,127 @@
+/**
+ * Search Unit Tests — Phase 3 Plans 03-05 + 03-06
+ *
+ * Covers: SRCH-01 (hashtag search), SRCH-02 (pet name/breed search)
+ *
+ * Tests:
+ * - pets: matches by name ILIKE
+ * - pets: matches by breed name
+ * - byHashtag: returns posts for known tag
+ * - byHashtag: returns empty for unknown tag
+ */
+
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("server-only", () => ({}));
+vi.mock("@clerk/nextjs/server", () => ({
+ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
+}));
+vi.mock("@/lib/redis", () => ({ redis: {} }));
+vi.mock("@/lib/feed-helpers", () => ({
+ fanOutPost: vi.fn().mockResolvedValue(undefined),
+ backfillOnFollow: vi.fn().mockResolvedValue(undefined),
+ feedKey: vi.fn((id: string) => `feed:${id}`),
+ getFeedPage: vi.fn(),
+ trimFeed: vi.fn(),
+ PAGE_SIZE: 20,
+ FEED_RETENTION_MS: 90 * 24 * 60 * 60 * 1000,
+}));
+vi.mock("@/lib/prisma", () => ({ prisma: {} }));
+
+import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
+
+async function createSearchCaller(overrides?: {
+ prisma?: ReturnType;
+}) {
+ const { searchRouter } = await import("@/trpc/routers/search");
+ const { initTRPC } = await import("@trpc/server");
+
+ const mockPrisma = overrides?.prisma ?? createMockPrisma();
+
+ const t = initTRPC
+ .context<{ userId: string | null; prisma: typeof mockPrisma }>()
+ .create();
+
+ const testRouter = t.router({ search: searchRouter });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const caller = testRouter.createCaller({ userId: "owner-1", prisma: mockPrisma as any });
+ return { caller, mockPrisma };
+}
+
+// ─── search.pets ─────────────────────────────────────────────────────────────
+
+describe("search.pets", () => {
+ it("SRCH-02 — matches pets by name", async () => {
+ const { caller, mockPrisma } = await createSearchCaller();
+
+ const mockPets = [
+ { id: "pet-1", name: "Buddy", species: { name: "Dog" }, breed: null, _count: { followers: 3 } },
+ ];
+ mockPrisma.pet.findMany.mockResolvedValue(mockPets);
+
+ const result = await caller.search.pets({ query: "Buddy" });
+
+ expect(mockPrisma.pet.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ OR: expect.arrayContaining([
+ expect.objectContaining({ name: expect.objectContaining({ contains: "Buddy" }) }),
+ ]),
+ }),
+ })
+ );
+ expect(result.pets).toHaveLength(1);
+ expect(result.pets[0].name).toBe("Buddy");
+ });
+
+ it("SRCH-02 — matches pets by breed name", async () => {
+ const { caller, mockPrisma } = await createSearchCaller();
+
+ const mockPets = [
+ { id: "pet-2", name: "Max", species: { name: "Dog" }, breed: { name: "Golden Retriever" }, _count: { followers: 5 } },
+ ];
+ mockPrisma.pet.findMany.mockResolvedValue(mockPets);
+
+ const result = await caller.search.pets({ query: "Golden" });
+
+ expect(result.pets).toHaveLength(1);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect((result.pets[0] as any).breed?.name).toBe("Golden Retriever");
+ });
+});
+
+// ─── search.byHashtag ────────────────────────────────────────────────────────
+
+describe("search.byHashtag", () => {
+ it("SRCH-01 — returns posts for a known hashtag", async () => {
+ const { caller, mockPrisma } = await createSearchCaller();
+
+ mockPrisma.hashtag.findUnique.mockResolvedValue({ id: "ht-1", tag: "goldenretriever" });
+ const mockPost = {
+ id: "post-1",
+ petId: "pet-1",
+ type: "PHOTO",
+ caption: "#goldenretriever love",
+ createdAt: new Date(),
+ pet: { id: "pet-1", name: "Buddy", avatarKey: null },
+ images: [{ storageKey: "posts/img1.jpg", position: 0 }],
+ };
+ mockPrisma.postHashtag.findMany.mockResolvedValue([{ post: mockPost }]);
+
+ const result = await caller.search.byHashtag({ tag: "goldenretriever" });
+
+ expect(result.posts).toHaveLength(1);
+ expect(result.nextCursor).toBeNull();
+ });
+
+ it("SRCH-01 — returns empty array for unknown hashtag", async () => {
+ const { caller, mockPrisma } = await createSearchCaller();
+
+ mockPrisma.hashtag.findUnique.mockResolvedValue(null);
+
+ const result = await caller.search.byHashtag({ tag: "nonexistenttag" });
+
+ expect(result.posts).toHaveLength(0);
+ expect(result.nextCursor).toBeNull();
+ });
+});
diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts
index fe33767..0621ef1 100644
--- a/src/trpc/routers/_app.ts
+++ b/src/trpc/routers/_app.ts
@@ -8,6 +8,11 @@ import { followsRouter } from "@/trpc/routers/follows";
import { blocksRouter } from "@/trpc/routers/blocks";
import { reportsRouter } from "@/trpc/routers/reports";
import { feedRouter } from "@/trpc/routers/feed";
+import { reactionsRouter } from "@/trpc/routers/reactions";
+import { commentsRouter } from "@/trpc/routers/comments";
+import { repostsRouter } from "@/trpc/routers/reposts";
+import { exploreRouter } from "@/trpc/routers/explore";
+import { searchRouter } from "@/trpc/routers/search";
export const appRouter = router({
pets: petsRouter,
@@ -19,6 +24,11 @@ export const appRouter = router({
blocks: blocksRouter,
reports: reportsRouter,
feed: feedRouter,
+ reactions: reactionsRouter,
+ comments: commentsRouter,
+ reposts: repostsRouter,
+ explore: exploreRouter,
+ search: searchRouter,
});
// Export the type for use in the client
diff --git a/src/trpc/routers/comments.ts b/src/trpc/routers/comments.ts
new file mode 100644
index 0000000..7848176
--- /dev/null
+++ b/src/trpc/routers/comments.ts
@@ -0,0 +1,41 @@
+import "server-only";
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, protectedProcedure } from "@/trpc/init";
+
+export const commentsRouter = router({
+ create: protectedProcedure
+ .input(
+ z.object({
+ postId: z.string().min(1),
+ petId: z.string().min(1),
+ body: z.string().min(1).max(500),
+ })
+ )
+ .mutation(async (): Promise> => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }),
+
+ listByPost: protectedProcedure
+ .input(
+ z.object({
+ postId: z.string().min(1),
+ limit: z.number().int().min(1).max(50).default(20),
+ cursor: z.string().optional(),
+ })
+ )
+ .query(
+ async (): Promise<{
+ comments: Record[];
+ nextCursor: string | null;
+ }> => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }
+ ),
+
+ delete: protectedProcedure
+ .input(z.object({ commentId: z.string().min(1), petId: z.string().min(1) }))
+ .mutation(async (): Promise => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }),
+});
diff --git a/src/trpc/routers/explore.ts b/src/trpc/routers/explore.ts
new file mode 100644
index 0000000..e5508fb
--- /dev/null
+++ b/src/trpc/routers/explore.ts
@@ -0,0 +1,19 @@
+import "server-only";
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, publicProcedure } from "@/trpc/init";
+
+export const exploreRouter = router({
+ listPets: publicProcedure
+ .input(
+ z.object({
+ speciesId: z.string().optional(),
+ breedId: z.string().optional(),
+ limit: z.number().int().min(1).max(40).default(20),
+ cursor: z.string().optional(),
+ })
+ )
+ .query(async (): Promise<{ pets: Record[]; nextCursor: string | null }> => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }),
+});
diff --git a/src/trpc/routers/reactions.ts b/src/trpc/routers/reactions.ts
new file mode 100644
index 0000000..f38f162
--- /dev/null
+++ b/src/trpc/routers/reactions.ts
@@ -0,0 +1,24 @@
+import "server-only";
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, protectedProcedure } from "@/trpc/init";
+
+export const reactionsRouter = router({
+ toggle: protectedProcedure
+ .input(z.object({ postId: z.string().min(1), petId: z.string().min(1) }))
+ .mutation(async (): Promise<{ reacted: boolean }> => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }),
+
+ hasReacted: protectedProcedure
+ .input(z.object({ postId: z.string().min(1), petId: z.string().min(1) }))
+ .query(async (): Promise => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }),
+
+ getCount: protectedProcedure
+ .input(z.object({ postId: z.string().min(1) }))
+ .query(async (): Promise => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }),
+});
diff --git a/src/trpc/routers/reposts.ts b/src/trpc/routers/reposts.ts
new file mode 100644
index 0000000..0b4c0e5
--- /dev/null
+++ b/src/trpc/routers/reposts.ts
@@ -0,0 +1,34 @@
+import "server-only";
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, protectedProcedure } from "@/trpc/init";
+
+export const repostsRouter = router({
+ create: protectedProcedure
+ .input(
+ z.object({
+ originalPostId: z.string().min(1),
+ reposterPetId: z.string().min(1),
+ })
+ )
+ .mutation(async (): Promise<{ postId: string }> => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }),
+
+ delete: protectedProcedure
+ .input(
+ z.object({
+ originalPostId: z.string().min(1),
+ reposterPetId: z.string().min(1),
+ })
+ )
+ .mutation(async (): Promise => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }),
+
+ isReposted: protectedProcedure
+ .input(z.object({ postId: z.string().min(1), petId: z.string().min(1) }))
+ .query(async (): Promise => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }),
+});
diff --git a/src/trpc/routers/search.ts b/src/trpc/routers/search.ts
new file mode 100644
index 0000000..cdac4c6
--- /dev/null
+++ b/src/trpc/routers/search.ts
@@ -0,0 +1,38 @@
+import "server-only";
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, protectedProcedure } from "@/trpc/init";
+
+export const searchRouter = router({
+ pets: protectedProcedure
+ .input(
+ z.object({
+ query: z.string().min(1).max(100),
+ limit: z.number().int().min(1).max(40).default(20),
+ cursor: z.string().optional(),
+ })
+ )
+ .query(
+ async (): Promise<{ pets: Record[]; nextCursor: string | null }> => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }
+ ),
+
+ byHashtag: protectedProcedure
+ .input(
+ z.object({
+ tag: z
+ .string()
+ .min(1)
+ .max(50)
+ .transform((s) => s.replace(/^#/, "").toLowerCase()),
+ limit: z.number().int().min(1).max(30).default(20),
+ cursor: z.string().optional(),
+ })
+ )
+ .query(
+ async (): Promise<{ posts: Record[]; nextCursor: string | null }> => {
+ throw new TRPCError({ code: "NOT_IMPLEMENTED" });
+ }
+ ),
+});