wg-verwaltung/backend/src/routes/tickets.ts
bernd 4ba0100d31 Add 1-click PDF export for tickets (craftsman handoff)
Implements the "1-Klick-PDF-Export für Handwerker" requirement from
the ticketing module spec, which existed only as a schema stub
(HandoverProtocol.pdfUrl) with no actual generator anywhere in the
codebase. Adds a server-side PDF endpoint (pdfkit, no headless
browser needed) summarizing a ticket's category, priority, status,
room, reporter and description, plus a "PDF" button in the landlord
cockpit that downloads it via an authenticated blob fetch (the API
uses a Bearer token, not cookies, so a plain <a href> wouldn't carry
auth).
2026-08-13 07:27:01 +00:00

246 lines
8.4 KiB
TypeScript

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<string | null> {
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<string, unknown> = {};
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<string, unknown> = {};
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<TicketCategory, string> = {
SANITAER: 'Sanitär',
ELEKTRIK: 'Elektrik',
MOEBEL: 'Möbel',
HEIZUNG: 'Heizung',
SCHIMMEL: 'Schimmel',
SCHLUESSEL_SCHLOSS: 'Schlüssel/Schloss',
SONSTIGES: 'Sonstiges',
};
const PRIORITY_LABELS: Record<TicketPriority, string> = {
LOW: 'Niedrig',
MEDIUM: 'Mittel',
HIGH: 'Hoch',
EMERGENCY: 'Notfall',
};
const STATUS_LABELS: Record<TicketStatus, string> = {
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();
},
);