fix: harden booking and meeting flows
This commit is contained in:
@@ -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.
|
||||||
@@ -15,8 +15,8 @@ export default async function KontoOverviewPage() {
|
|||||||
const bookings = await getCustomerBookings(customer.id);
|
const bookings = await getCustomerBookings(customer.id);
|
||||||
const meetingSettings = await getMeetingSettingsGlobal();
|
const meetingSettings = await getMeetingSettingsGlobal();
|
||||||
const pending = bookings.filter((b) => b.status === "pending");
|
const pending = bookings.filter((b) => b.status === "pending");
|
||||||
const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.date) >= new Date());
|
const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.endTime) >= new Date());
|
||||||
const past = bookings.filter((b) => new Date(b.date) < new Date() || b.status === "rejected" || b.status === "cancelled");
|
const past = bookings.filter((b) => new Date(b.endTime) < new Date() || b.status === "rejected" || b.status === "cancelled");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { createMeetingToken } from "@/lib/meeting/token";
|
import { createMeetingToken } from "@/lib/meeting/token";
|
||||||
|
import { authorizeMeeting } from "@/lib/meeting/access";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export async function POST(request: Request) {
|
|||||||
let body: Body;
|
let body: Body;
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
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 });
|
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({
|
const { token } = createMeetingToken({
|
||||||
eventSlug,
|
eventSlug,
|
||||||
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
|
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
|
||||||
role: "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
|
// Keep the token in the URL fragment: browsers do not send fragments to
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export async function POST(request: Request) {
|
|||||||
eventSlug,
|
eventSlug,
|
||||||
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
|
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
|
||||||
role: "host",
|
role: "host",
|
||||||
|
testRoom: true,
|
||||||
ttlMs: 60 * 60 * 1000,
|
ttlMs: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
|
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const raw = await request.text();
|
const raw = await request.text();
|
||||||
body = JSON.parse(raw);
|
body = JSON.parse(raw);
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||||
} catch {
|
} catch {
|
||||||
return new NextResponse(null, { status: 204 });
|
return new NextResponse(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
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 { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
|
||||||
import { verificationEmail } from "@/lib/email/authTemplates";
|
import { verificationEmail } from "@/lib/email/authTemplates";
|
||||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||||
@@ -57,11 +58,12 @@ export async function POST(request: Request) {
|
|||||||
let body: { email?: unknown } = {};
|
let body: { email?: unknown } = {};
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||||
} catch {
|
} catch {
|
||||||
// no body — handled below as a missing email
|
// no body — handled below as a missing email
|
||||||
}
|
}
|
||||||
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
|
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
|
||||||
if (!email) {
|
if (!email || email.length > 254) {
|
||||||
return NextResponse.json({ error: "E-Mail-Adresse erforderlich." }, { status: 400 });
|
return NextResponse.json({ error: "E-Mail-Adresse erforderlich." }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!checkRateLimit(`resend-verification:email:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
|
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 (customer && !customer.emailVerified) {
|
||||||
if (!checkRateLimit(`resend-verification:customer:${customer.id}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
|
if (!allowVerificationEmail(request, customer.email, customer.id)) {
|
||||||
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
|
return NextResponse.json(GENERIC_RESPONSE);
|
||||||
}
|
}
|
||||||
await issueAndSend(payload, customer);
|
await issueAndSend(payload, customer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
|
import { combineDateAndTime } from "@/lib/meeting/status";
|
||||||
|
import { withBookingTransaction } from "@/lib/booking/transaction";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 });
|
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);
|
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
|
||||||
if (!booking || Number(booking.user) !== Number(user.id)) {
|
const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req });
|
||||||
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
|
const booking = docs[0];
|
||||||
}
|
if (!booking || Number(booking.user) !== Number(user.id)) {
|
||||||
const alternative = booking.proposedAlternative;
|
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
|
||||||
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 });
|
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
|
const alternativeStart = combineDateAndTime(alternative.date, alternative.startTime);
|
||||||
// (with an advisory lock) before allowing this to become "confirmed" —
|
if (!Number.isFinite(alternativeStart.getTime()) || alternativeStart <= new Date()) {
|
||||||
// the alternative slot may have been taken by someone else since it was
|
return NextResponse.json({ error: "Dieser Terminvorschlag liegt bereits in der Vergangenheit." }, { status: 409 });
|
||||||
// proposed.
|
}
|
||||||
await payload.update({
|
|
||||||
collection: "booking-requests",
|
// The beforeChange hook on booking-requests re-checks for conflicts
|
||||||
id,
|
// (with an advisory lock) before allowing this to become "confirmed" —
|
||||||
data: {
|
// the alternative slot may have been taken by someone else since it was
|
||||||
status: "confirmed",
|
// proposed.
|
||||||
date: alternative.date,
|
await payload.update({
|
||||||
startTime: alternative.startTime,
|
collection: "booking-requests",
|
||||||
endTime: alternative.endTime,
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) {
|
if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
|
import { withBookingTransaction } from "@/lib/booking/transaction";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 —
|
// Cancelling is deliberately allowed regardless of verification status —
|
||||||
// only creating/confirming a booking is a gated "protected" action.
|
// only creating/confirming a booking is a gated "protected" action.
|
||||||
|
|
||||||
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
|
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
|
||||||
if (!booking || Number(booking.user) !== Number(user.id)) {
|
const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req });
|
||||||
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
|
const booking = docs[0];
|
||||||
}
|
if (!booking || Number(booking.user) !== Number(user.id)) {
|
||||||
if (booking.status === "cancelled") {
|
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
|
||||||
return NextResponse.json({ ok: true });
|
}
|
||||||
}
|
if (booking.status === "cancelled") {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
await payload.update({ collection: "booking-requests", id, data: { status: "cancelled" } });
|
await payload.update({ collection: "booking-requests", id, req, data: { status: "cancelled" } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("booking cancel failed", error);
|
console.error("booking cancel failed", error);
|
||||||
return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 });
|
return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 });
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { getAvailableSlots } from "@/lib/booking/slots";
|
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";
|
import { berlinDayBounds } from "@/lib/calendar/timezone";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -17,13 +19,15 @@ export async function POST(request: Request) {
|
|||||||
let body: Body;
|
let body: Body;
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : "";
|
const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : "";
|
||||||
const startISO = typeof body.start === "string" ? body.start : "";
|
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) : "";
|
const message = typeof body.message === "string" ? body.message.trim().slice(0, 2000) : "";
|
||||||
|
|
||||||
if (!offerSlug || !startISO) {
|
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 });
|
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({
|
const { docs } = await payload.find({
|
||||||
collection: "offers",
|
collection: "offers",
|
||||||
where: { slug: { equals: offerSlug } },
|
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
|
// stale slot list) — the hard, race-safe check still runs again when
|
||||||
// Anna confirms (see collections/BookingRequests.ts).
|
// Anna confirms (see collections/BookingRequests.ts).
|
||||||
const dayBounds = berlinDayBounds(start);
|
const dayBounds = berlinDayBounds(start);
|
||||||
const slotsByDay = await getAvailableSlots(payload, {
|
return await withBookingTransaction(payload, dayBounds.key, async (req) => {
|
||||||
durationMinutes: offer.durationMinutes,
|
const { docs: existing } = await payload.find({ collection: "booking-requests", req, depth: 0, limit: 1,
|
||||||
from: dayBounds.start,
|
where: { and: [
|
||||||
to: dayBounds.end,
|
{ user: { equals: user.id } }, { offer: { equals: offer.id } },
|
||||||
});
|
{ startTime: { equals: start.toISOString() } }, { appointmentType: { equals: appointmentType } },
|
||||||
const key = dayBounds.key;
|
{ status: { in: ["pending", "confirmed"] } },
|
||||||
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 });
|
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({
|
await payload.create({
|
||||||
collection: "booking-requests",
|
collection: "booking-requests",
|
||||||
data: {
|
req,
|
||||||
user: user.id,
|
data: {
|
||||||
offer: offer.id,
|
user: user.id,
|
||||||
date: start.toISOString(),
|
offer: offer.id,
|
||||||
startTime: start.toISOString(),
|
date: start.toISOString(),
|
||||||
endTime: end.toISOString(),
|
startTime: start.toISOString(),
|
||||||
appointmentType,
|
endTime: end.toISOString(),
|
||||||
status: "pending",
|
appointmentType,
|
||||||
userMessage: message || undefined,
|
status: "pending",
|
||||||
},
|
userMessage: message || undefined,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("booking request failed", 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 });
|
return NextResponse.json({ error: "Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { getAvailableSlots } from "@/lib/booking/slots";
|
import { getAvailableSlots } from "@/lib/booking/slots";
|
||||||
|
import { berlinDayBounds, parseBerlinDate } from "@/lib/calendar/timezone";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 });
|
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const from = fromParam ? new Date(`${fromParam}T00:00:00`) : new Date();
|
const from = fromParam ? parseBerlinDate(fromParam) : berlinDayBounds(new Date()).start;
|
||||||
if (Number.isNaN(from.getTime())) {
|
if (!from) {
|
||||||
return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 });
|
return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 });
|
||||||
}
|
}
|
||||||
from.setHours(0, 0, 0, 0);
|
const toDate = toParam ? parseBerlinDate(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
|
||||||
const to = toParam ? new Date(`${toParam}T00:00:00`) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
|
if (!toDate) {
|
||||||
if (Number.isNaN(to.getTime())) {
|
|
||||||
return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 });
|
return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 });
|
||||||
}
|
}
|
||||||
to.setHours(23, 59, 59, 999);
|
const to = berlinDayBounds(toDate).end;
|
||||||
|
|
||||||
if (to < from) {
|
if (to < from) {
|
||||||
return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 });
|
return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import config from "@payload-config";
|
|||||||
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
|
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
|
||||||
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
|
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
|
||||||
import { verifyMeetingToken } from "@/lib/meeting/token";
|
import { verifyMeetingToken } from "@/lib/meeting/token";
|
||||||
|
import { authorizeMeeting } from "@/lib/meeting/access";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -23,11 +24,17 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
if (!isAdminTestRoom) {
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
const { docs } = await payload.find({ collection: "events", where: { slug: { equals: slug } }, limit: 1 });
|
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 });
|
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({
|
const participantToken = await createLiveKitToken({
|
||||||
@@ -36,6 +43,8 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
name: meetingToken.name,
|
name: meetingToken.name,
|
||||||
role: meetingToken.role,
|
role: meetingToken.role,
|
||||||
clientId: meetingToken.clientId,
|
clientId: meetingToken.clientId,
|
||||||
|
accessVersion: meetingToken.accessVersion,
|
||||||
|
expiresAt,
|
||||||
});
|
});
|
||||||
return NextResponse.json({ serverUrl: getLiveKitClientUrl(), participantToken });
|
return NextResponse.json({ serverUrl: getLiveKitClientUrl(), participantToken });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { createMeetingToken } from "@/lib/meeting/token";
|
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 { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
|
||||||
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
|
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
|
||||||
|
|
||||||
@@ -20,10 +21,14 @@ type Args = { params: Promise<{ slug: string }> };
|
|||||||
|
|
||||||
export async function POST(request: Request, { params }: Args) {
|
export async function POST(request: Request, { params }: Args) {
|
||||||
const { slug } = await params;
|
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 };
|
let body: { name?: unknown; password?: unknown; clientId?: unknown };
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
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 access = await authorizeMeeting(payload, event, isHost ? "host" : "participant");
|
||||||
const start = combineDateAndTime(event.date, event.startTime);
|
if (!access) {
|
||||||
const end = event.endTime
|
return NextResponse.json({ error: "Dieser Termin ist nicht offen oder wurde storniert." }, { status: 403 });
|
||||||
? 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 { token, participantId } = createMeetingToken({
|
const { token, participantId } = createMeetingToken({
|
||||||
eventSlug: slug,
|
eventSlug: slug,
|
||||||
name,
|
name,
|
||||||
role: isHost ? "host" : "participant",
|
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
|
// Only meaningful for participants — a host authenticates via a real
|
||||||
// session already, so there's no need to anchor a kick to it (and
|
// session already, so there's no need to anchor a kick to it (and
|
||||||
// hosts can't be kicked in the first place.
|
// 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({
|
const participantToken = await createLiveKitToken({
|
||||||
eventSlug: slug,
|
eventSlug: slug,
|
||||||
|
accessVersion: access.accessVersion,
|
||||||
|
expiresAt: access.expiresAt,
|
||||||
participantId,
|
participantId,
|
||||||
name,
|
name,
|
||||||
role: isHost ? "host" : "participant",
|
role: isHost ? "host" : "participant",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
let body: { participantId?: unknown };
|
let body: { participantId?: unknown };
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -29,7 +30,7 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const roomService = getLiveKitRoomService();
|
const roomService = getLiveKitRoomService();
|
||||||
const room = getLiveKitRoomName(slug);
|
const room = getLiveKitRoomName(slug, host.accessVersion);
|
||||||
const participant = (await roomService.listParticipants(room)).find((item) => item.identity === participantId);
|
const participant = (await roomService.listParticipants(room)).find((item) => item.identity === participantId);
|
||||||
if (!participant) return NextResponse.json({ error: "Teilnehmer nicht gefunden." }, { status: 404 });
|
if (!participant) return NextResponse.json({ error: "Teilnehmer nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
let body: { name?: unknown; email?: unknown };
|
let body: { name?: unknown; email?: unknown };
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const AvailabilityOverrides: CollectionConfig = {
|
|||||||
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
|
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: () => true,
|
read: isAdmin,
|
||||||
create: isAdmin,
|
create: isAdmin,
|
||||||
update: isAdmin,
|
update: isAdmin,
|
||||||
delete: isAdmin,
|
delete: isAdmin,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { APIError } from "payload";
|
import { APIError } from "payload";
|
||||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload";
|
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload";
|
||||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||||
|
import { combineDateAndTime } from "@/lib/meeting/status";
|
||||||
import { acquireBookingDayLock } from "@/lib/booking/lock";
|
import { acquireBookingDayLock } from "@/lib/booking/lock";
|
||||||
import {
|
import {
|
||||||
alternativeProposedEmail,
|
alternativeProposedEmail,
|
||||||
@@ -12,7 +14,7 @@ import {
|
|||||||
} from "@/lib/email/bookingTemplates";
|
} from "@/lib/email/bookingTemplates";
|
||||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||||
import type { Customer, Event, Offer } from "@/payload-types";
|
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 = [
|
export const APPOINTMENT_TYPES = [
|
||||||
{ label: "Vor Ort", value: "onsite" },
|
{ 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)));
|
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
|
// Normalize Payload time-only inputs before conflict checks and synchronize
|
||||||
// (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings
|
// the private meeting whenever a booking is moved, cancelled or reactivated.
|
||||||
// inside that same lock/transaction, and — for online appointments — creates
|
export const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
|
||||||
// the linked video-call Event (reusing the existing meeting/password system)
|
const status = data.status ?? originalDoc?.status ?? "pending";
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = data.date ?? originalDoc?.date;
|
const date = data.date ?? originalDoc?.date;
|
||||||
const startTime = data.startTime ?? originalDoc?.startTime;
|
const startTime = data.startTime ?? originalDoc?.startTime;
|
||||||
const endTime = data.endTime ?? originalDoc?.endTime;
|
const endTime = data.endTime ?? originalDoc?.endTime;
|
||||||
const appointmentType = data.appointmentType ?? originalDoc?.appointmentType;
|
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 alternative = data.proposedAlternative;
|
||||||
|
if (alternative && (alternative.date || alternative.startTime || alternative.endTime)) {
|
||||||
const dayBounds = berlinDayBounds(new Date(date));
|
if (!alternative.date || !alternative.startTime || !alternative.endTime) {
|
||||||
const dayStart = dayBounds.start.toISOString();
|
throw new APIError("Bitte den alternativen Termin vollständig angeben.", 400, undefined, true);
|
||||||
const dayEnd = dayBounds.end.toISOString();
|
}
|
||||||
const conflictClauses: Where[] = [
|
const altStart = combineDateAndTime(alternative.date, alternative.startTime);
|
||||||
{ status: { equals: "confirmed" } },
|
const altEnd = combineDateAndTime(alternative.date, alternative.endTime);
|
||||||
{ date: { greater_than_equal: dayStart } },
|
if (!Number.isFinite(altStart.getTime()) || !Number.isFinite(altEnd.getTime()) || altEnd <= altStart) {
|
||||||
{ date: { less_than_equal: dayEnd } },
|
throw new APIError("Der alternative Zeitraum ist ungültig.", 400, undefined, true);
|
||||||
];
|
}
|
||||||
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
|
data.proposedAlternative = { date: altStart.toISOString(), startTime: altStart.toISOString(), endTime: altEnd.toISOString() };
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appointmentType === "online" && !data.linkedEvent && !originalDoc?.linkedEvent) {
|
if (status === "confirmed") {
|
||||||
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc?.offer, req })) as Offer;
|
await acquireBookingDayLock(req, dateKey(data.date));
|
||||||
const event = (await req.payload.create({
|
const conflictClauses: Where[] = [
|
||||||
collection: "events",
|
{ status: { equals: "confirmed" } },
|
||||||
req,
|
{ startTime: { less_than: data.endTime } },
|
||||||
data: {
|
{ endTime: { greater_than: data.startTime } },
|
||||||
title: offer.title,
|
];
|
||||||
date,
|
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
|
||||||
startTime,
|
const { totalDocs } = await req.payload.count({ collection: "booking-requests", where: { and: conflictClauses }, req });
|
||||||
endTime,
|
if (totalDocs) throw new APIError("Dieser Zeitraum ist bereits mit einem bestätigten Termin belegt.", 409, undefined, true);
|
||||||
category: "sonstiges",
|
}
|
||||||
isOnline: true, // Events' own beforeChange hook auto-generates the password.
|
|
||||||
isPrivateBooking: true,
|
const linked = data.linkedEvent ?? originalDoc?.linkedEvent;
|
||||||
// Only set on the update-to-confirmed path — on create-as-confirmed
|
const linkedId = typeof linked === "object" ? linked?.id : linked;
|
||||||
// this booking doesn't have an id yet (assigned after insert), so
|
if (linkedId) {
|
||||||
// the back-reference is simply left blank in that one case.
|
const existing = await req.payload.findByID({ collection: "events", id: linkedId, depth: 0, req });
|
||||||
...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
|
if (existing.isPrivateBooking) {
|
||||||
},
|
const isOnline = status === "confirmed" && appointmentType === "online";
|
||||||
// Created as a draft so it never appears in public /termine listings
|
if (existing.date !== data.date || existing.startTime !== data.startTime || existing.endTime !== data.endTime || existing.isOnline !== isOnline) {
|
||||||
// (see lib/payload/content.ts) — it's still reachable via its direct
|
await req.payload.update({ collection: "events", id: existing.id, req, data: {
|
||||||
// join link, which is exactly what a private 1:1 booking needs.
|
date: data.date, startTime: data.startTime, endTime: data.endTime, isOnline,
|
||||||
draft: true,
|
} });
|
||||||
})) as Event;
|
}
|
||||||
|
}
|
||||||
|
} 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;
|
data.linkedEvent = event.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,7 +170,8 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
|||||||
|
|
||||||
const statusChanged = operation === "create" || previousDoc?.status !== doc.status;
|
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 online: { joinUrl: string; password: string } | undefined;
|
||||||
let onsite: { address: string; mapsUrl: 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") {
|
if (proposedNew && doc.status === "pending") {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
user.email,
|
user.email,
|
||||||
@@ -282,7 +270,7 @@ export const BookingRequests: CollectionConfig = {
|
|||||||
// setting their own status to "confirmed" directly.
|
// setting their own status to "confirmed" directly.
|
||||||
create: isAdmin,
|
create: isAdmin,
|
||||||
read: ({ req }) => {
|
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 } };
|
if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
@@ -290,8 +278,19 @@ export const BookingRequests: CollectionConfig = {
|
|||||||
delete: isAdmin,
|
delete: isAdmin,
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
|
beforeOperation: [async ({ args, operation, req }) => {
|
||||||
|
if (operation === "create" || operation === "update" || operation === "delete") {
|
||||||
|
await acquireBookingDayLock(req, "booking-calendar");
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}],
|
||||||
beforeChange: [handleConfirmation],
|
beforeChange: [handleConfirmation],
|
||||||
afterChange: [notifyByEmail],
|
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: [
|
fields: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
||||||
|
import { APIError } from "payload";
|
||||||
|
import { allowVerificationEmail } from "@/lib/auth/verificationRateLimit";
|
||||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||||
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
|
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
|
||||||
import { verificationEmail } from "@/lib/email/authTemplates";
|
import { verificationEmail } from "@/lib/email/authTemplates";
|
||||||
@@ -35,6 +37,10 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc,
|
|||||||
data.email !== originalDoc.email;
|
data.email !== originalDoc.email;
|
||||||
if (operation !== "create" && !emailChanged) return data;
|
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();
|
const token = generateVerificationToken();
|
||||||
data.emailVerified = false;
|
data.emailVerified = false;
|
||||||
data.emailVerificationTokenHash = hashVerificationToken(token);
|
data.emailVerificationTokenHash = hashVerificationToken(token);
|
||||||
@@ -46,6 +52,7 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc,
|
|||||||
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => {
|
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => {
|
||||||
const token = req.context.pendingVerificationToken;
|
const token = req.context.pendingVerificationToken;
|
||||||
if (typeof token !== "string") return doc;
|
if (typeof token !== "string") return doc;
|
||||||
|
delete req.context.pendingVerificationToken;
|
||||||
try {
|
try {
|
||||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
|
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { isAdmin, isAdminFieldLevel, publishedOrAdmin } from "@/access";
|
|||||||
import { slugField } from "@/fields/slug";
|
import { slugField } from "@/fields/slug";
|
||||||
import { seoFields } from "@/fields/seo";
|
import { seoFields } from "@/fields/seo";
|
||||||
import { generateMeetingPassword } from "@/lib/meeting/password";
|
import { generateMeetingPassword } from "@/lib/meeting/password";
|
||||||
|
import { revokeChangedMeeting } from "@/lib/meeting/revoke";
|
||||||
|
|
||||||
export const EVENT_CATEGORIES = [
|
export const EVENT_CATEGORIES = [
|
||||||
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
||||||
@@ -18,9 +19,9 @@ export const EVENT_CATEGORIES = [
|
|||||||
// Auto-generates the meeting password for new online events, and
|
// Auto-generates the meeting password for new online events, and
|
||||||
// regenerates it whenever the admin ticks "Neues Passwort generieren".
|
// regenerates it whenever the admin ticks "Neues Passwort generieren".
|
||||||
// Never derived from the event id/slug — always a fresh random value.
|
// Never derived from the event id/slug — always a fresh random value.
|
||||||
const setMeetingPassword: CollectionBeforeChangeHook = ({ data }) => {
|
const setMeetingPassword: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
|
||||||
if (!data?.isOnline) return data;
|
if (!(data.isOnline ?? originalDoc?.isOnline)) return data;
|
||||||
if (!data.meetingPassword || data.regeneratePassword) {
|
if (!(data.meetingPassword ?? originalDoc?.meetingPassword) || data.regeneratePassword || originalDoc?.isOnline === false) {
|
||||||
data.meetingPassword = generateMeetingPassword();
|
data.meetingPassword = generateMeetingPassword();
|
||||||
}
|
}
|
||||||
data.regeneratePassword = false;
|
data.regeneratePassword = false;
|
||||||
@@ -52,6 +53,7 @@ export const Events: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeChange: [setMeetingPassword],
|
beforeChange: [setMeetingPassword],
|
||||||
|
afterChange: [revokeChangedMeeting],
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, type FormEvent } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { ResendVerificationButton } from "./ResendVerificationButton";
|
import { ResendVerificationButton } from "./ResendVerificationButton";
|
||||||
import { TextField, FormError } from "@/components/FormField";
|
import { TextField, FormError } from "@/components/FormField";
|
||||||
|
import { safeLoginRedirect } from "@/lib/auth/redirect";
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -32,7 +33,7 @@ export function LoginForm() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(searchParams.get("next") || "/konto");
|
router.push(safeLoginRedirect(searchParams.get("next")));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
|||||||
@@ -3,18 +3,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge";
|
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 { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||||
import type { MeetingStatus } from "@/lib/meeting/status";
|
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 router = useRouter();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const offer = typeof booking.offer === "object" ? (booking.offer as Offer) : null;
|
const offer = booking.offer;
|
||||||
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
|
const event = booking.linkedEvent;
|
||||||
const displayStatus = resolveDisplayStatus(booking.status, booking.date);
|
const displayStatus = resolveDisplayStatus(booking.status, booking.endTime);
|
||||||
const hasAlternative = booking.status === "pending" && Boolean(booking.proposedAlternative?.date);
|
const hasAlternative = booking.status === "pending" && Boolean(booking.proposedAlternative?.date);
|
||||||
|
|
||||||
async function callAction(path: string) {
|
async function callAction(path: string) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function BookingWidget({
|
|||||||
}) {
|
}) {
|
||||||
const [slotsByDay, setSlotsByDay] = useState<SlotsResponse["slots"]>({});
|
const [slotsByDay, setSlotsByDay] = useState<SlotsResponse["slots"]>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||||
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
||||||
const [appointmentType, setAppointmentType] = useState<"onsite" | "online">("onsite");
|
const [appointmentType, setAppointmentType] = useState<"onsite" | "online">("onsite");
|
||||||
@@ -35,12 +36,15 @@ export function BookingWidget({
|
|||||||
const [result, setResult] = useState<{ ok: boolean; error?: string } | null>(null);
|
const [result, setResult] = useState<{ ok: boolean; error?: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
const from = new Date();
|
const from = new Date();
|
||||||
const to = new Date(from.getTime() + 28 * 24 * 60 * 60 * 1000);
|
const to = new Date(from.getTime() + 28 * 24 * 60 * 60 * 1000);
|
||||||
fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`)
|
fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`, { signal: controller.signal })
|
||||||
.then((res) => res.json())
|
.then((res) => { if (!res.ok) throw new Error("Slots unavailable"); return res.json(); })
|
||||||
.then((data: SlotsResponse) => setSlotsByDay(data.slots ?? {}))
|
.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]);
|
}, [offerSlug]);
|
||||||
|
|
||||||
if (result?.ok) {
|
if (result?.ok) {
|
||||||
@@ -126,11 +130,12 @@ export function BookingWidget({
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="text-sm text-anouma-plum/70">Verfügbare Termine werden geladen …</p>;
|
return <p className="text-sm text-anouma-plum/70">Verfügbare Termine werden geladen …</p>;
|
||||||
}
|
}
|
||||||
|
if (loadError) return <FormError>Verfügbare Termine konnten nicht geladen werden. Bitte lade die Seite erneut.</FormError>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,340px)_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,340px)_1fr]">
|
||||||
<MonthCalendar markers={markers} onSelectDay={setSelectedDay} selectedDay={selectedDay} />
|
<MonthCalendar markers={markers} onSelectDay={(day) => { setSelectedDay(day); setSelectedSlot(null); }} selectedDay={selectedDay} />
|
||||||
<div>
|
<div>
|
||||||
{!selectedDay && <p className="text-sm text-anouma-plum/70">Wähle einen markierten Tag, um freie Zeiten zu sehen.</p>}
|
{!selectedDay && <p className="text-sm text-anouma-plum/70">Wähle einen markierten Tag, um freie Zeiten zu sehen.</p>}
|
||||||
{selectedDay && daySlots.length === 0 && (
|
{selectedDay && daySlots.length === 0 && (
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import { useState } from "react";
|
|||||||
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
|
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
|
||||||
import { BookingCard } from "./BookingCard";
|
import { BookingCard } from "./BookingCard";
|
||||||
import { resolveDisplayStatus } from "./BookingStatusBadge";
|
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 { berlinDateKey } from "@/lib/calendar/timezone";
|
||||||
import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess";
|
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<Date | null>(null);
|
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||||
|
|
||||||
const markers: CalendarMarker[] = bookings.map((b) => ({
|
const markers: CalendarMarker[] = bookings.map((b) => ({
|
||||||
date: new Date(b.date),
|
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
|
const selectedBookings = selectedDay
|
||||||
|
|||||||
@@ -171,9 +171,9 @@ export function CallRoom({ slug }: { slug: string }) {
|
|||||||
localTracksRef.current = previewTracks;
|
localTracksRef.current = previewTracks;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let serverUrl = session.serverUrl;
|
let serverUrl: string;
|
||||||
let participantToken = session.participantToken;
|
let participantToken: string;
|
||||||
if (!serverUrl || !participantToken) {
|
{
|
||||||
const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/connect`, {
|
const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/connect`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${session.token}` },
|
headers: { Authorization: `Bearer ${session.token}` },
|
||||||
|
|||||||
@@ -13,16 +13,17 @@ const MAX_TRACKED_KEYS = 5000;
|
|||||||
|
|
||||||
function sweepExpired(now: number) {
|
function sweepExpired(now: number) {
|
||||||
for (const [key, bucket] of buckets) {
|
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 {
|
export function checkRateLimit(key: string, opts: { max: number; windowMs: number }): boolean {
|
||||||
const now = Date.now();
|
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);
|
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 });
|
buckets.set(key, { count: 1, resetAt: now + opts.windowMs });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -31,7 +32,7 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe
|
|||||||
return true;
|
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 is always behind exactly one trusted reverse proxy (see
|
||||||
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
|
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
|
||||||
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
|
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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<typeof customerBookingView>;
|
||||||
+3
-2
@@ -13,13 +13,14 @@ import type { PayloadRequest } from "payload";
|
|||||||
*/
|
*/
|
||||||
export async function acquireBookingDayLock(req: PayloadRequest, dateKey: string): Promise<void> {
|
export async function acquireBookingDayLock(req: PayloadRequest, dateKey: string): Promise<void> {
|
||||||
const txID = req.transactionID ? String(await req.transactionID) : undefined;
|
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 {
|
const adapter = req.payload.db as unknown as {
|
||||||
sessions?: Record<string, { db: { execute: (query: unknown) => Promise<unknown> } }>;
|
sessions?: Record<string, { db: { execute: (query: unknown) => Promise<unknown> } }>;
|
||||||
drizzle: { execute: (query: unknown) => Promise<unknown> };
|
drizzle: { execute: (query: unknown) => Promise<unknown> };
|
||||||
};
|
};
|
||||||
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}))`);
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${dateKey}))`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, type MeetingStatus } from "@/lib/meeting/status";
|
||||||
|
|
||||||
export function getBookingMeetingStatus(booking: BookingRequest, settings: MeetingSetting): MeetingStatus | null {
|
export function getBookingMeetingStatus(booking: CustomerBooking, settings: MeetingSetting): MeetingStatus | null {
|
||||||
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
|
const event = booking.linkedEvent;
|
||||||
if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null;
|
if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null;
|
||||||
|
|
||||||
const start = combineDateAndTime(booking.date, booking.startTime);
|
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);
|
const status = getBookingMeetingStatus(booking, settings);
|
||||||
return status !== null && canJoinMeeting(status);
|
return status !== null && canJoinMeeting(status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { getCMS } from "@/lib/payload/getPayload";
|
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
|
* These are called only after the caller has already verified who the
|
||||||
* current customer is (see lib/auth/customer.ts) — the `where: { user }`
|
* current customer is (see lib/auth/customer.ts) — the `where: { user }`
|
||||||
* filter below is what actually scopes results to that one customer.
|
* filter below is what actually scopes results to that one customer.
|
||||||
*/
|
*/
|
||||||
export const getCustomerBookings = cache(async (customerId: number): Promise<BookingRequest[]> => {
|
export const getCustomerBookings = cache(async (customerId: number): Promise<CustomerBooking[]> => {
|
||||||
const payload = await getCMS();
|
const payload = await getCMS();
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: "booking-requests",
|
collection: "booking-requests",
|
||||||
@@ -16,10 +16,10 @@ export const getCustomerBookings = cache(async (customerId: number): Promise<Boo
|
|||||||
depth: 2,
|
depth: 2,
|
||||||
limit: 200,
|
limit: 200,
|
||||||
});
|
});
|
||||||
return result.docs;
|
return result.docs.map(customerBookingView);
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function getCustomerBookingById(customerId: number, id: number): Promise<BookingRequest | null> {
|
export async function getCustomerBookingById(customerId: number, id: number): Promise<CustomerBooking | null> {
|
||||||
const payload = await getCMS();
|
const payload = await getCMS();
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: "booking-requests",
|
collection: "booking-requests",
|
||||||
@@ -27,5 +27,5 @@ export async function getCustomerBookingById(customerId: number, id: number): Pr
|
|||||||
depth: 2,
|
depth: 2,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
return result.docs[0] ?? null;
|
return result.docs[0] ? customerBookingView(result.docs[0]) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-8
@@ -1,4 +1,4 @@
|
|||||||
import type { Payload } from "payload";
|
import type { Payload, PayloadRequest } from "payload";
|
||||||
import { WEEKDAYS } from "@/collections/Availability";
|
import { WEEKDAYS } from "@/collections/Availability";
|
||||||
import { berlinParts, berlinWallTimeToUTC, type CalendarDate } from "@/lib/calendar/timezone";
|
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(
|
export async function getAvailableSlots(
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
args: { durationMinutes: number; from: Date; to: Date },
|
args: { durationMinutes: number; from: Date; to: Date; req?: PayloadRequest },
|
||||||
): Promise<Map<string, TimeSlot[]>> {
|
): Promise<Map<string, TimeSlot[]>> {
|
||||||
const { durationMinutes, from, to } = args;
|
const { durationMinutes, from, to, req } = args;
|
||||||
const result = new Map<string, TimeSlot[]>();
|
const result = new Map<string, TimeSlot[]>();
|
||||||
|
if (!Number.isInteger(durationMinutes) || durationMinutes <= 0 || durationMinutes > 1440 ||
|
||||||
|
!Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || to < from ||
|
||||||
|
to.getTime() - from.getTime() > 63 * 86400000) return result;
|
||||||
|
|
||||||
const [{ docs: weekly }, { docs: overrides }, { docs: busyBookings }] = await Promise.all([
|
const [{ docs: weekly }, { docs: overrides }, { docs: busyBookings }] = await Promise.all([
|
||||||
payload.find({ collection: "availability", where: { active: { equals: true } }, limit: 100 }),
|
payload.find({ collection: "availability", where: { active: { equals: true } }, pagination: false, req }),
|
||||||
payload.find({
|
payload.find({
|
||||||
collection: "availability-overrides",
|
collection: "availability-overrides",
|
||||||
where: { and: [{ date: { greater_than_equal: from.toISOString() } }, { date: { less_than_equal: to.toISOString() } }] },
|
where: { and: [{ date: { greater_than_equal: from.toISOString() } }, { date: { less_than_equal: to.toISOString() } }] },
|
||||||
limit: 200,
|
pagination: false, req,
|
||||||
}),
|
}),
|
||||||
payload.find({
|
payload.find({
|
||||||
collection: "booking-requests",
|
collection: "booking-requests",
|
||||||
@@ -84,7 +87,7 @@ export async function getAvailableSlots(
|
|||||||
{ date: { less_than_equal: to.toISOString() } },
|
{ date: { less_than_equal: to.toISOString() } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
limit: 500,
|
pagination: false, req,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -131,11 +134,11 @@ export async function getAvailableSlots(
|
|||||||
const slotEnd = atMinutesOfDay(day, start + durationMinutes);
|
const slotEnd = atMinutesOfDay(day, start + durationMinutes);
|
||||||
if (slotStart < now) continue;
|
if (slotStart < now) continue;
|
||||||
if (dayBusy.some((b) => overlaps(slotStart, slotEnd, b.start, b.end))) continue;
|
if (dayBusy.some((b) => overlaps(slotStart, slotEnd, b.start, b.end))) continue;
|
||||||
slots.push({ start: slotStart, end: slotEnd });
|
if (!slots.some((slot) => slot.start.getTime() === slotStart.getTime())) slots.push({ start: slotStart, end: slotEnd });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slots.length > 0) result.set(key, slots);
|
if (slots.length > 0) result.set(key, slots.sort((a, b) => a.start.getTime() - b.start.getTime()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { commitTransaction, createLocalReq, initTransaction, killTransaction, type Payload, type PayloadRequest } from "payload";
|
||||||
|
import { acquireBookingDayLock } from "./lock";
|
||||||
|
|
||||||
|
export async function withBookingTransaction<T>(payload: Payload, key: string, action: (req: PayloadRequest) => Promise<T>): Promise<T> {
|
||||||
|
const req = await createLocalReq({}, payload);
|
||||||
|
try {
|
||||||
|
if (!await initTransaction(req)) throw new Error("Booking transaction unavailable");
|
||||||
|
await acquireBookingDayLock(req, "booking-calendar");
|
||||||
|
await acquireBookingDayLock(req, key);
|
||||||
|
const result = await action(req);
|
||||||
|
await commitTransaction(req);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await killTransaction(req);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,15 @@ const BERLIN_TZ = "Europe/Berlin";
|
|||||||
|
|
||||||
export type CalendarDate = { year: number; month: number; day: number };
|
export type CalendarDate = { year: number; month: number; day: number };
|
||||||
|
|
||||||
|
/** Strict calendar input, interpreted in Berlin regardless of server TZ. */
|
||||||
|
export function parseBerlinDate(value: string): Date | null {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||||
|
const [year, month, day] = value.split("-").map(Number);
|
||||||
|
if (year < 1000 || month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||||
|
const date = berlinWallTimeToUTC({ year, month, day }, 0, 0);
|
||||||
|
return berlinDateKey(date) === value ? date : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function berlinDateKey(date: Date): string {
|
export function berlinDateKey(date: Date): string {
|
||||||
const { year, month, day } = berlinParts(date);
|
const { year, month, day } = berlinParts(date);
|
||||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export function getMailTransport(): nodemailer.Transporter {
|
|||||||
host: SMTP_HOST,
|
host: SMTP_HOST,
|
||||||
port: Number(SMTP_PORT),
|
port: Number(SMTP_PORT),
|
||||||
secure: Number(SMTP_PORT) === 465,
|
secure: Number(SMTP_PORT) === 465,
|
||||||
|
connectionTimeout: 10000,
|
||||||
|
greetingTimeout: 10000,
|
||||||
|
socketTimeout: 20000,
|
||||||
auth: { user: SMTP_USER, pass: SMTP_PASSWORD },
|
auth: { user: SMTP_USER, pass: SMTP_PASSWORD },
|
||||||
});
|
});
|
||||||
return cached;
|
return cached;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import type { Event, MeetingSetting } from "@/payload-types";
|
||||||
|
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "./status";
|
||||||
|
import type { MeetingRole } from "./token";
|
||||||
|
|
||||||
|
export function meetingAccessVersion(event: Event): string {
|
||||||
|
const secret = process.env.MEETING_SESSION_SECRET;
|
||||||
|
if (!secret) throw new Error("MEETING_SESSION_SECRET is not set");
|
||||||
|
return createHmac("sha256", secret).update(JSON.stringify([
|
||||||
|
event.id, event.slug, event.meetingPassword, event.date, event.startTime,
|
||||||
|
event.endTime, event.isOnline, event._status, event.isPrivateBooking,
|
||||||
|
])).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meetingWindow(event: Event, settings: MeetingSetting, role: MeetingRole, now = new Date()) {
|
||||||
|
const start = combineDateAndTime(event.date, event.startTime);
|
||||||
|
const end = event.endTime ? combineDateAndTime(event.date, event.endTime) : new Date(start.getTime() + 3600000);
|
||||||
|
const closeAt = end.getTime() + settings.meetingCloseAfterMinutes * 60000;
|
||||||
|
const status = getMeetingStatus({ start, end,
|
||||||
|
joinWindowMinutes: role === "host" ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
|
||||||
|
closeAfterMinutes: settings.meetingCloseAfterMinutes, now });
|
||||||
|
return { allowed: Number.isFinite(closeAt) && end > start && canJoinMeeting(status), closeAt, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by every issuer: existing credentials are not a substitute for current access. */
|
||||||
|
export async function authorizeMeeting(payload: Payload, event: Event, role: MeetingRole) {
|
||||||
|
if (!event.isOnline || (!event.isPrivateBooking && event._status !== "published" && role !== "host")) return null;
|
||||||
|
if (event.isPrivateBooking) {
|
||||||
|
const { docs } = await payload.find({ collection: "booking-requests", depth: 0, limit: 1,
|
||||||
|
where: { and: [
|
||||||
|
{ linkedEvent: { equals: event.id } }, { status: { equals: "confirmed" } },
|
||||||
|
{ appointmentType: { equals: "online" } },
|
||||||
|
] },
|
||||||
|
});
|
||||||
|
if (!docs[0]) return null;
|
||||||
|
}
|
||||||
|
const settings = await payload.findGlobal({ slug: "meeting-settings" });
|
||||||
|
const window = meetingWindow(event, settings, role);
|
||||||
|
if (!window.allowed) return null;
|
||||||
|
return { accessVersion: meetingAccessVersion(event), expiresAt: window.closeAt };
|
||||||
|
}
|
||||||
+10
-4
@@ -17,9 +17,9 @@ function getLiveKitApiUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Stable opaque room name; the public event slug is never sent to LiveKit logs. */
|
/** Stable opaque room name; the public event slug is never sent to LiveKit logs. */
|
||||||
export function getLiveKitRoomName(eventSlug: string): string {
|
export function getLiveKitRoomName(eventSlug: string, accessVersion?: string): string {
|
||||||
const secret = requiredEnv("LIVEKIT_API_SECRET");
|
const secret = requiredEnv("LIVEKIT_API_SECRET");
|
||||||
return `anouma-${createHmac("sha256", secret).update(eventSlug).digest("hex").slice(0, 32)}`;
|
return `anouma-${createHmac("sha256", secret).update(JSON.stringify([eventSlug, accessVersion])).digest("hex").slice(0, 32)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLiveKitToken(args: {
|
export async function createLiveKitToken(args: {
|
||||||
@@ -28,12 +28,18 @@ export async function createLiveKitToken(args: {
|
|||||||
name: string;
|
name: string;
|
||||||
role: MeetingRole;
|
role: MeetingRole;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
|
accessVersion?: string;
|
||||||
|
expiresAt: number;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const room = getLiveKitRoomName(args.eventSlug);
|
const room = getLiveKitRoomName(args.eventSlug, args.accessVersion);
|
||||||
|
const remainingSeconds = Math.floor((args.expiresAt - Date.now()) / 1000);
|
||||||
|
if (remainingSeconds <= 0) throw new Error("Meeting access has expired");
|
||||||
const token = new AccessToken(requiredEnv("LIVEKIT_API_KEY"), requiredEnv("LIVEKIT_API_SECRET"), {
|
const token = new AccessToken(requiredEnv("LIVEKIT_API_KEY"), requiredEnv("LIVEKIT_API_SECRET"), {
|
||||||
identity: args.participantId,
|
identity: args.participantId,
|
||||||
name: args.name,
|
name: args.name,
|
||||||
ttl: "2h",
|
// Self-hosted LiveKit cannot revoke already issued JWTs. Keep the
|
||||||
|
// connection credential short-lived; the app re-authorizes reconnects.
|
||||||
|
ttl: Math.min(60, remainingSeconds),
|
||||||
metadata: JSON.stringify({ role: args.role, clientId: args.clientId }),
|
metadata: JSON.stringify({ role: args.role, clientId: args.clientId }),
|
||||||
});
|
});
|
||||||
token.addGrant({
|
token.addGrant({
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { CollectionAfterChangeHook } from "payload";
|
||||||
|
import type { Event } from "@/payload-types";
|
||||||
|
import { meetingAccessVersion } from "./access";
|
||||||
|
import { getLiveKitRoomName, getLiveKitRoomService } from "./livekit";
|
||||||
|
|
||||||
|
/** Disconnect the previous room when its password, schedule or access changes. */
|
||||||
|
export const revokeChangedMeeting: CollectionAfterChangeHook<Event> = async ({ doc, previousDoc, req }) => {
|
||||||
|
if (!previousDoc?.isOnline || !previousDoc.slug || !process.env.LIVEKIT_URL || !process.env.MEETING_SESSION_SECRET) return doc;
|
||||||
|
const previousVersion = meetingAccessVersion(previousDoc);
|
||||||
|
if (previousVersion === meetingAccessVersion(doc)) return doc;
|
||||||
|
try {
|
||||||
|
const service = getLiveKitRoomService();
|
||||||
|
const name = getLiveKitRoomName(previousDoc.slug, previousVersion);
|
||||||
|
if ((await service.listRooms([name])).length) await service.deleteRoom(name);
|
||||||
|
} catch (err) {
|
||||||
|
req.payload.logger.error({ err, msg: "Could not disconnect revoked meeting room" });
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
};
|
||||||
@@ -36,6 +36,8 @@ export function getMeetingStatus({
|
|||||||
const closeAt = end.getTime() + closeAfterMinutes * 60_000;
|
const closeAt = end.getTime() + closeAfterMinutes * 60_000;
|
||||||
const t = now.getTime();
|
const t = now.getTime();
|
||||||
|
|
||||||
|
if (![openAt, closeAt, t].every(Number.isFinite) || end <= start) return "ended";
|
||||||
|
|
||||||
if (t >= closeAt) return "ended";
|
if (t >= closeAt) return "ended";
|
||||||
if (t >= start.getTime()) return "live";
|
if (t >= start.getTime()) return "live";
|
||||||
if (t >= openAt) return "open";
|
if (t >= openAt) return "open";
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type MeetingTokenPayload = {
|
|||||||
clientId?: string;
|
clientId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: MeetingRole;
|
role: MeetingRole;
|
||||||
|
accessVersion?: string;
|
||||||
|
testRoom?: boolean;
|
||||||
exp: number; // unix ms
|
exp: number; // unix ms
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ export function createMeetingToken(args: {
|
|||||||
role: MeetingRole;
|
role: MeetingRole;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
ttlMs?: number;
|
ttlMs?: number;
|
||||||
|
accessVersion?: string;
|
||||||
|
testRoom?: boolean;
|
||||||
}): { token: string; participantId: string } {
|
}): { token: string; participantId: string } {
|
||||||
const participantId = randomUUID();
|
const participantId = randomUUID();
|
||||||
const clientId =
|
const clientId =
|
||||||
@@ -53,6 +57,8 @@ export function createMeetingToken(args: {
|
|||||||
clientId,
|
clientId,
|
||||||
name: args.name,
|
name: args.name,
|
||||||
role: args.role,
|
role: args.role,
|
||||||
|
accessVersion: args.accessVersion,
|
||||||
|
testRoom: args.testRoom,
|
||||||
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
|
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
|
||||||
};
|
};
|
||||||
const body = base64url(JSON.stringify(payload));
|
const body = base64url(JSON.stringify(payload));
|
||||||
@@ -76,7 +82,9 @@ export function verifyMeetingToken(token: unknown): MeetingTokenPayload | null {
|
|||||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload;
|
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload;
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(payload.exp) ||
|
!Number.isFinite(payload.exp) ||
|
||||||
payload.exp < Date.now() ||
|
payload.exp <= Date.now() ||
|
||||||
|
(payload.accessVersion !== undefined && !/^[0-9a-f]{64}$/.test(payload.accessVersion)) ||
|
||||||
|
(payload.testRoom !== undefined && typeof payload.testRoom !== "boolean") ||
|
||||||
typeof payload.eventSlug !== "string" ||
|
typeof payload.eventSlug !== "string" ||
|
||||||
payload.eventSlug.length > 200 ||
|
payload.eventSlug.length > 200 ||
|
||||||
typeof payload.participantId !== "string" ||
|
typeof payload.participantId !== "string" ||
|
||||||
|
|||||||
Generated
+3
-392
@@ -566,395 +566,6 @@
|
|||||||
"source-map-support": "^0.5.21"
|
"source-map-support": "^0.5.21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/core-utils/node_modules/esbuild": {
|
|
||||||
"version": "0.18.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
|
|
||||||
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/android-arm": "0.18.20",
|
|
||||||
"@esbuild/android-arm64": "0.18.20",
|
|
||||||
"@esbuild/android-x64": "0.18.20",
|
|
||||||
"@esbuild/darwin-arm64": "0.18.20",
|
|
||||||
"@esbuild/darwin-x64": "0.18.20",
|
|
||||||
"@esbuild/freebsd-arm64": "0.18.20",
|
|
||||||
"@esbuild/freebsd-x64": "0.18.20",
|
|
||||||
"@esbuild/linux-arm": "0.18.20",
|
|
||||||
"@esbuild/linux-arm64": "0.18.20",
|
|
||||||
"@esbuild/linux-ia32": "0.18.20",
|
|
||||||
"@esbuild/linux-loong64": "0.18.20",
|
|
||||||
"@esbuild/linux-mips64el": "0.18.20",
|
|
||||||
"@esbuild/linux-ppc64": "0.18.20",
|
|
||||||
"@esbuild/linux-riscv64": "0.18.20",
|
|
||||||
"@esbuild/linux-s390x": "0.18.20",
|
|
||||||
"@esbuild/linux-x64": "0.18.20",
|
|
||||||
"@esbuild/netbsd-x64": "0.18.20",
|
|
||||||
"@esbuild/openbsd-x64": "0.18.20",
|
|
||||||
"@esbuild/sunos-x64": "0.18.20",
|
|
||||||
"@esbuild/win32-arm64": "0.18.20",
|
|
||||||
"@esbuild/win32-ia32": "0.18.20",
|
|
||||||
"@esbuild/win32-x64": "0.18.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild-kit/esm-loader": {
|
"node_modules/@esbuild-kit/esm-loader": {
|
||||||
"version": "2.6.5",
|
"version": "2.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz",
|
||||||
@@ -5999,9 +5610,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.8",
|
"version": "3.4.15",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
|
||||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
"integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@@ -46,5 +46,11 @@
|
|||||||
"eslint-config-next": "^16.3.5",
|
"eslint-config-next": "^16.3.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"dompurify": "^3.4.15",
|
||||||
|
"@esbuild-kit/core-utils": {
|
||||||
|
"esbuild": "^0.25.12"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import type { Payload, PayloadRequest } from "payload";
|
||||||
|
import type { BookingRequest, Event, MeetingSetting } from "../payload-types";
|
||||||
|
import { customerBookingView } from "../lib/booking/customerView";
|
||||||
|
import { authorizeMeeting, meetingAccessVersion, meetingWindow } from "../lib/meeting/access";
|
||||||
|
import { createMeetingToken, verifyMeetingToken } from "../lib/meeting/token";
|
||||||
|
import { createLiveKitToken, getLiveKitRoomName } from "../lib/meeting/livekit";
|
||||||
|
import { allowVerificationEmail } from "../lib/auth/verificationRateLimit";
|
||||||
|
import { safeLoginRedirect } from "../lib/auth/redirect";
|
||||||
|
import { parseBerlinDate, berlinDayBounds } from "../lib/calendar/timezone";
|
||||||
|
import { getAvailableSlots } from "../lib/booking/slots";
|
||||||
|
import { handleConfirmation } from "../collections/BookingRequests";
|
||||||
|
import { Events } from "../collections/Events";
|
||||||
|
import { AvailabilityOverrides } from "../collections/AvailabilityOverrides";
|
||||||
|
|
||||||
|
process.env.MEETING_SESSION_SECRET = "test-only-meeting-secret";
|
||||||
|
process.env.LIVEKIT_API_KEY = "test-api-key";
|
||||||
|
process.env.LIVEKIT_API_SECRET = "test-only-livekit-secret-long-enough";
|
||||||
|
|
||||||
|
const settings = { hostJoinWindowMinutes: 30, participantJoinWindowMinutes: 10, meetingCloseAfterMinutes: 10 } as MeetingSetting;
|
||||||
|
const event = { id: 1, slug: "private-call", title: "Fixture", date: "2027-07-01T10:00:00.000Z", startTime: "2027-07-01T10:00:00.000Z", endTime: "2027-07-01T11:00:00.000Z", isOnline: true, _status: "published", meetingPassword: "fixture" } as Event;
|
||||||
|
|
||||||
|
test("customer props exclude private fields, even inside populated relationships", () => {
|
||||||
|
const booking = { id: 1, internalNote: "SECRET_NOTE", user: { emailVerificationTokenHash: "SECRET_HASH" },
|
||||||
|
offer: { title: "Consultation", internalNote: "SECRET_OFFER" },
|
||||||
|
linkedEvent: { slug: "private-call", isOnline: true, meetingPassword: "SECRET_PASSWORD", bookingRequest: { internalNote: "SECRET_NESTED" } },
|
||||||
|
proposedAlternative: { date: "2027-07-01", internalNote: "SECRET_ALTERNATIVE" },
|
||||||
|
} as unknown as BookingRequest;
|
||||||
|
const serialized = JSON.stringify(customerBookingView(booking));
|
||||||
|
assert.ok(!serialized.includes("SECRET"));
|
||||||
|
assert.ok(serialized.includes("Consultation"));
|
||||||
|
assert.ok(serialized.includes("private-call"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closed and malformed meeting windows never grant access", () => {
|
||||||
|
assert.equal(meetingWindow(event, settings, "participant", new Date("2027-07-01T11:10:00Z")).allowed, false);
|
||||||
|
assert.equal(meetingWindow({ ...event, endTime: "invalid" }, settings, "participant").allowed, false);
|
||||||
|
assert.equal(meetingWindow(event, settings, "participant", new Date("2027-07-01T09:49:59Z")).allowed, false);
|
||||||
|
assert.equal(meetingWindow(event, settings, "participant", new Date("2027-07-01T10:00:00Z")).allowed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelled private meetings are rejected using the current booking status", async () => {
|
||||||
|
let queried = false;
|
||||||
|
const payload = { find: async (args: { where: unknown }) => {
|
||||||
|
queried = true;
|
||||||
|
assert.ok(JSON.stringify(args.where).includes('"confirmed"'));
|
||||||
|
return { docs: [] };
|
||||||
|
} } as unknown as Payload;
|
||||||
|
assert.equal(await authorizeMeeting(payload, { ...event, isPrivateBooking: true }, "participant"), null);
|
||||||
|
assert.equal(queried, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("password and schedule changes invalidate credentials and isolate the new room", () => {
|
||||||
|
const version = meetingAccessVersion(event);
|
||||||
|
const changed = meetingAccessVersion({ ...event, meetingPassword: "replacement" });
|
||||||
|
assert.notEqual(version, changed);
|
||||||
|
assert.notEqual(version, meetingAccessVersion({ ...event, endTime: "2027-07-01T12:00:00Z" }));
|
||||||
|
assert.notEqual(getLiveKitRoomName(event.slug!, version), getLiveKitRoomName(event.slug!, changed));
|
||||||
|
const issued = createMeetingToken({ eventSlug: event.slug!, name: "Guest", role: "participant", accessVersion: version });
|
||||||
|
assert.equal(verifyMeetingToken(issued.token)?.accessVersion, version);
|
||||||
|
const expired = createMeetingToken({ eventSlug: event.slug!, name: "Guest", role: "participant", ttlMs: 0 });
|
||||||
|
assert.equal(verifyMeetingToken(expired.token), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("LiveKit connection credentials expire within the allowed window and one minute", async () => {
|
||||||
|
const deadline = Date.now() + 30000;
|
||||||
|
const jwt = await createLiveKitToken({ eventSlug: "fixture", participantId: "guest", name: "Guest", role: "participant", expiresAt: deadline });
|
||||||
|
const claims = JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString());
|
||||||
|
assert.ok(claims.exp * 1000 <= deadline);
|
||||||
|
assert.ok(claims.exp - claims.nbf <= 60);
|
||||||
|
assert.equal(claims.video.roomAdmin, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verification budgets apply across operations, address casing and changing IPs", () => {
|
||||||
|
for (let i = 0; i < 3; i++) assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": `fixture-${i}` }) }, "Target@example.com"), true);
|
||||||
|
assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": "fixture-new" }) }, "target@EXAMPLE.com"), false);
|
||||||
|
for (let i = 0; i < 3; i++) assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": `account-${i}` }) }, `target${i}@example.com`, 17), true);
|
||||||
|
assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": "account-new" }) }, "fresh@example.com", 17), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login rejects external, script and backslash redirects", () => {
|
||||||
|
for (const value of ["javascript:alert(1)", "https://other.invalid", "//other.invalid", "/\\other.invalid", "/\n/other.invalid", null]) assert.equal(safeLoginRedirect(value), "/konto");
|
||||||
|
assert.equal(safeLoginRedirect("/angebote/beratung?x=1"), "/angebote/beratung?x=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("slot date parsing uses Berlin boundaries and rejects impossible calendar dates", () => {
|
||||||
|
assert.equal(parseBerlinDate("2027-07-01")?.toISOString(), "2027-06-30T22:00:00.000Z");
|
||||||
|
assert.equal(parseBerlinDate("2027-02-30"), null);
|
||||||
|
assert.equal(parseBerlinDate("2027-7-1"), null);
|
||||||
|
assert.equal(berlinDayBounds(parseBerlinDate("2027-03-28")!).end.getTime() - parseBerlinDate("2027-03-28")!.getTime() + 1, 23 * 3600000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid slot durations terminate safely without querying the database", async () => {
|
||||||
|
const payload = { find: () => { throw new Error("Unexpected query"); } } as unknown as Payload;
|
||||||
|
for (const durationMinutes of [0, -1, NaN, Infinity, 0.5]) {
|
||||||
|
assert.equal((await getAvailableSlots(payload, { durationMinutes, from: new Date(), to: new Date() })).size, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public users cannot read absence reasons", () => {
|
||||||
|
const read = AvailabilityOverrides.access!.read!;
|
||||||
|
assert.equal(read({ req: { user: null } } as Parameters<typeof read>[0]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function hookArgs(data: Record<string, unknown>, originalDoc: Record<string, unknown>, payload: Record<string, unknown>) {
|
||||||
|
return { data, originalDoc, req: { transactionID: "fixture", payload: { ...payload,
|
||||||
|
db: { sessions: { fixture: { db: { execute: async () => undefined } } } },
|
||||||
|
} } as unknown as PayloadRequest } as Parameters<typeof handleConfirmation>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
test("rescheduling a confirmed booking checks conflicts instead of silently overlapping", async () => {
|
||||||
|
const original = { id: 1, status: "confirmed", appointmentType: "onsite", date: event.date, startTime: event.startTime, endTime: event.endTime };
|
||||||
|
await assert.rejects(() => handleConfirmation(hookArgs({ startTime: "2027-07-01T10:30:00Z" }, original, {
|
||||||
|
count: async () => ({ totalDocs: 1 }),
|
||||||
|
})), { status: 409 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelling a private booking disables its event", async () => {
|
||||||
|
let updated = false;
|
||||||
|
await handleConfirmation(hookArgs({ status: "cancelled" }, { id: 1, status: "confirmed", appointmentType: "online", date: event.date, startTime: event.startTime, endTime: event.endTime, linkedEvent: 2 }, {
|
||||||
|
findByID: async () => ({ ...event, id: 2, isPrivateBooking: true }),
|
||||||
|
update: async (args: { data: { isOnline: boolean } }) => { assert.equal(args.data.isOnline, false); updated = true; },
|
||||||
|
}));
|
||||||
|
assert.equal(updated, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated bookings for one offer get distinct private meeting slugs", async () => {
|
||||||
|
const slugs: string[] = [];
|
||||||
|
const payload = { count: async () => ({ totalDocs: 0 }), findByID: async () => ({ title: "Same offer" }),
|
||||||
|
create: async (args: { data: { slug: string } }) => { slugs.push(args.data.slug); return { id: slugs.length }; },
|
||||||
|
};
|
||||||
|
for (let i = 0; i < 2; i++) await handleConfirmation(hookArgs({ status: "confirmed", offer: 1, appointmentType: "online", date: event.date, startTime: event.startTime, endTime: event.endTime }, {}, payload));
|
||||||
|
assert.equal(new Set(slugs).size, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ordinary event edits preserve the password; explicit regeneration changes it", async () => {
|
||||||
|
const hook = Events.hooks!.beforeChange![0];
|
||||||
|
const unchanged = await hook({ data: { isOnline: true }, originalDoc: event } as unknown as Parameters<typeof hook>[0]);
|
||||||
|
assert.equal(unchanged.meetingPassword, undefined);
|
||||||
|
const changed = await hook({ data: { regeneratePassword: true }, originalDoc: event } as unknown as Parameters<typeof hook>[0]);
|
||||||
|
assert.ok(changed.meetingPassword);
|
||||||
|
assert.notEqual(changed.meetingPassword, event.meetingPassword);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user