Accepting an invitation as a TENANT now mandates phone number, current
first-residence address (Erstwohnsitz), and both sides of an ID
document — the accept endpoint rejects the request with a clear error
if any are missing. A Schufa credit report upload stays optional, but
if provided its issue date must be within the last 3 months or the
request is rejected.
New User fields: phoneNumber was already there; added
firstResidenceAddress, idDocumentFrontUrl, idDocumentBackUrl,
schufaDocumentUrl, schufaDocumentDate. Kept nullable at the DB level
(existing accounts have none of this and shouldn't be broken) —
enforcement lives in the accept-invitation route, not a DB constraint.
Landlords now see a completeness badge ("Profil vollständig" /
"Unvollständig: X fehlt") plus a Schufa freshness badge on each
contract card in Verträge & Abrechnung, with direct download links for
the uploaded ID/Schufa files.
409 lines
17 KiB
TypeScript
409 lines
17 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,
|
|
landlordName: true,
|
|
landlordAddress: true,
|
|
propertyAddress: true,
|
|
signedAt: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
fullName: true,
|
|
phoneNumber: true,
|
|
firstResidenceAddress: true,
|
|
idDocumentFrontUrl: true,
|
|
idDocumentBackUrl: true,
|
|
schufaDocumentUrl: true,
|
|
schufaDocumentDate: 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> = { 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 });
|
|
},
|
|
);
|