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:
parent
3e87ff3476
commit
e77e33d7ff
@ -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 } },
|
||||
};
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 @@
|
||||
<label for="acceptPassword">Passwort wählen (min. 8 Zeichen)</label>
|
||||
<input type="password" id="acceptPassword" required minlength="8" autocomplete="new-password" />
|
||||
</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 & anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -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')}`);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user