add backend/src/routes/invitations.ts
This commit is contained in:
parent
b20eec366f
commit
bd96bb2db1
178
backend/src/routes/invitations.ts
Normal file
178
backend/src/routes/invitations.ts
Normal 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) });
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user