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 }>( "/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 }>( "/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 }); } ); }