/** * Service-Schicht fuer Reservierungen (plan.md Abschnitt 5, 6 und 15). * * Die Doppelbuchungspruefung laeuft ATOMAR innerhalb einer serialisierbaren * Transaktion: Der Server validiert Berechtigung, Zeitraum, maximale * Dauer und Kollision in einem einzigen kritischen Abschnitt. Zwei * gleichzeitige Buchungen koennen so niemals denselben Slot belegen. * * Dauer: Reservierungen geben Start- und Endzeit an; die maximale * Dauer wird gegen die Kalender-Einstellung (max_reservation_minutes) * geprueft. 0 bedeutet unbegrenzt. */ import { prisma } from '@/lib/db/client'; import { ReservationConflictError, validateReservationPeriod } from '@/lib/reservations/conflicts'; import type { Reservation } from '@prisma/client'; /** Zeitfenster-Puffer rund um den gebuchten Zeitraum (24 Stunden). */ const WINDOW_BUFFER_MS = 24 * 60 * 60 * 1000; /** * Absolute Obergrenze fuer eine einzelne Reservierung (24 Stunden). * Gilt AUCH wenn der Kalender "unbegrenzt" (0) eingestellt ist, damit * kein Benutzer den Kalender mit jahrelangen Blockaden lahmlegen kann. */ export const ABSOLUTE_MAX_DURATION_MINUTES = 24 * 60; /** Fehler, wenn die maximale Reservierungsdauer ueberschritten wird. */ export class MaxDurationExceededError extends Error { readonly maxMinutes: number; constructor(maxMinutes: number) { super( maxMinutes === 0 ? 'Die Reservierungsdauer ist ungueltig.' : `Maximale Reservierungsdauer ist ${formatDuration(maxMinutes)}.`, ); this.name = 'MaxDurationExceededError'; this.maxMinutes = maxMinutes; } } /** Fehler, wenn der Kalender nicht existiert oder deaktiviert ist. */ export class CalendarInactiveError extends Error { constructor() { super('Kalender nicht gefunden oder deaktiviert.'); this.name = 'CalendarInactiveError'; } } /** Fehler, wenn die zu aendernde Reservierung nicht existiert. */ export class ReservationNotFoundError extends Error { constructor() { super('Reservierung nicht gefunden.'); this.name = 'ReservationNotFoundError'; } } /** Formatiert Minuten als lesbare Angabe (z. B. "4 Stunden" / "90 Minuten"). */ export function formatDuration(minutes: number): string { if (minutes < 60) { return `${minutes} Minuten`; } const hours = minutes / 60; return Number.isInteger(hours) ? `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}` : `${hours.toFixed(1).replace('.', ',')} Stunden`; } /** Prueft, ob der Zeitraum die maximale Dauer des Kalenders einhaelt. */ function assertMaxDuration( startAt: Date, endAt: Date, maxReservationMinutes: number, ): void { const durationMinutes = (endAt.getTime() - startAt.getTime()) / 60_000; // Absolute Obergrenze gilt immer (DoS-Schutz gegen Blockaden). if (durationMinutes > ABSOLUTE_MAX_DURATION_MINUTES) { throw new MaxDurationExceededError(ABSOLUTE_MAX_DURATION_MINUTES); } if (maxReservationMinutes === 0) { return; // 0 = unbegrenzt (innerhalb der absoluten Obergrenze). } if (durationMinutes > maxReservationMinutes) { throw new MaxDurationExceededError(maxReservationMinutes); } } export interface CreateReservationInput { calendarId: string; userId: string; title: string; startAt: Date; endAt: Date; notes?: string | null; } export interface UpdateReservationInput { startAt?: Date; endAt?: Date; title?: string; notes?: string | null; status?: 'CONFIRMED' | 'CANCELLED'; } /** * Erstellt eine Reservierung atomar. * * Ablauf innerhalb der Transaktion: * 1. Kalender laden und pruefen (aktiv? maximale Dauer?). * 2. Bestehende Reservierungen im Zeitfenster sperren/lesen. * 3. Kollision pruefen -> bei Konflikt ReservationConflictError. * 4. Reservierung anlegen. * * Das Isolation-Level SERIALIZABLE verhindert, dass zwei parallele * Transaktionen dieselbe Luecke gleichzeitig als frei einstufen. */ export async function createReservationAtomically( input: CreateReservationInput, ): Promise { validateReservationPeriod(input.startAt, input.endAt); return prisma.$transaction( async (tx) => { const calendar = await tx.calendar.findUnique({ where: { id: input.calendarId }, select: { id: true, isActive: true, maxReservationMinutes: true }, }); if (!calendar || !calendar.isActive) { throw new CalendarInactiveError(); } assertMaxDuration(input.startAt, input.endAt, calendar.maxReservationMinutes); // Zeitfenster mit Puffer lesen: alle Reservierungen, die den Tag // des neuen Zeitraums ueberdecken, genau genug fuer den Overlap-Check. const windowStart = new Date(input.startAt.getTime() - WINDOW_BUFFER_MS); const windowEnd = new Date(input.endAt.getTime() + WINDOW_BUFFER_MS); const overlapping = await tx.reservation.findMany({ where: { calendarId: input.calendarId, status: 'CONFIRMED', startAt: { lt: windowEnd }, endAt: { gt: windowStart }, }, select: { id: true, startAt: true, endAt: true }, }); const conflict = overlapping.some( (existing) => existing.startAt < input.endAt && input.startAt < existing.endAt, ); if (conflict) { throw new ReservationConflictError(); } return tx.reservation.create({ data: { calendarId: input.calendarId, userId: input.userId, title: input.title, startAt: input.startAt, endAt: input.endAt, notes: input.notes ?? null, }, }); }, { isolationLevel: 'Serializable' }, ); } /** * Aktualisiert eine Reservierung atomar (gleiche Konflikt- und * Dauerlogik; die eigene Reservierung wird aus der Kollisionspruefung * ausgenommen). */ export async function updateReservationAtomically( reservationId: string, input: UpdateReservationInput, ): Promise { return prisma.$transaction( async (tx) => { const existing = await tx.reservation.findUnique({ where: { id: reservationId }, }); if (!existing) { throw new ReservationNotFoundError(); } const startAt = input.startAt ?? existing.startAt; const endAt = input.endAt ?? existing.endAt; validateReservationPeriod(startAt, endAt); const calendar = await tx.calendar.findUnique({ where: { id: existing.calendarId }, select: { isActive: true, maxReservationMinutes: true }, }); // Konsistenz zum Anlegen: In deaktivierten Kalendern sind auch // Aenderungen bestehender Reservierungen nicht mehr moeglich. if (!calendar || !calendar.isActive) { throw new CalendarInactiveError(); } assertMaxDuration(startAt, endAt, calendar.maxReservationMinutes); const windowStart = new Date(startAt.getTime() - WINDOW_BUFFER_MS); const windowEnd = new Date(endAt.getTime() + WINDOW_BUFFER_MS); const overlapping = await tx.reservation.findMany({ where: { calendarId: existing.calendarId, status: 'CONFIRMED', id: { not: reservationId }, startAt: { lt: windowEnd }, endAt: { gt: windowStart }, }, select: { id: true, startAt: true, endAt: true }, }); const conflict = overlapping.some( (other) => other.startAt < endAt && startAt < other.endAt, ); if (conflict) { throw new ReservationConflictError(); } return tx.reservation.update({ where: { id: reservationId }, data: { startAt, endAt, title: input.title ?? existing.title, notes: input.notes === undefined ? existing.notes : input.notes, status: input.status ?? existing.status, }, }); }, { isolationLevel: 'Serializable' }, ); }