Integrate LiveKit SFU and harden booking workflow
This commit is contained in:
+8
-7
@@ -51,13 +51,14 @@ SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM="ANOUMA <support@anouma.org>"
|
||||
|
||||
# ── WebRTC (STUN/TURN) ──────────────────────────────────────────────────
|
||||
# P2P works with just STUN for most networks. Add a TURN server later for
|
||||
# restrictive NATs/firewalls — no code changes needed, just set these.
|
||||
STUN_SERVER=stun:stun.l.google.com:19302
|
||||
TURN_SERVER=
|
||||
TURN_USERNAME=
|
||||
TURN_PASSWORD=
|
||||
# ── LiveKit SFU / embedded TURN ─────────────────────────────────────────
|
||||
# Public HTTPS endpoint of the self-hosted LiveKit server. The app converts
|
||||
# https:// to wss:// for browsers and uses the same endpoint for its API.
|
||||
# Put a trusted TLS reverse proxy in front of LiveKit (normally on :7880).
|
||||
LIVEKIT_URL=https://livekit.example.com
|
||||
# Keep these values private. setup.sh generates them automatically.
|
||||
LIVEKIT_API_KEY=LKreplace-with-setup-secret
|
||||
LIVEKIT_API_SECRET=replace-with-setup-secret
|
||||
|
||||
# ── SEO ──────────────────────────────────────────────────────────────────
|
||||
# Optional Google Search Console domain-verification token (Search Console →
|
||||
|
||||
+42
-19
@@ -130,26 +130,49 @@ In Nginx Proxy Manager, add a Proxy Host:
|
||||
| Scheme | `http` |
|
||||
| Forward Host | `app` (the Compose service name) |
|
||||
| Forward Port | `3000` |
|
||||
| **Websockets Support** | **enabled** — required for the video-call signaling (`/ws/signaling`) |
|
||||
| **Websockets Support** | **enabled** — required for the LiveKit signal connection |
|
||||
|
||||
## WebRTC: signaling, STUN, TURN
|
||||
Für LiveKit legst du zusätzlich einen zweiten Proxy Host an:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Domain | z. B. `livekit.anouma.org` |
|
||||
| Scheme | `http` |
|
||||
| Forward Host | der Server, auf dem LiveKit läuft |
|
||||
| Forward Port | `7880` |
|
||||
| **Websockets Support** | **enabled** |
|
||||
|
||||
In DNS muss die LiveKit-Domain auf denselben Server zeigen. Das Zertifikat
|
||||
muss von einer öffentlichen CA stammen; selbst signierte Zertifikate werden
|
||||
von Browsern für LiveKit nicht akzeptiert.
|
||||
|
||||
## WebRTC: LiveKit SFU und eingebettetes TURN
|
||||
|
||||
```
|
||||
Client A Client B
|
||||
│ │
|
||||
│ WebSocket (wss://…/ws/signaling) │
|
||||
▼ ▼
|
||||
Signaling server (in the "app" container)
|
||||
│
|
||||
▼
|
||||
relays only small JSON offer/answer/ICE
|
||||
messages — never touches audio/video/screen
|
||||
|
||||
Client A ═══════════ P2P WebRTC ═══════════ Client B
|
||||
Audio / Video / Screen
|
||||
Client A ── WebRTC ──┐
|
||||
├── LiveKit SFU ── WebRTC ── Client B
|
||||
Client C ── WebRTC ──┘ │
|
||||
└── embedded TURN fallback
|
||||
```
|
||||
|
||||
STUN is on by default (`STUN_SERVER` in `.env`). Add a TURN server for restrictive NATs/firewalls by setting `TURN_SERVER`/`TURN_USERNAME`/`TURN_PASSWORD` and redeploying — no code changes needed.
|
||||
`docker-compose.yml` startet LiveKit mit Host-Networking, dem UDP-Medienbereich
|
||||
`50000–60000`, dem TCP-Fallback `7881` und eingebettetem TURN/UDP auf `3478`.
|
||||
Die API-Keys werden von `setup.sh` generiert und nur an den LiveKit-Container
|
||||
und die serverseitige Token-Route gegeben. Für den Browser ist ausschließlich
|
||||
`LIVEKIT_URL` als `wss://`-Endpoint sichtbar.
|
||||
|
||||
Firewall/NAT-Freigaben auf dem Server:
|
||||
|
||||
```text
|
||||
TCP 443 LiveKit über den TLS-Reverse-Proxy
|
||||
TCP 7881 LiveKit-TCP-Fallback
|
||||
UDP 3478 eingebettetes TURN
|
||||
UDP 50000-60000 LiveKit-Medien
|
||||
```
|
||||
|
||||
Für genau einen parallelen kleinen Call ist kein Redis und keine GPU nötig.
|
||||
Bei mehreren LiveKit-Instanzen oder deutlich höherer Last kommt Redis bzw. ein
|
||||
Load-Balancer dazu.
|
||||
|
||||
## Environment variables
|
||||
|
||||
@@ -161,7 +184,7 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file
|
||||
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
|
||||
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site |
|
||||
| `SMTP_*` | The existing ANOUMA mail system |
|
||||
| `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers |
|
||||
| `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | eigener LiveKit-SFU und serverseitige Token-Erzeugung |
|
||||
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names |
|
||||
| `BACKUP_RETENTION_DAYS` | Deploy-backup retention |
|
||||
| `REGISTRY`, `REGISTRY_REPO`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` | Where `deploy.sh` pulls the image from |
|
||||
@@ -182,7 +205,7 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu
|
||||
- **app**: `GET /api/health` (built into the image's `HEALTHCHECK`) — checks that the app can actually query Postgres, not just that the process is listening.
|
||||
- **postgres**: `pg_isready`.
|
||||
|
||||
`docker compose ps` (from `/opt/anouma`) shows both statuses.
|
||||
`docker compose ps` (from `/opt/anouma`) shows the app, Postgres and LiveKit services.
|
||||
|
||||
## Security
|
||||
|
||||
@@ -201,6 +224,6 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu
|
||||
| Release workflow fails at "Pull the tested image" | The tagged commit was never pushed to `main`, or `main`'s CI/build-image run for it hasn't finished yet — push to `main`, wait for CI to go green, then tag |
|
||||
| Release workflow fails at "Deploy to production" | Check the Gitea secrets are all set correctly, and that the server's SSH port is reachable from the runner |
|
||||
| App container unhealthy after a deploy | `ssh` directly to the server (not via the restricted key) and run `docker compose logs app` from `/opt/anouma` |
|
||||
| Video calls connect but no audio/video | Likely a restrictive NAT — configure a TURN server (see above) |
|
||||
| Reverse proxy shows a 502/connection reset on the call page | Websockets Support isn't enabled on the Nginx Proxy Manager proxy host |
|
||||
| Video calls connect but no audio/video | Check LiveKit logs, the UDP 3478/50000–60000 firewall rules and the public `LIVEKIT_URL` |
|
||||
| Browser cannot connect to LiveKit | Check the LiveKit proxy host, trusted TLS certificate and that Websockets Support is enabled |
|
||||
| Need to redeploy the same version | `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`, or push a new patch tag |
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
# Multi-stage build for the ANOUMA app (Next.js + Payload CMS + WebRTC
|
||||
# signaling, all served by the custom server.ts — see next.config.ts for why
|
||||
# Multi-stage build for the ANOUMA app (Next.js + Payload CMS + LiveKit SFU
|
||||
# integration, all served by the custom server.ts — see next.config.ts for why
|
||||
# this can't use Next's "standalone" output). DATABASE_URI/PAYLOAD_SECRET/etc.
|
||||
# are only needed at *runtime*, not at build time (see docker-compose.yml and
|
||||
# .env.example) — this image builds without a database connection.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Anouma
|
||||
|
||||
Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload CMS](https://payloadcms.com) (PostgreSQL) für Termine, Beiträge, Angebote und Seiteninhalte, plus Online-Termine mit P2P-Video-Call (WebRTC), Anmeldungen und E-Mail-Erinnerungen.
|
||||
Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload CMS](https://payloadcms.com) (PostgreSQL) für Termine, Beiträge, Angebote und Seiteninhalte, plus Online-Termine mit selbst gehostetem [LiveKit](https://livekit.io) SFU-Video-Call, Anmeldungen und E-Mail-Erinnerungen.
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
@@ -10,11 +10,11 @@ Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload C
|
||||
- `app/global-not-found.tsx` — statische 404-Seite (siehe unten, warum sie nötig ist)
|
||||
- `collections/`, `globals/`, `access/`, `fields/`, `payload.config.ts` — die CMS-Konfiguration
|
||||
- `lib/payload/` — Local-API-Zugriffe für die öffentliche Website (mit `React.cache` pro Request memoisiert)
|
||||
- `lib/meeting/` — Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, WebSocket-Signaling, Reminder-Logik
|
||||
- `lib/meeting/` — Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, LiveKit-SFU-Token und Reminder-Logik
|
||||
- `lib/email/` — SMTP-Versand (nodemailer) und das E-Mail-Template für Reminder
|
||||
- `lib/texte.ts` — die ursprünglichen Anouma-Texte, nur noch als Seed-Quelle verwendet
|
||||
- `scripts/seed.ts` — überträgt die vorhandenen Inhalte ins CMS
|
||||
- `server.ts` — eigener Node-Server (statt `next start`), weil daran der WebRTC-Signaling-WebSocket hängt
|
||||
- `server.ts` — eigener Node-Server (statt `next start`), passend zur bestehenden Payload-Konfiguration
|
||||
|
||||
Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next.js kann daraus keine einzelne 404-Seite komponieren, deshalb gibt es `app/global-not-found.tsx` (siehe `experimental.globalNotFound` in `next.config.ts`).
|
||||
|
||||
@@ -24,8 +24,8 @@ Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next
|
||||
- Öffentliche Beitrittsseite: `/termine/[slug]/beitreten` (Name + Passwort, beide Pflicht) → bei Erfolg `/termine/[slug]/call`.
|
||||
- Ist die aufrufende Person im selben Browser als Admin eingeloggt, wird sie automatisch als **Host** erkannt (kein Passwort nötig, größeres Beitritts-Zeitfenster, exklusive Steuerung: Bildschirmfreigabe, Teilnehmer entfernen).
|
||||
- Die Zeitfenster (wie früh Host/Teilnehmer beitreten dürfen, wie lange das Meeting nach Ende offen bleibt) sind zentral im CMS unter **Video-Termin-Einstellungen** konfigurierbar.
|
||||
- Server-seitige Validierung: `/api/meetings/[slug]/join` prüft Termin, Zeitfenster und Passwort und stellt danach erst ein kurzlebiges, signiertes Sitzungs-Token aus (`MEETING_SESSION_SECRET`). Das WebSocket-Signaling (`/ws/signaling`, siehe `server.ts`) prüft dieses Token erneut, bevor jemand einem Raum beitreten darf.
|
||||
- Reines P2P-WebRTC: Der Server relayt nur kleine Signaling-Nachrichten (Angebot/Antwort/ICE), niemals Audio/Video/Bildschirmfreigabe. Es wird nichts aufgezeichnet oder gespeichert.
|
||||
- Server-seitige Validierung: `/api/meetings/[slug]/join` prüft Termin, Zeitfenster und Passwort und stellt danach erst ein kurzlebiges, signiertes Sitzungs-Token aus (`MEETING_SESSION_SECRET`). Ein zweiter Endpoint tauscht dieses App-Token gegen ein kurzlebiges LiveKit-Token aus.
|
||||
- LiveKit läuft als SFU auf dem eigenen Server: Audio, Video und Bildschirm werden nicht zwischen allen Browsern vermasht, sondern einmal zum SFU gesendet und gezielt an die anderen Teilnehmer verteilt. Der Server zeichnet nichts auf oder speichert Medien.
|
||||
- Teilnehmer-Anmeldungen laufen über die Collection **Anmeldungen** (`event-registrations`), öffentlich erreichbar über `/api/meetings/[slug]/register`.
|
||||
|
||||
### E-Mail-Erinnerungen
|
||||
@@ -59,7 +59,7 @@ git tag v1.2.0 && git push origin v1.2.0
|
||||
|
||||
Gitea Actions testet, baut das Image, pusht es in die Registry und deployt es per SSH auf den Server — inklusive Backup, Healthcheck und automatischem Rollback bei Fehlschlag. Es gibt kein separates Update-Script.
|
||||
|
||||
Für alle Details (Architektur, Gitea Secrets, CI/Release-Workflows, NPM-Reverse-Proxy-Einrichtung, Environment-Variablen, WebRTC/STUN/TURN, Backups, Rollback, Security) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
|
||||
Für alle Details (Architektur, Gitea Secrets, CI/Release-Workflows, Reverse-Proxy- und LiveKit-Einrichtung, Environment-Variablen, Backups, Rollback, Security) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
|
||||
|
||||
## Lokale Entwicklung (ohne Docker für die App)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
import { getCustomerBookings } from "@/lib/booking/queries";
|
||||
import { PersonalCalendar } from "@/components/booking/PersonalCalendar";
|
||||
import { getMeetingSettingsGlobal } from "@/lib/payload/globals";
|
||||
|
||||
export const metadata: Metadata = { title: "Mein Kalender" };
|
||||
|
||||
@@ -10,12 +11,13 @@ export default async function KontoKalenderPage() {
|
||||
if (!customer) return null;
|
||||
|
||||
const bookings = await getCustomerBookings(customer.id);
|
||||
const meetingSettings = await getMeetingSettingsGlobal();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-serif text-xl font-medium text-anouma-plum">Mein Kalender</h2>
|
||||
<div className="mt-6">
|
||||
<PersonalCalendar bookings={bookings} />
|
||||
<PersonalCalendar bookings={bookings} meetingSettings={meetingSettings} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
import { getCustomerBookings } from "@/lib/booking/queries";
|
||||
import { BookingCard } from "@/components/booking/BookingCard";
|
||||
import { ArrowLink } from "@/components/ArrowLink";
|
||||
import { getMeetingSettingsGlobal } from "@/lib/payload/globals";
|
||||
import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess";
|
||||
|
||||
export const metadata: Metadata = { title: "Mein Konto" };
|
||||
|
||||
@@ -11,6 +13,7 @@ export default async function KontoOverviewPage() {
|
||||
if (!customer) return null; // Layout already redirects; keeps TS happy.
|
||||
|
||||
const bookings = await getCustomerBookings(customer.id);
|
||||
const meetingSettings = await getMeetingSettingsGlobal();
|
||||
const pending = bookings.filter((b) => b.status === "pending");
|
||||
const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.date) >= new Date());
|
||||
const past = bookings.filter((b) => new Date(b.date) < new Date() || b.status === "rejected" || b.status === "cancelled");
|
||||
@@ -53,7 +56,7 @@ export default async function KontoOverviewPage() {
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
{confirmed.map((b) => (
|
||||
<BookingCard key={b.id} booking={b} />
|
||||
<BookingCard key={b.id} booking={b} meetingStatus={getBookingMeetingStatus(b, meetingSettings)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
import { getCustomerBookings } from "@/lib/booking/queries";
|
||||
import { BookingCard } from "@/components/booking/BookingCard";
|
||||
import { getMeetingSettingsGlobal } from "@/lib/payload/globals";
|
||||
import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Termine" };
|
||||
|
||||
@@ -10,6 +12,7 @@ export default async function KontoTerminePage() {
|
||||
if (!customer) return null;
|
||||
|
||||
const bookings = await getCustomerBookings(customer.id);
|
||||
const meetingSettings = await getMeetingSettingsGlobal();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -21,7 +24,7 @@ export default async function KontoTerminePage() {
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
{bookings.map((b) => (
|
||||
<BookingCard key={b.id} booking={b} />
|
||||
<BookingCard key={b.id} booking={b} meetingStatus={getBookingMeetingStatus(b, meetingSettings)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { JoinForm } from "@/components/meeting/JoinForm";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
import { getEventForJoin } from "@/lib/payload/content";
|
||||
import { combineDateAndTime } from "@/lib/meeting/status";
|
||||
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,8 +22,8 @@ export default async function BeitretenPage({ params }: Args) {
|
||||
if (!event || !event.isOnline) notFound();
|
||||
|
||||
const start = combineDateAndTime(event.date, event.startTime);
|
||||
const dateLabel = start.toLocaleDateString("de-DE", { day: "2-digit", month: "long" });
|
||||
const timeLabel = start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
const dateLabel = formatBerlinDate(start, { day: "2-digit", month: "long" });
|
||||
const timeLabel = formatBerlinTime(start, { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-[80vh] items-center overflow-hidden py-20">
|
||||
|
||||
@@ -76,7 +76,7 @@ export default async function EventDetailPage({ params }: Args) {
|
||||
const { user } = await payload.auth({ headers: await headers() });
|
||||
// Must check the collection, not just presence — a logged-in customer
|
||||
// (collection "customers") must never be treated as host.
|
||||
const isHost = user?.collection === "users";
|
||||
const isHost = user?.collection === "users" && user.role === "admin";
|
||||
const settings = await payload.findGlobal({ slug: "meeting-settings" });
|
||||
const start = combineDateAndTime(event.date, event.startTime);
|
||||
const end = event.endTime
|
||||
|
||||
@@ -21,7 +21,9 @@ export default async function TerminePage() {
|
||||
const events = await getAllEvents();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const upcoming = events.filter((e) => new Date(e.date) >= today).reverse();
|
||||
const upcoming = events
|
||||
.filter((e) => new Date(e.date) >= today)
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
const past = events.filter((e) => new Date(e.date) < today);
|
||||
const siteUrl = await getSiteUrl();
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { createMeetingToken } from "@/lib/meeting/token";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Body = { eventSlug?: unknown };
|
||||
|
||||
/** Creates a host session for an online event from the authenticated CRM. */
|
||||
export async function POST(request: Request) {
|
||||
let body: Body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
const eventSlug = typeof body.eventSlug === "string" ? body.eventSlug.trim() : "";
|
||||
if (!eventSlug) return NextResponse.json({ error: "Online-Termin fehlt." }, { status: 400 });
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers: request.headers });
|
||||
if (!user || user.collection !== "users" || user.role !== "admin") {
|
||||
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
||||
}
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: "events",
|
||||
where: { slug: { equals: eventSlug } },
|
||||
limit: 1,
|
||||
});
|
||||
const event = docs[0];
|
||||
if (!event || !event.isOnline) {
|
||||
return NextResponse.json({ error: "Dieser Termin ist kein Online-Termin." }, { status: 404 });
|
||||
}
|
||||
|
||||
const { token } = createMeetingToken({
|
||||
eventSlug,
|
||||
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
|
||||
role: "host",
|
||||
});
|
||||
|
||||
// Keep the token in the URL fragment: browsers do not send fragments to
|
||||
// the server, so the temporary host credential does not enter request or
|
||||
// reverse-proxy logs. The CRM copies this test link without navigating.
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
|
||||
const testUrl = `${baseUrl.replace(/\/$/, "")}/termine/${encodeURIComponent(eventSlug)}/call#token=${encodeURIComponent(token)}`;
|
||||
|
||||
return NextResponse.json({ testUrl });
|
||||
} catch (error) {
|
||||
console.error("admin meeting start failed", error);
|
||||
return NextResponse.json({ error: "Der Video-Call konnte nicht gestartet werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { getAvailableSlots } from "@/lib/booking/slots";
|
||||
import { berlinDayBounds } from "@/lib/calendar/timezone";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -59,16 +60,13 @@ export async function POST(request: Request) {
|
||||
// Re-validate the slot is actually free right now (defense against a
|
||||
// stale slot list) — the hard, race-safe check still runs again when
|
||||
// Anna confirms (see collections/BookingRequests.ts).
|
||||
const dayStart = new Date(start);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(start);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
const dayBounds = berlinDayBounds(start);
|
||||
const slotsByDay = await getAvailableSlots(payload, {
|
||||
durationMinutes: offer.durationMinutes,
|
||||
from: dayStart,
|
||||
to: dayEnd,
|
||||
from: dayBounds.start,
|
||||
to: dayBounds.end,
|
||||
});
|
||||
const key = dayStart.toISOString().slice(0, 10);
|
||||
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 });
|
||||
|
||||
@@ -29,11 +29,21 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
|
||||
}
|
||||
|
||||
const from = fromParam ? new Date(fromParam) : new Date();
|
||||
const from = fromParam ? new Date(`${fromParam}T00:00:00`) : new Date();
|
||||
if (Number.isNaN(from.getTime())) {
|
||||
return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 });
|
||||
}
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const to = toParam ? new Date(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 (Number.isNaN(to.getTime())) {
|
||||
return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 });
|
||||
}
|
||||
to.setHours(23, 59, 59, 999);
|
||||
|
||||
if (to < from) {
|
||||
return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Cap the range so this can't be abused to run an expensive scan.
|
||||
const maxRangeMs = 62 * 24 * 60 * 60 * 1000;
|
||||
if (to.getTime() - from.getTime() > maxRangeMs) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
|
||||
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
|
||||
import { verifyMeetingToken } from "@/lib/meeting/token";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
/** Exchanges the app's short-lived meeting credential for a LiveKit token. */
|
||||
export async function POST(request: Request, { params }: Args) {
|
||||
const { slug } = await params;
|
||||
const authorization = request.headers.get("authorization") ?? "";
|
||||
const appToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
|
||||
const meetingToken = verifyMeetingToken(appToken);
|
||||
if (!meetingToken || meetingToken.eventSlug !== slug) {
|
||||
return NextResponse.json({ error: "Die Meeting-Sitzung ist ungültig." }, { status: 401 });
|
||||
}
|
||||
if (isMeetingParticipantKicked(slug, meetingToken.participantId, meetingToken.clientId)) {
|
||||
return NextResponse.json({ error: "Du wurdest aus diesem Meeting entfernt." }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
const { docs } = await payload.find({ collection: "events", where: { slug: { equals: slug } }, limit: 1 });
|
||||
if (!docs[0]?.isOnline) return NextResponse.json({ error: "Dieser Online-Termin wurde nicht gefunden." }, { status: 404 });
|
||||
|
||||
const participantToken = await createLiveKitToken({
|
||||
eventSlug: slug,
|
||||
participantId: meetingToken.participantId,
|
||||
name: meetingToken.name,
|
||||
role: meetingToken.role,
|
||||
clientId: meetingToken.clientId,
|
||||
});
|
||||
return NextResponse.json({ serverUrl: getLiveKitClientUrl(), participantToken });
|
||||
} catch (error) {
|
||||
console.error("meeting LiveKit connect failed", error);
|
||||
return NextResponse.json({ error: "Der Video-Server ist gerade nicht erreichbar." }, { status: 503 });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { createMeetingToken } from "@/lib/meeting/token";
|
||||
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status";
|
||||
import { getIceServers } from "@/lib/meeting/iceServers";
|
||||
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
|
||||
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -30,9 +31,12 @@ export async function POST(request: Request, { params }: Args) {
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
const password = typeof body.password === "string" ? body.password.trim() : "";
|
||||
const clientId = typeof body.clientId === "string" ? body.clientId.trim() : undefined;
|
||||
if (!name) {
|
||||
if (!name || name.length > 120) {
|
||||
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
|
||||
}
|
||||
if (password.length > 200) {
|
||||
return NextResponse.json({ error: "Das Meeting-Passwort ist ungültig." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
@@ -50,7 +54,7 @@ export async function POST(request: Request, { params }: Args) {
|
||||
const { user } = await payload.auth({ headers: request.headers });
|
||||
// Must check the collection, not just presence — a logged-in customer
|
||||
// (collection "customers") must never be treated as host.
|
||||
const isHost = user?.collection === "users";
|
||||
const isHost = user?.collection === "users" && user.role === "admin";
|
||||
|
||||
if (!isHost) {
|
||||
if (!password) {
|
||||
@@ -88,7 +92,18 @@ export async function POST(request: Request, { params }: Args) {
|
||||
role: isHost ? "host" : "participant",
|
||||
// Only meaningful for participants — a host authenticates via a real
|
||||
// session already, so there's no need to anchor a kick to it (and
|
||||
// hosts can't be kicked in the first place, see signalingServer.ts).
|
||||
// hosts can't be kicked in the first place.
|
||||
clientId: isHost ? undefined : clientId,
|
||||
});
|
||||
|
||||
if (!isHost && isMeetingParticipantKicked(slug, participantId, clientId)) {
|
||||
return NextResponse.json({ error: "Du wurdest aus diesem Meeting entfernt." }, { status: 403 });
|
||||
}
|
||||
const participantToken = await createLiveKitToken({
|
||||
eventSlug: slug,
|
||||
participantId,
|
||||
name,
|
||||
role: isHost ? "host" : "participant",
|
||||
clientId: isHost ? undefined : clientId,
|
||||
});
|
||||
|
||||
@@ -97,7 +112,8 @@ export async function POST(request: Request, { params }: Args) {
|
||||
participantId,
|
||||
role: isHost ? "host" : "participant",
|
||||
eventTitle: event.title,
|
||||
iceServers: getIceServers(),
|
||||
participantToken,
|
||||
serverUrl: getLiveKitClientUrl(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("meeting join failed", error);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getLiveKitRoomName, getLiveKitRoomService } from "@/lib/meeting/livekit";
|
||||
import { markMeetingParticipantKicked } from "@/lib/meeting/kicks";
|
||||
import { verifyMeetingToken } from "@/lib/meeting/token";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Args) {
|
||||
const { slug } = await params;
|
||||
const authorization = request.headers.get("authorization") ?? "";
|
||||
const appToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
|
||||
const host = verifyMeetingToken(appToken);
|
||||
if (!host || host.eventSlug !== slug || host.role !== "host") {
|
||||
return NextResponse.json({ error: "Nur der Host darf Teilnehmer entfernen." }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { participantId?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
const participantId = typeof body.participantId === "string" ? body.participantId.trim() : "";
|
||||
if (!participantId || participantId.length > 100 || participantId === host.participantId) {
|
||||
return NextResponse.json({ error: "Ungültiger Teilnehmer." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const roomService = getLiveKitRoomService();
|
||||
const room = getLiveKitRoomName(slug);
|
||||
const participant = (await roomService.listParticipants(room)).find((item) => item.identity === participantId);
|
||||
if (!participant) return NextResponse.json({ error: "Teilnehmer nicht gefunden." }, { status: 404 });
|
||||
|
||||
let clientId: string | undefined;
|
||||
try {
|
||||
const metadata = JSON.parse(participant.metadata || "{}") as { clientId?: unknown };
|
||||
if (typeof metadata.clientId === "string" && metadata.clientId.length <= 100) clientId = metadata.clientId;
|
||||
} catch {
|
||||
// Metadata is optional; the participant identity is still revoked below.
|
||||
}
|
||||
markMeetingParticipantKicked(slug, participantId, clientId);
|
||||
await roomService.removeParticipant(room, participantId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("meeting participant kick failed", error);
|
||||
return NextResponse.json({ error: "Der Teilnehmer konnte nicht entfernt werden." }, { status: 503 });
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Registration capacity is checked before the insert. Serialize registrations
|
||||
// for the same event so two simultaneous requests cannot both observe the
|
||||
// same free seat in this single-process deployment.
|
||||
const registrationLocks = new Map<string, Promise<void>>();
|
||||
|
||||
async function withEventRegistrationLock<T>(eventId: string, action: () => Promise<T>): Promise<T> {
|
||||
const previous = registrationLocks.get(eventId) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
registrationLocks.set(eventId, current);
|
||||
await previous;
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
release();
|
||||
if (registrationLocks.get(eventId) === current) registrationLocks.delete(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Args) {
|
||||
const { slug } = await params;
|
||||
|
||||
if (!checkRateLimit(`event-registration:ip:${getClientIp(request)}`, { max: 20, windowMs: 60 * 60 * 1000 })) {
|
||||
return NextResponse.json({ error: "Zu viele Anmeldungen. Bitte versuche es später erneut." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: { name?: unknown; email?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
@@ -17,8 +43,8 @@ export async function POST(request: Request, { params }: Args) {
|
||||
}
|
||||
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
const email = typeof body.email === "string" ? body.email.trim() : "";
|
||||
if (!name || !email || !email.includes("@")) {
|
||||
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
|
||||
if (!name || name.length > 120 || !email || email.length > 254 || !email.includes("@")) {
|
||||
return NextResponse.json({ error: "Bitte gib deinen Namen und eine gültige E-Mail-Adresse an." }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -31,41 +57,46 @@ export async function POST(request: Request, { params }: Args) {
|
||||
limit: 1,
|
||||
});
|
||||
const event = docs[0];
|
||||
if (!event) {
|
||||
if (!event || event._status !== "published" || !event.registrationRequired) {
|
||||
return NextResponse.json({ error: "Dieser Termin wurde nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const { docs: existingForEmail } = await payload.find({
|
||||
collection: "event-registrations",
|
||||
where: {
|
||||
and: [
|
||||
{ event: { equals: event.id } },
|
||||
{ email: { equals: email } },
|
||||
{ status: { equals: "registered" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
if (existingForEmail[0]) {
|
||||
return NextResponse.json({ ok: true, alreadyRegistered: true });
|
||||
if (!checkRateLimit(`event-registration:email:${event.id}:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
|
||||
return NextResponse.json({ error: "Zu viele Anmeldeversuche für diese Adresse. Bitte versuche es später erneut." }, { status: 429 });
|
||||
}
|
||||
|
||||
if (event.maxParticipants) {
|
||||
const { totalDocs } = await payload.count({
|
||||
return await withEventRegistrationLock(String(event.id), async () => {
|
||||
const { docs: existingForEmail } = await payload.find({
|
||||
collection: "event-registrations",
|
||||
where: { and: [{ event: { equals: event.id } }, { status: { equals: "registered" } }] },
|
||||
where: {
|
||||
and: [
|
||||
{ event: { equals: event.id } },
|
||||
{ email: { equals: email } },
|
||||
{ status: { equals: "registered" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
if (totalDocs >= event.maxParticipants) {
|
||||
return NextResponse.json({ error: "Dieser Termin ist bereits ausgebucht." }, { status: 409 });
|
||||
if (existingForEmail[0]) {
|
||||
return NextResponse.json({ ok: true, alreadyRegistered: true });
|
||||
}
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: "event-registrations",
|
||||
data: { event: event.id, name, email, status: "registered" },
|
||||
if (event.maxParticipants) {
|
||||
const { totalDocs } = await payload.count({
|
||||
collection: "event-registrations",
|
||||
where: { and: [{ event: { equals: event.id } }, { status: { equals: "registered" } }] },
|
||||
});
|
||||
if (totalDocs >= event.maxParticipants) {
|
||||
return NextResponse.json({ error: "Dieser Termin ist bereits ausgebucht." }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: "event-registrations",
|
||||
data: { event: event.id, name, email, status: "registered" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("event registration failed", error);
|
||||
return NextResponse.json({ error: "Die Anmeldung ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 });
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/lib/email/bookingTemplates";
|
||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||
import type { Customer, Event, Offer } from "@/payload-types";
|
||||
import { berlinDateKey, berlinDayBounds, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||
|
||||
export const APPOINTMENT_TYPES = [
|
||||
{ label: "Vor Ort", value: "onsite" },
|
||||
@@ -26,15 +27,15 @@ export const BOOKING_STATUSES = [
|
||||
] as const;
|
||||
|
||||
function dateKey(iso: string): string {
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
return berlinDateKey(new Date(iso));
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
|
||||
return formatBerlinDate(new Date(iso), { day: "2-digit", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
return formatBerlinTime(new Date(iso), { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
const appointmentTypeLabel = (value: string) =>
|
||||
@@ -70,8 +71,9 @@ const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDo
|
||||
|
||||
await acquireBookingDayLock(req, dateKey(date));
|
||||
|
||||
const dayStart = new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString();
|
||||
const dayEnd = new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString();
|
||||
const dayBounds = berlinDayBounds(new Date(date));
|
||||
const dayStart = dayBounds.start.toISOString();
|
||||
const dayEnd = dayBounds.end.toISOString();
|
||||
const conflictClauses: Where[] = [
|
||||
{ status: { equals: "confirmed" } },
|
||||
{ date: { greater_than_equal: dayStart } },
|
||||
|
||||
@@ -14,8 +14,11 @@ export const EventRegistrations: CollectionConfig = {
|
||||
description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.",
|
||||
},
|
||||
access: {
|
||||
// Visitors register themselves through the public event page.
|
||||
create: () => true,
|
||||
// Public registrations must go through /api/meetings/[slug]/register,
|
||||
// which verifies publication/registrationRequired, normalizes the email,
|
||||
// and enforces the capacity check. The Local API used by that route
|
||||
// intentionally overrides this access rule after those checks.
|
||||
create: () => false,
|
||||
read: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "rea
|
||||
* validation attributes, autoComplete, etc. all keep working exactly as
|
||||
* before.
|
||||
*/
|
||||
const fieldClass =
|
||||
export const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
function FieldLabel({ label, htmlFor, hidden = false }: { label: string; htmlFor: string; hidden?: boolean }) {
|
||||
|
||||
@@ -155,13 +155,21 @@
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f7f4f1;
|
||||
text-decoration: none;
|
||||
color: #1f1f1f;
|
||||
font-size: 13px;
|
||||
}
|
||||
.entry:hover {
|
||||
background: #efe7e1;
|
||||
}
|
||||
.entryLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AdminViewServerProps } from "payload";
|
||||
import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar";
|
||||
import { berlinParts } from "@/lib/calendar/timezone";
|
||||
import { CalendarFeedPanel } from "./CalendarFeedPanel";
|
||||
import { AdminCallButton } from "./AdminCallButton";
|
||||
import styles from "./AdminCalendarView.module.css";
|
||||
|
||||
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
@@ -27,7 +29,8 @@ const BADGE_CLASS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
const { year, month, day } = berlinParts(d);
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
function parseMonthParam(value: string | null): Date {
|
||||
if (value && /^\d{4}-\d{2}$/.test(value)) {
|
||||
@@ -40,7 +43,11 @@ function monthParam(d: Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
function fmtTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
return new Date(iso).toLocaleTimeString("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export async function AdminCalendarView({ initPageResult }: AdminViewServerProps) {
|
||||
@@ -48,7 +55,9 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
|
||||
const searchParams = req.searchParams;
|
||||
const month = parseMonthParam(searchParams.get("month"));
|
||||
const selectedDayParam = searchParams.get("day");
|
||||
const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T00:00:00`) : null;
|
||||
// Noon UTC stays on the same Berlin calendar day even when the server runs
|
||||
// in UTC and the selected date is around a DST transition.
|
||||
const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T12:00:00Z`) : null;
|
||||
|
||||
const gridStart = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const firstWeekday = (gridStart.getDay() + 6) % 7;
|
||||
@@ -82,7 +91,10 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
|
||||
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
|
||||
|
||||
const dayItems = selectedDay ? (itemsByDay.get(dateKey(selectedDay)) ?? []) : [];
|
||||
const hours = Array.from({ length: 13 }, (_, i) => i + 8); // 08:00–20:00
|
||||
const startHours = dayItems.length ? dayItems.map((item) => berlinParts(new Date(item.startTime)).hour) : [];
|
||||
const firstHour = Math.min(8, ...startHours);
|
||||
const lastHour = Math.max(20, ...startHours);
|
||||
const hours = Array.from({ length: lastHour - firstHour + 1 }, (_, i) => i + firstHour);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
@@ -156,23 +168,26 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
|
||||
{selectedDay &&
|
||||
dayItems.length > 0 &&
|
||||
hours.map((hour) => {
|
||||
const hourEntries = dayItems.filter((item) => new Date(item.startTime).getHours() === hour);
|
||||
const hourEntries = dayItems.filter((item) => berlinParts(new Date(item.startTime)).hour === hour);
|
||||
return (
|
||||
<div key={hour} className={styles.hourRow}>
|
||||
<div className={styles.hourLabel}>{String(hour).padStart(2, "0")}:00</div>
|
||||
<div className={styles.hourContent}>
|
||||
{hourEntries.map((entry) => (
|
||||
<a key={entry.id} href={entry.href} className={styles.entry}>
|
||||
<span className={`${styles.badge} ${BADGE_CLASS[entry.status] ?? ""}`}>
|
||||
{STATUS_LABEL[entry.status] ?? entry.status}
|
||||
</span>
|
||||
<strong>{entry.title}</strong>
|
||||
<span>
|
||||
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
|
||||
</span>
|
||||
{entry.subtitle && <span>· {entry.subtitle}</span>}
|
||||
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
|
||||
</a>
|
||||
<div key={entry.id} className={styles.entry}>
|
||||
<a href={entry.href} className={styles.entryLink}>
|
||||
<span className={`${styles.badge} ${BADGE_CLASS[entry.status] ?? ""}`}>
|
||||
{STATUS_LABEL[entry.status] ?? entry.status}
|
||||
</span>
|
||||
<strong>{entry.title}</strong>
|
||||
<span>
|
||||
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
|
||||
</span>
|
||||
{entry.subtitle && <span>· {entry.subtitle}</span>}
|
||||
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
|
||||
</a>
|
||||
{entry.callEventSlug && <AdminCallButton eventSlug={entry.callEventSlug} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
async function startCall() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
try {
|
||||
const response = await fetch("/api/admin/meetings/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ eventSlug }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setError(data.error || "Der Video-Call konnte nicht gestartet werden.");
|
||||
return;
|
||||
}
|
||||
setUrl(data.testUrl);
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.testUrl);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setError("Der Link wurde erzeugt. Bitte kopiere ihn aus dem Feld.");
|
||||
}
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startCall}
|
||||
disabled={loading}
|
||||
className="rounded-full bg-anouma-mauve-dark px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-anouma-plum disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Test-Link wird erzeugt …" : copied ? "Test-Link kopiert" : "Call testen"}
|
||||
</button>
|
||||
{url && !copied && (
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={url}
|
||||
aria-label="Test-Link"
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
className="w-56 rounded border border-anouma-taupe/30 bg-white px-2 py-1 text-xs text-anouma-plum"
|
||||
/>
|
||||
)}
|
||||
{error && <span className="text-xs text-red-700">{error}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,10 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge";
|
||||
import type { BookingRequest, Event, Offer } from "@/payload-types";
|
||||
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||
import type { MeetingStatus } from "@/lib/meeting/status";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" });
|
||||
}
|
||||
function fmtTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function BookingCard({ booking }: { booking: BookingRequest }) {
|
||||
export function BookingCard({ booking, meetingStatus }: { booking: BookingRequest; meetingStatus?: MeetingStatus | null }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -46,7 +41,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
|
||||
<div>
|
||||
<h3 className="font-serif text-xl font-medium text-anouma-plum">{offer?.title ?? "Termin"}</h3>
|
||||
<p className="mt-1 text-sm text-anouma-plum/80">
|
||||
{fmtDate(booking.date)} · {fmtTime(booking.startTime)} – {fmtTime(booking.endTime)}
|
||||
{formatBerlinDate(new Date(booking.date), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.startTime), { hour: "2-digit", minute: "2-digit" })} – {formatBerlinTime(new Date(booking.endTime), { hour: "2-digit", minute: "2-digit" })}
|
||||
</p>
|
||||
</div>
|
||||
<BookingStatusBadge status={displayStatus} />
|
||||
@@ -62,8 +57,8 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
|
||||
<div className="mt-4 rounded-2xl bg-anouma-cream-light p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-anouma-plum/70">Alternativer Termin</p>
|
||||
<p className="mt-1 text-base text-anouma-plum">
|
||||
{fmtDate(booking.proposedAlternative.date!)} · {fmtTime(booking.proposedAlternative.startTime!)} –{" "}
|
||||
{fmtTime(booking.proposedAlternative.endTime!)}
|
||||
{formatBerlinDate(new Date(booking.proposedAlternative.date!), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.proposedAlternative.startTime!), { hour: "2-digit", minute: "2-digit" })} –{" "}
|
||||
{formatBerlinTime(new Date(booking.proposedAlternative.endTime!), { hour: "2-digit", minute: "2-digit" })}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<button
|
||||
@@ -88,14 +83,17 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
|
||||
|
||||
{booking.status === "confirmed" && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{booking.appointmentType === "online" && event && (
|
||||
<a
|
||||
href={`/termine/${event.slug}/beitreten`}
|
||||
className="inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
|
||||
>
|
||||
{booking.appointmentType === "online" && event && meetingStatus !== "ended" && meetingStatus !== "scheduled" && (
|
||||
<a href={`/termine/${event.slug}/beitreten`} className="inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum">
|
||||
Video-Call betreten
|
||||
</a>
|
||||
)}
|
||||
{booking.appointmentType === "online" && event && meetingStatus === "scheduled" && (
|
||||
<p className="text-sm text-anouma-plum/75">Der Video-Call wird 10 Minuten vor Beginn freigeschaltet.</p>
|
||||
)}
|
||||
{booking.appointmentType === "online" && event && meetingStatus === "ended" && (
|
||||
<p className="text-sm text-anouma-plum/75">Dieser Video-Call ist bereits beendet.</p>
|
||||
)}
|
||||
{booking.appointmentType === "onsite" && (
|
||||
<p className="text-sm text-anouma-plum/80">Vor Ort – Details siehe Bestätigungs-E-Mail.</p>
|
||||
)}
|
||||
|
||||
@@ -5,19 +5,16 @@ import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
|
||||
import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
|
||||
import { TextAreaField, FormError } from "@/components/FormField";
|
||||
import { track } from "@/lib/analytics/client";
|
||||
import { berlinDateKey, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||
|
||||
type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> };
|
||||
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
return berlinDateKey(d);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" });
|
||||
}
|
||||
const fmtTime = (iso: string) => formatBerlinTime(new Date(iso), { hour: "2-digit", minute: "2-digit" });
|
||||
const fmtDate = (iso: string) => formatBerlinDate(new Date(iso), { weekday: "long", day: "2-digit", month: "long" });
|
||||
|
||||
export function BookingWidget({
|
||||
offerSlug,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { berlinDateKey, formatBerlinDate } from "@/lib/calendar/timezone";
|
||||
|
||||
export type CalendarMarker = {
|
||||
date: Date;
|
||||
@@ -23,7 +24,7 @@ function startOfMonth(d: Date) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
return berlinDateKey(d);
|
||||
}
|
||||
|
||||
export function MonthCalendar({
|
||||
@@ -62,7 +63,7 @@ export function MonthCalendar({
|
||||
return result;
|
||||
}, [month]);
|
||||
|
||||
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
|
||||
const monthLabel = formatBerlinDate(month, { month: "long", year: "numeric" });
|
||||
const today = dateKey(new Date());
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,13 +4,11 @@ import { useState } from "react";
|
||||
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
|
||||
import { BookingCard } from "./BookingCard";
|
||||
import { resolveDisplayStatus } from "./BookingStatusBadge";
|
||||
import type { BookingRequest } from "@/payload-types";
|
||||
import type { BookingRequest, MeetingSetting } from "@/payload-types";
|
||||
import { berlinDateKey } from "@/lib/calendar/timezone";
|
||||
import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess";
|
||||
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
|
||||
export function PersonalCalendar({ bookings, meetingSettings }: { bookings: BookingRequest[]; meetingSettings: MeetingSetting }) {
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||
|
||||
const markers: CalendarMarker[] = bookings.map((b) => ({
|
||||
@@ -19,7 +17,7 @@ export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
|
||||
}));
|
||||
|
||||
const selectedBookings = selectedDay
|
||||
? bookings.filter((b) => dateKey(new Date(b.date)) === dateKey(selectedDay))
|
||||
? bookings.filter((b) => berlinDateKey(new Date(b.date)) === berlinDateKey(selectedDay))
|
||||
: [];
|
||||
|
||||
return (
|
||||
@@ -30,7 +28,7 @@ export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
|
||||
selectedBookings.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{selectedBookings.map((b) => (
|
||||
<BookingCard key={b.id} booking={b} />
|
||||
<BookingCard key={b.id} booking={b} meetingStatus={getBookingMeetingStatus(b, meetingSettings)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+256
-416
@@ -1,478 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { VideoTile } from "./VideoTile";
|
||||
import { DisconnectReason, Room, RoomEvent, Track, type LocalTrack, type Participant } from "livekit-client";
|
||||
import { PreJoinScreen } from "./PreJoinScreen";
|
||||
import { SfuVideoTile } from "./SfuVideoTile";
|
||||
import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm";
|
||||
import type { ClientToServerMessage, ParticipantSummary, ServerToClientMessage } from "@/lib/meeting/protocol";
|
||||
|
||||
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
|
||||
type Phase = "loading" | "prejoin" | "connecting" | "connected" | "kicked" | "ended" | "error";
|
||||
type LayoutMode = "grid" | "speaker" | "screen";
|
||||
|
||||
type RemoteEntry = ParticipantSummary & { stream: MediaStream | null };
|
||||
type ParticipantView = {
|
||||
identity: string;
|
||||
name: string;
|
||||
role: "host" | "participant";
|
||||
cameraTrack: Track | null;
|
||||
microphoneTrack: Track | null;
|
||||
screenTrack: Track | null;
|
||||
speaking: boolean;
|
||||
microphoneEnabled: boolean;
|
||||
cameraEnabled: boolean;
|
||||
};
|
||||
|
||||
type Phase = "loading" | "connecting" | "connected" | "kicked" | "ended" | "error";
|
||||
function participantRole(participant: Participant, fallback: "host" | "participant"): "host" | "participant" {
|
||||
try {
|
||||
const metadata = JSON.parse(participant.metadata || "{}") as { role?: unknown };
|
||||
if (metadata.role === "host" || metadata.role === "participant") return metadata.role;
|
||||
} catch {
|
||||
// A malformed metadata value cannot grant host privileges.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function snapshotParticipant(
|
||||
participant: Participant,
|
||||
activeSpeakers: Set<string>,
|
||||
fallbackRole: "host" | "participant",
|
||||
fallbackName: string,
|
||||
): ParticipantView {
|
||||
return {
|
||||
identity: participant.identity,
|
||||
name: participant.name || fallbackName,
|
||||
role: participantRole(participant, fallbackRole),
|
||||
cameraTrack: participant.getTrackPublication(Track.Source.Camera)?.track ?? null,
|
||||
microphoneTrack: participant.getTrackPublication(Track.Source.Microphone)?.track ?? null,
|
||||
screenTrack: participant.getTrackPublication(Track.Source.ScreenShare)?.track ?? null,
|
||||
speaking: activeSpeakers.has(participant.identity) || participant.isSpeaking,
|
||||
microphoneEnabled: participant.isMicrophoneEnabled,
|
||||
cameraEnabled: participant.isCameraEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function CallRoom({ slug }: { slug: string }) {
|
||||
const router = useRouter();
|
||||
const [session, setSession] = useState<StoredMeetingSession | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [remotes, setRemotes] = useState<Map<string, RemoteEntry>>(new Map());
|
||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||
const [participants, setParticipants] = useState<ParticipantView[]>([]);
|
||||
const [layout, setLayout] = useState<LayoutMode>("grid");
|
||||
const [activeSpeakerId, setActiveSpeakerId] = useState<string | null>(null);
|
||||
const [showParticipants, setShowParticipants] = useState(false);
|
||||
const [sharingScreen, setSharingScreen] = useState(false);
|
||||
const [micOn, setMicOn] = useState(true);
|
||||
const [camOn, setCamOn] = useState(true);
|
||||
const [sharingScreen, setSharingScreen] = useState(false);
|
||||
const [showParticipants, setShowParticipants] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
const sessionRef = useRef<StoredMeetingSession | null>(null);
|
||||
const remotesRef = useRef<Map<string, RemoteEntry>>(new Map());
|
||||
const roomRef = useRef<Room | null>(null);
|
||||
const localTracksRef = useRef<LocalTrack[]>([]);
|
||||
const activeSpeakersRef = useRef<Set<string>>(new Set());
|
||||
const intentionalCloseRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
remotesRef.current = remotes;
|
||||
}, [remotes]);
|
||||
|
||||
const send = useCallback((message: ClientToServerMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closePeer = useCallback((participantId: string) => {
|
||||
pcsRef.current.get(participantId)?.close();
|
||||
pcsRef.current.delete(participantId);
|
||||
setRemotes((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(participantId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const createPeerConnection = useCallback(
|
||||
(participantId: string, name: string, role: ParticipantSummary["role"], isInitiator: boolean) => {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: sessionRef.current?.iceServers?.length ? sessionRef.current.iceServers : DEFAULT_ICE_SERVERS,
|
||||
});
|
||||
pcsRef.current.set(participantId, pc);
|
||||
|
||||
localStreamRef.current?.getTracks().forEach((track) => {
|
||||
pc.addTrack(track, localStreamRef.current!);
|
||||
});
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
setRemotes((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(participantId);
|
||||
next.set(participantId, { participantId, name, role, stream: event.streams[0] ?? existing?.stream ?? null });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
send({ type: "ice-candidate", to: participantId, payload: event.candidate.toJSON() });
|
||||
}
|
||||
};
|
||||
|
||||
setRemotes((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(participantId, { participantId, name, role, stream: next.get(participantId)?.stream ?? null });
|
||||
return next;
|
||||
});
|
||||
|
||||
if (isInitiator) {
|
||||
pc.createOffer()
|
||||
.then((offer) => pc.setLocalDescription(offer).then(() => offer))
|
||||
.then((offer) => send({ type: "offer", to: participantId, payload: offer }))
|
||||
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
|
||||
}
|
||||
|
||||
return pc;
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
pcsRef.current.forEach((pc) => pc.close());
|
||||
pcsRef.current.clear();
|
||||
localStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
localStreamRef.current = null;
|
||||
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Load session + acquire local media + open signaling connection. This
|
||||
// reads sessionStorage and opens external connections, so it belongs in an
|
||||
// effect; the early setState calls below are unavoidable for the
|
||||
// missing/invalid-session error paths (no server-renderable equivalent).
|
||||
useEffect(() => {
|
||||
const raw = sessionStorage.getItem(MEETING_SESSION_KEY);
|
||||
if (!raw) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPhase("error");
|
||||
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
||||
const hashToken = new URLSearchParams(window.location.hash.replace(/^#/, "")).get("token");
|
||||
const rawSession = hashToken ? JSON.stringify({ token: hashToken }) : raw;
|
||||
if (!rawSession) {
|
||||
queueMicrotask(() => {
|
||||
setPhase("error");
|
||||
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
||||
});
|
||||
return;
|
||||
}
|
||||
let parsed: StoredMeetingSession;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
const candidate = JSON.parse(rawSession) as Partial<StoredMeetingSession>;
|
||||
const parsed: StoredMeetingSession = {
|
||||
token: typeof candidate.token === "string" ? candidate.token : "",
|
||||
participantToken: candidate.participantToken,
|
||||
serverUrl: candidate.serverUrl,
|
||||
participantId: typeof candidate.participantId === "string" ? candidate.participantId : "",
|
||||
role: candidate.role === "participant" ? "participant" : "host",
|
||||
name: typeof candidate.name === "string" && candidate.name ? candidate.name : "Host",
|
||||
eventTitle: typeof candidate.eventTitle === "string" && candidate.eventTitle ? candidate.eventTitle : "Video-Call",
|
||||
eventSlug: typeof candidate.eventSlug === "string" && candidate.eventSlug ? candidate.eventSlug : slug,
|
||||
};
|
||||
if (!parsed.token || parsed.eventSlug !== slug) throw new Error("invalid session");
|
||||
queueMicrotask(() => {
|
||||
setSession(parsed);
|
||||
setPhase("prejoin");
|
||||
});
|
||||
} catch {
|
||||
setPhase("error");
|
||||
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
||||
return;
|
||||
}
|
||||
if (parsed.eventSlug !== slug) {
|
||||
setPhase("error");
|
||||
setErrorMessage("Diese Sitzung gehört zu einem anderen Termin.");
|
||||
return;
|
||||
}
|
||||
setSession(parsed);
|
||||
sessionRef.current = parsed;
|
||||
setPhase("connecting");
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||
if (cancelled) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
localStreamRef.current = stream;
|
||||
setLocalStream(stream);
|
||||
} catch {
|
||||
// Continue without local media — participant can still watch/listen to others.
|
||||
setErrorMessage("Kamera/Mikrofon konnten nicht aktiviert werden. Du kannst trotzdem teilnehmen.");
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling`);
|
||||
wsRef.current = ws;
|
||||
|
||||
// Token travels as the first message, not a `?token=` query param, so
|
||||
// it doesn't end up persisted in reverse-proxy/access logs.
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: "auth", token: parsed.token }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message: ServerToClientMessage = JSON.parse(event.data);
|
||||
handleServerMessage(message);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setPhase((p) => (p === "kicked" || p === "ended" ? p : "ended"));
|
||||
};
|
||||
ws.onerror = () => setErrorMessage("Verbindung zum Meeting-Server fehlgeschlagen.");
|
||||
})();
|
||||
|
||||
function handleServerMessage(message: ServerToClientMessage) {
|
||||
const me = sessionRef.current;
|
||||
if (!me) return;
|
||||
|
||||
switch (message.type) {
|
||||
case "welcome": {
|
||||
setPhase("connected");
|
||||
for (const p of message.participants) {
|
||||
createPeerConnection(p.participantId, p.name, p.role, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "peer-joined": {
|
||||
createPeerConnection(message.participantId, message.name, message.role, false);
|
||||
break;
|
||||
}
|
||||
case "peer-left": {
|
||||
closePeer(message.participantId);
|
||||
break;
|
||||
}
|
||||
case "offer": {
|
||||
const pc =
|
||||
pcsRef.current.get(message.from) ??
|
||||
createPeerConnection(message.from, remotesLookupName(message.from), "participant", false);
|
||||
pc.setRemoteDescription(new RTCSessionDescription(message.payload))
|
||||
.then(() => pc.createAnswer())
|
||||
.then((answer) => pc.setLocalDescription(answer).then(() => answer))
|
||||
.then((answer) => send({ type: "answer", to: message.from, payload: answer }))
|
||||
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
|
||||
break;
|
||||
}
|
||||
case "answer": {
|
||||
pcsRef.current.get(message.from)?.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
break;
|
||||
}
|
||||
case "ice-candidate": {
|
||||
pcsRef.current.get(message.from)?.addIceCandidate(new RTCIceCandidate(message.payload)).catch(() => {});
|
||||
break;
|
||||
}
|
||||
case "kicked": {
|
||||
setPhase("kicked");
|
||||
cleanup();
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
break;
|
||||
}
|
||||
case "auth-error": {
|
||||
setPhase("error");
|
||||
setErrorMessage("Die Meeting-Sitzung ist abgelaufen oder ungültig.");
|
||||
cleanup();
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function remotesLookupName(participantId: string): string {
|
||||
return remotesRef.current.get(participantId)?.name ?? "Teilnehmer:in";
|
||||
queueMicrotask(() => {
|
||||
setPhase("error");
|
||||
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
intentionalCloseRef.current = true;
|
||||
roomRef.current?.disconnect();
|
||||
roomRef.current = null;
|
||||
localTracksRef.current.forEach((track) => track.stop());
|
||||
localTracksRef.current = [];
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slug]);
|
||||
|
||||
function toggleMic() {
|
||||
localStreamRef.current?.getAudioTracks().forEach((t) => (t.enabled = !micOn));
|
||||
setMicOn((v) => !v);
|
||||
function refreshParticipants(room: Room) {
|
||||
const local = snapshotParticipant(room.localParticipant, activeSpeakersRef.current, session?.role ?? "participant", session?.name ?? "Du");
|
||||
const remote = [...room.remoteParticipants.values()].map((participant) =>
|
||||
snapshotParticipant(participant, activeSpeakersRef.current, "participant", "Teilnehmer:in"),
|
||||
);
|
||||
setParticipants([local, ...remote]);
|
||||
}
|
||||
|
||||
function toggleCam() {
|
||||
localStreamRef.current?.getVideoTracks().forEach((t) => (t.enabled = !camOn));
|
||||
setCamOn((v) => !v);
|
||||
async function connectRoom(previewTracks: LocalTrack[]) {
|
||||
if (!session) return;
|
||||
setPhase("connecting");
|
||||
setErrorMessage(null);
|
||||
intentionalCloseRef.current = false;
|
||||
localTracksRef.current = previewTracks;
|
||||
|
||||
try {
|
||||
let serverUrl = session.serverUrl;
|
||||
let participantToken = session.participantToken;
|
||||
if (!serverUrl || !participantToken) {
|
||||
const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/connect`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${session.token}` },
|
||||
credentials: "include",
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as { serverUrl?: string; participantToken?: string; error?: string };
|
||||
if (!response.ok || !data.serverUrl || !data.participantToken) throw new Error(data.error || "Video-Server nicht erreichbar");
|
||||
serverUrl = data.serverUrl;
|
||||
participantToken = data.participantToken;
|
||||
}
|
||||
|
||||
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||
roomRef.current = room;
|
||||
const refresh = () => refreshParticipants(room);
|
||||
|
||||
room.on(RoomEvent.Connected, refresh);
|
||||
room.on(RoomEvent.ParticipantConnected, refresh);
|
||||
room.on(RoomEvent.ParticipantDisconnected, refresh);
|
||||
room.on(RoomEvent.TrackPublished, refresh);
|
||||
room.on(RoomEvent.TrackUnpublished, refresh);
|
||||
room.on(RoomEvent.TrackSubscribed, refresh);
|
||||
room.on(RoomEvent.TrackUnsubscribed, refresh);
|
||||
room.on(RoomEvent.TrackMuted, refresh);
|
||||
room.on(RoomEvent.TrackUnmuted, refresh);
|
||||
room.on(RoomEvent.LocalTrackPublished, refresh);
|
||||
room.on(RoomEvent.LocalTrackUnpublished, refresh);
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||
activeSpeakersRef.current = new Set(speakers.map((speaker) => speaker.identity));
|
||||
setActiveSpeakerId(speakers[0]?.identity ?? null);
|
||||
refresh();
|
||||
});
|
||||
room.on(RoomEvent.Reconnecting, () => setPhase("connecting"));
|
||||
room.on(RoomEvent.Reconnected, () => {
|
||||
setPhase("connected");
|
||||
refresh();
|
||||
});
|
||||
room.on(RoomEvent.Disconnected, (reason) => {
|
||||
if (reason === DisconnectReason.PARTICIPANT_REMOVED) {
|
||||
localTracksRef.current.forEach((track) => track.stop());
|
||||
localTracksRef.current = [];
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
setPhase("kicked");
|
||||
} else if (!intentionalCloseRef.current) {
|
||||
setPhase("ended");
|
||||
}
|
||||
});
|
||||
room.on(RoomEvent.MediaDevicesError, () => setErrorMessage("Kamera oder Mikrofon konnten nicht verwendet werden."));
|
||||
|
||||
await room.connect(serverUrl, participantToken);
|
||||
for (const track of previewTracks) {
|
||||
await room.localParticipant.publishTrack(track, {
|
||||
source: track.kind === Track.Kind.Audio ? Track.Source.Microphone : Track.Source.Camera,
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
setMicOn(previewTracks.some((track) => track.kind === Track.Kind.Audio && track.mediaStreamTrack.enabled));
|
||||
setCamOn(previewTracks.some((track) => track.kind === Track.Kind.Video && track.mediaStreamTrack.enabled));
|
||||
setPhase("connected");
|
||||
} catch (error) {
|
||||
localTracksRef.current.forEach((track) => track.stop());
|
||||
localTracksRef.current = [];
|
||||
intentionalCloseRef.current = true;
|
||||
roomRef.current?.disconnect();
|
||||
roomRef.current = null;
|
||||
setPhase("error");
|
||||
setErrorMessage(error instanceof Error ? error.message : "Der Video-Call konnte nicht geöffnet werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMic() {
|
||||
const next = !micOn;
|
||||
await roomRef.current?.localParticipant.setMicrophoneEnabled(next);
|
||||
setMicOn(next);
|
||||
}
|
||||
|
||||
async function toggleCamera() {
|
||||
const next = !camOn;
|
||||
await roomRef.current?.localParticipant.setCameraEnabled(next);
|
||||
setCamOn(next);
|
||||
}
|
||||
|
||||
async function toggleScreenShare() {
|
||||
if (sharingScreen) {
|
||||
stopScreenShare();
|
||||
return;
|
||||
}
|
||||
const next = !sharingScreen;
|
||||
try {
|
||||
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||
screenStreamRef.current = screenStream;
|
||||
const screenTrack = screenStream.getVideoTracks()[0];
|
||||
screenTrack.onended = () => stopScreenShare();
|
||||
|
||||
pcsRef.current.forEach((pc) => {
|
||||
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
|
||||
sender?.replaceTrack(screenTrack);
|
||||
});
|
||||
setSharingScreen(true);
|
||||
await roomRef.current?.localParticipant.setScreenShareEnabled(next);
|
||||
setSharingScreen(next);
|
||||
} catch {
|
||||
// User cancelled the share dialog — nothing to do.
|
||||
setErrorMessage("Die Bildschirmfreigabe konnte nicht gestartet werden.");
|
||||
}
|
||||
}
|
||||
|
||||
function stopScreenShare() {
|
||||
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
const cameraTrack = localStreamRef.current?.getVideoTracks()[0] ?? null;
|
||||
pcsRef.current.forEach((pc) => {
|
||||
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
|
||||
sender?.replaceTrack(cameraTrack);
|
||||
async function kickParticipant(identity: string) {
|
||||
if (!session) return;
|
||||
const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/kick`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.token}` },
|
||||
body: JSON.stringify({ participantId: identity }),
|
||||
credentials: "include",
|
||||
});
|
||||
setSharingScreen(false);
|
||||
}
|
||||
|
||||
function kickParticipant(participantId: string) {
|
||||
send({ type: "kick", targetParticipantId: participantId });
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
setErrorMessage(data.error || "Teilnehmer konnte nicht entfernt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
function leaveMeeting() {
|
||||
cleanup();
|
||||
intentionalCloseRef.current = true;
|
||||
roomRef.current?.disconnect();
|
||||
roomRef.current = null;
|
||||
localTracksRef.current.forEach((track) => track.stop());
|
||||
localTracksRef.current = [];
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
router.push(`/termine/${slug}`);
|
||||
}
|
||||
|
||||
if (phase === "loading" || phase === "connecting") {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center bg-neutral-900 text-white">
|
||||
<p className="text-sm text-white/70">Verbindung wird aufgebaut …</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "loading") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 text-sm text-white/60">Meeting wird geladen …</div>;
|
||||
if (phase === "prejoin" && session) return <PreJoinScreen onContinue={connectRoom} />;
|
||||
if (phase === "connecting") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 text-sm text-white/65">Verbindung wird aufgebaut …</div>;
|
||||
if (phase === "error" || !session) {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
||||
<p className="text-lg">{errorMessage || "Dieses Meeting konnte nicht geöffnet werden."}</p>
|
||||
<a
|
||||
href={`/termine/${slug}/beitreten`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
|
||||
>
|
||||
Zurück zur Beitrittsseite
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-neutral-950 px-6 text-center text-white"><p>{errorMessage || "Dieses Meeting konnte nicht geöffnet werden."}</p><a href={`/termine/${slug}/beitreten`} className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum">Zurück zur Beitrittsseite</a></div>;
|
||||
}
|
||||
if (phase === "kicked") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 px-6 text-center text-white"><p>Du wurdest vom Meeting entfernt.</p></div>;
|
||||
if (phase === "ended") return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-neutral-950 px-6 text-center text-white"><p>{errorMessage || "Die Verbindung zum Meeting wurde beendet."}</p><a href={`/termine/${slug}`} className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum">Zurück zum Termin</a></div>;
|
||||
|
||||
if (phase === "kicked") {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
||||
<p className="text-lg">Du wurdest vom Meeting entfernt.</p>
|
||||
<a
|
||||
href={`/termine/${slug}/beitreten`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
|
||||
>
|
||||
Erneut beitreten
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "ended") {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
||||
<p className="text-lg">Die Verbindung zum Meeting wurde beendet.</p>
|
||||
<a
|
||||
href={`/termine/${slug}`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
|
||||
>
|
||||
Zurück zum Termin
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isHost = session.role === "host";
|
||||
const remoteList = [...remotes.values()];
|
||||
const local = participants[0];
|
||||
const remoteParticipants = participants.slice(1);
|
||||
const screenOwner = participants.find((participant) => participant.screenTrack);
|
||||
const activeSpeaker = participants.find((participant) => participant.identity === activeSpeakerId) ?? remoteParticipants[0] ?? local;
|
||||
const tile = (participant: ParticipantView, options: { compact?: boolean; videoTrack?: Track | null; name?: string } = {}) => (
|
||||
<SfuVideoTile
|
||||
key={`${participant.identity}-${options.name ?? "camera"}`}
|
||||
videoTrack={options.videoTrack ?? participant.cameraTrack}
|
||||
audioTrack={participant.microphoneTrack}
|
||||
name={options.name ?? participant.name}
|
||||
isLocal={participant.identity === local?.identity}
|
||||
isHost={participant.role === "host"}
|
||||
muted={!participant.microphoneEnabled}
|
||||
videoOff={!participant.cameraEnabled && !options.videoTrack}
|
||||
speaking={participant.speaking}
|
||||
compact={options.compact}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[80vh] flex-col bg-neutral-900 text-white">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3">
|
||||
<span className="text-sm font-medium">{session.eventTitle}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowParticipants((v) => !v)}
|
||||
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium hover:bg-white/20"
|
||||
>
|
||||
Teilnehmer ({remoteList.length + 1})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="bg-amber-900/50 px-5 py-2 text-center text-xs text-amber-100">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1">
|
||||
<div className="grid flex-1 auto-rows-fr grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<VideoTile stream={localStream} name={session.name} isLocal isHost={isHost} muted={!micOn} videoOff={!camOn} />
|
||||
{remoteList.map((r) => (
|
||||
<VideoTile key={r.participantId} stream={r.stream} name={r.name} isHost={r.role === "host"} />
|
||||
))}
|
||||
<main className="flex min-h-screen flex-col bg-neutral-950 text-white">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-white/10 px-5 py-4">
|
||||
<div><p className="text-sm font-medium">{session.eventTitle}</p><p className="text-xs text-white/45">LiveKit-SFU · {participants.length} Teilnehmer</p></div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(["grid", "speaker", "screen"] as LayoutMode[]).map((mode) => <button key={mode} type="button" onClick={() => setLayout(mode)} aria-pressed={layout === mode} className={`rounded-full px-3 py-2 text-xs ${layout === mode ? "bg-white text-neutral-900" : "bg-white/10 text-white/75 hover:bg-white/15"}`}>{mode === "grid" ? "Galerie" : mode === "speaker" ? "Sprecher" : "Bildschirm"}</button>)}
|
||||
<button type="button" onClick={() => setShowParticipants((value) => !value)} className="rounded-full bg-white/10 px-3 py-2 text-xs hover:bg-white/15">Teilnehmer ({participants.length})</button>
|
||||
</div>
|
||||
|
||||
{showParticipants && (
|
||||
<aside className="w-64 shrink-0 border-l border-white/10 p-4">
|
||||
<h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-white/60">Teilnehmer</h2>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center justify-between text-sm">
|
||||
<span>● {session.name} (du)</span>
|
||||
</li>
|
||||
{remoteList.map((r) => (
|
||||
<li key={r.participantId} className="flex items-center justify-between text-sm">
|
||||
<span>● {r.name}</span>
|
||||
{isHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => kickParticipant(r.participantId)}
|
||||
className="text-xs text-white/50 hover:text-red-300"
|
||||
>
|
||||
entfernen
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
</header>
|
||||
{errorMessage && <p className="bg-amber-900/50 px-5 py-2 text-center text-xs text-amber-100">{errorMessage}</p>}
|
||||
<div className="flex flex-1 flex-col lg:flex-row">
|
||||
<section className="flex-1 p-4">
|
||||
{layout === "grid" && <div className="grid auto-rows-fr grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">{participants.map((participant) => tile(participant, { videoTrack: participant.screenTrack ?? participant.cameraTrack, name: participant.screenTrack ? `${participant.name} · Bildschirm` : participant.name }))}</div>}
|
||||
{layout === "speaker" && activeSpeaker && <div className="space-y-3"><div className="mx-auto max-w-5xl">{tile(activeSpeaker, { videoTrack: activeSpeaker.screenTrack ?? activeSpeaker.cameraTrack })}</div><div className="grid grid-cols-2 gap-3 sm:grid-cols-4">{participants.filter((participant) => participant.identity !== activeSpeaker.identity).map((participant) => tile(participant, { compact: true }))}</div></div>}
|
||||
{layout === "screen" && <div className="space-y-3">{screenOwner ? <div className="mx-auto max-w-6xl">{tile(screenOwner, { videoTrack: screenOwner.screenTrack, name: `${screenOwner.name} · Bildschirm` })}</div> : <div className="flex aspect-video items-center justify-center rounded-3xl border border-dashed border-white/15 text-sm text-white/45">Noch niemand teilt den Bildschirm.</div>}<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">{participants.map((participant) => tile(participant, { compact: true }))}</div></div>}
|
||||
</section>
|
||||
{showParticipants && <aside className="w-full border-t border-white/10 p-4 lg:w-72 lg:border-l lg:border-t-0"><h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-white/55">Teilnehmer</h2><ul className="space-y-3">{participants.map((participant) => <li key={participant.identity} className="flex items-center justify-between gap-2 text-sm"><span className="truncate">● {participant.name}{participant.identity === local?.identity && " (du)"}</span>{session.role === "host" && participant.identity !== local?.identity && <button type="button" onClick={() => void kickParticipant(participant.identity)} className="shrink-0 text-xs text-white/50 hover:text-red-300">entfernen</button>}</li>)}</ul></aside>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
|
||||
<ControlButton active={micOn} onClick={toggleMic} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" />
|
||||
<ControlButton active={camOn} onClick={toggleCam} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" />
|
||||
{isHost && (
|
||||
<ControlButton
|
||||
active={sharingScreen}
|
||||
onClick={toggleScreenShare}
|
||||
label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"}
|
||||
icon="screen"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={leaveMeeting}
|
||||
className="rounded-full bg-red-700 px-6 py-3 text-sm font-medium text-white hover:bg-red-800"
|
||||
>
|
||||
Meeting verlassen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="flex flex-wrap items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
|
||||
<ControlButton active={micOn} onClick={() => void toggleMic()} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" />
|
||||
<ControlButton active={camOn} onClick={() => void toggleCamera()} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" />
|
||||
{session.role === "host" && <ControlButton active={sharingScreen} onClick={() => void toggleScreenShare()} label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"} icon="screen" />}
|
||||
<button type="button" onClick={leaveMeeting} className="rounded-full bg-red-700 px-6 py-3 text-sm font-medium text-white hover:bg-red-800">Meeting verlassen</button>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
icon,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
icon: "mic" | "cam" | "screen";
|
||||
}) {
|
||||
function ControlButton({ active, onClick, label, icon }: { active: boolean; onClick: () => void; label: string; icon: "mic" | "cam" | "screen" }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-pressed={active}
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full transition-colors ${
|
||||
active ? "bg-white/15 hover:bg-white/25" : "bg-red-700 hover:bg-red-800"
|
||||
}`}
|
||||
>
|
||||
<button type="button" onClick={onClick} title={label} aria-pressed={active} className={`flex h-12 w-12 items-center justify-center rounded-full transition-colors ${active ? "bg-white/15 hover:bg-white/25" : "bg-red-700 hover:bg-red-800"}`}>
|
||||
<span className="sr-only">{label}</span>
|
||||
<Icon name={icon} off={!active} />
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{icon === "mic" && <><path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3M8 21h8" /></>}
|
||||
{icon === "cam" && <><path d="M4 7h11a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Z" /><path d="m17 10 5-3v10l-5-3" /></>}
|
||||
{icon === "screen" && <><rect x="3" y="4" width="18" height="12" rx="1.5" /><path d="M8 20h8M12 16v4" /></>}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ name, off }: { name: "mic" | "cam" | "screen"; off: boolean }) {
|
||||
if (name === "mic") {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
|
||||
<path
|
||||
d="M12 15a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Zm5-3a5 5 0 0 1-10 0M12 18v3"
|
||||
stroke="white"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (name === "cam") {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" stroke="white" strokeWidth="1.8" />
|
||||
<path d="M16 10l6-3v10l-6-3" stroke="white" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="4" width="20" height="13" rx="2" stroke="white" strokeWidth="1.8" />
|
||||
<path d="M8 21h8M12 17v4" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { TextField, FormError } from "@/components/FormField";
|
||||
import { fieldClass, TextField, FormError } from "@/components/FormField";
|
||||
|
||||
export const MEETING_SESSION_KEY = "anouma-meeting-session";
|
||||
const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
|
||||
@@ -28,18 +28,20 @@ function getOrCreateClientId(): string {
|
||||
|
||||
export type StoredMeetingSession = {
|
||||
token: string;
|
||||
participantToken?: string;
|
||||
serverUrl?: string;
|
||||
participantId: string;
|
||||
role: "host" | "participant";
|
||||
name: string;
|
||||
eventTitle: string;
|
||||
eventSlug: string;
|
||||
iceServers: RTCIceServer[];
|
||||
};
|
||||
|
||||
export function JoinForm({ slug }: { slug: string }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -63,12 +65,13 @@ export function JoinForm({ slug }: { slug: string }) {
|
||||
|
||||
const session: StoredMeetingSession = {
|
||||
token: data.token,
|
||||
participantToken: data.participantToken,
|
||||
serverUrl: data.serverUrl,
|
||||
participantId: data.participantId,
|
||||
role: data.role,
|
||||
name,
|
||||
eventTitle: data.eventTitle,
|
||||
eventSlug: slug,
|
||||
iceServers: data.iceServers,
|
||||
};
|
||||
sessionStorage.setItem(MEETING_SESSION_KEY, JSON.stringify(session));
|
||||
router.push(`/termine/${slug}/call`);
|
||||
@@ -90,16 +93,31 @@ export function JoinForm({ slug }: { slug: string }) {
|
||||
placeholder="Name eingeben"
|
||||
autoComplete="name"
|
||||
/>
|
||||
<TextField
|
||||
label="Meeting-Passwort"
|
||||
id="password"
|
||||
type="text"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-anouma-plum">
|
||||
Meeting-Passwort
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-anouma-mauve-dark underline-offset-2 hover:underline"
|
||||
onClick={() => setShowPassword((visible) => !visible)}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
{showPassword ? "Verbergen" : "Anzeigen"}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
className={fieldClass}
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{error && <FormError>{error}</FormError>}
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createLocalTracks, Track, type LocalTrack } from "livekit-client";
|
||||
|
||||
export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[]) => void }) {
|
||||
const [tracks, setTracks] = useState<LocalTrack[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [micOn, setMicOn] = useState(true);
|
||||
const [cameraOn, setCameraOn] = useState(true);
|
||||
const [speakerTested, setSpeakerTested] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const handedOffRef = useRef(false);
|
||||
|
||||
const videoTrack = tracks.find((track) => track.kind === Track.Kind.Video);
|
||||
const audioTrack = tracks.find((track) => track.kind === Track.Kind.Audio);
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement || !videoTrack) return;
|
||||
videoTrack.attach(videoElement);
|
||||
return () => {
|
||||
videoTrack.detach(videoElement);
|
||||
};
|
||||
}, [videoTrack]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!handedOffRef.current) tracks.forEach((track) => track.stop());
|
||||
};
|
||||
}, [tracks]);
|
||||
|
||||
async function requestDevices() {
|
||||
tracks.forEach((track) => track.stop());
|
||||
setError(null);
|
||||
try {
|
||||
const nextTracks = await createLocalTracks({ audio: true, video: true });
|
||||
setTracks(nextTracks);
|
||||
setMicOn(true);
|
||||
setCameraOn(true);
|
||||
} catch {
|
||||
setError("Kamera oder Mikrofon konnten nicht freigegeben werden. Du kannst trotzdem ohne sie beitreten.");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMic() {
|
||||
if (!audioTrack) return;
|
||||
if (micOn) await audioTrack.mute();
|
||||
else await audioTrack.unmute();
|
||||
setMicOn(!micOn);
|
||||
}
|
||||
|
||||
async function toggleCamera() {
|
||||
if (!videoTrack) return;
|
||||
if (cameraOn) await videoTrack.mute();
|
||||
else await videoTrack.unmute();
|
||||
setCameraOn(!cameraOn);
|
||||
}
|
||||
|
||||
function testSpeaker() {
|
||||
const AudioContextClass = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
const context = new AudioContextClass();
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.frequency.value = 660;
|
||||
gain.gain.value = 0.08;
|
||||
oscillator.connect(gain).connect(context.destination);
|
||||
oscillator.start();
|
||||
oscillator.stop(context.currentTime + 0.25);
|
||||
oscillator.addEventListener("ended", () => void context.close());
|
||||
setSpeakerTested(true);
|
||||
}
|
||||
|
||||
function continueToCall() {
|
||||
handedOffRef.current = true;
|
||||
onContinue(tracks);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-neutral-950 px-5 py-10 text-white">
|
||||
<section className="w-full max-w-3xl rounded-[2rem] border border-white/10 bg-neutral-900 p-6 shadow-2xl sm:p-8">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-white/50">Vorbereitung</p>
|
||||
<h1 className="mt-2 font-serif text-3xl">Prüfe deine Geräte</h1>
|
||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-white/65">
|
||||
Erlaube Kamera und Mikrofon, teste kurz den Ton und tritt dann dem geschützten Meeting bei.
|
||||
</p>
|
||||
|
||||
<div className="mt-7 grid gap-5 lg:grid-cols-[1.35fr_1fr]">
|
||||
<div className="relative aspect-video overflow-hidden rounded-3xl bg-neutral-800">
|
||||
{videoTrack && cameraOn ? (
|
||||
<video ref={videoRef} autoPlay muted playsInline className="h-full w-full object-cover [transform:scaleX(-1)]" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-white/45">Kamera ausgeschaltet</div>
|
||||
)}
|
||||
{tracks.length > 0 && (
|
||||
<span className="absolute bottom-3 left-3 rounded-full bg-black/50 px-3 py-1 text-xs text-white/80">
|
||||
{micOn ? "Mikrofon an" : "Mikrofon aus"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestDevices}
|
||||
className="rounded-full bg-white px-5 py-3 text-sm font-medium text-neutral-900 hover:bg-white/85"
|
||||
>
|
||||
{tracks.length > 0 ? "Geräte erneut prüfen" : "Kamera & Mikrofon aktivieren"}
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button type="button" onClick={() => void toggleMic()} disabled={!audioTrack} className="rounded-2xl bg-white/10 px-4 py-3 text-xs hover:bg-white/15 disabled:opacity-40">
|
||||
{micOn ? "Mikrofon aus" : "Mikrofon an"}
|
||||
</button>
|
||||
<button type="button" onClick={() => void toggleCamera()} disabled={!videoTrack} className="rounded-2xl bg-white/10 px-4 py-3 text-xs hover:bg-white/15 disabled:opacity-40">
|
||||
{cameraOn ? "Kamera aus" : "Kamera an"}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={testSpeaker} className="rounded-2xl border border-white/15 px-4 py-3 text-left text-xs text-white/80 hover:bg-white/10">
|
||||
{speakerTested ? "✓ Lautsprecher getestet" : "Lautsprecher testen"}
|
||||
</button>
|
||||
{error && <p role="alert" className="text-xs leading-relaxed text-amber-200">{error}</p>}
|
||||
<button type="button" onClick={continueToCall} className="mt-2 rounded-full bg-anouma-mauve-dark px-5 py-3.5 text-sm font-medium hover:bg-anouma-plum">
|
||||
Meeting beitreten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Track } from "livekit-client";
|
||||
|
||||
export function SfuVideoTile({
|
||||
videoTrack,
|
||||
audioTrack,
|
||||
name,
|
||||
isLocal = false,
|
||||
isHost = false,
|
||||
muted = false,
|
||||
videoOff = false,
|
||||
speaking = false,
|
||||
compact = false,
|
||||
}: {
|
||||
videoTrack?: Track | null;
|
||||
audioTrack?: Track | null;
|
||||
name: string;
|
||||
isLocal?: boolean;
|
||||
isHost?: boolean;
|
||||
muted?: boolean;
|
||||
videoOff?: boolean;
|
||||
speaking?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement || !videoTrack || videoOff) return;
|
||||
videoTrack.attach(videoElement);
|
||||
return () => {
|
||||
videoTrack.detach(videoElement);
|
||||
};
|
||||
}, [videoTrack, videoOff]);
|
||||
|
||||
useEffect(() => {
|
||||
const audioElement = audioRef.current;
|
||||
if (!audioElement || !audioTrack || isLocal) return;
|
||||
audioTrack.attach(audioElement);
|
||||
return () => {
|
||||
audioTrack.detach(audioElement);
|
||||
};
|
||||
}, [audioTrack, isLocal]);
|
||||
|
||||
return (
|
||||
<div className={`relative aspect-video overflow-hidden rounded-2xl bg-neutral-800 ${speaking ? "ring-2 ring-anouma-rose" : ""}`}>
|
||||
{videoTrack && !videoOff ? (
|
||||
<video ref={videoRef} autoPlay playsInline muted={isLocal} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-800">
|
||||
<div className={`${compact ? "h-10 w-10 text-lg" : "h-16 w-16 text-2xl"} flex items-center justify-center rounded-full bg-neutral-700 font-serif text-white`}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLocal && <audio ref={audioRef} autoPlay />}
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/75 to-transparent px-3 py-2">
|
||||
<span className="truncate text-sm font-medium text-white">{name}{isLocal && " (du)"}</span>
|
||||
<div className="flex items-center gap-2 text-white/80">
|
||||
{muted && <span aria-label="Mikrofon stumm">⌁</span>}
|
||||
{isHost && <span className="rounded-full bg-anouma-mauve-dark/90 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">Host</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,29 @@ services:
|
||||
# HEALTHCHECK instruction (GET /api/health, which itself checks Postgres
|
||||
# connectivity) — nothing to duplicate here.
|
||||
|
||||
# Self-hosted LiveKit SFU. Host networking is intentional: WebRTC media
|
||||
# needs the UDP port range below to be reachable directly from the internet.
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
LIVEKIT_CONFIG: |
|
||||
port: 7880
|
||||
log_level: info
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
port_range_start: 50000
|
||||
port_range_end: 60000
|
||||
use_external_ip: true
|
||||
keys:
|
||||
${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}
|
||||
turn:
|
||||
enabled: true
|
||||
udp_port: 3478
|
||||
|
||||
# Optional external network to a reverse proxy (e.g. Nginx Proxy Manager)
|
||||
# is attached via docker-compose.override.yml, generated by setup.sh only
|
||||
# when such a network is actually detected — see DEPLOYMENT.md. Nothing
|
||||
|
||||
@@ -18,7 +18,7 @@ export const MeetingSettings: GlobalConfig = {
|
||||
type: "number",
|
||||
label: "Teilnehmer dürfen beitreten (Minuten vor Beginn)",
|
||||
required: true,
|
||||
defaultValue: 30,
|
||||
defaultValue: 10,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AdminCalendarItem = {
|
||||
endTime: string;
|
||||
status: string; // booking status, or "public" for a standalone Event
|
||||
appointmentType?: "onsite" | "online";
|
||||
callEventSlug?: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
@@ -61,6 +62,10 @@ export async function getAdminCalendarItems(
|
||||
endTime: b.endTime,
|
||||
status: b.status,
|
||||
appointmentType: b.appointmentType,
|
||||
callEventSlug:
|
||||
b.appointmentType === "online" && typeof b.linkedEvent === "object" && b.linkedEvent?.slug
|
||||
? b.linkedEvent.slug
|
||||
: undefined,
|
||||
href: `/admin/collections/booking-requests/${b.id}`,
|
||||
};
|
||||
});
|
||||
@@ -74,6 +79,7 @@ export async function getAdminCalendarItems(
|
||||
endTime: e.endTime ?? e.date,
|
||||
status: "public",
|
||||
appointmentType: e.isOnline ? "online" : "onsite",
|
||||
callEventSlug: e.isOnline && e.slug ? e.slug : undefined,
|
||||
href: `/admin/collections/events/${e.id}`,
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BookingRequest, Event, MeetingSetting } from "@/payload-types";
|
||||
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, type MeetingStatus } from "@/lib/meeting/status";
|
||||
|
||||
export function getBookingMeetingStatus(booking: BookingRequest, settings: MeetingSetting): MeetingStatus | null {
|
||||
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
|
||||
if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null;
|
||||
|
||||
const start = combineDateAndTime(booking.date, booking.startTime);
|
||||
const end = booking.endTime
|
||||
? combineDateAndTime(booking.date, booking.endTime)
|
||||
: new Date(start.getTime() + 60 * 60_000);
|
||||
return getMeetingStatus({
|
||||
start,
|
||||
end,
|
||||
joinWindowMinutes: settings.participantJoinWindowMinutes,
|
||||
closeAfterMinutes: settings.meetingCloseAfterMinutes,
|
||||
});
|
||||
}
|
||||
|
||||
export function canJoinBookingMeeting(booking: BookingRequest, settings: MeetingSetting): boolean {
|
||||
const status = getBookingMeetingStatus(booking, settings);
|
||||
return status !== null && canJoinMeeting(status);
|
||||
}
|
||||
+1
-34
@@ -1,10 +1,9 @@
|
||||
import type { Payload } from "payload";
|
||||
import { WEEKDAYS } from "@/collections/Availability";
|
||||
import { berlinParts, berlinWallTimeToUTC, type CalendarDate } from "@/lib/calendar/timezone";
|
||||
|
||||
export type TimeSlot = { start: Date; end: Date };
|
||||
|
||||
const BERLIN_TZ = "Europe/Berlin";
|
||||
|
||||
const WEEKDAY_BY_INDEX = [
|
||||
"sunday",
|
||||
"monday",
|
||||
@@ -15,44 +14,12 @@ const WEEKDAY_BY_INDEX = [
|
||||
"saturday",
|
||||
] as const;
|
||||
|
||||
type CalendarDate = { year: number; month: number; day: number };
|
||||
|
||||
// Availability/AvailabilityOverrides times are entered by the admin in their
|
||||
// browser (Europe/Berlin) and stored as UTC instants. All wall-clock reads
|
||||
// and writes here go through Intl's Berlin timezone data (not local Date
|
||||
// accessors, and not manual UTC+1/+2 arithmetic), so the server process's
|
||||
// own timezone (this app runs in UTC in production) never leaks into the
|
||||
// computed availability — see lib/calendar/timezone.ts for the same pattern.
|
||||
function berlinParts(date: Date): CalendarDate & { hour: number; minute: number } {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: BERLIN_TZ,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
|
||||
return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour"), minute: get("minute") };
|
||||
}
|
||||
|
||||
function berlinOffsetMinutes(date: Date): number {
|
||||
const tzName = new Intl.DateTimeFormat("en-US", { timeZone: BERLIN_TZ, timeZoneName: "shortOffset" })
|
||||
.formatToParts(date)
|
||||
.find((p) => p.type === "timeZoneName")?.value ?? "GMT+1";
|
||||
const match = tzName.match(/GMT([+-]\d+)(?::(\d+))?/);
|
||||
const hours = match ? Number(match[1]) : 1;
|
||||
const minutes = match?.[2] ? Number(match[2]) : 0;
|
||||
return hours * 60 + (hours < 0 ? -minutes : minutes);
|
||||
}
|
||||
|
||||
/** Converts a Europe/Berlin wall-clock date+time into the UTC instant it represents. */
|
||||
function berlinWallTimeToUTC(date: CalendarDate, hour: number, minute: number): Date {
|
||||
const guess = Date.UTC(date.year, date.month - 1, date.day, hour, minute);
|
||||
return new Date(guess - berlinOffsetMinutes(new Date(guess)) * 60_000);
|
||||
}
|
||||
|
||||
function dateKey(date: Date): string {
|
||||
const { year, month, day } = berlinParts(date);
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
|
||||
@@ -1,5 +1,59 @@
|
||||
const BERLIN_TZ = "Europe/Berlin";
|
||||
|
||||
export type CalendarDate = { year: number; month: number; day: number };
|
||||
|
||||
export function berlinDateKey(date: Date): string {
|
||||
const { year, month, day } = berlinParts(date);
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatBerlinDate(date: Date, options: Intl.DateTimeFormatOptions = {}): string {
|
||||
return new Intl.DateTimeFormat("de-DE", { ...options, timeZone: BERLIN_TZ }).format(date);
|
||||
}
|
||||
|
||||
export function formatBerlinTime(date: Date, options: Intl.DateTimeFormatOptions = {}): string {
|
||||
return new Intl.DateTimeFormat("de-DE", { ...options, timeZone: BERLIN_TZ }).format(date);
|
||||
}
|
||||
|
||||
export function berlinParts(date: Date): CalendarDate & { hour: number; minute: number } {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: BERLIN_TZ,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const get = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0");
|
||||
return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour"), minute: get("minute") };
|
||||
}
|
||||
|
||||
export function berlinOffsetMinutes(date: Date): number {
|
||||
const tzName = new Intl.DateTimeFormat("en-US", { timeZone: BERLIN_TZ, timeZoneName: "shortOffset" })
|
||||
.formatToParts(date)
|
||||
.find((part) => part.type === "timeZoneName")?.value ?? "GMT+1";
|
||||
const match = tzName.match(/GMT([+-]\d+)(?::(\d+))?/);
|
||||
const hours = match ? Number(match[1]) : 1;
|
||||
const minutes = match?.[2] ? Number(match[2]) : 0;
|
||||
return hours * 60 + (hours < 0 ? -minutes : minutes);
|
||||
}
|
||||
|
||||
/** Converts a Berlin wall-clock date/time into the UTC instant it represents. */
|
||||
export function berlinWallTimeToUTC(date: CalendarDate, hour: number, minute: number): Date {
|
||||
const guess = Date.UTC(date.year, date.month - 1, date.day, hour, minute);
|
||||
return new Date(guess - berlinOffsetMinutes(new Date(guess)) * 60_000);
|
||||
}
|
||||
|
||||
export function berlinDayBounds(date: Date): { key: string; start: Date; end: Date } {
|
||||
const current = berlinParts(date);
|
||||
const nextCalendarDay = new Date(Date.UTC(current.year, current.month - 1, current.day + 1));
|
||||
const next = berlinParts(nextCalendarDay);
|
||||
const start = berlinWallTimeToUTC(current, 0, 0);
|
||||
const nextStart = berlinWallTimeToUTC(next, 0, 0);
|
||||
return { key: berlinDateKey(date), start, end: new Date(nextStart.getTime() - 1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JS Date (a UTC instant) into its Europe/Berlin wall-clock
|
||||
* representation for iCalendar's local `TZID` date-time form
|
||||
|
||||
+6
-4
@@ -1,14 +1,16 @@
|
||||
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||
|
||||
/** Day + short month, e.g. { day: "15", month: "SEP" } — for the date badge on event cards. */
|
||||
export function formatEventDateParts(iso: string) {
|
||||
const date = new Date(iso);
|
||||
return {
|
||||
day: date.toLocaleDateString("de-DE", { day: "2-digit" }),
|
||||
month: date.toLocaleDateString("de-DE", { month: "short" }).toUpperCase().replace(".", ""),
|
||||
day: formatBerlinDate(date, { day: "2-digit" }),
|
||||
month: formatBerlinDate(date, { month: "short" }).toUpperCase().replace(".", ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatFullDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
return formatBerlinDate(new Date(iso), {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
@@ -17,7 +19,7 @@ export function formatFullDate(iso: string) {
|
||||
}
|
||||
|
||||
export function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
return formatBerlinTime(new Date(iso), { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function formatTimeRange(start?: string | null, end?: string | null) {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Builds the WebRTC ICE server list from environment variables so a TURN
|
||||
* server can be added later without any code changes (see DEPLOYMENT.md).
|
||||
* P2P works fine with just STUN for most networks; TURN becomes necessary
|
||||
* behind restrictive NATs/firewalls.
|
||||
*/
|
||||
export function getIceServers(): RTCIceServer[] {
|
||||
const servers: RTCIceServer[] = [
|
||||
{ urls: process.env.STUN_SERVER || "stun:stun.l.google.com:19302" },
|
||||
];
|
||||
|
||||
const turnServer = process.env.TURN_SERVER;
|
||||
if (turnServer) {
|
||||
servers.push({
|
||||
urls: turnServer,
|
||||
username: process.env.TURN_USERNAME || undefined,
|
||||
credential: process.env.TURN_PASSWORD || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const KICK_RETENTION_MS = 24 * 60 * 60_000;
|
||||
type KickList = { ids: Set<string>; expiresAt: number };
|
||||
const kickedParticipantIds = new Map<string, KickList>();
|
||||
const kickedClientIds = new Map<string, KickList>();
|
||||
|
||||
function activeList(map: Map<string, KickList>, eventSlug: string): Set<string> | undefined {
|
||||
const entry = map.get(eventSlug);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
map.delete(eventSlug);
|
||||
return undefined;
|
||||
}
|
||||
return entry.ids;
|
||||
}
|
||||
|
||||
export function isMeetingParticipantKicked(eventSlug: string, participantId: string, clientId?: string): boolean {
|
||||
return Boolean(
|
||||
activeList(kickedParticipantIds, eventSlug)?.has(participantId) ||
|
||||
(clientId && activeList(kickedClientIds, eventSlug)?.has(clientId)),
|
||||
);
|
||||
}
|
||||
|
||||
export function markMeetingParticipantKicked(eventSlug: string, participantId: string, clientId?: string): void {
|
||||
const expiresAt = Date.now() + KICK_RETENTION_MS;
|
||||
const participantIds = activeList(kickedParticipantIds, eventSlug) ?? new Set<string>();
|
||||
participantIds.add(participantId);
|
||||
kickedParticipantIds.set(eventSlug, { ids: participantIds, expiresAt });
|
||||
if (clientId) {
|
||||
const clientIds = activeList(kickedClientIds, eventSlug) ?? new Set<string>();
|
||||
clientIds.add(clientId);
|
||||
kickedClientIds.set(eventSlug, { ids: clientIds, expiresAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { AccessToken, RoomServiceClient, TrackSource } from "livekit-server-sdk";
|
||||
import type { MeetingRole } from "./token";
|
||||
|
||||
function requiredEnv(name: "LIVEKIT_URL" | "LIVEKIT_API_KEY" | "LIVEKIT_API_SECRET"): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is not set`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getLiveKitClientUrl(): string {
|
||||
return requiredEnv("LIVEKIT_URL").replace(/^http:/, "ws:").replace(/^https:/, "wss:").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function getLiveKitApiUrl(): string {
|
||||
return requiredEnv("LIVEKIT_URL").replace(/^ws:/, "http:").replace(/^wss:/, "https:").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/** Stable opaque room name; the public event slug is never sent to LiveKit logs. */
|
||||
export function getLiveKitRoomName(eventSlug: string): string {
|
||||
const secret = requiredEnv("LIVEKIT_API_SECRET");
|
||||
return `anouma-${createHmac("sha256", secret).update(eventSlug).digest("hex").slice(0, 32)}`;
|
||||
}
|
||||
|
||||
export async function createLiveKitToken(args: {
|
||||
eventSlug: string;
|
||||
participantId: string;
|
||||
name: string;
|
||||
role: MeetingRole;
|
||||
clientId?: string;
|
||||
}): Promise<string> {
|
||||
const room = getLiveKitRoomName(args.eventSlug);
|
||||
const token = new AccessToken(requiredEnv("LIVEKIT_API_KEY"), requiredEnv("LIVEKIT_API_SECRET"), {
|
||||
identity: args.participantId,
|
||||
name: args.name,
|
||||
ttl: "2h",
|
||||
metadata: JSON.stringify({ role: args.role, clientId: args.clientId }),
|
||||
});
|
||||
token.addGrant({
|
||||
roomJoin: true,
|
||||
room,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
canPublishSources: args.role === "host"
|
||||
? [TrackSource.CAMERA, TrackSource.MICROPHONE, TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO]
|
||||
: [TrackSource.CAMERA, TrackSource.MICROPHONE],
|
||||
});
|
||||
return token.toJwt();
|
||||
}
|
||||
|
||||
export function getLiveKitRoomService(): RoomServiceClient {
|
||||
return new RoomServiceClient(getLiveKitApiUrl(), requiredEnv("LIVEKIT_API_KEY"), requiredEnv("LIVEKIT_API_SECRET"));
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { MeetingRole } from "./token";
|
||||
|
||||
export type ParticipantSummary = {
|
||||
participantId: string;
|
||||
name: string;
|
||||
role: MeetingRole;
|
||||
};
|
||||
|
||||
export type ClientToServerMessage =
|
||||
| { type: "auth"; token: string }
|
||||
| { type: "offer"; to: string; payload: RTCSessionDescriptionInit }
|
||||
| { type: "answer"; to: string; payload: RTCSessionDescriptionInit }
|
||||
| { type: "ice-candidate"; to: string; payload: RTCIceCandidateInit }
|
||||
| { type: "kick"; targetParticipantId: string };
|
||||
|
||||
export type ServerToClientMessage =
|
||||
| { type: "welcome"; participantId: string; role: MeetingRole; participants: ParticipantSummary[] }
|
||||
| ({ type: "peer-joined" } & ParticipantSummary)
|
||||
| { type: "peer-left"; participantId: string }
|
||||
| { type: "offer"; from: string; payload: RTCSessionDescriptionInit }
|
||||
| { type: "answer"; from: string; payload: RTCSessionDescriptionInit }
|
||||
| { type: "ice-candidate"; from: string; payload: RTCIceCandidateInit }
|
||||
| { type: "kicked" }
|
||||
| { type: "auth-error"; reason: string };
|
||||
@@ -2,6 +2,7 @@ import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { combineDateAndTime } from "./status";
|
||||
import { sendReminderEmail } from "@/lib/email/sendReminderEmail";
|
||||
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||
|
||||
const REMINDER_TOLERANCE_MS = 10 * 60 * 1000; // cron may run every few minutes
|
||||
|
||||
@@ -10,11 +11,11 @@ function isDue(reminderAt: number, now: number): boolean {
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
|
||||
return formatBerlinDate(date, { day: "2-digit", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
return formatBerlinTime(date, { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import type { MeetingRole } from "./token";
|
||||
|
||||
export type Participant = {
|
||||
ws: WebSocket;
|
||||
participantId: string;
|
||||
clientId?: string;
|
||||
name: string;
|
||||
role: MeetingRole;
|
||||
};
|
||||
|
||||
type Room = {
|
||||
participants: Map<string, Participant>;
|
||||
kickedParticipantIds: Set<string>;
|
||||
// Keyed by the client-declared, localStorage-persisted clientId (see
|
||||
// token.ts) rather than the per-connection participantId, so a kick
|
||||
// survives the participant simply reconnecting (which always mints a
|
||||
// fresh participantId) — see isKicked below.
|
||||
kickedClientIds: Set<string>;
|
||||
};
|
||||
|
||||
// In-memory only — this server process is the single source of truth for
|
||||
// signaling (no media is ever stored or relayed through it). If this app is
|
||||
// ever scaled to multiple instances, this state needs to move to a shared
|
||||
// store (e.g. Redis pub/sub) alongside a real SFU.
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
function getOrCreateRoom(eventSlug: string): Room {
|
||||
let room = rooms.get(eventSlug);
|
||||
if (!room) {
|
||||
room = { participants: new Map(), kickedParticipantIds: new Set(), kickedClientIds: new Set() };
|
||||
rooms.set(eventSlug, room);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
export function isKicked(eventSlug: string, participantId: string, clientId?: string): boolean {
|
||||
const room = rooms.get(eventSlug);
|
||||
if (!room) return false;
|
||||
if (room.kickedParticipantIds.has(participantId)) return true;
|
||||
return clientId ? room.kickedClientIds.has(clientId) : false;
|
||||
}
|
||||
|
||||
export function joinRoom(eventSlug: string, participant: Participant): Participant[] {
|
||||
const room = getOrCreateRoom(eventSlug);
|
||||
const existing = [...room.participants.values()];
|
||||
// Two connections claiming the same participantId (e.g. two tabs replaying
|
||||
// the same stored session) would otherwise silently overwrite the map
|
||||
// entry here and orphan the first socket — close it first so it doesn't
|
||||
// leak as an unreachable broadcast target.
|
||||
room.participants.get(participant.participantId)?.ws.close(4002, "replaced-by-new-connection");
|
||||
room.participants.set(participant.participantId, participant);
|
||||
return existing;
|
||||
}
|
||||
|
||||
export function leaveRoom(eventSlug: string, participantId: string) {
|
||||
const room = rooms.get(eventSlug);
|
||||
if (!room) return;
|
||||
room.participants.delete(participantId);
|
||||
if (room.participants.size === 0) rooms.delete(eventSlug);
|
||||
}
|
||||
|
||||
export function kickFromRoom(eventSlug: string, participantId: string) {
|
||||
const room = rooms.get(eventSlug);
|
||||
if (!room) return;
|
||||
room.kickedParticipantIds.add(participantId);
|
||||
const target = room.participants.get(participantId);
|
||||
if (target?.clientId) room.kickedClientIds.add(target.clientId);
|
||||
room.participants.delete(participantId);
|
||||
}
|
||||
|
||||
export function getParticipant(eventSlug: string, participantId: string): Participant | undefined {
|
||||
return rooms.get(eventSlug)?.participants.get(participantId);
|
||||
}
|
||||
|
||||
export function getRoomParticipants(eventSlug: string): Participant[] {
|
||||
return [...(rooms.get(eventSlug)?.participants.values() ?? [])];
|
||||
}
|
||||
|
||||
export function broadcast(
|
||||
eventSlug: string,
|
||||
message: unknown,
|
||||
opts: { exclude?: string } = {},
|
||||
) {
|
||||
const room = rooms.get(eventSlug);
|
||||
if (!room) return;
|
||||
const data = JSON.stringify(message);
|
||||
for (const p of room.participants.values()) {
|
||||
if (opts.exclude && p.participantId === opts.exclude) continue;
|
||||
if (p.ws.readyState === p.ws.OPEN) p.ws.send(data);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { WebSocket, WebSocketServer } from "ws";
|
||||
import { verifyMeetingToken } from "./token";
|
||||
import {
|
||||
broadcast,
|
||||
getParticipant,
|
||||
isKicked,
|
||||
joinRoom,
|
||||
kickFromRoom,
|
||||
leaveRoom,
|
||||
type Participant,
|
||||
} from "./rooms";
|
||||
import type { ClientToServerMessage, ParticipantSummary } from "./protocol";
|
||||
|
||||
// A connection has this long to send its "auth" message before being
|
||||
// dropped. The token travels as the first WS message rather than a
|
||||
// `?token=` query parameter so it doesn't end up persisted in reverse-proxy
|
||||
// or access logs; it's still HMAC-signed and short-lived either way.
|
||||
const AUTH_TIMEOUT_MS = 10_000;
|
||||
|
||||
function participantSummary(p: Participant): ParticipantSummary {
|
||||
return { participantId: p.participantId, name: p.name, role: p.role };
|
||||
}
|
||||
|
||||
export function attachSignalingServer(wss: WebSocketServer) {
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
let self: Participant | null = null;
|
||||
let eventSlug: string | null = null;
|
||||
|
||||
const authTimeout = setTimeout(() => {
|
||||
if (!self) ws.close(4000, "auth-timeout");
|
||||
}, AUTH_TIMEOUT_MS);
|
||||
|
||||
function handleAuth(token: string) {
|
||||
const payload = verifyMeetingToken(token);
|
||||
if (!payload) {
|
||||
ws.send(JSON.stringify({ type: "auth-error", reason: "invalid-token" }));
|
||||
ws.close(4000, "invalid-token");
|
||||
return;
|
||||
}
|
||||
if (isKicked(payload.eventSlug, payload.participantId, payload.clientId)) {
|
||||
ws.close(4001, "removed-by-host");
|
||||
return;
|
||||
}
|
||||
|
||||
eventSlug = payload.eventSlug;
|
||||
self = {
|
||||
ws,
|
||||
participantId: payload.participantId,
|
||||
clientId: payload.clientId,
|
||||
name: payload.name,
|
||||
role: payload.role,
|
||||
};
|
||||
|
||||
const existing = joinRoom(eventSlug, self);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "welcome",
|
||||
participantId: self.participantId,
|
||||
role: self.role,
|
||||
participants: existing.map(participantSummary),
|
||||
}),
|
||||
);
|
||||
|
||||
broadcast(eventSlug, { type: "peer-joined", ...participantSummary(self) }, {
|
||||
exclude: self.participantId,
|
||||
});
|
||||
}
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
let message: ClientToServerMessage;
|
||||
try {
|
||||
message = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "auth") {
|
||||
clearTimeout(authTimeout);
|
||||
if (self) return; // already authenticated on this connection
|
||||
handleAuth(message.token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self || !eventSlug) return; // no messages accepted before auth
|
||||
|
||||
if (message.type === "offer" || message.type === "answer" || message.type === "ice-candidate") {
|
||||
const target = getParticipant(eventSlug, message.to);
|
||||
if (target && target.ws.readyState === target.ws.OPEN) {
|
||||
target.ws.send(
|
||||
JSON.stringify({ type: message.type, from: self.participantId, payload: message.payload }),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "kick") {
|
||||
if (self.role !== "host") return; // server-side authority, not client-claimed
|
||||
const target = getParticipant(eventSlug, message.targetParticipantId);
|
||||
if (!target) return;
|
||||
kickFromRoom(eventSlug, target.participantId);
|
||||
target.ws.send(JSON.stringify({ type: "kicked" }));
|
||||
target.ws.close(4001, "removed-by-host");
|
||||
broadcast(eventSlug, { type: "peer-left", participantId: target.participantId });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
clearTimeout(authTimeout);
|
||||
if (!self || !eventSlug) return;
|
||||
leaveRoom(eventSlug, self.participantId);
|
||||
broadcast(eventSlug, { type: "peer-left", participantId: self.participantId });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { berlinParts, berlinWallTimeToUTC } from "@/lib/calendar/timezone";
|
||||
|
||||
export type MeetingStatus = "scheduled" | "open" | "live" | "ended";
|
||||
|
||||
/**
|
||||
@@ -9,9 +11,10 @@ export function combineDateAndTime(dateISO: string, timeISO?: string | null): Da
|
||||
const date = new Date(dateISO);
|
||||
if (!timeISO) return date;
|
||||
const time = new Date(timeISO);
|
||||
const combined = new Date(date);
|
||||
combined.setHours(time.getHours(), time.getMinutes(), 0, 0);
|
||||
return combined;
|
||||
if (Number.isNaN(date.getTime()) || Number.isNaN(time.getTime())) return new Date(NaN);
|
||||
const dateParts = berlinParts(date);
|
||||
const timeParts = berlinParts(time);
|
||||
return berlinWallTimeToUTC(dateParts, timeParts.hour, timeParts.minute);
|
||||
}
|
||||
|
||||
export type MeetingWindow = {
|
||||
|
||||
+13
-3
@@ -61,7 +61,8 @@ export function createMeetingToken(args: {
|
||||
}
|
||||
|
||||
/** Verifies signature + expiry. Returns null if invalid/expired/tampered. */
|
||||
export function verifyMeetingToken(token: string): MeetingTokenPayload | null {
|
||||
export function verifyMeetingToken(token: unknown): MeetingTokenPayload | null {
|
||||
if (typeof token !== "string" || token.length > 4096) return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 2) return null;
|
||||
const [body, signature] = parts;
|
||||
@@ -73,8 +74,17 @@ export function verifyMeetingToken(token: string): MeetingTokenPayload | null {
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload;
|
||||
if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null;
|
||||
if (!payload.eventSlug || !payload.participantId || !payload.name || !payload.role) return null;
|
||||
if (
|
||||
!Number.isFinite(payload.exp) ||
|
||||
payload.exp < Date.now() ||
|
||||
typeof payload.eventSlug !== "string" ||
|
||||
payload.eventSlug.length > 200 ||
|
||||
typeof payload.participantId !== "string" ||
|
||||
payload.participantId.length > 100 ||
|
||||
typeof payload.name !== "string" ||
|
||||
payload.name.length > 120 ||
|
||||
(payload.role !== "host" && payload.role !== "participant")
|
||||
) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cache } from "react";
|
||||
import { getCMS } from "@/lib/payload/getPayload";
|
||||
import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, CookieSetting, Home, SeoSetting } from "@/payload-types";
|
||||
import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, CookieSetting, Home, MeetingSetting, SeoSetting } from "@/payload-types";
|
||||
|
||||
export const getHomeGlobal = cache(async (): Promise<Home> => {
|
||||
const payload = await getCMS();
|
||||
@@ -37,6 +37,11 @@ export const getBookingSettingsGlobal = cache(async (): Promise<BookingSetting>
|
||||
return payload.findGlobal({ slug: "booking-settings" });
|
||||
});
|
||||
|
||||
export const getMeetingSettingsGlobal = cache(async (): Promise<MeetingSetting> => {
|
||||
const payload = await getCMS();
|
||||
return payload.findGlobal({ slug: "meeting-settings" });
|
||||
});
|
||||
|
||||
export const getSEOSettingsGlobal = cache(async (): Promise<SeoSetting> => {
|
||||
const payload = await getCMS();
|
||||
return payload.findGlobal({ slug: "seo-settings" });
|
||||
|
||||
+4
-5
@@ -23,9 +23,8 @@ const baseSecurityHeaders = [
|
||||
// Deliberately NOT applied to /admin or /api: Payload's admin bundle isn't
|
||||
// audited here for CSP compatibility, and API routes serve data, not HTML,
|
||||
// so a content policy doesn't apply to them anyway. connect-src stays broad
|
||||
// (rather than 'self') because the WebRTC call pages connect to whatever
|
||||
// STUN/TURN server is configured via env vars at runtime (see
|
||||
// lib/meeting/iceServers.ts) — that host isn't known here. style-src needs
|
||||
// because the WebRTC call pages connect to the separately hosted LiveKit SFU
|
||||
// at runtime. style-src needs
|
||||
// 'unsafe-inline' because framer-motion drives its animations through
|
||||
// inline style attributes. script-src allows 'unsafe-eval' only in dev,
|
||||
// matching Next's own documented CSP guidance — React uses eval there for
|
||||
@@ -48,8 +47,8 @@ const frontendCSP = [
|
||||
].join("; ");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// NOT "standalone": this project uses a custom server (server.ts) for the
|
||||
// WebRTC signaling WebSocket, and standalone output can't be combined with
|
||||
// NOT "standalone": this project uses a custom server (server.ts), and
|
||||
// standalone output can't be combined with
|
||||
// a custom server. The Dockerfile runs the full app via `npm start` instead.
|
||||
images: {
|
||||
localPatterns: [
|
||||
|
||||
Generated
+379
-318
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -8,7 +8,7 @@
|
||||
"build": "next build",
|
||||
"start": "NODE_ENV=production tsx server.ts",
|
||||
"lint": "eslint",
|
||||
"test": "echo \"No automated test suite configured yet — add one (e.g. vitest) and update this script.\"",
|
||||
"test": "node --import tsx --test tests/**/*.test.ts",
|
||||
"payload": "payload",
|
||||
"generate:types": "payload generate:types",
|
||||
"generate:importmap": "payload generate:importmap",
|
||||
@@ -18,22 +18,23 @@
|
||||
"seo:check": "payload run scripts/seo-check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/db-postgres": "^3.88.0",
|
||||
"@payloadcms/next": "^3.88.0",
|
||||
"@payloadcms/richtext-lexical": "^3.88.0",
|
||||
"@payloadcms/ui": "^3.88.0",
|
||||
"@payloadcms/db-postgres": "^3.89.0",
|
||||
"@payloadcms/next": "^3.89.0",
|
||||
"@payloadcms/richtext-lexical": "^3.89.0",
|
||||
"@payloadcms/ui": "^3.89.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"framer-motion": "^13.1.1",
|
||||
"graphql": "^16.14.2",
|
||||
"next": "16.3.2",
|
||||
"livekit-client": "^2.22.3",
|
||||
"livekit-server-sdk": "^2.19.0",
|
||||
"next": "^16.3.5",
|
||||
"nodemailer": "^9.0.5",
|
||||
"payload": "^3.88.0",
|
||||
"payload": "^3.89.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"recharts": "^3.10.1",
|
||||
"sharp": "^0.35.3",
|
||||
"tsx": "^4.23.12",
|
||||
"ws": "^8.21.3"
|
||||
"tsx": "^4.23.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -41,9 +42,8 @@
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.2",
|
||||
"eslint-config-next": "^16.3.5",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ export default buildConfig({
|
||||
AnalyticsSettings,
|
||||
],
|
||||
editor: lexicalEditor(),
|
||||
graphQL: {
|
||||
disablePlaygroundInProduction: true,
|
||||
disableIntrospectionInProduction: true,
|
||||
},
|
||||
secret: process.env.PAYLOAD_SECRET || "",
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, "payload-types.ts"),
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
/**
|
||||
* Custom Next.js server so we can attach a plain WebSocket server for WebRTC
|
||||
* signaling (offer/answer/ICE relay) alongside the normal Next.js request
|
||||
* handler. Standalone output mode can't be combined with a custom server
|
||||
* (see next.config.ts), so this project deploys by running this file
|
||||
* directly (`npm run start` / `npm run dev`) instead of `next start`.
|
||||
*
|
||||
* The WS server never touches audio/video/screen-share media — it only
|
||||
* relays small JSON signaling messages between participants of the same
|
||||
* meeting room (see lib/meeting/signalingServer.ts).
|
||||
* Custom Next.js server. Standalone output mode can't be combined with a
|
||||
* custom server (see next.config.ts), so production runs this file directly.
|
||||
* LiveKit handles signaling and media separately; this process only serves
|
||||
* the website and its API.
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import next from "next";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { attachSignalingServer } from "./lib/meeting/signalingServer";
|
||||
|
||||
const port = parseInt(process.env.PORT || "3000", 10);
|
||||
const hostname = process.env.HOSTNAME || "0.0.0.0";
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
|
||||
const app = next({ dev, hostname, port });
|
||||
const SIGNALING_PATH = "/ws/signaling";
|
||||
|
||||
app.prepare().then(() => {
|
||||
// Must be requested after prepare() resolves.
|
||||
@@ -30,24 +22,14 @@ app.prepare().then(() => {
|
||||
handle(req, res);
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
attachSignalingServer(wss);
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const url = req.url ?? "";
|
||||
if (url.startsWith(SIGNALING_PATH)) {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
} else {
|
||||
// Let Next.js handle its own upgrade requests (e.g. dev-mode HMR).
|
||||
upgradeHandle(req, socket, head);
|
||||
}
|
||||
// Let Next.js handle its own upgrade requests (e.g. dev-mode HMR).
|
||||
upgradeHandle(req, socket, head);
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(
|
||||
`> ANOUMA server listening at http://${hostname}:${port} (${dev ? "development" : "production"}), signaling on ${SIGNALING_PATH}`,
|
||||
`> ANOUMA server listening at http://${hostname}:${port} (${dev ? "development" : "production"})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -310,10 +310,9 @@ SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
STUN_SERVER=stun:stun.l.google.com:19302
|
||||
TURN_SERVER=
|
||||
TURN_USERNAME=
|
||||
TURN_PASSWORD=
|
||||
LIVEKIT_URL=
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
GOOGLE_SITE_VERIFICATION=
|
||||
DOCKER_NETWORK=anouma-network
|
||||
NPM_NETWORK=
|
||||
@@ -353,6 +352,14 @@ else
|
||||
log_info "NEXT_PUBLIC_SERVER_URL bereits vorhanden — unverändert."
|
||||
fi
|
||||
|
||||
if [ -z "$(env_get LIVEKIT_URL)" ]; then
|
||||
BASE_DOMAIN="${DOMAIN:-$(env_get NEXT_PUBLIC_SERVER_URL | sed -E 's#^https?://([^/]+).*#\1#')}"
|
||||
LIVEKIT_DOMAIN="$(ask "LiveKit-Domain (DNS + TLS-Reverse-Proxy nötig)" "livekit.${BASE_DOMAIN}" "LIVEKIT_DOMAIN")"
|
||||
env_set LIVEKIT_URL "https://${LIVEKIT_DOMAIN}"
|
||||
fi
|
||||
env_ensure LIVEKIT_API_KEY "LK$(openssl rand -hex 12)" && log_info "LIVEKIT_API_KEY generiert." || log_info "LIVEKIT_API_KEY bereits vorhanden — unverändert."
|
||||
env_ensure LIVEKIT_API_SECRET "$(gen_secret)" && log_info "LIVEKIT_API_SECRET generiert." || log_info "LIVEKIT_API_SECRET bereits vorhanden — unverändert."
|
||||
|
||||
if [ -z "$(env_get SMTP_HOST)" ]; then
|
||||
SETUP_SMTP="$(ask_yn "SMTP-Zugangsdaten jetzt eintragen? (sonst später manuell in .env)" "N")"
|
||||
if [ "$SETUP_SMTP" = true ]; then
|
||||
@@ -437,6 +444,9 @@ echo "ANALYTICS_SALT_SECRET: $(mask "$(env_get ANALYTICS_SALT_SECRET)")"
|
||||
echo "CRON_SECRET: $(mask "$(env_get CRON_SECRET)")"
|
||||
echo "SMTP_HOST: $(env_get SMTP_HOST)"
|
||||
echo "SMTP_PASSWORD: $(mask "$(env_get SMTP_PASSWORD)")"
|
||||
echo "LIVEKIT_URL: $(env_get LIVEKIT_URL)"
|
||||
echo "LIVEKIT_API_KEY: $(mask "$(env_get LIVEKIT_API_KEY)")"
|
||||
echo "LIVEKIT_API_SECRET: $(mask "$(env_get LIVEKIT_API_SECRET)")"
|
||||
echo "NPM_NETWORK: $(env_get NPM_NETWORK)"
|
||||
echo "REGISTRY: $(env_get REGISTRY)/$(env_get REGISTRY_REPO)"
|
||||
echo "REGISTRY_USERNAME: $(env_get REGISTRY_USERNAME)"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
process.env.MEETING_SESSION_SECRET = "test-only-meeting-secret";
|
||||
|
||||
import { berlinParts } from "../lib/calendar/timezone";
|
||||
import { combineDateAndTime, getMeetingStatus } from "../lib/meeting/status";
|
||||
import { createMeetingToken, verifyMeetingToken } from "../lib/meeting/token";
|
||||
|
||||
test("meeting tokens round-trip and preserve their role", () => {
|
||||
const issued = createMeetingToken({ eventSlug: "beratung", name: "Anna", role: "host", ttlMs: 60_000 });
|
||||
const verified = verifyMeetingToken(issued.token);
|
||||
|
||||
assert.ok(verified);
|
||||
assert.equal(verified.eventSlug, "beratung");
|
||||
assert.equal(verified.participantId, issued.participantId);
|
||||
assert.equal(verified.role, "host");
|
||||
});
|
||||
|
||||
test("tampered and malformed meeting tokens are rejected", () => {
|
||||
const issued = createMeetingToken({ eventSlug: "beratung", name: "Guest", role: "participant" });
|
||||
|
||||
assert.equal(verifyMeetingToken(`${issued.token}x`), null);
|
||||
assert.equal(verifyMeetingToken("not-a-token"), null);
|
||||
assert.equal(verifyMeetingToken({ token: issued.token }), null);
|
||||
});
|
||||
|
||||
test("date-only and time-only Payload fields combine in Berlin time", () => {
|
||||
// 08:00 in Berlin on a July date is 06:00 UTC (CEST).
|
||||
const result = combineDateAndTime("2026-07-01T00:00:00.000Z", "2026-01-01T07:00:00.000Z");
|
||||
assert.equal(result.toISOString(), "2026-07-01T06:00:00.000Z");
|
||||
});
|
||||
|
||||
test("Berlin DST offsets are applied at both sides of the summer-time boundary", () => {
|
||||
assert.equal(berlinParts(new Date("2026-01-15T12:00:00.000Z")).hour, 13);
|
||||
assert.equal(berlinParts(new Date("2026-07-15T12:00:00.000Z")).hour, 14);
|
||||
});
|
||||
|
||||
test("meeting status follows the join and close windows", () => {
|
||||
const start = new Date("2026-07-01T10:00:00.000Z");
|
||||
const end = new Date("2026-07-01T11:00:00.000Z");
|
||||
const window = { start, end, joinWindowMinutes: 15, closeAfterMinutes: 10 };
|
||||
|
||||
assert.equal(getMeetingStatus({ ...window, now: new Date("2026-07-01T09:44:59.000Z") }), "scheduled");
|
||||
assert.equal(getMeetingStatus({ ...window, now: new Date("2026-07-01T09:45:00.000Z") }), "open");
|
||||
assert.equal(getMeetingStatus({ ...window, now: start }), "live");
|
||||
assert.equal(getMeetingStatus({ ...window, now: new Date("2026-07-01T11:10:00.000Z") }), "ended");
|
||||
});
|
||||
Reference in New Issue
Block a user