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

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;