869 lines
29 KiB
TypeScript
869 lines
29 KiB
TypeScript
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<string, string> }>(
|
|
"/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<typeof statusBody> }>(
|
|
"/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<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 },
|
|
},
|
|
});
|
|
|
|
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<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: { 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 });
|
|
}
|
|
);
|
|
|
|
// 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<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 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) {
|
|
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<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 });
|
|
}
|
|
}
|
|
);
|
|
}
|