import { Router, Response } from 'express'; import { PrismaClient, TicketCategory, TicketPriority, TicketStatus } from '@prisma/client'; import PDFDocument from 'pdfkit'; 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 } }, craftsman: { select: { id: true, name: true, trade: true, phone: true, email: 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, craftsmanId } = 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 (craftsmanId !== undefined) { data.craftsmanId = craftsmanId || 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(); }); const CATEGORY_LABELS: Record = { SANITAER: 'Sanitär', ELEKTRIK: 'Elektrik', MOEBEL: 'Möbel', HEIZUNG: 'Heizung', SCHIMMEL: 'Schimmel', SCHLUESSEL_SCHLOSS: 'Schlüssel/Schloss', SONSTIGES: 'Sonstiges', }; const PRIORITY_LABELS: Record = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', EMERGENCY: 'Notfall', }; const STATUS_LABELS: Record = { OPEN: 'Gemeldet', IN_PROGRESS: 'In Bearbeitung', RESOLVED: 'Erledigt', CLOSED: 'Geschlossen', }; // 1-Klick-PDF-Export für Handwerker: kompakte Auftragsübersicht zu einem Ticket. ticketsRouter.get( '/tickets/:id/pdf', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id }, select: TICKET_SELECT, }); if (!ticket) return res.status(404).json({ error: 'Ticket nicht gefunden' }); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="ticket-${ticket.id}.pdf"`); const doc = new PDFDocument({ margin: 50 }); doc.pipe(res); doc.fontSize(18).text('Auftrag / Schadensmeldung', { underline: true }); doc.moveDown(); doc.fontSize(12).text(`Titel: ${ticket.title}`); doc.text(`Kategorie: ${CATEGORY_LABELS[ticket.category]}`); doc.text(`Dringlichkeit: ${PRIORITY_LABELS[ticket.priority]}`); doc.text(`Status: ${STATUS_LABELS[ticket.status]}`); doc.text(`Zimmer: ${ticket.room?.roomNumber ?? 'Gemeinschaftsbereich'}`); doc.text(`Gemeldet von: ${ticket.creator.fullName}`); doc.text(`Gemeldet am: ${ticket.createdAt.toLocaleDateString('de-DE')}`); if (ticket.craftsman) { doc.text(`Handwerker: ${ticket.craftsman.name} (${ticket.craftsman.trade})`); } doc.moveDown(); doc.fontSize(14).text('Beschreibung', { underline: true }); doc.fontSize(12).text(ticket.description); if (ticket.imageUrls && ticket.imageUrls.length > 0) { doc.moveDown(); doc.fontSize(14).text('Fotos', { underline: true }); doc.fontSize(10).text(ticket.imageUrls.join('\n')); } doc.end(); }, );