add backend/src/routes/invitations.ts

This commit is contained in:
Giuseppe Lombardo 2026-08-12 17:48:25 +00:00
parent b20eec366f
commit bd96bb2db1

View File

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