import { FastifyInstance } from "fastify"; 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 { firePluginEvent } from "../../services/webhooks.js"; import { decrypt } from "../../services/encryption.js"; import { masterKey } from "../../config.js"; import { prisma } from "../../lib/prisma.js"; 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.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 }>( "/admin/posts", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => { 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; if (boardId) where.boardId = boardId; const [posts, total] = await Promise.all([ prisma.post.findMany({ where, orderBy: { createdAt: "desc" }, skip: Math.min((page - 1) * limit, 50000), take: limit, include: { board: { select: { slug: true, name: 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: 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 }>( "/admin/posts/:id/status", { 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 { status, reason } = statusBody.parse(req.body); const reasonText = reason?.trim() || null; // check if the target status exists and is enabled for this board const DEFAULT_STATUSES = [ { status: "OPEN", label: "Open" }, { status: "UNDER_REVIEW", label: "Under Review" }, { status: "PLANNED", label: "Planned" }, { status: "IN_PROGRESS", label: "In Progress" }, { status: "DONE", label: "Done" }, { status: "DECLINED", label: "Declined" }, ]; const boardConfig = await prisma.boardStatus.findMany({ where: { boardId: post.boardId, enabled: true }, }); const validStatuses = boardConfig.length > 0 ? boardConfig.map((c) => ({ status: c.status, label: c.label })) : DEFAULT_STATUSES; const statusEntry = validStatuses.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, statusReason: reasonText, lastActivityAt: new Date() }, }), prisma.statusChange.create({ data: { postId: post.id, fromStatus: oldStatus, toStatus: status, changedBy: req.adminId!, reason: reasonText, }, }), prisma.activityEvent.create({ data: { type: "status_changed", boardId: post.boardId, postId: post.id, metadata: { from: oldStatus, to: status }, }, }), ]); // 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: notifBody, url: `/post/${post.id}`, tag: `status-${post.id}`, }); firePluginEvent("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], 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; } 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 }>( "/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 }>( "/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 = {}; 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 }, }, }); firePluginEvent("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 }>( "/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: { 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 = { 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 }); } ); // delete a status change entry app.delete<{ Params: { id: string } }>( "/admin/status-changes/:id", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const sc = await prisma.statusChange.findUnique({ where: { id: req.params.id }, select: { id: true, postId: true, toStatus: true } }); if (!sc) { reply.status(404).send({ error: "Status change not found" }); return; } // delete related notifications await prisma.notification.deleteMany({ where: { postId: sc.postId, type: "status_changed" }, }); await prisma.statusChange.delete({ where: { id: sc.id } }); req.log.info({ adminId: req.adminId, statusChangeId: sc.id }, "status change deleted"); reply.status(204).send(); } ); app.post<{ Params: { id: string }; Body: z.infer }>( "/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 = {}; 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 }>( "/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) { reply.status(404).send({ error: "Comment not found" }); return; } 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 }>( "/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 }>( "/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 }>( "/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 }); } } ); }