From ffea782e961f575921845728886245baf0afabda Mon Sep 17 00:00:00 2001 From: Giuseppe Lombardo Date: Wed, 12 Aug 2026 18:22:42 +0000 Subject: [PATCH] Add tickets API with room-scoped access control for tenants --- backend/src/routes/tickets.ts | 170 ++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 backend/src/routes/tickets.ts diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts new file mode 100644 index 0000000..488bfbf --- /dev/null +++ b/backend/src/routes/tickets.ts @@ -0,0 +1,170 @@ +import { Router, Response } from 'express'; +import { PrismaClient, TicketCategory, TicketPriority, TicketStatus } from '@prisma/client'; +import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; + +const prisma = new PrismaClient(); + +/** + * Router für Schadensmeldungen/Tickets. + * + * Zugriffsregel (Kern der Berechtigungslogik): ein Mieter darf nur Tickets sehen, + * die er selbst erstellt hat, die einen Gemeinschaftsbereich betreffen (roomId + * = null, z. B. Küche/Bad/Allgemein) oder die sein eigenes Zimmer betreffen. + * Tickets zu den privaten Zimmern anderer Mieter sind für ihn unsichtbar. + * Vermieter/Admin sehen alles. + */ +export const ticketsRouter = Router(); + +const CATEGORIES = Object.values(TicketCategory); +const PRIORITIES = Object.values(TicketPriority); + +async function getOwnRoomId(userId: string): Promise { + const user = await prisma.user.findUnique({ where: { id: userId }, select: { roomId: true } }); + return user?.roomId ?? null; +} + +const TICKET_SELECT = { + id: true, + title: true, + description: true, + category: true, + priority: true, + status: true, + imageUrls: true, + createdAt: true, + resolvedAt: true, + room: { select: { id: true, roomNumber: true } }, + creator: { select: { id: true, fullName: true } }, + assignee: { select: { id: true, fullName: true } }, +}; + +ticketsRouter.post('/tickets', requireAuth, async (req: AuthedRequest, res: Response) => { + const { title, description, category, priority, roomId } = req.body || {}; + + if (!title || typeof title !== 'string' || !title.trim()) { + return res.status(400).json({ error: 'Titel ist erforderlich' }); + } + if (!description || typeof description !== 'string' || !description.trim()) { + return res.status(400).json({ error: 'Beschreibung ist erforderlich' }); + } + if (!category || !CATEGORIES.includes(category)) { + return res.status(400).json({ error: `Ungültige Kategorie. Erlaubt: ${CATEGORIES.join(', ')}` }); + } + if (priority && !PRIORITIES.includes(priority)) { + return res.status(400).json({ error: `Ungültige Priorität. Erlaubt: ${PRIORITIES.join(', ')}` }); + } + + const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; + let targetRoomId: string | null = roomId || null; + + if (!isLandlord) { + // Mieter dürfen nur für ihr eigenes Zimmer oder allgemein (Gemeinschaftsbereich) melden. + const ownRoomId = await getOwnRoomId(req.user!.id); + if (targetRoomId && targetRoomId !== ownRoomId) { + return res.status(403).json({ error: 'Du kannst nur für dein eigenes Zimmer oder den Gemeinschaftsbereich melden' }); + } + } else if (targetRoomId) { + const room = await prisma.room.findUnique({ where: { id: targetRoomId } }); + if (!room) return res.status(400).json({ error: 'Zimmer nicht gefunden' }); + } + + const ticket = await prisma.ticket.create({ + data: { + creatorId: req.user!.id, + roomId: targetRoomId, + title: title.trim(), + description: description.trim(), + category, + priority: priority || TicketPriority.MEDIUM, + status: TicketStatus.OPEN, + }, + select: TICKET_SELECT, + }); + + res.status(201).json({ ticket }); +}); + +ticketsRouter.get('/tickets', requireAuth, async (req: AuthedRequest, res: Response) => { + const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; + const statusFilter = typeof req.query.status === 'string' ? req.query.status : undefined; + + const where: Record = {}; + if (statusFilter && (Object.values(TicketStatus) as string[]).includes(statusFilter)) { + where.status = statusFilter; + } + + if (!isLandlord) { + const ownRoomId = await getOwnRoomId(req.user!.id); + where.OR = [ + { creatorId: req.user!.id }, + { roomId: null }, + ...(ownRoomId ? [{ roomId: ownRoomId }] : []), + ]; + } + + const tickets = await prisma.ticket.findMany({ + where, + orderBy: { createdAt: 'desc' }, + select: TICKET_SELECT, + }); + + res.status(200).json({ tickets }); +}); + +ticketsRouter.patch( + '/tickets/:id', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const { status, priority, assigneeId } = req.body || {}; + const data: Record = {}; + + if (status !== undefined) { + if (!(Object.values(TicketStatus) as string[]).includes(status)) { + return res.status(400).json({ error: `Ungültiger Status. Erlaubt: ${Object.values(TicketStatus).join(', ')}` }); + } + data.status = status; + data.resolvedAt = status === 'RESOLVED' || status === 'CLOSED' ? new Date() : null; + } + if (priority !== undefined) { + if (!PRIORITIES.includes(priority)) { + return res.status(400).json({ error: `Ungültige Priorität. Erlaubt: ${PRIORITIES.join(', ')}` }); + } + data.priority = priority; + } + if (assigneeId !== undefined) { + data.assigneeId = assigneeId || null; + } + + if (Object.keys(data).length === 0) { + return res.status(400).json({ error: 'Keine Änderungen übergeben' }); + } + + try { + const ticket = await prisma.ticket.update({ + where: { id: req.params.id }, + data, + select: TICKET_SELECT, + }); + res.status(200).json({ ticket }); + } catch { + res.status(404).json({ error: 'Ticket nicht gefunden' }); + } + }, +); + +// Mieter dürfen ihre eigene Meldung zurückziehen, solange sie noch nicht bearbeitet wird. +ticketsRouter.delete('/tickets/:id', requireAuth, async (req: AuthedRequest, res: Response) => { + const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id } }); + if (!ticket) return res.status(404).json({ error: 'Ticket nicht gefunden' }); + + const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; + const isOwnOpenTicket = ticket.creatorId === req.user!.id && ticket.status === 'OPEN'; + + if (!isLandlord && !isOwnOpenTicket) { + return res.status(403).json({ error: 'Keine Berechtigung, dieses Ticket zurückzuziehen' }); + } + + await prisma.ticket.delete({ where: { id: req.params.id } }); + res.status(204).send(); +});