Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
81
lib/reservations/conflicts.ts
Normal file
81
lib/reservations/conflicts.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Konfliktpruefung fuer Reservierungen (plan.md Abschnitt 6).
|
||||
*
|
||||
* Zwei Zeitraume kollidieren, wenn sie sich ueberlappen:
|
||||
* existing.start < new.end UND new.start < existing.end
|
||||
*
|
||||
* Die Pruefung MUSS serverseitig innerhalb einer Transaktion mit
|
||||
* konsistentem Isolation-Level erfolgen, damit gleichzeitige Buchungen
|
||||
* zweier Benutzer nicht zu einer Doppelbuchung fuehren koennen.
|
||||
*/
|
||||
import type { Reservation } from '@prisma/client';
|
||||
|
||||
/** Fehler, der bei einer Zeitkollision geworfen wird. */
|
||||
export class ReservationConflictError extends Error {
|
||||
constructor() {
|
||||
super('Der gewuenschte Zeitraum ist bereits belegt.');
|
||||
this.name = 'ReservationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Fehler, wenn der Zeitraum ungueltig ist (Vergangenheit, Start>=Ende). */
|
||||
export class ReservationValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ReservationValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Prueft rein funktional, ob sich zwei Zeitraume ueberlappen. */
|
||||
export function intervalsOverlap(
|
||||
aStart: Date,
|
||||
aEnd: Date,
|
||||
bStart: Date,
|
||||
bEnd: Date,
|
||||
): boolean {
|
||||
return aStart < bEnd && bStart < aEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet kollidierende Reservierungen eines Kalenders im Zeitraum.
|
||||
* Rein funktional und damit gut testbar; die Datenbankabfrage wird
|
||||
* als Parameter injiziert.
|
||||
*/
|
||||
export function findConflicts(
|
||||
existing: Pick<Reservation, 'id' | 'startAt' | 'endAt'>[],
|
||||
newStart: Date,
|
||||
newEnd: Date,
|
||||
ignoreReservationId?: string,
|
||||
): Pick<Reservation, 'id' | 'startAt' | 'endAt'>[] {
|
||||
return existing.filter(
|
||||
(reservation) =>
|
||||
reservation.id !== ignoreReservationId &&
|
||||
intervalsOverlap(reservation.startAt, reservation.endAt, newStart, newEnd),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert, dass Start vor Ende liegt und der Zeitraum nicht in der
|
||||
* Vergangenheit beginnt. Wirft sonst eine aussagekraeftige Fehlermeldung.
|
||||
*/
|
||||
export function validateReservationPeriod(
|
||||
startAt: Date,
|
||||
endAt: Date,
|
||||
now: Date = new Date(),
|
||||
): void {
|
||||
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
|
||||
throw new ReservationValidationError(
|
||||
'Start- und Endzeit muessen gueltige Zeitpunkte sein.',
|
||||
);
|
||||
}
|
||||
if (startAt >= endAt) {
|
||||
throw new ReservationValidationError(
|
||||
'Die Startzeit muss vor der Endzeit liegen.',
|
||||
);
|
||||
}
|
||||
if (startAt < now) {
|
||||
throw new ReservationValidationError(
|
||||
'Der Zeitraum liegt in der Vergangenheit.',
|
||||
);
|
||||
}
|
||||
}
|
||||
238
lib/reservations/service.ts
Normal file
238
lib/reservations/service.ts
Normal 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' },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user