feat(03-01): Phase 3 schema foundation — Reaction/Comment/Repost/Hashtag models + router stubs RED
- Prisma: add Reaction, Comment, Repost, Hashtag, PostHashtag models; PostType.REPOST enum - Back-relations on Post and Pet for all Phase 3 models (named to avoid ambiguous-relation errors) - 5 tRPC router stubs: reactions, comments, reposts, explore, search — all registered in _app.ts - Extend prisma-mock.ts with Phase 3 model mocks - 5 test scaffold files: 14 failing RED + 3 FORBIDDEN passing; 53 Phase 2 tests untouched - Phase 3 planning: CONTEXT, RESEARCH, UI-SPEC, VALIDATION, 6 PLANs, ROADMAP + STATE updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
273d412fb4
commit
73666a3916
26 files changed
+2681
-18
No files matched your search
+17
-4
@@ -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 | - |
|
||||
|
||||
+28
-14
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
|
||||
@T:/CC-Projekte/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Prisma schema — 5 new models, PostType.REPOST, back-relations, [BLOCKING] db push</name>
|
||||
<files>prisma/schema.prisma</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx prisma validate && npx prisma generate</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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])
|
||||
</acceptance_criteria>
|
||||
<done>5 models in DB; Prisma client regenerated; schema validates.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: 5 tRPC router stubs + register in _app.ts</name>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- _app.ts registers reactions, comments, reposts, explore, search alongside existing routers
|
||||
- publicProcedure exists in src/trpc/init.ts
|
||||
</acceptance_criteria>
|
||||
<done>5 router stubs registered and type-checking.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Prisma mock extension + 5 failing test scaffolds</name>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All 5 test files load without import errors (todo/skipped allowed; no crash)
|
||||
- prisma-mock.ts exports mock for reaction, comment, repost, hashtag, postHashtag
|
||||
</acceptance_criteria>
|
||||
<done>5 test scaffolds runnable; mock helper extended.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/03-engagement-discovery/03-01-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: RED — failing reactions tests</name>
|
||||
<files>src/__tests__/reactions.test.ts</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/__tests__/reactions.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>Tests run but fail with NOT_IMPLEMENTED or wrong assertions.</acceptance_criteria>
|
||||
<done>RED: 3 failing tests.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: GREEN — implement reactions router</name>
|
||||
<files>src/trpc/routers/reactions.ts</files>
|
||||
<read_first>
|
||||
- .planning/phases/03-engagement-discovery/03-RESEARCH.md (Pattern 3: Paw Reaction Toggle)
|
||||
- src/trpc/routers/pets.ts (assertPetOwnership helper)
|
||||
</read_first>
|
||||
<action>
|
||||
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 } }))
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/__tests__/reactions.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>All 3 reaction tests pass. `npx tsc --noEmit` exits 0.</acceptance_criteria>
|
||||
<done>GREEN: reactions router fully implemented.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: PawButton component + PostCard action row</name>
|
||||
<files>src/components/feed/PawButton.tsx, src/components/feed/PostCard.tsx</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>PawButton live on PostCard; reactions end-to-end.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npx vitest run src/__tests__/reactions.test.ts` — all pass
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- `npx vitest run` — full suite green
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- reactions.toggle, hasReacted, getCount implemented and tested
|
||||
- PostCard shows paw count + button that toggles
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/03-engagement-discovery/03-02-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: RED — failing comments tests</name>
|
||||
<files>src/__tests__/comments.test.ts</files>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify><automated>npx vitest run src/__tests__/comments.test.ts</automated></verify>
|
||||
<acceptance_criteria>3 tests run and fail (NOT_IMPLEMENTED).</acceptance_criteria>
|
||||
<done>RED: 3 failing comments tests.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: GREEN — implement comments router</name>
|
||||
<files>src/trpc/routers/comments.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify><automated>npx vitest run src/__tests__/comments.test.ts</automated></verify>
|
||||
<acceptance_criteria>All 3 comment tests pass. `npx tsc --noEmit` exits 0.</acceptance_criteria>
|
||||
<done>GREEN: comments router implemented.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: CommentSheet + wire comment count chip on PostCard</name>
|
||||
<files>src/components/feed/CommentSheet.tsx, src/components/feed/PostCard.tsx</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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 <CommentSheet postId={post.id} petId={activePetId} open={commentSheetOpen} onOpenChange={setCommentSheetOpen} />
|
||||
</action>
|
||||
<verify><automated>npx tsc --noEmit</automated></verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- CommentSheet opens from PostCard comment chip
|
||||
- Comments load, new comment submits and appears, own comment deletable
|
||||
</acceptance_criteria>
|
||||
<done>CommentSheet live; comments end-to-end.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npx vitest run src/__tests__/comments.test.ts` — all pass
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- `npx vitest run` — full suite green
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- comments.create, listByPost, delete implemented and tested
|
||||
- CommentSheet works from PostCard
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/03-engagement-discovery/03-03-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: RED — failing reposts tests</name>
|
||||
<files>src/__tests__/reposts.test.ts</files>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify><automated>npx vitest run src/__tests__/reposts.test.ts</automated></verify>
|
||||
<acceptance_criteria>3 tests run and fail.</acceptance_criteria>
|
||||
<done>RED: 3 failing repost tests.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: GREEN — implement reposts router</name>
|
||||
<files>src/trpc/routers/reposts.ts</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify><automated>npx vitest run src/__tests__/reposts.test.ts</automated></verify>
|
||||
<acceptance_criteria>All 3 repost tests pass. `npx tsc --noEmit` exits 0.</acceptance_criteria>
|
||||
<done>GREEN: reposts router implemented.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: RepostCard + Paw-Back button on PostCard</name>
|
||||
<files>src/components/feed/RepostCard.tsx, src/components/feed/PostCard.tsx</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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 <RepostCard post={post} />
|
||||
- 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
|
||||
</action>
|
||||
<verify><automated>npx tsc --noEmit</automated></verify>
|
||||
<acceptance_criteria>
|
||||
- RepostCard renders correctly for type=REPOST posts
|
||||
- Paw-Back button toggles repost state
|
||||
- `npx tsc --noEmit` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Paw-Back end-to-end; RepostCard in feed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npx vitest run src/__tests__/reposts.test.ts` — all pass
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- `npx vitest run` — full suite green
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- reposts.create, delete, isReposted implemented and tested
|
||||
- RepostCard renders in feed; Paw-Back button toggles
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/03-engagement-discovery/03-04-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: RED — failing explore + search (pets) tests</name>
|
||||
<files>src/__tests__/explore.test.ts, src/__tests__/search.test.ts</files>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify><automated>npx vitest run src/__tests__/explore.test.ts src/__tests__/search.test.ts</automated></verify>
|
||||
<acceptance_criteria>Tests run and fail (NOT_IMPLEMENTED).</acceptance_criteria>
|
||||
<done>RED established.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: GREEN — explore.listPets + search.pets router</name>
|
||||
<files>src/trpc/routers/explore.ts, src/trpc/routers/search.ts</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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 }
|
||||
</action>
|
||||
<verify><automated>npx vitest run src/__tests__/explore.test.ts src/__tests__/search.test.ts</automated></verify>
|
||||
<acceptance_criteria>explore + search pets tests pass. `npx tsc --noEmit` exits 0.</acceptance_criteria>
|
||||
<done>GREEN: explore + search.pets implemented.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: ExploreCard component</name>
|
||||
<files>src/components/explore/ExploreCard.tsx</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify><automated>npx tsc --noEmit</automated></verify>
|
||||
<acceptance_criteria>`npx tsc --noEmit` exits 0. ExploreCard renders without errors.</acceptance_criteria>
|
||||
<done>ExploreCard created.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: /explore and /search pages + nav additions</name>
|
||||
<files>src/app/(app)/explore/page.tsx, src/app/(app)/search/page.tsx, src/components/layout/Sidebar.tsx, src/components/layout/MobileNav.tsx</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify><automated>npx tsc --noEmit</automated></verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- /explore and /search routes exist and type-check
|
||||
- Sidebar + MobileNav include Explore + Search links
|
||||
</acceptance_criteria>
|
||||
<done>Explore and Search pages live; nav updated.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/03-engagement-discovery/03-05-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -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]"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Hashtag helper libs</name>
|
||||
<files>src/lib/hashtag-helpers.ts, src/lib/parse-caption.ts</files>
|
||||
<read_first>
|
||||
- .planning/phases/03-engagement-discovery/03-RESEARCH.md (Pattern 2: Hashtag Extraction, Pattern 7: Caption Hashtag Rendering, Pitfall 3: regex lastIndex)
|
||||
</read_first>
|
||||
<action>
|
||||
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[]
|
||||
</action>
|
||||
<verify><automated>npx tsc --noEmit</automated></verify>
|
||||
<acceptance_criteria>`npx tsc --noEmit` exits 0. Both helpers export correctly.</acceptance_criteria>
|
||||
<done>Hashtag helper libs created.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: RED — failing search.byHashtag tests</name>
|
||||
<files>src/__tests__/search.test.ts</files>
|
||||
<action>
|
||||
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 }
|
||||
</action>
|
||||
<verify><automated>npx vitest run src/__tests__/search.test.ts</automated></verify>
|
||||
<acceptance_criteria>2 hashtag tests run and fail.</acceptance_criteria>
|
||||
<done>RED: 2 failing byHashtag tests.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: GREEN — implement search.byHashtag + wire extraction into posts.create</name>
|
||||
<files>src/trpc/routers/search.ts, src/trpc/routers/posts.ts, src/trpc/routers/reposts.ts</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify><automated>npx vitest run src/__tests__/search.test.ts</automated></verify>
|
||||
<acceptance_criteria>All search tests pass (both pets + hashtag). `npx tsc --noEmit` exits 0.</acceptance_criteria>
|
||||
<done>GREEN: byHashtag implemented; hashtags extracted on post create.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: /hashtag/[tag] page + Tags tab in /search + clickable captions</name>
|
||||
<files>src/app/(app)/hashtag/[tag]/page.tsx, src/app/(app)/search/page.tsx, src/components/feed/PostCard.tsx</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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" ? <Link> : <span>)
|
||||
- Hashtag links: text-orange-500 hover:underline, route to /hashtag/[tag]
|
||||
</action>
|
||||
<verify><automated>npx tsc --noEmit</automated></verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>Hashtag search end-to-end; Phase 3 complete.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/03-engagement-discovery/03-06-SUMMARY.md` when done
|
||||
Update STATE.md: Phase 3 In Progress
|
||||
</output>
|
||||
@@ -0,0 +1,132 @@
|
||||
# Phase 3: Engagement and Discovery — Context
|
||||
|
||||
**Gathered:** 2026-06-13
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## 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.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## 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)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## 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
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## 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.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## 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
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## 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)
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 03-engagement-discovery*
|
||||
*Context gathered: 2026-06-13*
|
||||
@@ -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<void> {
|
||||
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" ? (
|
||||
<Link key={i} href={`/hashtag/${seg.tag}`} className="text-orange-500 hover:underline">
|
||||
#{seg.tag}
|
||||
</Link>
|
||||
) : (
|
||||
<span key={i}>{seg.text}</span>
|
||||
)
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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 <Loader2 />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. RepostCard
|
||||
|
||||
When `post.type === "REPOST"`, PostCard renders `<RepostCard>` 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" |
|
||||
@@ -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
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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<typeof createMockPrisma>;
|
||||
}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createMockPrisma>;
|
||||
}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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<typeof createMockPrisma>;
|
||||
}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createMockPrisma>;
|
||||
}) {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createMockPrisma>;
|
||||
}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<Record<string, unknown>> => {
|
||||
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<string, unknown>[];
|
||||
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<void> => {
|
||||
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
|
||||
}),
|
||||
});
|
||||
@@ -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<string, unknown>[]; nextCursor: string | null }> => {
|
||||
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
|
||||
}),
|
||||
});
|
||||
@@ -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<boolean> => {
|
||||
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
|
||||
}),
|
||||
|
||||
getCount: protectedProcedure
|
||||
.input(z.object({ postId: z.string().min(1) }))
|
||||
.query(async (): Promise<number> => {
|
||||
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
|
||||
}),
|
||||
});
|
||||
@@ -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<void> => {
|
||||
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
|
||||
}),
|
||||
|
||||
isReposted: protectedProcedure
|
||||
.input(z.object({ postId: z.string().min(1), petId: z.string().min(1) }))
|
||||
.query(async (): Promise<boolean> => {
|
||||
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
|
||||
}),
|
||||
});
|
||||
@@ -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<string, unknown>[]; 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<string, unknown>[]; nextCursor: string | null }> => {
|
||||
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
|
||||
}
|
||||
),
|
||||
});
|
||||
Reference in new issue
Block a user