wg-verwaltung/backend/src/routes/invitations.ts

259 lines
10 KiB
TypeScript

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) });
});