feat(03-04): Paw-Back/Repost — reposts router GREEN + RepostCard + PawBackButton

- reposts.create: ownership + repost-of-repost guard + idempotency + fanOutPost
- reposts.delete: ownership + cascade via post.delete
- reposts.isReposted: compound-key lookup, returns boolean
- PawBackButton: optimistic toggle, orange when reposted, invalidates feed
- RepostCard: attribution header + original pet/images/caption, no action row
- PostCard: REPOST type dispatches to RepostCard; stub replaced with PawBackButton
- feed.ts + posts.ts: repost include added to all post hydration queries
- PostCardPost type: OriginalPostContent + repost field

Tests: 3/3 reposts GREEN, 60 total passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-13 12:01:58 +02:00
1 parent 6fbb99e7c9
commit 8507fe9489
7 files changed
+447 -15

No files matched your search

@@ -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 `<RepostCard>`
- Stub `Repeat2` button replaced with `<PawBackButton>`
- `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
+84
View File
@@ -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<boolean | null>(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 (
<button
type="button"
onClick={handleClick}
disabled={isPending || isLoading}
aria-label={reposted ? "Remove Paw-Back" : "Paw-Back this post"}
className="ml-auto flex items-center gap-1.5 text-sm transition-colors disabled:opacity-60"
>
<Repeat2
className={`h-4 w-4 transition-colors ${
reposted
? "text-orange-500"
: "text-muted-foreground hover:text-foreground"
}`}
aria-hidden="true"
/>
</button>
);
}
+41 -9
View File
@@ -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 <MilestoneCard post={post} />;
}
// Repost posts delegate to RepostCard
if (post.type === "REPOST") {
return <RepostCard post={post} />;
}
return (
<article className="bg-card border border-border rounded-lg overflow-hidden">
{/* Header row — 16px horizontal padding, 12px vertical */}
@@ -402,6 +429,7 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
<button
type="button"
aria-label="View comments"
onClick={() => setShowCommentSheet(true)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<MessageCircle className="h-4 w-4" aria-hidden="true" />
@@ -409,13 +437,7 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
<span>{post._count!.comments}</span>
)}
</button>
<button
type="button"
aria-label="Paw-Back this post"
className="ml-auto flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Repeat2 className="h-4 w-4" aria-hidden="true" />
</button>
<PawBackButton postId={post.id} petId={activePetId} />
</div>
)}
</div>
@@ -453,6 +475,16 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
/>
)}
{/* CommentSheet */}
{activePetId && (
<CommentSheet
postId={post.id}
petId={activePetId}
open={showCommentSheet}
onOpenChange={setShowCommentSheet}
/>
)}
{/* BlockDialog — other's post */}
{activePetId && !isOwnPost && (
<BlockDialog
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useState, useEffect } from "react";
import { PawPrint } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
type CarouselApi,
} from "@/components/ui/carousel";
import { getAvatarUrl } from "@/lib/avatar-url";
import { getMediaUrl } from "@/lib/media-url";
import { MilestoneCard } from "@/components/feed/MilestoneCard";
import type { PostCardPost } from "@/components/feed/PostCard";
interface RepostCardProps {
post: PostCardPost;
}
export function RepostCard({ post }: RepostCardProps) {
const originalPost = post.repost?.originalPost;
if (!originalPost) return null;
return (
<RepostCardInner post={post} originalPost={originalPost} />
);
}
function RepostCardInner({
post,
originalPost,
}: {
post: PostCardPost;
originalPost: NonNullable<PostCardPost["repost"]>["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<CarouselApi>();
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 (
<article className="bg-card border border-border rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
<PawPrint className="h-3.5 w-3.5 text-orange-400" aria-hidden="true" />
<span className="text-xs text-muted-foreground">
Pawed-back by{" "}
<a href={`/pets/${post.pet.id}`} className="font-medium hover:underline">
@{reposterName}
</a>
</span>
</div>
<Separator className="mx-4" />
<MilestoneCard post={originalPost} />
</article>
);
}
return (
<article className="bg-card border border-border rounded-lg overflow-hidden">
{/* Repost attribution header */}
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
<PawPrint className="h-3.5 w-3.5 text-orange-400" aria-hidden="true" />
<span className="text-xs text-muted-foreground">
Pawed-back by{" "}
<a href={`/pets/${post.pet.id}`} className="font-medium hover:underline">
@{reposterName}
</a>
</span>
</div>
<Separator className="mx-4" />
{/* Original pet header */}
<div className="flex items-center gap-3 px-4 py-3">
<Avatar className="w-8 h-8">
{origAvatarUrl && (
<AvatarImage src={origAvatarUrl} alt={origPet.name} />
)}
<AvatarFallback className="text-xs bg-muted">
{origPet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<p className="text-base font-semibold text-foreground leading-tight truncate">
{origPet.name}
</p>
</div>
{/* Original images */}
{origImages.length > 0 && (
<>
{isCarousel ? (
<div className="relative">
<Carousel setApi={setCarouselApi}>
<CarouselContent>
{origImages.map((img) => (
<CarouselItem key={img.id} className="basis-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getMediaUrl(img.storageKey) ?? ""}
alt={originalPost.caption ?? `${origPet.name}'s post`}
className="w-full object-cover max-h-[480px]"
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-2" />
<CarouselNext className="right-2" />
</Carousel>
<span className="absolute top-2 right-2 text-xs text-white bg-black/50 rounded px-1.5 py-0.5 select-none">
{currentSlide} / {origImages.length}
</span>
</div>
) : (
<div className="w-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getMediaUrl(origImages[0].storageKey) ?? ""}
alt={originalPost.caption ?? `${origPet.name}'s post`}
className="w-full object-cover max-h-[480px]"
/>
</div>
)}
</>
)}
{/* Original caption — no action row */}
{originalPost.caption && (
<div className="px-4 py-3">
<p className="text-sm text-foreground line-clamp-3">
{originalPost.caption}
</p>
</div>
)}
</article>
);
}
+11
View File
@@ -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,
},
},
},
},
},
});
+11
View File
@@ -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,
},
},
},
},
},
});
+90 -6
View File
@@ -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<void> => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
.mutation(async ({ ctx, input }): Promise<void> => {
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<boolean> => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
.query(async ({ ctx, input }): Promise<boolean> => {
const repost = await ctx.prisma.repost.findUnique({
where: {
reposterPetId_originalPostId: {
reposterPetId: input.petId,
originalPostId: input.postId,
},
},
});
return repost !== null;
}),
});