feat : Init Project

This commit is contained in:
Thibault Pouch
2026-02-28 14:20:09 +01:00
commit f700d7fd86
20 changed files with 4066 additions and 0 deletions

44
nest-backend/src/app.ts Normal file
View File

@@ -0,0 +1,44 @@
import express from 'express';
import cors from 'cors';
import authRouter from './routes/auth.js';
import usersRouter from './routes/users.js';
import forumRouter from './routes/forum.js';
import bugsRouter from './routes/bugs.js';
import feedRouter from './routes/feed.js';
import eventsRouter from './routes/events.js';
import teamRouter from './routes/team.js';
const app = express();
app.use(cors({
origin: [
'http://localhost:5173', // nest-front dev
'http://localhost:5174', // nest-intra dev
process.env.FRONT_ORIGIN ?? '',
process.env.INTRA_ORIGIN ?? '',
].filter(Boolean),
credentials: true,
}));
app.use(express.json());
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.use('/api/auth', authRouter);
app.use('/api/users', usersRouter);
app.use('/api/forum', forumRouter);
app.use('/api/bugs', bugsRouter);
app.use('/api/feed', feedRouter);
app.use('/api/events', eventsRouter);
app.use('/api/team', teamRouter);
// 404
app.use((_req, res) => res.status(404).json({ error: 'Not found' }));
// Global error handler
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
export default app;

View File

@@ -0,0 +1,8 @@
import 'dotenv/config';
import app from './app.js';
const PORT = parseInt(process.env.PORT ?? '3000', 10);
app.listen(PORT, () => {
console.log(`[server] Running on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default prisma;

View File

@@ -0,0 +1,57 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface JwtPayload {
userId: string;
role: string;
isAdmin: boolean;
}
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' });
return;
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = payload;
next();
} catch {
res.status(401).json({ error: 'Token expired or invalid' });
}
}
export function requireStaff(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
if (req.user.role !== 'dev' && req.user.role !== 'com') {
res.status(403).json({ error: 'Staff access required' });
return;
}
next();
}
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
if (!req.user.isAdmin) {
res.status(403).json({ error: 'Admin access required' });
return;
}
next();
}

View File

@@ -0,0 +1,115 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import prisma from '../lib/prisma.js';
import { authenticate } from '../middleware/auth.js';
const router = Router();
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const registerSchema = z.object({
username: z.string().min(2).max(32),
email: z.string().email(),
password: z.string().min(6),
});
function signToken(userId: string, role: string, isAdmin: boolean): string {
return jwt.sign({ userId, role, isAdmin }, process.env.JWT_SECRET!, { expiresIn: '7d' });
}
function safeUser(user: { id: string; username: string; email: string; role: string; isAdmin: boolean; isBanned: boolean; avatarUrl: string | null; createdAt: Date }) {
return {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt.toISOString(),
};
}
// POST /api/auth/login
router.post('/login', async (req: Request, res: Response): Promise<void> => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (!user) {
res.status(401).json({ error: 'No account found with that email address.' });
return;
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
res.status(401).json({ error: 'Incorrect password.' });
return;
}
if (user.isBanned) {
res.status(403).json({ error: 'This account has been suspended.' });
return;
}
const token = signToken(user.id, user.role, user.isAdmin);
res.json({ token, user: safeUser(user) });
});
// POST /api/auth/register (public site only — creates role=user)
router.post('/register', async (req: Request, res: Response): Promise<void> => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
const { username, email, password } = parsed.data;
const emailTaken = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (emailTaken) {
res.status(409).json({ error: 'An account with this email already exists.' });
return;
}
const usernameTaken = await prisma.user.findUnique({ where: { username } });
if (usernameTaken) {
res.status(409).json({ error: 'This username is already taken.' });
return;
}
const hashed = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username,
email: email.toLowerCase(),
password: hashed,
role: 'user',
},
});
const token = signToken(user.id, user.role, user.isAdmin);
res.status(201).json({ token, user: safeUser(user) });
});
// GET /api/auth/me
router.get('/me', authenticate, async (req: Request, res: Response): Promise<void> => {
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json(safeUser(user));
});
export default router;

View File

@@ -0,0 +1,230 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma.js';
import { authenticate, requireStaff, requireAdmin } from '../middleware/auth.js';
const router = Router();
let bugCounter = 0;
async function nextUniqueCode(): Promise<string> {
if (bugCounter === 0) {
const last = await prisma.bugReport.findFirst({ orderBy: { uniqueCode: 'desc' } });
if (last) {
bugCounter = parseInt(last.uniqueCode.replace('HH-', ''), 10);
}
}
bugCounter++;
return `HH-${String(bugCounter).padStart(4, '0')}`;
}
// Inline include to allow Prisma to correctly infer the return type
function includeRelations() {
return {
submittedBy: { select: { id: true, username: true } },
assignedTo: { select: { id: true, username: true } },
comments: {
include: { author: { select: { id: true, username: true } } },
orderBy: { createdAt: 'asc' as const },
},
notes: {
include: { author: { select: { id: true, username: true } } },
orderBy: { createdAt: 'asc' as const },
},
meTooBugs: { select: { userId: true } },
} as const;
}
type BugWithRelations = Awaited<ReturnType<typeof getBugWithRelations>>;
async function getBugWithRelations(id: string) {
return prisma.bugReport.findUnique({ where: { id }, include: includeRelations() });
}
function formatBug(bug: NonNullable<BugWithRelations>) {
return {
id: bug.id,
uniqueCode: bug.uniqueCode,
title: bug.title,
description: bug.description,
stepsToReproduce: bug.stepsToReproduce,
severity: bug.severity,
gameVersion: bug.gameVersion,
screenshotUrl: bug.screenshotUrl,
status: bug.status,
submittedById: bug.submittedBy.id,
submittedByName: bug.submittedBy.username,
assignedToId: bug.assignedTo?.id ?? null,
assignedToName: bug.assignedTo?.username ?? null,
createdAt: bug.createdAt.toISOString(),
updatedAt: bug.updatedAt.toISOString(),
meTooBugs: bug.meTooBugs.map((m) => m.userId),
comments: bug.comments.map((c) => ({
id: c.id,
bugReportId: bug.id,
authorId: c.author.id,
authorName: c.author.username,
content: c.content,
createdAt: c.createdAt.toISOString(),
})),
notes: bug.notes.map((n) => ({
id: n.id,
bugReportId: bug.id,
authorId: n.author.id,
authorName: n.author.username,
content: n.content,
createdAt: n.createdAt.toISOString(),
})),
};
}
// GET /api/bugs?status=...&severity=...&assignedTo=...&page=1&limit=20
router.get('/', async (req: Request, res: Response): Promise<void> => {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(50, parseInt(req.query.limit as string) || 20);
const where: Record<string, unknown> = {};
if (req.query.status && req.query.status !== 'all') where.status = req.query.status;
if (req.query.severity && req.query.severity !== 'all') where.severity = req.query.severity;
if (req.query.assignedTo && req.query.assignedTo !== 'all') {
where.assignedToId = req.query.assignedTo === 'unassigned' ? null : req.query.assignedTo;
}
const [bugs, total] = await Promise.all([
prisma.bugReport.findMany({
where,
include: includeRelations(),
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.bugReport.count({ where }),
]);
res.json({ bugs: bugs.map(formatBug), total, page, pages: Math.ceil(total / limit) });
});
// GET /api/bugs/:id
router.get('/:id', async (req: Request, res: Response): Promise<void> => {
const bug = await getBugWithRelations(req.params.id);
if (!bug) { res.status(404).json({ error: 'Bug report not found' }); return; }
res.json(formatBug(bug));
});
// POST /api/bugs (authenticated)
router.post('/', authenticate, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
title: z.string().min(1).max(200),
description: z.string().min(1),
stepsToReproduce: z.string().min(1),
severity: z.enum(['low', 'medium', 'high', 'critical']),
gameVersion: z.string().min(1),
screenshotUrl: z.string().url().optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const uniqueCode = await nextUniqueCode();
const bug = await prisma.bugReport.create({
data: { ...parsed.data, uniqueCode, submittedById: req.user!.userId },
include: includeRelations(),
});
res.status(201).json(formatBug(bug));
});
// PATCH /api/bugs/:id (staff or admin)
router.patch('/:id', authenticate, requireStaff, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
status: z.enum(['open', 'in_progress', 'resolved', 'closed']).optional(),
assignedToId: z.string().nullable().optional(),
severity: z.enum(['low', 'medium', 'high', 'critical']).optional(),
title: z.string().min(1).max(200).optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const bug = await prisma.bugReport.update({
where: { id: req.params.id },
data: parsed.data,
include: includeRelations(),
});
res.json(formatBug(bug));
});
// POST /api/bugs/:id/me-too (authenticated — toggle)
router.post('/:id/me-too', authenticate, async (req: Request, res: Response): Promise<void> => {
const userId = req.user!.userId;
const bugReportId = req.params.id;
const existing = await prisma.meTooBug.findUnique({
where: { userId_bugReportId: { userId, bugReportId } },
});
if (existing) {
await prisma.meTooBug.delete({ where: { userId_bugReportId: { userId, bugReportId } } });
} else {
await prisma.meTooBug.create({ data: { userId, bugReportId } });
}
const bug = await getBugWithRelations(bugReportId);
if (!bug) { res.status(404).json({ error: 'Bug report not found' }); return; }
res.json(formatBug(bug));
});
// POST /api/bugs/:id/comments (authenticated)
router.post('/:id/comments', authenticate, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({ content: z.string().min(1) });
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const comment = await prisma.bugComment.create({
data: { content: parsed.data.content, bugReportId: req.params.id, authorId: req.user!.userId },
include: { author: { select: { id: true, username: true } } },
});
res.status(201).json({
id: comment.id,
bugReportId: req.params.id,
authorId: comment.author.id,
authorName: comment.author.username,
content: comment.content,
createdAt: comment.createdAt.toISOString(),
});
});
// DELETE /api/bugs/comments/:id (admin only)
router.delete('/comments/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
await prisma.bugComment.delete({ where: { id: req.params.id } });
res.status(204).end();
});
// POST /api/bugs/:id/notes (staff only)
router.post('/:id/notes', authenticate, requireStaff, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({ content: z.string().min(1) });
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const note = await prisma.bugReportNote.create({
data: { content: parsed.data.content, bugReportId: req.params.id, authorId: req.user!.userId },
include: { author: { select: { id: true, username: true } } },
});
res.status(201).json({
id: note.id,
bugReportId: req.params.id,
authorId: note.author.id,
authorName: note.author.username,
content: note.content,
createdAt: note.createdAt.toISOString(),
});
});
// DELETE /api/bugs/notes/:id (admin only)
router.delete('/notes/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
await prisma.bugReportNote.delete({ where: { id: req.params.id } });
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,230 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma.js';
import { authenticate, requireStaff, requireAdmin } from '../middleware/auth.js';
const router = Router();
function includeRelations() {
return {
author: { select: { id: true, username: true, role: true } },
poll: {
include: {
options: { include: { votes: { select: { userId: true } } } },
},
},
} as const;
}
type EventWithRelations = Awaited<ReturnType<typeof getEventWithRelations>>;
async function getEventWithRelations(id: string) {
return prisma.eventPost.findUnique({ where: { id }, include: includeRelations() });
}
function formatEvent(event: NonNullable<EventWithRelations>) {
return {
id: event.id,
type: event.type,
title: event.title,
content: event.content,
isPublic: event.isPublic,
authorId: event.author.id,
authorName: event.author.username,
authorRole: event.author.role,
createdAt: event.createdAt.toISOString(),
updatedAt: event.updatedAt.toISOString(),
pollId: event.poll?.id ?? null,
poll: event.poll ? {
id: event.poll.id,
eventId: event.id,
question: event.poll.question,
isActive: event.poll.isActive,
endsAt: event.poll.endsAt?.toISOString() ?? null,
allowMultipleVotes: event.poll.allowMultipleVotes,
createdAt: event.poll.createdAt.toISOString(),
options: event.poll.options.map((o) => ({
id: o.id,
text: o.text,
votes: o.votes.length,
votedUserIds: o.votes.map((v) => v.userId),
})),
} : null,
};
}
// GET /api/events?public=true&page=1&limit=20
router.get('/', async (req: Request, res: Response): Promise<void> => {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(50, parseInt(req.query.limit as string) || 20);
const publicOnly = req.query.public === 'true';
const where = publicOnly ? { isPublic: true } : {};
const [events, total] = await Promise.all([
prisma.eventPost.findMany({
where,
include: includeRelations(),
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.eventPost.count({ where }),
]);
res.json({ events: events.map(formatEvent), total, page, pages: Math.ceil(total / limit) });
});
// GET /api/events/:id
router.get('/:id', async (req: Request, res: Response): Promise<void> => {
const event = await getEventWithRelations(req.params.id);
if (!event) { res.status(404).json({ error: 'Event not found' }); return; }
res.json(formatEvent(event));
});
// POST /api/events (staff only)
router.post('/', authenticate, requireStaff, async (req: Request, res: Response): Promise<void> => {
const pollOptionSchema = z.object({ text: z.string().min(1) });
const pollSchema = z.object({
question: z.string().min(1),
options: z.array(pollOptionSchema).min(2).max(10),
isActive: z.boolean().optional(),
endsAt: z.string().datetime().optional(),
allowMultipleVotes: z.boolean().optional(),
});
const schema = z.object({
type: z.enum(['announcement', 'update', 'milestone', 'poll']),
title: z.string().min(1).max(200),
content: z.string().min(1),
isPublic: z.boolean().optional(),
poll: pollSchema.optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const { type, title, content, isPublic = true, poll } = parsed.data;
if (type === 'poll' && !poll) {
res.status(400).json({ error: 'Poll data required for event type "poll"' });
return;
}
const event = await prisma.eventPost.create({
data: {
type,
title,
content,
isPublic,
authorId: req.user!.userId,
...(poll && type === 'poll' ? {
poll: {
create: {
question: poll.question,
isActive: poll.isActive ?? true,
endsAt: poll.endsAt ? new Date(poll.endsAt) : null,
allowMultipleVotes: poll.allowMultipleVotes ?? false,
options: { create: poll.options.map((o) => ({ text: o.text })) },
},
},
} : {}),
},
include: includeRelations(),
});
res.status(201).json(formatEvent(event));
});
// PATCH /api/events/:id (staff or admin)
router.patch('/:id', authenticate, requireStaff, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
title: z.string().min(1).max(200).optional(),
content: z.string().min(1).optional(),
isPublic: z.boolean().optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const event = await prisma.eventPost.update({
where: { id: req.params.id },
data: parsed.data,
include: includeRelations(),
});
res.json(formatEvent(event));
});
// DELETE /api/events/:id (admin only)
router.delete('/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
await prisma.eventPost.delete({ where: { id: req.params.id } });
res.status(204).end();
});
// POST /api/events/:id/vote (authenticated)
router.post('/:id/vote', authenticate, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({ optionIds: z.array(z.string()).min(1) });
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const event = await getEventWithRelations(req.params.id);
if (!event?.poll) { res.status(404).json({ error: 'Poll not found for this event' }); return; }
if (!event.poll.isActive) { res.status(400).json({ error: 'This poll is no longer active' }); return; }
if (event.poll.endsAt && new Date(event.poll.endsAt) < new Date()) {
res.status(400).json({ error: 'This poll has ended' });
return;
}
const { optionIds } = parsed.data;
const userId = req.user!.userId;
if (!event.poll.allowMultipleVotes && optionIds.length > 1) {
res.status(400).json({ error: 'This poll only allows one vote' });
return;
}
const validOptionIds = event.poll.options.map((o) => o.id);
if (!optionIds.every((id) => validOptionIds.includes(id))) {
res.status(400).json({ error: 'Invalid option ID(s)' });
return;
}
// Remove existing votes for this poll by this user
await prisma.pollVote.deleteMany({
where: { userId, pollOption: { pollId: event.poll.id } },
});
// Create new votes
await prisma.pollVote.createMany({
data: optionIds.map((pollOptionId) => ({ userId, pollOptionId })),
});
const updated = await getEventWithRelations(req.params.id);
res.json(formatEvent(updated!));
});
// PATCH /api/events/:id/poll — toggle poll active / update endsAt (staff only)
router.patch('/:id/poll', authenticate, requireStaff, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
isActive: z.boolean().optional(),
endsAt: z.string().datetime().nullable().optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const event = await prisma.eventPost.findUnique({ where: { id: req.params.id }, include: { poll: true } });
if (!event?.poll) { res.status(404).json({ error: 'Poll not found' }); return; }
await prisma.poll.update({
where: { id: event.poll.id },
data: {
isActive: parsed.data.isActive,
endsAt: parsed.data.endsAt !== undefined
? (parsed.data.endsAt ? new Date(parsed.data.endsAt) : null)
: undefined,
},
});
const updated = await getEventWithRelations(req.params.id);
res.json(formatEvent(updated!));
});
export default router;

View File

@@ -0,0 +1,59 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma.js';
import { authenticate, requireStaff, requireAdmin } from '../middleware/auth.js';
const router = Router();
// GET /api/feed (staff only)
router.get('/', authenticate, requireStaff, async (_req: Request, res: Response): Promise<void> => {
const posts = await prisma.staffPost.findMany({
include: { author: { select: { id: true, username: true, role: true } } },
orderBy: { createdAt: 'desc' },
});
res.json(posts.map((p) => ({
id: p.id,
authorId: p.author.id,
authorName: p.author.username,
authorRole: p.author.role,
content: p.content,
createdAt: p.createdAt.toISOString(),
})));
});
// POST /api/feed (staff only)
router.post('/', authenticate, requireStaff, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({ content: z.string().min(1) });
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const post = await prisma.staffPost.create({
data: { content: parsed.data.content, authorId: req.user!.userId },
include: { author: { select: { id: true, username: true, role: true } } },
});
res.status(201).json({
id: post.id,
authorId: post.author.id,
authorName: post.author.username,
authorRole: post.author.role,
content: post.content,
createdAt: post.createdAt.toISOString(),
});
});
// DELETE /api/feed/:id (author or admin)
router.delete('/:id', authenticate, async (req: Request, res: Response): Promise<void> => {
const post = await prisma.staffPost.findUnique({ where: { id: req.params.id } });
if (!post) { res.status(404).json({ error: 'Post not found' }); return; }
const isAuthor = post.authorId === req.user!.userId;
const isAdmin = req.user!.isAdmin;
if (!isAuthor && !isAdmin) { res.status(403).json({ error: 'Forbidden' }); return; }
await prisma.staffPost.delete({ where: { id: post.id } });
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,294 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma.js';
import { authenticate, requireAdmin } from '../middleware/auth.js';
const router = Router();
// ── Categories ─────────────────────────────────────────────────────────────────
// GET /api/forum/categories
router.get('/categories', async (_req: Request, res: Response): Promise<void> => {
const categories = await prisma.forumCategory.findMany({
include: { _count: { select: { threads: true } } },
orderBy: { createdAt: 'asc' },
});
const result = await Promise.all(categories.map(async (cat) => {
const lastThread = await prisma.forumThread.findFirst({
where: { categoryId: cat.id },
orderBy: { updatedAt: 'desc' },
include: { replies: { orderBy: { createdAt: 'desc' }, take: 1, include: { author: true } } },
});
return {
id: cat.id,
name: cat.name,
description: cat.description,
icon: cat.icon,
threadCount: cat._count.threads,
lastActivity: lastThread?.updatedAt.toISOString() ?? null,
};
}));
res.json(result);
});
// POST /api/forum/categories (admin only)
router.post('/categories', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
name: z.string().min(1).max(64),
description: z.string().min(1).max(256),
icon: z.string().min(1).max(8),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const cat = await prisma.forumCategory.create({ data: parsed.data });
res.status(201).json(cat);
});
// PATCH /api/forum/categories/:id (admin only)
router.patch('/categories/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
name: z.string().min(1).max(64).optional(),
description: z.string().min(1).max(256).optional(),
icon: z.string().min(1).max(8).optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const cat = await prisma.forumCategory.update({ where: { id: req.params.id }, data: parsed.data });
res.json(cat);
});
// DELETE /api/forum/categories/:id (admin only)
router.delete('/categories/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
await prisma.forumCategory.delete({ where: { id: req.params.id } });
res.status(204).end();
});
// ── Threads ────────────────────────────────────────────────────────────────────
// GET /api/forum/threads?categoryId=...&page=1&limit=20
router.get('/threads', async (req: Request, res: Response): Promise<void> => {
const categoryId = typeof req.query.categoryId === 'string' ? req.query.categoryId : undefined;
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(50, parseInt(req.query.limit as string) || 20);
const where = categoryId ? { categoryId } : {};
const [threads, total] = await Promise.all([
prisma.forumThread.findMany({
where,
include: {
author: { select: { id: true, username: true } },
category: { select: { id: true, name: true } },
_count: { select: { replies: true } },
replies: {
orderBy: { createdAt: 'desc' },
take: 1,
include: { author: { select: { username: true } } },
},
},
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
prisma.forumThread.count({ where }),
]);
res.json({
threads: threads.map((t) => ({
id: t.id,
title: t.title,
authorId: t.author.id,
authorName: t.author.username,
categoryId: t.category.id,
categoryName: t.category.name,
content: t.content,
isPinned: t.isPinned,
isLocked: t.isLocked,
replyCount: t._count.replies,
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
lastReplyAuthor: t.replies[0]?.author.username ?? null,
})),
total,
page,
pages: Math.ceil(total / limit),
});
});
// GET /api/forum/threads/:id
router.get('/threads/:id', async (req: Request, res: Response): Promise<void> => {
const thread = await prisma.forumThread.findUnique({
where: { id: req.params.id },
include: {
author: { select: { id: true, username: true } },
category: { select: { id: true, name: true } },
replies: {
include: { author: { select: { id: true, username: true } } },
orderBy: { createdAt: 'asc' },
},
},
});
if (!thread) { res.status(404).json({ error: 'Thread not found' }); return; }
res.json({
id: thread.id,
title: thread.title,
authorId: thread.author.id,
authorName: thread.author.username,
categoryId: thread.category.id,
categoryName: thread.category.name,
content: thread.content,
isPinned: thread.isPinned,
isLocked: thread.isLocked,
replyCount: thread.replies.length,
createdAt: thread.createdAt.toISOString(),
updatedAt: thread.updatedAt.toISOString(),
replies: thread.replies.map((r) => ({
id: r.id,
content: r.content,
authorId: r.author.id,
authorName: r.author.username,
threadId: thread.id,
createdAt: r.createdAt.toISOString(),
})),
});
});
// POST /api/forum/threads (authenticated)
router.post('/threads', authenticate, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
categoryId: z.string(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const thread = await prisma.forumThread.create({
data: {
title: parsed.data.title,
content: parsed.data.content,
categoryId: parsed.data.categoryId,
authorId: req.user!.userId,
},
include: {
author: { select: { id: true, username: true } },
category: { select: { id: true, name: true } },
},
});
res.status(201).json({
id: thread.id,
title: thread.title,
authorId: thread.author.id,
authorName: thread.author.username,
categoryId: thread.category.id,
categoryName: thread.category.name,
content: thread.content,
isPinned: thread.isPinned,
isLocked: thread.isLocked,
replyCount: 0,
createdAt: thread.createdAt.toISOString(),
updatedAt: thread.updatedAt.toISOString(),
});
});
// PATCH /api/forum/threads/:id (author or admin)
router.patch('/threads/:id', authenticate, async (req: Request, res: Response): Promise<void> => {
const thread = await prisma.forumThread.findUnique({ where: { id: req.params.id } });
if (!thread) { res.status(404).json({ error: 'Thread not found' }); return; }
const isAuthor = thread.authorId === req.user!.userId;
const isAdmin = req.user!.isAdmin;
if (!isAuthor && !isAdmin) { res.status(403).json({ error: 'Forbidden' }); return; }
const schema = z.object({
title: z.string().min(1).max(200).optional(),
content: z.string().min(1).optional(),
isPinned: z.boolean().optional(),
isLocked: z.boolean().optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
// Non-admins cannot pin/lock
if (!isAdmin) {
delete (parsed.data as { isPinned?: boolean }).isPinned;
delete (parsed.data as { isLocked?: boolean }).isLocked;
}
const updated = await prisma.forumThread.update({
where: { id: thread.id },
data: parsed.data,
include: {
author: { select: { id: true, username: true } },
category: { select: { id: true, name: true } },
_count: { select: { replies: true } },
},
});
res.json({
id: updated.id,
title: updated.title,
authorId: updated.author.id,
authorName: updated.author.username,
categoryId: updated.category.id,
categoryName: updated.category.name,
content: updated.content,
isPinned: updated.isPinned,
isLocked: updated.isLocked,
replyCount: updated._count.replies,
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
});
});
// DELETE /api/forum/threads/:id (admin only)
router.delete('/threads/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
await prisma.forumThread.delete({ where: { id: req.params.id } });
res.status(204).end();
});
// ── Replies ────────────────────────────────────────────────────────────────────
// POST /api/forum/threads/:id/replies (authenticated)
router.post('/threads/:id/replies', authenticate, async (req: Request, res: Response): Promise<void> => {
const thread = await prisma.forumThread.findUnique({ where: { id: req.params.id } });
if (!thread) { res.status(404).json({ error: 'Thread not found' }); return; }
if (thread.isLocked && !req.user!.isAdmin) {
res.status(403).json({ error: 'Thread is locked.' }); return;
}
const schema = z.object({ content: z.string().min(1) });
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const reply = await prisma.forumReply.create({
data: { content: parsed.data.content, threadId: thread.id, authorId: req.user!.userId },
include: { author: { select: { id: true, username: true } } },
});
await prisma.forumThread.update({ where: { id: thread.id }, data: { updatedAt: new Date() } });
res.status(201).json({
id: reply.id,
content: reply.content,
authorId: reply.author.id,
authorName: reply.author.username,
threadId: thread.id,
createdAt: reply.createdAt.toISOString(),
});
});
// DELETE /api/forum/replies/:id (admin only)
router.delete('/replies/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
await prisma.forumReply.delete({ where: { id: req.params.id } });
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,96 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma.js';
import { authenticate, requireAdmin } from '../middleware/auth.js';
const router = Router();
function formatMember(m: {
id: string; name: string; role: string; bio: string | null;
avatarInitials: string; twitterHandle: string | null; githubHandle: string | null;
}) {
return {
id: m.id,
name: m.name,
role: m.role,
bio: m.bio,
avatarInitials: m.avatarInitials,
social: {
twitter: m.twitterHandle ?? undefined,
github: m.githubHandle ?? undefined,
},
};
}
// GET /api/team (public)
router.get('/', async (_req: Request, res: Response): Promise<void> => {
const members = await prisma.teamMember.findMany();
res.json(members.map(formatMember));
});
// POST /api/team (admin only)
router.post('/', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
name: z.string().min(1),
role: z.string().min(1),
bio: z.string().optional(),
avatarInitials: z.string().min(1).max(3),
social: z.object({
twitter: z.string().optional(),
github: z.string().optional(),
}).optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const member = await prisma.teamMember.create({
data: {
name: parsed.data.name,
role: parsed.data.role,
bio: parsed.data.bio,
avatarInitials: parsed.data.avatarInitials,
twitterHandle: parsed.data.social?.twitter,
githubHandle: parsed.data.social?.github,
},
});
res.status(201).json(formatMember(member));
});
// PATCH /api/team/:id (admin only)
router.patch('/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
name: z.string().min(1).optional(),
role: z.string().min(1).optional(),
bio: z.string().optional(),
avatarInitials: z.string().min(1).max(3).optional(),
social: z.object({
twitter: z.string().nullable().optional(),
github: z.string().nullable().optional(),
}).optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const member = await prisma.teamMember.update({
where: { id: req.params.id },
data: {
name: parsed.data.name,
role: parsed.data.role,
bio: parsed.data.bio,
avatarInitials: parsed.data.avatarInitials,
twitterHandle: parsed.data.social?.twitter,
githubHandle: parsed.data.social?.github,
},
});
res.json(formatMember(member));
});
// DELETE /api/team/:id (admin only)
router.delete('/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
await prisma.teamMember.delete({ where: { id: req.params.id } });
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,113 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import prisma from '../lib/prisma.js';
import { authenticate, requireAdmin } from '../middleware/auth.js';
const router = Router();
function safeUser(user: { id: string; username: string; email: string; role: string; isAdmin: boolean; isBanned: boolean; avatarUrl: string | null; createdAt: Date }) {
return {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt.toISOString(),
};
}
// GET /api/users — list all (admin only)
router.get('/', authenticate, requireAdmin, async (_req: Request, res: Response): Promise<void> => {
const users = await prisma.user.findMany({ orderBy: { createdAt: 'asc' } });
res.json(users.map(safeUser));
});
// GET /api/users/me/profile — current user profile
router.get('/me/profile', authenticate, async (req: Request, res: Response): Promise<void> => {
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) { res.status(404).json({ error: 'User not found' }); return; }
res.json(safeUser(user));
});
// PATCH /api/users/me/username
router.patch('/me/username', authenticate, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({ username: z.string().min(2).max(32) });
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const taken = await prisma.user.findUnique({ where: { username: parsed.data.username } });
if (taken && taken.id !== req.user!.userId) {
res.status(409).json({ error: 'Username already taken.' });
return;
}
const user = await prisma.user.update({
where: { id: req.user!.userId },
data: { username: parsed.data.username },
});
res.json(safeUser(user));
});
// PATCH /api/users/me/password
router.patch('/me/password', authenticate, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(6),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) { res.status(404).json({ error: 'User not found' }); return; }
const valid = await bcrypt.compare(parsed.data.currentPassword, user.password);
if (!valid) { res.status(401).json({ error: 'Current password is incorrect.' }); return; }
const hashed = await bcrypt.hash(parsed.data.newPassword, 10);
await prisma.user.update({ where: { id: user.id }, data: { password: hashed } });
res.json({ success: true });
});
// GET /api/users/:id
router.get('/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) { res.status(404).json({ error: 'User not found' }); return; }
res.json(safeUser(user));
});
// PATCH /api/users/:id — update role/isAdmin (admin only)
router.patch('/:id', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
const schema = z.object({
role: z.enum(['user', 'dev', 'com']).optional(),
isAdmin: z.boolean().optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
const user = await prisma.user.update({
where: { id: req.params.id },
data: parsed.data,
});
res.json(safeUser(user));
});
// POST /api/users/:id/ban
router.post('/:id/ban', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
if (req.params.id === req.user!.userId) {
res.status(400).json({ error: 'Cannot ban yourself.' });
return;
}
const user = await prisma.user.update({ where: { id: req.params.id }, data: { isBanned: true } });
res.json(safeUser(user));
});
// POST /api/users/:id/unban
router.post('/:id/unban', authenticate, requireAdmin, async (req: Request, res: Response): Promise<void> => {
const user = await prisma.user.update({ where: { id: req.params.id }, data: { isBanned: false } });
res.json(safeUser(user));
});
export default router;