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:
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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user