wg-verwaltung/backend/src/routes/contracts.ts
bernd bba4fc318b Add click-based contract generator + digital tenant signature
Landlords configure a contract via checkboxes/selects (WLAN included,
furnished, utility billing model, notice period, deposit, rent
adjustment clause) and generate a PDF (pdfkit) that's automatically
attached to the contract's document list. Tenants sign their own
contract in-app by drawing a signature on a canvas; signing embeds the
signature image into a freshly generated final PDF and marks the
contract as signed with a timestamp.

New: Contract.wifiIncluded/furnished/utilityBillingModel (+ new
UtilityBillingModel enum), Contract.tenantSignatureUrl/signedAt.
New endpoints: POST /contracts/:id/generate-document (landlord/admin),
POST /contracts/:id/sign (tenant on own contract, or landlord/admin).

Explicitly a documentation template generated from app data, not a
legally reviewed contract — noted in the PDF itself.
2026-08-13 10:23:27 +00:00

266 lines
10 KiB
TypeScript

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<string, unknown> = { 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,
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<string, unknown> = {};
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,
} = 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,
},
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 });
});