feat : Init Project
This commit is contained in:
115
nest-backend/src/routes/auth.ts
Normal file
115
nest-backend/src/routes/auth.ts
Normal 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;
|
||||
230
nest-backend/src/routes/bugs.ts
Normal file
230
nest-backend/src/routes/bugs.ts
Normal 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;
|
||||
230
nest-backend/src/routes/events.ts
Normal file
230
nest-backend/src/routes/events.ts
Normal 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;
|
||||
59
nest-backend/src/routes/feed.ts
Normal file
59
nest-backend/src/routes/feed.ts
Normal 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;
|
||||
294
nest-backend/src/routes/forum.ts
Normal file
294
nest-backend/src/routes/forum.ts
Normal 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;
|
||||
96
nest-backend/src/routes/team.ts
Normal file
96
nest-backend/src/routes/team.ts
Normal 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;
|
||||
113
nest-backend/src/routes/users.ts
Normal file
113
nest-backend/src/routes/users.ts
Normal 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;
|
||||
Reference in New Issue
Block a user