145 lines
5.4 KiB
TypeScript
145 lines
5.4 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
|
import { z } from "zod";
|
|
import { seedTemplatesForBoard } from "../../lib/default-templates.js";
|
|
import { prisma } from "../../lib/prisma.js";
|
|
|
|
const safeUrl = z.string().url().refine((u) => /^https?:\/\//i.test(u), { message: "URL must use http or https" });
|
|
|
|
const iconName = z.string().max(80).regex(/^Icon[A-Za-z0-9]+$/).optional().nullable();
|
|
const iconColor = z.string().max(30).regex(/^#[0-9a-fA-F]{3,8}$/).optional().nullable();
|
|
|
|
const createBoardBody = z.object({
|
|
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/),
|
|
name: z.string().min(1).max(100),
|
|
description: z.string().max(500).optional(),
|
|
externalUrl: safeUrl.optional(),
|
|
iconName,
|
|
iconColor,
|
|
voteBudget: z.number().int().min(0).default(10),
|
|
voteBudgetReset: z.enum(["weekly", "biweekly", "monthly", "quarterly", "per_release", "never"]).default("monthly"),
|
|
allowMultiVote: z.boolean().default(false),
|
|
rssEnabled: z.boolean().default(true),
|
|
rssFeedCount: z.number().int().min(1).max(200).default(50),
|
|
staleDays: z.number().int().min(0).max(365).default(0),
|
|
});
|
|
|
|
const updateBoardBody = z.object({
|
|
name: z.string().min(1).max(100).optional(),
|
|
description: z.string().max(500).optional().nullable(),
|
|
externalUrl: safeUrl.optional().nullable(),
|
|
iconName,
|
|
iconColor,
|
|
isArchived: z.boolean().optional(),
|
|
voteBudget: z.number().int().min(0).optional(),
|
|
voteBudgetReset: z.enum(["weekly", "biweekly", "monthly", "quarterly", "per_release", "never"]).optional(),
|
|
allowMultiVote: z.boolean().optional(),
|
|
rssEnabled: z.boolean().optional(),
|
|
rssFeedCount: z.number().int().min(1).max(200).optional(),
|
|
staleDays: z.number().int().min(0).max(365).optional(),
|
|
});
|
|
|
|
export default async function adminBoardRoutes(app: FastifyInstance) {
|
|
app.get(
|
|
"/admin/boards",
|
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
|
async (_req, reply) => {
|
|
const boards = await prisma.board.findMany({
|
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
|
include: {
|
|
_count: { select: { posts: true } },
|
|
},
|
|
});
|
|
reply.send(boards);
|
|
}
|
|
);
|
|
|
|
app.post<{ Body: z.infer<typeof createBoardBody> }>(
|
|
"/admin/boards",
|
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
|
async (req, reply) => {
|
|
const body = createBoardBody.parse(req.body);
|
|
|
|
const existing = await prisma.board.findUnique({ where: { slug: body.slug } });
|
|
if (existing) {
|
|
reply.status(409).send({ error: "Slug already taken" });
|
|
return;
|
|
}
|
|
|
|
const board = await prisma.board.create({ data: body });
|
|
await seedTemplatesForBoard(prisma, board.id);
|
|
req.log.info({ adminId: req.adminId, boardId: board.id, slug: board.slug }, "board created");
|
|
reply.status(201).send(board);
|
|
}
|
|
);
|
|
|
|
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBoardBody> }>(
|
|
"/admin/boards/:id",
|
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
|
async (req, reply) => {
|
|
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
|
if (!board) {
|
|
reply.status(404).send({ error: "Board not found" });
|
|
return;
|
|
}
|
|
|
|
const body = updateBoardBody.parse(req.body);
|
|
const updated = await prisma.board.update({
|
|
where: { id: board.id },
|
|
data: body,
|
|
});
|
|
|
|
req.log.info({ adminId: req.adminId, boardId: board.id }, "board updated");
|
|
reply.send(updated);
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
"/admin/boards/:id/reset-budget",
|
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
|
async (req, reply) => {
|
|
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
|
if (!board) {
|
|
reply.status(404).send({ error: "Board not found" });
|
|
return;
|
|
}
|
|
|
|
const updated = await prisma.board.update({
|
|
where: { id: board.id },
|
|
data: { lastBudgetReset: new Date() },
|
|
});
|
|
|
|
req.log.info({ adminId: req.adminId, boardId: board.id }, "budget reset");
|
|
reply.send(updated);
|
|
}
|
|
);
|
|
|
|
app.delete<{ Params: { id: string } }>(
|
|
"/admin/boards/:id",
|
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
|
async (req, reply) => {
|
|
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
|
if (!board) {
|
|
reply.status(404).send({ error: "Board not found" });
|
|
return;
|
|
}
|
|
|
|
await prisma.board.delete({ where: { id: board.id } });
|
|
req.log.info({ adminId: req.adminId, boardId: board.id, boardSlug: board.slug }, "board deleted");
|
|
reply.status(204).send();
|
|
}
|
|
);
|
|
|
|
// reorder boards
|
|
app.put(
|
|
"/admin/boards/reorder",
|
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
|
async (req, reply) => {
|
|
const body = z.object({ boardIds: z.array(z.string().min(1)).min(1) }).parse(req.body);
|
|
await prisma.$transaction(
|
|
body.boardIds.map((id, i) => prisma.board.update({ where: { id }, data: { position: i } }))
|
|
);
|
|
reply.send({ ok: true });
|
|
}
|
|
);
|
|
}
|