diff --git a/.planning/phases/02-content-core/02-04-SUMMARY.md b/.planning/phases/02-content-core/02-04-SUMMARY.md new file mode 100644 index 0000000..9b73a4a --- /dev/null +++ b/.planning/phases/02-content-core/02-04-SUMMARY.md @@ -0,0 +1,184 @@ +--- +phase: "02" +plan: "04" +subsystem: photo-posts +tags: [trpc, supabase-storage, redis, fan-out, carousel, embla, tdd, shadcn, postcard] +dependency_graph: + requires: + - Phase 02 Plan 01 (posts.ts stub, feed-helpers.ts fanOutPost, redis.ts, prisma schema) + - Phase 02 Plan 02 (BlockDialog, ReportSheet from safety/) + - Phase 02 Plan 03 (FeedList, PostCard placeholder seam) + provides: + - postsRouter: create+fanOut, getPresignedUrl, updateCaption, delete, byPetId + - src/components/feed/PostCard.tsx — full carousel + caption clamp + options menu + - src/components/post-creation/MultiImageUpload.tsx — multi-file presigned upload + - src/components/post-creation/PhotoPostForm.tsx — caption + share CTA + - src/components/post-creation/PostTypeSheet.tsx — D-01 bottom Sheet (3 choices) + - PHOTO-01, PHOTO-02, PHOTO-03, PHOTO-04 requirements implemented and tested + affects: + - src/components/layout/Sidebar.tsx (New Post button → PostTypeSheet) + - src/components/layout/MobileNav.tsx (+ center button → PostTypeSheet) + - src/components/feed/FeedList.tsx (PostCard now full, not placeholder) +tech_stack: + added: [] + patterns: + - TDD RED/GREEN: failing tests committed before implementation + - postsRouter: assertPostOwnership helper (post→pet→ownerId) for edit/delete + - posts.create: nested Prisma write (Post + PostImage[]) + synchronous fanOutPost + - getPresignedUrl: key scoped to posts/{ownerId}/{petId}/{uuid}.ext (T-02-31) + - imageKeys.startsWith('posts/') Zod validation — path-traversal prevention + - imageKeys.max(10) Zod validation — carousel limit enforcement (T-02-32) + - MultiImageUpload: per-file XHR PUT with Content-Type matching getPresignedUrl (Pitfall 4) + - PostCard: Embla carousel via shadcn Carousel, setApi/selectedScrollSnap index tracking + - PostCard: activePetId === post.pet.id own-vs-other determination + - PostTypeSheet: Sheet (D-01) → Dialog (D-02) pattern, no URL navigation + - PhotoPostForm: onSuccess invalidates feed query + router.push('/feed') (D-03) +key_files: + created: + - src/components/post-creation/MultiImageUpload.tsx + - src/components/post-creation/PhotoPostForm.tsx + - src/components/post-creation/PostTypeSheet.tsx + modified: + - src/trpc/routers/posts.ts (replaced NOT_IMPLEMENTED stubs with full implementation) + - src/__tests__/posts.test.ts (replaced it.todo stubs with 4 passing tests) + - src/components/feed/PostCard.tsx (replaced 02-03 placeholder with full implementation) + - src/components/layout/Sidebar.tsx (added New Post button → PostTypeSheet) + - src/components/layout/MobileNav.tsx (wired center + button → PostTypeSheet) +decisions: + - "posts.create folds confirmUpload into create via imageKeys input — avoids a separate round-trip; keys validated with .startsWith('posts/') at mutation entry (T-02-31)" + - "assertPostOwnership helper used for both updateCaption and delete — single source for post-level FORBIDDEN check" + - "byPetId uses cursor from createdAt ISO string (not Redis) — direct Prisma cursor pagination for pet profile view, separate from Redis feed" + - "feed cleanup on delete not implemented in Phase 2 — feed.getFeed already filters deleted post IDs since Prisma findMany won't return them" + - "caption expand threshold heuristic: >140 chars triggers more/less toggle — avoids showing toggle for very short captions" + - "PostCard: PostTypeSheet placed inside the outer return div in MobileNav (not outside closing div) — fixed TypeScript structural error" +metrics: + duration: "~25 minutes" + completed: "2026-06-07T10:49:00Z" + tasks_completed: 3 + tasks_total: 3 + files_created: 3 + files_modified: 5 +--- + +# Phase 02 Plan 04: Photo Posts Summary + +**One-liner:** Posts router (create+fanOut, presigned upload, caption edit, delete) + full Embla carousel PostCard + PostTypeSheet→PhotoPostForm creation flow wiring D-01/D-02/D-03 + +## What Was Built + +A complete vertical slice for PHOTO-01–04: server-side tRPC router, full PostCard UI, and the post-creation flow from + button to feed. + +**Task 1 — posts router TDD RED/GREEN (PHOTO-01–04):** + +RED phase: Wrote 4 failing tests covering: +- PHOTO-01: `posts.create` inserts Post + PostImage with correct petId and calls `fanOutPost` +- PHOTO-02: `posts.create` rejects >10 images via Zod `array.max(10)` (already passing from stub schema) +- PHOTO-03: `posts.updateCaption` throws FORBIDDEN for non-owner via `assertPostOwnership` +- PHOTO-04: `posts.delete` calls `prisma.post.delete` (PostImages cascade via schema) + +GREEN phase: Implemented `postsRouter`: +- `getPresignedUrl`: ownership assertion (T-02-30); key scoped to `posts/{ownerId}/{petId}/{uuid}.ext` (T-02-31); returns `{ presignedUrl, key }` +- `create`: ownership assertion + nested Prisma write (`Post` + `PostImage[]` with position index) + `fanOutPost` (PHOTO-01); `imageKeys.max(10)` (PHOTO-02); `imageKeys.startsWith('posts/')` prevents path traversal (T-02-31) +- `updateCaption`: `assertPostOwnership` (post→pet→ownerId, T-02-34) + Prisma update (PHOTO-03) +- `delete`: `assertPostOwnership` + `prisma.post.delete`; PostImages cascade via schema `onDelete: Cascade` (PHOTO-04); feed cleanup deferred (feed hydration handles it via Prisma findMany) +- `byPetId`: cursor-paginated query (ISO string cursor, not Redis) with images+pet+milestone include + +All 4 PHOTO tests green. Full suite: 40 passing, 10 todo stubs (future plans), only pre-existing `media-upload.test.ts` failure. + +**Task 2 — Full PostCard (PHOTO-03/04 UI, carousel, caption clamp):** + +Replaced 02-03 placeholder with full implementation: +- Header: Avatar (story ring seam `// TODO(02-05): StoryRing wrapper`), pet name `text-base font-semibold`, timestamp `text-xs muted`, `MoreHorizontal` options button 44px +- Single image: `object-cover max-h-[560px]` +- Carousel (2–10): shadcn Carousel (Embla) with `setApi/selectedScrollSnap` index indicator `"{current} / {total}"` top-right; dot indicators `bg-muted-foreground/40` inactive / `bg-foreground` active (NOT orange — accent reservation per UI-SPEC) +- Caption: `line-clamp-3` with more/less toggle (instant, no animation per UI-SPEC); threshold >140 chars +- Options menu (base-nova: onClick handlers, no asChild): + - Own post (`activePetId === post.pet.id`): "Edit caption" (inline Textarea → `updateCaption` mutation, toast "Caption updated") + "Delete post" (AlertDialog → `delete` mutation, toast "Post deleted") + - Other's post: "Report post" (opens `ReportSheet`) + separator + "Block {pet name}" (opens `BlockDialog`) +- Exact UI-SPEC copy throughout (delete dialog, toast messages, button labels) +- `tsc --noEmit` exits 0 + +**Task 3 — Post creation flow (D-01/D-02/D-03):** + +`MultiImageUpload.tsx`: +- Per-file validation: size (<10MB → "Photo must be under 10 MB."), type (jpg/png/webp → "Upload a JPG, PNG, or WebP image."), count (>10 → "You can upload up to 10 photos per post.") +- `posts.getPresignedUrl` → XHR PUT to Supabase signed URL; `Content-Type` header MUST match exactly (Pitfall 4 prevention) +- Thumbnail grid with remove button, upload progress bar (orange fill), status overlays +- `onKeysChange` callback with all successfully uploaded `posts/` keys + +`PhotoPostForm.tsx`: +- `MultiImageUpload` + caption `Textarea` (placeholder "Write a caption…", max 500, error on overflow) +- "Share Post" CTA (`bg-orange-500`) → `posts.create({ petId: activePetId, caption, imageKeys })` +- `onSuccess`: `queryClient.invalidateQueries` (feed), `onClose()` (close Dialog), `router.push('/feed')` (D-03), toast "Post shared" +- Error toast: "Couldn't share your post. Please try again." + +`PostTypeSheet.tsx`: +- Bottom `Sheet` titled "Create" with 3 full-width buttons: "Photo Post" (Camera), "Story" (BookOpen), "Milestone" (Trophy) (D-01) +- Photo → full-screen `Dialog` with `PhotoPostForm` (D-02, no URL change) +- Story: `// TODO(02-05): StoryForm` seam — Dialog with placeholder "Story creation coming soon." +- Milestone: `// TODO(02-06): MilestoneForm` seam — Dialog with placeholder "Milestone creation coming soon." + +`Sidebar.tsx` + `MobileNav.tsx`: +- `Sidebar`: "New Post" button (orange-500) above owner identity → opens `PostTypeSheet` +- `MobileNav`: center + button changed from `Link href="/pets"` to `button onClick → setPostTypeSheetOpen(true)` +- Both pass `open/onOpenChange` props to `PostTypeSheet` (D-01) + +`tsc --noEmit` exits 0; full suite 40 passing. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] MobileNav JSX structure error — PostTypeSheet outside closing div** +- **Found during:** Task 3, `npx tsc --noEmit` +- **Issue:** PostTypeSheet was placed after `` closing the outer container but before `);` — TypeScript error TS1005 `')' expected` +- **Fix:** Moved PostTypeSheet inside the outer `
` wrapper, before its closing tag +- **Files modified:** `src/components/layout/MobileNav.tsx` +- **Commit:** e4b6196 (fixed inline during task) + +### Design Decisions (Not Deviations) + +- `confirmUpload` is folded into `create` (imageKeys validated with `.startsWith('posts/')`) rather than being a separate procedure — avoids an extra round-trip and follows the plan spec explicitly +- Feed cleanup (ZREM from follower sets on delete) not implemented in Phase 2 — documented in router comment; Prisma findMany already filters deleted post IDs naturally + +## Known Stubs + +| File | Stub | Reason | +|------|------|--------| +| `src/components/feed/PostCard.tsx` | `// TODO(02-05): StoryRing wrapper` | StoryRing is 02-05 scope | +| `src/components/post-creation/PostTypeSheet.tsx` | Story dialog: "Story creation coming soon." | StoryForm is 02-05 scope | +| `src/components/post-creation/PostTypeSheet.tsx` | Milestone dialog: "Milestone creation coming soon." | MilestoneForm is 02-06 scope | +| `src/app/(app)/feed/page.tsx` | `TODO(02-05): StoryTray above feed` | From 02-03, still pending | + +These stubs are intentional seams. Photo post creation (the plan goal) is fully functional. Story/Milestone seams don't block PHOTO-01–04 requirements. + +## Threat Flags + +No new security-relevant surface not in the plan's threat model. All mitigations implemented: + +| Threat ID | Status | +|-----------|--------| +| T-02-30 | Mitigated — `assertPetOwnership` (pet.ownerId === ctx.userId) before any post mutation | +| T-02-31 | Mitigated — `imageKeys.startsWith('posts/')` in `create`; `getPresignedUrl` scopes key to `posts/{ownerId}/{petId}/` | +| T-02-32 | Mitigated — `z.array(...).max(10)` on imageKeys in `posts.create` | +| T-02-33 | Mitigated — `supabase-storage.ts` is `import "server-only"` (inherited); client receives only signed URL + public media URL | +| T-02-34 | Mitigated — `assertPostOwnership` (post→pet→ownerId) in both `updateCaption` and `delete` | +| T-02-35 | Mitigated — `MultiImageUpload` XHR PUT `Content-Type` header set to exact `file.type` matching `getPresignedUrl` input | + +## Self-Check: PASSED + +Files verified: +- src/trpc/routers/posts.ts: FOUND +- src/__tests__/posts.test.ts: FOUND +- src/components/feed/PostCard.tsx: FOUND +- src/components/post-creation/MultiImageUpload.tsx: FOUND +- src/components/post-creation/PhotoPostForm.tsx: FOUND +- src/components/post-creation/PostTypeSheet.tsx: FOUND +- src/components/layout/Sidebar.tsx: FOUND +- src/components/layout/MobileNav.tsx: FOUND + +Commits verified: +- 5e71010: test(02-04): add failing tests for posts router RED +- 75d1e80: feat(02-04): implement postsRouter GREEN — PHOTO-01–04 +- 15f5182: feat(02-04): full PostCard — carousel + caption clamp + options menu +- e4b6196: feat(02-04): post creation flow — MultiImageUpload, PhotoPostForm, PostTypeSheet, + button nav