import { Router, Response } from 'express'; import { PrismaClient, UtilityBillingModel, RentAdjustmentType } from '@prisma/client'; import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; import { generateContractPdf } from '../services/contractDocumentGenerator'; const prisma = new PrismaClient(); /** * Router für Kündigungsfristen-Erinnerungen. * * Nutzt die bereits vorhandenen Vertragsfelder (endDate, noticePeriodMonths) * — kein Extra-Datenmodell nötig. Ein Mieter sieht ausschließlich seinen * eigenen Vertrag; Vermieter/Admin sehen alle aktiven Verträge. */ export const contractsRouter = Router(); const CONTRACT_SELECT = { id: true, startDate: true, endDate: true, noticePeriodMonths: true, isActive: true, user: { select: { id: true, fullName: true } }, room: { select: { id: true, roomNumber: true } }, }; function addMonths(date: Date, months: number): Date { const d = new Date(date); d.setMonth(d.getMonth() - months); return d; } // GET /contracts/notice-deadlines — für befristete Verträge (endDate gesetzt) // wird die späteste Kündigungsfrist (endDate - noticePeriodMonths) berechnet. // Unbefristete Verträge (endDate = NULL) zeigen nur die geltende Kündigungs- // frist an, ohne konkretes Datum. Rückgabe ist nach Dringlichkeit sortiert. contractsRouter.get('/contracts/notice-deadlines', requireAuth, async (req: AuthedRequest, res: Response) => { const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; const where: Record = { isActive: true }; if (!isLandlord) { where.userId = req.user!.id; } const contracts = await prisma.contract.findMany({ where, select: CONTRACT_SELECT, orderBy: { endDate: 'asc' }, }); const now = new Date(); const items = contracts.map((c) => { const noticeDeadline = c.endDate ? addMonths(c.endDate, c.noticePeriodMonths) : null; const daysUntilDeadline = noticeDeadline ? Math.ceil((noticeDeadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) : null; return { contractId: c.id, user: c.user, room: c.room, startDate: c.startDate, endDate: c.endDate, noticePeriodMonths: c.noticePeriodMonths, noticeDeadline, daysUntilDeadline, isUrgent: daysUntilDeadline !== null && daysUntilDeadline <= 30 && daysUntilDeadline >= 0, isOverdue: daysUntilDeadline !== null && daysUntilDeadline < 0, }; }); items.sort((a, b) => { if (a.daysUntilDeadline === null) return 1; if (b.daysUntilDeadline === null) return -1; return a.daysUntilDeadline - b.daysUntilDeadline; }); res.status(200).json({ deadlines: items }); }); // ---------------------------------------------------------------------------- // VERTRAGS-DOKUMENTE (unterschriebener Vertrag, Nachträge etc.) // ---------------------------------------------------------------------------- const MAX_CONTRACT_DOCUMENTS = 10; const CONTRACT_DOCUMENTS_SELECT = { id: true, startDate: true, endDate: true, isActive: true, contractDocumentUrls: true, totalWarmRent: true, depositAmount: true, paymentDueDay: true, noticePeriodMonths: true, rentAdjustmentType: true, rentAdjustmentClause: true, wifiIncluded: true, furnished: true, utilityBillingModel: true, landlordName: true, landlordAddress: true, propertyAddress: true, signedAt: true, user: { select: { id: true, fullName: true } }, room: { select: { id: true, roomNumber: true, sizeSqm: true } }, }; const UTILITY_MODELS = Object.values(UtilityBillingModel); const ADJUSTMENT_TYPES = Object.values(RentAdjustmentType); function sanitizeDocumentUrls(input: unknown): string[] { if (!Array.isArray(input)) return []; return input.filter((u) => typeof u === 'string' && u.trim()).map((u) => (u as string).trim()); } // Mieter sehen nur den eigenen Vertrag, Vermieter/Admin alle — dieselbe // Sichtbarkeitsregel wie bei allen anderen privaten Vertragsdaten. contractsRouter.get('/contracts/documents', requireAuth, async (req: AuthedRequest, res: Response) => { const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; const where: Record = { isActive: true }; if (!isLandlord) where.userId = req.user!.id; const contracts = await prisma.contract.findMany({ where, select: CONTRACT_DOCUMENTS_SELECT, orderBy: { startDate: 'desc' }, }); res.status(200).json({ contracts }); }); // Vermieter/Admin: Dateien zu einem Vertrag hinzufügen (mehrere auf einmal, // werden an bereits vorhandene angehängt statt sie zu ersetzen). contractsRouter.post( '/contracts/:id/documents', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { const contract = await prisma.contract.findUnique({ where: { id: req.params.id } }); if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' }); const newUrls = sanitizeDocumentUrls(req.body?.urls); if (!newUrls.length) return res.status(400).json({ error: 'urls (Array) ist erforderlich' }); const merged = [...contract.contractDocumentUrls, ...newUrls].slice(0, MAX_CONTRACT_DOCUMENTS); const updated = await prisma.contract.update({ where: { id: contract.id }, data: { contractDocumentUrls: merged }, select: CONTRACT_DOCUMENTS_SELECT, }); res.status(200).json({ contract: updated }); }, ); // Vermieter/Admin: einzelne Datei aus einem Vertrag entfernen. contractsRouter.delete( '/contracts/:id/documents', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { const contract = await prisma.contract.findUnique({ where: { id: req.params.id } }); if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' }); const { url } = req.body || {}; if (!url || typeof url !== 'string') return res.status(400).json({ error: 'url ist erforderlich' }); const remaining = contract.contractDocumentUrls.filter((u) => u !== url); const updated = await prisma.contract.update({ where: { id: contract.id }, data: { contractDocumentUrls: remaining }, select: CONTRACT_DOCUMENTS_SELECT, }); res.status(200).json({ contract: updated }); }, ); // ---------------------------------------------------------------------------- // MUSTERMIETVERTRAG GENERIEREN & DIGITAL UNTERSCHREIBEN // ---------------------------------------------------------------------------- // Vermieter/Admin: Vertrags-Baukasten-Optionen speichern und daraus ein PDF // erzeugen, das automatisch den Vertragsdokumenten angehängt wird. Eine // bereits vorhandene Unterschrift bleibt unangetastet — wer die Klauseln // nachträglich ändert, muss den Mieter erneut unterschreiben lassen (dafür // separat der Sign-Endpunkt). contractsRouter.post( '/contracts/:id/generate-document', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { const { wifiIncluded, furnished, utilityBillingModel, noticePeriodMonths, depositAmount, rentAdjustmentType, rentAdjustmentClause, landlordName, landlordAddress, propertyAddress, } = req.body || {}; if (utilityBillingModel !== undefined && !UTILITY_MODELS.includes(utilityBillingModel)) { return res.status(400).json({ error: `Ungültiges Nebenkosten-Modell. Erlaubt: ${UTILITY_MODELS.join(', ')}` }); } if (rentAdjustmentType !== undefined && !ADJUSTMENT_TYPES.includes(rentAdjustmentType)) { return res.status(400).json({ error: `Ungültige Mietanpassung. Erlaubt: ${ADJUSTMENT_TYPES.join(', ')}` }); } const contract = await prisma.contract.update({ where: { id: req.params.id }, data: { wifiIncluded: typeof wifiIncluded === 'boolean' ? wifiIncluded : undefined, furnished: typeof furnished === 'boolean' ? furnished : undefined, utilityBillingModel: utilityBillingModel || undefined, noticePeriodMonths: Number.isFinite(Number(noticePeriodMonths)) ? Number(noticePeriodMonths) : undefined, depositAmount: Number.isFinite(Number(depositAmount)) ? Number(depositAmount) : undefined, rentAdjustmentType: rentAdjustmentType || undefined, rentAdjustmentClause: typeof rentAdjustmentClause === 'string' ? rentAdjustmentClause.trim() || null : undefined, landlordName: typeof landlordName === 'string' && landlordName.trim() ? landlordName.trim() : undefined, landlordAddress: typeof landlordAddress === 'string' ? landlordAddress.trim() || null : undefined, propertyAddress: typeof propertyAddress === 'string' ? propertyAddress.trim() || null : undefined, }, include: { room: true, user: true }, }).catch(() => null); if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' }); const pdfBuffer = await generateContractPdf(contract); const dataUrl = `data:application/pdf;base64,${pdfBuffer.toString('base64')}`; const updated = await prisma.contract.update({ where: { id: contract.id }, data: { contractDocumentUrls: [...contract.contractDocumentUrls, dataUrl].slice(0, MAX_CONTRACT_DOCUMENTS) }, select: CONTRACT_DOCUMENTS_SELECT, }); res.status(200).json({ contract: updated }); }, ); // Mieter (nur der eigene Vertrag) unterschreibt digital: Unterschrift-Bild // wird gespeichert, ein finales PDF mit eingebetteter Unterschrift erzeugt // und den Vertragsdokumenten angehängt. contractsRouter.post('/contracts/:id/sign', requireAuth, async (req: AuthedRequest, res: Response) => { const contract = await prisma.contract.findUnique({ where: { id: req.params.id }, include: { room: true, user: true } }); if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' }); const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; if (!isLandlord && contract.userId !== req.user!.id) { return res.status(403).json({ error: 'Nur der eigene Vertrag kann unterschrieben werden' }); } const { signatureDataUrl } = req.body || {}; if (typeof signatureDataUrl !== 'string' || !/^data:image\/(png|jpeg);base64,/.test(signatureDataUrl)) { return res.status(400).json({ error: 'signatureDataUrl (PNG/JPEG Data-URL) ist erforderlich' }); } const signedAt = new Date(); const signedContract = { ...contract, tenantSignatureUrl: signatureDataUrl, signedAt }; const pdfBuffer = await generateContractPdf(signedContract); const dataUrl = `data:application/pdf;base64,${pdfBuffer.toString('base64')}`; const updated = await prisma.contract.update({ where: { id: contract.id }, data: { tenantSignatureUrl: signatureDataUrl, signedAt, contractDocumentUrls: [...contract.contractDocumentUrls, dataUrl].slice(0, MAX_CONTRACT_DOCUMENTS), }, select: CONTRACT_DOCUMENTS_SELECT, }); res.status(200).json({ contract: updated }); }); // ---------------------------------------------------------------------------- // MIETER AUSZIEHEN LASSEN (Archiv statt Löschen) // ---------------------------------------------------------------------------- // Vermieter/Admin: Mieter aus dem Zimmer entfernen. Der Vertrag wird NICHT // gelöscht, sondern nur deaktiviert (isActive=false, endDate gesetzt) und // bleibt damit samt aller verknüpften Daten (Zahlungen, Tickets, Bewertungen, // Übergabeprotokolle, Vertragsdokumente ...) dauerhaft einsehbar — siehe // GET /contracts/archive. Das Zimmer wird wieder als frei markiert und kann // über die Einladungsfunktion neu vergeben werden. contractsRouter.post( '/contracts/:id/move-out', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { const contract = await prisma.contract.findUnique({ where: { id: req.params.id } }); if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' }); if (!contract.isActive) return res.status(400).json({ error: 'Vertrag ist bereits inaktiv' }); const moveOutDate = new Date(); await prisma.$transaction([ prisma.contract.update({ where: { id: contract.id }, data: { isActive: false, endDate: contract.endDate ?? moveOutDate }, }), prisma.user.update({ where: { id: contract.userId }, data: { roomId: null }, }), prisma.room.update({ where: { id: contract.roomId }, data: { status: 'VACANT' }, }), ]); const updated = await prisma.contract.findUnique({ where: { id: contract.id }, select: CONTRACT_DOCUMENTS_SELECT }); res.status(200).json({ contract: updated }); }, ); // Vermieter/Admin: Archiv ausgezogener Mieter — nichts wird gelöscht, diese // Route macht die bereits deaktivierten Verträge (samt Dokumenten) dauerhaft // einsehbar. contractsRouter.get('/contracts/archive', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (_req: AuthedRequest, res: Response) => { const contracts = await prisma.contract.findMany({ where: { isActive: false }, select: CONTRACT_DOCUMENTS_SELECT, orderBy: { endDate: 'desc' }, }); res.status(200).json({ contracts }); }); // ---------------------------------------------------------------------------- // ARCHIVIERTEN MIETER WIEDER AUFNEHMEN (neuer Vertrag, alter bleibt im Archiv) // ---------------------------------------------------------------------------- // Zimmer ohne aktiven Vertrag — für die Auswahl beim Wiederaufnehmen. contractsRouter.get('/contracts/vacant-rooms', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (_req: AuthedRequest, res: Response) => { const rooms = await prisma.room.findMany({ where: { contracts: { none: { isActive: true } } }, select: { id: true, roomNumber: true, sizeSqm: true, baseRent: true, utilityPauschal: true }, orderBy: { roomNumber: 'asc' }, }); res.status(200).json({ rooms }); }); // Vermieter/Admin: ein Mieter aus dem Archiv (Contract.isActive=false, User // ohne roomId) bekommt einen VÖLLIG NEUEN Vertrag — der alte Vertrag bleibt // unverändert als Dokumentation im Archiv stehen, es wird kein alter Vertrag // reaktiviert oder überschrieben. contractsRouter.post( '/contracts/reactivate', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { const { userId, roomId, startDate, totalWarmRent, depositAmount, paymentDueDay, noticePeriodMonths } = req.body || {}; if (!userId || typeof userId !== 'string') return res.status(400).json({ error: 'userId ist erforderlich' }); if (!roomId || typeof roomId !== 'string') return res.status(400).json({ error: 'roomId ist erforderlich' }); if (!startDate || Number.isNaN(Date.parse(startDate))) return res.status(400).json({ error: 'startDate ist erforderlich' }); if (!Number.isFinite(Number(totalWarmRent)) || Number(totalWarmRent) <= 0) { return res.status(400).json({ error: 'totalWarmRent muss eine positive Zahl sein' }); } if (!Number.isFinite(Number(depositAmount)) || Number(depositAmount) < 0) { return res.status(400).json({ error: 'depositAmount muss eine Zahl sein' }); } const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) return res.status(404).json({ error: 'Mieter nicht gefunden' }); const room = await prisma.room.findUnique({ where: { id: roomId } }); if (!room) return res.status(404).json({ error: 'Zimmer nicht gefunden' }); const [activeForUser, activeForRoom] = await Promise.all([ prisma.contract.findFirst({ where: { userId, isActive: true } }), prisma.contract.findFirst({ where: { roomId, isActive: true } }), ]); if (activeForUser) return res.status(409).json({ error: 'Mieter hat bereits einen aktiven Vertrag' }); if (activeForRoom) return res.status(409).json({ error: 'Zimmer ist bereits belegt' }); const [contract] = await prisma.$transaction([ prisma.contract.create({ data: { userId, roomId, startDate: new Date(startDate), totalWarmRent: Number(totalWarmRent), depositAmount: Number(depositAmount), paymentDueDay: Number.isFinite(Number(paymentDueDay)) ? Number(paymentDueDay) : 3, noticePeriodMonths: Number.isFinite(Number(noticePeriodMonths)) ? Number(noticePeriodMonths) : 3, isActive: true, }, select: CONTRACT_DOCUMENTS_SELECT, }), prisma.user.update({ where: { id: userId }, data: { roomId } }), prisma.room.update({ where: { id: roomId }, data: { status: 'OCCUPIED' } }), ]); res.status(201).json({ contract }); }, );