docs(02): create phase plan (6 plans)
This commit is contained in:
1 parent
90240f46b3
commit
ff6b48e3aa
9 files changed
+1293
-4
No files matched your search
+44
-2
@@ -7,6 +7,7 @@ PawFeed is built in strict dependency order. Phase 1 establishes the identity an
|
||||
## Phases
|
||||
|
||||
**Phase Numbering:**
|
||||
|
||||
- Integer phases (1, 2, 3): Planned milestone work
|
||||
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
|
||||
|
||||
@@ -23,98 +24,139 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
## Phase Details
|
||||
|
||||
### Phase 1: Foundation
|
||||
|
||||
**Goal**: Owner can sign up, create and manage pet profiles, and upload media — the identity layer and media pipeline that underpin every subsequent feature
|
||||
**Mode:** mvp
|
||||
**Depends on**: Nothing (first phase)
|
||||
**Requirements**: AUTH-01, AUTH-02, AUTH-03, AUTH-04, PET-01, PET-02, PET-03, PET-04, PET-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Owner can create an account, verify email, reset a forgotten password, and remain logged in across browser refresh
|
||||
2. Owner can create a pet profile (name, breed, species, bio, avatar photo, adoption origin story) for a dog, cat, or bird
|
||||
3. Owner can edit and delete a pet profile they own
|
||||
4. Owner with multiple pets can switch the active pet without logging out; the active pet identity is reflected throughout the UI
|
||||
5. Owner can upload a photo via presigned R2 URL — the image is stored in Cloudflare R2 and served via CDN, never through the API server
|
||||
|
||||
**Plans**:
|
||||
|
||||
- 01: Walking Skeleton — scaffold, Clerk proxy, Prisma schema, tRPC wiring, test scaffold
|
||||
- 02: Auth screens — sign-up, log-in, forgot/reset password, verify-email, app shell
|
||||
- 03: Pet CRUD — create/edit/delete pet, My Pets grid, profile view, breed seed, unit tests
|
||||
- 04: Media pipeline — presigned R2 upload, AvatarUpload component, CDN delivery, unit tests
|
||||
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 2: Content Core
|
||||
|
||||
**Goal**: Pets can post photos, stories, and milestone events; owners see a live home feed from pets they follow; block and report are available before any content is public-facing
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 1
|
||||
**Requirements**: PHOTO-01, PHOTO-02, PHOTO-03, PHOTO-04, STORY-01, STORY-02, STORY-03, MILE-01, MILE-02, SOCL-01, SOCL-02, SOCL-03, FEED-01, SAFE-01, SAFE-02
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Pet can post a single photo or a carousel (up to 10 images) with a caption; owner can edit the caption and delete the post
|
||||
2. Pet can post a story (photo) that disappears after 24 hours; an active story ring appears on the pet's avatar; the owner can see which pets viewed the story
|
||||
3. Pet can create a milestone post (birthday, adoption anniversary, first walk, etc.) rendered in a visually distinct format on the profile grid
|
||||
4. Pet can follow and unfollow other pets; owner can view their pet's followers and following lists
|
||||
5. Owner sees a chronological home feed of posts from followed pets, delivered via Redis fan-out sorted sets with cursor pagination
|
||||
6. Owner can block another pet/owner (content hidden, interaction prevented) and report a post or pet profile for review
|
||||
**Plans**: TBD
|
||||
|
||||
**Plans**: 6 plansPlans:
|
||||
**Wave 1**
|
||||
|
||||
- [ ] 02-01-PLAN.md — Wave 0: Prisma schema (8 models), tRPC router stubs, shared libs (Redis/feed/media-url), 7 test scaffolds, deps + [BLOCKING] db push
|
||||
- [ ] 02-02-PLAN.md — Social graph (follow/unfollow + lists) and safety (block/report) — SOCL-01–03, SAFE-01–02
|
||||
- [ ] 02-03-PLAN.md — Redis fan-out home feed with cursor pagination, /feed page, home redirect + nav — FEED-01
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
|
||||
- [ ] 02-04-PLAN.md — Photo posts (single + carousel), full PostCard, creation flow (+ button → Sheet → Dialog → /feed) — PHOTO-01–04
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
|
||||
- [ ] 02-05-PLAN.md — Stories (24h photo, ring, viewer, tray) — STORY-01–03
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
|
||||
- [ ] 02-06-PLAN.md — Milestone posts (distinct card, profile tabs) — MILE-01–02
|
||||
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 3: Engagement and Discovery
|
||||
|
||||
**Goal**: Owners can react to and comment on posts, repost content with attribution, and discover new pets through breed/species browse, hashtag search, and name search
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 2
|
||||
**Requirements**: ENGM-01, ENGM-02, ENGM-03, ENGM-04, EXPL-01, SRCH-01, SRCH-02
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Owner can like / paw-react to any post; reaction count updates are visible in near real-time
|
||||
2. Owner can comment on a post and delete their own comment; comment count is reflected on the post
|
||||
3. Pet can paw-back (repost) another pet's post with attribution to the original pet; it appears on the reposting pet's profile
|
||||
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
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 4: Notifications
|
||||
|
||||
**Goal**: Owners receive timely in-app notifications for all social activity so they can engage without polling the app
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 3
|
||||
**Requirements**: NOTF-01, NOTF-02, NOTF-03, NOTF-04
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Owner receives an in-app notification when another pet follows theirs
|
||||
2. Owner receives batched in-app notifications for likes and paw reactions on their pet's posts (not one notification per reaction)
|
||||
3. Owner receives an in-app notification when someone comments on their pet's posts
|
||||
4. Owner receives an in-app notification for new direct messages (wired up when DMs ship in Phase 6)
|
||||
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 5: Video
|
||||
|
||||
**Goal**: Pets can post short videos that are transcoded asynchronously via Mux and delivered as HLS streams; the post shows a processing state until the video is ready
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 2
|
||||
**Requirements**: VIDEO-01, VIDEO-02
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Pet can upload a short video (15–60 seconds); the post appears immediately in a "processing" state while Mux transcodes
|
||||
2. Once Mux transcoding completes and the webhook fires, the post transitions to published and plays back via HLS in the feed and profile
|
||||
3. Video upload never passes raw bytes through the API server — the client uploads directly to Mux via a signed URL
|
||||
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 6: Direct Messages
|
||||
|
||||
**Goal**: Pets can exchange direct messages managed by their owners, with real-time delivery and notification integration
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 4
|
||||
**Requirements**: DM-01, DM-02, DM-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Pet can send a direct message to another pet; message appears in a conversation thread managed by the owner
|
||||
2. Owner can view all DM conversations and reply within the thread
|
||||
3. Owner receives an in-app notification for new DMs and messages deliver in real-time via Supabase Realtime WebSocket without a page refresh
|
||||
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 7: Health Tracking
|
||||
|
||||
**Goal**: Owners can log their pet's health events (weight, vet visits, vaccines) and share a point-in-time health card with a signed URL
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 2
|
||||
**Requirements**: HLTH-01, HLTH-02, HLTH-03, HLTH-04
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Owner can log weight entries with a date; the log is append-only and displayed chronologically on the pet's profile
|
||||
2. Owner can log vet visits with date and notes, and vaccine records with name and date
|
||||
3. Pet profile displays a health card aggregating all logged health data (weight history, vet visits, vaccines)
|
||||
4. Owner can share a publicly accessible health card via a signed, token-based URL that reflects a point-in-time snapshot
|
||||
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
@@ -126,7 +168,7 @@ 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/TBD | Not started | - |
|
||||
| 2. Content Core | 0/6 | Planned | - |
|
||||
| 3. Engagement and Discovery | 0/TBD | Not started | - |
|
||||
| 4. Notifications | 0/TBD | Not started | - |
|
||||
| 5. Video | 0/TBD | Not started | - |
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: verifying
|
||||
stopped_at: Phase 2 context gathered
|
||||
last_updated: "2026-06-06T10:17:45.387Z"
|
||||
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
|
||||
progress:
|
||||
total_phases: 7
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
phase: 02-content-core
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 0
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- prisma/schema.prisma
|
||||
- src/lib/redis.ts
|
||||
- src/lib/feed-helpers.ts
|
||||
- src/lib/media-url.ts
|
||||
- src/lib/milestone-thresholds.ts
|
||||
- src/trpc/routers/posts.ts
|
||||
- src/trpc/routers/stories.ts
|
||||
- src/trpc/routers/milestones.ts
|
||||
- src/trpc/routers/follows.ts
|
||||
- src/trpc/routers/blocks.ts
|
||||
- src/trpc/routers/reports.ts
|
||||
- src/trpc/routers/feed.ts
|
||||
- src/trpc/routers/_app.ts
|
||||
- src/__tests__/posts.test.ts
|
||||
- src/__tests__/stories.test.ts
|
||||
- src/__tests__/milestones.test.ts
|
||||
- src/__tests__/follows.test.ts
|
||||
- src/__tests__/feed.test.ts
|
||||
- src/__tests__/blocks.test.ts
|
||||
- src/__tests__/reports.test.ts
|
||||
- src/__tests__/helpers/prisma-mock.ts
|
||||
- package.json
|
||||
- components.json
|
||||
autonomous: false
|
||||
requirements: [PHOTO-01, PHOTO-02, PHOTO-03, PHOTO-04, STORY-01, STORY-02, STORY-03, MILE-01, MILE-02, SOCL-01, SOCL-02, SOCL-03, FEED-01, SAFE-01, SAFE-02]
|
||||
user_setup:
|
||||
- service: upstash-redis
|
||||
why: "Feed fan-out sorted sets (FEED-01) — feed router throws if env vars absent"
|
||||
env_vars:
|
||||
- name: UPSTASH_REDIS_REST_URL
|
||||
source: "Upstash Console → Redis database → REST API → UPSTASH_REDIS_REST_URL"
|
||||
- name: UPSTASH_REDIS_REST_TOKEN
|
||||
source: "Upstash Console → Redis database → REST API → UPSTASH_REDIS_REST_TOKEN"
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "All 8 new Prisma models exist and the database schema is pushed"
|
||||
- "Every Phase 2 tRPC router is registered in _app.ts and type-checks"
|
||||
- "7 test scaffold files exist with failing/todo tests for every Phase 2 requirement"
|
||||
- "react-intersection-observer is installed and 7 shadcn components are added"
|
||||
artifacts:
|
||||
- path: "prisma/schema.prisma"
|
||||
provides: "Post, PostImage, Milestone, Story, StoryView, Follow, Block, Report models + Pet back-relations + 3 enums"
|
||||
contains: "model Post"
|
||||
- path: "src/trpc/routers/_app.ts"
|
||||
provides: "All 7 new routers registered"
|
||||
contains: "feed:"
|
||||
- path: "src/lib/redis.ts"
|
||||
provides: "@upstash/redis singleton"
|
||||
contains: "new Redis"
|
||||
- path: "src/__tests__/feed.test.ts"
|
||||
provides: "FEED-01 test scaffold with Redis mock"
|
||||
min_lines: 10
|
||||
key_links:
|
||||
- from: "src/trpc/routers/_app.ts"
|
||||
to: "src/trpc/routers/posts.ts"
|
||||
via: "import + register"
|
||||
pattern: "posts:\\s*postsRouter"
|
||||
- from: "src/lib/redis.ts"
|
||||
to: "@upstash/redis"
|
||||
via: "Redis constructor"
|
||||
pattern: "new Redis"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Establish the complete data and contract foundation for Phase 2: all 8 Prisma models, all 7 tRPC router stubs (registered and type-checking), shared lib modules (Redis singleton, feed helpers, media URL, milestone thresholds), the 7 Wave 0 test scaffolds, and the new package/shadcn installs. This is the interface-first wave — every downstream plan builds against the contracts defined here.
|
||||
|
||||
Purpose: Downstream waves (social graph, feed, photo posts, stories, milestones) must not redefine schema or router shapes. They import stable contracts from this wave. The [BLOCKING] schema push makes the new models real in the database before any implementation wave runs.
|
||||
Output: Migrated schema, registered router stubs, shared libs, test scaffolds, installed dependencies.
|
||||
</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/02-content-core/02-RESEARCH.md
|
||||
@.planning/phases/02-content-core/02-VALIDATION.md
|
||||
@.planning/phases/01-foundation/01-01-SUMMARY.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 — 8 models, 3 enums, Pet back-relations, then [BLOCKING] db push</name>
|
||||
<files>prisma/schema.prisma</files>
|
||||
<read_first>
|
||||
- prisma/schema.prisma (current Owner/Species/Breed/Pet models; Prisma 7 format — no url in datasource)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Pattern 1: Prisma 7 Schema — exact model definitions, lines 263–412)
|
||||
- .planning/phases/01-foundation/01-01-SUMMARY.md (Prisma 7 constraints: url lives in prisma.config.ts, not schema.prisma)
|
||||
</read_first>
|
||||
<action>
|
||||
Append to prisma/schema.prisma exactly the models and enums from RESEARCH.md Pattern 1: enums `PostType` (PHOTO, MILESTONE), `MilestoneType` (BIRTHDAY, ADOPTION_DAY, FIRST_OUTING, VET_VISIT, LITTER, FOLLOWERS_500, FOLLOWERS_1K, FOLLOWERS_2_5K, FOLLOWERS_5K, FOLLOWERS_10K, FOLLOWERS_25K, FOLLOWERS_50K, FOLLOWERS_100K, FOLLOWERS_250K, FOLLOWERS_500K, FOLLOWERS_1M) per D-09, and `ReportReason` (SPAM, HARASSMENT, INAPPROPRIATE, MISINFORMATION, OTHER).
|
||||
Models: `Post` (petId FK cascade, type, caption VarChar(500), images PostImage[], milestone Milestone?, timestamps, @@index([petId, createdAt(sort: Desc)])); `PostImage` (postId FK cascade, storageKey, position Int, width/height optional, @@unique([postId, position])); `Milestone` (postId FK cascade @unique, milestoneType, notes VarChar(500) for LITTER free-text per D-09); `Story` (petId FK cascade, storageKey, expiresAt DateTime per D-07, views StoryView[], createdAt, @@index([petId, expiresAt])); `StoryView` (storyId FK with NO onDelete cascade per D-08, viewerPetId FK relation "StoryViewer", viewedAt, @@id([storyId, viewerPetId]) for dedup, @@index([viewedAt]) for 30-day cleanup per D-08); `Follow` (followerPetId relation "Follower" cascade, followeePetId relation "Followee" cascade, createdAt, @@id composite, indexes on both); `Block` (blockerPetId relation "Blocker" cascade, blockedPetId relation "Blocked" cascade, @@id composite); `Report` (id cuid, reporterPetId relation "Reporter", targetPostId optional, targetPetId optional, reason, notes VarChar(500), createdAt).
|
||||
Add Pet back-relations exactly per RESEARCH.md lines 399–412: posts, stories, storyViews @relation("StoryViewer"), following @relation("Follower"), followers @relation("Followee"), blocking @relation("Blocker"), blockedBy @relation("Blocked"), reports @relation("Reporter").
|
||||
Use explicit named relations on all self-referential relations (Follow/Block/Report/StoryView) to avoid the "ambiguous relation" Prisma error (RESEARCH Pitfall 3).
|
||||
Run `npx prisma validate` then `npx prisma generate`.
|
||||
[BLOCKING] After schema validates and generates, run `npx prisma db push` to create all 8 tables in the dev Supabase database. This MUST succeed before any verification.
|
||||
</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 generate` produces a client exporting Post, PostImage, Milestone, Story, StoryView, Follow, Block, Report types
|
||||
- `npx prisma db push` reports the 8 new tables created in the database
|
||||
- MilestoneType enum contains all 5 user-initiated types + 11 follower thresholds (D-09)
|
||||
- StoryView.storyId relation has NO onDelete cascade (D-08 — views survive story expiry)
|
||||
</acceptance_criteria>
|
||||
<done>All 8 models migrated to the database; Prisma client regenerated; schema validates.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Shared libs + router stubs + register in _app.ts</name>
|
||||
<files>src/lib/redis.ts, src/lib/feed-helpers.ts, src/lib/media-url.ts, src/lib/milestone-thresholds.ts, src/trpc/routers/posts.ts, src/trpc/routers/stories.ts, src/trpc/routers/milestones.ts, src/trpc/routers/follows.ts, src/trpc/routers/blocks.ts, src/trpc/routers/reports.ts, src/trpc/routers/feed.ts, src/trpc/routers/_app.ts</files>
|
||||
<read_first>
|
||||
- src/trpc/routers/pets.ts (assertPetOwnership helper pattern + protectedProcedure usage — replicate)
|
||||
- src/trpc/routers/media.ts (server-only import, presigned flow shape)
|
||||
- src/lib/supabase-storage.ts (createSignedUploadUrl, getPublicUrl, STORAGE_BUCKET, MAX_FILE_SIZE, ALLOWED_CONTENT_TYPES exports)
|
||||
- src/lib/avatar-url.ts (getAvatarUrl — replicate as getMediaUrl)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Pattern 2 Redis helpers, Pattern 4 media URL, code examples)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/lib/redis.ts`: export `redis` singleton via `new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN! })` from `@upstash/redis` (RESEARCH code example, lines 811–820). Add `import "server-only"` at top.
|
||||
Create `src/lib/feed-helpers.ts` with `import "server-only"`: export `feedKey(petId: string): string` returning `feed:${petId}`; export async `fanOutPost(redis, postId, petId, createdAt, prisma)`, `backfillOnFollow(redis, followerPetId, followeePetId, prisma, limit=20)`, and `getFeedPage(redis, petId, cursor): Promise<{ postIds: string[]; nextCursor: number | null }>` — implement exactly per RESEARCH Pattern 2 (ZADD pipeline fan-out including own feed per Open Question 3; ZRANGE with maxScore-first arg order per Pitfall 2; cast result as Array<{member,score}> per Pitfall 1; PAGE_SIZE=20, fetch 21 to detect hasNextPage). Per the additional constraint, also expose `FEED_RETENTION_MS = 90 * 24 * 60 * 60 * 1000` and a `trimFeed(redis, petId)` helper using ZREMRANGEBYSCORE for the pg_cron 90-day trim (called by cleanup job, not request path).
|
||||
Create `src/lib/media-url.ts`: export `getMediaUrl(key: string | null | undefined): string | null` returning the public Supabase Storage URL for the `pet-avatars` bucket (RESEARCH Pattern 4 lines 627–634; same bucket, posts/ prefix per A1).
|
||||
Create `src/lib/milestone-thresholds.ts`: export `FOLLOWER_MILESTONE_THRESHOLDS` constant array [500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000] mapped to MilestoneType enum values (D-09, A3 — hard-coded constant, not a DB table).
|
||||
Create 7 router stubs (`posts`, `stories`, `milestones`, `follows`, `blocks`, `reports`, `feed`), each `import "server-only"`, each exporting a `router({})` named e.g. `postsRouter`. Stub each procedure key that downstream plans will implement with a `protectedProcedure` returning a `TRPCError({ code: "NOT_IMPLEMENTED" })` placeholder OR `.query/.mutation` returning a minimal typed shape — the goal is that the router compiles and is callable. Procedure keys to declare: posts → {create, updateCaption, delete, byPetId, getPresignedUrl, confirmUpload}; stories → {create, listActive, recordView, getViewers, hasActiveStory}; milestones → {create, listByPet}; follows → {create, delete, isFollowing, listFollowers, listFollowing}; blocks → {create, list}; reports → {create}; feed → {getFeed}.
|
||||
Register all 7 in `src/trpc/routers/_app.ts` alongside existing `pets` and `media`: posts, stories, milestones, follows, blocks, reports, feed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- _app.ts registers all 9 routers (pets, media, posts, stories, milestones, follows, blocks, reports, feed)
|
||||
- feed.getFeed input schema includes a field named exactly `cursor` (z.number().nullish()) per RESEARCH Pitfall 6
|
||||
- redis.ts, feed-helpers.ts, media-url.ts all carry `import "server-only"` except media-url.ts which uses NEXT_PUBLIC_ env (client-safe, no server-only)
|
||||
- getMediaUrl mirrors getAvatarUrl signature
|
||||
</acceptance_criteria>
|
||||
<done>All shared libs and 7 router stubs exist, registered, and the project type-checks.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: 7 test scaffolds + prisma-mock extension + install deps + shadcn components</name>
|
||||
<files>src/__tests__/posts.test.ts, src/__tests__/stories.test.ts, src/__tests__/milestones.test.ts, src/__tests__/follows.test.ts, src/__tests__/feed.test.ts, src/__tests__/blocks.test.ts, src/__tests__/reports.test.ts, src/__tests__/helpers/prisma-mock.ts, package.json, components.json</files>
|
||||
<read_first>
|
||||
- src/__tests__/helpers/prisma-mock.ts (existing mock factory — extend with new models)
|
||||
- src/__tests__/media-upload.test.ts (test style: vi.mock, TRPCError assertions, caller pattern)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Validation Architecture → Phase Requirements → Test Map, lines 982–1017)
|
||||
- .planning/phases/02-content-core/02-VALIDATION.md (Per-Task Verification Map — exact commands)
|
||||
</read_first>
|
||||
<action>
|
||||
Extend `src/__tests__/helpers/prisma-mock.ts` with mock methods for post, postImage, milestone, story, storyView, follow, block, report (findUnique, findMany, create, update, delete, upsert, count, deleteMany as needed).
|
||||
Create 7 test files with `it.todo` or failing `it` stubs covering every requirement test in RESEARCH lines 986–1000 and VALIDATION lines 41–47:
|
||||
- posts.test.ts: "create single photo" (PHOTO-01), "carousel limit" rejects >10 (PHOTO-02), "updateCaption forbidden" non-owner (PHOTO-03), "delete cascades" (PHOTO-04)
|
||||
- stories.test.ts: "expiresAt 24h" (STORY-01), "hasActiveStory" filters expiresAt>now (STORY-02), "recordView dedup" upsert (STORY-03)
|
||||
- milestones.test.ts: "invalid type" rejected (MILE-01)
|
||||
- follows.test.ts: "follow creates row" (SOCL-01), "unfollow removes row" (SOCL-02), "listFollowers scoped" (SOCL-03)
|
||||
- feed.test.ts: "empty feed" (FEED-01), "cursor pagination" returns nextCursor (FEED-01) — mock Redis via `vi.mock('@/lib/redis', ...)` per RESEARCH line 1014
|
||||
- blocks.test.ts: "block filters feed" (SAFE-01)
|
||||
- reports.test.ts: "report creates row" (SAFE-02)
|
||||
Each test file MUST run (no import errors) even with todo stubs. Use `it.todo(...)` for not-yet-implemented behavior so the suite stays green but the gap is tracked.
|
||||
Run `npm install react-intersection-observer` (v10.0.3 per RESEARCH legitimacy audit [OK]).
|
||||
Run `npx shadcn@latest add carousel sheet alert-dialog scroll-area tabs popover radio-group` (official registry only — no legitimacy gate needed per RESEARCH).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/__tests__/posts.test.ts src/__tests__/stories.test.ts src/__tests__/milestones.test.ts src/__tests__/follows.test.ts src/__tests__/feed.test.ts src/__tests__/blocks.test.ts src/__tests__/reports.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All 7 test files load and run without import errors (todo/skipped allowed; zero failures)
|
||||
- prisma-mock.ts exposes mocks for all 8 new models
|
||||
- package.json lists react-intersection-observer ^10.0.3
|
||||
- src/components/ui/ contains carousel.tsx, sheet.tsx, alert-dialog.tsx, scroll-area.tsx, tabs.tsx, popover.tsx, radio-group.tsx
|
||||
- feed.test.ts mocks @/lib/redis (no real Redis calls in tests)
|
||||
</acceptance_criteria>
|
||||
<done>7 test scaffolds run green, deps installed, shadcn components present.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → tRPC mutation | Untrusted petId/key/input crosses into server procedures |
|
||||
| package install (npm/shadcn) | Third-party code enters the build |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-01 | Tampering | Prisma schema relations | mitigate | Explicit named self-relations prevent ambiguous-relation footguns; StoryView NOT cascade-deleted (D-08) enforced at schema layer |
|
||||
| T-02-02 | Information Disclosure | redis.ts / feed-helpers.ts | mitigate | `import "server-only"` on all server libs; UPSTASH tokens never reach client bundle |
|
||||
| T-02-SC | Tampering | npm install react-intersection-observer + shadcn components | mitigate | RESEARCH Package Legitimacy Audit verified react-intersection-observer [OK] (4M+ weekly downloads, official repo); embla [OK]; shadcn official registry only; no postinstall scripts. No [ASSUMED]/[SUS] packages remain — no blocking checkpoint required. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx prisma validate` exits 0; `npx prisma db push` created 8 tables
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- `npx vitest run` (full suite) green — Phase 1 tests still pass, 7 new scaffolds run
|
||||
- All 7 routers registered in _app.ts
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 8 Prisma models exist in the database
|
||||
- 7 tRPC routers registered and type-checking
|
||||
- 7 test scaffolds present and runnable
|
||||
- react-intersection-observer + 7 shadcn components installed
|
||||
- Redis/feed-helpers/media-url/milestone-thresholds libs exist
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-content-core/02-01-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
phase: 02-content-core
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: ["02-01"]
|
||||
files_modified:
|
||||
- src/trpc/routers/follows.ts
|
||||
- src/trpc/routers/blocks.ts
|
||||
- src/trpc/routers/reports.ts
|
||||
- src/__tests__/follows.test.ts
|
||||
- src/__tests__/blocks.test.ts
|
||||
- src/__tests__/reports.test.ts
|
||||
- src/components/social/FollowButton.tsx
|
||||
- src/components/social/PetListItem.tsx
|
||||
- src/components/safety/BlockDialog.tsx
|
||||
- src/components/safety/ReportSheet.tsx
|
||||
- src/app/(app)/pets/[petId]/followers/page.tsx
|
||||
- src/app/(app)/pets/[petId]/following/page.tsx
|
||||
- src/app/(app)/pets/[petId]/page.tsx
|
||||
autonomous: true
|
||||
requirements: [SOCL-01, SOCL-02, SOCL-03, SAFE-01, SAFE-02]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A pet can follow and unfollow another pet (pet-to-pet, never owner-to-owner)"
|
||||
- "Owner can view their pet's followers and following lists"
|
||||
- "Owner can block another pet — blocked pet's content is hidden and interaction prevented"
|
||||
- "Owner can report a post or pet profile with a reason"
|
||||
artifacts:
|
||||
- path: "src/trpc/routers/follows.ts"
|
||||
provides: "create, delete, isFollowing, listFollowers, listFollowing"
|
||||
exports: ["followsRouter"]
|
||||
- path: "src/trpc/routers/blocks.ts"
|
||||
provides: "create, list"
|
||||
exports: ["blocksRouter"]
|
||||
- path: "src/trpc/routers/reports.ts"
|
||||
provides: "create"
|
||||
exports: ["reportsRouter"]
|
||||
- path: "src/components/social/FollowButton.tsx"
|
||||
provides: "optimistic follow/unfollow button"
|
||||
min_lines: 30
|
||||
key_links:
|
||||
- from: "src/components/social/FollowButton.tsx"
|
||||
to: "follows.create / follows.delete"
|
||||
via: "useMutation with optimistic update"
|
||||
pattern: "follows\\.(create|delete)"
|
||||
- from: "src/trpc/routers/blocks.ts"
|
||||
to: "prisma.block"
|
||||
via: "ownership-asserted create"
|
||||
pattern: "prisma\\.block\\.create"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Deliver the social graph and safety baseline as complete vertical slices: follow/unfollow with optimistic UI and follower/following list pages (SOCL-01–03), and block/report with confirmation dialogs (SAFE-01, SAFE-02). Per the locked roadmap decision, block/report ships before any feed content is publicly visible — this plan runs in Wave 1 alongside feed infrastructure so the feed (Wave 2) can apply block filters from day one.
|
||||
|
||||
Purpose: The follow graph is the input to the Redis feed fan-out. Block must be authoritative in Postgres before feed hydration filters on it. Implementing SOCL-01 (D — follow is pet-to-pet, cites the pet-as-actor identity model) wires the graph that FEED-01 consumes.
|
||||
Output: 3 routers (follows, blocks, reports) fully implemented + UI (FollowButton, PetListItem, BlockDialog, ReportSheet) + followers/following pages + profile wiring.
|
||||
</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/02-content-core/02-RESEARCH.md
|
||||
@.planning/phases/02-content-core/02-UI-SPEC.md
|
||||
@.planning/phases/02-content-core/02-01-SUMMARY.md
|
||||
@src/trpc/routers/pets.ts
|
||||
@src/trpc/init.ts
|
||||
@src/lib/feed-helpers.ts
|
||||
@src/lib/avatar-url.ts
|
||||
@src/app/(app)/pets/[petId]/page.tsx
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: follows router (create/delete/isFollowing/listFollowers/listFollowing) + tests</name>
|
||||
<files>src/trpc/routers/follows.ts, src/__tests__/follows.test.ts</files>
|
||||
<behavior>
|
||||
- follows.create inserts a Follow row (followerPetId, followeePetId) — asserts ctx.userId owns followerPetId (SOCL-01)
|
||||
- follows.create triggers backfillOnFollow to seed followee's recent posts into follower's Redis feed
|
||||
- follows.delete removes the Follow row — asserts ownership of followerPetId (SOCL-02)
|
||||
- follows.listFollowers / listFollowing return pet rows scoped by petId (SOCL-03)
|
||||
- isFollowing returns boolean for (followerPetId, followeePetId)
|
||||
- Self-follow (followerPetId === followeePetId) is rejected
|
||||
</behavior>
|
||||
<read_first>
|
||||
- src/trpc/routers/pets.ts (assertPetOwnership — copy this exact helper into follows.ts or import a shared one)
|
||||
- src/lib/feed-helpers.ts (backfillOnFollow signature from Wave 0)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Pattern 2 backfillOnFollow lines 461–487; Security Domain — pet-to-pet only, never ownerId)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement followsRouter replacing the Wave 0 stub. `create`: input { followerPetId, followeePetId }; assert ctx.userId owns followerPetId via assertPetOwnership; reject if followerPetId === followeePetId (BAD_REQUEST); `prisma.follow.create` with composite key; then call `backfillOnFollow(redis, followerPetId, followeePetId, prisma)` from feed-helpers. `delete`: input { followerPetId, followeePetId }; assert ownership of followerPetId; `prisma.follow.delete` by composite `followerPetId_followeePetId`. `isFollowing`: input { followerPetId, followeePetId }; `prisma.follow.findUnique` → return boolean. `listFollowers`: input { petId }; return `prisma.follow.findMany({ where: { followeePetId: petId }, include: { follower: { select: id,name,avatarKey,species } } })`. `listFollowing`: input { petId }; mirror with `followerPetId: petId` and include followee. All mutations use protectedProcedure. The social graph references petId exclusively — never ownerId (RESEARCH anti-pattern). Implements SOCL-01, SOCL-02, SOCL-03.
|
||||
Fill in follows.test.ts: "follow creates row", "unfollow removes row", "listFollowers scoped" + self-follow rejection. Mock backfillOnFollow via `vi.mock('@/lib/feed-helpers')`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/__tests__/follows.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All follows.test.ts tests pass (no todo remaining for SOCL-01/02/03)
|
||||
- create asserts ownership of followerPetId before insert; self-follow → BAD_REQUEST
|
||||
- listFollowers/listFollowing scoped by petId and include pet display fields
|
||||
- backfillOnFollow invoked on create
|
||||
</acceptance_criteria>
|
||||
<done>follows router fully implemented; SOCL-01–03 tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: blocks + reports routers + tests</name>
|
||||
<files>src/trpc/routers/blocks.ts, src/trpc/routers/reports.ts, src/__tests__/blocks.test.ts, src/__tests__/reports.test.ts</files>
|
||||
<behavior>
|
||||
- blocks.create inserts a Block row (blockerPetId, blockedPetId) — asserts ctx.userId owns blockerPetId (SAFE-01)
|
||||
- blocks.list returns the active pet's blocked petIds
|
||||
- A query helper demonstrates blocked pets are excluded (feed filter test — blocked pet's posts not returned)
|
||||
- reports.create inserts a Report with reason enum + optional notes, scoped to reporterPetId (SAFE-02)
|
||||
- Report targets either a post (targetPostId) or a profile (targetPetId), not both null
|
||||
</behavior>
|
||||
<read_first>
|
||||
- src/trpc/routers/pets.ts (assertPetOwnership pattern)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Security Domain threat table; feed block filter lines 530–549)
|
||||
- .planning/phases/02-content-core/02-UI-SPEC.md (Report reason enum values + copy)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement blocksRouter: `create` input { blockerPetId, blockedPetId }; assert ctx.userId owns blockerPetId; reject self-block; `prisma.block.create` composite key (SAFE-01). `list` input { petId }; assert ownership; return `prisma.block.findMany({ where: { blockerPetId: petId }, select: { blockedPetId: true } })` — the array consumed by feed hydration filter. Blocks are authoritative in Postgres, never Redis (RESEARCH anti-pattern).
|
||||
Implement reportsRouter: `create` input { reporterPetId, targetPostId?: string, targetPetId?: string, reason: ReportReason enum, notes?: string(max 500) }; assert ctx.userId owns reporterPetId; require at least one of targetPostId/targetPetId (BAD_REQUEST if both null); `prisma.report.create` (SAFE-02). Reason enum: SPAM, HARASSMENT, INAPPROPRIATE, MISINFORMATION, OTHER.
|
||||
Fill blocks.test.ts: "block filters feed" — assert a blocked pet's posts are excluded by the block-filter helper. Fill reports.test.ts: "report creates row" with reason; assert both-targets-null rejection.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/__tests__/blocks.test.ts src/__tests__/reports.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- blocks.test.ts and reports.test.ts pass (SAFE-01, SAFE-02)
|
||||
- block/report assert ownership of the acting pet via ctx.userId
|
||||
- report requires at least one target; reason constrained to the 5-value enum
|
||||
- self-block rejected
|
||||
</acceptance_criteria>
|
||||
<done>blocks + reports routers implemented; SAFE-01, SAFE-02 tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Social + safety UI — FollowButton, PetListItem, BlockDialog, ReportSheet, followers/following pages</name>
|
||||
<files>src/components/social/FollowButton.tsx, src/components/social/PetListItem.tsx, src/components/safety/BlockDialog.tsx, src/components/safety/ReportSheet.tsx, src/app/(app)/pets/[petId]/followers/page.tsx, src/app/(app)/pets/[petId]/following/page.tsx, src/app/(app)/pets/[petId]/page.tsx</files>
|
||||
<read_first>
|
||||
- src/components/layout/Sidebar.tsx (tRPC client usage pattern: useTRPC, queryOptions/mutationOptions; base-nova DropdownMenu no asChild)
|
||||
- src/app/(app)/pets/[petId]/page.tsx (existing profile page — add stats row + Follow button + options menu)
|
||||
- .planning/phases/02-content-core/02-UI-SPEC.md (Follow button states, PetListItem spec, Report Sheet spec, Block dialog copy, all locked copy strings)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Pattern 6 optimistic follow/unfollow)
|
||||
</read_first>
|
||||
<action>
|
||||
`FollowButton.tsx` (client): props { followerPetId, followeePetId }; uses `isFollowing` query + `follows.create`/`follows.delete` mutations with TanStack Query optimistic update per RESEARCH Pattern 6 (onMutate cancel + setData, onError revert + toast "Something went wrong. Try again.", onSettled invalidate). States per UI-SPEC: not-following = "Follow" bg-orange-500 text-white; following = "Following" variant outline + check icon; pending = disabled spinner opacity-60; hidden when followerPetId === followeePetId (own pet). 44px min-height touch target. Unfollow opens AlertDialog confirmation ("Unfollow {pet name}?" copy). Implements SOCL-01, SOCL-02.
|
||||
`PetListItem.tsx`: row with sm Avatar (getAvatarUrl), pet name text-sm font-semibold, species Badge secondary, right-aligned FollowButton. Used in lists (SOCL-03).
|
||||
`followers/page.tsx` + `following/page.tsx` (RSC shells): heading "{N} Followers" / "Following {N}" (UI-SPEC copy), ScrollArea + map of PetListItem from follows.listFollowers/listFollowing; empty states per UI-SPEC copy. Implements SOCL-03.
|
||||
`BlockDialog.tsx` (client): AlertDialog with "Block {pet name}?" heading + body copy (UI-SPEC); destructive "Block" confirm → blocks.create mutation → toast "You've blocked {pet name}". Implements SAFE-01.
|
||||
`ReportSheet.tsx` (client): Sheet (bottom mobile / right desktop) with RadioGroup of 5 reasons (UI-SPEC labels), optional notes Textarea (max 500), destructive "Submit Report" → reports.create → toast "Report submitted. We'll review it shortly.". Implements SAFE-02.
|
||||
Update `pets/[petId]/page.tsx`: add stats row (posts/followers/following counts; followers & following are links to the list pages), render FollowButton (hidden on own pet), add post-options MoreHorizontal DropdownMenu wiring BlockDialog + ReportSheet for other pets' profiles.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit && npx vitest run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tsc exits 0; full vitest suite green
|
||||
- FollowButton hidden when followerPetId === followeePetId; optimistic toggle + revert-on-error implemented
|
||||
- followers/following pages render PetListItem lists with correct headings + empty-state copy
|
||||
- BlockDialog and ReportSheet use exact UI-SPEC copy strings and destructive styling
|
||||
- Profile page links followers/following counts to the list routes
|
||||
</acceptance_criteria>
|
||||
<done>Full social + safety UI wired; a user can follow/unfollow, view lists, block, and report.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → follows/blocks/reports mutations | Client supplies petIds for follow/block/report actions |
|
||||
| feed read ← block table | Block filter must be authoritative when feed hydrates |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-10 | Spoofing | follows.create / blocks.create / reports.create | mitigate | assertPetOwnership(prisma, actingPetId, ctx.userId) before every write — never trust client petId; acting pet (follower/blocker/reporter) must belong to ctx.userId |
|
||||
| T-02-11 | Tampering | follows self-follow / blocks self-block | mitigate | Reject when source petId === target petId (BAD_REQUEST) |
|
||||
| T-02-12 | Information Disclosure | blocked pet content in feed/profile | mitigate | blocks.list authoritative in Postgres; feed hydration (Wave 2) applies `pet: { id: { notIn: blockedPetIds } }` filter |
|
||||
| T-02-13 | Tampering | reports.create target validation | mitigate | Require at least one of targetPostId/targetPetId; reason constrained to ReportReason enum via Zod |
|
||||
| T-02-14 | Denial of Service | mass report abuse | accept | Rate-limiting deferred to Phase 4 per RESEARCH; Phase 2 relies on auth + Zod; low risk at v1 scale |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx vitest run` full suite green (follows, blocks, reports + Phase 1)
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- Follow graph is pet-to-pet (no ownerId in follows/blocks/reports)
|
||||
- Block table queryable for feed filter
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SOCL-01, SOCL-02, SOCL-03 implemented and tested
|
||||
- SAFE-01, SAFE-02 implemented and tested
|
||||
- FollowButton optimistic; followers/following pages live; block/report dialogs wired to profile
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-content-core/02-02-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
phase: 02-content-core
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: ["02-01"]
|
||||
files_modified:
|
||||
- src/trpc/routers/feed.ts
|
||||
- src/__tests__/feed.test.ts
|
||||
- src/components/feed/FeedList.tsx
|
||||
- src/components/feed/SkeletonCard.tsx
|
||||
- src/components/feed/EmptyFeed.tsx
|
||||
- src/app/(app)/feed/page.tsx
|
||||
- src/app/(app)/layout.tsx
|
||||
- src/app/page.tsx
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/components/layout/MobileNav.tsx
|
||||
autonomous: true
|
||||
requirements: [FEED-01]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Owner sees a chronological home feed of posts from followed pets plus their own pet's posts"
|
||||
- "Feed loads more posts on scroll via cursor pagination (no OFFSET)"
|
||||
- "Blocked pets' posts never appear in the feed"
|
||||
- "Home route (/) redirects to /feed; Home nav link points to /feed"
|
||||
- "Empty feed shows the locked empty-state copy with a Find Pets CTA"
|
||||
artifacts:
|
||||
- path: "src/trpc/routers/feed.ts"
|
||||
provides: "getFeed infinite query backed by Redis sorted sets"
|
||||
exports: ["feedRouter"]
|
||||
- path: "src/components/feed/FeedList.tsx"
|
||||
provides: "infinite-scroll feed list with IntersectionObserver sentinel"
|
||||
min_lines: 40
|
||||
- path: "src/app/(app)/feed/page.tsx"
|
||||
provides: "/feed route shell"
|
||||
min_lines: 10
|
||||
key_links:
|
||||
- from: "src/components/feed/FeedList.tsx"
|
||||
to: "feed.getFeed"
|
||||
via: "useInfiniteQuery + useInView sentinel"
|
||||
pattern: "useInfiniteQuery|useInView"
|
||||
- from: "src/trpc/routers/feed.ts"
|
||||
to: "src/lib/feed-helpers.ts"
|
||||
via: "getFeedPage Redis read"
|
||||
pattern: "getFeedPage"
|
||||
- from: "src/app/page.tsx"
|
||||
to: "/feed"
|
||||
via: "redirect"
|
||||
pattern: "redirect\\(.*/feed"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Deliver the chronological home feed (FEED-01) as a complete vertical slice: the Redis-backed `feed.getFeed` infinite query, the FeedList client with IntersectionObserver cursor pagination, the /feed page, the home redirect (/ → /feed per D-04), and the nav update (Home → /feed per D-05). The feed hydrates post IDs from Redis sorted sets and filters blocked pets at the Prisma layer.
|
||||
|
||||
Purpose: FEED-01 is the central deliverable of Phase 2 — it is what the owner lands on after login (D-04). The Redis fan-out written in Wave 0 (feed-helpers) is consumed here. PostCard rendering is owned by the photo-posts wave (02-04); this plan renders feed items via a PostCard the photo wave will flesh out — to avoid a contract gap, this plan ships a minimal PostCard placeholder that 02-04 replaces.
|
||||
Output: feed router implemented + FeedList/SkeletonCard/EmptyFeed + /feed page + home redirect (D-04) + nav Home→/feed (D-05) + empty-state per D-06.
|
||||
</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/02-content-core/02-RESEARCH.md
|
||||
@.planning/phases/02-content-core/02-UI-SPEC.md
|
||||
@.planning/phases/02-content-core/02-01-SUMMARY.md
|
||||
@src/lib/feed-helpers.ts
|
||||
@src/trpc/init.ts
|
||||
@src/trpc/routers/pets.ts
|
||||
@src/app/(app)/layout.tsx
|
||||
@src/components/layout/Sidebar.tsx
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: feed.getFeed router (Redis read + block filter + ordered hydration) + tests</name>
|
||||
<files>src/trpc/routers/feed.ts, src/__tests__/feed.test.ts</files>
|
||||
<behavior>
|
||||
- feed.getFeed for a pet with no follows and no own posts returns empty posts + null nextCursor (FEED-01)
|
||||
- feed.getFeed reads post IDs from Redis sorted set via getFeedPage, hydrates with Prisma, returns nextCursor for pagination (FEED-01)
|
||||
- Blocked pets are excluded from hydrated results (pet.id notIn blockedPetIds)
|
||||
- Hydrated posts preserve Redis score order (newest first), not Prisma natural order
|
||||
- input includes a field named exactly `cursor` (z.number().nullish()) so useInfiniteQuery is exposed
|
||||
</behavior>
|
||||
<read_first>
|
||||
- src/lib/feed-helpers.ts (getFeedPage signature + return shape from Wave 0)
|
||||
- src/trpc/routers/pets.ts (assertPetOwnership)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Pattern 2 feed retrieval + hydration lines 490–549; Pattern 3 tRPC infinite query lines 552–605; Pitfall 6 cursor field name; Pitfall 7 hydration order)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement feedRouter.getFeed (protectedProcedure) replacing the Wave 0 stub. Input: { petId: z.string(), cursor: z.number().nullish(), limit: z.number().min(1).max(50).default(20) }. Assert ctx.userId owns petId (assertPetOwnership). Call `getFeedPage(redis, petId, cursor ?? null)` → { postIds, nextCursor }. Fetch blocked petIds: `prisma.block.findMany({ where: { blockerPetId: petId }, select: { blockedPetId: true } })`. Hydrate: `prisma.post.findMany({ where: { id: { in: postIds }, pet: { id: { notIn: blockedPetIds } } }, include: { pet: { select: id,name,avatarKey }, images: { orderBy: { position: 'asc' } }, milestone: true } })`. Re-order results to match postIds order via an id→post map (RESEARCH Pitfall 7). Return { posts, nextCursor }. The `cursor` input field name is mandatory for useInfiniteQuery (Pitfall 6). Own posts already appear because fan-out ZADDs to feed:{petId} (per additional constraint: own posts in own feed). Implements FEED-01.
|
||||
Fill feed.test.ts: "empty feed" (no follows → empty + null cursor) and "cursor pagination" (getFeedPage returns nextCursor; assert pass-through). Mock `@/lib/redis` and getFeedPage via `vi.mock('@/lib/feed-helpers')`; mock prisma.block + prisma.post.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/__tests__/feed.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- feed.test.ts passes (FEED-01 both cases)
|
||||
- input has a `cursor` field (z.number().nullish())
|
||||
- block filter applied at hydration; result re-ordered to Redis score order
|
||||
- ownership of petId asserted before read
|
||||
</acceptance_criteria>
|
||||
<done>feed router implemented; FEED-01 tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: FeedList (infinite scroll) + SkeletonCard + EmptyFeed + minimal PostCard placeholder</name>
|
||||
<files>src/components/feed/FeedList.tsx, src/components/feed/SkeletonCard.tsx, src/components/feed/EmptyFeed.tsx, src/components/feed/PostCard.tsx</files>
|
||||
<read_first>
|
||||
- src/components/layout/Sidebar.tsx (useTRPC client pattern, infiniteQueryOptions usage)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Pattern 3 client useInfiniteQuery + useInView sentinel lines 576–605)
|
||||
- .planning/phases/02-content-core/02-UI-SPEC.md (Feed layout, SkeletonCard spec, EmptyFeed copy, end-of-feed message, infinite scroll contract)
|
||||
</read_first>
|
||||
<action>
|
||||
`PostCard.tsx` (minimal placeholder for this wave — 02-04 replaces with full photo/carousel implementation): renders header row (Avatar via getAvatarUrl + pet name text-base font-semibold + timestamp text-xs muted), a single image area (getMediaUrl, object-cover max-h-560px) when images exist, and caption text-sm. Accept a typed `post` prop matching feed.getFeed output. Mark with a clear `// TODO(02-04): full carousel + caption clamp + options menu` comment so the photo wave knows the seam.
|
||||
`SkeletonCard.tsx`: animate-pulse skeleton — 80px header row + image aspect placeholder + 2 text lines (UI-SPEC).
|
||||
`EmptyFeed.tsx`: centered "Your feed is empty" heading (text-xl font-semibold) + "Follow some pets to see their posts here." body + "Find Pets" CTA button linking to /explore (placeholder route — Phase 3) per D-06 and UI-SPEC copy.
|
||||
`FeedList.tsx` (client): uses useInfiniteQuery via `feed.getFeed.infiniteQueryOptions({ petId: activePetId }, { getNextPageParam: (last) => last.nextCursor, initialCursor: null, staleTime: 30_000 })`; flatMap pages → posts; render PostCard list in max-w-[640px] centered, xl gap; `useInView` sentinel at bottom calls fetchNextPage when inView && hasNextPage && !isFetchingNextPage (RESEARCH Pattern 3); show 3 SkeletonCards while fetching next page; render EmptyFeed when zero posts and not loading; end-of-feed message "You're all caught up. Follow more pets to see more posts." when !hasNextPage and posts exist. activePetId from useActivePet(). Implements FEED-01.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tsc exits 0
|
||||
- FeedList uses useInfiniteQuery + useInView sentinel; cursor pagination wired
|
||||
- EmptyFeed uses exact UI-SPEC copy + Find Pets CTA (D-06)
|
||||
- PostCard placeholder accepts feed.getFeed post shape and is marked for 02-04 replacement
|
||||
</acceptance_criteria>
|
||||
<done>Feed list renders with infinite scroll, skeletons, and empty state.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: /feed page + home redirect (D-04) + nav Home→/feed (D-05) + onboarding/empty-state redirect (D-06)</name>
|
||||
<files>src/app/(app)/feed/page.tsx, src/app/(app)/layout.tsx, src/app/page.tsx, src/components/layout/Sidebar.tsx, src/components/layout/MobileNav.tsx</files>
|
||||
<read_first>
|
||||
- src/app/(app)/layout.tsx (current redirect logic: zero pets → /onboarding/pet)
|
||||
- src/app/page.tsx (current root route behavior)
|
||||
- src/components/layout/Sidebar.tsx (navLinks array — Home currently href "/")
|
||||
- src/components/layout/MobileNav.tsx (mobile nav links)
|
||||
- .planning/phases/02-content-core/02-CONTEXT.md (D-04, D-05, D-06 exact wording)
|
||||
</read_first>
|
||||
<action>
|
||||
`feed/page.tsx` (RSC shell): renders the StoryTray seam comment (// TODO(02-05): StoryTray above feed) + `<FeedList />` client component inside the max-w-[640px] px-6 py-8 container; document title "Home — PawFeed". The /feed route lives in the (app) protected group.
|
||||
`src/app/page.tsx`: redirect("/feed") for authenticated users (D-04 — home route redirects to /feed). Keep unauthenticated handling consistent with existing proxy.ts gating.
|
||||
`(app)/layout.tsx`: keep D-06 logic — zero pets → /onboarding/pet. Do NOT redirect pets-with-no-follows away; the feed EmptyFeed handles that empty state (D-06). Confirm no behavior regresses the onboarding redirect.
|
||||
`Sidebar.tsx`: change the Home navLink href from "/" to "/feed" and keep label "Home" (D-05); update isCurrentPage active-detection so /feed highlights Home. Mark Explore/Notifications/Messages still placeholders.
|
||||
`MobileNav.tsx`: same Home → /feed change; keep label "Home" (D-05). Note: this plan does NOT add the center + button sheet — that is 02-04 (which also edits MobileNav in a later wave, creating the correct file-overlap ordering).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit && npx vitest run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- / redirects to /feed (D-04)
|
||||
- Sidebar + MobileNav Home link → /feed, label "Home" (D-05)
|
||||
- Zero-pets owner still redirected to /onboarding/pet; pets-without-follows land on feed empty state (D-06)
|
||||
- /feed renders FeedList; full suite green
|
||||
</acceptance_criteria>
|
||||
<done>Home is the feed; nav points there; empty/onboarding flows correct.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → feed.getFeed | Client supplies petId + cursor for feed reads |
|
||||
| Redis sorted set → feed hydration | Pre-computed post IDs hydrated against Postgres |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-20 | Spoofing | feed.getFeed petId | mitigate | assertPetOwnership(prisma, petId, ctx.userId) — owner can only read their own pet's feed |
|
||||
| T-02-21 | Information Disclosure | blocked pet content in feed | mitigate | Block filter `pet: { id: { notIn: blockedPetIds } }` applied at Prisma hydration, authoritative in Postgres (not Redis) |
|
||||
| T-02-22 | Tampering | feed poisoning via direct ZADD | mitigate | Redis writes only from server-side ownership-asserted mutations (fan-out in 02-04); getFeed is read-only ZRANGE |
|
||||
| T-02-23 | Denial of Service | unbounded Redis feed set growth | accept | 90-day pg_cron trim (trimFeed helper from Wave 0) handles growth; no request-path impact |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx vitest run` full suite green
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- / → /feed redirect; Home nav → /feed
|
||||
- Feed empty state renders for pets with no follows (D-06)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- FEED-01 implemented: chronological Redis-backed feed with cursor pagination + block filter
|
||||
- /feed is the post-login landing page (D-04); Home nav points to /feed (D-05)
|
||||
- Empty-state and onboarding redirect behavior correct (D-06)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-content-core/02-03-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,212 @@
|
||||
---
|
||||
phase: 02-content-core
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["02-01", "02-03"]
|
||||
files_modified:
|
||||
- src/trpc/routers/posts.ts
|
||||
- src/__tests__/posts.test.ts
|
||||
- src/components/feed/PostCard.tsx
|
||||
- src/components/post-creation/PostTypeSheet.tsx
|
||||
- src/components/post-creation/PhotoPostForm.tsx
|
||||
- src/components/post-creation/MultiImageUpload.tsx
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/components/layout/MobileNav.tsx
|
||||
autonomous: true
|
||||
requirements: [PHOTO-01, PHOTO-02, PHOTO-03, PHOTO-04]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A pet can post a single photo with a caption"
|
||||
- "A pet can post a carousel of up to 10 images with a caption"
|
||||
- "Owner can edit a post caption after publishing"
|
||||
- "Owner can delete their pet's post (images cascade, removed from feed)"
|
||||
- "After posting, the modal closes and the owner lands on /feed with the new post at top"
|
||||
artifacts:
|
||||
- path: "src/trpc/routers/posts.ts"
|
||||
provides: "create, updateCaption, delete, byPetId, getPresignedUrl, confirmUpload"
|
||||
exports: ["postsRouter"]
|
||||
- path: "src/components/feed/PostCard.tsx"
|
||||
provides: "full photo + carousel post card with options menu"
|
||||
min_lines: 60
|
||||
- path: "src/components/post-creation/PhotoPostForm.tsx"
|
||||
provides: "multi-image upload + caption creation form"
|
||||
min_lines: 40
|
||||
key_links:
|
||||
- from: "src/trpc/routers/posts.ts"
|
||||
to: "src/lib/feed-helpers.ts"
|
||||
via: "fanOutPost after create"
|
||||
pattern: "fanOutPost"
|
||||
- from: "src/components/post-creation/PhotoPostForm.tsx"
|
||||
to: "posts.create"
|
||||
via: "useMutation onSuccess → router.push('/feed')"
|
||||
pattern: "posts\\.create"
|
||||
- from: "src/components/post-creation/PostTypeSheet.tsx"
|
||||
to: "PhotoPostForm Dialog"
|
||||
via: "type選択 → full-screen Dialog"
|
||||
pattern: "Dialog"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Deliver photo posts as a complete vertical slice (PHOTO-01–04): the posts router (create with fan-out, caption edit, delete, presigned upload reusing the Phase 1 Supabase Storage pipeline), the full PostCard (single image + Embla carousel + caption clamp + options menu), and the post-creation flow (+ button → PostTypeSheet → full-screen Dialog → PhotoPostForm → land on /feed). This replaces the minimal PostCard placeholder from 02-03.
|
||||
|
||||
Purpose: PHOTO-01–04 are the primary content type. Post creation wires D-01 (+ button → bottom Sheet with 3 type choices), D-02 (full-screen Dialog, no URL change), and D-03 (close → land on /feed with new post at top). The fan-out on create makes posts appear in followers' feeds (consumes Wave 0 feed-helpers).
|
||||
Output: posts router fully implemented + full PostCard + PostTypeSheet + PhotoPostForm + MultiImageUpload + nav + button wiring.
|
||||
</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/02-content-core/02-RESEARCH.md
|
||||
@.planning/phases/02-content-core/02-UI-SPEC.md
|
||||
@.planning/phases/02-content-core/02-01-SUMMARY.md
|
||||
@.planning/phases/01-foundation/01-04-SUMMARY.md
|
||||
@src/trpc/routers/media.ts
|
||||
@src/lib/supabase-storage.ts
|
||||
@src/lib/media-url.ts
|
||||
@src/lib/feed-helpers.ts
|
||||
@src/components/pet/AvatarUpload.tsx
|
||||
@src/components/layout/MobileNav.tsx
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: posts router — getPresignedUrl, confirmUpload, create+fanOut, updateCaption, delete, byPetId + tests</name>
|
||||
<files>src/trpc/routers/posts.ts, src/__tests__/posts.test.ts</files>
|
||||
<behavior>
|
||||
- posts.create inserts Post + PostImage[] with petId, asserts ctx.userId owns petId, then fans out to Redis (PHOTO-01)
|
||||
- posts.create rejects >10 images (PHOTO-02 — Zod array max 10)
|
||||
- posts.updateCaption requires ownership — non-owner gets FORBIDDEN (PHOTO-03)
|
||||
- posts.delete cascades PostImages and removes post from feed (PHOTO-04)
|
||||
- getPresignedUrl scopes key to posts/{ownerId}/{petId}/{uuid}.ext, validates contentType + size
|
||||
- confirmUpload validates key starts with 'posts/' (path-traversal prevention)
|
||||
</behavior>
|
||||
<read_first>
|
||||
- src/trpc/routers/media.ts (getPresignedUrl + confirmUpload exact shape: ownership assertion, contentType enum, fileSize max, key validation — replicate for posts/ prefix)
|
||||
- src/lib/supabase-storage.ts (createSignedUploadUrl, ALLOWED_CONTENT_TYPES, MAX_FILE_SIZE)
|
||||
- src/lib/feed-helpers.ts (fanOutPost signature)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Pattern 4 posts/ key + confirmUpload prefix Pitfall 4; Pattern 2 fanOutPost; Security Domain threats)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement postsRouter (server-only) replacing Wave 0 stub:
|
||||
`getPresignedUrl`: input { petId, contentType: z.enum(ALLOWED_CONTENT_TYPES), fileSize: z.number().max(MAX_FILE_SIZE) }; assert ownership; key = `posts/${ctx.userId}/${petId}/${randomUUID()}.${ext}`; return { presignedUrl, key } (mirror media.ts).
|
||||
`confirmUpload` is folded into `create`: `create` input { petId, caption: z.string().max(500).optional(), imageKeys: z.array(z.string().startsWith('posts/')).min(1).max(10) } — the `.max(10)` enforces PHOTO-02 carousel limit and `.startsWith('posts/')` prevents path traversal (RESEARCH Pitfall 4). Assert ownership; create Post (type PHOTO) + nested PostImage[] with position index; then `fanOutPost(redis, post.id, petId, post.createdAt, prisma)`. Returns the created post. Implements PHOTO-01 + PHOTO-02.
|
||||
`updateCaption`: input { postId, caption: z.string().max(500).nullable() }; load post → assert ctx.userId owns post.pet (via post→pet→ownerId); update caption (PHOTO-03).
|
||||
`delete`: input { postId }; assert ownership via post→pet→ownerId; `prisma.post.delete` (PostImage cascades per schema); remove from feed (best-effort ZREM across follower sets is acceptable to skip in Phase 2 — feed hydration already filters deleted IDs since findMany won't return them; document this). Implements PHOTO-04.
|
||||
`byPetId`: input { petId }; return pet's posts ordered createdAt desc with images.
|
||||
Add a shared ownership helper for post-level checks (post.pet.ownerId === ctx.userId).
|
||||
Fill posts.test.ts: "create single photo" (PHOTO-01), "carousel limit" rejects 11 images (PHOTO-02), "updateCaption forbidden" non-owner → FORBIDDEN (PHOTO-03), "delete cascades" (PHOTO-04). Mock fanOutPost via vi.mock('@/lib/feed-helpers').
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/__tests__/posts.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- posts.test.ts passes all 4 (PHOTO-01–04)
|
||||
- create asserts ownership + fans out; imageKeys capped at 10 and prefixed posts/
|
||||
- updateCaption/delete assert ownership via post→pet→ownerId
|
||||
- getPresignedUrl scopes key to posts/{ownerId}/{petId}/
|
||||
</acceptance_criteria>
|
||||
<done>posts router fully implemented; PHOTO-01–04 tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Full PostCard (carousel + caption clamp + options menu)</name>
|
||||
<files>src/components/feed/PostCard.tsx</files>
|
||||
<read_first>
|
||||
- src/components/feed/PostCard.tsx (the 02-03 placeholder — replace it)
|
||||
- src/components/social/* and safety/* from 02-02 (BlockDialog, ReportSheet — wire into options menu)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (shadcn Carousel example lines 863–899; getMediaUrl)
|
||||
- .planning/phases/02-content-core/02-UI-SPEC.md (PostCard spec, carousel dots, caption clamp, post options menu, owner-vs-other menu items, all copy)
|
||||
</read_first>
|
||||
<action>
|
||||
Replace the placeholder PostCard with the full implementation. Header: Avatar (getAvatarUrl) with story-ring seam (// TODO(02-05): StoryRing wrapper), pet name text-base font-semibold, timestamp text-xs muted, MoreHorizontal options button (44px target, aria-label "Post options").
|
||||
Image area: if 1 image → single img object-cover max-h-[560px]; if 2–10 → shadcn Carousel (Embla) per RESEARCH example with setApi/selectedScrollSnap index indicator "{current} / {total}" top-right and dot indicators (bg-muted inactive / bg-foreground active, NOT orange — accent reservation). MilestoneCard rendering is NOT here (02-06 owns milestone posts; this PostCard handles type PHOTO).
|
||||
Footer: caption text-sm with line-clamp-3 + "more"/"less" toggle (instant, no animation per UI-SPEC); reserve likes/comments space rendering 0 (Phase 3).
|
||||
Options DropdownMenu (base-nova: onClick handlers, no asChild): own post → "Edit caption" (inline Textarea edit calling posts.updateCaption, copy "Save caption"/"Cancel", toast "Caption updated") + "Delete post" (AlertDialog "Delete this post?" copy → posts.delete → toast "Post deleted"); other's post → "Report post" (opens ReportSheet) + separator + "Block {pet name}" (opens BlockDialog). Determine own-vs-other via activePetId === post.pet.id (the active pet's own posts). Implements PHOTO-03 (edit) + PHOTO-04 (delete) UI.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tsc exits 0
|
||||
- Single image and 2–10 carousel both render; index indicator + dots present; dots NOT orange
|
||||
- Caption clamps to 3 lines with more/less toggle
|
||||
- Options menu shows correct items for own vs other posts; edit + delete wired to mutations with exact copy
|
||||
</acceptance_criteria>
|
||||
<done>Full PostCard renders single + carousel posts with edit/delete/report/block actions.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Post creation flow — MultiImageUpload, PhotoPostForm, PostTypeSheet, + button in nav (D-01/D-02/D-03)</name>
|
||||
<files>src/components/post-creation/MultiImageUpload.tsx, src/components/post-creation/PhotoPostForm.tsx, src/components/post-creation/PostTypeSheet.tsx, src/components/layout/Sidebar.tsx, src/components/layout/MobileNav.tsx</files>
|
||||
<read_first>
|
||||
- src/components/pet/AvatarUpload.tsx (five-state upload reference: presigned → XHR PUT → confirm; Content-Type must match exactly; progress via XHR)
|
||||
- src/components/layout/MobileNav.tsx (center + button slot from Phase 1 — wire the Sheet)
|
||||
- src/components/layout/Sidebar.tsx (desktop + button slot)
|
||||
- .planning/phases/02-content-core/02-UI-SPEC.md (New post copy, upload zone copy, Share Post CTA, PostTypeSheet "Create" title + 3 options, error states)
|
||||
- .planning/phases/02-content-core/02-CONTEXT.md (D-01, D-02, D-03)
|
||||
</read_first>
|
||||
<action>
|
||||
`MultiImageUpload.tsx` (client): adapts AvatarUpload to multiple files (up to 10). For each selected file: validate type (jpg/png/webp) + size (<10MB, error copy "Photo must be under 10 MB." / "Upload a JPG, PNG, or WebP image."); reject >10 total ("You can upload up to 10 photos per post."); call posts.getPresignedUrl → XHR PUT to Supabase signed URL with exact Content-Type (Pitfall 4) + progress → collect returned keys. Reorderable/removable thumbnails. Upload zone copy: "Add photos" / "Drag photos here or tap to browse — up to 10".
|
||||
`PhotoPostForm.tsx` (client): MultiImageUpload + caption Textarea (placeholder "Write a caption…", max 500, error "Caption must be 500 characters or fewer."); "Share Post" CTA (bg-orange-500) calls posts.create({ petId: activePetId, caption, imageKeys }); onSuccess: invalidate feed query, close Dialog, `router.push('/feed')` so the new post is at top (D-03), toast "Post shared"; onError toast "Couldn't share your post. Please try again.". Implements PHOTO-01 + PHOTO-02.
|
||||
`PostTypeSheet.tsx` (client): bottom Sheet titled "Create" (D-01) with 3 full-width Buttons (Lucide icon + label): "Photo Post", "Story", "Milestone". Selecting Photo opens a full-screen Dialog (D-02 — no URL navigation) containing PhotoPostForm. Story and Milestone options render seam stubs (// TODO(02-05) StoryForm, // TODO(02-06) MilestoneForm) wired to open their Dialogs once those waves land — pass through props so 02-05/02-06 only swap the form component.
|
||||
`Sidebar.tsx` + `MobileNav.tsx`: wire the existing center/desktop + button to open PostTypeSheet (D-01). Mobile center "Post" tab (accent-filled from Phase 1) triggers the Sheet. Keep the Home→/feed change from 02-03 intact (this plan edits nav after 02-03 in a later wave).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit && npx vitest run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tsc exits 0; full suite green
|
||||
- + button opens PostTypeSheet titled "Create" with 3 options (D-01)
|
||||
- Photo選択 opens full-screen Dialog with PhotoPostForm, no URL change (D-02)
|
||||
- Successful post closes Dialog and routes to /feed with toast (D-03)
|
||||
- Upload enforces ≤10 images, type/size limits with exact error copy
|
||||
- Story/Milestone options present as seams for 02-05/02-06
|
||||
</acceptance_criteria>
|
||||
<done>Full photo post creation flow live end-to-end; + button → Sheet → Dialog → /feed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → posts mutations | Client supplies petId, caption, image keys |
|
||||
| client → Supabase Storage | Client PUTs bytes directly to signed URL |
|
||||
| post create → Redis fan-out | Server writes post IDs into follower feed sets |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-30 | Spoofing | posts.create petId | mitigate | assertPetOwnership(prisma, petId, ctx.userId) before insert + fan-out — posting as another pet blocked |
|
||||
| T-02-31 | Tampering | storageKey path traversal | mitigate | confirmUpload/create validate `z.string().startsWith('posts/')`; getPresignedUrl scopes key to posts/{ownerId}/{petId}/ (RESEARCH Pitfall 4) |
|
||||
| T-02-32 | Tampering | carousel image injection (>10) | mitigate | `z.array(...).max(10)` on imageKeys in posts.create |
|
||||
| T-02-33 | Information Disclosure | Supabase service key leak | mitigate | supabase-storage.ts is server-only; client only receives signed URL + NEXT_PUBLIC media URL |
|
||||
| T-02-34 | Elevation | edit/delete another pet's post | mitigate | updateCaption/delete assert ownership via post→pet→ownerId before write |
|
||||
| T-02-35 | Tampering | content-type mismatch in PUT | mitigate | XHR PUT Content-Type must exactly match getPresignedUrl contentType (Phase 1 Pitfall 4) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx vitest run` full suite green (posts PHOTO-01–04 + prior)
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- Manual: + button → Create sheet → Photo → Dialog → upload → Share Post → lands on /feed with post at top
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- PHOTO-01, PHOTO-02, PHOTO-03, PHOTO-04 implemented and tested
|
||||
- Post creation flow matches D-01/D-02/D-03
|
||||
- Posts fan out to follower feeds; appear in /feed
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-content-core/02-04-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,202 @@
|
||||
---
|
||||
phase: 02-content-core
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["02-01", "02-03", "02-04"]
|
||||
files_modified:
|
||||
- src/trpc/routers/stories.ts
|
||||
- src/__tests__/stories.test.ts
|
||||
- src/components/stories/StoryRing.tsx
|
||||
- src/components/stories/StoryViewer.tsx
|
||||
- src/components/stories/StoryTray.tsx
|
||||
- src/components/post-creation/StoryForm.tsx
|
||||
- src/components/post-creation/PostTypeSheet.tsx
|
||||
- src/components/feed/PostCard.tsx
|
||||
- src/app/(app)/feed/page.tsx
|
||||
autonomous: true
|
||||
requirements: [STORY-01, STORY-02, STORY-03]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A pet can post a photo story that disappears after 24 hours"
|
||||
- "An active story ring appears on the pet's avatar while non-expired stories exist"
|
||||
- "Owner can see the deduplicated list of pets that viewed their pet's story"
|
||||
- "Expired stories are never fetched (queries filter expiresAt > now)"
|
||||
artifacts:
|
||||
- path: "src/trpc/routers/stories.ts"
|
||||
provides: "create, listActive, recordView, getViewers, hasActiveStory"
|
||||
exports: ["storiesRouter"]
|
||||
- path: "src/components/stories/StoryRing.tsx"
|
||||
provides: "avatar wrapper with conditional orange ring"
|
||||
min_lines: 15
|
||||
- path: "src/components/stories/StoryViewer.tsx"
|
||||
provides: "full-screen story viewer with progress bar + viewer list"
|
||||
min_lines: 40
|
||||
key_links:
|
||||
- from: "src/components/stories/StoryRing.tsx"
|
||||
to: "stories.hasActiveStory"
|
||||
via: "query → conditional ring class"
|
||||
pattern: "hasActiveStory"
|
||||
- from: "src/trpc/routers/stories.ts"
|
||||
to: "prisma.storyView"
|
||||
via: "upsert dedup by composite key"
|
||||
pattern: "storyView\\.upsert"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Deliver stories as a complete vertical slice (STORY-01–03): the stories router (create with 24h expiry, active-only listing, deduplicated view recording, viewer list, active-story check), the StoryRing avatar wrapper, the full-screen StoryViewer with progress bar, the StoryTray at the top of the feed, and the StoryForm in the post-creation Sheet. Stories are photo-only in Phase 2 (video stories are Phase 5 — direct Supabase Storage upload, no Mux).
|
||||
|
||||
Purpose: STORY-01 (24h disappearing) uses the lazy-delete pattern (D-07 — filter expiresAt > NOW(), weekly pg_cron cleanup). STORY-02 (ring on avatar) and STORY-03 (viewer list, retained 30 days per D-08) complete the stories feature. This plan fills the StoryRing/StoryTray seams left by 02-04 (PostCard avatar, feed page) and the Story option seam in PostTypeSheet.
|
||||
Output: stories router + StoryRing + StoryViewer + StoryTray + StoryForm + PostTypeSheet Story wiring + PostCard ring + feed StoryTray.
|
||||
</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/02-content-core/02-RESEARCH.md
|
||||
@.planning/phases/02-content-core/02-UI-SPEC.md
|
||||
@.planning/phases/02-content-core/02-01-SUMMARY.md
|
||||
@src/trpc/routers/posts.ts
|
||||
@src/lib/supabase-storage.ts
|
||||
@src/lib/media-url.ts
|
||||
@src/components/pet/AvatarUpload.tsx
|
||||
@src/components/post-creation/PostTypeSheet.tsx
|
||||
@src/components/feed/PostCard.tsx
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: stories router — create(24h), listActive, recordView(dedup), getViewers, hasActiveStory + presigned upload + tests</name>
|
||||
<files>src/trpc/routers/stories.ts, src/__tests__/stories.test.ts</files>
|
||||
<behavior>
|
||||
- stories.create sets expiresAt = now + 24h (UTC) and asserts ctx.userId owns petId (STORY-01)
|
||||
- stories.hasActiveStory returns true only when a story with expiresAt > now exists (STORY-02)
|
||||
- stories.listActive returns only non-expired stories (expiresAt > now) — lazy delete (D-07)
|
||||
- stories.recordView upserts StoryView by composite (storyId, viewerPetId) — dedup, one view per viewer (STORY-03)
|
||||
- stories.getViewers returns the deduplicated viewer pet list for a story owned by ctx.userId
|
||||
- Photo-only: storageKey must be an image key (stories/ prefix)
|
||||
</behavior>
|
||||
<read_first>
|
||||
- src/trpc/routers/posts.ts (getPresignedUrl pattern + ownership; replicate for stories/ prefix)
|
||||
- src/trpc/routers/media.ts (presigned + confirm shape)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Pattern 5 hasActiveStory lines 663–676; deduplicated view upsert lines 822–840; Pitfall 5 timezone; Open Question 2 photo-only)
|
||||
- .planning/phases/02-content-core/02-CONTEXT.md (D-07 lazy delete, D-08 30-day view retention)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement storiesRouter (server-only) replacing Wave 0 stub:
|
||||
`getPresignedUrl`: input { petId, contentType, fileSize }; assert ownership; key `stories/${ctx.userId}/${petId}/${randomUUID()}.${ext}`; return { presignedUrl, key }.
|
||||
`create`: input { petId, storageKey: z.string().startsWith('stories/') }; assert ownership; `expiresAt = new Date(Date.now() + 24*60*60*1000)` in UTC (RESEARCH Pitfall 5); `prisma.story.create`. Implements STORY-01.
|
||||
`listActive`: input { petId }; `prisma.story.findMany({ where: { petId, expiresAt: { gt: new Date() } }, orderBy: { createdAt: 'asc' } })` — lazy-delete filter (D-07).
|
||||
`hasActiveStory`: input { petId }; `prisma.story.count({ where: { petId, expiresAt: { gt: new Date() } } }) > 0` (RESEARCH Pattern 5). Implements STORY-02.
|
||||
`recordView`: input { storyId }; viewerPetId = active pet (passed as input, ownership-asserted); `prisma.storyView.upsert({ where: { storyId_viewerPetId: { storyId, viewerPetId } }, update: { viewedAt: now }, create: {...} })` (RESEARCH lines 822–840). Implements STORY-03 dedup.
|
||||
`getViewers`: input { storyId }; assert the story's pet belongs to ctx.userId; return deduplicated viewer list `prisma.storyView.findMany({ where: { storyId }, include: { viewerPet: { select: id,name,avatarKey } }, orderBy: { viewedAt: 'desc' } })`. Implements STORY-03.
|
||||
Fill stories.test.ts: "expiresAt 24h" (STORY-01 — assert expiresAt ≈ now+24h), "hasActiveStory" filters expiresAt>now (STORY-02), "recordView dedup" calls upsert with composite key (STORY-03).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/__tests__/stories.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- stories.test.ts passes all 3 (STORY-01–03)
|
||||
- create sets expiresAt = now+24h UTC; asserts ownership
|
||||
- listActive/hasActiveStory filter expiresAt > now (D-07)
|
||||
- recordView upserts by composite (storyId, viewerPetId) — dedup
|
||||
- getViewers asserts story ownership before returning viewers
|
||||
</acceptance_criteria>
|
||||
<done>stories router implemented; STORY-01–03 tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: StoryRing, StoryViewer, StoryTray + PostCard ring seam + feed StoryTray seam</name>
|
||||
<files>src/components/stories/StoryRing.tsx, src/components/stories/StoryViewer.tsx, src/components/stories/StoryTray.tsx, src/components/feed/PostCard.tsx, src/app/(app)/feed/page.tsx</files>
|
||||
<read_first>
|
||||
- .planning/phases/02-content-core/02-UI-SPEC.md (Story Ring CSS, Story Tray spec, Story Viewer spec, viewers list copy, "Add story" copy, accent reservation for story ring + progress bar)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Pattern 5 ring conditional classes lines 642–661, 844–860)
|
||||
- src/components/feed/PostCard.tsx (the // TODO(02-05) StoryRing seam in the header)
|
||||
- src/app/(app)/feed/page.tsx (the // TODO(02-05) StoryTray seam)
|
||||
</read_first>
|
||||
<action>
|
||||
`StoryRing.tsx` (client): wraps an Avatar; props { petId, children }; queries stories.hasActiveStory; applies `ring-1 ring-orange-500 ring-offset-1 ring-offset-background` when hasActiveStory, else no ring (UI-SPEC; Phase 2 uses orange ring for all active stories per A5 — gray "all seen" ring is a Phase 3 enhancement). Implements STORY-02.
|
||||
`StoryViewer.tsx` (client): full-screen Sheet/overlay (fixed inset-0 z-50 bg-black/80); image object-contain; progress bar absolute top h-1 bg-muted with bg-orange-500 fill transitioning 0→100% over 5s (CSS transition-all duration-[5000ms] linear); pet info overlay bottom-left (avatar + name text-white); close X top-right (44px, aria-label "Close story"); viewer-list button top-left (eye + count, aria-label "Seen by {N} pets") opening a Sheet panel listing stories.getViewers results with heading "Seen by {N}" / empty "No views yet". On open, calls stories.recordView for the active viewer pet; auto-advances/closes after 5s. Implements STORY-03 viewer list.
|
||||
`StoryTray.tsx` (client): horizontal scroll row (h-24, gap-4, px-6 py-2) above the feed; first item "Add story" (orange-500 circle + white + icon, label "Add story") opening StoryForm; remaining items are followed pets with active stories (64px avatar w-16 h-16, name text-xs truncate) wrapped in StoryRing, tapping opens StoryViewer.
|
||||
PostCard.tsx: replace the // TODO(02-05) header seam — wrap the post pet Avatar in `<StoryRing petId={post.pet.id}>`.
|
||||
feed/page.tsx: replace the // TODO(02-05) seam — render `<StoryTray />` above `<FeedList />`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tsc exits 0
|
||||
- StoryRing applies orange ring only when hasActiveStory (STORY-02)
|
||||
- StoryViewer has 5s progress bar, recordView on open, viewer-list sheet with correct copy
|
||||
- StoryTray renders "Add story" + active-story pets; PostCard avatar wrapped in StoryRing; feed page shows StoryTray
|
||||
</acceptance_criteria>
|
||||
<done>Story display surfaces live: rings, viewer, tray.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: StoryForm + PostTypeSheet Story wiring (D-01/D-02/D-03)</name>
|
||||
<files>src/components/post-creation/StoryForm.tsx, src/components/post-creation/PostTypeSheet.tsx</files>
|
||||
<read_first>
|
||||
- src/components/pet/AvatarUpload.tsx (single-image upload reference)
|
||||
- src/components/post-creation/PostTypeSheet.tsx (the // TODO(02-05) StoryForm seam from 02-04)
|
||||
- .planning/phases/02-content-core/02-UI-SPEC.md (New Story copy: "Add a photo", "Stories disappear after 24 hours", "Post Story" CTA, toasts)
|
||||
</read_first>
|
||||
<action>
|
||||
`StoryForm.tsx` (client): single-image upload zone (reuse AvatarUpload五-state pattern via stories.getPresignedUrl → XHR PUT exact Content-Type → key); copy "Add a photo" / "Stories disappear after 24 hours"; "Post Story" CTA (bg-orange-500) calls stories.create({ petId: activePetId, storageKey }); onSuccess: close Dialog, invalidate stories queries, toast "Story posted"; onError toast "Couldn't post your story. Please try again.". Photo-only (no video in Phase 2). Implements STORY-01 creation UI.
|
||||
PostTypeSheet.tsx: replace the // TODO(02-05) Story seam — the "Story" option now opens a full-screen Dialog (D-02) containing StoryForm; on success lands per StoryForm's onSuccess (D-03 — modal closes). Keep Photo and Milestone (// TODO(02-06)) options intact; do not regress 02-04's Photo wiring.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit && npx vitest run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tsc exits 0; full suite green
|
||||
- Story option opens full-screen Dialog with StoryForm (D-01/D-02)
|
||||
- Posting a story closes the modal, toasts "Story posted", invalidates story queries (D-03)
|
||||
- StoryForm is photo-only with exact upload copy
|
||||
</acceptance_criteria>
|
||||
<done>Story creation flow live; + button → Create → Story → Dialog → posted.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → stories mutations | Client supplies petId, storageKey, storyId |
|
||||
| story viewer → recordView | Viewing pet records a view on another pet's story |
|
||||
| getViewers → story owner only | Only the story's owner may see who viewed it |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-40 | Spoofing | stories.create petId | mitigate | assertPetOwnership before create — posting a story as another pet blocked |
|
||||
| T-02-41 | Tampering | storageKey path traversal | mitigate | `z.string().startsWith('stories/')`; getPresignedUrl scopes stories/{ownerId}/{petId}/ |
|
||||
| T-02-42 | Tampering | story view inflation | mitigate | Composite `@@id([storyId, viewerPetId])` + upsert — one view per viewer (RESEARCH) |
|
||||
| T-02-43 | Information Disclosure | getViewers exposes viewers to non-owner | mitigate | getViewers asserts the story's pet belongs to ctx.userId before returning the viewer list |
|
||||
| T-02-44 | Information Disclosure | expired story still readable | mitigate | All story queries filter `expiresAt > now` (D-07 lazy delete) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx vitest run` full suite green (stories STORY-01–03 + prior)
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- Manual: post story → orange ring appears; view as another pet → appears in viewer list; expired story not fetched
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- STORY-01, STORY-02, STORY-03 implemented and tested
|
||||
- 24h lazy-delete expiry (D-07); 30-day view retention schema (D-08)
|
||||
- Story creation flow wired to PostTypeSheet (D-01/D-02/D-03)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-content-core/02-05-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
phase: 02-content-core
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["02-01", "02-04", "02-05"]
|
||||
files_modified:
|
||||
- src/trpc/routers/milestones.ts
|
||||
- src/__tests__/milestones.test.ts
|
||||
- src/lib/milestone-meta.ts
|
||||
- src/components/feed/MilestoneCard.tsx
|
||||
- src/components/post-creation/MilestoneForm.tsx
|
||||
- src/components/post-creation/PostTypeSheet.tsx
|
||||
- src/components/feed/PostCard.tsx
|
||||
- src/components/profile/ProfileTabs.tsx
|
||||
- src/app/(app)/pets/[petId]/page.tsx
|
||||
autonomous: true
|
||||
requirements: [MILE-01, MILE-02]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A pet can create a milestone post with a predefined structured type"
|
||||
- "Milestone posts render in a visually distinct format (bg-orange-50 card with type icon + label)"
|
||||
- "Milestone posts appear in the feed and on a dedicated profile Milestones tab"
|
||||
- "Invalid milestone types are rejected by the server"
|
||||
artifacts:
|
||||
- path: "src/trpc/routers/milestones.ts"
|
||||
provides: "create, listByPet"
|
||||
exports: ["milestonesRouter"]
|
||||
- path: "src/components/feed/MilestoneCard.tsx"
|
||||
provides: "distinct bg-orange-50 milestone card"
|
||||
min_lines: 25
|
||||
- path: "src/lib/milestone-meta.ts"
|
||||
provides: "milestone type → icon + label mapping"
|
||||
min_lines: 15
|
||||
key_links:
|
||||
- from: "src/trpc/routers/milestones.ts"
|
||||
to: "src/lib/feed-helpers.ts"
|
||||
via: "fanOutPost after milestone post create"
|
||||
pattern: "fanOutPost"
|
||||
- from: "src/components/feed/PostCard.tsx"
|
||||
to: "src/components/feed/MilestoneCard.tsx"
|
||||
via: "type MILESTONE → MilestoneCard"
|
||||
pattern: "MilestoneCard"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Deliver milestone posts as a complete vertical slice (MILE-01, MILE-02): the milestones router (create a Post of type MILESTONE with a structured MilestoneType + optional notes, listByPet), the MilestoneType→icon/label meta map, the distinct MilestoneCard (bg-orange-50), the MilestoneForm in the post-creation Sheet, PostCard routing to MilestoneCard for type MILESTONE, and the profile Posts/Milestones Tabs. Predefined types only (D-09, D-11 — custom types deferred).
|
||||
|
||||
Purpose: MILE-01 (structured milestone type) and MILE-02 (distinct visual format) complete the Phase 2 content trio. Follower milestones are seeded as enum values (D-09) but their auto-trigger fires in Phase 4 (CONTEXT deferred) — Phase 2 only ships the user-initiated milestone post type. This plan fills the final Milestone seam in PostTypeSheet.
|
||||
Output: milestones router + milestone-meta + MilestoneCard + MilestoneForm + PostTypeSheet Milestone wiring + PostCard MILESTONE routing + ProfileTabs.
|
||||
</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/02-content-core/02-RESEARCH.md
|
||||
@.planning/phases/02-content-core/02-UI-SPEC.md
|
||||
@.planning/phases/02-content-core/02-01-SUMMARY.md
|
||||
@src/trpc/routers/posts.ts
|
||||
@src/lib/feed-helpers.ts
|
||||
@src/lib/milestone-thresholds.ts
|
||||
@src/components/post-creation/PostTypeSheet.tsx
|
||||
@src/components/feed/PostCard.tsx
|
||||
@src/app/(app)/pets/[petId]/page.tsx
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: milestones router — create(Post type MILESTONE + fanOut), listByPet + tests</name>
|
||||
<files>src/trpc/routers/milestones.ts, src/__tests__/milestones.test.ts</files>
|
||||
<behavior>
|
||||
- milestones.create creates a Post (type MILESTONE) + nested Milestone (milestoneType, optional notes), asserts ownership, fans out to Redis (MILE-01)
|
||||
- milestones.create rejects an invalid milestone type (Zod enum) (MILE-01)
|
||||
- LITTER type accepts free-text notes (D-09)
|
||||
- milestones.listByPet returns the pet's milestone posts (for the profile Milestones tab)
|
||||
- User-initiated only: follower-threshold types are valid enum values but auto-trigger is Phase 4 (not wired here)
|
||||
</behavior>
|
||||
<read_first>
|
||||
- src/trpc/routers/posts.ts (Post create + fanOutPost pattern; ownership helper)
|
||||
- src/lib/feed-helpers.ts (fanOutPost)
|
||||
- .planning/phases/02-content-core/02-CONTEXT.md (D-09 milestone types, D-10/D-11 deferred auto-trigger + custom types)
|
||||
- .planning/phases/02-content-core/02-RESEARCH.md (Milestone model + MilestoneType enum)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement milestonesRouter (server-only) replacing Wave 0 stub:
|
||||
`create`: input { petId, milestoneType: z.nativeEnum(MilestoneType), notes: z.string().max(500).optional(), caption: z.string().max(500).optional(), imageKey: z.string().startsWith('posts/').optional() }; assert ctx.userId owns petId; create Post (type MILESTONE, caption) with nested Milestone (milestoneType, notes) and optional PostImage if imageKey provided; then `fanOutPost(redis, post.id, petId, post.createdAt, prisma)`. Zod nativeEnum rejects invalid types (MILE-01). LITTER notes free-text per D-09. Do NOT auto-create follower milestones — that is Phase 4 (D-10 deferred).
|
||||
`listByPet`: input { petId }; return `prisma.post.findMany({ where: { petId, type: 'MILESTONE' }, include: { milestone: true, images: true, pet: {...} }, orderBy: { createdAt: 'desc' } })`.
|
||||
Fill milestones.test.ts: "invalid type" — assert `milestones.create` with a non-enum milestoneType throws (MILE-01); add "create milestone" happy path asserting Post type MILESTONE + Milestone row + fanOut called.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/__tests__/milestones.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- milestones.test.ts passes (MILE-01 invalid-type rejection + happy path)
|
||||
- create asserts ownership, makes Post type MILESTONE + Milestone, fans out
|
||||
- milestoneType constrained to the D-09 enum; LITTER notes free-text supported
|
||||
- no follower-milestone auto-trigger logic (deferred to Phase 4)
|
||||
</acceptance_criteria>
|
||||
<done>milestones router implemented; MILE-01 tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: milestone-meta map + MilestoneCard + PostCard MILESTONE routing</name>
|
||||
<files>src/lib/milestone-meta.ts, src/components/feed/MilestoneCard.tsx, src/components/feed/PostCard.tsx</files>
|
||||
<read_first>
|
||||
- .planning/phases/02-content-core/02-UI-SPEC.md (Milestone Card spec bg-orange-50, milestone type visual mapping table, accent reservation: milestone label text-orange-500)
|
||||
- .planning/phases/02-content-core/02-CONTEXT.md (D-09 type labels + emoji)
|
||||
- src/components/feed/PostCard.tsx (route type PHOTO vs MILESTONE)
|
||||
</read_first>
|
||||
<action>
|
||||
`milestone-meta.ts`: export `MILESTONE_META: Record<MilestoneType, { label: string; icon: LucideIcon }>` mapping each MilestoneType to its Lucide icon + label per UI-SPEC visual mapping and D-09 (BIRTHDAY→Cake/"Birthday", ADOPTION_DAY→Heart/"Adoption Day", FIRST_OUTING→Footprints/"First Outing", VET_VISIT→Stethoscope/"Vet Visit", LITTER→ (baby/milk icon)/"New Litter", and the follower thresholds → a trophy/star icon with labels like "1,000 Followers"). Keep labels aligned with D-09 copy.
|
||||
`MilestoneCard.tsx`: props { post } (Post with milestone relation). Container bg-orange-50 border border-orange-200 rounded-lg p-4; Lucide icon 32px text-orange-500 from MILESTONE_META; type label text-base font-semibold text-orange-500 (accent reservation #8); pet name + date text-sm muted; optional caption/notes text-sm; optional photo rounded-md full width. Implements MILE-02 distinct format.
|
||||
PostCard.tsx: route by post.type — if type === 'MILESTONE' render `<MilestoneCard post={post} />`; else the photo/carousel body. Keep header (StoryRing avatar + name + options) shared.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tsc exits 0
|
||||
- MILESTONE_META maps every MilestoneType to icon + label (D-09)
|
||||
- MilestoneCard uses bg-orange-50 + orange-500 type label (MILE-02)
|
||||
- PostCard renders MilestoneCard for type MILESTONE, photo body otherwise
|
||||
</acceptance_criteria>
|
||||
<done>Milestone posts render in distinct format in feed and profile.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: MilestoneForm + PostTypeSheet Milestone wiring + ProfileTabs (Posts/Milestones)</name>
|
||||
<files>src/components/post-creation/MilestoneForm.tsx, src/components/post-creation/PostTypeSheet.tsx, src/components/profile/ProfileTabs.tsx, src/app/(app)/pets/[petId]/page.tsx</files>
|
||||
<read_first>
|
||||
- src/components/post-creation/PostTypeSheet.tsx (the // TODO(02-06) Milestone seam)
|
||||
- src/components/post-creation/PhotoPostForm.tsx (form + Dialog onSuccess → /feed pattern from 02-04)
|
||||
- .planning/phases/02-content-core/02-UI-SPEC.md (New Milestone copy, milestone type picker Popover, "Share Milestone" CTA, Profile Tabs spec Posts/Milestones, Post Grid)
|
||||
- src/app/(app)/pets/[petId]/page.tsx (profile page from 02-02 — add Tabs)
|
||||
</read_first>
|
||||
<action>
|
||||
`MilestoneForm.tsx` (client): milestone type Popover picker (label "What's the milestone?") listing the 5 user-initiated types from MILESTONE_META (birthday, adoption_day, first_outing, vet_visit, litter) — follower thresholds excluded from the picker (auto-triggered only, D-10); for LITTER show a free-text notes field; optional caption Textarea (placeholder "Add a note… (optional)"); optional single photo upload (posts.getPresignedUrl, posts/ prefix); "Share Milestone" CTA (bg-orange-500) calls milestones.create({ petId: activePetId, milestoneType, notes, caption, imageKey }); onSuccess: close Dialog, invalidate feed + milestones queries, router.push('/feed') (D-03), toast "Milestone shared"; onError toast "Couldn't share this milestone. Please try again.". Implements MILE-01 UI.
|
||||
PostTypeSheet.tsx: replace the // TODO(02-06) Milestone seam — "Milestone" option opens a full-screen Dialog (D-02) with MilestoneForm. Photo (02-04) and Story (02-05) options remain intact — all three type choices now fully wired (D-01).
|
||||
`ProfileTabs.tsx` (client): shadcn Tabs — "Posts" (3-col grid of photo posts via posts.byPetId; carousel posts show Layers2 overlay; default active) | "Milestones" (chronological MilestoneCard list via milestones.listByPet). Orange-500 active underline (shadcn default). Implements MILE-02 profile surface.
|
||||
pets/[petId]/page.tsx: render `<ProfileTabs petId={pet.id} />` below the stats row (added in 02-02).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit && npx vitest run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tsc exits 0; full suite green
|
||||
- Milestone option opens full-screen Dialog with MilestoneForm; picker lists only the 5 user-initiated types (D-10)
|
||||
- Posting a milestone closes modal, toasts "Milestone shared", routes to /feed (D-03)
|
||||
- ProfileTabs shows Posts grid + Milestones list; all 3 PostTypeSheet options wired (D-01)
|
||||
</acceptance_criteria>
|
||||
<done>Milestone creation flow live; profile has Posts/Milestones tabs; all post types wired.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → milestones.create | Client supplies petId, milestoneType, notes, optional imageKey |
|
||||
| milestone create → Redis fan-out | Server writes milestone post IDs into follower feeds |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-50 | Spoofing | milestones.create petId | mitigate | assertPetOwnership before create + fan-out — creating a milestone as another pet blocked |
|
||||
| T-02-51 | Tampering | invalid/forged milestoneType | mitigate | `z.nativeEnum(MilestoneType)` rejects any non-enum value (MILE-01) |
|
||||
| T-02-52 | Tampering | milestone imageKey path traversal | mitigate | optional imageKey `z.string().startsWith('posts/')` validation |
|
||||
| T-02-53 | Elevation | self-triggering follower milestones | accept | Follower-threshold types excluded from the creation picker; auto-trigger is Phase 4 — Phase 2 surface cannot mint vanity milestones for arbitrary counts (no count verification needed in Phase 2) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx vitest run` full suite green (milestones MILE-01 + prior)
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- Manual: create milestone → distinct bg-orange-50 card in feed + Milestones tab; all 3 + button options work
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- MILE-01, MILE-02 implemented and tested
|
||||
- Predefined types only (D-09, D-11); follower auto-trigger deferred to Phase 4 (D-10)
|
||||
- All three post-creation type choices fully wired (D-01)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-content-core/02-06-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -930,7 +930,7 @@ useEffect(() => {
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **Redis feed set maximum size / TTL**
|
||||
- What we know: No explicit TTL or max size is configured on the feed sorted set.
|
||||
|
||||
Reference in new issue
Block a user