Initial commit: Kalendartool (Next.js, Prisma, Docker)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kühn
2026-10-08 14:38:04 +02:00
commit bc49c3074e
94 changed files with 12812 additions and 0 deletions

238
lib/reservations/service.ts Normal file
View File

@@ -0,0 +1,238 @@
/**
* 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' },
);
}