Require phone, current address and ID copy for new tenants

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.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 11:25:57 +00:00
parent 3e87ff3476
commit e77e33d7ff
4 changed files with 191 additions and 4 deletions

View File

@ -102,7 +102,18 @@ const CONTRACT_DOCUMENTS_SELECT = {
landlordAddress: true, landlordAddress: true,
propertyAddress: true, propertyAddress: true,
signedAt: 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 } }, room: { select: { id: true, roomNumber: true, sizeSqm: true } },
}; };

View File

@ -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) => { 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) { if (!password || String(password).length < 8) {
return res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' }); 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' }); 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 } }); const existingUser = await prisma.user.findUnique({ where: { email: invitation.email } });
if (existingUser) { if (existingUser) {
return res.status(409).json({ error: 'Es gibt bereits einen Nutzer mit dieser E-Mail-Adresse' }); 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(), fullName: String(fullName).trim(),
role: invitation.role, role: invitation.role,
roomId: invitation.roomId, 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({ await tx.invitation.update({

View File

@ -168,6 +168,15 @@ model User {
avatarUrl String? @map("avatar_url") avatarUrl String? @map("avatar_url")
pushToken String? @map("push_token") // OneSignal Player ID 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, // Ein Mieter ist aktuell genau einem Zimmer zugeordnet (Bequemlichkeits-FK,
// die verbindliche Quelle bleibt Contract). // die verbindliche Quelle bleibt Contract).
roomId String? @map("room_id") roomId String? @map("room_id")

View File

@ -175,6 +175,8 @@
width: 100%; width: 100%;
max-width: 380px; 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 h1 { font-size: 19px; margin: 0 0 4px; }
.auth-card p.sub { color: var(--text-muted); font-size: 13.5px; margin: 0 0 22px; } .auth-card p.sub { color: var(--text-muted); font-size: 13.5px; margin: 0 0 22px; }
.field { margin-bottom: 14px; } .field { margin-bottom: 14px; }
@ -492,6 +494,35 @@
<label for="acceptPassword">Passwort wählen (min. 8 Zeichen)</label> <label for="acceptPassword">Passwort wählen (min. 8 Zeichen)</label>
<input type="password" id="acceptPassword" required minlength="8" autocomplete="new-password" /> <input type="password" id="acceptPassword" required minlength="8" autocomplete="new-password" />
</div> </div>
<div id="acceptTenantFields" style="display:none">
<div class="field">
<label for="acceptPhone">Telefonnummer</label>
<input type="tel" id="acceptPhone" autocomplete="tel" placeholder="z. B. 0151 23456789" />
</div>
<div class="field">
<label for="acceptFirstResidence">Aktueller Erstwohnsitz (laut Meldebescheinigung)</label>
<input type="text" id="acceptFirstResidence" placeholder="Straße Hausnr., PLZ Ort" />
</div>
<div class="field">
<label for="acceptIdFront">Ausweiskopie — Vorderseite</label>
<input type="file" id="acceptIdFront" accept="image/*,application/pdf" />
</div>
<div class="field">
<label for="acceptIdBack">Ausweiskopie — Rückseite</label>
<input type="file" id="acceptIdBack" accept="image/*,application/pdf" />
</div>
<div class="field">
<label for="acceptSchufa">Schufa-Auskunft (optional)</label>
<input type="file" id="acceptSchufa" accept="image/*,application/pdf" />
</div>
<div class="field" id="acceptSchufaDateField" style="display:none">
<label for="acceptSchufaDate">Ausstellungsdatum der Schufa-Auskunft</label>
<input type="date" id="acceptSchufaDate" />
</div>
<p class="field-hint">Telefonnummer, Erstwohnsitz und Ausweiskopie (beidseitig) sind für Mieter Pflichtangaben. Eine Schufa-Auskunft darf, falls hochgeladen, nicht älter als 3 Monate sein.</p>
</div>
<button type="submit" class="btn-primary" id="acceptSubmitBtn">Konto erstellen &amp; anmelden</button> <button type="submit" class="btn-primary" id="acceptSubmitBtn">Konto erstellen &amp; anmelden</button>
</form> </form>
</div> </div>
@ -1149,6 +1180,24 @@
form.style.display = 'flex'; form.style.display = 'flex';
form.style.flexDirection = 'column'; 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) => { form.onsubmit = async (e) => {
e.preventDefault(); e.preventDefault();
errorBox.style.display = 'none'; errorBox.style.display = 'none';
@ -1158,9 +1207,20 @@
try { try {
const fullName = document.getElementById('acceptFullName').value.trim(); const fullName = document.getElementById('acceptFullName').value.trim();
const password = document.getElementById('acceptPassword').value; 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`, { const data = await apiFetch(`/invitations/${token}/accept`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ fullName, password }), body: JSON.stringify(body),
}); });
setSession(data.token, data.user); setSession(data.token, data.user);
history.replaceState(null, '', location.pathname); history.replaceState(null, '', location.pathname);
@ -2758,6 +2818,59 @@
return `Dokument ${index + 1}.${ext}`; 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) { function renderContractDocuments(contracts) {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
const list = document.getElementById('contractDocumentsList'); const list = document.getElementById('contractDocumentsList');
@ -2778,6 +2891,10 @@
top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`)); top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`));
card.appendChild(top); card.appendChild(top);
if (isLandlord) {
card.appendChild(buildProfileCompletenessBadge(c.user));
}
const isOwnContract = c.user.id === currentUser.id; const isOwnContract = c.user.id === currentUser.id;
if (c.signedAt) { if (c.signedAt) {
const signedNote = el('div', 'meta-text', `✓ Digital unterschrieben am ${new Date(c.signedAt).toLocaleDateString('de-DE')}`); const signedNote = el('div', 'meta-text', `✓ Digital unterschrieben am ${new Date(c.signedAt).toLocaleDateString('de-DE')}`);