security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user