diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 6b6c321..9091028 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index e49a523..aef5889 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 diff --git a/.planning/phases/02-content-core/02-01-PLAN.md b/.planning/phases/02-content-core/02-01-PLAN.md new file mode 100644 index 0000000..c196732 --- /dev/null +++ b/.planning/phases/02-content-core/02-01-PLAN.md @@ -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" +--- + + +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. + + + +@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md +@T:/CC-Projekte/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: Prisma schema — 8 models, 3 enums, Pet back-relations, then [BLOCKING] db push + prisma/schema.prisma + + - 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) + + + 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. + + + npx prisma validate && npx prisma generate + + + - `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) + + All 8 models migrated to the database; Prisma client regenerated; schema validates. + + + + Task 2: Shared libs + router stubs + register in _app.ts + 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/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) + + + 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. + + + npx tsc --noEmit + + + - `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 + + All shared libs and 7 router stubs exist, registered, and the project type-checks. + + + + Task 3: 7 test scaffolds + prisma-mock extension + install deps + shadcn components + 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 + + - 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) + + + 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). + + + 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 + + + - 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) + + 7 test scaffolds run green, deps installed, shadcn components present. + + + + + +## 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. | + + + +- `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 + + + +- 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 + + + +Create `.planning/phases/02-content-core/02-01-SUMMARY.md` when done + diff --git a/.planning/phases/02-content-core/02-02-PLAN.md b/.planning/phases/02-content-core/02-02-PLAN.md new file mode 100644 index 0000000..ae6524d --- /dev/null +++ b/.planning/phases/02-content-core/02-02-PLAN.md @@ -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" +--- + + +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. + + + +@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md +@T:/CC-Projekte/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: follows router (create/delete/isFollowing/listFollowers/listFollowing) + tests + src/trpc/routers/follows.ts, src/__tests__/follows.test.ts + + - 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 + + + - 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) + + + 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')`. + + + npx vitest run src/__tests__/follows.test.ts + + + - 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 + + follows router fully implemented; SOCL-01–03 tests green. + + + + Task 2: blocks + reports routers + tests + src/trpc/routers/blocks.ts, src/trpc/routers/reports.ts, src/__tests__/blocks.test.ts, src/__tests__/reports.test.ts + + - 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 + + + - 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) + + + 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. + + + npx vitest run src/__tests__/blocks.test.ts src/__tests__/reports.test.ts + + + - 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 + + blocks + reports routers implemented; SAFE-01, SAFE-02 tests green. + + + + Task 3: Social + safety UI — FollowButton, PetListItem, BlockDialog, ReportSheet, followers/following pages + 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 + + - 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) + + + `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. + + + npx tsc --noEmit && npx vitest run + + + - 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 + + Full social + safety UI wired; a user can follow/unfollow, view lists, block, and report. + + + + + +## 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 | + + + +- `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 + + + +- 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 + + + +Create `.planning/phases/02-content-core/02-02-SUMMARY.md` when done + diff --git a/.planning/phases/02-content-core/02-03-PLAN.md b/.planning/phases/02-content-core/02-03-PLAN.md new file mode 100644 index 0000000..bba557c --- /dev/null +++ b/.planning/phases/02-content-core/02-03-PLAN.md @@ -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" +--- + + +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. + + + +@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md +@T:/CC-Projekte/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: feed.getFeed router (Redis read + block filter + ordered hydration) + tests + src/trpc/routers/feed.ts, src/__tests__/feed.test.ts + + - 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 + + + - 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) + + + 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. + + + npx vitest run src/__tests__/feed.test.ts + + + - 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 + + feed router implemented; FEED-01 tests green. + + + + Task 2: FeedList (infinite scroll) + SkeletonCard + EmptyFeed + minimal PostCard placeholder + src/components/feed/FeedList.tsx, src/components/feed/SkeletonCard.tsx, src/components/feed/EmptyFeed.tsx, src/components/feed/PostCard.tsx + + - 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) + + + `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. + + + npx tsc --noEmit + + + - 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 + + Feed list renders with infinite scroll, skeletons, and empty state. + + + + Task 3: /feed page + home redirect (D-04) + nav Home→/feed (D-05) + onboarding/empty-state redirect (D-06) + 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 + + - 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) + + + `feed/page.tsx` (RSC shell): renders the StoryTray seam comment (// TODO(02-05): StoryTray above feed) + `` 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). + + + npx tsc --noEmit && npx vitest run + + + - / 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 + + Home is the feed; nav points there; empty/onboarding flows correct. + + + + + +## 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 | + + + +- `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) + + + +- 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) + + + +Create `.planning/phases/02-content-core/02-03-SUMMARY.md` when done + diff --git a/.planning/phases/02-content-core/02-04-PLAN.md b/.planning/phases/02-content-core/02-04-PLAN.md new file mode 100644 index 0000000..5df6d4e --- /dev/null +++ b/.planning/phases/02-content-core/02-04-PLAN.md @@ -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" +--- + + +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. + + + +@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md +@T:/CC-Projekte/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: posts router — getPresignedUrl, confirmUpload, create+fanOut, updateCaption, delete, byPetId + tests + src/trpc/routers/posts.ts, src/__tests__/posts.test.ts + + - 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) + + + - 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) + + + 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'). + + + npx vitest run src/__tests__/posts.test.ts + + + - 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}/ + + posts router fully implemented; PHOTO-01–04 tests green. + + + + Task 2: Full PostCard (carousel + caption clamp + options menu) + src/components/feed/PostCard.tsx + + - 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) + + + 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. + + + npx tsc --noEmit + + + - 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 + + Full PostCard renders single + carousel posts with edit/delete/report/block actions. + + + + Task 3: Post creation flow — MultiImageUpload, PhotoPostForm, PostTypeSheet, + button in nav (D-01/D-02/D-03) + 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 + + - 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) + + + `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). + + + npx tsc --noEmit && npx vitest run + + + - 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 + + Full photo post creation flow live end-to-end; + button → Sheet → Dialog → /feed. + + + + + +## 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) | + + + +- `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 + + + +- 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 + + + +Create `.planning/phases/02-content-core/02-04-SUMMARY.md` when done + diff --git a/.planning/phases/02-content-core/02-05-PLAN.md b/.planning/phases/02-content-core/02-05-PLAN.md new file mode 100644 index 0000000..31e654b --- /dev/null +++ b/.planning/phases/02-content-core/02-05-PLAN.md @@ -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" +--- + + +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. + + + +@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md +@T:/CC-Projekte/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: stories router — create(24h), listActive, recordView(dedup), getViewers, hasActiveStory + presigned upload + tests + src/trpc/routers/stories.ts, src/__tests__/stories.test.ts + + - 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) + + + - 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) + + + 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). + + + npx vitest run src/__tests__/stories.test.ts + + + - 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 + + stories router implemented; STORY-01–03 tests green. + + + + Task 2: StoryRing, StoryViewer, StoryTray + PostCard ring seam + feed StoryTray seam + 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 + + - .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) + + + `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 ``. + feed/page.tsx: replace the // TODO(02-05) seam — render `` above ``. + + + npx tsc --noEmit + + + - 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 + + Story display surfaces live: rings, viewer, tray. + + + + Task 3: StoryForm + PostTypeSheet Story wiring (D-01/D-02/D-03) + src/components/post-creation/StoryForm.tsx, src/components/post-creation/PostTypeSheet.tsx + + - 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) + + + `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. + + + npx tsc --noEmit && npx vitest run + + + - 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 + + Story creation flow live; + button → Create → Story → Dialog → posted. + + + + + +## 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) | + + + +- `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 + + + +- 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) + + + +Create `.planning/phases/02-content-core/02-05-SUMMARY.md` when done + diff --git a/.planning/phases/02-content-core/02-06-PLAN.md b/.planning/phases/02-content-core/02-06-PLAN.md new file mode 100644 index 0000000..3c47268 --- /dev/null +++ b/.planning/phases/02-content-core/02-06-PLAN.md @@ -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" +--- + + +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. + + + +@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md +@T:/CC-Projekte/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: milestones router — create(Post type MILESTONE + fanOut), listByPet + tests + src/trpc/routers/milestones.ts, src/__tests__/milestones.test.ts + + - 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) + + + - 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) + + + 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. + + + npx vitest run src/__tests__/milestones.test.ts + + + - 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) + + milestones router implemented; MILE-01 tests green. + + + + Task 2: milestone-meta map + MilestoneCard + PostCard MILESTONE routing + src/lib/milestone-meta.ts, src/components/feed/MilestoneCard.tsx, src/components/feed/PostCard.tsx + + - .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) + + + `milestone-meta.ts`: export `MILESTONE_META: Record` 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 ``; else the photo/carousel body. Keep header (StoryRing avatar + name + options) shared. + + + npx tsc --noEmit + + + - 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 + + Milestone posts render in distinct format in feed and profile. + + + + Task 3: MilestoneForm + PostTypeSheet Milestone wiring + ProfileTabs (Posts/Milestones) + src/components/post-creation/MilestoneForm.tsx, src/components/post-creation/PostTypeSheet.tsx, src/components/profile/ProfileTabs.tsx, src/app/(app)/pets/[petId]/page.tsx + + - 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) + + + `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 `` below the stats row (added in 02-02). + + + npx tsc --noEmit && npx vitest run + + + - 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) + + Milestone creation flow live; profile has Posts/Milestones tabs; all post types wired. + + + + + +## 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) | + + + +- `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 + + + +- 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) + + + +Create `.planning/phases/02-content-core/02-06-SUMMARY.md` when done + diff --git a/.planning/phases/02-content-core/02-RESEARCH.md b/.planning/phases/02-content-core/02-RESEARCH.md index 3c8ecf6..cc0b6af 100644 --- a/.planning/phases/02-content-core/02-RESEARCH.md +++ b/.planning/phases/02-content-core/02-RESEARCH.md @@ -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.