diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md
new file mode 100644
index 0000000..fe1838c
--- /dev/null
+++ b/SECURITY_AUDIT.md
@@ -0,0 +1,98 @@
+# Security-Audit — 17.09.2026
+
+Geprüfter Commit: `cb30eca`. Ergebnis: **ein Befund hoher Priorität (P1), fünf Befunde mittlerer Priorität (P2)** sowie Hinweise zu Abhängigkeiten. Keine Behebung vorgenommen.
+
+## Umfang und Nachweisgrenzen
+
+Quellcodeprüfung von Payload-Zugriffsregeln, Kunden-/Admin-Authentifizierung, Buchungen, Meetings, Analytics, Kalender-Feed, E-Mail-Versand und Deployment-Konfiguration. Die installierten Next.js-Guides zu Datensicherheit und Route Handlers sowie Payloads tatsächliche Local-API-Implementierung wurden herangezogen.
+
+`npm test`: 5/5 Tests bestanden. Zusätzlich wurden die unveränderten Route Handler für Stornierung, Meeting-Beitritt, Token-Austausch und Buchungsanfragen isoliert ausgeführt, mit simulierter Datenbank und simuliertem LiveKit. Keine Produktivzugriffe, echten E-Mails oder Datenänderungen. Diese Prüfungen belegen die Handler-Logik, nicht das Verhalten eines realen Reverse-Proxys oder Video-Servers. Produktionskonfiguration, Git-Historie und laufende Infrastruktur waren nicht Gegenstand eines Penetrationstests.
+
+## 1. P1 — Interne Buchungsnotizen gelangen in den Kundenbrowser
+
+**Stellen:** `lib/booking/queries.ts:12`, `app/(frontend)/konto/termine/page.tsx:27`, `app/(frontend)/konto/page.tsx:46`, `collections/BookingRequests.ts:364`.
+
+`getCustomerBookings()` liest vollständige Buchungsdokumente mit `depth: 2` über die Payload Local API. Ohne explizites `overrideAccess: false` umgeht diese sowohl Collection- als auch Feldzugriffsregeln. Das wurde in `node_modules/payload/dist/collections/operations/local/find.js` und `node_modules/payload/dist/fields/hooks/afterRead/promise.js` verifiziert.
+
+Die Kontoseiten übergeben das gesamte Ergebnis als `booking` an die Client Component `BookingCard`. Dadurch wird auch `internalNote` an den Browser serialisiert, obwohl es durch `isAdminFieldLevel` geschützt und als „Nur für Anna sichtbar“ bezeichnet ist. Dass die Komponente die Notiz nicht sichtbar rendert, schützt sie nicht.
+
+**Auswirkung:** Ein angemeldeter Kunde kann interne Notizen zu seinen Buchungen in der Seiten-/RSC-Antwort lesen. Kein Zugriff auf fremde Buchungen wurde hier festgestellt.
+
+**Nachweis:** Statisch vollständiger Datenfluss von privilegierter Abfrage bis zur Client-Grenze; kein Live-Abruf realer Kundendaten.
+
+**Behebung:** Kundenansichten nur explizit ausgewählte Felder als separates DTO übergeben. Wenn möglich zusätzlich mit `overrideAccess: false` und authentifiziertem Benutzer lesen. Privilegiert benötigte Meeting-Daten gesondert und minimal auflösen. Auch verschachtelte Beziehungen nicht vollständig serialisieren.
+
+**Regressionstest:** Eine Buchung mit einer eindeutigen internen Testnotiz anlegen; die Notiz darf weder in HTML noch in einer RSC-Antwort des Kundenkontos vorkommen.
+
+## 2. P2 — Stornierung widerruft den privaten Meeting-Zugang nicht
+
+**Stellen:** `app/(payload)/api/booking/[id]/cancel/route.ts:29`, `app/(payload)/api/meetings/[slug]/join/route.ts:49`, `collections/BookingRequests.ts:229`.
+
+Die Stornierung setzt nur den Buchungsstatus. Der verknüpfte Online-Event und sein Passwort bleiben gültig. Der Join-Endpunkt prüft Online-Status, Passwort und Zeitfenster, aber nicht den Status der zugehörigen privaten Buchung. Der E-Mail-Hook bei Stornierung sperrt den Event ebenfalls nicht.
+
+**Auswirkung:** Wer das zuvor erhaltene Passwort kennt, kann innerhalb des ursprünglichen Zeitfensters auch nach der Stornierung neue Meeting-Zugangsdaten erhalten.
+
+**Nachweis:** Isolierter Aufruf des Cancel-Handlers änderte die Buchung auf `cancelled`; anschließender anonymer Join mit dem alten Passwort lieferte HTTP 200 und Zugangsdaten.
+
+**Behebung:** Für private Events bei jedem Join und Connect eine weiterhin bestätigte, passend verknüpfte Buchung verlangen. Bei Stornierung bestehende Sitzungen widerrufen; gegebenenfalls aktive Teilnehmer über LiveKit entfernen.
+
+## 3. P2 — Token-Austausch umgeht das Meeting-Ende
+
+**Stelle:** `app/(payload)/api/meetings/[slug]/connect/route.ts:27`.
+
+`/connect` prüft das App-Token und die Kick-Liste, anschließend nur noch `isOnline`. Anders als `/join` prüft der Handler weder das aktuelle Meeting-Zeitfenster noch einen Widerruf durch Passwortwechsel. App-Tokens sind standardmäßig sechs Stunden gültig; neu ausgestellte LiveKit-Tokens gelten jeweils zwei Stunden.
+
+**Auswirkung:** Ein zuvor zugelassener Teilnehmer erhält nach Ende des erlaubten Beitrittsfensters weiterhin neue Video-Zugangsdaten, solange sein App-Token gültig ist. Ein Passwortwechsel allein invalidiert bereits ausgestellte App-Tokens ebenfalls nicht.
+
+**Nachweis:** Mit gültigem App-Token und einem simulierten, bereits am Vortag beendeten Event lieferte der unveränderte Connect-Handler HTTP 200 mit einem neuen Video-Token.
+
+**Behebung:** Dieselbe serverseitige Zugangsprüfung für Join und Connect verwenden. Token-Laufzeiten am erlaubten Meeting-Ende begrenzen und einen Sitzungs-/Berechtigungsstand für Widerrufe prüfen. Die beabsichtigte Admin-Testzimmer-Ausnahme separat behandeln.
+
+## 4. P2 — Krankheits- und Urlaubsgründe sind öffentlich abrufbar
+
+**Stelle:** `collections/AvailabilityOverrides.ts:17`.
+
+Die Collection erlaubt anonymes Lesen aller Dokumente. Das Feld `reason` enthält unter anderem `krankheit` und `urlaub` und besitzt keinen eigenen Leseschutz. Die generische Payload-API unter `/api/availability-overrides` macht damit Datum und Abwesenheitsgrund öffentlich; gleiches gilt grundsätzlich für GraphQL.
+
+**Auswirkung:** Persönliche Abwesenheitsgründe lassen sich ohne Anmeldung abfragen, soweit entsprechende Einträge gespeichert sind. Zur Buchung reicht die Information, welche Slots verfügbar sind.
+
+**Nachweis:** Statisch anhand von Collection-Zugriff, Felddefinition und generischem REST-Router.
+
+**Behebung:** Collection-Lesen auf Admins beschränken oder mindestens den Grund schützen. Der bestehende serverseitige Slot-Endpunkt kann weiterhin ausschließlich verfügbare Zeiten herausgeben.
+
+## 5. P2 — Registrierung und E-Mail-Wechsel umgehen die Versandlimits
+
+**Stellen:** `collections/Customers.ts:84`, `collections/Customers.ts:46`.
+
+Die öffentliche Kundenerstellung löst unmittelbar eine Bestätigungs-E-Mail aus. Änderungen der eigenen E-Mail-Adresse lösen denselben Versand erneut aus. Für diese Operationen gibt es keine entsprechenden Limits in der Collection. Die Limits des separaten `/api/auth/resend-verification`-Handlers greifen hier nicht; `maxLoginAttempts` schützt nur Login-Versuche.
+
+**Auswirkung:** Ohne bestätigte Adresse lassen sich neue Konten für verschiedene Empfänger erzeugen beziehungsweise eigene E-Mail-Adressen wiederholt ändern und damit Bestätigungsmails auslösen. Das ermöglicht Versandmissbrauch, Datenbankwachstum und Belastung des SMTP-Kontos. Ein externes Proxy-Limit könnte die Häufigkeit mindern, ist im geprüften Repository aber nicht als Schutz für diese Vorgänge implementiert.
+
+**Nachweis:** Statisch anhand der öffentlichen Collection-Operationen und Versand-Hooks. Kein Versandtest durchgeführt.
+
+**Behebung:** Erstellung und E-Mail-Änderung vor dem Versand zentral pro IP, Konto und Zieladresse begrenzen. Regeln auf Collection-/Hook-Ebene oder einem exklusiven, geschützten Einstiegspunkt durchsetzen, sodass REST und GraphQL sie nicht umgehen können.
+
+## 6. P2 — Ein bestätigtes Konto kann beliebig viele identische Buchungsanfragen erzeugen
+
+**Stellen:** `app/(payload)/api/booking/request/route.ts:75`, `collections/BookingRequests.ts:163`.
+
+Der Handler prüft Anmeldung, E-Mail-Bestätigung und Slot-Verfügbarkeit, aber weder eine Rate-Limit-Quote noch eine schon bestehende identische offene Anfrage. Offene Anfragen belegen den Slot nicht. Jeder erfolgreiche Create löst Bestätigungs- und Admin-Benachrichtigungen aus.
+
+**Auswirkung:** Ein einziges verifiziertes Konto kann den Admin-Eingang, die Buchungsliste und den Mailversand mit Duplikaten fluten.
+
+**Nachweis:** 25 unmittelbar aufeinanderfolgende identische Anfragen wurden vom unveränderten Handler mit HTTP 200 angenommen und an die simulierte Create-Operation weitergegeben. Keine echten E-Mails verschickt.
+
+**Behebung:** Pro Kunde begrenzen und identische offene Anfragen atomar deduplizieren. Wiederholte identische Requests sollten keinen weiteren Datensatz und keinen erneuten Mailversand erzeugen.
+
+## Abhängigkeiten und weitere Prüfergebnisse
+
+`npm audit --json` meldete im Lockfile **7 betroffene Pakete: 6 moderate, 1 low, 0 high, 0 critical**. Die Zahl enthält transitive Abhängigkeitsketten und bedeutet nicht sieben unabhängig ausnutzbare Anwendungslücken.
+
+- **esbuild 0.18.20** ist über `@payloadcms/db-postgres → drizzle-kit → @esbuild-kit/esm-loader → @esbuild-kit/core-utils` installiert. Das [Advisory GHSA-67mh-4wv8-2f99](https://github.com/advisories/GHSA-67mh-4wv8-2f99) betrifft esbuilds Entwicklungs-Webserver. Im geprüften App-Startpfad wurde dieser Server nicht gefunden; daraus wird keine bestätigte Produktionslücke abgeleitet. npm meldet für diese Kette keinen verfügbaren automatischen Fix.
+- **DOMPurify/Monaco:** Das Lockfile-Audit meldete mehrere DOMPurify-Advisories, darunter [GHSA-55q2-fjhq-7xh7](https://github.com/advisories/GHSA-55q2-fjhq-7xh7), mit verfügbarem Fix. Dieses Advisory setzt spezielle `IN_PLACE`-/Hook-Nutzung voraus. `npm ls dompurify monaco-editor --all` zeigte beide Pakete im aktuellen Installationsbaum nicht an. Produktionsinstallation aus dem Lockfile und tatsächliche Nutzung müssen vor einer Aussage zur Ausnutzbarkeit abgeglichen werden; ein XSS-Exploit wurde nicht nachgewiesen.
+- Ein begrenzter Musterscan der aktuell versionierten Dateien fand keine privaten Schlüsselblöcke oder GitHub-/AWS-Tokenmuster. Das ist kein vollständiger Secret- oder Historien-Scan.
+- Positiv: Adminrechte verlangen Auth-Collection und Rolle; Buchungsaktionen prüfen den Eigentümer; Verifizierungs-/Kalender-Tokens werden gehasht gespeichert; öffentliche Event-Felder geben das Meeting-Passwort nicht über die reguläre Payload-API frei; Cron-Aufrufe prüfen ein Secret.
+
+## Reihenfolge der Behebung
+
+Zuerst die Weitergabe interner Notizen stoppen. Danach private Meeting-Zugänge und Token-Austausch an den aktuellen Berechtigungsstatus binden, Abwesenheitsgründe schützen und die beiden Versand-/Buchungsmissbrauchspfade begrenzen. Abhängigkeitsupdates separat prüfen und mit dem tatsächlich ausgelieferten Installationsbaum abgleichen.
diff --git a/app/(frontend)/konto/page.tsx b/app/(frontend)/konto/page.tsx
index f541ed4..3cbd558 100644
--- a/app/(frontend)/konto/page.tsx
+++ b/app/(frontend)/konto/page.tsx
@@ -15,8 +15,8 @@ export default async function KontoOverviewPage() {
const bookings = await getCustomerBookings(customer.id);
const meetingSettings = await getMeetingSettingsGlobal();
const pending = bookings.filter((b) => b.status === "pending");
- const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.date) >= new Date());
- const past = bookings.filter((b) => new Date(b.date) < new Date() || b.status === "rejected" || b.status === "cancelled");
+ const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.endTime) >= new Date());
+ const past = bookings.filter((b) => new Date(b.endTime) < new Date() || b.status === "rejected" || b.status === "cancelled");
return (
diff --git a/app/(payload)/api/admin/meetings/start/route.ts b/app/(payload)/api/admin/meetings/start/route.ts
index 77cf9f3..e2d3aa4 100644
--- a/app/(payload)/api/admin/meetings/start/route.ts
+++ b/app/(payload)/api/admin/meetings/start/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token";
+import { authorizeMeeting } from "@/lib/meeting/access";
export const dynamic = "force-dynamic";
@@ -12,6 +13,7 @@ export async function POST(request: Request) {
let body: Body;
try {
body = await request.json();
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
@@ -36,10 +38,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Dieser Termin ist kein Online-Termin." }, { status: 404 });
}
+ const access = await authorizeMeeting(payload, event, "host");
+ if (!access) return NextResponse.json({ error: "Dieser Termin ist nicht offen oder wurde storniert." }, { status: 403 });
const { token } = createMeetingToken({
eventSlug,
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
role: "host",
+ accessVersion: access.accessVersion,
+ ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
});
// Keep the token in the URL fragment: browsers do not send fragments to
diff --git a/app/(payload)/api/admin/meetings/test/route.ts b/app/(payload)/api/admin/meetings/test/route.ts
index f2b2b49..762dc4e 100644
--- a/app/(payload)/api/admin/meetings/test/route.ts
+++ b/app/(payload)/api/admin/meetings/test/route.ts
@@ -20,6 +20,7 @@ export async function POST(request: Request) {
eventSlug,
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
role: "host",
+ testRoom: true,
ttlMs: 60 * 60 * 1000,
});
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
diff --git a/app/(payload)/api/analytics/track/route.ts b/app/(payload)/api/analytics/track/route.ts
index 6b26d2e..bed5e99 100644
--- a/app/(payload)/api/analytics/track/route.ts
+++ b/app/(payload)/api/analytics/track/route.ts
@@ -53,6 +53,7 @@ export async function POST(request: Request) {
try {
const raw = await request.text();
body = JSON.parse(raw);
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
return new NextResponse(null, { status: 204 });
}
diff --git a/app/(payload)/api/auth/resend-verification/route.ts b/app/(payload)/api/auth/resend-verification/route.ts
index ecdce8c..af5b64c 100644
--- a/app/(payload)/api/auth/resend-verification/route.ts
+++ b/app/(payload)/api/auth/resend-verification/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
+import { allowVerificationEmail } from "@/lib/auth/verificationRateLimit";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
@@ -57,11 +58,12 @@ export async function POST(request: Request) {
let body: { email?: unknown } = {};
try {
body = await request.json();
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
// no body — handled below as a missing email
}
- const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
- if (!email) {
+ const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
+ if (!email || email.length > 254) {
return NextResponse.json({ error: "E-Mail-Adresse erforderlich." }, { status: 400 });
}
if (!checkRateLimit(`resend-verification:email:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
@@ -77,8 +79,8 @@ export async function POST(request: Request) {
}
if (customer && !customer.emailVerified) {
- if (!checkRateLimit(`resend-verification:customer:${customer.id}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
- return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
+ if (!allowVerificationEmail(request, customer.email, customer.id)) {
+ return NextResponse.json(GENERIC_RESPONSE);
}
await issueAndSend(payload, customer);
}
diff --git a/app/(payload)/api/booking/[id]/accept-alternative/route.ts b/app/(payload)/api/booking/[id]/accept-alternative/route.ts
index 445e691..d59b124 100644
--- a/app/(payload)/api/booking/[id]/accept-alternative/route.ts
+++ b/app/(payload)/api/booking/[id]/accept-alternative/route.ts
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
+import { combineDateAndTime } from "@/lib/meeting/status";
+import { withBookingTransaction } from "@/lib/booking/transaction";
export const dynamic = "force-dynamic";
@@ -19,31 +21,40 @@ export async function POST(request: Request, { params }: Args) {
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
}
- const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
- if (!booking || Number(booking.user) !== Number(user.id)) {
- return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
- }
- const alternative = booking.proposedAlternative;
- if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
- return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
- }
+ return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
+ const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req });
+ const booking = docs[0];
+ if (!booking || Number(booking.user) !== Number(user.id)) {
+ return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
+ }
+ const alternative = booking.proposedAlternative;
+ if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
+ return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
+ }
- // The beforeChange hook on booking-requests re-checks for conflicts
- // (with an advisory lock) before allowing this to become "confirmed" —
- // the alternative slot may have been taken by someone else since it was
- // proposed.
- await payload.update({
- collection: "booking-requests",
- id,
- data: {
- status: "confirmed",
- date: alternative.date,
- startTime: alternative.startTime,
- endTime: alternative.endTime,
- },
+ const alternativeStart = combineDateAndTime(alternative.date, alternative.startTime);
+ if (!Number.isFinite(alternativeStart.getTime()) || alternativeStart <= new Date()) {
+ return NextResponse.json({ error: "Dieser Terminvorschlag liegt bereits in der Vergangenheit." }, { status: 409 });
+ }
+
+ // The beforeChange hook on booking-requests re-checks for conflicts
+ // (with an advisory lock) before allowing this to become "confirmed" —
+ // the alternative slot may have been taken by someone else since it was
+ // proposed.
+ await payload.update({
+ collection: "booking-requests",
+ id,
+ req,
+ data: {
+ status: "confirmed",
+ date: alternative.date,
+ startTime: alternative.startTime,
+ endTime: alternative.endTime,
+ },
+ });
+
+ return NextResponse.json({ ok: true });
});
-
- return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) {
return NextResponse.json({ error: error.message }, { status: 409 });
diff --git a/app/(payload)/api/booking/[id]/cancel/route.ts b/app/(payload)/api/booking/[id]/cancel/route.ts
index 8cd7f67..091935a 100644
--- a/app/(payload)/api/booking/[id]/cancel/route.ts
+++ b/app/(payload)/api/booking/[id]/cancel/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
+import { withBookingTransaction } from "@/lib/booking/transaction";
export const dynamic = "force-dynamic";
@@ -18,16 +19,19 @@ export async function POST(request: Request, { params }: Args) {
// Cancelling is deliberately allowed regardless of verification status —
// only creating/confirming a booking is a gated "protected" action.
- const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
- if (!booking || Number(booking.user) !== Number(user.id)) {
- return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
- }
- if (booking.status === "cancelled") {
- return NextResponse.json({ ok: true });
- }
+ return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
+ const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req });
+ const booking = docs[0];
+ if (!booking || Number(booking.user) !== Number(user.id)) {
+ return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
+ }
+ if (booking.status === "cancelled") {
+ return NextResponse.json({ ok: true });
+ }
- await payload.update({ collection: "booking-requests", id, data: { status: "cancelled" } });
- return NextResponse.json({ ok: true });
+ await payload.update({ collection: "booking-requests", id, req, data: { status: "cancelled" } });
+ return NextResponse.json({ ok: true });
+ });
} catch (error) {
console.error("booking cancel failed", error);
return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 });
diff --git a/app/(payload)/api/booking/request/route.ts b/app/(payload)/api/booking/request/route.ts
index 9817eca..d078983 100644
--- a/app/(payload)/api/booking/request/route.ts
+++ b/app/(payload)/api/booking/request/route.ts
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots";
+import { withBookingTransaction } from "@/lib/booking/transaction";
+import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { berlinDayBounds } from "@/lib/calendar/timezone";
export const dynamic = "force-dynamic";
@@ -17,13 +19,15 @@ export async function POST(request: Request) {
let body: Body;
try {
body = await request.json();
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : "";
const startISO = typeof body.start === "string" ? body.start : "";
- const appointmentType = body.appointmentType === "online" ? "online" : "onsite";
+ const appointmentType = body.appointmentType;
+ if (appointmentType !== "online" && appointmentType !== "onsite") return NextResponse.json({ error: "Ungültige Terminart." }, { status: 400 });
const message = typeof body.message === "string" ? body.message.trim().slice(0, 2000) : "";
if (!offerSlug || !startISO) {
@@ -40,6 +44,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
}
+ if (!checkRateLimit(`booking:ip:${getClientIp(request)}`, { max: 30, windowMs: 3600000 }) ||
+ !checkRateLimit(`booking:customer:${user.id}`, { max: 10, windowMs: 3600000 })) {
+ return NextResponse.json({ error: "Zu viele Terminanfragen. Bitte versuche es später erneut." }, { status: 429 });
+ }
+
const { docs } = await payload.find({
collection: "offers",
where: { slug: { equals: offerSlug } },
@@ -61,32 +70,44 @@ export async function POST(request: Request) {
// stale slot list) — the hard, race-safe check still runs again when
// Anna confirms (see collections/BookingRequests.ts).
const dayBounds = berlinDayBounds(start);
- const slotsByDay = await getAvailableSlots(payload, {
- durationMinutes: offer.durationMinutes,
- from: dayBounds.start,
- to: dayBounds.end,
- });
- const key = dayBounds.key;
- const isStillFree = (slotsByDay.get(key) ?? []).some((s) => s.start.getTime() === start.getTime());
- if (!isStillFree) {
- return NextResponse.json({ error: "Dieser Termin ist leider nicht mehr verfügbar." }, { status: 409 });
- }
+ return await withBookingTransaction(payload, dayBounds.key, async (req) => {
+ const { docs: existing } = await payload.find({ collection: "booking-requests", req, depth: 0, limit: 1,
+ where: { and: [
+ { user: { equals: user.id } }, { offer: { equals: offer.id } },
+ { startTime: { equals: start.toISOString() } }, { appointmentType: { equals: appointmentType } },
+ { status: { in: ["pending", "confirmed"] } },
+ ] },
+ });
+ if (existing[0]) return NextResponse.json({ ok: true });
+ const slotsByDay = await getAvailableSlots(payload, {
+ durationMinutes: offer.durationMinutes!,
+ req,
+ from: dayBounds.start,
+ to: dayBounds.end,
+ });
+ const key = dayBounds.key;
+ const isStillFree = (slotsByDay.get(key) ?? []).some((s) => s.start.getTime() === start.getTime());
+ if (!isStillFree) {
+ return NextResponse.json({ error: "Dieser Termin ist leider nicht mehr verfügbar." }, { status: 409 });
+ }
- await payload.create({
- collection: "booking-requests",
- data: {
- user: user.id,
- offer: offer.id,
- date: start.toISOString(),
- startTime: start.toISOString(),
- endTime: end.toISOString(),
- appointmentType,
- status: "pending",
- userMessage: message || undefined,
- },
- });
+ await payload.create({
+ collection: "booking-requests",
+ req,
+ data: {
+ user: user.id,
+ offer: offer.id,
+ date: start.toISOString(),
+ startTime: start.toISOString(),
+ endTime: end.toISOString(),
+ appointmentType,
+ status: "pending",
+ userMessage: message || undefined,
+ },
+ });
- return NextResponse.json({ ok: true });
+ return NextResponse.json({ ok: true });
+ });
} catch (error) {
console.error("booking request failed", error);
return NextResponse.json({ error: "Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
diff --git a/app/(payload)/api/booking/slots/route.ts b/app/(payload)/api/booking/slots/route.ts
index 95396f8..c0d0338 100644
--- a/app/(payload)/api/booking/slots/route.ts
+++ b/app/(payload)/api/booking/slots/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots";
+import { berlinDayBounds, parseBerlinDate } from "@/lib/calendar/timezone";
export const dynamic = "force-dynamic";
@@ -29,16 +30,15 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
}
- const from = fromParam ? new Date(`${fromParam}T00:00:00`) : new Date();
- if (Number.isNaN(from.getTime())) {
+ const from = fromParam ? parseBerlinDate(fromParam) : berlinDayBounds(new Date()).start;
+ if (!from) {
return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 });
}
- from.setHours(0, 0, 0, 0);
- const to = toParam ? new Date(`${toParam}T00:00:00`) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
- if (Number.isNaN(to.getTime())) {
+ const toDate = toParam ? parseBerlinDate(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
+ if (!toDate) {
return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 });
}
- to.setHours(23, 59, 59, 999);
+ const to = berlinDayBounds(toDate).end;
if (to < from) {
return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 });
diff --git a/app/(payload)/api/meetings/[slug]/connect/route.ts b/app/(payload)/api/meetings/[slug]/connect/route.ts
index 6ea4758..933852b 100644
--- a/app/(payload)/api/meetings/[slug]/connect/route.ts
+++ b/app/(payload)/api/meetings/[slug]/connect/route.ts
@@ -4,6 +4,7 @@ import config from "@payload-config";
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
import { verifyMeetingToken } from "@/lib/meeting/token";
+import { authorizeMeeting } from "@/lib/meeting/access";
export const dynamic = "force-dynamic";
@@ -23,11 +24,17 @@ export async function POST(request: Request, { params }: Args) {
}
try {
- const isAdminTestRoom = meetingToken.role === "host" && slug.startsWith("test-");
+ let expiresAt = meetingToken.exp;
+ const isAdminTestRoom = meetingToken.role === "host" && meetingToken.testRoom === true && slug.startsWith("test-");
if (!isAdminTestRoom) {
const payload = await getPayload({ config });
const { docs } = await payload.find({ collection: "events", where: { slug: { equals: slug } }, limit: 1 });
if (!docs[0]?.isOnline) return NextResponse.json({ error: "Dieser Online-Termin wurde nicht gefunden." }, { status: 404 });
+ const access = await authorizeMeeting(payload, docs[0], meetingToken.role);
+ if (!access || access.accessVersion !== meetingToken.accessVersion) {
+ return NextResponse.json({ error: "Dieser Meeting-Zugang ist nicht mehr gültig. Bitte tritt erneut bei." }, { status: 403 });
+ }
+ expiresAt = Math.min(expiresAt, access.expiresAt);
}
const participantToken = await createLiveKitToken({
@@ -36,6 +43,8 @@ export async function POST(request: Request, { params }: Args) {
name: meetingToken.name,
role: meetingToken.role,
clientId: meetingToken.clientId,
+ accessVersion: meetingToken.accessVersion,
+ expiresAt,
});
return NextResponse.json({ serverUrl: getLiveKitClientUrl(), participantToken });
} catch (error) {
diff --git a/app/(payload)/api/meetings/[slug]/join/route.ts b/app/(payload)/api/meetings/[slug]/join/route.ts
index 64a4860..3596767 100644
--- a/app/(payload)/api/meetings/[slug]/join/route.ts
+++ b/app/(payload)/api/meetings/[slug]/join/route.ts
@@ -3,7 +3,8 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token";
-import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status";
+import { authorizeMeeting } from "@/lib/meeting/access";
+import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
@@ -20,10 +21,14 @@ type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
+ if (!checkRateLimit(`meeting-join:${getClientIp(request)}`, { max: 30, windowMs: 60000 })) {
+ return NextResponse.json({ error: "Zu viele Beitrittsversuche." }, { status: 429 });
+ }
let body: { name?: unknown; password?: unknown; clientId?: unknown };
try {
body = await request.json();
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
@@ -65,31 +70,17 @@ export async function POST(request: Request, { params }: Args) {
}
}
- const settings = await payload.findGlobal({ slug: "meeting-settings" });
- const start = combineDateAndTime(event.date, event.startTime);
- const end = event.endTime
- ? combineDateAndTime(event.date, event.endTime)
- : new Date(start.getTime() + 60 * 60_000);
-
- const status = getMeetingStatus({
- start,
- end,
- joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
- closeAfterMinutes: settings.meetingCloseAfterMinutes,
- });
-
- if (!canJoinMeeting(status)) {
- const message =
- status === "scheduled"
- ? "Dieser Termin ist noch nicht offen. Bitte versuche es näher am Beginn erneut."
- : "Dieser Termin ist bereits beendet.";
- return NextResponse.json({ error: message }, { status: 403 });
+ const access = await authorizeMeeting(payload, event, isHost ? "host" : "participant");
+ if (!access) {
+ return NextResponse.json({ error: "Dieser Termin ist nicht offen oder wurde storniert." }, { status: 403 });
}
const { token, participantId } = createMeetingToken({
eventSlug: slug,
name,
role: isHost ? "host" : "participant",
+ accessVersion: access.accessVersion,
+ ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
// Only meaningful for participants — a host authenticates via a real
// session already, so there's no need to anchor a kick to it (and
// hosts can't be kicked in the first place.
@@ -101,6 +92,8 @@ export async function POST(request: Request, { params }: Args) {
}
const participantToken = await createLiveKitToken({
eventSlug: slug,
+ accessVersion: access.accessVersion,
+ expiresAt: access.expiresAt,
participantId,
name,
role: isHost ? "host" : "participant",
diff --git a/app/(payload)/api/meetings/[slug]/kick/route.ts b/app/(payload)/api/meetings/[slug]/kick/route.ts
index 87c6d7d..f505168 100644
--- a/app/(payload)/api/meetings/[slug]/kick/route.ts
+++ b/app/(payload)/api/meetings/[slug]/kick/route.ts
@@ -19,6 +19,7 @@ export async function POST(request: Request, { params }: Args) {
let body: { participantId?: unknown };
try {
body = await request.json();
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
@@ -29,7 +30,7 @@ export async function POST(request: Request, { params }: Args) {
try {
const roomService = getLiveKitRoomService();
- const room = getLiveKitRoomName(slug);
+ const room = getLiveKitRoomName(slug, host.accessVersion);
const participant = (await roomService.listParticipants(room)).find((item) => item.identity === participantId);
if (!participant) return NextResponse.json({ error: "Teilnehmer nicht gefunden." }, { status: 404 });
diff --git a/app/(payload)/api/meetings/[slug]/register/route.ts b/app/(payload)/api/meetings/[slug]/register/route.ts
index e1b0302..86f5e31 100644
--- a/app/(payload)/api/meetings/[slug]/register/route.ts
+++ b/app/(payload)/api/meetings/[slug]/register/route.ts
@@ -38,6 +38,7 @@ export async function POST(request: Request, { params }: Args) {
let body: { name?: unknown; email?: unknown };
try {
body = await request.json();
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
diff --git a/collections/AvailabilityOverrides.ts b/collections/AvailabilityOverrides.ts
index 0ea5bb4..95a50a9 100644
--- a/collections/AvailabilityOverrides.ts
+++ b/collections/AvailabilityOverrides.ts
@@ -14,7 +14,7 @@ export const AvailabilityOverrides: CollectionConfig = {
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
},
access: {
- read: () => true,
+ read: isAdmin,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
diff --git a/collections/BookingRequests.ts b/collections/BookingRequests.ts
index 5bc844e..b70689b 100644
--- a/collections/BookingRequests.ts
+++ b/collections/BookingRequests.ts
@@ -1,6 +1,8 @@
+import { randomUUID } from "node:crypto";
import { APIError } from "payload";
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload";
import { isAdmin, isAdminFieldLevel } from "@/access";
+import { combineDateAndTime } from "@/lib/meeting/status";
import { acquireBookingDayLock } from "@/lib/booking/lock";
import {
alternativeProposedEmail,
@@ -12,7 +14,7 @@ import {
} from "@/lib/email/bookingTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import type { Customer, Event, Offer } from "@/payload-types";
-import { berlinDateKey, berlinDayBounds, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
+import { berlinDateKey, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
export const APPOINTMENT_TYPES = [
{ label: "Vor Ort", value: "onsite" },
@@ -49,88 +51,73 @@ async function notifyAdmins(
await Promise.all(admins.filter((admin) => admin.email).map((admin) => sendEmail(admin.email, template)));
}
-// Runs whenever a booking is about to become "confirmed": locks the day
-// (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings
-// inside that same lock/transaction, and — for online appointments — creates
-// the linked video-call Event (reusing the existing meeting/password system)
-// before the booking itself is written.
-const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
- // Covers both paths: an existing request being confirmed, AND Anna
- // manually creating a booking that's already "confirmed" from the start
- // (e.g. a phone booking) — both must go through the same conflict check.
- const wasAlreadyConfirmed = originalDoc?.status === "confirmed";
- if (data.status !== "confirmed" || wasAlreadyConfirmed) {
- return data;
- }
-
+// Normalize Payload time-only inputs before conflict checks and synchronize
+// the private meeting whenever a booking is moved, cancelled or reactivated.
+export const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
+ const status = data.status ?? originalDoc?.status ?? "pending";
const date = data.date ?? originalDoc?.date;
const startTime = data.startTime ?? originalDoc?.startTime;
const endTime = data.endTime ?? originalDoc?.endTime;
const appointmentType = data.appointmentType ?? originalDoc?.appointmentType;
- if (!date || !startTime || !endTime) return data; // Field-level validation will reject this anyway.
+ if (!date || !startTime || !endTime) return data;
+ const start = combineDateAndTime(date, startTime);
+ const end = combineDateAndTime(date, endTime);
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start) {
+ throw new APIError("Das Ende muss nach dem Beginn am selben Tag liegen.", 400, undefined, true);
+ }
+ data.date = start.toISOString();
+ data.startTime = start.toISOString();
+ data.endTime = end.toISOString();
- await acquireBookingDayLock(req, dateKey(date));
-
- const dayBounds = berlinDayBounds(new Date(date));
- const dayStart = dayBounds.start.toISOString();
- const dayEnd = dayBounds.end.toISOString();
- const conflictClauses: Where[] = [
- { status: { equals: "confirmed" } },
- { date: { greater_than_equal: dayStart } },
- { date: { less_than_equal: dayEnd } },
- ];
- if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
-
- const { docs: sameDayConfirmed } = await req.payload.find({
- collection: "booking-requests",
- where: { and: conflictClauses },
- req,
- limit: 200,
- });
-
- const newStart = new Date(startTime).getTime();
- const newEnd = new Date(endTime).getTime();
- const conflict = sameDayConfirmed.some((doc) => {
- const s = new Date(doc.startTime).getTime();
- const e = new Date(doc.endTime).getTime();
- return newStart < e && s < newEnd;
- });
-
- if (conflict) {
- throw new APIError(
- "Dieser Zeitraum ist bereits mit einem anderen bestätigten Termin belegt. Bitte wähle eine andere Zeit oder lehne den überschneidenden Termin zuerst ab.",
- 409,
- undefined,
- true,
- );
+ const alternative = data.proposedAlternative;
+ if (alternative && (alternative.date || alternative.startTime || alternative.endTime)) {
+ if (!alternative.date || !alternative.startTime || !alternative.endTime) {
+ throw new APIError("Bitte den alternativen Termin vollständig angeben.", 400, undefined, true);
+ }
+ const altStart = combineDateAndTime(alternative.date, alternative.startTime);
+ const altEnd = combineDateAndTime(alternative.date, alternative.endTime);
+ if (!Number.isFinite(altStart.getTime()) || !Number.isFinite(altEnd.getTime()) || altEnd <= altStart) {
+ throw new APIError("Der alternative Zeitraum ist ungültig.", 400, undefined, true);
+ }
+ data.proposedAlternative = { date: altStart.toISOString(), startTime: altStart.toISOString(), endTime: altEnd.toISOString() };
}
- if (appointmentType === "online" && !data.linkedEvent && !originalDoc?.linkedEvent) {
- const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc?.offer, req })) as Offer;
- const event = (await req.payload.create({
- collection: "events",
- req,
- data: {
- title: offer.title,
- date,
- startTime,
- endTime,
- category: "sonstiges",
- isOnline: true, // Events' own beforeChange hook auto-generates the password.
- isPrivateBooking: true,
- // Only set on the update-to-confirmed path — on create-as-confirmed
- // this booking doesn't have an id yet (assigned after insert), so
- // the back-reference is simply left blank in that one case.
- ...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
- },
- // Created as a draft so it never appears in public /termine listings
- // (see lib/payload/content.ts) — it's still reachable via its direct
- // join link, which is exactly what a private 1:1 booking needs.
- draft: true,
- })) as Event;
+ if (status === "confirmed") {
+ await acquireBookingDayLock(req, dateKey(data.date));
+ const conflictClauses: Where[] = [
+ { status: { equals: "confirmed" } },
+ { startTime: { less_than: data.endTime } },
+ { endTime: { greater_than: data.startTime } },
+ ];
+ if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
+ const { totalDocs } = await req.payload.count({ collection: "booking-requests", where: { and: conflictClauses }, req });
+ if (totalDocs) throw new APIError("Dieser Zeitraum ist bereits mit einem bestätigten Termin belegt.", 409, undefined, true);
+ }
+
+ const linked = data.linkedEvent ?? originalDoc?.linkedEvent;
+ const linkedId = typeof linked === "object" ? linked?.id : linked;
+ if (linkedId) {
+ const existing = await req.payload.findByID({ collection: "events", id: linkedId, depth: 0, req });
+ if (existing.isPrivateBooking) {
+ const isOnline = status === "confirmed" && appointmentType === "online";
+ if (existing.date !== data.date || existing.startTime !== data.startTime || existing.endTime !== data.endTime || existing.isOnline !== isOnline) {
+ await req.payload.update({ collection: "events", id: existing.id, req, data: {
+ date: data.date, startTime: data.startTime, endTime: data.endTime, isOnline,
+ } });
+ }
+ }
+ } else if (status === "confirmed" && appointmentType === "online") {
+ const relatedOffer = data.offer ?? originalDoc?.offer;
+ const offer = await req.payload.findByID({ collection: "offers", id: typeof relatedOffer === "object" ? relatedOffer.id : relatedOffer, req });
+ const event = await req.payload.create({ collection: "events", req, draft: true, data: {
+ title: offer.title,
+ slug: `buchung-${randomUUID()}`,
+ date: data.date, startTime: data.startTime, endTime: data.endTime,
+ category: "sonstiges", isOnline: true, isPrivateBooking: true,
+ ...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
+ } });
data.linkedEvent = event.id;
}
-
return data;
};
@@ -183,7 +170,8 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
const statusChanged = operation === "create" || previousDoc?.status !== doc.status;
- if (statusChanged && doc.status === "confirmed") {
+ const scheduleChanged = previousDoc && ["date", "startTime", "endTime", "appointmentType"].some((field) => doc[field] !== previousDoc[field]);
+ if ((statusChanged || scheduleChanged) && doc.status === "confirmed") {
let online: { joinUrl: string; password: string } | undefined;
let onsite: { address: string; mapsUrl: string } | undefined;
@@ -239,7 +227,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
);
}
- const proposedNew = doc.proposedAlternative?.date && doc.proposedAlternative.date !== previousDoc?.proposedAlternative?.date;
+ const proposedNew = doc.proposedAlternative?.date && JSON.stringify(doc.proposedAlternative) !== JSON.stringify(previousDoc?.proposedAlternative);
if (proposedNew && doc.status === "pending") {
await sendEmail(
user.email,
@@ -282,7 +270,7 @@ export const BookingRequests: CollectionConfig = {
// setting their own status to "confirmed" directly.
create: isAdmin,
read: ({ req }) => {
- if (req.user?.collection === "users") return true;
+ if (req.user?.collection === "users") return req.user.role === "admin";
if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
return false;
},
@@ -290,8 +278,19 @@ export const BookingRequests: CollectionConfig = {
delete: isAdmin,
},
hooks: {
+ beforeOperation: [async ({ args, operation, req }) => {
+ if (operation === "create" || operation === "update" || operation === "delete") {
+ await acquireBookingDayLock(req, "booking-calendar");
+ }
+ return args;
+ }],
beforeChange: [handleConfirmation],
afterChange: [notifyByEmail],
+ afterDelete: [async ({ doc, req }) => {
+ const id = typeof doc.linkedEvent === "object" ? doc.linkedEvent?.id : doc.linkedEvent;
+ if (id) await req.payload.update({ collection: "events", id, req, data: { isOnline: false } });
+ return doc;
+ }],
},
fields: [
{
diff --git a/collections/Customers.ts b/collections/Customers.ts
index 6d58420..c876443 100644
--- a/collections/Customers.ts
+++ b/collections/Customers.ts
@@ -1,4 +1,6 @@
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
+import { APIError } from "payload";
+import { allowVerificationEmail } from "@/lib/auth/verificationRateLimit";
import { isAdmin, isAdminFieldLevel } from "@/access";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates";
@@ -35,6 +37,10 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc,
data.email !== originalDoc.email;
if (operation !== "create" && !emailChanged) return data;
+ if (!allowVerificationEmail(req, String(data.email ?? originalDoc?.email ?? ""), originalDoc?.id)) {
+ throw new APIError("Zu viele Bestätigungs-E-Mails. Bitte versuche es später erneut.", 429, undefined, true);
+ }
+
const token = generateVerificationToken();
data.emailVerified = false;
data.emailVerificationTokenHash = hashVerificationToken(token);
@@ -46,6 +52,7 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc,
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => {
const token = req.context.pendingVerificationToken;
if (typeof token !== "string") return doc;
+ delete req.context.pendingVerificationToken;
try {
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
await sendEmail(
diff --git a/collections/Events.ts b/collections/Events.ts
index bdc114f..6b36a2c 100644
--- a/collections/Events.ts
+++ b/collections/Events.ts
@@ -3,6 +3,7 @@ import { isAdmin, isAdminFieldLevel, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
import { seoFields } from "@/fields/seo";
import { generateMeetingPassword } from "@/lib/meeting/password";
+import { revokeChangedMeeting } from "@/lib/meeting/revoke";
export const EVENT_CATEGORIES = [
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
@@ -18,9 +19,9 @@ export const EVENT_CATEGORIES = [
// Auto-generates the meeting password for new online events, and
// regenerates it whenever the admin ticks "Neues Passwort generieren".
// Never derived from the event id/slug — always a fresh random value.
-const setMeetingPassword: CollectionBeforeChangeHook = ({ data }) => {
- if (!data?.isOnline) return data;
- if (!data.meetingPassword || data.regeneratePassword) {
+const setMeetingPassword: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
+ if (!(data.isOnline ?? originalDoc?.isOnline)) return data;
+ if (!(data.meetingPassword ?? originalDoc?.meetingPassword) || data.regeneratePassword || originalDoc?.isOnline === false) {
data.meetingPassword = generateMeetingPassword();
}
data.regeneratePassword = false;
@@ -52,6 +53,7 @@ export const Events: CollectionConfig = {
},
hooks: {
beforeChange: [setMeetingPassword],
+ afterChange: [revokeChangedMeeting],
},
fields: [
{
diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx
index a39a658..712e34c 100644
--- a/components/auth/LoginForm.tsx
+++ b/components/auth/LoginForm.tsx
@@ -4,6 +4,7 @@ import { useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ResendVerificationButton } from "./ResendVerificationButton";
import { TextField, FormError } from "@/components/FormField";
+import { safeLoginRedirect } from "@/lib/auth/redirect";
export function LoginForm() {
const router = useRouter();
@@ -32,7 +33,7 @@ export function LoginForm() {
setLoading(false);
return;
}
- router.push(searchParams.get("next") || "/konto");
+ router.push(safeLoginRedirect(searchParams.get("next")));
router.refresh();
} catch {
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
diff --git a/components/booking/BookingCard.tsx b/components/booking/BookingCard.tsx
index 66b74e6..85ace66 100644
--- a/components/booking/BookingCard.tsx
+++ b/components/booking/BookingCard.tsx
@@ -3,18 +3,18 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge";
-import type { BookingRequest, Event, Offer } from "@/payload-types";
+import type { CustomerBooking } from "@/lib/booking/customerView";
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
import type { MeetingStatus } from "@/lib/meeting/status";
-export function BookingCard({ booking, meetingStatus }: { booking: BookingRequest; meetingStatus?: MeetingStatus | null }) {
+export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooking; meetingStatus?: MeetingStatus | null }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState
(null);
- const offer = typeof booking.offer === "object" ? (booking.offer as Offer) : null;
- const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
- const displayStatus = resolveDisplayStatus(booking.status, booking.date);
+ const offer = booking.offer;
+ const event = booking.linkedEvent;
+ const displayStatus = resolveDisplayStatus(booking.status, booking.endTime);
const hasAlternative = booking.status === "pending" && Boolean(booking.proposedAlternative?.date);
async function callAction(path: string) {
diff --git a/components/booking/BookingWidget.tsx b/components/booking/BookingWidget.tsx
index 605bd5f..21c6372 100644
--- a/components/booking/BookingWidget.tsx
+++ b/components/booking/BookingWidget.tsx
@@ -27,6 +27,7 @@ export function BookingWidget({
}) {
const [slotsByDay, setSlotsByDay] = useState({});
const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(false);
const [selectedDay, setSelectedDay] = useState(null);
const [selectedSlot, setSelectedSlot] = useState(null);
const [appointmentType, setAppointmentType] = useState<"onsite" | "online">("onsite");
@@ -35,12 +36,15 @@ export function BookingWidget({
const [result, setResult] = useState<{ ok: boolean; error?: string } | null>(null);
useEffect(() => {
+ const controller = new AbortController();
const from = new Date();
const to = new Date(from.getTime() + 28 * 24 * 60 * 60 * 1000);
- fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`)
- .then((res) => res.json())
+ fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`, { signal: controller.signal })
+ .then((res) => { if (!res.ok) throw new Error("Slots unavailable"); return res.json(); })
.then((data: SlotsResponse) => setSlotsByDay(data.slots ?? {}))
- .finally(() => setLoading(false));
+ .catch(() => { if (!controller.signal.aborted) setLoadError(true); })
+ .finally(() => { if (!controller.signal.aborted) setLoading(false); });
+ return () => controller.abort();
}, [offerSlug]);
if (result?.ok) {
@@ -126,11 +130,12 @@ export function BookingWidget({
if (loading) {
return Verfügbare Termine werden geladen …
;
}
+ if (loadError) return Verfügbare Termine konnten nicht geladen werden. Bitte lade die Seite erneut.;
return (
-
+
{ setSelectedDay(day); setSelectedSlot(null); }} selectedDay={selectedDay} />
{!selectedDay &&
Wähle einen markierten Tag, um freie Zeiten zu sehen.
}
{selectedDay && daySlots.length === 0 && (
diff --git a/components/booking/PersonalCalendar.tsx b/components/booking/PersonalCalendar.tsx
index 1897ed7..7cd4e02 100644
--- a/components/booking/PersonalCalendar.tsx
+++ b/components/booking/PersonalCalendar.tsx
@@ -4,16 +4,17 @@ import { useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
import { BookingCard } from "./BookingCard";
import { resolveDisplayStatus } from "./BookingStatusBadge";
-import type { BookingRequest, MeetingSetting } from "@/payload-types";
+import type { MeetingSetting } from "@/payload-types";
+import type { CustomerBooking } from "@/lib/booking/customerView";
import { berlinDateKey } from "@/lib/calendar/timezone";
import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess";
-export function PersonalCalendar({ bookings, meetingSettings }: { bookings: BookingRequest[]; meetingSettings: MeetingSetting }) {
+export function PersonalCalendar({ bookings, meetingSettings }: { bookings: CustomerBooking[]; meetingSettings: MeetingSetting }) {
const [selectedDay, setSelectedDay] = useState
(null);
const markers: CalendarMarker[] = bookings.map((b) => ({
date: new Date(b.date),
- status: resolveDisplayStatus(b.status, b.date) as CalendarMarker["status"],
+ status: resolveDisplayStatus(b.status, b.endTime) as CalendarMarker["status"],
}));
const selectedBookings = selectedDay
diff --git a/components/meeting/CallRoom.tsx b/components/meeting/CallRoom.tsx
index 9f1ff1d..61a7da3 100644
--- a/components/meeting/CallRoom.tsx
+++ b/components/meeting/CallRoom.tsx
@@ -171,9 +171,9 @@ export function CallRoom({ slug }: { slug: string }) {
localTracksRef.current = previewTracks;
try {
- let serverUrl = session.serverUrl;
- let participantToken = session.participantToken;
- if (!serverUrl || !participantToken) {
+ let serverUrl: string;
+ let participantToken: string;
+ {
const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/connect`, {
method: "POST",
headers: { Authorization: `Bearer ${session.token}` },
diff --git a/lib/auth/rateLimit.ts b/lib/auth/rateLimit.ts
index d2a8077..b396705 100644
--- a/lib/auth/rateLimit.ts
+++ b/lib/auth/rateLimit.ts
@@ -13,16 +13,17 @@ const MAX_TRACKED_KEYS = 5000;
function sweepExpired(now: number) {
for (const [key, bucket] of buckets) {
- if (bucket.resetAt < now) buckets.delete(key);
+ if (bucket.resetAt <= now) buckets.delete(key);
}
}
export function checkRateLimit(key: string, opts: { max: number; windowMs: number }): boolean {
const now = Date.now();
- if (buckets.size > MAX_TRACKED_KEYS) sweepExpired(now);
+ if (buckets.size >= MAX_TRACKED_KEYS) sweepExpired(now);
const bucket = buckets.get(key);
- if (!bucket || bucket.resetAt < now) {
+ if (!bucket || bucket.resetAt <= now) {
+ if (!bucket && buckets.size >= MAX_TRACKED_KEYS) return false;
buckets.set(key, { count: 1, resetAt: now + opts.windowMs });
return true;
}
@@ -31,7 +32,7 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe
return true;
}
-export function getClientIp(request: Request): string {
+export function getClientIp(request: { headers: Headers }): string {
// Deployment is always behind exactly one trusted reverse proxy (see
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
diff --git a/lib/auth/redirect.ts b/lib/auth/redirect.ts
new file mode 100644
index 0000000..c5c8362
--- /dev/null
+++ b/lib/auth/redirect.ts
@@ -0,0 +1,5 @@
+/** Only origin-relative application paths may be used after login. */
+export function safeLoginRedirect(value: string | null): string {
+ if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u0020]/.test(value)) return "/konto";
+ return value;
+}
diff --git a/lib/auth/verificationRateLimit.ts b/lib/auth/verificationRateLimit.ts
new file mode 100644
index 0000000..8cc9da4
--- /dev/null
+++ b/lib/auth/verificationRateLimit.ts
@@ -0,0 +1,9 @@
+import { checkRateLimit, getClientIp } from "./rateLimit";
+
+/** Shared budgets for registration, email changes and resend (REST and GraphQL). */
+export function allowVerificationEmail(request: { headers: Headers }, email: string, customerId?: string | number): boolean {
+ const windowMs = 60 * 60 * 1000;
+ return checkRateLimit(`verification:ip:${getClientIp(request)}`, { max: 10, windowMs }) &&
+ (customerId === undefined || checkRateLimit(`verification:customer:${customerId}`, { max: 3, windowMs })) &&
+ checkRateLimit(`verification:email:${email.trim().toLowerCase()}`, { max: 3, windowMs });
+}
diff --git a/lib/booking/customerView.ts b/lib/booking/customerView.ts
new file mode 100644
index 0000000..60ff90a
--- /dev/null
+++ b/lib/booking/customerView.ts
@@ -0,0 +1,25 @@
+import type { BookingRequest } from "@/payload-types";
+
+/** Explicit allow-list at the server/client boundary, including relationships. */
+export function customerBookingView(booking: BookingRequest) {
+ const offer = typeof booking.offer === "object" ? booking.offer : null;
+ const event = typeof booking.linkedEvent === "object" ? booking.linkedEvent : null;
+ const alternative = booking.proposedAlternative;
+ return {
+ id: booking.id,
+ date: booking.date,
+ startTime: booking.startTime,
+ endTime: booking.endTime,
+ status: booking.status,
+ appointmentType: booking.appointmentType,
+ offer: offer ? { title: offer.title } : null,
+ linkedEvent: event ? { slug: event.slug, isOnline: event.isOnline } : null,
+ proposedAlternative: alternative ? {
+ date: alternative.date,
+ startTime: alternative.startTime,
+ endTime: alternative.endTime,
+ } : null,
+ };
+}
+
+export type CustomerBooking = ReturnType;
diff --git a/lib/booking/lock.ts b/lib/booking/lock.ts
index 0e3fab9..6e76f29 100644
--- a/lib/booking/lock.ts
+++ b/lib/booking/lock.ts
@@ -13,13 +13,14 @@ import type { PayloadRequest } from "payload";
*/
export async function acquireBookingDayLock(req: PayloadRequest, dateKey: string): Promise {
const txID = req.transactionID ? String(await req.transactionID) : undefined;
- if (!txID) return; // No active transaction — nothing to lock against.
+ if (!txID) throw new Error("A booking lock requires an active transaction");
const adapter = req.payload.db as unknown as {
sessions?: Record Promise } }>;
drizzle: { execute: (query: unknown) => Promise };
};
- const tx = adapter.sessions?.[txID]?.db ?? adapter.drizzle;
+ const tx = adapter.sessions?.[txID]?.db;
+ if (!tx) throw new Error("Booking transaction not found");
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${dateKey}))`);
}
diff --git a/lib/booking/meetingAccess.ts b/lib/booking/meetingAccess.ts
index 2f65afe..6b2ab0a 100644
--- a/lib/booking/meetingAccess.ts
+++ b/lib/booking/meetingAccess.ts
@@ -1,8 +1,9 @@
-import type { BookingRequest, Event, MeetingSetting } from "@/payload-types";
+import type { MeetingSetting } from "@/payload-types";
+import type { CustomerBooking } from "./customerView";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, type MeetingStatus } from "@/lib/meeting/status";
-export function getBookingMeetingStatus(booking: BookingRequest, settings: MeetingSetting): MeetingStatus | null {
- const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
+export function getBookingMeetingStatus(booking: CustomerBooking, settings: MeetingSetting): MeetingStatus | null {
+ const event = booking.linkedEvent;
if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null;
const start = combineDateAndTime(booking.date, booking.startTime);
@@ -17,7 +18,7 @@ export function getBookingMeetingStatus(booking: BookingRequest, settings: Meeti
});
}
-export function canJoinBookingMeeting(booking: BookingRequest, settings: MeetingSetting): boolean {
+export function canJoinBookingMeeting(booking: CustomerBooking, settings: MeetingSetting): boolean {
const status = getBookingMeetingStatus(booking, settings);
return status !== null && canJoinMeeting(status);
}
diff --git a/lib/booking/queries.ts b/lib/booking/queries.ts
index e8aaa34..36e99bd 100644
--- a/lib/booking/queries.ts
+++ b/lib/booking/queries.ts
@@ -1,13 +1,13 @@
import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload";
-import type { BookingRequest } from "@/payload-types";
+import { customerBookingView, type CustomerBooking } from "./customerView";
/**
* These are called only after the caller has already verified who the
* current customer is (see lib/auth/customer.ts) — the `where: { user }`
* filter below is what actually scopes results to that one customer.
*/
-export const getCustomerBookings = cache(async (customerId: number): Promise => {
+export const getCustomerBookings = cache(async (customerId: number): Promise => {
const payload = await getCMS();
const result = await payload.find({
collection: "booking-requests",
@@ -16,10 +16,10 @@ export const getCustomerBookings = cache(async (customerId: number): Promise {
+export async function getCustomerBookingById(customerId: number, id: number): Promise {
const payload = await getCMS();
const result = await payload.find({
collection: "booking-requests",
@@ -27,5 +27,5 @@ export async function getCustomerBookingById(customerId: number, id: number): Pr
depth: 2,
limit: 1,
});
- return result.docs[0] ?? null;
+ return result.docs[0] ? customerBookingView(result.docs[0]) : null;
}
diff --git a/lib/booking/slots.ts b/lib/booking/slots.ts
index 883a304..f95b5f9 100644
--- a/lib/booking/slots.ts
+++ b/lib/booking/slots.ts
@@ -1,4 +1,4 @@
-import type { Payload } from "payload";
+import type { Payload, PayloadRequest } from "payload";
import { WEEKDAYS } from "@/collections/Availability";
import { berlinParts, berlinWallTimeToUTC, type CalendarDate } from "@/lib/calendar/timezone";
@@ -63,17 +63,20 @@ function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
*/
export async function getAvailableSlots(
payload: Payload,
- args: { durationMinutes: number; from: Date; to: Date },
+ args: { durationMinutes: number; from: Date; to: Date; req?: PayloadRequest },
): Promise