259 lines
10 KiB
TypeScript
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) });
|
|
});
|