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,
|
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 } },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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 & anmelden</button>
|
<button type="submit" class="btn-primary" id="acceptSubmitBtn">Konto erstellen & 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')}`);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user