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:
216
packages/api/src/routes/posts.ts
Normal file
216
packages/api/src/routes/posts.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient, PostType, PostStatus, Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { verifyChallenge } from "../services/altcha.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const createPostSchema = z.object({
|
||||
type: z.nativeEnum(PostType),
|
||||
title: z.string().min(3).max(200),
|
||||
description: z.any(),
|
||||
category: z.string().optional(),
|
||||
altcha: z.string(),
|
||||
});
|
||||
|
||||
const updatePostSchema = z.object({
|
||||
title: z.string().min(3).max(200).optional(),
|
||||
description: z.any().optional(),
|
||||
category: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const querySchema = z.object({
|
||||
type: z.nativeEnum(PostType).optional(),
|
||||
category: z.string().optional(),
|
||||
status: z.nativeEnum(PostStatus).optional(),
|
||||
sort: z.enum(["newest", "oldest", "top", "trending"]).default("newest"),
|
||||
search: z.string().optional(),
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
limit: z.coerce.number().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export default async function postRoutes(app: FastifyInstance) {
|
||||
app.get<{ Params: { boardSlug: string }; Querystring: Record<string, string> }>(
|
||||
"/boards/:boardSlug/posts",
|
||||
{ preHandler: [app.optionalUser] },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const q = querySchema.parse(req.query);
|
||||
|
||||
const where: Prisma.PostWhereInput = { boardId: board.id };
|
||||
if (q.type) where.type = q.type;
|
||||
if (q.category) where.category = q.category;
|
||||
if (q.status) where.status = q.status;
|
||||
if (q.search) where.title = { contains: q.search, mode: "insensitive" };
|
||||
|
||||
let orderBy: Prisma.PostOrderByWithRelationInput;
|
||||
switch (q.sort) {
|
||||
case "oldest": orderBy = { createdAt: "asc" }; break;
|
||||
case "top": orderBy = { voteCount: "desc" }; break;
|
||||
case "trending": orderBy = { voteCount: "desc" }; break;
|
||||
default: orderBy = { createdAt: "desc" };
|
||||
}
|
||||
|
||||
const [posts, total] = await Promise.all([
|
||||
prisma.post.findMany({
|
||||
where,
|
||||
orderBy: [{ isPinned: "desc" }, orderBy],
|
||||
skip: (q.page - 1) * q.limit,
|
||||
take: q.limit,
|
||||
include: {
|
||||
_count: { select: { comments: true } },
|
||||
author: { select: { id: true, displayName: true } },
|
||||
},
|
||||
}),
|
||||
prisma.post.count({ where }),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
posts: posts.map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
title: p.title,
|
||||
status: p.status,
|
||||
category: p.category,
|
||||
voteCount: p.voteCount,
|
||||
isPinned: p.isPinned,
|
||||
commentCount: p._count.comments,
|
||||
author: p.author,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
})),
|
||||
total,
|
||||
page: q.page,
|
||||
pages: Math.ceil(total / q.limit),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { boardSlug: string; id: string } }>(
|
||||
"/boards/:boardSlug/posts/:id",
|
||||
{ preHandler: [app.optionalUser] },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
author: { select: { id: true, displayName: true } },
|
||||
_count: { select: { comments: true, votes: true } },
|
||||
adminResponses: {
|
||||
include: { admin: { select: { id: true, email: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
statusChanges: { orderBy: { createdAt: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
let voted = false;
|
||||
if (req.user) {
|
||||
const existing = await prisma.vote.findUnique({
|
||||
where: { postId_voterId: { postId: post.id, voterId: req.user.id } },
|
||||
});
|
||||
voted = !!existing;
|
||||
}
|
||||
|
||||
reply.send({ ...post, voted });
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { boardSlug: string }; Body: z.infer<typeof createPostSchema> }>(
|
||||
"/boards/:boardSlug/posts",
|
||||
{ preHandler: [app.requireUser] },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board || board.isArchived) {
|
||||
reply.status(404).send({ error: "Board not found or archived" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = createPostSchema.parse(req.body);
|
||||
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.create({
|
||||
data: {
|
||||
type: body.type,
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
category: body.category,
|
||||
boardId: board.id,
|
||||
authorId: req.user!.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.activityEvent.create({
|
||||
data: {
|
||||
type: "post_created",
|
||||
boardId: board.id,
|
||||
postId: post.id,
|
||||
metadata: { title: post.title, type: post.type },
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(201).send(post);
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof updatePostSchema> }>(
|
||||
"/boards/:boardSlug/posts/:id",
|
||||
{ preHandler: [app.requireUser] },
|
||||
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.authorId !== req.user!.id) {
|
||||
reply.status(403).send({ error: "Not your post" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = updatePostSchema.parse(req.body);
|
||||
const updated = await prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: {
|
||||
...(body.title !== undefined && { title: body.title }),
|
||||
...(body.description !== undefined && { description: body.description }),
|
||||
...(body.category !== undefined && { category: body.category }),
|
||||
},
|
||||
});
|
||||
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { boardSlug: string; id: string } }>(
|
||||
"/boards/:boardSlug/posts/:id",
|
||||
{ preHandler: [app.requireUser] },
|
||||
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.authorId !== req.user!.id) {
|
||||
reply.status(403).send({ error: "Not your post" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.post.delete({ where: { id: post.id } });
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user