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 { 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>; async function getBugWithRelations(id: string) { return prisma.bugReport.findUnique({ where: { id }, include: includeRelations() }); } function formatBug(bug: NonNullable) { 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 => { 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 = {}; 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({ data: bugs.map(formatBug), total, page, pages: Math.ceil(total / limit) }); }); // GET /api/bugs/:id router.get('/:id', async (req: Request, res: Response): Promise => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { await prisma.bugReportNote.delete({ where: { id: req.params.id } }); res.status(204).end(); }); export default router;