From e77e33d7ff45acb4f13c6e05a4ae20a11343d678 Mon Sep 17 00:00:00 2001 From: bernd Date: Thu, 13 Aug 2026 11:25:57 +0000 Subject: [PATCH] Require phone, current address and ID copy for new tenants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/routes/contracts.ts | 13 +++- backend/src/routes/invitations.ts | 54 +++++++++++++- prisma/schema.prisma | 9 +++ web-dashboard/index.html | 119 +++++++++++++++++++++++++++++- 4 files changed, 191 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts index c8ad07f..60b9ade 100644 --- a/backend/src/routes/contracts.ts +++ b/backend/src/routes/contracts.ts @@ -102,7 +102,18 @@ const CONTRACT_DOCUMENTS_SELECT = { landlordAddress: true, propertyAddress: true, signedAt: true, - user: { select: { id: true, fullName: 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 } }, }; diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts index f120ab6..76aba7c 100644 --- a/backend/src/routes/invitations.ts +++ b/backend/src/routes/invitations.ts @@ -130,9 +130,25 @@ invitationsRouter.get('/invitations/:token/preview', async (req: Request, res: R }); }); -// POST /v1/invitations/:token/accept (öffentlich) — { fullName, password } -> { token, user } +// POST /v1/invitations/:token/accept (öffentlich) — { fullName, password, ... } -> { token, user } +// +// Für Mieter (TENANT) sind Telefonnummer, aktueller Erstwohnsitz sowie +// Ausweiskopie (Vorder- und Rückseite) Pflichtangaben — ohne sie kann kein +// Account angelegt werden. Eine Schufa-Auskunft ist optional, muss aber, +// falls hochgeladen, ein Ausstellungsdatum haben, das nicht älter als drei +// Monate ist. invitationsRouter.post('/invitations/:token/accept', async (req: Request, res: Response) => { - const { fullName, password } = req.body || {}; + const { + fullName, + password, + phoneNumber, + firstResidenceAddress, + idDocumentFrontUrl, + idDocumentBackUrl, + schufaDocumentUrl, + schufaDocumentDate, + } = req.body || {}; + if (!password || String(password).length < 8) { return res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' }); } @@ -149,6 +165,31 @@ invitationsRouter.post('/invitations/:token/accept', async (req: Request, res: R return res.status(410).json({ error: 'Diese Einladung ist abgelaufen' }); } + if (invitation.role === UserRole.TENANT) { + if (!phoneNumber || !String(phoneNumber).trim()) { + return res.status(400).json({ error: 'Telefonnummer ist erforderlich' }); + } + if (!firstResidenceAddress || !String(firstResidenceAddress).trim()) { + return res.status(400).json({ error: 'Aktueller Erstwohnsitz ist erforderlich' }); + } + if (typeof idDocumentFrontUrl !== 'string' || !idDocumentFrontUrl.startsWith('data:')) { + return res.status(400).json({ error: 'Ausweiskopie Vorderseite ist erforderlich' }); + } + if (typeof idDocumentBackUrl !== 'string' || !idDocumentBackUrl.startsWith('data:')) { + return res.status(400).json({ error: 'Ausweiskopie Rückseite ist erforderlich' }); + } + if (schufaDocumentUrl) { + if (!schufaDocumentDate || Number.isNaN(Date.parse(schufaDocumentDate))) { + return res.status(400).json({ error: 'Bitte Ausstellungsdatum der Schufa-Auskunft angeben' }); + } + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + if (new Date(schufaDocumentDate) < threeMonthsAgo) { + return res.status(400).json({ error: 'Die Schufa-Auskunft darf nicht älter als 3 Monate sein' }); + } + } + } + const existingUser = await prisma.user.findUnique({ where: { email: invitation.email } }); if (existingUser) { return res.status(409).json({ error: 'Es gibt bereits einen Nutzer mit dieser E-Mail-Adresse' }); @@ -164,6 +205,15 @@ invitationsRouter.post('/invitations/:token/accept', async (req: Request, res: R fullName: String(fullName).trim(), role: invitation.role, roomId: invitation.roomId, + phoneNumber: typeof phoneNumber === 'string' ? phoneNumber.trim() || null : null, + firstResidenceAddress: typeof firstResidenceAddress === 'string' ? firstResidenceAddress.trim() || null : null, + idDocumentFrontUrl: typeof idDocumentFrontUrl === 'string' ? idDocumentFrontUrl : null, + idDocumentBackUrl: typeof idDocumentBackUrl === 'string' ? idDocumentBackUrl : null, + schufaDocumentUrl: typeof schufaDocumentUrl === 'string' ? schufaDocumentUrl || null : null, + schufaDocumentDate: + schufaDocumentUrl && schufaDocumentDate && !Number.isNaN(Date.parse(schufaDocumentDate)) + ? new Date(schufaDocumentDate) + : null, }, }); await tx.invitation.update({ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f6c6239..5e3e5fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -168,6 +168,15 @@ model User { avatarUrl String? @map("avatar_url") pushToken String? @map("push_token") // OneSignal Player ID + // Pflichtangaben bei Einladungsannahme (Mieter): Kontakt, Meldeadresse, + // Ausweiskopie beidseitig. Schufa-Auskunft optional, aber falls vorhanden + // darf sie laut Vorgabe nicht älter als 3 Monate sein (schufaDocumentDate). + firstResidenceAddress String? @map("first_residence_address") // aktueller Erstwohnsitz laut Meldebescheinigung + idDocumentFrontUrl String? @map("id_document_front_url") + idDocumentBackUrl String? @map("id_document_back_url") + schufaDocumentUrl String? @map("schufa_document_url") + schufaDocumentDate DateTime? @map("schufa_document_date") // Ausstellungsdatum der Schufa-Auskunft + // Ein Mieter ist aktuell genau einem Zimmer zugeordnet (Bequemlichkeits-FK, // die verbindliche Quelle bleibt Contract). roomId String? @map("room_id") diff --git a/web-dashboard/index.html b/web-dashboard/index.html index 7fef847..1ee5c7e 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -175,6 +175,8 @@ width: 100%; max-width: 380px; } + #acceptInviteView .auth-card { max-width: 460px; } + .field-hint { font-size: 11.5px; color: var(--text-muted); margin: -10px 0 14px; } .auth-card h1 { font-size: 19px; margin: 0 0 4px; } .auth-card p.sub { color: var(--text-muted); font-size: 13.5px; margin: 0 0 22px; } .field { margin-bottom: 14px; } @@ -492,6 +494,35 @@ + + + @@ -1149,6 +1180,24 @@ form.style.display = 'flex'; form.style.flexDirection = 'column'; + const isTenant = preview.role === 'TENANT'; + document.getElementById('acceptTenantFields').style.display = isTenant ? 'block' : 'none'; + const idFrontInput = document.getElementById('acceptIdFront'); + const idBackInput = document.getElementById('acceptIdBack'); + const schufaInput = document.getElementById('acceptSchufa'); + const schufaDateField = document.getElementById('acceptSchufaDateField'); + const schufaDateInput = document.getElementById('acceptSchufaDate'); + if (isTenant) { + idFrontInput.required = true; + idBackInput.required = true; + document.getElementById('acceptPhone').required = true; + document.getElementById('acceptFirstResidence').required = true; + } + schufaInput.onchange = () => { + schufaDateField.style.display = schufaInput.files.length ? 'block' : 'none'; + schufaDateInput.required = !!schufaInput.files.length; + }; + form.onsubmit = async (e) => { e.preventDefault(); errorBox.style.display = 'none'; @@ -1158,9 +1207,20 @@ try { const fullName = document.getElementById('acceptFullName').value.trim(); const password = document.getElementById('acceptPassword').value; + const body = { fullName, password }; + if (isTenant) { + body.phoneNumber = document.getElementById('acceptPhone').value.trim(); + body.firstResidenceAddress = document.getElementById('acceptFirstResidence').value.trim(); + body.idDocumentFrontUrl = await readFileAsDataUrl(idFrontInput.files[0]); + body.idDocumentBackUrl = await readFileAsDataUrl(idBackInput.files[0]); + if (schufaInput.files.length) { + body.schufaDocumentUrl = await readFileAsDataUrl(schufaInput.files[0]); + body.schufaDocumentDate = schufaDateInput.value; + } + } const data = await apiFetch(`/invitations/${token}/accept`, { method: 'POST', - body: JSON.stringify({ fullName, password }), + body: JSON.stringify(body), }); setSession(data.token, data.user); history.replaceState(null, '', location.pathname); @@ -2758,6 +2818,59 @@ return `Dokument ${index + 1}.${ext}`; } + function buildProfileCompletenessBadge(user) { + const missing = []; + if (!user.phoneNumber) missing.push('Telefonnummer'); + if (!user.firstResidenceAddress) missing.push('Erstwohnsitz'); + if (!user.idDocumentFrontUrl) missing.push('Ausweis Vorderseite'); + if (!user.idDocumentBackUrl) missing.push('Ausweis Rückseite'); + + const wrap = el('div'); + wrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:8px; align-items:center;'; + + const badge = el('span', 'invite-status-chip'); + if (missing.length) { + badge.classList.add('PENDING'); + badge.textContent = `Unvollständig: ${missing.join(', ')} fehlt`; + } else { + badge.classList.add('ACCEPTED'); + badge.textContent = 'Profil vollständig'; + } + wrap.appendChild(badge); + + if (user.schufaDocumentUrl) { + const schufaBadge = el('span', 'invite-status-chip'); + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + const isOld = user.schufaDocumentDate && new Date(user.schufaDocumentDate) < threeMonthsAgo; + schufaBadge.classList.add(isOld ? 'EXPIRED' : 'ACCEPTED'); + schufaBadge.textContent = isOld + ? `Schufa veraltet (${new Date(user.schufaDocumentDate).toLocaleDateString('de-DE')})` + : `Schufa vorhanden (${user.schufaDocumentDate ? new Date(user.schufaDocumentDate).toLocaleDateString('de-DE') : '–'})`; + wrap.appendChild(schufaBadge); + } + + const linkWrap = el('div'); + linkWrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px;'; + [ + ['Ausweis Vorderseite', user.idDocumentFrontUrl], + ['Ausweis Rückseite', user.idDocumentBackUrl], + ['Schufa-Auskunft', user.schufaDocumentUrl], + ].forEach(([label, url]) => { + if (!url) return; + const link = document.createElement('a'); + link.href = url; + link.download = label + '.' + (url.includes('application/pdf') ? 'pdf' : 'jpg'); + link.textContent = label; + link.className = 'invite-status-chip'; + link.style.cssText = 'background:var(--bg); color:var(--text); text-transform:none;'; + linkWrap.appendChild(link); + }); + if (linkWrap.children.length) wrap.appendChild(linkWrap); + + return wrap; + } + function renderContractDocuments(contracts) { const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; const list = document.getElementById('contractDocumentsList'); @@ -2778,6 +2891,10 @@ top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`)); card.appendChild(top); + if (isLandlord) { + card.appendChild(buildProfileCompletenessBadge(c.user)); + } + const isOwnContract = c.user.id === currentUser.id; if (c.signedAt) { const signedNote = el('div', 'meta-text', `✓ Digital unterschrieben am ${new Date(c.signedAt).toLocaleDateString('de-DE')}`);