security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -1,27 +1,87 @@
import { FastifyInstance } from "fastify";
import { PrismaClient, PostStatus, Prisma } from "@prisma/client";
import { Prisma, PostType } from "@prisma/client";
import { z } from "zod";
import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import { notifyPostSubscribers } from "../../services/push.js";
import { fireWebhook } from "../../services/webhooks.js";
import { decrypt } from "../../services/encryption.js";
import { masterKey } from "../../config.js";
import { prisma } from "../../lib/prisma.js";
const prisma = new PrismaClient();
const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
function decryptName(v: string | null): string | null {
if (!v) return null;
try { return decrypt(v, masterKey); } catch { return null; }
}
const statusBody = z.object({
status: z.nativeEnum(PostStatus),
status: z.string().min(1).max(50),
reason: z.string().max(2000).optional(),
});
const respondBody = z.object({
body: z.string().min(1).max(5000),
});
const mergeBody = z.object({
targetPostId: z.string().min(1),
});
const rollbackBody = z.object({
editHistoryId: z.string().min(1),
});
const bulkIds = z.array(z.string().min(1)).min(1).max(100);
const bulkStatusBody = z.object({
postIds: bulkIds,
status: z.string().min(1).max(50),
});
const bulkDeleteBody = z.object({
postIds: bulkIds,
});
const bulkTagBody = z.object({
postIds: bulkIds,
tagId: z.string().min(1),
action: z.enum(['add', 'remove']),
});
const allowedDescKeys = new Set(["stepsToReproduce", "expectedBehavior", "actualBehavior", "environment", "useCase", "proposedSolution", "alternativesConsidered", "additionalContext"]);
const descriptionRecord = z.record(z.string()).refine(
(obj) => Object.keys(obj).length <= 10 && Object.keys(obj).every((k) => allowedDescKeys.has(k)),
{ message: "Unknown description fields" }
);
const proxyPostBody = z.object({
type: z.nativeEnum(PostType),
title: z.string().min(5).max(200),
description: descriptionRecord,
onBehalfOf: z.string().min(1).max(200),
});
const adminPostsQuery = z.object({
page: z.coerce.number().int().min(1).max(500).default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
status: z.string().max(50).optional(),
boardId: z.string().min(1).optional(),
});
export default async function adminPostRoutes(app: FastifyInstance) {
app.get<{ Querystring: Record<string, string> }>(
"/admin/posts",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const page = Math.max(1, parseInt(req.query.page ?? "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit ?? "50", 10)));
const status = req.query.status as PostStatus | undefined;
const boardId = req.query.boardId;
const q = adminPostsQuery.safeParse(req.query);
if (!q.success) {
reply.status(400).send({ error: "Invalid query parameters" });
return;
}
const { page, limit, status, boardId } = q.data;
const where: Prisma.PostWhereInput = {};
if (status) where.status = status;
@@ -31,24 +91,46 @@ export default async function adminPostRoutes(app: FastifyInstance) {
prisma.post.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
skip: Math.min((page - 1) * limit, 50000),
take: limit,
include: {
board: { select: { slug: true, name: true } },
author: { select: { id: true, displayName: true } },
_count: { select: { comments: true, votes: true } },
author: { select: { id: true, displayName: true, avatarPath: true } },
_count: { select: { comments: true, votes: true, adminNotes: true } },
tags: { include: { tag: true } },
},
}),
prisma.post.count({ where }),
]);
reply.send({ posts, total, page, pages: Math.ceil(total / limit) });
reply.send({
posts: posts.map((p) => ({
id: p.id,
type: p.type,
title: p.title,
description: p.description,
status: p.status,
statusReason: p.statusReason,
category: p.category,
voteCount: p.voteCount,
viewCount: p.viewCount,
isPinned: p.isPinned,
onBehalfOf: p.onBehalfOf,
board: p.board,
author: p.author ? { id: p.author.id, displayName: decryptName(p.author.displayName), avatarUrl: p.author.avatarPath ? `/api/v1/avatars/${p.author.id}` : null } : null,
tags: p.tags.map((pt) => pt.tag),
_count: p._count,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
total, page, pages: Math.ceil(total / limit),
});
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof statusBody> }>(
"/admin/posts/:id/status",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
@@ -56,17 +138,37 @@ export default async function adminPostRoutes(app: FastifyInstance) {
return;
}
const { status } = statusBody.parse(req.body);
const { status, reason } = statusBody.parse(req.body);
const reasonText = reason?.trim() || null;
// check if the target status exists and is enabled for this board
const boardConfig = await prisma.boardStatus.findMany({
where: { boardId: post.boardId, enabled: true },
});
if (boardConfig.length === 0) {
reply.status(400).send({ error: "No statuses configured for this board" });
return;
}
const statusEntry = boardConfig.find((c) => c.status === status);
if (!statusEntry) {
reply.status(400).send({ error: `Status "${status}" is not available for this board` });
return;
}
const oldStatus = post.status;
const [updated] = await Promise.all([
prisma.post.update({ where: { id: post.id }, data: { status } }),
prisma.post.update({
where: { id: post.id },
data: { status, statusReason: reasonText, lastActivityAt: new Date() },
}),
prisma.statusChange.create({
data: {
postId: post.id,
fromStatus: oldStatus,
toStatus: status,
changedBy: req.adminId!,
reason: reasonText,
},
}),
prisma.activityEvent.create({
@@ -79,20 +181,55 @@ export default async function adminPostRoutes(app: FastifyInstance) {
}),
]);
// notify post author and voters
const voters = await prisma.vote.findMany({
where: { postId: post.id },
select: { voterId: true },
});
const statusLabel = statusEntry.label || status.replace(/_/g, " ");
const notifBody = reasonText
? `"${post.title}" moved to ${statusLabel} - Reason: ${reasonText}`
: `"${post.title}" moved to ${statusLabel}`;
const sentinelId = "deleted-user-sentinel";
const voterIds = voters.map((v) => v.voterId).filter((id) => id !== sentinelId);
if (voterIds.length > 1000) {
req.log.warn({ postId: post.id, totalVoters: voterIds.length }, "notification capped at 1000 voters");
}
const notifyIds = voterIds.slice(0, 1000);
const userIds = new Set([post.authorId, ...notifyIds]);
userIds.delete(sentinelId);
await prisma.notification.createMany({
data: [...userIds].map((userId) => ({
type: "status_changed",
title: "Status updated",
body: notifBody,
postId: post.id,
userId,
})),
});
await notifyPostSubscribers(post.id, {
title: "Status updated",
body: `"${post.title}" moved to ${status}`,
body: notifBody,
url: `/post/${post.id}`,
tag: `status-${post.id}`,
});
fireWebhook("status_changed", {
postId: post.id,
title: post.title,
boardId: post.boardId,
from: oldStatus,
to: status,
});
reply.send(updated);
}
);
app.put<{ Params: { id: string } }>(
"/admin/posts/:id/pin",
{ preHandler: [app.requireAdmin] },
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
@@ -100,65 +237,323 @@ export default async function adminPostRoutes(app: FastifyInstance) {
return;
}
if (!post.isPinned) {
try {
const updated = await prisma.$transaction(async (tx) => {
const pinnedCount = await tx.post.count({
where: { boardId: post.boardId, isPinned: true },
});
if (pinnedCount >= 3) throw new Error("PIN_LIMIT");
return tx.post.update({
where: { id: post.id },
data: { isPinned: true },
});
}, { isolationLevel: "Serializable" });
reply.send(updated);
} catch (err: any) {
if (err.message === "PIN_LIMIT") {
reply.status(409).send({ error: "Max 3 pinned posts per board" });
return;
}
throw err;
}
} else {
const updated = await prisma.post.update({
where: { id: post.id },
data: { isPinned: false },
});
reply.send(updated);
}
}
);
app.post<{ Params: { id: string }; Body: z.infer<typeof respondBody> }>(
"/admin/posts/:id/respond",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin || !admin.linkedUserId) {
reply.status(500).send({ error: "Admin account not linked" });
return;
}
const { body } = respondBody.parse(req.body);
const cleanBody = body.replace(INVISIBLE_RE, '');
const comment = await prisma.comment.create({
data: {
body: cleanBody,
postId: post.id,
authorId: admin.linkedUserId,
isAdmin: true,
adminUserId: req.adminId!,
},
});
await prisma.activityEvent.create({
data: {
type: "admin_responded",
boardId: post.boardId,
postId: post.id,
metadata: {},
},
});
await notifyPostSubscribers(post.id, {
title: "Official response",
body: cleanBody.slice(0, 100),
url: `/post/${post.id}`,
tag: `response-${post.id}`,
});
reply.status(201).send(comment);
}
);
// Admin creates a post on behalf of a user
app.post<{ Params: { boardId: string }; Body: z.infer<typeof proxyPostBody> }>(
"/admin/boards/:boardId/posts",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board || board.isArchived) {
reply.status(404).send({ error: "Board not found or archived" });
return;
}
const body = proxyPostBody.parse(req.body);
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin || !admin.linkedUserId) {
reply.status(400).send({ error: "Admin account must be linked to a user to submit posts" });
return;
}
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
const cleanDesc: Record<string, string> = {};
for (const [k, v] of Object.entries(body.description)) {
cleanDesc[k] = v.replace(INVISIBLE_RE, '');
}
const post = await prisma.post.create({
data: {
type: body.type,
title: cleanTitle,
description: cleanDesc,
boardId: board.id,
authorId: admin.linkedUserId,
onBehalfOf: body.onBehalfOf,
},
});
await prisma.activityEvent.create({
data: {
type: "post_created",
boardId: board.id,
postId: post.id,
metadata: { title: post.title, type: post.type, onBehalfOf: body.onBehalfOf },
},
});
fireWebhook("post_created", {
postId: post.id,
title: post.title,
type: post.type,
boardId: board.id,
boardSlug: board.slug,
onBehalfOf: body.onBehalfOf,
});
reply.status(201).send(post);
}
);
// Merge source post into target post
app.post<{ Params: { id: string }; Body: z.infer<typeof mergeBody> }>(
"/admin/posts/:id/merge",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (req, reply) => {
const { targetPostId } = mergeBody.parse(req.body);
const source = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!source) {
reply.status(404).send({ error: "Source post not found" });
return;
}
const target = await prisma.post.findUnique({ where: { id: targetPostId } });
if (!target) {
reply.status(404).send({ error: "Target post not found" });
return;
}
if (source.id === target.id) {
reply.status(400).send({ error: "Cannot merge a post into itself" });
return;
}
if (source.boardId !== target.boardId) {
reply.status(400).send({ error: "Cannot merge posts across different boards" });
return;
}
// only load IDs and weights to minimize memory
const sourceVotes = await prisma.vote.findMany({
where: { postId: source.id },
select: { id: true, voterId: true, weight: true },
});
const targetVoterIds = new Set(
(await prisma.vote.findMany({ where: { postId: target.id }, select: { voterId: true } }))
.map((v) => v.voterId)
);
const votesToMove = sourceVotes.filter((v) => !targetVoterIds.has(v.voterId));
const targetTagIds = (await prisma.postTag.findMany({
where: { postId: target.id },
select: { tagId: true },
})).map((t) => t.tagId);
await prisma.$transaction(async (tx) => {
for (const v of votesToMove) {
await tx.vote.update({ where: { id: v.id }, data: { postId: target.id } });
}
await tx.vote.deleteMany({ where: { postId: source.id } });
await tx.comment.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
await tx.attachment.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
await tx.postTag.deleteMany({
where: { postId: source.id, tagId: { in: targetTagIds } },
});
await tx.postTag.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
await tx.post.update({
where: { id: target.id },
data: { voteCount: { increment: votesToMove.reduce((sum, v) => sum + v.weight, 0) } },
});
await tx.postMerge.create({
data: { sourcePostId: source.id, targetPostId: target.id, mergedBy: req.adminId! },
});
await tx.post.delete({ where: { id: source.id } });
}, { isolationLevel: "Serializable" });
const actualCount = await prisma.vote.aggregate({ where: { postId: target.id }, _sum: { weight: true } });
await prisma.post.update({ where: { id: target.id }, data: { voteCount: actualCount._sum.weight ?? 0 } });
req.log.info({ adminId: req.adminId, sourcePostId: source.id, targetPostId: target.id }, "posts merged");
reply.send({ merged: true, targetPostId: target.id });
}
);
app.put<{ Params: { id: string } }>(
"/admin/posts/:id/lock-edits",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const updated = await prisma.post.update({
where: { id: post.id },
data: { isPinned: !post.isPinned },
data: { isEditLocked: !post.isEditLocked },
});
reply.send({ isEditLocked: updated.isEditLocked });
}
);
app.put<{ Params: { id: string } }>(
"/admin/posts/:id/lock-thread",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = z.object({ lockVoting: z.boolean().optional() }).parse(req.body);
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const newThreadLocked = !post.isThreadLocked;
const data: Record<string, boolean> = { isThreadLocked: newThreadLocked };
if (newThreadLocked && body.lockVoting) {
data.isVotingLocked = true;
}
if (!newThreadLocked) {
data.isVotingLocked = false;
}
const updated = await prisma.post.update({ where: { id: post.id }, data });
reply.send({ isThreadLocked: updated.isThreadLocked, isVotingLocked: updated.isVotingLocked });
}
);
app.put<{ Params: { id: string } }>(
"/admin/comments/:id/lock-edits",
{ preHandler: [app.requireAdmin], 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 updated = await prisma.comment.update({
where: { id: comment.id },
data: { isEditLocked: !comment.isEditLocked },
});
reply.send({ isEditLocked: updated.isEditLocked });
}
);
app.post<{ Params: { id: string }; Body: z.infer<typeof rollbackBody> }>(
"/admin/posts/:id/rollback",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const { editHistoryId } = rollbackBody.parse(req.body);
const edit = await prisma.editHistory.findUnique({ where: { id: editHistoryId } });
if (!edit || edit.postId !== post.id) {
reply.status(404).send({ error: "Edit history entry not found" });
return;
}
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin?.linkedUserId) {
reply.status(400).send({ error: "Admin account must be linked to a user" });
return;
}
// save current state before rollback
await prisma.editHistory.create({
data: {
postId: post.id,
editedBy: admin.linkedUserId,
previousTitle: post.title,
previousDescription: post.description as any,
},
});
const data: Record<string, any> = {};
if (edit.previousTitle !== null) data.title = edit.previousTitle;
if (edit.previousDescription !== null) data.description = edit.previousDescription;
const updated = await prisma.post.update({
where: { id: post.id },
data,
});
reply.send(updated);
}
);
app.post<{ Params: { id: string }; Body: z.infer<typeof respondBody> }>(
"/admin/posts/:id/respond",
{ preHandler: [app.requireAdmin] },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const { body } = respondBody.parse(req.body);
const response = await prisma.adminResponse.create({
data: {
body,
postId: post.id,
adminId: req.adminId!,
},
include: { admin: { select: { id: true, email: true } } },
});
await notifyPostSubscribers(post.id, {
title: "Official response",
body: body.slice(0, 100),
url: `/post/${post.id}`,
tag: `response-${post.id}`,
});
reply.status(201).send(response);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/posts/:id",
{ preHandler: [app.requireAdmin] },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
await prisma.post.delete({ where: { id: post.id } });
reply.status(204).send();
}
);
app.delete<{ Params: { id: string } }>(
"/admin/comments/:id",
{ preHandler: [app.requireAdmin] },
app.post<{ Params: { id: string }; Body: z.infer<typeof rollbackBody> }>(
"/admin/comments/:id/rollback",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) {
@@ -166,8 +561,284 @@ export default async function adminPostRoutes(app: FastifyInstance) {
return;
}
await prisma.comment.delete({ where: { id: comment.id } });
const { editHistoryId } = rollbackBody.parse(req.body);
const edit = await prisma.editHistory.findUnique({ where: { id: editHistoryId } });
if (!edit || edit.commentId !== comment.id) {
reply.status(404).send({ error: "Edit history entry not found" });
return;
}
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin?.linkedUserId) {
reply.status(400).send({ error: "Admin account must be linked to a user" });
return;
}
await prisma.editHistory.create({
data: {
commentId: comment.id,
editedBy: admin.linkedUserId,
previousBody: comment.body,
},
});
if (edit.previousBody === null) {
reply.status(400).send({ error: "No previous body to restore" });
return;
}
const updated = await prisma.comment.update({
where: { id: comment.id },
data: { body: edit.previousBody },
});
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/posts/:id",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const commentIds = (await prisma.comment.findMany({
where: { postId: post.id },
select: { id: true },
})).map((c) => c.id);
const attachments = await prisma.attachment.findMany({
where: {
OR: [
{ postId: post.id },
...(commentIds.length ? [{ commentId: { in: commentIds } }] : []),
],
},
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.postMerge.deleteMany({
where: { OR: [{ sourcePostId: post.id }, { targetPostId: post.id }] },
});
await prisma.post.delete({ where: { id: post.id } });
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
req.log.info({ adminId: req.adminId, postId: post.id }, "admin post deleted");
reply.status(204).send();
}
);
app.delete<{ Params: { id: string } }>(
"/admin/comments/:id",
{ preHandler: [app.requireAdmin], 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 attachments = await prisma.attachment.findMany({
where: { commentId: comment.id },
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.comment.delete({ where: { id: comment.id } });
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
req.log.info({ adminId: req.adminId, commentId: comment.id }, "admin comment deleted");
reply.status(204).send();
}
);
app.post<{ Body: z.infer<typeof bulkStatusBody> }>(
"/admin/posts/bulk-status",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const parsed = bulkStatusBody.safeParse(req.body);
if (!parsed.success) {
reply.status(400).send({ error: "Invalid request body" });
return;
}
const { postIds, status } = parsed.data;
const posts = await prisma.post.findMany({
where: { id: { in: postIds } },
select: { id: true, status: true, boardId: true },
});
if (posts.length === 0) {
reply.send({ updated: 0 });
return;
}
// validate target status against each board's config
const boardIds = [...new Set(posts.map((p) => p.boardId))];
for (const boardId of boardIds) {
const boardStatuses = await prisma.boardStatus.findMany({
where: { boardId, enabled: true },
});
if (boardStatuses.length > 0 && !boardStatuses.some((s) => s.status === status)) {
reply.status(400).send({ error: `Status "${status}" is not enabled for board ${boardId}` });
return;
}
}
await prisma.$transaction([
prisma.post.updateMany({
where: { id: { in: posts.map((p) => p.id) } },
data: { status, lastActivityAt: new Date() },
}),
...posts.map((p) =>
prisma.statusChange.create({
data: {
postId: p.id,
fromStatus: p.status,
toStatus: status,
changedBy: req.adminId!,
},
})
),
]);
reply.send({ updated: posts.length });
}
);
app.post<{ Body: z.infer<typeof bulkDeleteBody> }>(
"/admin/posts/bulk-delete",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (req, reply) => {
const parsed = bulkDeleteBody.safeParse(req.body);
if (!parsed.success) {
reply.status(400).send({ error: "Invalid request body" });
return;
}
const { postIds } = parsed.data;
const posts = await prisma.post.findMany({
where: { id: { in: postIds } },
select: { id: true, boardId: true, title: true },
});
if (posts.length === 0) {
reply.send({ deleted: 0 });
return;
}
const validPostIds = posts.map((p) => p.id);
const commentIds = (await prisma.comment.findMany({
where: { postId: { in: validPostIds } },
select: { id: true },
})).map((c) => c.id);
const attachments = await prisma.attachment.findMany({
where: {
OR: [
{ postId: { in: validPostIds } },
...(commentIds.length ? [{ commentId: { in: commentIds } }] : []),
],
},
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.$transaction([
prisma.post.deleteMany({ where: { id: { in: validPostIds } } }),
...posts.map((p) =>
prisma.activityEvent.create({
data: {
type: "post_deleted",
boardId: p.boardId,
postId: p.id,
metadata: { title: p.title, deletedBy: req.adminId },
},
})
),
]);
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
req.log.info({ adminId: req.adminId, count: posts.length }, "bulk posts deleted");
reply.send({ deleted: posts.length });
}
);
app.post<{ Body: z.infer<typeof bulkTagBody> }>(
"/admin/posts/bulk-tag",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const parsed = bulkTagBody.safeParse(req.body);
if (!parsed.success) {
reply.status(400).send({ error: "Invalid request body" });
return;
}
const { postIds, tagId, action } = parsed.data;
const tag = await prisma.tag.findUnique({ where: { id: tagId } });
if (!tag) {
reply.status(404).send({ error: "Tag not found" });
return;
}
const existingPosts = await prisma.post.findMany({
where: { id: { in: postIds } },
select: { id: true },
});
const validIds = existingPosts.map((p) => p.id);
if (validIds.length === 0) {
reply.send({ affected: 0 });
return;
}
if (action === 'add') {
const existing = await prisma.postTag.findMany({
where: { tagId, postId: { in: validIds } },
select: { postId: true },
});
const alreadyTagged = new Set(existing.map((e) => e.postId));
const toAdd = validIds.filter((id) => !alreadyTagged.has(id));
if (toAdd.length > 0) {
await prisma.postTag.createMany({
data: toAdd.map((postId) => ({ postId, tagId })),
});
}
reply.send({ affected: toAdd.length });
} else {
const result = await prisma.postTag.deleteMany({
where: { tagId, postId: { in: validIds } },
});
reply.send({ affected: result.count });
}
}
);
}