initial project setup
Fastify + Prisma backend, React + Vite frontend, Docker deployment. Multi-board feedback platform with anonymous cookie auth, passkey upgrade path, ALTCHA spam protection, plugin system, and full privacy-first architecture.
This commit is contained in:
42
packages/api/src/routes/admin/auth.ts
Normal file
42
packages/api/src/routes/admin/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { z } from "zod";
|
||||
import { config } from "../../config.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const loginBody = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export default async function adminAuthRoutes(app: FastifyInstance) {
|
||||
app.post<{ Body: z.infer<typeof loginBody> }>(
|
||||
"/admin/login",
|
||||
async (req, reply) => {
|
||||
const body = loginBody.parse(req.body);
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { email: body.email } });
|
||||
if (!admin) {
|
||||
reply.status(401).send({ error: "Invalid credentials" });
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(body.password, admin.passwordHash);
|
||||
if (!valid) {
|
||||
reply.status(401).send({ error: "Invalid credentials" });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ sub: admin.id, type: "admin" },
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: "24h" }
|
||||
);
|
||||
|
||||
reply.send({ token });
|
||||
}
|
||||
);
|
||||
}
|
||||
112
packages/api/src/routes/admin/boards.ts
Normal file
112
packages/api/src/routes/admin/boards.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
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: z.string().url().optional(),
|
||||
voteBudget: z.number().int().min(0).default(10),
|
||||
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).default("monthly"),
|
||||
allowMultiVote: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const updateBoardBody = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
externalUrl: z.string().url().optional().nullable(),
|
||||
isArchived: z.boolean().optional(),
|
||||
voteBudget: z.number().int().min(0).optional(),
|
||||
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).optional(),
|
||||
allowMultiVote: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export default async function adminBoardRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/admin/boards",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
async (_req, reply) => {
|
||||
const boards = await prisma.board.findMany({
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
_count: { select: { posts: true } },
|
||||
},
|
||||
});
|
||||
reply.send(boards);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: z.infer<typeof createBoardBody> }>(
|
||||
"/admin/boards",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
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 });
|
||||
reply.status(201).send(board);
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBoardBody> }>(
|
||||
"/admin/boards/:id",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
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,
|
||||
});
|
||||
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/admin/boards/:id/reset-budget",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
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() },
|
||||
});
|
||||
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/boards/:id",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
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 } });
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
46
packages/api/src/routes/admin/categories.ts
Normal file
46
packages/api/src/routes/admin/categories.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const createCategoryBody = z.object({
|
||||
name: z.string().min(1).max(50),
|
||||
slug: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/),
|
||||
});
|
||||
|
||||
export default async function adminCategoryRoutes(app: FastifyInstance) {
|
||||
app.post<{ Body: z.infer<typeof createCategoryBody> }>(
|
||||
"/admin/categories",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const body = createCategoryBody.parse(req.body);
|
||||
|
||||
const existing = await prisma.category.findFirst({
|
||||
where: { OR: [{ name: body.name }, { slug: body.slug }] },
|
||||
});
|
||||
if (existing) {
|
||||
reply.status(409).send({ error: "Category already exists" });
|
||||
return;
|
||||
}
|
||||
|
||||
const cat = await prisma.category.create({ data: body });
|
||||
reply.status(201).send(cat);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/categories/:id",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const cat = await prisma.category.findUnique({ where: { id: req.params.id } });
|
||||
if (!cat) {
|
||||
reply.status(404).send({ error: "Category not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.category.delete({ where: { id: cat.id } });
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
173
packages/api/src/routes/admin/posts.ts
Normal file
173
packages/api/src/routes/admin/posts.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient, PostStatus, Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { notifyPostSubscribers } from "../../services/push.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const statusBody = z.object({
|
||||
status: z.nativeEnum(PostStatus),
|
||||
});
|
||||
|
||||
const respondBody = z.object({
|
||||
body: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export default async function adminPostRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: Record<string, string> }>(
|
||||
"/admin/posts",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
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 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: (page - 1) * limit,
|
||||
take: limit,
|
||||
include: {
|
||||
board: { select: { slug: true, name: true } },
|
||||
author: { select: { id: true, displayName: true } },
|
||||
_count: { select: { comments: true, votes: true } },
|
||||
},
|
||||
}),
|
||||
prisma.post.count({ where }),
|
||||
]);
|
||||
|
||||
reply.send({ posts, total, page, pages: Math.ceil(total / limit) });
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof statusBody> }>(
|
||||
"/admin/posts/:id/status",
|
||||
{ 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 { status } = statusBody.parse(req.body);
|
||||
const oldStatus = post.status;
|
||||
|
||||
const [updated] = await Promise.all([
|
||||
prisma.post.update({ where: { id: post.id }, data: { status } }),
|
||||
prisma.statusChange.create({
|
||||
data: {
|
||||
postId: post.id,
|
||||
fromStatus: oldStatus,
|
||||
toStatus: status,
|
||||
changedBy: req.adminId!,
|
||||
},
|
||||
}),
|
||||
prisma.activityEvent.create({
|
||||
data: {
|
||||
type: "status_changed",
|
||||
boardId: post.boardId,
|
||||
postId: post.id,
|
||||
metadata: { from: oldStatus, to: status },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await notifyPostSubscribers(post.id, {
|
||||
title: "Status updated",
|
||||
body: `"${post.title}" moved to ${status}`,
|
||||
url: `/post/${post.id}`,
|
||||
tag: `status-${post.id}`,
|
||||
});
|
||||
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/posts/:id/pin",
|
||||
{ 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 updated = await prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: { isPinned: !post.isPinned },
|
||||
});
|
||||
|
||||
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] },
|
||||
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;
|
||||
}
|
||||
|
||||
await prisma.comment.delete({ where: { id: comment.id } });
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
86
packages/api/src/routes/admin/stats.ts
Normal file
86
packages/api/src/routes/admin/stats.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { config } from "../../config.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default async function adminStatsRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/admin/stats",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
async (_req, reply) => {
|
||||
const [
|
||||
totalPosts,
|
||||
totalUsers,
|
||||
totalComments,
|
||||
totalVotes,
|
||||
postsByStatus,
|
||||
postsByType,
|
||||
boardStats,
|
||||
] = await Promise.all([
|
||||
prisma.post.count(),
|
||||
prisma.user.count(),
|
||||
prisma.comment.count(),
|
||||
prisma.vote.count(),
|
||||
prisma.post.groupBy({ by: ["status"], _count: true }),
|
||||
prisma.post.groupBy({ by: ["type"], _count: true }),
|
||||
prisma.board.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
_count: { select: { posts: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
totals: {
|
||||
posts: totalPosts,
|
||||
users: totalUsers,
|
||||
comments: totalComments,
|
||||
votes: totalVotes,
|
||||
},
|
||||
postsByStatus: Object.fromEntries(postsByStatus.map((s) => [s.status, s._count])),
|
||||
postsByType: Object.fromEntries(postsByType.map((t) => [t.type, t._count])),
|
||||
boards: boardStats.map((b) => ({
|
||||
id: b.id,
|
||||
slug: b.slug,
|
||||
name: b.name,
|
||||
postCount: b._count.posts,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/admin/data-retention",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
async (_req, reply) => {
|
||||
const activityCutoff = new Date();
|
||||
activityCutoff.setDate(activityCutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS);
|
||||
|
||||
const orphanCutoff = new Date();
|
||||
orphanCutoff.setDate(orphanCutoff.getDate() - config.DATA_RETENTION_ORPHAN_USER_DAYS);
|
||||
|
||||
const [staleEvents, orphanUsers] = await Promise.all([
|
||||
prisma.activityEvent.count({ where: { createdAt: { lt: activityCutoff } } }),
|
||||
prisma.user.count({
|
||||
where: {
|
||||
createdAt: { lt: orphanCutoff },
|
||||
posts: { none: {} },
|
||||
comments: { none: {} },
|
||||
votes: { none: {} },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
activityRetentionDays: config.DATA_RETENTION_ACTIVITY_DAYS,
|
||||
orphanRetentionDays: config.DATA_RETENTION_ORPHAN_USER_DAYS,
|
||||
staleActivityEvents: staleEvents,
|
||||
orphanedUsers: orphanUsers,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user