import { Router, Request, Response } from 'express'; import { PrismaClient, Prisma, UserRole } from '@prisma/client'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { signAuthToken, requireAuth, requireRole, AuthedRequest } from '../middleware/auth'; const prisma = new PrismaClient(); export const invitationsRouter = Router(); const INVITE_EXPIRY_DAYS = 7; const VALID_ROLES: UserRole[] = [UserRole.TENANT, UserRole.CRAFTSMAN, UserRole.ADMIN]; function publicUser(u: { id: string; email: string; fullName: string; role: string; roomId: string | null }) { return { id: u.id, email: u.email, fullName: u.fullName, role: u.role, roomId: u.roomId }; } function inviteLinkFor(req: Request, token: string): string { const baseUrl = process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`; return `${baseUrl.replace(/\/$/, '')}/?invite=${token}`; } // POST /v1/invitations (nur Vermieter/Admin) — { email, fullName?, roomId?, role? } -> { invitation, inviteLink } invitationsRouter.post( '/invitations', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { const { email, fullName, roomId, role } = req.body || {}; if (!email || !String(email).trim()) { return res.status(400).json({ error: 'E-Mail-Adresse erforderlich' }); } const normalizedEmail = String(email).toLowerCase().trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) { return res.status(400).json({ error: 'Ungültige E-Mail-Adresse' }); } const invitedRole: UserRole = role && VALID_ROLES.includes(role) ? role : UserRole.TENANT; const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } }); if (existingUser) { return res.status(409).json({ error: 'Es gibt bereits einen Nutzer mit dieser E-Mail-Adresse' }); } const existingPending = await prisma.invitation.findFirst({ where: { email: normalizedEmail, status: 'PENDING' }, }); if (existingPending) { return res.status(200).json({ invitation: existingPending, inviteLink: inviteLinkFor(req, existingPending.token), note: 'Es besteht bereits eine offene Einladung für diese E-Mail-Adresse.', }); } if (roomId) { const room = await prisma.room.findUnique({ where: { id: roomId } }); if (!room) return res.status(400).json({ error: 'Zimmer nicht gefunden' }); } const token = crypto.randomBytes(24).toString('hex'); const expiresAt = new Date(Date.now() + INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000); const invitation = await prisma.invitation.create({ data: { email: normalizedEmail, fullName: typeof fullName === 'string' && fullName.trim() ? fullName.trim() : null, token, role: invitedRole, roomId: roomId || null, invitedById: req.user!.id, expiresAt, }, }); res.status(201).json({ invitation, inviteLink: inviteLinkFor(req, token) }); }, ); // GET /v1/invitations (nur Vermieter/Admin) — Liste aller Einladungen invitationsRouter.get( '/invitations', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: Request, res: Response) => { const invitations = await prisma.invitation.findMany({ orderBy: { createdAt: 'desc' }, include: { room: { select: { id: true, roomNumber: true } } }, }); res.status(200).json({ invitations: invitations.map((i: (typeof invitations)[number]) => ({ ...i, inviteLink: inviteLinkFor(req, i.token) })), }); }, ); // DELETE /v1/invitations/:id (nur Vermieter/Admin) — offene Einladung zurückziehen invitationsRouter.delete( '/invitations/:id', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { const result = await prisma.invitation.updateMany({ where: { id: req.params.id, status: 'PENDING' }, data: { status: 'REVOKED' }, }); if (result.count === 0) { return res.status(404).json({ error: 'Offene Einladung nicht gefunden' }); } res.status(204).send(); }, ); // GET /v1/invitations/:token/preview (öffentlich) — Infos zur Einladung vor Annahme invitationsRouter.get('/invitations/:token/preview', async (req: Request, res: Response) => { const invitation = await prisma.invitation.findUnique({ where: { token: req.params.token }, include: { room: { select: { id: true, roomNumber: true } } }, }); if (!invitation) return res.status(404).json({ error: 'Einladung nicht gefunden' }); if (invitation.status !== 'PENDING') { return res.status(410).json({ error: 'Diese Einladung wurde bereits verwendet oder zurückgezogen' }); } if (invitation.expiresAt < new Date()) { return res.status(410).json({ error: 'Diese Einladung ist abgelaufen' }); } res.status(200).json({ email: invitation.email, fullName: invitation.fullName, role: invitation.role, room: invitation.room, expiresAt: invitation.expiresAt, }); }); // 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, 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' }); } if (!fullName || !String(fullName).trim()) { return res.status(400).json({ error: 'Name erforderlich' }); } const invitation = await prisma.invitation.findUnique({ where: { token: req.params.token } }); if (!invitation) return res.status(404).json({ error: 'Einladung nicht gefunden' }); if (invitation.status !== 'PENDING') { return res.status(410).json({ error: 'Diese Einladung wurde bereits verwendet oder zurückgezogen' }); } if (invitation.expiresAt < new Date()) { 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' }); } const passwordHash = await bcrypt.hash(String(password), 12); const user = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const created = await tx.user.create({ data: { email: invitation.email, passwordHash, 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({ where: { id: invitation.id }, data: { status: 'ACCEPTED', acceptedAt: new Date() }, }); // War der Einladung ein Zimmer zugeordnet, entsteht daraus jetzt auch ein // Mietvertrag — sonst taucht der Mieter zwar mit roomId beim User auf, // aber nirgendwo in Miet-Ampel/Vertragsdokumenten/Kündigungsfristen, da // die überall an Contract hängen, nicht an User.roomId. Warmmiete/Kaution // werden aus den bereits am Zimmer hinterlegten Werten übernommen (wie // beim „Wieder aufnehmen“-Formular) — der Vermieter kann sie danach über // den Vertragsdokumente-Bereich jederzeit anpassen. if (invitation.roomId && invitation.role === UserRole.TENANT) { const room = await tx.room.findUnique({ where: { id: invitation.roomId } }); const activeContract = room ? await tx.contract.findFirst({ where: { roomId: room.id, isActive: true } }) : null; if (room && !activeContract) { await tx.contract.create({ data: { userId: created.id, roomId: room.id, startDate: new Date(), totalWarmRent: Number(room.baseRent) + Number(room.utilityPauschal), depositAmount: Number(room.baseRent) * 3, isActive: true, }, }); await tx.room.update({ where: { id: room.id }, data: { status: 'OCCUPIED' } }); } } return created; }); const token = signAuthToken({ id: user.id, email: user.email, role: user.role }); res.status(201).json({ token, user: publicUser(user) }); });