diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts new file mode 100644 index 0000000..f120ab6 --- /dev/null +++ b/backend/src/routes/invitations.ts @@ -0,0 +1,178 @@ +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, roomId?, role? } -> { invitation, inviteLink } +invitationsRouter.post( + '/invitations', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const { email, 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, + 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, + role: invitation.role, + room: invitation.room, + expiresAt: invitation.expiresAt, + }); +}); + +// POST /v1/invitations/:token/accept (öffentlich) — { fullName, password } -> { token, user } +invitationsRouter.post('/invitations/:token/accept', async (req: Request, res: Response) => { + const { fullName, password } = 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' }); + } + + 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, + }, + }); + await tx.invitation.update({ + where: { id: invitation.id }, + data: { status: 'ACCEPTED', acceptedAt: new Date() }, + }); + return created; + }); + + const token = signAuthToken({ id: user.id, email: user.email, role: user.role }); + res.status(201).json({ token, user: publicUser(user) }); +});