238 lines
7.7 KiB
TypeScript
238 lines
7.7 KiB
TypeScript
/**
|
|
* 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<Reservation> {
|
|
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<Reservation> {
|
|
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' },
|
|
);
|
|
} |