Files
petfeed/src/trpc/routers/comments.ts
T
admin e0c887c71c refactor(trpc): centralize pet-identity select fragments in a repository layer
The Prisma select fragment { id, name, nickname, avatarKey } (and its
isVerified + owner-sponsor extension) was copy-pasted ~38x across 13
router files -- no repository/query layer existed to consolidate it
(Gitea #31 DRY audit).

Adds src/repositories/pet-repository.ts exporting petIdentitySelect and
petCardSelect (composed via spread) plus their inferred Prisma payload
types, and migrates every call site to import and reuse them -- either
directly or composed with the few extra fields a given query needs
(e.g. ownerId, bio, isVerified where the base fragment doesn't cover
it). A handful of structurally different, smaller field selections
(e.g. name+nickname only, no id) were deliberately left as-is rather
than force-fit into the shared shape.

Pure refactor, no behavior change -- verified via tsc, vitest, and a
production build.
2026-08-21 22:01:57 +02:00

122 lines
3.5 KiB
TypeScript

import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure, rateLimited } from "@/trpc/init";
import { createMentionNotifications } from "@/lib/mention-helpers";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { notify } from "@/lib/notify";
import { petIdentitySelect } from "@/repositories/pet-repository";
export const commentsRouter = router({
create: protectedProcedure
.use(rateLimited("comments.create", 20, 300))
.input(
z.object({
postId: 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 [comment, post] = await ctx.prisma.$transaction([
ctx.prisma.comment.create({
data: {
postId: input.postId,
petId: input.petId,
body: input.body,
},
include: {
pet: { select: petIdentitySelect },
},
}),
ctx.prisma.post.update({
where: { id: input.postId },
data: { commentCount: { increment: 1 } },
select: { petId: true },
}),
]);
// NOTF-03: notify post owner (skip self-comments)
if (post.petId !== input.petId) {
notify(ctx.prisma, {
recipientPetId: post.petId,
type: "COMMENT",
actorPetId: input.petId,
postId: input.postId,
commentId: comment.id,
});
}
// Notify all @mentioned pets in the comment body
createMentionNotifications(
ctx.prisma,
input.body,
input.petId,
input.postId,
comment.id
).catch((err) => console.error("[comments] mention notifications failed", err));
return comment;
}),
listByPost: protectedProcedure
.input(
z.object({
postId: z.string().min(1),
limit: z.number().int().min(1).max(50).default(20),
cursor: z.string().optional(), // comment id cursor
})
)
.query(async ({ ctx, input }) => {
const comments = await ctx.prisma.comment.findMany({
where: {
postId: input.postId,
...(input.cursor ? { id: { gt: input.cursor } } : {}),
},
orderBy: { createdAt: "asc" },
take: input.limit + 1,
include: {
pet: { select: petIdentitySelect },
},
});
const hasNextPage = comments.length > input.limit;
if (hasNextPage) comments.pop();
return {
comments,
nextCursor: hasNextPage ? comments[comments.length - 1]!.id : null,
};
}),
delete: 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.comment.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.comment.delete({ where: { id: input.commentId } }),
ctx.prisma.post.update({
where: { id: comment.postId },
data: { commentCount: { decrement: 1 } },
}),
]);
}),
});