3 Commits
Author SHA1 Message Date
maro 32e37d277f Add GitHub Actions build and deploy workflows
CI / test (push) Canceled after 0s
CI / build-image (push) Canceled after 0s
2026-09-16 19:40:40 +02:00
maro d0864e2911 Integrate LiveKit SFU and harden booking workflow 2026-09-16 19:38:28 +02:00
maro f1cd07b3ad CI/CD: build the Docker image once on main, promote it on release tags
CI / test (push) Failing after 11m55s
CI / build-image (push) Skipped
Previously release.yml fully rebuilt and re-ran lint/typecheck/test/build
from source on every tag push, duplicating what ci.yml had already just
verified on main. Now ci.yml builds and pushes the image (tagged by commit
SHA) once, right after its checks pass, but only for pushes to main.
release.yml no longer builds anything: it pulls that exact image, retags
it as the release version + latest, and deploys it.

This guarantees the deployed image is byte-for-byte what CI verified, and
turns "tag a commit that was never pushed to main or hasn't finished CI"
into a loud, immediate failure (no image to promote) instead of a rebuild
that silently skips the checks.
2026-09-06 22:24:07 +02:00
59 changed files with 1876 additions and 1290 deletions
+8 -7
View File
@@ -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 →
+67
View File
@@ -49,3 +49,70 @@ jobs:
env:
NODE_ENV: production
run: npm run build
# Builds once per commit that lands on main and has passed the checks
# above, and pushes it to the registry tagged by commit SHA. release.yml
# (triggered by a version tag) never rebuilds — it only promotes this
# exact, already-tested image to the release tag + `latest` and deploys
# it. This guarantees byte-for-byte that what gets deployed is what CI
# verified, and makes tagging a commit that never passed CI on main fail
# loudly (no image to promote) instead of silently rebuilding from
# source and skipping the checks.
build-image:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
# The anouma-runner runs as a Docker container itself (gitea/runner:3),
# with the host's docker.sock bind-mounted in but no `docker` CLI binary
# of its own — a job with no `container:` here runs directly inside that
# minimal runner image and gets "docker: command not found".
#
# A plain `docker:27-cli` image (Alpine, no Node.js) doesn't work either:
# actions/checkout@v4 is a JS action and needs a Node runtime in the job
# container, which that image doesn't have — it fails at Checkout with
# exit 127. Using a Node image as the base guarantees Node is already
# there (same reason the `test` job's node:22-alpine works), and the
# Docker CLI is added via apt. The runner auto-mounts the same host
# docker.sock into this container (sibling containers — see
# container.docker_host in the runner's config.yaml), so `docker
# build`/`push` operate on the host's real Docker daemon. release.yml's
# promote-and-deploy job uses the same setup, for the same reasons.
container:
image: node:22-bookworm-slim
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Docker CLI
run: |
apt-get update
apt-get install -y --no-install-recommends docker.io
rm -rf /var/lib/apt/lists/*
- name: Determine image name
id: image
run: |
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
env:
REGISTRY: git.maro.run
REGISTRY_REPO: maro/anouma
- name: Log in to registry
env:
REGISTRY_IMAGE: ${{ steps.image.outputs.registry }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin
- name: Build image
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
# Tagged by commit SHA only — `latest` is reserved for whatever was
# last actually released (see release.yml), not the newest main build.
run: docker build -t "$IMAGE:$GITHUB_SHA" .
- name: Push image
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
run: docker push "$IMAGE:$GITHUB_SHA"
+27 -49
View File
@@ -6,56 +6,23 @@ on:
- 'v*'
jobs:
test:
# Deliberately does NOT rebuild or re-run lint/typecheck/test/build: the
# image for this exact commit was already built and pushed by ci.yml's
# build-image job when it landed on main (which itself only runs after
# the `test` job passes). This job just promotes that already-verified
# image to the release tag + `latest` and deploys it — if the tagged
# commit was never pushed to main (or CI hasn't finished yet), the
# "Pull the tested image" step below fails loudly instead of silently
# rebuilding from source and skipping the checks.
promote-and-deploy:
runs-on: ubuntu-latest
container:
image: node:22-alpine
# NODE_ENV is deliberately NOT set at job level: `npm ci` treats a
# production NODE_ENV as `--omit=dev`, which would skip eslint/
# typescript/etc. and break Lint/Typecheck/Test. It's scoped to the
# Build step below instead.
env:
NEXT_TELEMETRY_DISABLED: "1"
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
PAYLOAD_SECRET: ci-only-not-a-real-secret
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
CRON_SECRET: ci-only-not-a-real-secret
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
steps:
- uses: actions/checkout@v4
- run: apk add --no-cache libc6-compat
- run: npm ci
- run: npm run lint
- run: npx next typegen && npx tsc --noEmit
- run: npm test
- run: npm run build
env:
NODE_ENV: production
build-and-deploy:
needs: test
runs-on: ubuntu-latest
# The anouma-runner runs as a Docker container itself (gitea/runner:3),
# with the host's docker.sock bind-mounted in but no `docker` CLI binary
# of its own — a job with no `container:` here runs directly inside that
# minimal runner image and gets "docker: command not found".
#
# A plain `docker:27-cli` image (Alpine, no Node.js) doesn't work either:
# actions/checkout@v4 is a JS action and needs a Node runtime in the job
# container, which that image doesn't have — it failed at Checkout with
# exit 127. Using a Node image as the base guarantees Node is already
# there (same reason the `test` job's node:22-alpine works), and the
# Docker CLI + ssh client are added via apt. The runner auto-mounts the
# same host docker.sock into this container (sibling containers — see
# container.docker_host in the runner's config.yaml), so `docker
# build`/`push` operate on the host's real Docker daemon.
# Node-based image (not the bare runner, not a Docker-only image) for
# the same reasons as ci.yml's build-image job: the runner needs a
# Docker CLI added via apt (it has none of its own), and actions/checkout
# needs a Node runtime that a plain docker:*-cli image doesn't have.
container:
image: node:22-bookworm-slim
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Docker CLI and ssh client
run: |
apt-get update
@@ -94,13 +61,24 @@ jobs:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin
- name: Build image
- name: Pull the tested image for this commit
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
run: |
docker pull "$IMAGE:$GITHUB_SHA" || {
echo "::error::No image found for commit $GITHUB_SHA. This tag must point at a commit that was already pushed to main and built by ci.yml's build-image job — push to main first (and wait for CI to finish) before tagging a release." >&2
exit 1
}
- name: Promote image to the release tag and latest
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
VERSION: ${{ steps.image.outputs.version }}
run: docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
run: |
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:$VERSION"
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:latest"
- name: Push image
- name: Push promoted tags
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
VERSION: ${{ steps.image.outputs.version }}
+63
View File
@@ -0,0 +1,63 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
env:
NEXT_TELEMETRY_DISABLED: "1"
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
PAYLOAD_SECRET: ci-only-not-a-real-secret
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
CRON_SECRET: ci-only-not-a-real-secret
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npx next typegen && npx tsc --noEmit
- name: Test
run: npm test
- name: Build
env:
NODE_ENV: production
run: npm run build
build-image:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
env:
IMAGE: git.maro.run/maro/anouma
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: git.maro.run
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build image
run: docker build -t "$IMAGE:$GITHUB_SHA" .
- name: Push image
run: docker push "$IMAGE:$GITHUB_SHA"
+55
View File
@@ -0,0 +1,55 @@
name: Release
on:
push:
tags: ["v*.*.*"]
permissions:
contents: read
jobs:
promote-and-deploy:
runs-on: ubuntu-latest
env:
IMAGE: git.maro.run/maro/anouma
steps:
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: git.maro.run
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Pull tested image
run: docker pull "$IMAGE:$GITHUB_SHA"
- name: Promote release image
env:
VERSION: ${{ github.ref_name }}
run: |
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:$VERSION"
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:latest"
docker push "$IMAGE:$VERSION"
docker push "$IMAGE:latest"
- name: Deploy to production
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST_KEY: ${{ secrets.DEPLOY_HOST_KEY }}
VERSION: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
umask 077
key_file="$(mktemp)"
known_hosts_file="$(mktemp)"
trap 'rm -f "$key_file" "$known_hosts_file"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY" > "$key_file"
chmod 600 "$key_file"
if [ -n "${DEPLOY_HOST_KEY:-}" ]; then
printf '%s\n' "$DEPLOY_HOST_KEY" > "$known_hosts_file"
else
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > "$known_hosts_file" 2>/dev/null
fi
ssh -i "$key_file" -o UserKnownHostsFile="$known_hosts_file" -p "${DEPLOY_PORT:-22}" \
"${DEPLOY_USER}@${DEPLOY_HOST}" "deploy ${VERSION}"
+58 -26
View File
@@ -3,12 +3,18 @@
Production deployment is fully CI/CD-driven via Gitea Actions. There is no `update.sh` and the production server never builds anything — it only ever pulls a tested image from the registry.
```text
Developer → git push → Gitea → Gitea Actions
→ Tests → Docker Build → Registry Push
→ Release (git tag vX.Y.Z) → SSH → deploy.sh
→ docker compose pull → migrate → up -d → Healthcheck → LIVE
Developer → git push main → Gitea Actions (CI)
→ Tests → Docker Build → Registry Push (tagged by commit SHA)
→ Release (git tag vX.Y.Z) → promote that exact image to vX.Y.Z + latest
→ SSH → deploy.sh → docker compose pull → migrate → up -d → Healthcheck → LIVE
```
Building and testing happen exactly once, on `main` — tagging a release never
rebuilds or re-tests, it only promotes the already-verified image for that
commit. Tagging a commit that hasn't been pushed to `main` (or whose CI run
hasn't finished yet) fails the release with a clear error instead of quietly
building from source and skipping the checks.
## One-time server setup
```bash
@@ -64,15 +70,17 @@ Gitea Actions takes it from there (see below). Once it's live, seed the original
Runs on every push and PR: `npm ci` → lint → typecheck → test → build, on the `ubuntu-latest`-labelled runner. No database is required — every Payload-backed route in this app is `force-dynamic`, so a production build never touches Postgres. (There's no automated test suite yet — `npm test` is an honest placeholder; add a real one, e.g. vitest, whenever the project needs it, and it'll run here automatically.)
On top of that, every push to `main` that passes the checks above also builds the Docker image and pushes it to the registry tagged by commit SHA (`git.maro.run/maro/anouma:<sha>`) — this is the one and only build of that commit; nothing later rebuilds it.
### Release (`.gitea/workflows/release.yml`)
Triggered by pushing a tag matching `v*` (e.g. `v1.2.0`):
1. Re-runs the same quality gates as CI.
2. Builds the Docker image and pushes `git.maro.run/maro/anouma:vX.Y.Z` **and** `:latest`.
1. Pulls the image CI already built and pushed for that commit's SHA — fails immediately with a clear error if it isn't there (the tagged commit was never on `main`, or CI for it hasn't finished yet).
2. Retags that same image as `git.maro.run/maro/anouma:vX.Y.Z` **and** `:latest`, and pushes both.
3. SSHes into the production server as `anouma-deploy` and runs `deploy.sh deploy vX.Y.Z`.
Production always runs a specific version tag — `latest` is pushed for convenience/reference only, `deploy.sh` never uses it.
Production always runs a specific version tag — `latest` is pushed for convenience/reference only (and always points at the most recently *released* build, not just the newest commit on `main`), `deploy.sh` never uses it.
## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`)
@@ -122,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
@@ -153,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 |
@@ -174,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
@@ -190,8 +221,9 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu
| Symptom | Check |
| --- | --- |
| `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` (from `/opt/anouma`) — usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` |
| 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
View File
@@ -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.
+6 -6
View File
@@ -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)
+3 -1
View File
@@ -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>
);
+4 -1
View File
@@ -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>
)}
+4 -1
View File
@@ -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">
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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 });
}
}
+5 -7
View File
@@ -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 });
+12 -2
View File
@@ -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 });
+7 -5
View File
@@ -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 } },
+5 -2
View File
@@ -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,
+1 -1
View File
@@ -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;
+31 -16
View File
@@ -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>
+64
View File
@@ -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>
);
}
+14 -16
View File
@@ -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>
)}
+4 -7
View File
@@ -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,
+3 -2
View File
@@ -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 (
+6 -8
View File
@@ -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
View File
@@ -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>
);
}
+31 -13
View File
@@ -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"
+131
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+23
View File
@@ -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
+1 -1
View File
@@ -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,
},
{
+6
View File
@@ -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}`,
}));
+23
View File
@@ -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
View File
@@ -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")}`;
+54
View File
@@ -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
View File
@@ -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) {
-22
View File
@@ -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;
}
+33
View File
@@ -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 });
}
}
+54
View File
@@ -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"));
}
-24
View File
@@ -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 };
+3 -2
View File
@@ -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" });
}
/**
-92
View File
@@ -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);
}
}
-115
View File
@@ -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 });
});
});
}
+6 -3
View File
@@ -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
View File
@@ -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;
+6 -1
View File
@@ -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
View File
@@ -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: [
+379 -318
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -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"
}
+4
View File
@@ -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"),
+7 -25
View File
@@ -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"})`,
);
});
});
+14 -4
View File
@@ -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)"
+48
View File
@@ -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");
});