diff --git a/.planning/phases/03-engagement-discovery/03-04-SUMMARY.md b/.planning/phases/03-engagement-discovery/03-04-SUMMARY.md new file mode 100644 index 0000000..42607a5 --- /dev/null +++ b/.planning/phases/03-engagement-discovery/03-04-SUMMARY.md @@ -0,0 +1,55 @@ +--- +phase: 03-engagement-discovery +plan: 04 +status: complete +completed: 2026-06-13 +--- + +# Plan 03-04 Summary — Paw-Back / Repost + +## What Was Built + +**reposts.ts router (GREEN):** +- `create`: assertPetOwnership → fetch originalPost (FORBIDDEN if type=REPOST) → idempotency check (return existing.postId) → post.create(type=REPOST, nested repost.create) → fanOutPost +- `delete`: assertPetOwnership → findUnique by compound key → post.delete (cascades Repost) +- `isReposted`: findUnique by compound key → return boolean + +**PawBackButton.tsx:** +- `useQuery(trpc.reposts.isReposted)` for initial state +- Optimistic local state override (null = use server state) +- createMutation / deleteMutation with onMutate optimistic update + onError revert +- Orange Repeat2 when reposted; outline muted when not +- Invalidates `feed` and `reposts.isReposted` on success + +**RepostCard.tsx:** +- Attribution header: PawPrint + "Pawed-back by @{reposterName}" link +- Separator +- Original pet header (avatar + name) +- Image carousel (with slide indicator) or single image +- Caption (line-clamp-3, no action row) +- Handles milestone originalPost by delegating to MilestoneCard + +**PostCard.tsx updates:** +- `post.type === "REPOST"` dispatches to `` +- Stub `Repeat2` button replaced with `` +- `Repeat2` import removed + +**PostCardPost type:** +- Added `OriginalPostContent` type (exported) +- Added `repost?: { originalPostId, reposterPetId, originalPost: OriginalPostContent } | null` + +**feed.ts + posts.ts hydration:** +- Added `repost: { include: { originalPost: { include: { pet, images, milestone } } } }` to all post.findMany includes + +## Test Results + +- reposts.test.ts: 3/3 GREEN +- Full suite: 60 passing, 7 failing RED (explore 3 + search 4 — planned stubs for 03-05/06) +- `npx tsc --noEmit`: clean + +## Key Decisions + +- Paw-Back on a Repost (repost-of-repost) → FORBIDDEN — no chain-reposts +- Idempotency: second create returns existing `postId` without duplicate DB write +- `post.delete` cascades to `Repost` record via Prisma `onDelete: Cascade` +- No action row on original content inside RepostCard — interactions belong to the original post diff --git a/src/components/feed/PawBackButton.tsx b/src/components/feed/PawBackButton.tsx new file mode 100644 index 0000000..688e18d --- /dev/null +++ b/src/components/feed/PawBackButton.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Repeat2 } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTRPC } from "@/trpc/client"; + +interface PawBackButtonProps { + postId: string; // original post ID + petId: string; // active pet (reposter) +} + +export function PawBackButton({ postId, petId }: PawBackButtonProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const { data: serverReposted, isLoading } = useQuery( + trpc.reposts.isReposted.queryOptions({ postId, petId }) + ); + + // Optimistic local override; null = use server state + const [localState, setLocalState] = useState(null); + const reposted = localState !== null ? localState : (serverReposted ?? false); + + const createMutation = useMutation( + trpc.reposts.create.mutationOptions({ + onMutate: () => setLocalState(true), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [["trpc", "feed"]] }); + void queryClient.invalidateQueries({ queryKey: [["trpc", "reposts", "isReposted"]] }); + }, + onError: () => { + setLocalState(null); + toast.error("Couldn't Paw-Back. Please try again."); + }, + }) + ); + + const deleteMutation = useMutation( + trpc.reposts.delete.mutationOptions({ + onMutate: () => setLocalState(false), + onSuccess: () => { + toast.success("Paw-Back removed"); + void queryClient.invalidateQueries({ queryKey: [["trpc", "feed"]] }); + void queryClient.invalidateQueries({ queryKey: [["trpc", "reposts", "isReposted"]] }); + }, + onError: () => { + setLocalState(null); + toast.error("Couldn't remove Paw-Back. Please try again."); + }, + }) + ); + + const isPending = createMutation.isPending || deleteMutation.isPending; + + const handleClick = useCallback(() => { + if (isPending || isLoading) return; + if (reposted) { + deleteMutation.mutate({ originalPostId: postId, reposterPetId: petId }); + } else { + createMutation.mutate({ originalPostId: postId, reposterPetId: petId }); + } + }, [isPending, isLoading, reposted, postId, petId, createMutation, deleteMutation]); + + return ( + + ); +} diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx index 2cab8d2..20c2246 100644 --- a/src/components/feed/PostCard.tsx +++ b/src/components/feed/PostCard.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { MoreHorizontal, MessageCircle, Repeat2 } from "lucide-react"; +import { MoreHorizontal, MessageCircle } from "lucide-react"; import { toast } from "sonner"; import { useMutation } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; @@ -39,6 +39,9 @@ import { getMediaUrl } from "@/lib/media-url"; import { PawRing } from "@/components/stories/PawRing"; import { MilestoneCard } from "@/components/feed/MilestoneCard"; import { PawButton } from "@/components/feed/PawButton"; +import { PawBackButton } from "@/components/feed/PawBackButton"; +import { CommentSheet } from "@/components/feed/CommentSheet"; +import { RepostCard } from "@/components/feed/RepostCard"; import { BlockDialog } from "@/components/safety/BlockDialog"; import { ReportSheet } from "@/components/safety/ReportSheet"; @@ -69,6 +72,17 @@ type PostPet = { avatarKey: string | null; }; +type PostMilestone = { milestoneType: import("@prisma/client").MilestoneType; notes: string | null }; + +export type OriginalPostContent = { + id: string; + pet: PostPet; + images: PostImage[]; + caption: string | null; + createdAt: Date | string; + milestone: PostMilestone | null; +}; + export type PostCardPost = { id: string; type?: string | null; @@ -76,8 +90,13 @@ export type PostCardPost = { createdAt: Date | string; pet: PostPet; images: PostImage[]; - milestone: { milestoneType: import("@prisma/client").MilestoneType; notes: string | null } | null; + milestone: PostMilestone | null; _count?: { reactions: number; comments: number } | null; + repost?: { + originalPostId: string; + reposterPetId: string; + originalPost: OriginalPostContent; + } | null; }; export type PostCardProps = { @@ -128,6 +147,9 @@ export function PostCard({ post, onDeleted }: PostCardProps) { // Block dialog state const [showBlockDialog, setShowBlockDialog] = useState(false); + // Comment sheet state + const [showCommentSheet, setShowCommentSheet] = useState(false); + // Ownership: active pet's own post vs another pet's post const isOwnPost = activePetId === pet.id; @@ -191,6 +213,11 @@ export function PostCard({ post, onDeleted }: PostCardProps) { return ; } + // Repost posts delegate to RepostCard + if (post.type === "REPOST") { + return ; + } + return (
{/* Header row — 16px horizontal padding, 12px vertical */} @@ -402,6 +429,7 @@ export function PostCard({ post, onDeleted }: PostCardProps) { - + )} @@ -453,6 +475,16 @@ export function PostCard({ post, onDeleted }: PostCardProps) { /> )} + {/* CommentSheet */} + {activePetId && ( + + )} + {/* BlockDialog — other's post */} {activePetId && !isOwnPost && ( + ); +} + +function RepostCardInner({ + post, + originalPost, +}: { + post: PostCardPost; + originalPost: NonNullable["originalPost"]; +}) { + const reposterName = post.pet.name; + const origPet = originalPost.pet; + const origAvatarUrl = getAvatarUrl(origPet.avatarKey); + const origImages = originalPost.images; + const isCarousel = origImages.length > 1; + + const [carouselApi, setCarouselApi] = useState(); + const [currentSlide, setCurrentSlide] = useState(1); + + useEffect(() => { + if (!carouselApi) return; + setCurrentSlide(carouselApi.selectedScrollSnap() + 1); + carouselApi.on("select", () => { + setCurrentSlide(carouselApi.selectedScrollSnap() + 1); + }); + }, [carouselApi]); + + // Milestone original post + if (originalPost.milestone) { + return ( + + ); + } + + return ( +
+ {/* Repost attribution header */} +
+
+ + + + {/* Original pet header */} +
+ + {origAvatarUrl && ( + + )} + + {origPet.name[0]?.toUpperCase() ?? "?"} + + +

+ {origPet.name} +

+
+ + {/* Original images */} + {origImages.length > 0 && ( + <> + {isCarousel ? ( +
+ + + {origImages.map((img) => ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {originalPost.caption + + ))} + + + + + + {currentSlide} / {origImages.length} + +
+ ) : ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {originalPost.caption +
+ )} + + )} + + {/* Original caption — no action row */} + {originalPost.caption && ( +
+

+ {originalPost.caption} +

+
+ )} +
+ ); +} diff --git a/src/trpc/routers/feed.ts b/src/trpc/routers/feed.ts index d8cb4a7..52c2f8b 100644 --- a/src/trpc/routers/feed.ts +++ b/src/trpc/routers/feed.ts @@ -83,6 +83,17 @@ export const feedRouter = router({ images: { orderBy: { position: "asc" } }, milestone: true, _count: { select: { reactions: true, comments: true } }, + repost: { + include: { + originalPost: { + include: { + pet: { select: { id: true, name: true, avatarKey: true } }, + images: { orderBy: { position: "asc" } }, + milestone: true, + }, + }, + }, + }, }, }); diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index 585a3cb..fe1c8f8 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -210,6 +210,17 @@ export const postsRouter = router({ pet: { select: { id: true, name: true, avatarKey: true } }, milestone: true, _count: { select: { reactions: true, comments: true } }, + repost: { + include: { + originalPost: { + include: { + pet: { select: { id: true, name: true, avatarKey: true } }, + images: { orderBy: { position: "asc" } }, + milestone: true, + }, + }, + }, + }, }, }); diff --git a/src/trpc/routers/reposts.ts b/src/trpc/routers/reposts.ts index 0b4c0e5..5c35203 100644 --- a/src/trpc/routers/reposts.ts +++ b/src/trpc/routers/reposts.ts @@ -2,6 +2,24 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { router, protectedProcedure } from "@/trpc/init"; +import { redis } from "@/lib/redis"; +import { fanOutPost } from "@/lib/feed-helpers"; +import type { PrismaClient } from "@prisma/client"; + +async function assertPetOwnership( + prisma: PrismaClient, + petId: string, + userId: string +) { + const pet = await prisma.pet.findUnique({ where: { id: petId } }); + if (!pet || pet.ownerId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not have permission to access this pet.", + }); + } + return pet; +} export const repostsRouter = router({ create: protectedProcedure @@ -11,8 +29,51 @@ export const repostsRouter = router({ reposterPetId: z.string().min(1), }) ) - .mutation(async (): Promise<{ postId: string }> => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .mutation(async ({ ctx, input }): Promise<{ postId: string }> => { + await assertPetOwnership(ctx.prisma, input.reposterPetId, ctx.userId); + + const originalPost = await ctx.prisma.post.findUnique({ + where: { id: input.originalPostId }, + }); + if (!originalPost) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + if (originalPost.type === "REPOST") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Cannot repost a repost.", + }); + } + + // Idempotency: return existing repost postId without duplicate create + const existing = await ctx.prisma.repost.findUnique({ + where: { + reposterPetId_originalPostId: { + reposterPetId: input.reposterPetId, + originalPostId: input.originalPostId, + }, + }, + }); + if (existing) { + return { postId: existing.postId }; + } + + const post = await ctx.prisma.post.create({ + data: { + petId: input.reposterPetId, + type: "REPOST", + repost: { + create: { + originalPostId: input.originalPostId, + reposterPetId: input.reposterPetId, + }, + }, + }, + }); + + await fanOutPost(redis, post.id, input.reposterPetId, post.createdAt, ctx.prisma); + + return { postId: post.id }; }), delete: protectedProcedure @@ -22,13 +83,36 @@ export const repostsRouter = router({ reposterPetId: z.string().min(1), }) ) - .mutation(async (): Promise => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .mutation(async ({ ctx, input }): Promise => { + await assertPetOwnership(ctx.prisma, input.reposterPetId, ctx.userId); + + const repost = await ctx.prisma.repost.findUnique({ + where: { + reposterPetId_originalPostId: { + reposterPetId: input.reposterPetId, + originalPostId: input.originalPostId, + }, + }, + }); + if (!repost) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + // Deleting the Post cascades to the Repost record via onDelete: Cascade + await ctx.prisma.post.delete({ where: { id: repost.postId } }); }), isReposted: protectedProcedure .input(z.object({ postId: z.string().min(1), petId: z.string().min(1) })) - .query(async (): Promise => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .query(async ({ ctx, input }): Promise => { + const repost = await ctx.prisma.repost.findUnique({ + where: { + reposterPetId_originalPostId: { + reposterPetId: input.petId, + originalPostId: input.postId, + }, + }, + }); + return repost !== null; }), });