diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts index 5ff5862..93fe942 100644 --- a/backend/src/routes/invitations.ts +++ b/backend/src/routes/invitations.ts @@ -3,6 +3,7 @@ import { PrismaClient, Prisma, UserRole } from '@prisma/client'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { signAuthToken, requireAuth, requireRole, AuthedRequest } from '../middleware/auth'; +import { findOverlappingContract } from './contracts'; const prisma = new PrismaClient(); @@ -20,13 +21,20 @@ function inviteLinkFor(req: Request, token: string): string { return `${baseUrl.replace(/\/$/, '')}/?invite=${token}`; } -// POST /v1/invitations (nur Vermieter/Admin) — { email, fullName?, roomId?, role? } -> { invitation, inviteLink } +// POST /v1/invitations (nur Vermieter/Admin) +// { email, fullName?, roomId?, role?, contractStartDate?, contractEndDate? } -> { invitation, inviteLink } +// +// contractStartDate/contractEndDate sind nur relevant, wenn ein Zimmer +// zugeordnet wird: sie legen fest, ab wann (und ggf. bis wann, sonst +// unbefristet) der bei Einladungsannahme automatisch erzeugte Mietvertrag +// laufen soll — Basis für die Plausibilitätsprüfung gegen bereits vergebene +// Zimmer (siehe findOverlappingContract in contracts.ts). invitationsRouter.post( '/invitations', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { - const { email, fullName, roomId, role } = req.body || {}; + const { email, fullName, roomId, role, contractStartDate, contractEndDate } = req.body || {}; if (!email || !String(email).trim()) { return res.status(400).json({ error: 'E-Mail-Adresse erforderlich' }); } @@ -53,9 +61,29 @@ invitationsRouter.post( }); } + let parsedContractStart: Date | null = null; + let parsedContractEnd: Date | null = null; + if (roomId) { const room = await prisma.room.findUnique({ where: { id: roomId } }); if (!room) return res.status(400).json({ error: 'Zimmer nicht gefunden' }); + + if (contractEndDate !== undefined && contractEndDate !== null && Number.isNaN(Date.parse(contractEndDate))) { + return res.status(400).json({ error: 'Mietende ist ungültig' }); + } + if (contractStartDate !== undefined && contractStartDate !== null && Number.isNaN(Date.parse(contractStartDate))) { + return res.status(400).json({ error: 'Mietbeginn ist ungültig' }); + } + parsedContractStart = contractStartDate ? new Date(contractStartDate) : new Date(); + parsedContractEnd = contractEndDate ? new Date(contractEndDate) : null; + + const overlap = await findOverlappingContract(prisma, roomId, parsedContractStart, parsedContractEnd); + if (overlap) { + const overlapUntil = overlap.endDate ? overlap.endDate.toLocaleDateString('de-DE') : 'unbefristet'; + return res.status(409).json({ + error: `Das Zimmer ist im gewählten Zeitraum bereits vergeben (belegt bis ${overlapUntil}).`, + }); + } } const token = crypto.randomBytes(24).toString('hex'); @@ -68,6 +96,8 @@ invitationsRouter.post( token, role: invitedRole, roomId: roomId || null, + contractStartDate: parsedContractStart, + contractEndDate: parsedContractEnd, invitedById: req.user!.id, expiresAt, }, @@ -128,6 +158,8 @@ invitationsRouter.get('/invitations/:token/preview', async (req: Request, res: R fullName: invitation.fullName, role: invitation.role, room: invitation.room, + contractStartDate: invitation.contractStartDate, + contractEndDate: invitation.contractEndDate, expiresAt: invitation.expiresAt, }); }); @@ -240,7 +272,8 @@ invitationsRouter.post('/invitations/:token/accept', async (req: Request, res: R data: { userId: created.id, roomId: room.id, - startDate: new Date(), + startDate: invitation.contractStartDate || new Date(), + endDate: invitation.contractEndDate, totalWarmRent: Number(room.baseRent) + Number(room.utilityPauschal), depositAmount: Number(room.baseRent) * 3, isActive: true, diff --git a/web-dashboard/index.html b/web-dashboard/index.html index e052823..3305100 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -670,6 +670,17 @@ + +