docs(02-04): complete photo posts plan summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-07 12:50:55 +02:00
1 parent e4b619671f
commit f7b09902f3
1 file changed
+184
@@ -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 `</div>` closing the outer container but before `);` — TypeScript error TS1005 `')' expected`
- **Fix:** Moved PostTypeSheet inside the outer `<div>` 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