Files
echoboard/packages/api/src/routes/reactions.ts

115 lines
3.5 KiB
TypeScript

import { FastifyInstance } from "fastify";
import { z } from "zod";
import prisma from "../lib/prisma.js";
const reactionBody = z.object({
emoji: z.string().min(1).max(8).regex(/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u, "Invalid emoji"),
});
export default async function reactionRoutes(app: FastifyInstance) {
app.post<{ Params: { id: string }; Body: z.infer<typeof reactionBody> }>(
"/comments/:id/reactions",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) {
reply.status(404).send({ error: "Comment not found" });
return;
}
const post = await prisma.post.findUnique({
where: { id: comment.postId },
select: { isThreadLocked: true, board: { select: { isArchived: true } } },
});
if (post?.board?.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
if (post?.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const { emoji } = reactionBody.parse(req.body);
const existing = await prisma.reaction.findUnique({
where: {
commentId_userId_emoji: {
commentId: comment.id,
userId: req.user!.id,
emoji,
},
},
});
if (existing) {
await prisma.reaction.delete({ where: { id: existing.id } });
reply.send({ toggled: false });
} else {
const distinctCount = await prisma.reaction.count({
where: { commentId: comment.id, userId: req.user!.id },
});
if (distinctCount >= 10) {
reply.status(400).send({ error: "Too many reactions" });
return;
}
await prisma.reaction.create({
data: {
emoji,
commentId: comment.id,
userId: req.user!.id,
},
});
reply.send({ toggled: true });
}
}
);
app.delete<{ Params: { id: string; emoji: string } }>(
"/comments/:id/reactions/:emoji",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const emoji = req.params.emoji;
if (!emoji || emoji.length > 8 || !/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u.test(emoji)) {
reply.status(400).send({ error: "Invalid emoji" });
return;
}
const comment = await prisma.comment.findUnique({ where: { id: req.params.id }, select: { id: true, postId: true } });
if (!comment) {
reply.status(404).send({ error: "Comment not found" });
return;
}
const post = await prisma.post.findUnique({
where: { id: comment.postId },
select: { isThreadLocked: true, board: { select: { isArchived: true } } },
});
if (post?.board?.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
if (post?.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const deleted = await prisma.reaction.deleteMany({
where: {
commentId: req.params.id,
userId: req.user!.id,
emoji: req.params.emoji,
},
});
if (deleted.count === 0) {
reply.status(404).send({ error: "Reaction not found" });
return;
}
reply.send({ ok: true });
}
);
}