feat(stories): add paw reactions and comments (Gitea #29)

Story reactions/comments mirror the existing Post/Ad engagement models
(StoryReaction/StoryComment tables, denormalized counters on Story) with
a comment overlay rendered on top of the still-playing story rather than
pausing it. No notification wiring yet, matching the Advertisement
engagement model's precedent.
This commit is contained in:
2026-08-24 18:28:19 +02:00
parent 59cb72b57e
commit 05c9abfb7a
9 changed files with 633 additions and 4 deletions
+2 -1
View File
@@ -533,7 +533,8 @@
"noViewersYet": "Noch niemand hat diese Story angesehen.",
"mute": "Stummschalten",
"unmute": "Ton einschalten",
"videoError": "Video konnte nicht verarbeitet werden."
"videoError": "Video konnte nicht verarbeitet werden.",
"openComments": "Kommentare öffnen"
},
"StoryTray": {
"storiesAriaLabel": "Storys",
+2 -1
View File
@@ -533,7 +533,8 @@
"noViewersYet": "No one has viewed this story yet.",
"mute": "Mute",
"unmute": "Unmute",
"videoError": "Video could not be processed."
"videoError": "Video could not be processed.",
"openComments": "Open comments"
},
"StoryTray": {
"storiesAriaLabel": "Stories",
+38
View File
@@ -163,6 +163,8 @@ model Pet {
posts Post[]
stories Story[]
storyViews StoryView[] @relation("StoryViewer")
storyReactions StoryReaction[] @relation("StoryReactions")
storyComments StoryComment[] @relation("StoryComments")
following Follow[] @relation("Follower")
followers Follow[] @relation("Followee")
blocking Block[] @relation("Blocker")
@@ -372,6 +374,13 @@ model Story {
// Gitea #18 — legal AI-content disclosure, set once at creation.
aiDisclosure AiDisclosure @default(NONE)
// Gitea #29 — Denormalized engagement counters, same rationale as Post
// (see Post model comment). Maintained transactionally in stories.ts.
reactionCount Int @default(0)
commentCount Int @default(0)
reactions StoryReaction[]
comments StoryComment[]
@@index([petId, expiresAt]) // supports lazy-delete filter query
@@index([muxUploadId])
@@index([muxAssetId])
@@ -393,6 +402,35 @@ model StoryView {
@@index([viewedAt]) // supports 30-day cleanup job (D-08)
}
// Gitea #29 — story reactions/comments, mirrors Reaction/Comment (Post) and
// AdReaction/AdComment (Advertisement) rather than a shared postId/storyId
// polymorphic table, matching this codebase's existing per-target-type
// pattern (see AdReaction/AdComment above).
model StoryReaction {
id String @id @default(cuid())
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
storyId String
pet Pet @relation("StoryReactions", fields: [petId], references: [id], onDelete: Cascade)
petId String
createdAt DateTime @default(now())
@@unique([storyId, petId])
@@index([storyId])
}
model StoryComment {
id String @id @default(cuid())
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
storyId String
pet Pet @relation("StoryComments", fields: [petId], references: [id], onDelete: Cascade)
petId String
body String @db.VarChar(500)
createdAt DateTime @default(now())
@@index([storyId, createdAt(sort: Asc)])
@@index([petId])
}
model Follow {
follower Pet @relation("Follower", fields: [followerPetId], references: [id], onDelete: Cascade)
followerPetId String
+27
View File
@@ -178,6 +178,20 @@ interface MockRepostClient {
delete: MockFn;
}
// Gitea #29 — story reactions/comments
interface MockStoryReactionClient {
findUnique: MockFn;
create: MockFn;
delete: MockFn;
}
interface MockStoryCommentClient {
create: MockFn;
findMany: MockFn;
findUnique: MockFn;
delete: MockFn;
}
interface MockHashtagClient {
findUnique: MockFn;
findMany: MockFn;
@@ -300,6 +314,8 @@ interface MockPrismaClient {
reaction: MockReactionClient;
comment: MockCommentClient;
repost: MockRepostClient;
storyReaction: MockStoryReactionClient;
storyComment: MockStoryCommentClient;
hashtag: MockHashtagClient;
postHashtag: MockPostHashtagClient;
sponsorContribution: MockSponsorContributionClient;
@@ -470,6 +486,17 @@ export function createMockPrisma(): MockPrismaClient {
create: vi.fn(),
delete: vi.fn(),
},
storyReaction: {
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
storyComment: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
},
hashtag: {
findUnique: vi.fn(),
findMany: vi.fn(),
+149
View File
@@ -166,3 +166,152 @@ describe("stories.recordView", () => {
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});
// Gitea #29 — story reactions + comments
describe("stories.toggleReaction", () => {
beforeEach(() => vi.clearAllMocks());
it("creates a reaction and increments reactionCount when not yet reacted", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
mockPrisma.storyReaction.findUnique.mockResolvedValue(null);
mockPrisma.story.findUnique.mockResolvedValue({
petId: "pet-1",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
mockPrisma.$transaction.mockResolvedValue([{}, {}]);
const result = await caller.toggleReaction({ storyId: "story-1", petId: "pet-2" });
expect(result).toEqual({ reacted: true });
expect(mockPrisma.$transaction).toHaveBeenCalledOnce();
});
it("deletes the reaction and decrements reactionCount when already reacted", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
mockPrisma.storyReaction.findUnique.mockResolvedValue({ id: "reaction-1" });
mockPrisma.$transaction.mockResolvedValue([{}, {}]);
const result = await caller.toggleReaction({ storyId: "story-1", petId: "pet-2" });
expect(result).toEqual({ reacted: false });
});
it("throws NOT_FOUND when reacting to an expired story", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
mockPrisma.storyReaction.findUnique.mockResolvedValue(null);
mockPrisma.story.findUnique.mockResolvedValue({
petId: "pet-1",
expiresAt: new Date(Date.now() - 1000),
});
await expect(
caller.toggleReaction({ storyId: "story-1", petId: "pet-2" })
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("throws FORBIDDEN when the reactor is blocked by the story owner", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
mockPrisma.storyReaction.findUnique.mockResolvedValue(null);
mockPrisma.story.findUnique.mockResolvedValue({
petId: "pet-1",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
mockPrisma.block.findFirst.mockResolvedValue({ id: "block-1" });
await expect(
caller.toggleReaction({ storyId: "story-1", petId: "pet-2" })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
describe("stories.addComment / listComments / deleteComment", () => {
beforeEach(() => vi.clearAllMocks());
it("creates a comment and increments commentCount", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
mockPrisma.story.findUnique.mockResolvedValue({
petId: "pet-1",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
const mockComment = { id: "comment-1", storyId: "story-1", petId: "pet-2", body: "cute!" };
mockPrisma.$transaction.mockResolvedValue([mockComment, {}]);
const result = await caller.addComment({
storyId: "story-1",
petId: "pet-2",
body: "cute!",
});
expect(result).toEqual(mockComment);
});
it("throws NOT_FOUND when commenting on an expired story", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
mockPrisma.story.findUnique.mockResolvedValue({
petId: "pet-1",
expiresAt: new Date(Date.now() - 1000),
});
await expect(
caller.addComment({ storyId: "story-1", petId: "pet-2", body: "cute!" })
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("lists comments ordered ascending by createdAt", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.storyComment.findMany.mockResolvedValue([]);
await caller.listComments({ storyId: "story-1" });
expect(mockPrisma.storyComment.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { storyId: "story-1" },
orderBy: { createdAt: "asc" },
})
);
});
it("deletes own comment and decrements commentCount", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
mockPrisma.storyComment.findUnique.mockResolvedValue({
id: "comment-1",
storyId: "story-1",
petId: "pet-2",
});
mockPrisma.$transaction.mockResolvedValue([{}, {}]);
await caller.deleteComment({ commentId: "comment-1", petId: "pet-2" });
expect(mockPrisma.$transaction).toHaveBeenCalledOnce();
});
it("throws FORBIDDEN when deleting another pet's comment", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
mockPrisma.storyComment.findUnique.mockResolvedValue({
id: "comment-1",
storyId: "story-1",
petId: "pet-3",
});
await expect(
caller.deleteComment({ commentId: "comment-1", petId: "pet-2" })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
@@ -0,0 +1,148 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Loader2, Send, Trash2, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { PetAvatar } from "@/components/ui/pet-avatar";
interface StoryCommentsOverlayProps {
storyId: string;
activePetId: string;
open: boolean;
onClose: () => void;
}
/**
* Gitea #29 — comment overlay for stories. Deliberately does NOT pause the
* story's own progress/video (the issue asked for an overlay "on top of the
* running video", not a paused one) — bg-black/40 keeps the story visible
* and legible behind the panel.
*/
export function StoryCommentsOverlay({
storyId,
activePetId,
open,
onClose,
}: StoryCommentsOverlayProps) {
const t = useTranslations("Comments");
const trpc = useTRPC();
const queryClient = useQueryClient();
const [body, setBody] = useState("");
const listEndRef = useRef<HTMLDivElement>(null);
const { data: comments = [], isLoading } = useQuery({
...trpc.stories.listComments.queryOptions({ storyId }),
enabled: open,
});
const invalidateComments = useCallback(() => {
void queryClient.invalidateQueries({
queryKey: trpc.stories.listComments.queryOptions({ storyId }).queryKey,
});
}, [queryClient, trpc, storyId]);
const createMutation = useMutation(
trpc.stories.addComment.mutationOptions({
onSuccess: () => {
setBody("");
invalidateComments();
setTimeout(() => listEndRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
},
})
);
const deleteMutation = useMutation(
trpc.stories.deleteComment.mutationOptions({
onSuccess: invalidateComments,
})
);
const handleSubmit = useCallback(() => {
const trimmed = body.trim();
if (!trimmed) return;
createMutation.mutate({ storyId, petId: activePetId, body: trimmed });
}, [body, storyId, activePetId, createMutation]);
if (!open) return null;
return (
<div
className="absolute inset-x-0 bottom-0 z-20 flex max-h-[60%] flex-col rounded-t-2xl bg-black/40 backdrop-blur-sm"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 shrink-0">
<span className="text-sm font-semibold text-white">{t("title")}</span>
<button
type="button"
onClick={onClose}
aria-label="Close comments"
className="text-white/70 hover:text-white"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="min-h-[80px] flex-1 space-y-2 overflow-y-auto px-4 py-2">
{isLoading && (
<div className="flex justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-white/70" aria-hidden="true" />
</div>
)}
{!isLoading && comments.length === 0 && (
<p className="py-3 text-center text-xs text-white/60">{t("empty")}</p>
)}
{comments.map((c) => (
<div key={c.id} className="group flex items-start gap-2">
<PetAvatar name={c.pet.name} avatarKey={c.pet.avatarKey} className="h-6 w-6 shrink-0" />
<div className="min-w-0 flex-1">
<span className="mr-1 text-xs font-semibold text-white">{c.pet.name}</span>
<span className="break-words text-xs text-white/90">{c.body}</span>
</div>
{c.pet.id === activePetId && (
<button
type="button"
aria-label="Delete comment"
onClick={() => deleteMutation.mutate({ commentId: c.id, petId: activePetId })}
className="shrink-0 text-white/60 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
)}
</div>
))}
<div ref={listEndRef} />
</div>
<div className="flex items-center gap-2 border-t border-white/10 px-3 py-2 shrink-0">
<input
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}}
maxLength={500}
placeholder={t("placeholder")}
className="flex-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white outline-none placeholder:text-white/50"
/>
<button
type="button"
onClick={handleSubmit}
disabled={!body.trim() || createMutation.isPending}
aria-label={t("post")}
className="shrink-0 text-orange-400 disabled:opacity-40"
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Send className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState, useCallback } from "react";
import { PawPrint } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
interface StoryPawButtonProps {
storyId: string;
petId: string;
initialCount: number;
}
/**
* Gitea #29 — paw-like button for stories, mirrors PawButton (feed posts).
* initialReacted is always false (same limitation PawButton documents:
* listActive doesn't return per-viewer reaction state) — the icon starts
* unfilled and flips correctly on the first toggle this session.
*/
export function StoryPawButton({ storyId, petId, initialCount }: StoryPawButtonProps) {
const trpc = useTRPC();
const [reacted, setReacted] = useState(false);
const [count, setCount] = useState(initialCount);
const [animationKey, setAnimationKey] = useState(0);
const [showBurst, setShowBurst] = useState(false);
const toggleMutation = useMutation(
trpc.stories.toggleReaction.mutationOptions({
onMutate: () => {
const prevReacted = reacted;
const prevCount = count;
const nextReacted = !reacted;
setReacted(nextReacted);
if (nextReacted) {
setAnimationKey((key) => key + 1);
setShowBurst(true);
}
return { prevReacted, prevCount };
},
onSuccess: (data, _vars, context) => {
setReacted(data.reacted);
if (context) {
setCount(data.reacted ? context.prevCount + 1 : Math.max(0, context.prevCount - 1));
}
},
onError: (_err, _vars, context) => {
if (context) {
setReacted(context.prevReacted);
setCount(context.prevCount);
}
},
})
);
const handleClick = useCallback(() => {
if (toggleMutation.isPending) return;
toggleMutation.mutate({ storyId, petId });
}, [storyId, petId, toggleMutation]);
return (
<button
type="button"
onClick={handleClick}
disabled={toggleMutation.isPending}
aria-label={reacted ? "Remove paw" : "Paw this story"}
className="flex flex-col items-center gap-0.5 disabled:opacity-60"
>
<span className="relative inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/40 hover:bg-black/60">
<PawPrint
key={animationKey}
className={`h-4 w-4 transition-colors ${
reacted ? "fill-orange-500 text-orange-500 animate-paw-spring" : "text-white"
}`}
aria-hidden="true"
/>
{showBurst && (
<PawPrint
className="absolute inset-0 m-auto h-4 w-4 fill-orange-500 text-orange-500 animate-paw-burst"
aria-hidden="true"
onAnimationEnd={() => setShowBurst(false)}
/>
)}
</span>
{count > 0 && (
<span className="text-xs text-white drop-shadow">{count}</span>
)}
</button>
);
}
+41 -1
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import dynamic from "next/dynamic";
import { X, Eye, Volume2, VolumeX, Loader2 } from "lucide-react";
import { X, Eye, Volume2, VolumeX, Loader2, MessageCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
@@ -19,6 +19,8 @@ import { getAvatarUrl } from "@/lib/avatar-url";
import { getMediaUrl } from "@/lib/media-url";
import { BlurImage } from "@/components/ui/blur-image";
import { AiDisclosureBadge } from "@/components/content/AiDisclosureBadge";
import { StoryPawButton } from "@/components/stories/StoryPawButton";
import { StoryCommentsOverlay } from "@/components/stories/StoryCommentsOverlay";
// Same lazy-load rationale as VideoCard.tsx — keep the HLS.js player runtime
// out of the main bundle, only load it when a video story actually renders.
@@ -63,6 +65,7 @@ export function StoryViewer({
const [currentIndex, setCurrentIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [viewerSheetOpen, setViewerSheetOpen] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(false);
const [isMuted, setIsMuted] = useState(true);
const advancedRef = useRef(false);
@@ -115,6 +118,7 @@ export function StoryViewer({
const advance = useCallback(() => {
if (advancedRef.current) return;
advancedRef.current = true;
setCommentsOpen(false);
if (currentIndex < stories.length - 1) {
setCurrentIndex((i) => i + 1);
} else {
@@ -176,6 +180,7 @@ export function StoryViewer({
setCurrentIndex(0);
setProgress(0);
setIsMuted(true);
setCommentsOpen(false);
}
}, [open]);
@@ -350,6 +355,41 @@ export function StoryViewer({
<span>{viewers.length}</span>
</button>
)}
{/* Gitea #29 — paw + comment buttons, bottom right */}
{activePetId && (
<div className="absolute bottom-6 right-4 z-10 flex flex-col items-center gap-4">
<StoryPawButton
storyId={currentStory.id}
petId={activePetId}
initialCount={currentStory.reactionCount}
/>
<button
type="button"
onClick={() => setCommentsOpen((o) => !o)}
aria-label={t("openComments")}
className="flex flex-col items-center gap-0.5"
>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white hover:bg-black/60">
<MessageCircle className="h-4 w-4" aria-hidden="true" />
</span>
{currentStory.commentCount > 0 && (
<span className="text-xs text-white drop-shadow">
{currentStory.commentCount}
</span>
)}
</button>
</div>
)}
{activePetId && (
<StoryCommentsOverlay
storyId={currentStory.id}
activePetId={activePetId}
open={commentsOpen}
onClose={() => setCommentsOpen(false)}
/>
)}
</div>,
document.body
)}
+137 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
import { router, protectedProcedure, rateLimited } from "@/trpc/init";
import {
createSignedUploadUrl,
ALLOWED_CONTENT_TYPES,
@@ -10,6 +10,7 @@ import {
import { randomUUID } from "crypto";
import { petIdentitySelect } from "@/repositories/pet-repository";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { assertNotBlocked } from "@/lib/assert-not-blocked";
import { mux } from "@/lib/mux";
import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits";
@@ -276,4 +277,139 @@ export const storiesRouter = router({
take: 200,
});
}),
/**
* Gitea #29 — paw-like toggle for stories, mirrors reactions.toggle.
*/
toggleReaction: protectedProcedure
.use(rateLimited("stories.toggleReaction", 60, 60))
.input(z.object({ storyId: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }): Promise<{ reacted: boolean }> => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const existing = await ctx.prisma.storyReaction.findUnique({
where: { storyId_petId: { storyId: input.storyId, petId: input.petId } },
});
if (existing) {
await ctx.prisma.$transaction([
ctx.prisma.storyReaction.delete({
where: { storyId_petId: { storyId: input.storyId, petId: input.petId } },
}),
ctx.prisma.story.update({
where: { id: input.storyId },
data: { reactionCount: { decrement: 1 } },
}),
]);
return { reacted: false };
}
const targetStory = await ctx.prisma.story.findUnique({
where: { id: input.storyId },
select: { petId: true, expiresAt: true },
});
if (!targetStory || targetStory.expiresAt <= new Date()) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await assertNotBlocked(ctx.prisma, input.petId, targetStory.petId);
await ctx.prisma.$transaction([
ctx.prisma.storyReaction.create({
data: { storyId: input.storyId, petId: input.petId },
}),
ctx.prisma.story.update({
where: { id: input.storyId },
data: { reactionCount: { increment: 1 } },
}),
]);
return { reacted: true };
}),
/**
* Gitea #29 — story comments, mirrors comments.listByPost.
*/
listComments: protectedProcedure
.input(z.object({ storyId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
return ctx.prisma.storyComment.findMany({
where: { storyId: input.storyId },
include: {
pet: { select: petIdentitySelect },
},
orderBy: { createdAt: "asc" },
take: 100,
});
}),
/**
* Gitea #29 — mirrors comments.create. No mention-notification fan-out
* (unlike post comments) — stories expire in 24h and have no notify()
* wiring at all yet, matching the Advertisement engagement model.
*/
addComment: protectedProcedure
.use(rateLimited("stories.addComment", 20, 300))
.input(
z.object({
storyId: z.string().min(1),
petId: z.string().min(1),
body: z.string().min(1).max(500),
})
)
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const targetStory = await ctx.prisma.story.findUnique({
where: { id: input.storyId },
select: { petId: true, expiresAt: true },
});
if (!targetStory || targetStory.expiresAt <= new Date()) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await assertNotBlocked(ctx.prisma, input.petId, targetStory.petId);
const [comment] = await ctx.prisma.$transaction([
ctx.prisma.storyComment.create({
data: {
storyId: input.storyId,
petId: input.petId,
body: input.body,
},
include: {
pet: { select: petIdentitySelect },
},
}),
ctx.prisma.story.update({
where: { id: input.storyId },
data: { commentCount: { increment: 1 } },
}),
]);
return comment;
}),
deleteComment: protectedProcedure
.input(z.object({ commentId: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }): Promise<void> => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const comment = await ctx.prisma.storyComment.findUnique({
where: { id: input.commentId },
});
if (!comment) throw new TRPCError({ code: "NOT_FOUND" });
if (comment.petId !== input.petId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only delete your own comments.",
});
}
await ctx.prisma.$transaction([
ctx.prisma.storyComment.delete({ where: { id: input.commentId } }),
ctx.prisma.story.update({
where: { id: comment.storyId },
data: { commentCount: { decrement: 1 } },
}),
]);
}),
});