36 Commits
Author SHA1 Message Date
maro 82a89cd3d9 Fix media permissions inside app container
Release / promote-and-deploy (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / build-image (push) Canceled after 0s
2026-09-18 02:22:15 +02:00
maro 46a7cc23bd Fix media directory permissions during deploy
CI / test (push) Canceled after 0s
Release / promote-and-deploy (push) Canceled after 0s
CI / build-image (push) Canceled after 0s
2026-09-18 02:09:16 +02:00
maro 661b12bb7b Fix media uploads without alt text
CI / test (push) Canceled after 0s
CI / build-image (push) Canceled after 0s
Release / promote-and-deploy (push) Canceled after 0s
2026-09-18 01:47:59 +02:00
maro d7576cb925 Allow Turnstile in frontend CSP 2026-09-18 01:27:07 +02:00
maro b29862b4b7 Fix Turnstile API initialization race 2026-09-18 01:12:39 +02:00
maro caa24905ec Improve CMS media uploads and admin styling 2026-09-18 00:40:20 +02:00
maro 1970be29e7 Add Turnstile protection to user actions 2026-09-18 00:39:14 +02:00
maro 79cd7f7475 Verbessere Lesbarkeit und Navigation im CMS 2026-09-17 17:07:34 +02:00
maro 7ec0c51ff5 feat: redesign CMS and unify German admin UI 2026-09-17 16:51:24 +02:00
maro a2b3e99174 feat: add CMS inquiries and group bookings 2026-09-17 15:48:40 +02:00
maro 97dd32a2b4 feat: apply antropos font globally 2026-09-17 02:58:10 +02:00
maro 66710fc65c fix: harden booking and meeting flows 2026-09-17 02:42:27 +02:00
maro cb30ecade9 fix: request media directly and expose host moderation 2026-09-17 01:13:07 +02:00
maro 4edc5c2422 fix: improve meeting device flow and call controls 2026-09-17 00:59:37 +02:00
maro d99c69f46d feat: configure public contact details 2026-09-17 00:38:48 +02:00
maro ba2147c717 fix: request call permissions and simplify CTA 2026-09-17 00:34:45 +02:00
maro 69f2653563 fix: allow Next hydration scripts under CSP 2026-09-16 22:11:23 +02:00
maro ab83a32d7b fix: initialize hash-based test meetings without storage 2026-09-16 21:55:34 +02:00
maro e6fa2e39b2 style: remove gender-inclusive wording 2026-09-16 21:43:44 +02:00
maro 8f5a074716 feat: add standalone admin video call test 2026-09-16 21:41:43 +02:00
maro 906ea764ac feat: configure Payload SMTP email adapter 2026-09-16 21:35:40 +02:00
maro e26da639ce fix: add missing globals migration and await seed
CI / test (push) Canceled after 0s
CI / build-image (push) Canceled after 0s
2026-09-16 21:23:55 +02:00
maro 7c2b0ec2b5 fix: make setup safe in repository directory 2026-09-16 20:49:28 +02:00
maro 16bcdd2383 Reapply "ci: use GitHub Container Registry"
This reverts commit caf586f1ad.
2026-09-16 20:37:41 +02:00
maro caf586f1ad Revert "ci: use GitHub Container Registry"
This reverts commit b29b017341.
2026-09-16 20:37:12 +02:00
maro b29b017341 ci: use GitHub Container Registry 2026-09-16 20:31:07 +02:00
maro f03e0e17de Document GitHub Actions deployment secrets
CI / test (push) Canceled after 0s
CI / build-image (push) Canceled after 0s
2026-09-16 19:45:43 +02:00
maro 8c88b48580 Synchronize dependency lockfile for CI
CI / test (push) Canceled after 0s
CI / build-image (push) Canceled after 0s
2026-09-16 19:41:54 +02:00
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
maro 526a908c80 Redesign: warmer, more editorial visual pass; remove stray em dashes
CI / test (push) Successful in 3m42s
Release / test (push) Successful in 3m44s
Release / build-and-deploy (push) Successful in 8m59s
Replaces em dashes with en dashes across all hardcoded copy, emails, and
seed content, then reworks the site's visual language so it reads as
deliberately designed rather than assembled from generic components:

- New ArrowLink component replaces ad hoc "Mehr erfahren ->" links and
  ghost buttons; one shared FormField (TextField/TextAreaField) replaces
  7 near-duplicate field styles across every form
- Navbar loses its glassmorphism blur for a solid warm background, gets a
  subtle active-link underline and a softer, warmer mega-menu
- PageHeader supports per-page blob tone/position and a compact mode
  instead of an identical band on every interior page
- Homepage: the core offer is featured large instead of four equal cards,
  the philosophy section gets a single calmer accent blob, blog teasers
  now show their cover image like the real /aktuelles page does
- Booking calendar and widget: 44px touch targets throughout (month nav,
  day cells, time slots, cancel action), a visible "today" ring, and
  responsive padding so the calendar isn't cramped on small screens
- Quieter, warmer empty states on /termine and /aktuelles

No functional changes: all interaction logic (nav menus, forms, booking,
CMS data fetching) is untouched, only markup and styling.
2026-09-06 22:00:28 +02:00
maro 65b6eeac51 Add privacy-friendly analytics and cookie consent, integrated into the CMS
Adds a first-party analytics system with no third-party trackers and no
persistent per-visitor identifiers: events are grouped into sessions via a
daily-rotating pseudonymous hash (IP+UA+host, HMAC-salted, never stored raw).

- New "Analytics" section in the Payload admin, gated by a canViewAnalytics
  permission on Users, following the existing custom-admin-view pattern
  used by the calendar feature
- Sessions, page views, unique visitors, top/entry/exit pages, events,
  device/browser breakdown, date range filter with period comparison
- Raw events and daily aggregate rollups as their own collections, with
  configurable retention and a cron endpoint that rolls up before purging
- Cookie consent banner (necessary/preferences/statistics) with categories
  and copy editable from a new CMS global; a track() helper that is a
  complete no-op without statistics consent, so nothing is ever sent
  before a visitor opts in
- track() calls wired into the booking flow, contact form, and navigation
2026-09-06 21:59:59 +02:00
maro 82c4261a81 Fix security and correctness issues found in full-codebase audit
- Restrict meetingPassword field so it isn't leaked via the public REST/GraphQL API
- Fix booking slot computation to use Europe/Berlin time instead of the server's UTC clock
- Reset email verification when a customer changes their email address
- Fix ICS calendar injection via unescaped carriage returns in event descriptions
- Bind WebRTC meeting kicks to a persistent client id so they survive a rejoin
- Move the meeting signaling token out of the WebSocket query string into the first message
- Prevent duplicate signaling connections from leaking when the same participant reconnects
- Use the rightmost X-Forwarded-For entry for rate limiting (single trusted reverse proxy)
- Use constant-time comparison for the cron endpoint's bearer token
- Remove SVG from allowed Media upload types (stored-XSS risk, unused anyway)
- Add baseline security headers and a CSP to next.config.ts
- Fix CI/CD script injection via unsanitized git ref names in release.yml
2026-09-06 21:59:39 +02:00
maro 1b7dbad843 Add initial Payload migration
CI / test (push) Successful in 3m18s
Release / test (push) Successful in 3m13s
Release / build-and-deploy (push) Successful in 9m6s
2026-08-26 00:45:59 +00:00
maroandClaude Sonnet 5 8d60d48bd0 Fix .env writer: quote values so shell metacharacters don't break source
CI / test (push) Successful in 3m14s
deploy.sh sources .env directly as bash (`set -a; source .env`), but
setup.sh wrote raw unquoted values. The default SMTP_FROM ("ANOUMA
<no-reply@...>") contains `<`/`>`, which bash parses as redirections,
failing with "syntax error near unexpected token `newline'" on every
deploy. Both scripts' env_set now write double-quoted values with
backslashes/quotes escaped — valid for both `source` and Docker
Compose's env_file parsing (which strips matching quotes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 02:12:24 +02:00
216 changed files with 22393 additions and 2702 deletions
+42 -21
View File
@@ -1,6 +1,6 @@
# This file documents every variable the app/deployment actually reads.
# In production, setup.sh generates the real file at /opt/anouma/.env for
# you — you shouldn't need to hand-edit this. For local development, copy
# you - you shouldn't need to hand-edit this. For local development, copy
# this to .env at the repo root and fill in DATABASE_URI/PAYLOAD_SECRET
# (see README.md → "Lokale Entwicklung").
@@ -11,7 +11,7 @@
# dev server against the Dockerized Postgres, use "127.0.0.1" instead.
DATABASE_URI=postgresql://postgres:postgres@postgres:5432/anouma
# Only used by the "postgres" service in docker-compose.yml — keep these in
# Only used by the "postgres" service in docker-compose.yml - keep these in
# sync with DATABASE_URI above (setup.sh does this for you).
POSTGRES_USER=postgres
POSTGRES_PASSWORD=replace-with-a-long-random-secret
@@ -25,6 +25,11 @@ PAYLOAD_SECRET=replace-with-a-long-random-secret
# Generate with: openssl rand -hex 32
MEETING_SESSION_SECRET=replace-with-a-long-random-secret
# Salts the daily-rotating, non-reversible visitor hash used for Analytics
# session grouping (kept separate from PAYLOAD_SECRET on purpose, see
# lib/analytics/salt.ts). Generate with: openssl rand -hex 32
ANALYTICS_SALT_SECRET=replace-with-a-long-random-secret
# Sent as "Authorization: Bearer <value>" by the external cron job that
# triggers /api/cron/event-reminders. Generate with: openssl rand -hex 32
CRON_SECRET=replace-with-a-long-random-secret
@@ -34,25 +39,41 @@ CRON_SECRET=replace-with-a-long-random-secret
# links inside emails. Set this to the real domain in production.
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Cloudflare Turnstile. The site key is public and is passed to the client
# from the server; the secret is used only by the server-side Siteverify call.
TURNSTILE_SITE_KEY=0x4AAAAAAE7AqgHiQF9DffQO
TURNSTILE_SECRET_KEY=
# Local development value. Production must contain only the public hostname:
# TURNSTILE_HOSTNAMES=anouma.org
TURNSTILE_HOSTNAMES=localhost,127.0.0.1
# Port the app container publishes on the host (behind a reverse proxy this
# usually doesn't need to be reachable directly — see DEPLOYMENT.md).
# usually doesn't need to be reachable directly - see DEPLOYMENT.md).
APP_PORT=3000
# ── Email (SMTP) ────────────────────────────────────────────────────────
# The existing ANOUMA mail system — no external newsletter service.
# The existing ANOUMA mail system - no external newsletter service.
SMTP_HOST=
SMTP_PORT=587
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=
# Address used as Reply-To for CMS replies. Configure this mailbox with an
# inbound provider/webhook that POSTs replies to /api/email/inbound.
INBOUND_REPLY_TO=reply-to@anouma.org
# Shared secret sent as x-inbound-secret (or Authorization: Bearer ...) by the
# inbound email provider. Generate with: openssl rand -hex 32
INBOUND_EMAIL_SECRET=
# ── 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 →
@@ -66,7 +87,7 @@ DOCKER_NETWORK=anouma-network
# Name of an existing external Docker network to join (e.g. Nginx Proxy
# Manager's network) so a reverse proxy can reach the app container
# directly. Leave empty if you don't use one — setup.sh auto-detects this.
# directly. Leave empty if you don't use one - setup.sh auto-detects this.
NPM_NETWORK=
# ── Backups (used by deploy.sh) ─────────────────────────────────────────
@@ -74,19 +95,19 @@ NPM_NETWORK=
BACKUP_RETENTION_DAYS=14
# ── Container registry & deployment (used by deploy.sh) ────────────────
# Gitea's built-in container registry. deploy.sh builds nothing — it only
# ever pulls this image (see .gitea/workflows/release.yml, which is what
# actually builds and pushes it).
REGISTRY=git.maro.run
REGISTRY_REPO=maro/anouma
# GitHub Container Registry. deploy.sh builds nothing - it only ever pulls
# the image that GitHub Actions built and pushed.
REGISTRY=ghcr.io
REGISTRY_REPO=whitehai11/anouma
# Only needed if the registry/repository requires authentication to pull.
# Use a Gitea access token as the password, not your account password.
# Use a GitHub token with at least read:packages as the password, not your
# account password.
REGISTRY_USERNAME=
REGISTRY_PASSWORD=
# Set automatically by deploy.sh on every successful deploy — do not edit
# Set automatically by deploy.sh on every successful deploy - do not edit
# by hand. Empty until the first release has been deployed.
ANOUMA_IMAGE=
# Informational only (shown in the setup summary) — actual automatic
# deployment is controlled by whether release.yml + its Gitea secrets are
# Informational only (shown in the setup summary) - actual automatic
# deployment is controlled by whether release.yml + its GitHub secrets are
# configured, not by this flag.
AUTO_DEPLOY=true
-50
View File
@@ -1,50 +0,0 @@
name: CI
on:
push:
branches: ['**']
pull_request:
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:22-alpine
# No live database is needed here — every Payload-backed route in this
# app is force-dynamic, so `next build` never touches Postgres. These
# are dummy values only so a real config error (a missing secret) is
# never silently masked by CI happening to run without one.
#
# NODE_ENV is deliberately NOT set here: `npm ci` treats a production
# NODE_ENV as `--omit=dev`, which would skip eslint/typescript/etc. and
# break Lint/Typecheck/Test. It's set below, scoped to the Build step.
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
CRON_SECRET: ci-only-not-a-real-secret
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install build dependencies (sharp needs libc6-compat on alpine)
run: apk add --no-cache libc6-compat
- 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
-125
View File
@@ -1,125 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
test:
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
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.
container:
image: node:22-bookworm-slim
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Docker CLI and ssh client
run: |
apt-get update
apt-get install -y --no-install-recommends docker.io openssh-client
rm -rf /var/lib/apt/lists/*
- name: Determine image tags
id: image
# Gitea Actions' runner is protocol-compatible with GitHub Actions,
# so step commands use the same $GITHUB_OUTPUT / $GITHUB_ENV
# mechanism (the `gitea.*` vs `github.*` expression contexts are
# just aliases of each other for `${{ }}` templating).
run: |
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
env:
REGISTRY: git.maro.run
REGISTRY_REPO: maro/anouma
- name: Log in to registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ steps.image.outputs.registry }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build image
run: |
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
VERSION="${{ steps.image.outputs.version }}"
docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
- name: Push image
run: |
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
VERSION="${{ steps.image.outputs.version }}"
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 }}
# Optional: pin the exact host key instead of trusting it on
# first connect (see DEPLOYMENT.md). Safe to leave unset.
DEPLOY_HOST_KEY: ${{ secrets.DEPLOY_HOST_KEY }}
VERSION: ${{ steps.image.outputs.version }}
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
# The forced command in the server's authorized_keys (see
# setup.sh) ignores this and always runs deploy.sh itself — the
# string below only ever reaches it via $SSH_ORIGINAL_COMMAND.
ssh -i "$key_file" -o UserKnownHostsFile="$known_hosts_file" -p "${DEPLOY_PORT:-22}" \
"${DEPLOY_USER}@${DEPLOY_HOST}" "deploy ${VERSION}"
+64
View File
@@ -0,0 +1,64 @@
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@v5
- name: Use Node.js 22
uses: actions/setup-node@v5
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
packages: write
env:
IMAGE: ghcr.io/whitehai11/anouma
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build image
run: docker build -t "$IMAGE:$GITHUB_SHA" .
- name: Push image
run: docker push "$IMAGE:$GITHUB_SHA"
+56
View File
@@ -0,0 +1,56 @@
name: Release
on:
push:
tags: ["v*.*.*"]
permissions:
contents: read
packages: write
jobs:
promote-and-deploy:
runs-on: ubuntu-latest
env:
IMAGE: ghcr.io/whitehai11/anouma
steps:
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- 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}"
+1 -1
View File
@@ -49,7 +49,7 @@ next-env.d.ts
*.deb
*.AppImage
# docker / deployment (machine-specific, generated by setup.sh — production
# docker / deployment (machine-specific, generated by setup.sh - production
# artifacts actually live in /opt/anouma on the server, never inside this
# repo checkout, but these are ignored here too in case of local testing)
docker-compose.override.yml
+2 -2
View File
@@ -2,8 +2,8 @@
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This version has breaking changes - APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
This block is written and re-added by `next dev` - verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+106 -63
View File
@@ -1,18 +1,24 @@
# ANOUMA — Deployment
# ANOUMA - Deployment
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.
Production deployment is fully CI/CD-driven via GitHub Actions. There is no `update.sh` and the production server never builds anything - it only ever pulls a tested image from GitHub Container 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 → GitHub Actions (CI)
→ Tests → Docker Build → GHCR 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
git clone https://git.maro.run/maro/anouma.git
git clone https://github.com/whitehai11/anouma.git
cd anouma
chmod +x setup.sh
./setup.sh
@@ -21,22 +27,22 @@ chmod +x setup.sh
`setup.sh` needs root (directly or via `sudo`) because it creates a system user and writes to `/opt`. It:
1. Checks/installs Docker, Docker Compose, git, openssl, curl, ssh.
2. Creates `/opt/anouma` (refuses to touch it if an installation already exists there — you choose "use existing" or abort; nothing is ever deleted).
2. Creates `/opt/anouma` (refuses to touch it if an installation already exists there - you choose "use existing" or abort; nothing is ever deleted).
3. Detects an existing Nginx Proxy Manager network and optionally joins it.
4. Writes `/opt/anouma/.env` with every secret the app needs, freshly generated (`openssl rand -hex 32`) and **never regenerated** on a later run.
5. Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) — skipped automatically if already answered before.
6. Creates a restricted `anouma-deploy` system user, generates a dedicated SSH keypair for it, and installs the public key with a **forced command** so that key can only ever run `deploy.sh` — never an interactive shell.
5. Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) - skipped automatically if already answered before.
6. Creates a restricted `anouma-deploy` system user, generates a dedicated SSH keypair for it, and installs the public key with a **forced command** so that key can only ever run `deploy.sh` - never an interactive shell.
7. Copies `docker-compose.yml` and `deploy.sh` into `/opt/anouma`.
8. Starts Postgres (so it's ready for the first deploy).
9. Prints the exact values to paste into **Gitea → maro/anouma → Settings → Secrets → Actions**, including the deploy private key — shown only once, the first time the key is generated.
9. Prints the exact values to paste into **GitHub → whitehai11/anouma → Settings → Secrets and variables → Actions**, including the deploy private key - shown only once, the first time the key is generated.
Run `./setup.sh --non-interactive` for a scripted install; any value that has no safe default and wasn't exported as an environment variable beforehand aborts with a clear message naming the missing variable, instead of silently guessing.
Re-running `./setup.sh` later (e.g. after pulling script updates) is always safe: existing secrets, the SSH key, the database and all volumes are left exactly as they are — only `docker-compose.yml` and `deploy.sh` themselves get refreshed, since those are generated artifacts, not data.
Re-running `./setup.sh` later (e.g. after pulling script updates) is always safe: existing secrets, the SSH key, the database and all volumes are left exactly as they are - only `docker-compose.yml` and `deploy.sh` themselves get refreshed, since those are generated artifacts, not data.
### After setup: add the Gitea secrets
### After setup: add the GitHub secrets
Copy the block `setup.sh` printed into **Gitea → maro/anouma → Settings → Secrets → Actions**:
Copy the deployment values `setup.sh` printed into **GitHub → whitehai11/anouma → Settings → Secrets and variables → Actions**:
| Secret | Value |
| --- | --- |
@@ -44,39 +50,48 @@ Copy the block `setup.sh` printed into **Gitea → maro/anouma → Settings →
| `DEPLOY_PORT` | `22` (or your SSH port) |
| `DEPLOY_USER` | `anouma-deploy` |
| `DEPLOY_SSH_KEY` | the private key setup.sh printed |
| `DEPLOY_HOST_KEY` | optional — pin the server's SSH host public key instead of trusting it on first connect (`ssh-keyscan -p <port> <host>` on a machine you already trust) |
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | registry push credentials (a Gitea access token as the password, not your account password) |
| `DEPLOY_HOST_KEY` | optional - pin the server's SSH host public key instead of trusting it on first connect (`ssh-keyscan -p <port> <host>` on a machine you already trust) |
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | **not needed in GitHub Actions**; these belong in `/opt/anouma/.env` on the server for pulling the private GHCR image |
### First deploy
Nothing runs on the server yet at this point — no release has been built. Push the first tag:
Nothing runs on the server yet at this point - no release has been built. Push the first tag:
```bash
git tag v1.0.0
git push origin v1.0.0
git push github v1.0.0 # GitHub Actions
```
Gitea Actions takes it from there (see below). Once it's live, seed the original content once: `ssh anouma-deploy@<host>` won't work (the key is restricted — see Security), so run it directly on the server instead: `cd /opt/anouma && docker compose run --rm app npm run seed`.
The GitHub workflow takes it from there. Once it's live, seed the original content once: `ssh anouma-deploy@<host>` won't work (the key is restricted - see Security), so run it directly on the server instead: `cd /opt/anouma && docker compose run --rm app npm run seed`.
## Gitea Actions
## GitHub Actions
### CI (`.gitea/workflows/ci.yml`)
The GitHub repository is `whitehai11/anouma`; the workflows live in
`.github/workflows/ci.yml` and `.github/workflows/release.yml`. In GitHub go to
**Settings → Secrets and variables → Actions → Repository secrets** and create:
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.)
| Secret | Required | Value |
| --- | --- | --- |
| `DEPLOY_HOST` | yes | Public IP or hostname of the production server |
| `DEPLOY_PORT` | yes | SSH port, normally `22` |
| `DEPLOY_USER` | yes | `anouma-deploy` |
| `DEPLOY_SSH_KEY` | yes | Private key generated by `setup.sh` for the restricted deploy user |
| `DEPLOY_HOST_KEY` | no | Complete SSH host-key line from a trusted `ssh-keyscan`; safer than first-connect trust |
### Release (`.gitea/workflows/release.yml`)
GitHub Actions authentifiziert sich mit dem automatisch bereitgestellten
`GITHUB_TOKEN`; dafür musst du keine Registry-Secrets anlegen. Registry und
Image sind im Workflow fest auf `ghcr.io/whitehai11/anouma` gesetzt. Runtime-Secrets
wie `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `LIVEKIT_API_SECRET` und
`DATABASE_URI` gehören ausschließlich in `/opt/anouma/.env` auf dem Server und
nicht in GitHub Actions.
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`.
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.
Wichtig: Für Releases nur GitHub Actions verwenden. Gitea kann als reiner
Repository-Spiegel bestehen bleiben, darf aber keine eigenen Build- oder
Release-Workflows ausführen.
## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`)
Not a manual tool — it's invoked exclusively by Gitea Actions over SSH. What it does for `deploy.sh deploy vX.Y.Z`:
Not a manual tool - it's invoked exclusively by GitHub Actions over SSH. What it does for `deploy.sh deploy vX.Y.Z`:
```text
Lock (refuse a second concurrent deploy)
@@ -90,13 +105,13 @@ Lock (refuse a second concurrent deploy)
→ success, or: automatic rollback to the previous version
```
If the post-deploy healthcheck fails, `deploy.sh` automatically redeploys the **previous** version (recorded from `ANOUMA_IMAGE` before the attempt) and reports the failed deploy as exit code 1 — Gitea Actions shows the release as failed even though production recovered. Database migrations from the failed release are **not** reverted (write migrations to be forward-compatible); restore the pre-deploy backup manually if a migration truly needs undoing.
If the post-deploy healthcheck fails, `deploy.sh` automatically redeploys the **previous** version (recorded from `ANOUMA_IMAGE` before the attempt) and reports the failed deploy as exit code 1 - GitHub Actions shows the release as failed even though production recovered. Database migrations from the failed release are **not** reverted (write migrations to be forward-compatible); restore the pre-deploy backup manually if a migration truly needs undoing.
A lock file (`/opt/anouma/.deploy.lock`) prevents two deploys from running at once; a stale lock from a killed process is detected and ignored automatically.
## Backups
Every deploy backs up the database first, to `/opt/anouma/backups/database-YYYY-MM-DD-HHMM.sql.gz`, before anything else changes. Backups older than `BACKUP_RETENTION_DAYS` (default 14) are cleaned up automatically — the backup just created is never deleted, even if retention is set very low.
Every deploy backs up the database first, to `/opt/anouma/backups/database-YYYY-MM-DD-HHMM.sql.gz`, before anything else changes. Backups older than `BACKUP_RETENTION_DAYS` (default 14) are cleaned up automatically - the backup just created is never deleted, even if retention is set very low.
Restore manually if needed:
@@ -122,26 +137,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
@@ -150,48 +188,53 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file
| Variable | Purpose |
| --- | --- |
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection |
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site |
| `TURNSTILE_SITE_KEY` | Public Cloudflare Turnstile site key |
| `TURNSTILE_SECRET_KEY` | Server-only Cloudflare Turnstile secret; keep it in `/opt/anouma/.env` and never in GitHub Actions or the repository |
| `TURNSTILE_HOSTNAMES` | Hostnames accepted by Siteverify; production is `anouma.org` only (local development may use `localhost,127.0.0.1`) |
| `SMTP_*` | The existing ANOUMA mail system |
| `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers |
| `INBOUND_REPLY_TO`, `INBOUND_EMAIL_SECRET` | Mailbox and shared secret for the inbound email webhook (`/api/email/inbound`) |
| `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 |
| `ANOUMA_IMAGE` | Set automatically by `deploy.sh` on every deploy — don't edit by hand |
| `ANOUMA_IMAGE` | Set automatically by `deploy.sh` on every deploy - don't edit by hand |
## Persistent data
Deployments never delete data:
- **Database** — the `pgdata` named volume, independent of the `postgres` container's lifecycle.
- **Uploaded media** — the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle.
- **Backups** — `/opt/anouma/backups`.
- **Database** - the `pgdata` named volume, independent of the `postgres` container's lifecycle.
- **Uploaded media** - the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle.
- **Backups** - `/opt/anouma/backups`.
Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volume prune`, `docker network prune`, or `docker compose down -v`. Neither touches a container or network it doesn't own (an existing NPM install is never modified beyond optionally joining its network).
## Healthchecks
- **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.
- **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
- `anouma-deploy` has **no sudo rights at all** and **no password** — the only way in is its dedicated SSH key.
- `anouma-deploy` has **no sudo rights at all** and **no password** - the only way in is its dedicated SSH key.
- That SSH key is restricted with `restrict,command="/opt/anouma/deploy.sh"` in `authorized_keys`: whatever command an SSH client requests is ignored and `deploy.sh` runs instead (the client's actual request only reaches it via `$SSH_ORIGINAL_COMMAND`, which `deploy.sh` validates against a strict `deploy vX.Y.Z` pattern before doing anything). No arbitrary shell access is possible through this key, even if it leaks.
- `anouma-deploy` **is** a member of the `docker` group, because running `docker compose` requires it — and Docker-group membership is well known to be effectively root-equivalent (a container can bind-mount the host filesystem). This is a deliberate, documented trade-off: the blast radius is contained by the two points above (no interactive access, and the only reachable code path is the fixed `deploy.sh` logic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs — out of scope here.
- `anouma-deploy` **is** a member of the `docker` group, because running `docker compose` requires it - and Docker-group membership is well known to be effectively root-equivalent (a container can bind-mount the host filesystem). This is a deliberate, documented trade-off: the blast radius is contained by the two points above (no interactive access, and the only reachable code path is the fixed `deploy.sh` logic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs - out of scope here.
- `.env` and the deploy SSH private key are `chmod 600`.
- Secrets are never committed, never logged, and never printed more than once (`setup.sh` shows the private key only the first time it's generated; on a later run it prints "Existing deployment key found." instead).
- `REGISTRY_PASSWORD`/`SMTP_PASSWORD`/etc. are masked (`********`) in `setup.sh`'s configuration summary — only the one-time Gitea-secrets block shows real values, since that block exists specifically for you to copy them into Gitea.
- `REGISTRY_PASSWORD`/`SMTP_PASSWORD`/etc. are masked (`********`) in `setup.sh`'s configuration summary. The GHCR token is needed only on the production server for pulling the private image; GitHub Actions uses its automatic `GITHUB_TOKEN`.
## Troubleshooting
| 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 "Deploy to production" | Check the Gitea secrets are all set correctly, and that the server's SSH port is reachable from the runner |
| `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 GitHub repository 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 |
+11 -7
View File
@@ -1,14 +1,14 @@
# 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.
# .env.example) - this image builds without a database connection.
FROM node:22-alpine AS base
RUN apk add --no-cache libc6-compat su-exec
# ---- deps: install once, cached as its own layer -------------------------
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
@@ -32,16 +32,20 @@ RUN addgroup --system --gid 1001 nodejs \
# Copied wholesale (not a hand-picked list of directories) so newly added
# source folders (collections, scripts, lib/*, components/*, ...) are never
# silently missing at runtime — .dockerignore already excludes what doesn't
# silently missing at runtime - .dockerignore already excludes what doesn't
# belong in the image (node_modules is re-added explicitly below, .next is
# the build output we do want).
COPY --from=builder --chown=nextjs:nodejs /app /app
# Writable at runtime, independent of the image — actual data lives in the
# Writable at runtime, independent of the image - actual data lives in the
# Docker volumes mounted over these paths (see docker-compose.yml).
RUN mkdir -p media backups && chown -R nextjs:nodejs media backups
USER nextjs
COPY --from=builder --chown=root:root /app/docker/anouma-entrypoint.sh /usr/local/bin/anouma-entrypoint.sh
RUN chmod 755 /usr/local/bin/anouma-entrypoint.sh
USER root
ENTRYPOINT ["/usr/local/bin/anouma-entrypoint.sh"]
EXPOSE 3000
ENV PORT=3000
+36 -25
View File
@@ -1,31 +1,42 @@
# 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
- `app/(frontend)/` — die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout)
- `app/(call)/` — die Video-Call-Oberfläche (`/termine/[slug]/call`), eigenes minimalistisches dunkles Layout ohne Navbar/Footer
- `app/(payload)/` — der Admin-Bereich unter `/admin`, Payloads REST/GraphQL-API sowie die Meeting-/Cron-API-Routen (`api/meetings/...`, `api/cron/...`)
- `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/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
- `app/(frontend)/` - die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout)
- `app/(call)/` - die Video-Call-Oberfläche (`/termine/[slug]/call`), eigenes minimalistisches dunkles Layout ohne Navbar/Footer
- `app/(payload)/` - der Admin-Bereich unter `/admin`, Payloads REST/GraphQL-API sowie die Meeting-/Cron-API-Routen (`api/meetings/...`, `api/cron/...`)
- `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, 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`), 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`).
## Online-Termine & Video-Call
## Kontaktanfragen und CMS-Nachrichten
Kontakt- und Terminanfragen werden unter `/admin/anfragen` als Konversationen
gespeichert. Antworten aus dem CMS werden per SMTP verschickt. Setze dafür
`INBOUND_REPLY_TO` auf die Mailbox, auf die Besucher antworten sollen. Ein
Inbound-Mail-Provider muss eingehende Antworten anschließend als JSON an
`POST /api/email/inbound` weiterleiten und dabei `x-inbound-secret` mit dem
Wert aus `INBOUND_EMAIL_SECRET` senden. Erwartet werden mindestens `from`,
`subject`, `text` und eine eindeutige `messageId`; der Thread-Schlüssel im
Betreff (`[ANOUMA-…]`) ordnet die Antwort der richtigen Anfrage zu.
- Ein Termin wird per Häkchen „Online-Termin“ zu einem Video-Call-Termin. Payload generiert dabei automatisch ein zufälliges Meeting-Passwort (nie aus der Meeting-ID abgeleitet); die Admin kann jederzeit ein neues erzeugen lassen.
- Ö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
@@ -36,20 +47,20 @@ Erinnerungs-Mails (60/30 Minuten vorher, pro Termin im CMS einzeln an-/abschaltb
curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
```
Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) — ein häufiger laufender Cron verschickt also nie doppelt.
Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) - ein häufiger laufender Cron verschickt also nie doppelt.
## Setup (Produktion / Deployment)
Produktion läuft komplett über Gitea Actions CI/CD — der Server baut nichts selbst, er zieht ein fertiges, getestetes Image aus der Registry. Einmalig auf dem Server:
Produktion läuft komplett über GitHub Actions CI/CD - der Server baut nichts selbst, er zieht ein fertiges, getestetes Image aus GitHub Container Registry. Einmalig auf dem Server:
```bash
git clone https://git.maro.run/maro/anouma.git
git clone https://github.com/whitehai11/anouma.git
cd anouma
chmod +x setup.sh
./setup.sh
```
`setup.sh` installiert bei Bedarf Docker, legt `/opt/anouma` an, generiert alle Secrets automatisch (nie erneut, wenn schon vorhanden), richtet einen eingeschränkten Deployment-User samt SSH-Key für Gitea Actions ein und zeigt am Ende genau die Werte an, die als Gitea Secrets hinterlegt werden müssen. `./setup.sh --non-interactive` läuft ohne Rückfragen.
`setup.sh` installiert bei Bedarf Docker, legt `/opt/anouma` an, generiert alle Secrets automatisch (nie erneut, wenn schon vorhanden), richtet einen eingeschränkten Deployment-User samt SSH-Key für GitHub Actions ein und zeigt am Ende genau die Werte an, die als GitHub Repository Secrets hinterlegt werden müssen. Das Docker-Image landet unter `ghcr.io/whitehai11/anouma`. `./setup.sh --non-interactive` läuft ohne Rückfragen.
Jedes weitere Deployment läuft danach ausschließlich über einen Git-Tag:
@@ -57,9 +68,9 @@ Jedes weitere Deployment läuft danach ausschließlich über einen Git-Tag:
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.
GitHub Actions testet, baut das Image, pusht es nach GHCR 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, GitHub 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)
@@ -77,7 +88,7 @@ Nur Postgres per Docker, die App läuft direkt auf dem Host:
docker compose up -d postgres
```
(oder eine gehostete Postgres-Instanz, z. B. Neon/Supabase — einfach `DATABASE_URI` in `.env` entsprechend setzen).
(oder eine gehostete Postgres-Instanz, z. B. Neon/Supabase - einfach `DATABASE_URI` in `.env` entsprechend setzen).
### Entwicklung
@@ -86,7 +97,7 @@ npm run dev
```
- Website: http://localhost:3000
- Admin: http://localhost:3000/admin — beim ersten Aufruf legst du dort direkt deinen eigenen Admin-Zugang (E-Mail + Passwort) an. Es gibt kein Standardpasswort im Code.
- Admin: http://localhost:3000/admin - beim ersten Aufruf legst du dort direkt deinen eigenen Admin-Zugang (E-Mail + Passwort) an. Es gibt kein Standardpasswort im Code.
### Vorhandene Inhalte ins CMS übertragen
@@ -94,7 +105,7 @@ npm run dev
npm run seed
```
Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt — bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen.
Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt - bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen.
### Produktions-Build ohne Docker
@@ -114,8 +125,8 @@ Für einen vollständig dockerisierten Produktionsbetrieb (App + Datenbank, Heal
| `npm run generate:importmap`| Admin-Importmap neu erzeugen (nach neuen Feldtypen) |
| `npm run migrate:create` | Neue Datenbank-Migration aus Config-Änderungen bauen |
| `npm run lint` | ESLint |
| `npm test` | Test-Suite (aktuell ein Platzhalter — noch keine Tests vorhanden) |
| `npm run seo:check` | Optionale SEO-Prüfung (doppelte Slugs, fehlende Alt-Texte) — nicht produktionskritisch |
| `npm test` | Test-Suite (aktuell ein Platzhalter - noch keine Tests vorhanden) |
| `npm run seo:check` | Optionale SEO-Prüfung (doppelte Slugs, fehlende Alt-Texte) - nicht produktionskritisch |
## Design
+98
View File
@@ -0,0 +1,98 @@
# Security-Audit - 17.09.2026
Geprüfter Commit: `cb30eca`. Ergebnis: **ein Befund hoher Priorität (P1), fünf Befunde mittlerer Priorität (P2)** sowie Hinweise zu Abhängigkeiten. Keine Behebung vorgenommen.
## Umfang und Nachweisgrenzen
Quellcodeprüfung von Payload-Zugriffsregeln, Kunden-/Admin-Authentifizierung, Buchungen, Meetings, Analytics, Kalender-Feed, E-Mail-Versand und Deployment-Konfiguration. Die installierten Next.js-Guides zu Datensicherheit und Route Handlers sowie Payloads tatsächliche Local-API-Implementierung wurden herangezogen.
`npm test`: 5/5 Tests bestanden. Zusätzlich wurden die unveränderten Route Handler für Stornierung, Meeting-Beitritt, Token-Austausch und Buchungsanfragen isoliert ausgeführt, mit simulierter Datenbank und simuliertem LiveKit. Keine Produktivzugriffe, echten E-Mails oder Datenänderungen. Diese Prüfungen belegen die Handler-Logik, nicht das Verhalten eines realen Reverse-Proxys oder Video-Servers. Produktionskonfiguration, Git-Historie und laufende Infrastruktur waren nicht Gegenstand eines Penetrationstests.
## 1. P1 - Interne Buchungsnotizen gelangen in den Kundenbrowser
**Stellen:** `lib/booking/queries.ts:12`, `app/(frontend)/konto/termine/page.tsx:27`, `app/(frontend)/konto/page.tsx:46`, `collections/BookingRequests.ts:364`.
`getCustomerBookings()` liest vollständige Buchungsdokumente mit `depth: 2` über die Payload Local API. Ohne explizites `overrideAccess: false` umgeht diese sowohl Collection- als auch Feldzugriffsregeln. Das wurde in `node_modules/payload/dist/collections/operations/local/find.js` und `node_modules/payload/dist/fields/hooks/afterRead/promise.js` verifiziert.
Die Kontoseiten übergeben das gesamte Ergebnis als `booking` an die Client Component `BookingCard`. Dadurch wird auch `internalNote` an den Browser serialisiert, obwohl es durch `isAdminFieldLevel` geschützt und als „Nur für Anna sichtbar“ bezeichnet ist. Dass die Komponente die Notiz nicht sichtbar rendert, schützt sie nicht.
**Auswirkung:** Ein angemeldeter Kunde kann interne Notizen zu seinen Buchungen in der Seiten-/RSC-Antwort lesen. Kein Zugriff auf fremde Buchungen wurde hier festgestellt.
**Nachweis:** Statisch vollständiger Datenfluss von privilegierter Abfrage bis zur Client-Grenze; kein Live-Abruf realer Kundendaten.
**Behebung:** Kundenansichten nur explizit ausgewählte Felder als separates DTO übergeben. Wenn möglich zusätzlich mit `overrideAccess: false` und authentifiziertem Benutzer lesen. Privilegiert benötigte Meeting-Daten gesondert und minimal auflösen. Auch verschachtelte Beziehungen nicht vollständig serialisieren.
**Regressionstest:** Eine Buchung mit einer eindeutigen internen Testnotiz anlegen; die Notiz darf weder in HTML noch in einer RSC-Antwort des Kundenkontos vorkommen.
## 2. P2 - Stornierung widerruft den privaten Meeting-Zugang nicht
**Stellen:** `app/(payload)/api/booking/[id]/cancel/route.ts:29`, `app/(payload)/api/meetings/[slug]/join/route.ts:49`, `collections/BookingRequests.ts:229`.
Die Stornierung setzt nur den Buchungsstatus. Der verknüpfte Online-Event und sein Passwort bleiben gültig. Der Join-Endpunkt prüft Online-Status, Passwort und Zeitfenster, aber nicht den Status der zugehörigen privaten Buchung. Der E-Mail-Hook bei Stornierung sperrt den Event ebenfalls nicht.
**Auswirkung:** Wer das zuvor erhaltene Passwort kennt, kann innerhalb des ursprünglichen Zeitfensters auch nach der Stornierung neue Meeting-Zugangsdaten erhalten.
**Nachweis:** Isolierter Aufruf des Cancel-Handlers änderte die Buchung auf `cancelled`; anschließender anonymer Join mit dem alten Passwort lieferte HTTP 200 und Zugangsdaten.
**Behebung:** Für private Events bei jedem Join und Connect eine weiterhin bestätigte, passend verknüpfte Buchung verlangen. Bei Stornierung bestehende Sitzungen widerrufen; gegebenenfalls aktive Teilnehmer über LiveKit entfernen.
## 3. P2 - Token-Austausch umgeht das Meeting-Ende
**Stelle:** `app/(payload)/api/meetings/[slug]/connect/route.ts:27`.
`/connect` prüft das App-Token und die Kick-Liste, anschließend nur noch `isOnline`. Anders als `/join` prüft der Handler weder das aktuelle Meeting-Zeitfenster noch einen Widerruf durch Passwortwechsel. App-Tokens sind standardmäßig sechs Stunden gültig; neu ausgestellte LiveKit-Tokens gelten jeweils zwei Stunden.
**Auswirkung:** Ein zuvor zugelassener Teilnehmer erhält nach Ende des erlaubten Beitrittsfensters weiterhin neue Video-Zugangsdaten, solange sein App-Token gültig ist. Ein Passwortwechsel allein invalidiert bereits ausgestellte App-Tokens ebenfalls nicht.
**Nachweis:** Mit gültigem App-Token und einem simulierten, bereits am Vortag beendeten Event lieferte der unveränderte Connect-Handler HTTP 200 mit einem neuen Video-Token.
**Behebung:** Dieselbe serverseitige Zugangsprüfung für Join und Connect verwenden. Token-Laufzeiten am erlaubten Meeting-Ende begrenzen und einen Sitzungs-/Berechtigungsstand für Widerrufe prüfen. Die beabsichtigte Admin-Testzimmer-Ausnahme separat behandeln.
## 4. P2 - Krankheits- und Urlaubsgründe sind öffentlich abrufbar
**Stelle:** `collections/AvailabilityOverrides.ts:17`.
Die Collection erlaubt anonymes Lesen aller Dokumente. Das Feld `reason` enthält unter anderem `krankheit` und `urlaub` und besitzt keinen eigenen Leseschutz. Die generische Payload-API unter `/api/availability-overrides` macht damit Datum und Abwesenheitsgrund öffentlich; gleiches gilt grundsätzlich für GraphQL.
**Auswirkung:** Persönliche Abwesenheitsgründe lassen sich ohne Anmeldung abfragen, soweit entsprechende Einträge gespeichert sind. Zur Buchung reicht die Information, welche Slots verfügbar sind.
**Nachweis:** Statisch anhand von Collection-Zugriff, Felddefinition und generischem REST-Router.
**Behebung:** Collection-Lesen auf Admins beschränken oder mindestens den Grund schützen. Der bestehende serverseitige Slot-Endpunkt kann weiterhin ausschließlich verfügbare Zeiten herausgeben.
## 5. P2 - Registrierung und E-Mail-Wechsel umgehen die Versandlimits
**Stellen:** `collections/Customers.ts:84`, `collections/Customers.ts:46`.
Die öffentliche Kundenerstellung löst unmittelbar eine Bestätigungs-E-Mail aus. Änderungen der eigenen E-Mail-Adresse lösen denselben Versand erneut aus. Für diese Operationen gibt es keine entsprechenden Limits in der Collection. Die Limits des separaten `/api/auth/resend-verification`-Handlers greifen hier nicht; `maxLoginAttempts` schützt nur Login-Versuche.
**Auswirkung:** Ohne bestätigte Adresse lassen sich neue Konten für verschiedene Empfänger erzeugen beziehungsweise eigene E-Mail-Adressen wiederholt ändern und damit Bestätigungsmails auslösen. Das ermöglicht Versandmissbrauch, Datenbankwachstum und Belastung des SMTP-Kontos. Ein externes Proxy-Limit könnte die Häufigkeit mindern, ist im geprüften Repository aber nicht als Schutz für diese Vorgänge implementiert.
**Nachweis:** Statisch anhand der öffentlichen Collection-Operationen und Versand-Hooks. Kein Versandtest durchgeführt.
**Behebung:** Erstellung und E-Mail-Änderung vor dem Versand zentral pro IP, Konto und Zieladresse begrenzen. Regeln auf Collection-/Hook-Ebene oder einem exklusiven, geschützten Einstiegspunkt durchsetzen, sodass REST und GraphQL sie nicht umgehen können.
## 6. P2 - Ein bestätigtes Konto kann beliebig viele identische Buchungsanfragen erzeugen
**Stellen:** `app/(payload)/api/booking/request/route.ts:75`, `collections/BookingRequests.ts:163`.
Der Handler prüft Anmeldung, E-Mail-Bestätigung und Slot-Verfügbarkeit, aber weder eine Rate-Limit-Quote noch eine schon bestehende identische offene Anfrage. Offene Anfragen belegen den Slot nicht. Jeder erfolgreiche Create löst Bestätigungs- und Admin-Benachrichtigungen aus.
**Auswirkung:** Ein einziges verifiziertes Konto kann den Admin-Eingang, die Buchungsliste und den Mailversand mit Duplikaten fluten.
**Nachweis:** 25 unmittelbar aufeinanderfolgende identische Anfragen wurden vom unveränderten Handler mit HTTP 200 angenommen und an die simulierte Create-Operation weitergegeben. Keine echten E-Mails verschickt.
**Behebung:** Pro Kunde begrenzen und identische offene Anfragen atomar deduplizieren. Wiederholte identische Requests sollten keinen weiteren Datensatz und keinen erneuten Mailversand erzeugen.
## Abhängigkeiten und weitere Prüfergebnisse
`npm audit --json` meldete im Lockfile **7 betroffene Pakete: 6 moderate, 1 low, 0 high, 0 critical**. Die Zahl enthält transitive Abhängigkeitsketten und bedeutet nicht sieben unabhängig ausnutzbare Anwendungslücken.
- **esbuild 0.18.20** ist über `@payloadcms/db-postgres → drizzle-kit → @esbuild-kit/esm-loader → @esbuild-kit/core-utils` installiert. Das [Advisory GHSA-67mh-4wv8-2f99](https://github.com/advisories/GHSA-67mh-4wv8-2f99) betrifft esbuilds Entwicklungs-Webserver. Im geprüften App-Startpfad wurde dieser Server nicht gefunden; daraus wird keine bestätigte Produktionslücke abgeleitet. npm meldet für diese Kette keinen verfügbaren automatischen Fix.
- **DOMPurify/Monaco:** Das Lockfile-Audit meldete mehrere DOMPurify-Advisories, darunter [GHSA-55q2-fjhq-7xh7](https://github.com/advisories/GHSA-55q2-fjhq-7xh7), mit verfügbarem Fix. Dieses Advisory setzt spezielle `IN_PLACE`-/Hook-Nutzung voraus. `npm ls dompurify monaco-editor --all` zeigte beide Pakete im aktuellen Installationsbaum nicht an. Produktionsinstallation aus dem Lockfile und tatsächliche Nutzung müssen vor einer Aussage zur Ausnutzbarkeit abgeglichen werden; ein XSS-Exploit wurde nicht nachgewiesen.
- Ein begrenzter Musterscan der aktuell versionierten Dateien fand keine privaten Schlüsselblöcke oder GitHub-/AWS-Tokenmuster. Das ist kein vollständiger Secret- oder Historien-Scan.
- Positiv: Adminrechte verlangen Auth-Collection und Rolle; Buchungsaktionen prüfen den Eigentümer; Verifizierungs-/Kalender-Tokens werden gehasht gespeichert; öffentliche Event-Felder geben das Meeting-Passwort nicht über die reguläre Payload-API frei; Cron-Aufrufe prüfen ein Secret.
## Reihenfolge der Behebung
Zuerst die Weitergabe interner Notizen stoppen. Danach private Meeting-Zugänge und Token-Austausch an den aktuellen Berechtigungsstatus binden, Abwesenheitsgründe schützen und die beiden Versand-/Buchungsmissbrauchspfade begrenzen. Abhängigkeitsupdates separat prüfen und mit dem tatsächlich ausgelieferten Installationsbaum abgleichen.
+12 -1
View File
@@ -1,7 +1,7 @@
import type { Access, FieldAccess } from "payload";
/**
* Only the "users" auth collection (Anna/admins) may perform the action —
* Only the "users" auth collection (Anna/admins) may perform the action -
* and only with the "admin" role, so adding further roles to Users later
* only means extending this one check. Site visitors authenticate against
* the separate "customers" collection (see collections/Customers.ts) and
@@ -13,6 +13,17 @@ export const isAdmin: Access = ({ req: { user } }) =>
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
user?.collection === "users" && user.role === "admin";
/**
* Admins always see Analytics; any other "users" account additionally needs
* its own `canViewAnalytics` flag (see collections/Users.ts) - this is the
* granular permission the single "admin" role doesn't otherwise provide.
* Used both as a Payload Access/FieldAccess function and called directly
* (with a minimal `{ req: { user } }` shape) from the custom admin nav-link
* and view components, which Payload does not gate via collection access.
*/
export const canViewAnalytics: Access = ({ req: { user } }) =>
user?.collection === "users" && (user.role === "admin" || user.canViewAnalytics === true);
/**
* Admins can read every document (including drafts); everyone else may only
* read documents whose Payload draft/publish status is "published".
+1 -1
View File
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
import { Inter } from "next/font/google";
import "../(frontend)/globals.css";
// Its own root layout — the video-call UI is intentionally full-bleed and
// Its own root layout - the video-call UI is intentionally full-bleed and
// dark, without the marketing navbar/footer around it.
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
+1 -1
View File
@@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
const resolved = resolveSeo({
seo: post.seo,
fallbackTitle: `${post.title} – ${siteConfig.name}`,
fallbackTitle: `${post.title} - ${siteConfig.name}`,
fallbackDescription: () => post.teaser,
fallbackImage: post.coverImage,
defaultOgImage: seoSettings.defaultOgImage,
+4 -3
View File
@@ -55,9 +55,10 @@ export default async function AktuellesPage() {
<Section tone="plain">
{posts.length === 0 ? (
<p className="text-lg text-anouma-plum">
Hier erscheinen bald die ersten Neuigkeiten — schau gerne wieder vorbei.
</p>
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
<p className="font-serif text-xl font-medium text-anouma-plum">Hier erscheinen bald die ersten Neuigkeiten</p>
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">Schau gerne wieder vorbei.</p>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, i) => (
+21 -2
View File
@@ -6,6 +6,7 @@ import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { BookingWidget } from "@/components/booking/BookingWidget";
import { GroupRegistrationForm } from "@/components/GroupRegistrationForm";
import { getOfferBySlug } from "@/lib/payload/content";
import { moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
@@ -21,8 +22,10 @@ import { resolveRedirect } from "@/lib/seo/redirects";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
type Args = { params: Promise<{ slug: string }> };
const WEEKDAY_LABELS: Record<string, string> = { monday: "Montag", tuesday: "Dienstag", wednesday: "Mittwoch", thursday: "Donnerstag", friday: "Freitag", saturday: "Samstag", sunday: "Sonntag" };
async function resolveOfferOrRedirect(slug: string) {
const offer = await getOfferBySlug(slug);
@@ -43,7 +46,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
const resolved = resolveSeo({
seo: offer.seo,
fallbackTitle: `${offer.title} – ${siteConfig.name}`,
fallbackTitle: `${offer.title} - ${siteConfig.name}`,
fallbackDescription: () => offer.shortDescription,
fallbackImage: offer.image,
defaultOgImage: seoSettings.defaultOgImage,
@@ -123,7 +126,23 @@ export default async function OfferDetailPage({ params }: Args) {
</div>
</Section>
{offer.bookable ? (
{offer.bookingMode === "group" ? (
<Section tone="cream">
<div className="mx-auto max-w-2xl">
<h2 className="mb-3 text-center font-serif text-3xl font-medium text-anouma-plum">Für die Gruppe anmelden</h2>
{offer.groupInfo && <p className="mx-auto mb-6 max-w-xl text-center text-base leading-relaxed text-anouma-plum">{offer.groupInfo}</p>}
{offer.groupSchedule && (
<div className="mb-8 rounded-2xl bg-white p-5 text-sm text-anouma-plum">
<p className="font-medium">Fester Termin</p>
<p className="mt-1">{offer.groupSchedule.recurrence === "monthly" ? "Monatlich" : "Wöchentlich"}{offer.groupSchedule.weekday ? ` · ${WEEKDAY_LABELS[offer.groupSchedule.weekday] ?? offer.groupSchedule.weekday}` : ""}{offer.groupSchedule.startTime ? ` · ${offer.groupSchedule.startTime}` : ""}{offer.groupSchedule.endTime ? `-${offer.groupSchedule.endTime}` : ""}</p>
</div>
)}
{offer.groupCapacity && <p className="mb-2 text-sm text-anouma-plum/75">Plätze: {offer.groupCapacity}</p>}
{offer.registrationDeadline && <p className="mb-6 text-sm text-anouma-plum/75">Anmeldung möglich bis {new Date(offer.registrationDeadline).toLocaleDateString("de-DE")}</p>}
<GroupRegistrationForm offerSlug={offer.slug!} siteKey={turnstileSiteKey} />
</div>
</Section>
) : offer.bookable ? (
<Section tone="cream">
<div className="mx-auto max-w-3xl">
<h2 className="mb-6 text-center font-serif text-3xl font-medium text-anouma-plum">Termin auswählen</h2>
+3 -3
View File
@@ -19,13 +19,13 @@ import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION =
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.";
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise - die Angebote von Anouma im Überblick.";
export async function generateMetadata(): Promise<Metadata> {
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: intro.seo,
fallbackTitle: `Begleitung für dich und deine Familie – ${siteConfig.name}`,
fallbackTitle: `Begleitung für dich und deine Familie - ${siteConfig.name}`,
fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
@@ -170,7 +170,7 @@ export default async function AngebotePage() {
<CTA
title="Unsicher, welcher Raum passt?"
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch — gemeinsam schauen wir, was dein Anliegen gerade braucht."
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch - gemeinsam schauen wir, was dein Anliegen gerade braucht."
/>
</>
);
+3 -3
View File
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({
title: `Singkreise – ${siteConfig.name}`,
title: `Singkreise - ${siteConfig.name}`,
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
path: "/angebote/singkreise",
});
@@ -30,7 +30,7 @@ export default async function SingkreisePage() {
<>
<PageHeader
eyebrow="Singkreise"
title="Singkreise – miteinander singen, klingen und sein"
title="Singkreise - miteinander singen, klingen und sein"
lead="Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele."
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: "Singkreise" }]}
/>
@@ -53,7 +53,7 @@ export default async function SingkreisePage() {
<CTA
title="Deine Stimme erklingen lassen"
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby — melde dich, wenn du einen Kreis besuchen möchtest."
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby - melde dich, wenn du einen Kreis besuchen möchtest."
/>
</>
);
@@ -21,6 +21,7 @@ export default async function VerifyEmailPage({ params }: Args) {
eyebrow="Konto"
title={verified ? "E-Mail-Adresse bestätigt" : "Bestätigung fehlgeschlagen"}
crumbs={[{ title: "E-Mail bestätigen" }]}
compact
/>
<Section tone="plain">
<div className="mx-auto max-w-md text-center">
+1 -1
View File
@@ -11,7 +11,7 @@ const TOKEN_SHAPE = /^[0-9a-f]{64}$/i;
type Args = { params: Promise<{ tokenFile: string }> };
/**
* GET /calendar/<token>.ics — a private iCalendar feed, subscribable from
* GET /calendar/<token>.ics - a private iCalendar feed, subscribable from
* Apple Calendar, Google Calendar, Outlook, Thunderbird etc. The token
* itself is the only credential (there's no login here), so it's treated
* like a secret: looked up by hash only, never logged, and the response is
+4 -4
View File
@@ -21,7 +21,7 @@ export default async function DatenschutzPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} />
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
@@ -33,9 +33,9 @@ export default async function DatenschutzPage() {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname]
[Platzhalter - Vor- und Nachname]
<br />
[Platzhalter — Anschrift]
[Platzhalter - Anschrift]
<br />
E-Mail: {contact.email}
</p>
@@ -48,7 +48,7 @@ export default async function DatenschutzPage() {
<p className="mt-4 leading-relaxed text-anouma-plum">
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des
Zugriffs). [Platzhalter — Details zum verwendeten Hosting-Anbieter ergänzen.]
Zugriffs). [Platzhalter - Details zum verwendeten Hosting-Anbieter ergänzen.]
</p>
</div>
+15 -3
View File
@@ -1,5 +1,13 @@
@import "tailwindcss";
@font-face {
font-family: "Antropos";
src: url("/fonts/baar-antropos.ttf") format("truetype");
font-display: swap;
font-style: normal;
font-weight: 100 900;
}
@theme inline {
/* Anouma color palette */
--color-anouma-mauve-dark: #8b616d;
@@ -27,8 +35,8 @@
--color-background: #fbf6f1;
--color-foreground: #453238;
--font-serif: var(--font-cormorant), "Cormorant Garamond", ui-serif, Georgia, serif;
--font-sans: var(--font-inter), "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Antropos", ui-serif, Georgia, serif;
--font-sans: "Antropos", ui-sans-serif, system-ui, sans-serif;
--animate-fade-up: fade-up 0.8s ease-out both;
--animate-fade-in: fade-in 1s ease-out both;
@@ -69,7 +77,11 @@ html {
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
font-family: "Antropos", ui-sans-serif, system-ui, sans-serif;
}
.font-antropos {
font-family: "Antropos", ui-serif, Georgia, serif;
}
::selection {
+3 -3
View File
@@ -10,7 +10,7 @@ import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic";
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.";
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma - Natur, Gemeinschaft und gemeinsame Räume.";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
@@ -36,11 +36,11 @@ export default async function ImpressionenPage() {
<PageHeader
eyebrow="Impressionen"
title="Einblicke in gemeinsame Räume"
lead="Diese Galerie ist als Platzhalter-System angelegt — echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
lead="Diese Galerie ist als Platzhalter-System angelegt - echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
crumbs={[{ title: "Impressionen" }]}
/>
<Section tone="plain">
<div className="grid auto-rows-[14rem] grid-cols-2 gap-4 sm:grid-cols-4">
<div className="grid auto-rows-[10rem] grid-cols-2 gap-4 sm:auto-rows-[14rem] sm:grid-cols-4">
{gallery.map((item, i) => (
<Reveal key={item.label} delay={Math.min(i * 0.04, 0.3)} className={item.span}>
<ImagePlaceholder mood={item.mood} label={item.label} shape="soft" className="h-full w-full" />
+7 -7
View File
@@ -12,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: "Impressum",
description: "Impressum von Anouma gemäß § 5 TMG.",
// noindex, but still crawlable — legal boilerplate deliberately kept
// noindex, but still crawlable - legal boilerplate deliberately kept
// out of search results without cutting off the internal links on it.
robots: { index: false, follow: true },
alternates: { canonical: `${siteUrl}/impressum` },
@@ -23,7 +23,7 @@ export default async function ImpressumPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} />
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
@@ -35,11 +35,11 @@ export default async function ImpressumPage() {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Angaben gemäß § 5 TMG</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname]
[Platzhalter - Vor- und Nachname]
<br />
[Platzhalter — Straße und Hausnummer]
[Platzhalter - Straße und Hausnummer]
<br />
[Platzhalter — PLZ und Ort]
[Platzhalter - PLZ und Ort]
</p>
</div>
@@ -61,7 +61,7 @@ export default async function ImpressumPage() {
Umsatzsteuer-Identifikationsnummer
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
[Platzhalter - sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
</p>
</div>
@@ -70,7 +70,7 @@ export default async function ImpressumPage() {
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname, Anschrift wie oben]
[Platzhalter - Vor- und Nachname, Anschrift wie oben]
</p>
</div>
+12 -9
View File
@@ -14,12 +14,13 @@ import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION = "Ich freue mich, von dir zu hören.";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
export async function generateMetadata(): Promise<Metadata> {
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: contact.seo,
fallbackTitle: `Kontakt – ${siteConfig.name}`,
fallbackTitle: `Kontakt - ${siteConfig.name}`,
fallbackDescription: () => contact.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
@@ -32,9 +33,11 @@ export async function generateMetadata(): Promise<Metadata> {
});
}
export default async function KontaktPage() {
export default async function KontaktPage({ searchParams }: { searchParams: Promise<{ subject?: string }> }) {
const contact = await getContactGlobal();
const siteUrl = await getSiteUrl();
const params = await searchParams;
const isAppointment = params.subject === "Terminanfrage";
return (
<>
@@ -50,12 +53,15 @@ export default async function KontaktPage() {
title={contact.title || "Ich freue mich, von dir zu hören"}
lead={contact.lead}
crumbs={[{ title: "Kontakt" }]}
blobTone="sage"
blobPosition="top-left"
compact
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
<Reveal>
<ContactForm toEmail={contact.email} />
<ContactForm siteKey={turnstileSiteKey} subjectPrefix={isAppointment ? "Terminanfrage" : "Allgemeine Anfrage"} type={isAppointment ? "appointment" : "contact"} submitLabel={isAppointment ? "Terminanfrage senden" : "Nachricht senden"} />
</Reveal>
<Reveal delay={0.1} className="space-y-8">
@@ -67,12 +73,9 @@ export default async function KontaktPage() {
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
E-Mail
</span>
<a
href={`mailto:${contact.email}`}
className="text-lg text-anouma-plum hover:text-anouma-mauve-dark"
>
<span className="text-lg text-anouma-plum">
{contact.email}
</a>
</span>
</p>
{contact.phone && (
<p>
@@ -85,7 +88,7 @@ export default async function KontaktPage() {
{contact.region && (
<p>
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
Region
Adresse
</span>
<span className="text-lg text-anouma-plum">{contact.region}</span>
</p>
+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>
);
+3 -3
View File
@@ -8,7 +8,7 @@ import { ResendVerificationButton } from "@/components/auth/ResendVerificationBu
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
// Applies to every /konto/* page at once — private account area, never indexable.
// Applies to every /konto/* page at once - private account area, never indexable.
export const metadata: Metadata = { robots: { index: false, follow: false } };
const navItems = [
@@ -44,12 +44,12 @@ export default async function KontoLayout({ children }: { children: ReactNode })
<Link
key={item.href}
href={item.href}
className="whitespace-nowrap rounded-full px-4 py-2.5 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
className="flex min-h-11 items-center whitespace-nowrap rounded-full px-4 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
>
{item.title}
</Link>
))}
<LogoutButton className="whitespace-nowrap rounded-full px-4 py-2.5 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
<LogoutButton className="flex min-h-11 items-center whitespace-nowrap rounded-full px-4 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
</nav>
<div className="min-w-0">{children}</div>
</div>
+8 -7
View File
@@ -1,8 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
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,9 +13,10 @@ 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");
const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.endTime) >= new Date());
const past = bookings.filter((b) => new Date(b.endTime) < new Date() || b.status === "rejected" || b.status === "cancelled");
return (
<div className="space-y-10">
@@ -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>
)}
@@ -61,9 +64,7 @@ export default async function KontoOverviewPage() {
{past.length > 0 && (
<div>
<Link href="/konto/termine" className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4">
Alle Termine (inkl. vergangene) ansehen →
</Link>
<ArrowLink href="/konto/termine">Alle Termine (inkl. vergangene) ansehen</ArrowLink>
</div>
)}
</div>
+1 -1
View File
@@ -14,7 +14,7 @@ export default async function KontoProfilPage() {
<div className="mt-6 rounded-3xl bg-white p-6">
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
<p className="mt-6 text-xs text-anouma-plum/60">
E-Mail: {customer.email} — für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
E-Mail: {customer.email} - für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
</p>
</div>
</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>
)}
+14 -4
View File
@@ -2,10 +2,11 @@ import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/components/Footer";
import { Analytics } from "@/components/Analytics";
import { siteConfig } from "@/lib/site";
import { getOffers } from "@/lib/payload/content";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getBookingSettingsGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getBookingSettingsGlobal, getContactGlobal, getCookieSettingsGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, organizationOrLocalBusinessJsonLd, websiteJsonLd } from "@/lib/seo/jsonld";
import "./globals.css";
@@ -30,7 +31,7 @@ export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain),
title: {
default: siteConfig.title,
template: `%s — ${siteConfig.name}`,
template: `%s - ${siteConfig.name}`,
},
description: siteConfig.description,
openGraph: {
@@ -46,18 +47,19 @@ export const metadata: Metadata = {
title: siteConfig.title,
description: siteConfig.description,
},
// Configurable via env only — never a hardcoded verification ID (see .env.example).
// Configurable via env only - never a hardcoded verification ID (see .env.example).
...(process.env.GOOGLE_SITE_VERIFICATION ? { verification: { google: process.env.GOOGLE_SITE_VERIFICATION } } : {}),
};
export default async function RootLayout({ children }: LayoutProps<"/">) {
const [offers, customer, seoSettings, bookingSettings, contact, siteUrl] = await Promise.all([
const [offers, customer, seoSettings, bookingSettings, contact, siteUrl, cookieSettings] = await Promise.all([
getOffers(),
getCurrentCustomer(),
getSEOSettingsGlobal(),
getBookingSettingsGlobal(),
getContactGlobal(),
getSiteUrl(),
getCookieSettingsGlobal(),
]);
return (
@@ -84,6 +86,14 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
{children}
</main>
<Footer />
<Analytics
consentVersion={cookieSettings.consentVersion}
preferencesEnabled={cookieSettings.preferences?.enabled !== false}
statisticsEnabled={cookieSettings.statistics?.enabled !== false}
necessary={{ label: cookieSettings.necessary?.label ?? "Notwendig", description: cookieSettings.necessary?.description ?? "" }}
preferences={{ label: cookieSettings.preferences?.label ?? "Präferenzen", description: cookieSettings.preferences?.description ?? "" }}
statistics={{ label: cookieSettings.statistics?.label ?? "Statistik", description: cookieSettings.statistics?.description ?? "" }}
/>
</body>
</html>
);
+3 -2
View File
@@ -8,6 +8,7 @@ import { LoginForm } from "@/components/auth/LoginForm";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
export const metadata: Metadata = {
title: "Anmelden",
@@ -20,11 +21,11 @@ export default async function LoginPage() {
return (
<>
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} />
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-md">
<Suspense>
<LoginForm />
<LoginForm siteKey={turnstileSiteKey} />
</Suspense>
<p className="mt-6 text-center text-sm text-anouma-plum">
Noch kein Konto?{" "}
+83 -62
View File
@@ -2,9 +2,11 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Hero } from "@/components/Hero";
import { Button } from "@/components/Button";
import { ArrowLink } from "@/components/ArrowLink";
import { Section, SectionHeading } from "@/components/Section";
import { AngebotCard } from "@/components/AngebotCard";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { OrganicBlob } from "@/components/OrganicBlob";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
@@ -25,11 +27,11 @@ export async function generateMetadata(): Promise<Metadata> {
const [home, seoSettings] = await Promise.all([getHomeGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: home.seo,
fallbackTitle: `${siteConfig.name} – Begleitung für dich und deine Familie`,
fallbackTitle: `${siteConfig.name} - Begleitung für dich und deine Familie`,
fallbackDescription: () =>
home.heroSupporting ||
seoSettings.defaultDescription ||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise - Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
fallbackImage: home.heroImage,
defaultOgImage: seoSettings.defaultOgImage,
});
@@ -40,8 +42,8 @@ export async function generateMetadata(): Promise<Metadata> {
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
// Homepage bypasses the "%s — Anouma" title template — the brand is
// already part of the fallback/CMS title itself, avoiding "Anouma — Anouma".
// Homepage bypasses the "%s - Anouma" title template - the brand is
// already part of the fallback/CMS title itself, avoiding "Anouma - Anouma".
return { ...meta, title: { absolute: resolved.title } };
}
@@ -53,6 +55,8 @@ export default async function Home() {
getUpcomingEvents(3),
]);
const groups = groupOffersByCategory(offers);
const featuredGroup = groups.find((g) => g.category === "prozessbegleitung");
const remainingGroups = featuredGroup ? groups.filter((g) => g !== featuredGroup) : groups;
const siteUrl = await getSiteUrl();
return (
@@ -73,9 +77,9 @@ export default async function Home() {
actions={
<>
<Button href="/termin-buchen">Termin buchen</Button>
<Button href="/angebote" variant="secondary">
<ArrowLink href="/angebote" size="base" className="self-center">
Angebote entdecken
</Button>
</ArrowLink>
</>
}
>
@@ -89,24 +93,39 @@ export default async function Home() {
<SectionHeading
eyebrow="Angebote"
title="Räume, die zu deinem Weg passen"
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen — jedes Angebot ist ein eigener, geschützter Raum."
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen - jedes Angebot ist ein eigener, geschützter Raum."
/>
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{groups.map((group, i) => {
const isSingle = group.offers.length === 1;
const first = group.offers[0];
return (
<Reveal key={group.category} delay={i * 0.08}>
<AngebotCard
href={isSingle ? `/angebote/${first.slug}` : group.href}
title={group.label}
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
mood={moodForOffer(first)}
imageSrc={mediaUrl(first.image, "card")}
/>
</Reveal>
);
})}
<div className="mt-14 space-y-6">
{/* Prozessbegleitung ist das Kernangebot - bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
{featuredGroup && (
<Reveal>
<AngebotCard
href={featuredGroup.offers.length === 1 ? `/angebote/${featuredGroup.offers[0].slug}` : featuredGroup.href}
title={featuredGroup.label}
tagline={featuredGroup.offers.length === 1 ? featuredGroup.offers[0].shortDescription : `${featuredGroup.offers.length} Angebote`}
mood={moodForOffer(featuredGroup.offers[0])}
imageSrc={mediaUrl(featuredGroup.offers[0].image, "card")}
size="large"
/>
</Reveal>
)}
<div className="grid gap-6 sm:grid-cols-3">
{remainingGroups.map((group, i) => {
const isSingle = group.offers.length === 1;
const first = group.offers[0];
return (
<Reveal key={group.category} delay={i * 0.08}>
<AngebotCard
href={isSingle ? `/angebote/${first.slug}` : group.href}
title={group.label}
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
mood={moodForOffer(first)}
imageSrc={mediaUrl(first.image, "card")}
/>
</Reveal>
);
})}
</div>
</div>
</Section>
@@ -141,9 +160,7 @@ export default async function Home() {
</div>
)}
<div className="mt-8">
<Button href="/ueber-mich" variant="ghost">
Mehr über mich lesen →
</Button>
<ArrowLink href="/ueber-mich">Mehr über mich lesen</ArrowLink>
</div>
</Reveal>
</div>
@@ -151,24 +168,27 @@ export default async function Home() {
)}
{home.values && home.values.length > 0 && (
<Section tone="mauve">
<SectionHeading
eyebrow={home.valuesEyebrow ?? undefined}
title={home.valuesTitle || "Philosophie"}
align="center"
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
/>
<div className="mt-16 grid gap-10 sm:grid-cols-3">
{home.values.map((wert, i) => (
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
{wert.title}
</p>
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
„{wert.quote}“
</p>
</Reveal>
))}
<Section tone="mauve" className="relative overflow-hidden">
<OrganicBlob tone="sage" className="-left-24 top-1/2 h-96 w-96 -translate-y-1/2 opacity-20" />
<div className="relative">
<SectionHeading
eyebrow={home.valuesEyebrow ?? undefined}
title={home.valuesTitle || "Philosophie"}
align="center"
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
/>
<div className="mt-16 grid gap-10 sm:grid-cols-3">
{home.values.map((wert, i) => (
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
{wert.title}
</p>
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
„{wert.quote}“
</p>
</Reveal>
))}
</div>
</div>
</Section>
)}
@@ -177,9 +197,7 @@ export default async function Home() {
<Section tone="warm">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionHeading eyebrow="Termine" title="Aktuelle Termine" className="mb-0" />
<Button href="/termine" variant="ghost">
Alle Termine →
</Button>
<ArrowLink href="/termine">Alle Termine</ArrowLink>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{events.map((event, i) => (
@@ -195,23 +213,31 @@ export default async function Home() {
<Section tone="cream">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionHeading eyebrow="Aktuelles" title="Neuigkeiten und Inspiration" className="mb-0" />
<Button href="/aktuelles" variant="ghost">
Alle Neuigkeiten →
</Button>
<ArrowLink href="/aktuelles">Alle Neuigkeiten</ArrowLink>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{posts.map((post, i) => (
<Reveal key={post.slug} delay={i * 0.08}>
<Link href={`/aktuelles/${post.slug}`} className="block rounded-3xl bg-background p-7">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum">
<Link href={`/aktuelles/${post.slug}`} className="group block">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-3xl">
<ImagePlaceholder
mood="sand"
label={post.title}
src={mediaUrl(post.coverImage, "card")}
alt={mediaAlt(post.coverImage) ?? post.title}
shape="soft"
className="h-full w-full transition-transform duration-700 group-hover:scale-[1.03]"
/>
</div>
<p className="mt-4 text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
<h3 className="mt-3 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">{post.teaser}</p>
<h3 className="mt-2 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">{post.teaser}</p>
</Link>
</Reveal>
))}
@@ -228,13 +254,8 @@ export default async function Home() {
</Reveal>
))}
</div>
<div className="mt-10 text-center">
<Link
href="/impressionen"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Impressionen ansehen
</Link>
<div className="mt-10 flex justify-center">
<ArrowLink href="/impressionen">Alle Impressionen ansehen</ArrowLink>
</div>
</Section>
@@ -242,7 +263,7 @@ export default async function Home() {
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
lead={
home.ctaLead ||
"Ich freue mich, von dir zu hören — schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
"Ich freue mich, von dir zu hören - schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
}
/>
</>
+3 -1
View File
@@ -7,6 +7,7 @@ import { RegisterForm } from "@/components/auth/RegisterForm";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
export const metadata: Metadata = {
title: "Konto erstellen",
@@ -24,10 +25,11 @@ export default async function RegisterPage() {
title="Konto erstellen"
lead="Mit einem Konto kannst du Termine anfragen und behältst alle deine Buchungen im Blick."
crumbs={[{ title: "Konto erstellen" }]}
compact
/>
<Section tone="plain">
<div className="mx-auto max-w-md">
<RegisterForm />
<RegisterForm siteKey={turnstileSiteKey} />
<p className="mt-6 text-center text-sm text-anouma-plum">
Schon ein Konto?{" "}
<Link href="/login" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
+25 -9
View File
@@ -4,7 +4,7 @@ import { Section, SectionHeading } from "@/components/Section";
import { ContactForm } from "@/components/ContactForm";
import { AngebotCard } from "@/components/AngebotCard";
import { Reveal } from "@/components/Reveal";
import { getBookingGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getBookingGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getOffers } from "@/lib/payload/content";
import { moodForOffer } from "@/lib/angebote";
import { mediaUrl } from "@/lib/payload/media";
@@ -17,12 +17,13 @@ import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION = "Kennenlernen & Termine vereinbaren.";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
export async function generateMetadata(): Promise<Metadata> {
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: booking.seo,
fallbackTitle: `Termin buchen – ${siteConfig.name}`,
fallbackTitle: `Termin buchen - ${siteConfig.name}`,
fallbackDescription: () => booking.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
@@ -36,7 +37,7 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function TerminBuchenPage() {
const [booking, contact, offers] = await Promise.all([getBookingGlobal(), getContactGlobal(), getOffers()]);
const [booking, offers] = await Promise.all([getBookingGlobal(), getOffers()]);
const bookableOffers = offers.filter((offer) => offer.bookable);
const siteUrl = await getSiteUrl();
@@ -56,8 +57,25 @@ export default async function TerminBuchenPage() {
crumbs={[{ title: "Termin buchen" }]}
/>
<Section tone="cream">
<div className="mx-auto grid max-w-4xl gap-5 md:grid-cols-2">
<a href="#freie-termine" className="rounded-3xl bg-white p-7 transition-transform hover:-translate-y-1">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-dustyrose">Direkt buchen</p>
<h2 className="mt-3 font-serif text-2xl font-medium text-anouma-plum">Freien Termin auswählen</h2>
<p className="mt-3 text-sm leading-relaxed text-anouma-plum/80">Wähle ein Angebot, einen freien Tag und eine passende Uhrzeit im Kalender.</p>
<span className="mt-5 inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white">Zum Kalender</span>
</a>
<a href="/kontakt?subject=Terminanfrage" className="rounded-3xl bg-anouma-plum p-7 transition-transform hover:-translate-y-1">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-cream-light/75">Persönlich abstimmen</p>
<h2 className="mt-3 font-serif text-2xl font-medium text-white">Anfrage stellen</h2>
<p className="mt-3 text-sm leading-relaxed text-white/80">Wenn du keinen freien Kalendertermin brauchst oder etwas Besonderes besprechen möchtest.</p>
<span className="mt-5 inline-flex rounded-full bg-white px-5 py-2.5 text-sm font-medium text-anouma-plum">Zur Kontaktseite</span>
</a>
</div>
</Section>
{bookableOffers.length > 0 && (
<Section tone="plain">
<Section tone="plain" id="freie-termine">
<SectionHeading eyebrow="Angebot auswählen" title="Wofür möchtest du einen Termin?" />
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{bookableOffers.map((offer, i) => (
@@ -98,21 +116,19 @@ export default async function TerminBuchenPage() {
title="Allgemeine Anfrage"
lead={
bookableOffers.length > 0
? "Für einen konkreten Termin wähle oben ein Angebot — hier kannst du auch einfach eine allgemeine Nachricht schreiben."
? "Für einen konkreten Termin wähle oben ein Angebot - hier kannst du auch einfach eine allgemeine Nachricht schreiben."
: undefined
}
align="left"
/>
<div className="mt-10">
<ContactForm
toEmail={contact.email}
siteKey={turnstileSiteKey}
subjectPrefix="Terminanfrage über anouma.org"
type="appointment"
submitLabel="Terminanfrage senden"
/>
</div>
{booking.formNote && (
<p className="mt-8 text-sm leading-relaxed text-anouma-plum/80">{booking.formNote}</p>
)}
</div>
</Section>
</>
@@ -5,8 +5,10 @@ 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";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
export const metadata: Metadata = {
title: "Meeting beitreten",
@@ -21,15 +23,15 @@ 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">
<OrganicBlob tone="rose" className="-right-24 -top-24 h-96 w-96" />
<OrganicBlob tone="cream" className="-left-32 bottom-0 h-96 w-96" />
<div className="relative mx-auto w-full max-w-md px-6 text-center">
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.12em] text-anouma-mauve-dark">
<Link href="/" className="font-antropos text-2xl font-semibold tracking-[0.12em] text-anouma-mauve-dark">
ANOUMA
</Link>
<p className="mt-8 text-sm font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
@@ -41,7 +43,7 @@ export default async function BeitretenPage({ params }: Args) {
</p>
<div className="mt-10 rounded-[2rem] bg-white/70 p-8 text-left shadow-sm backdrop-blur">
<JoinForm slug={slug} />
<JoinForm slug={slug} siteKey={turnstileSiteKey} />
</div>
</div>
</section>
+7 -4
View File
@@ -25,6 +25,7 @@ import { resolveRedirect } from "@/lib/seo/redirects";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
type Args = { params: Promise<{ slug: string }> };
@@ -47,7 +48,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
const resolved = resolveSeo({
seo: event.seo,
fallbackTitle: `${event.title} – ${siteConfig.name}`,
fallbackTitle: `${event.title} - ${siteConfig.name}`,
fallbackDescription: () => excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`,
fallbackImage: event.image,
defaultOgImage: seoSettings.defaultOgImage,
@@ -74,9 +75,9 @@ export default async function EventDetailPage({ params }: Args) {
if (event.isOnline) {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: await headers() });
// Must check the collection, not just presence — a logged-in customer
// 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
@@ -126,6 +127,8 @@ export default async function EventDetailPage({ params }: Args) {
eyebrow={categoryLabel}
title={event.title}
crumbs={[{ title: "Termine", href: "/termine" }, { title: event.title }]}
blobTone="mauve"
blobPosition="top-left"
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
@@ -135,7 +138,7 @@ export default async function EventDetailPage({ params }: Args) {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Anmeldung</h2>
<div className="mt-4 max-w-sm">
<RegisterForm slug={slug} />
<RegisterForm slug={slug} siteKey={turnstileSiteKey} />
</div>
</div>
)}
+15 -5
View File
@@ -3,6 +3,7 @@ import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard";
import { ArrowLink } from "@/components/ArrowLink";
import { getAllEvents } from "@/lib/payload/content";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
@@ -20,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();
@@ -32,14 +35,21 @@ export default async function TerminePage() {
title="Aktuelle Termine"
lead="Ein Überblick über anstehende Kreise, Begleitungen und Veranstaltungen."
crumbs={[{ title: "Termine" }]}
blobTone="mauve"
blobPosition="top-left"
/>
<Section tone="plain">
{upcoming.length === 0 ? (
<p className="text-lg text-anouma-plum">
Aktuell sind keine Termine geplant — schau gerne bald wieder vorbei oder melde dich
direkt über die <a href="/kontakt" className="underline underline-offset-4">Kontaktseite</a>.
</p>
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
<p className="font-serif text-xl font-medium text-anouma-plum">Aktuell sind keine Termine geplant</p>
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">
Schau gerne bald wieder vorbei, oder melde dich direkt - ich freue mich von dir zu hören.
</p>
<div className="mt-5 flex justify-center">
<ArrowLink href="/kontakt">Zur Kontaktseite</ArrowLink>
</div>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{upcoming.map((event, i) => (
+1 -1
View File
@@ -21,7 +21,7 @@ export async function generateMetadata(): Promise<Metadata> {
const [about, seoSettings] = await Promise.all([getAboutGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: about.seo,
fallbackTitle: `Über mich – ${siteConfig.name}`,
fallbackTitle: `Über mich - ${siteConfig.name}`,
fallbackDescription: () => excerptFromRichText(about.body) || seoSettings.defaultDescription || "Mein Weg, meine Werte und was mich bewegt.",
fallbackImage: about.portrait,
defaultOgImage: seoSettings.defaultOgImage,
+12
View File
@@ -22,7 +22,13 @@ import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997e
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink'
import { AnalyticsNavLink as AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962 } from '../../../components/admin/AnalyticsNavLink'
import { ConversationsNavLink as ConversationsNavLink_67a2594cf28806bc76971e67212a6181 } from '../../../components/admin/ConversationsNavLink'
import { UploadLimitGuard as UploadLimitGuard_1a82b0e63592dbfb4e9d3c5c946debf3 } from '../../../components/admin/UploadLimitGuard'
import { AdminWelcome as AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6 } from '../../../components/admin/AdminWelcome'
import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView'
import { AdminAnalyticsView as AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0 } from '../../../components/admin/AdminAnalyticsView'
import { AdminConversationsView as AdminConversationsView_180f5c400a066a0a8f248da44d961055 } from '../../../components/admin/AdminConversationsView'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
@@ -51,6 +57,12 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251,
"/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962,
"/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181,
"/components/admin/UploadLimitGuard#UploadLimitGuard": UploadLimitGuard_1a82b0e63592dbfb4e9d3c5c946debf3,
"/components/admin/AdminWelcome#AdminWelcome": AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6,
"/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a,
"/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0,
"/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
@@ -6,10 +6,10 @@ import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } fro
export const dynamic = "force-dynamic";
/**
* POST /api/admin/calendar-feed/regenerate — (re)generates Anna's personal
* POST /api/admin/calendar-feed/regenerate - (re)generates Anna's personal
* ICS feed link. Only the new token's hash is stored; the plaintext URL is
* returned exactly once in this response and can never be retrieved again
* — generating a new one immediately invalidates whichever link was issued
* - generating a new one immediately invalidates whichever link was issued
* before (see collections/Users.ts).
*/
export async function POST(request: Request) {
@@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token";
import { authorizeMeeting } from "@/lib/meeting/access";
export const dynamic = "force-dynamic";
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();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} 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 access = await authorizeMeeting(payload, event, "host");
if (!access) return NextResponse.json({ error: "Dieser Termin ist nicht offen oder wurde storniert." }, { status: 403 });
const { token } = createMeetingToken({
eventSlug,
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
role: "host",
accessVersion: access.accessVersion,
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
});
// Keep the token in the URL fragment: browsers do not send fragments to
// 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 });
}
}
@@ -0,0 +1,34 @@
import { randomUUID } from "node:crypto";
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";
/** Creates a short-lived, database-free test room for an authenticated admin. */
export async function POST(request: Request) {
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 eventSlug = `test-${randomUUID()}`;
const { token } = createMeetingToken({
eventSlug,
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
role: "host",
testRoom: true,
ttlMs: 60 * 60 * 1000,
});
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
const testUrl = `${baseUrl.replace(/\/$/, "")}/termine/${encodeURIComponent(eventSlug)}/call#token=${encodeURIComponent(token)}`;
return NextResponse.json({ testUrl });
} catch (error) {
console.error("admin test meeting start failed", error);
return NextResponse.json({ error: "Der Test-Call konnte nicht gestartet werden." }, { status: 500 });
}
}
@@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { visitorHash } from "@/lib/analytics/salt";
import { classifyBrowser, classifyDevice } from "@/lib/analytics/userAgent";
import { resolveSessionId } from "@/lib/analytics/session";
export const dynamic = "force-dynamic";
const MAX_PATH_LENGTH = 512;
const MAX_EVENT_NAME_LENGTH = 100;
function normalizePath(value: unknown): string | null {
if (typeof value !== "string" || !value.startsWith("/") || value.length > MAX_PATH_LENGTH) return null;
return value;
}
function normalizeEventName(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || trimmed.length > MAX_EVENT_NAME_LENGTH) return null;
return trimmed;
}
/** Hostname only (never path/query) - see collections/AnalyticsEvents.ts. */
function referrerHostFrom(referrer: unknown): string | undefined {
if (typeof referrer !== "string" || !referrer) return undefined;
try {
return new URL(referrer).hostname || undefined;
} catch {
return undefined;
}
}
/**
* Public tracking beacon, called by lib/analytics/client.ts only after the
* visitor has granted statistics consent - but the client's own claim is
* never trusted alone: this route re-checks the CookieSettings global's
* `statistics.enabled` flag server-side before writing anything, so a
* global kill-switch (or a stale/tampered client) can never bypass it.
*
* navigator.sendBeacon() can't reliably set a JSON Content-Type across
* browsers, so this accepts both application/json and a raw text body.
*/
export async function POST(request: Request) {
const ip = getClientIp(request);
if (!checkRateLimit(`analytics:${ip}`, { max: 120, windowMs: 60_000 })) {
return new NextResponse(null, { status: 204 });
}
let body: { eventName?: unknown; path?: unknown; referrer?: unknown };
try {
const raw = await request.text();
body = JSON.parse(raw);
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
return new NextResponse(null, { status: 204 });
}
const path = normalizePath(body.path);
const eventName = normalizeEventName(body.eventName);
if (!path || !eventName) {
return new NextResponse(null, { status: 204 });
}
try {
const payload = await getPayload({ config });
const cookieSettings = await payload.findGlobal({ slug: "cookie-settings" });
if (cookieSettings.statistics?.enabled === false) {
return new NextResponse(null, { status: 204 });
}
const userAgent = request.headers.get("user-agent") ?? "";
const host = request.headers.get("host") ?? "";
const hash = visitorHash({ ip, userAgent, host });
const sessionId = await resolveSessionId(payload, hash);
await payload.create({
collection: "analytics-events",
data: {
sessionId,
visitorHash: hash,
path,
eventName,
referrerHost: referrerHostFrom(body.referrer),
device: classifyDevice(userAgent),
browser: classifyBrowser(userAgent),
},
});
return new NextResponse(null, { status: 204 });
} catch (error) {
console.error("analytics track failed", error);
return new NextResponse(null, { status: 204 });
}
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { allowVerificationEmail } from "@/lib/auth/verificationRateLimit";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
@@ -10,7 +11,7 @@ import type { Customer } from "@/payload-types";
export const dynamic = "force-dynamic";
// Deliberately identical whether or not an account exists / is already
// verified — this endpoint must never let a caller find out either of
// verified - this endpoint must never let a caller find out either of
// those things about an email address that isn't their own session.
const GENERIC_RESPONSE = {
ok: true,
@@ -57,11 +58,12 @@ export async function POST(request: Request) {
let body: { email?: unknown } = {};
try {
body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
// no body — handled below as a missing email
// no body - handled below as a missing email
}
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email) {
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email || email.length > 254) {
return NextResponse.json({ error: "E-Mail-Adresse erforderlich." }, { status: 400 });
}
if (!checkRateLimit(`resend-verification:email:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
@@ -77,8 +79,8 @@ export async function POST(request: Request) {
}
if (customer && !customer.emailVerified) {
if (!checkRateLimit(`resend-verification:customer:${customer.id}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
if (!allowVerificationEmail(request, customer.email, customer.id)) {
return NextResponse.json(GENERIC_RESPONSE);
}
await issueAndSend(payload, customer);
}
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { combineDateAndTime } from "@/lib/meeting/status";
import { withBookingTransaction } from "@/lib/booking/transaction";
export const dynamic = "force-dynamic";
@@ -19,31 +21,40 @@ export async function POST(request: Request, { params }: Args) {
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
}
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) {
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
}
const alternative = booking.proposedAlternative;
if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
}
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req });
const booking = docs[0];
if (!booking || Number(booking.user) !== Number(user.id)) {
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
}
const alternative = booking.proposedAlternative;
if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
}
// The beforeChange hook on booking-requests re-checks for conflicts
// (with an advisory lock) before allowing this to become "confirmed" —
// the alternative slot may have been taken by someone else since it was
// proposed.
await payload.update({
collection: "booking-requests",
id,
data: {
status: "confirmed",
date: alternative.date,
startTime: alternative.startTime,
endTime: alternative.endTime,
},
const alternativeStart = combineDateAndTime(alternative.date, alternative.startTime);
if (!Number.isFinite(alternativeStart.getTime()) || alternativeStart <= new Date()) {
return NextResponse.json({ error: "Dieser Terminvorschlag liegt bereits in der Vergangenheit." }, { status: 409 });
}
// The beforeChange hook on booking-requests re-checks for conflicts
// (with an advisory lock) before allowing this to become "confirmed" -
// the alternative slot may have been taken by someone else since it was
// proposed.
await payload.update({
collection: "booking-requests",
id,
req,
data: {
status: "confirmed",
date: alternative.date,
startTime: alternative.startTime,
endTime: alternative.endTime,
},
});
return NextResponse.json({ ok: true });
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) {
return NextResponse.json({ error: error.message }, { status: 409 });
+14 -10
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { withBookingTransaction } from "@/lib/booking/transaction";
export const dynamic = "force-dynamic";
@@ -15,19 +16,22 @@ export async function POST(request: Request, { params }: Args) {
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
// Cancelling is deliberately allowed regardless of verification status —
// Cancelling is deliberately allowed regardless of verification status -
// only creating/confirming a booking is a gated "protected" action.
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) {
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
}
if (booking.status === "cancelled") {
return NextResponse.json({ ok: true });
}
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req });
const booking = docs[0];
if (!booking || Number(booking.user) !== Number(user.id)) {
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
}
if (booking.status === "cancelled") {
return NextResponse.json({ ok: true });
}
await payload.update({ collection: "booking-requests", id, data: { status: "cancelled" } });
return NextResponse.json({ ok: true });
await payload.update({ collection: "booking-requests", id, req, data: { status: "cancelled" } });
return NextResponse.json({ ok: true });
});
} catch (error) {
console.error("booking cancel failed", error);
return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 });
+52 -30
View File
@@ -2,6 +2,9 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots";
import { withBookingTransaction } from "@/lib/booking/transaction";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { berlinDayBounds } from "@/lib/calendar/timezone";
export const dynamic = "force-dynamic";
@@ -16,13 +19,15 @@ export async function POST(request: Request) {
let body: Body;
try {
body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : "";
const startISO = typeof body.start === "string" ? body.start : "";
const appointmentType = body.appointmentType === "online" ? "online" : "onsite";
const appointmentType = body.appointmentType;
if (appointmentType !== "online" && appointmentType !== "onsite") return NextResponse.json({ error: "Ungültige Terminart." }, { status: 400 });
const message = typeof body.message === "string" ? body.message.trim().slice(0, 2000) : "";
if (!offerSlug || !startISO) {
@@ -39,6 +44,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
}
if (!checkRateLimit(`booking:ip:${getClientIp(request)}`, { max: 30, windowMs: 3600000 }) ||
!checkRateLimit(`booking:customer:${user.id}`, { max: 10, windowMs: 3600000 })) {
return NextResponse.json({ error: "Zu viele Terminanfragen. Bitte versuche es später erneut." }, { status: 429 });
}
const { docs } = await payload.find({
collection: "offers",
where: { slug: { equals: offerSlug } },
@@ -57,38 +67,50 @@ export async function POST(request: Request) {
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
// Re-validate the slot is actually free right now (defense against a
// stale slot list) — the hard, race-safe check still runs again when
// stale slot list) - the hard, race-safe check still runs again when
// Anna confirms (see collections/BookingRequests.ts).
const dayStart = new Date(start);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(start);
dayEnd.setHours(23, 59, 59, 999);
const slotsByDay = await getAvailableSlots(payload, {
durationMinutes: offer.durationMinutes,
from: dayStart,
to: dayEnd,
});
const key = dayStart.toISOString().slice(0, 10);
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 });
}
const dayBounds = berlinDayBounds(start);
return await withBookingTransaction(payload, dayBounds.key, async (req) => {
const { docs: existing } = await payload.find({ collection: "booking-requests", req, depth: 0, limit: 1,
where: { and: [
{ user: { equals: user.id } }, { offer: { equals: offer.id } },
{ startTime: { equals: start.toISOString() } }, { appointmentType: { equals: appointmentType } },
{ status: { in: ["pending", "confirmed"] } },
] },
});
if (existing[0]) return NextResponse.json({ ok: true });
const slotsByDay = await getAvailableSlots(payload, {
durationMinutes: offer.durationMinutes!,
req,
from: dayBounds.start,
to: dayBounds.end,
});
const key = dayBounds.key;
const isStillFree = (slotsByDay.get(key) ?? []).some((s) => s.start.getTime() === start.getTime());
if (!isStillFree) {
return NextResponse.json({ error: "Dieser Termin ist leider nicht mehr verfügbar." }, { status: 409 });
}
await payload.create({
collection: "booking-requests",
data: {
user: user.id,
offer: offer.id,
date: start.toISOString(),
startTime: start.toISOString(),
endTime: end.toISOString(),
appointmentType,
status: "pending",
userMessage: message || undefined,
},
});
await payload.create({
collection: "booking-requests",
req,
data: {
user: user.id,
offer: offer.id,
date: start.toISOString(),
startTime: start.toISOString(),
endTime: end.toISOString(),
appointmentType,
// Calendar bookings use already published availability, so a slot
// selected here is booked immediately. The separate contact form
// remains the path for requests that need personal coordination.
status: "confirmed",
userMessage: message || undefined,
},
});
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: true });
});
} catch (error) {
console.error("booking request failed", error);
return NextResponse.json({ error: "Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
+14 -4
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, parseBerlinDate } from "@/lib/calendar/timezone";
export const dynamic = "force-dynamic";
@@ -29,10 +30,19 @@ 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();
from.setHours(0, 0, 0, 0);
const to = toParam ? new Date(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
to.setHours(23, 59, 59, 999);
const from = fromParam ? parseBerlinDate(fromParam) : berlinDayBounds(new Date()).start;
if (!from) {
return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 });
}
const toDate = toParam ? parseBerlinDate(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
if (!toDate) {
return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 });
}
const to = berlinDayBounds(toDate).end;
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;
@@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import { randomUUID } from "node:crypto";
import { getPayload } from "payload";
import config from "@payload-config";
import { getClientIp, checkRateLimit } from "@/lib/auth/rateLimit";
import { newConversationAdminEmail } from "@/lib/email/conversationTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
type Body = {
name?: unknown;
email?: unknown;
message?: unknown;
subject?: unknown;
type?: unknown;
token?: unknown;
};
function clean(value: unknown, max: number): string {
return typeof value === "string" ? value.trim().slice(0, max) : "";
}
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 name = clean(body.name, 160);
const email = clean(body.email, 254).toLowerCase();
const message = clean(body.message, 5000);
const subject = clean(body.subject, 180) || "Allgemeine Anfrage";
const type = body.type === "appointment" ? "appointment" : "contact";
if (!name || !email || !message || !/^\S+@\S+\.\S+$/.test(email)) {
return NextResponse.json({ error: "Bitte fülle alle Pflichtfelder korrekt aus." }, { status: 400 });
}
if (!(await verifyTurnstileToken(body.token, "contact", request))) {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
if (!checkRateLimit(`contact:ip:${getClientIp(request)}`, { max: 8, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
}
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
const customerId = user?.collection === "customers" ? user.id : undefined;
const conversation = await payload.create({
collection: "conversations",
overrideAccess: true,
data: {
name,
email,
subject,
type,
status: "open",
threadKey: randomUUID(),
...(customerId ? { customer: customerId } : {}),
lastMessageAt: new Date().toISOString(),
},
});
await payload.create({
collection: "conversation-messages",
overrideAccess: true,
data: {
conversation: conversation.id,
senderType: "visitor",
senderName: name,
senderEmail: email,
body: message,
},
});
const contact = await payload.findGlobal({ slug: "contact", overrideAccess: true });
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
const adminUrl = `${serverUrl}/admin/collections/conversations/${conversation.id}`;
if (contact.email) {
await sendEmail(contact.email, newConversationAdminEmail({ name, email, subject, body: message, adminUrl, threadKey: conversation.threadKey }), {
replyTo: process.env.INBOUND_REPLY_TO || "reply-to@anouma.org",
});
}
return NextResponse.json({ ok: true });
} catch (error) {
console.error("contact inquiry failed", error);
return NextResponse.json({ error: "Die Nachricht konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
}
}
@@ -0,0 +1,41 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { runAnalyticsCleanup } from "@/lib/analytics/cleanup";
export const dynamic = "force-dynamic";
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
/**
* Intended to be called by an external cron job once daily, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/analytics-cleanup
* (a second, separate cron entry from /api/cron/event-reminders - reuses
* the same CRON_SECRET, no new secret needed)
*/
export async function GET(request: Request) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 });
}
const authHeader = request.headers.get("authorization");
if (!authHeader || !safeEqual(authHeader, `Bearer ${secret}`)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const payload = await getPayload({ config });
const result = await runAnalyticsCleanup(payload);
return NextResponse.json({ ok: true, ...result });
} catch (error) {
console.error("analytics-cleanup cron failed", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
@@ -1,8 +1,16 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { runEventReminders } from "@/lib/meeting/reminders";
export const dynamic = "force-dynamic";
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
/**
* Intended to be called by an external cron job every few minutes, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
@@ -14,7 +22,7 @@ export async function GET(request: Request) {
}
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
if (!authHeader || !safeEqual(authHeader, `Bearer ${secret}`)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { REST_POST } from "@payloadcms/next/routes";
import config from "@payload-config";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
const payloadPost = REST_POST(config);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.clone().json();
} catch {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
const token = isRecord(body) ? body.token : undefined;
if (!(await verifyTurnstileToken(token, "login", request))) {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
const sanitizedBody = isRecord(body) ? { ...body } : body;
if (isRecord(sanitizedBody)) delete sanitizedBody.token;
const headers = new Headers(request.headers);
headers.delete("content-length");
headers.set("content-type", "application/json");
const downstreamRequest = new Request(request.url, {
method: request.method,
headers,
body: JSON.stringify(sanitizedBody),
});
return payloadPost(downstreamRequest, { params: Promise.resolve({ slug: ["customers", "login"] }) });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { REST_POST } from "@payloadcms/next/routes";
import config from "@payload-config";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
const payloadPost = REST_POST(config);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.clone().json();
} catch {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
const token = isRecord(body) ? body.token : undefined;
if (!(await verifyTurnstileToken(token, "registration", request))) {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
const sanitizedBody = isRecord(body) ? { ...body } : body;
if (isRecord(sanitizedBody)) delete sanitizedBody.token;
const headers = new Headers(request.headers);
headers.delete("content-length");
headers.set("content-type", "application/json");
const downstreamRequest = new Request(request.url, {
method: request.method,
headers,
body: JSON.stringify(sanitizedBody),
});
return payloadPost(downstreamRequest, { params: Promise.resolve({ slug: ["customers"] }) });
}
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type InboundBody = {
from?: unknown;
sender?: unknown;
email?: unknown;
name?: unknown;
subject?: unknown;
text?: unknown;
body?: unknown;
html?: unknown;
messageId?: unknown;
inReplyTo?: unknown;
};
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function extractEmail(value: unknown): string {
if (value && typeof value === "object" && "email" in value) return extractEmail((value as { email?: unknown }).email);
const raw = stringValue(value);
const match = raw.match(/<([^>]+)>/) || raw.match(/([\w.+-]+@[\w.-]+\.[A-Za-z]{2,})/);
return (match?.[1] || raw).trim().toLowerCase();
}
function stripHtml(value: string): string {
return value.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
function findThreadKey(subject: string): string | null {
return subject.match(/\[ANOUMA-([^\]]+)\]/i)?.[1] || null;
}
export async function POST(request: Request) {
const secret = process.env.INBOUND_EMAIL_SECRET;
const supplied = request.headers.get("x-inbound-secret") || request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
if (!secret || supplied !== secret) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
let body: InboundBody;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "JSON erwartet." }, { status: 400 });
}
const fromRaw = body.from ?? body.sender ?? body.email;
const email = extractEmail(fromRaw);
const fromName = fromRaw && typeof fromRaw === "object" && "name" in fromRaw ? stringValue((fromRaw as { name?: unknown }).name) : "";
const name = stringValue(body.name) || fromName || stringValue(fromRaw).replace(/<[^>]+>/, "").trim() || email;
const subject = stringValue(body.subject) || "Antwort auf deine Anfrage";
const messageId = stringValue(body.messageId);
const text = stringValue(body.text) || stringValue(body.body) || stripHtml(stringValue(body.html));
if (!email || !messageId || !text) return NextResponse.json({ error: "Absender, messageId und Inhalt sind erforderlich." }, { status: 400 });
try {
const payload = await getPayload({ config });
if (messageId) {
const existing = await payload.find({ collection: "conversation-messages", where: { externalMessageId: { equals: messageId } }, limit: 1, overrideAccess: true });
if (existing.docs[0]) return NextResponse.json({ ok: true, duplicate: true });
}
const threadKey = findThreadKey(subject);
let conversation = threadKey
? (await payload.find({ collection: "conversations", where: { threadKey: { equals: threadKey } }, limit: 1, overrideAccess: true })).docs[0]
: undefined;
if (!conversation) {
conversation = (await payload.find({ collection: "conversations", where: { email: { equals: email }, status: { not_equals: "closed" } }, sort: "-lastMessageAt", limit: 1, overrideAccess: true })).docs[0];
}
if (!conversation) return NextResponse.json({ error: "Keine passende Konversation gefunden." }, { status: 404 });
await payload.create({
collection: "conversation-messages",
overrideAccess: true,
data: {
conversation: conversation.id,
senderType: "visitor",
senderName: name,
senderEmail: email,
body: text.slice(0, 10000),
externalMessageId: messageId,
},
});
await payload.update({ collection: "conversations", id: conversation.id, overrideAccess: true, data: { status: "open", lastMessageAt: new Date().toISOString() } });
return NextResponse.json({ ok: true });
} catch (error) {
console.error("inbound email failed", error);
return NextResponse.json({ error: "E-Mail konnte nicht übernommen werden." }, { status: 500 });
}
}
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { groupRegistrationEmail } from "@/lib/email/conversationTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
const value = (v: unknown, max: number) => typeof v === "string" ? v.trim().slice(0, max) : "";
export async function POST(request: Request) {
if (!checkRateLimit(`group-registration:${getClientIp(request)}`, { max: 5, windowMs: 60 * 60 * 1000 })) return NextResponse.json({ error: "Zu viele Anmeldungen. Bitte versuche es später erneut." }, { status: 429 });
let body: Record<string, unknown>;
try { body = await request.json(); } catch { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); }
const offerSlug = value(body.offerSlug, 160);
const name = value(body.name, 160);
const email = value(body.email, 254).toLowerCase();
const phone = value(body.phone, 80);
const message = value(body.message, 3000);
if (!offerSlug || !name || !email || !/^\S+@\S+\.\S+$/.test(email)) return NextResponse.json({ error: "Bitte fülle Name und E-Mail korrekt aus." }, { status: 400 });
if (!(await verifyTurnstileToken(body.token, "group-registration", request))) return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
try {
const payload = await getPayload({ config });
const offerResult = await payload.find({ collection: "offers", where: { slug: { equals: offerSlug } }, limit: 1, overrideAccess: false });
const offer = offerResult.docs[0];
if (!offer || offer.bookingMode !== "group") return NextResponse.json({ error: "Dieses Gruppenangebot ist nicht verfügbar." }, { status: 404 });
if (offer.registrationDeadline && new Date(offer.registrationDeadline) < new Date()) return NextResponse.json({ error: "Die Anmeldefrist ist bereits abgelaufen." }, { status: 409 });
if (offer.groupCapacity) {
const count = await payload.count({ collection: "group-registrations", where: { and: [{ offer: { equals: offer.id } }, { status: { in: ["pending", "accepted"] } }] }, overrideAccess: true });
if (count.totalDocs >= offer.groupCapacity && offer.groupRegistrationMode === "capacity") return NextResponse.json({ error: "Für dieses Angebot sind aktuell keine Plätze mehr frei." }, { status: 409 });
}
const registration = await payload.create({ collection: "group-registrations", overrideAccess: true, data: { offer: offer.id, name, email, phone, message, status: "pending" } });
const contact = await payload.findGlobal({ slug: "contact", overrideAccess: true });
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
await Promise.all([
contact.email ? sendEmail(contact.email, groupRegistrationEmail({ name, offerTitle: offer.title, status: "admin", adminUrl: `${serverUrl}/admin/collections/group-registrations/${registration.id}` }), { replyTo: email }) : Promise.resolve(),
sendEmail(email, groupRegistrationEmail({ name, offerTitle: offer.title, status: "received" })),
]);
return NextResponse.json({ ok: true });
} catch (error) {
console.error("group registration failed", error);
return NextResponse.json({ error: "Die Anmeldung konnte nicht gesendet werden." }, { status: 500 });
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
/**
* Used by the Docker healthcheck (see docker-compose.yml) and by
* setup.sh/deploy.sh to confirm the app can actually reach Postgres before
* being considered "up" — not just that the Node process is listening.
* being considered "up" - not just that the Node process is listening.
*/
export async function GET() {
try {
@@ -0,0 +1,54 @@
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";
import { authorizeMeeting } from "@/lib/meeting/access";
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 {
let expiresAt = meetingToken.exp;
const isAdminTestRoom = meetingToken.role === "host" && meetingToken.testRoom === true && slug.startsWith("test-");
if (!isAdminTestRoom) {
const payload = await getPayload({ config });
const { docs } = await payload.find({ collection: "events", where: { slug: { equals: slug } }, limit: 1 });
if (!docs[0]?.isOnline) return NextResponse.json({ error: "Dieser Online-Termin wurde nicht gefunden." }, { status: 404 });
const access = await authorizeMeeting(payload, docs[0], meetingToken.role);
if (!access || access.accessVersion !== meetingToken.accessVersion) {
return NextResponse.json({ error: "Dieser Meeting-Zugang ist nicht mehr gültig. Bitte tritt erneut bei." }, { status: 403 });
}
expiresAt = Math.min(expiresAt, access.expiresAt);
}
const participantToken = await createLiveKitToken({
eventSlug: slug,
participantId: meetingToken.participantId,
name: meetingToken.name,
role: meetingToken.role,
clientId: meetingToken.clientId,
accessVersion: meetingToken.accessVersion,
expiresAt,
});
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 });
}
}
+44 -26
View File
@@ -3,8 +3,11 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status";
import { getIceServers } from "@/lib/meeting/iceServers";
import { authorizeMeeting } from "@/lib/meeting/access";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
@@ -19,19 +22,30 @@ type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
if (!checkRateLimit(`meeting-join:${getClientIp(request)}`, { max: 30, windowMs: 60000 })) {
return NextResponse.json({ error: "Zu viele Beitrittsversuche." }, { status: 429 });
}
let body: { name?: unknown; password?: unknown };
let body: { name?: unknown; password?: unknown; clientId?: unknown; token?: unknown };
try {
body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const password = typeof body.password === "string" ? body.password.trim() : "";
if (!name) {
const clientId = typeof body.clientId === "string" ? body.clientId.trim() : undefined;
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 });
}
if (!(await verifyTurnstileToken(body.token, "meeting-join", request))) {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
try {
const payload = await getPayload({ config });
@@ -47,9 +61,9 @@ 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
// 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) {
@@ -60,31 +74,34 @@ export async function POST(request: Request, { params }: Args) {
}
}
const settings = await payload.findGlobal({ slug: "meeting-settings" });
const start = combineDateAndTime(event.date, event.startTime);
const end = event.endTime
? combineDateAndTime(event.date, event.endTime)
: new Date(start.getTime() + 60 * 60_000);
const status = getMeetingStatus({
start,
end,
joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
closeAfterMinutes: settings.meetingCloseAfterMinutes,
});
if (!canJoinMeeting(status)) {
const message =
status === "scheduled"
? "Dieser Termin ist noch nicht offen. Bitte versuche es näher am Beginn erneut."
: "Dieser Termin ist bereits beendet.";
return NextResponse.json({ error: message }, { status: 403 });
const access = await authorizeMeeting(payload, event, isHost ? "host" : "participant");
if (!access) {
return NextResponse.json({ error: "Dieser Termin ist nicht offen oder wurde storniert." }, { status: 403 });
}
const { token, participantId } = createMeetingToken({
eventSlug: slug,
name,
role: isHost ? "host" : "participant",
accessVersion: access.accessVersion,
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
// Only meaningful for participants - a host authenticates via a real
// session already, so there's no need to anchor a kick to it (and
// hosts can't be kicked in the first place.
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,
accessVersion: access.accessVersion,
expiresAt: access.expiresAt,
participantId,
name,
role: isHost ? "host" : "participant",
clientId: isHost ? undefined : clientId,
});
return NextResponse.json({
@@ -92,7 +109,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,51 @@
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();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} 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, host.accessVersion);
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,26 +1,57 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
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;
let body: { name?: unknown; email?: unknown };
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; token?: unknown };
try {
body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
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 });
}
if (!(await verifyTurnstileToken(body.token, "meeting-registration", request))) {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
try {
const payload = await getPayload({ config });
@@ -31,41 +62,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 });
+592 -3
View File
@@ -1,3 +1,592 @@
/* Optional custom overrides for the Payload admin UI. Left empty intentionally —
the admin panel uses Payload's own default styling, only the public site
uses the ANOUMA design system (see app/(frontend)/globals.css). */
@font-face {
font-family: "Anouma Admin";
src: url("/fonts/baar-antropos.ttf") format("truetype");
font-display: swap;
font-style: normal;
font-weight: 100 900;
}
:root {
--anouma-ink: #342c31;
--anouma-plum: #66505f;
--anouma-mauve: #8b616d;
--anouma-rose: #d19ca1;
--anouma-cream: #f7f2ed;
--anouma-paper: #fffdfb;
--anouma-line: #e4d9d2;
--font-body: "Anouma Admin", Inter, ui-sans-serif, system-ui, sans-serif;
--font-mono: "SFMono-Regular", Consolas, monospace;
}
/* Keep Payload's own color tokens aligned with the light admin surface. The
previous theme could leave the content area pale while retaining white text
from dark mode, which made headings and descriptions effectively invisible. */
html,
html[data-theme="light"],
html[data-theme="dark"] {
--theme-bg: var(--anouma-cream);
--theme-input-bg: var(--anouma-paper);
--theme-text: var(--anouma-ink);
--theme-elevation-0: var(--anouma-paper);
--theme-elevation-50: #fbf7f4;
--theme-elevation-100: #f1e9e4;
--theme-elevation-150: var(--anouma-line);
--theme-elevation-200: #d7c9c1;
--theme-elevation-300: #b9aaa8;
--theme-elevation-400: #8b7b81;
--theme-elevation-500: #776870;
--theme-elevation-600: #66505f;
--theme-elevation-700: #51404b;
--theme-elevation-800: var(--anouma-ink);
--theme-elevation-900: #211a1e;
--theme-elevation-950: #171216;
--theme-elevation-1000: #100c0f;
--font-body: "Anouma Admin", Inter, ui-sans-serif, system-ui, sans-serif;
--font-mono: "SFMono-Regular", Consolas, monospace;
background: var(--anouma-cream);
color: var(--anouma-ink);
color-scheme: light;
}
html,
body {
background: var(--anouma-cream);
color: var(--anouma-ink);
font-family: var(--font-body);
}
body,
button,
input,
textarea,
select {
font-family: var(--font-body);
}
body,
#app,
.template-default,
.template-default__wrap,
.collection-list,
.collection-list__wrap,
.collection-edit,
.dashboard,
.dashboard__wrap {
color: var(--anouma-ink);
}
/* The left navigation is intentionally dark and quiet. Content stays light. */
.nav {
background: var(--anouma-ink);
border-right: 1px solid #4b3f46;
color: #f8f2ed;
}
.nav__header {
border-bottom: 1px solid #4b3f46;
padding: 24px 20px 20px;
}
.nav__header a,
.nav__header button {
color: #fffaf6;
}
.nav__links {
padding: 18px 12px;
}
.nav-group {
margin: 0 0 18px;
}
.nav-group__label,
.nav-group__toggle {
color: #bdaeb4;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.nav-group__label {
padding: 0 10px 8px;
}
.nav__link {
border-radius: 4px;
color: #f8f2ed;
font-size: 14px;
margin: 2px 0;
min-height: 38px;
padding: 9px 10px;
}
.nav__link:hover,
.nav__link:focus-visible,
.nav__link.active {
background: #4c3d47;
color: #fff;
}
.nav__link.active {
box-shadow: inset 3px 0 0 var(--anouma-rose);
}
.nav__controls {
border-top: 1px solid #4b3f46;
padding: 14px 12px;
}
.app-header,
.step-nav {
background: var(--anouma-paper);
border-bottom: 1px solid var(--anouma-line);
color: var(--anouma-plum);
}
.app-header a,
.app-header button,
.step-nav a,
.step-nav span,
.step-nav label {
color: var(--anouma-plum);
}
.app-header svg,
.step-nav svg {
color: var(--anouma-plum);
fill: currentColor;
}
.template-default__wrap,
.dashboard__wrap,
.collection-list__wrap,
.edit-view__wrap {
background: var(--anouma-cream);
}
.template-default__wrap {
min-height: 100vh;
}
.dashboard__header,
.collection-list__header,
.edit-view__header {
border-bottom: 1px solid var(--anouma-line);
margin-bottom: 26px;
padding-bottom: 18px;
}
.dashboard__header h1,
.collection-list__header h1,
.edit-view__header h1,
.list-header__title,
.list-header__title h1,
.document-fields__label {
color: var(--anouma-ink);
font-size: clamp(26px, 3vw, 38px);
font-weight: 600;
letter-spacing: -0.025em;
}
.list-header,
.list-header__content,
.collection-list__sub-header,
.list-controls,
.no-results,
.no-results h3,
.no-results p,
.collection-list__table-wrap,
.document-fields,
.document-fields__main,
.field-type,
.field-type__wrap,
.label-wrapper,
.field-label,
.field-label label,
.field-description,
.description {
color: var(--anouma-ink);
}
.list-header__title,
.list-header__title a,
.list-header__title button,
.list-header__actions,
.list-controls button,
.list-controls a {
color: var(--anouma-ink);
}
.list-header__title-actions .btn--style-secondary,
.list-header__actions .btn--style-secondary,
.list-controls .btn--style-subtle {
--color: var(--anouma-ink);
--hover-color: var(--anouma-ink);
--bg-color: var(--anouma-paper);
--hover-bg: #f1e9e4;
--btn-border: 1px solid var(--anouma-line);
--hover-btn-border: 1px solid var(--anouma-mauve);
}
.card,
.dashboard__card,
.table-wrap,
.collection-list__table-wrap,
.field-type,
.document-fields__main {
border-color: var(--anouma-line);
}
.card,
.dashboard__card,
.table-wrap {
border-radius: 6px;
box-shadow: 0 8px 24px rgb(52 44 49 / 4%);
}
.btn {
border-radius: 4px;
font-weight: 600;
}
.btn--style-primary {
--color: #fff;
--bg-color: var(--anouma-mauve);
--hover-bg: var(--anouma-plum);
}
input,
textarea,
select,
.rs__control {
background: var(--anouma-paper) !important;
border-color: #d7c9c1;
border-radius: 4px;
color: var(--anouma-ink) !important;
}
input:focus,
textarea:focus,
select:focus,
.rs__control--is-focused {
border-color: var(--anouma-mauve);
box-shadow: 0 0 0 2px rgb(139 97 109 / 12%);
}
.field-label,
.field-label label {
color: var(--anouma-plum);
font-weight: 650;
}
.description,
.field-description {
color: #7b6b72;
}
.upload,
.file-field,
.upload-field-card,
.upload__dropzoneContent,
.dropzone,
.bulk-upload--add-files,
.bulk-upload--file-manager {
color: var(--anouma-ink);
}
.upload,
.file-field,
.bulk-upload--add-files,
.bulk-upload--file-manager,
.file-field__filename,
.file-field__remote-file,
.upload-field-card {
background: var(--anouma-paper);
border-color: var(--anouma-line);
}
.upload__dragAndDropText,
.upload__orText,
.bulk-upload--add-files__dragAndDropText,
.file-field__filename,
.file-field__remote-file,
.upload-field-card__filename,
.upload-field-card__meta {
color: var(--anouma-plum);
}
.upload a,
.upload button,
.file-field button,
.bulk-upload--add-files button,
.bulk-upload--file-manager button {
color: var(--anouma-ink);
}
.table thead th,
.table th {
background: #f1e9e4;
color: var(--anouma-plum);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.table tbody tr:hover {
background: #fbf7f4;
}
.collections {
color: var(--anouma-ink);
}
.collections__group {
margin-bottom: 28px;
}
.collections__label {
color: var(--anouma-ink) !important;
font-size: 20px;
font-weight: 650;
letter-spacing: -0.015em;
margin-bottom: 12px;
}
.collections__card-list {
gap: 10px;
}
.collections .card {
background: var(--anouma-paper) !important;
border: 1px solid var(--anouma-line);
border-radius: 5px;
box-shadow: 0 8px 22px rgb(52 44 49 / 5%);
color: var(--anouma-ink) !important;
min-height: 64px;
}
.collections .card:hover {
border-color: var(--anouma-mauve);
}
.collections .card__title {
color: var(--anouma-ink) !important;
font-weight: 600;
}
.collections .card__actions .btn {
color: var(--anouma-mauve);
}
textarea::placeholder,
input::placeholder {
color: #8b7b81;
opacity: 1;
}
.anouma-admin-back {
align-items: center;
border: 1px solid var(--anouma-line);
border-radius: 4px;
color: var(--anouma-plum);
display: inline-flex;
font-size: 13px;
font-weight: 650;
padding: 8px 12px;
text-decoration: none;
}
.anouma-admin-back:hover,
.anouma-admin-back:focus-visible {
background: var(--anouma-paper);
border-color: var(--anouma-mauve);
color: var(--anouma-ink);
}
.anouma-admin-welcome {
background: var(--anouma-paper);
border: 1px solid var(--anouma-line);
border-left: 4px solid var(--anouma-mauve);
border-radius: 6px;
box-shadow: 0 12px 32px rgb(52 44 49 / 5%);
margin: 0 0 28px;
padding: 28px;
}
.anouma-admin-welcome__intro {
max-width: 680px;
}
.anouma-admin-eyebrow {
color: var(--anouma-mauve);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
margin: 0 0 8px;
text-transform: uppercase;
}
.anouma-admin-welcome h1 {
color: var(--anouma-ink);
font-size: clamp(25px, 3vw, 36px);
font-weight: 600;
letter-spacing: -0.03em;
margin: 0;
}
.anouma-admin-welcome__intro > p:last-child {
color: #776870;
font-size: 15px;
line-height: 1.6;
margin: 10px 0 0;
}
.anouma-admin-quicklinks {
display: grid;
gap: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 26px;
}
.anouma-admin-quicklink {
background: #faf5f1;
border: 1px solid var(--anouma-line);
border-radius: 4px;
color: var(--anouma-ink);
display: flex;
flex-direction: column;
min-height: 154px;
padding: 16px;
text-decoration: none;
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
}
.anouma-admin-quicklink:hover,
.anouma-admin-quicklink:focus-visible {
background: #fffaf7;
border-color: var(--anouma-mauve);
color: var(--anouma-ink);
transform: translateY(-2px);
}
.anouma-admin-quicklink__number {
color: var(--anouma-rose);
font-family: var(--font-mono);
font-size: 12px;
margin-bottom: 24px;
}
.anouma-admin-quicklink__kicker {
color: var(--anouma-mauve);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.anouma-admin-quicklink strong {
font-size: 17px;
font-weight: 650;
margin-top: 5px;
}
.anouma-admin-quicklink > span:last-child {
color: #7b6b72;
font-size: 12px;
line-height: 1.45;
margin-top: 6px;
}
.anouma-custom-nav-item,
.anouma-custom-nav-action {
margin: 0 0 6px;
padding: 0;
}
.anouma-custom-nav-link,
.anouma-custom-nav-button {
align-items: center;
border: 1px solid #5a4a55;
border-radius: 4px;
display: flex;
font-size: 13px;
font-weight: 650;
gap: 10px;
min-height: 38px;
padding: 8px 10px;
text-decoration: none;
width: 100%;
}
.anouma-custom-nav-link {
color: #fffaf6;
}
.anouma-custom-nav-link:hover,
.anouma-custom-nav-link:focus-visible {
background: #4c3d47;
border-color: #7d6371;
color: #fff;
}
.anouma-custom-nav-link__icon {
color: var(--anouma-rose);
font-family: var(--font-mono);
font-size: 10px;
}
.anouma-custom-nav-button {
background: var(--anouma-mauve);
border-color: var(--anouma-mauve);
color: #fff;
cursor: pointer;
justify-content: flex-start;
}
.anouma-custom-nav-button:hover,
.anouma-custom-nav-button:focus-visible {
background: var(--anouma-plum);
border-color: var(--anouma-plum);
}
.anouma-custom-nav-button:disabled {
cursor: wait;
opacity: 0.7;
}
.anouma-custom-nav-input {
background: #fffaf6;
border: 1px solid #d7c9c1;
border-radius: 3px;
color: var(--anouma-ink);
font-family: var(--font-mono);
font-size: 10px;
margin-top: 6px;
padding: 6px 7px;
width: 100%;
}
.anouma-custom-nav-error {
color: #f1b5b5;
font-size: 11px;
line-height: 1.4;
margin: 6px 0 0;
}
@media (max-width: 1100px) {
.anouma-admin-quicklinks {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.anouma-admin-welcome {
padding: 20px;
}
.anouma-admin-quicklinks {
grid-template-columns: 1fr;
}
}
+3 -3
View File
@@ -7,7 +7,7 @@ import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { siteConfig } from "@/lib/site";
import "./(frontend)/globals.css";
// Required because the app has two root layouts — (frontend) and (payload) —
// Required because the app has two root layouts - (frontend) and (payload) -
// so there is no single layout Next.js could compose a 404 page from.
// See node_modules/next/dist/docs/.../not-found.md ("global-not-found.js").
@@ -36,7 +36,7 @@ export default function GlobalNotFound() {
>
<body className="flex min-h-full flex-col bg-background text-foreground">
{/* This page is always statically prerendered, so it can't read live
CMS data — the mega menu is intentionally omitted here. */}
CMS data - the mega menu is intentionally omitted here. */}
<Navbar offers={[]} />
<main className="flex-1">
<section className="relative overflow-hidden py-24 sm:py-32">
@@ -67,7 +67,7 @@ export default function GlobalNotFound() {
</div>
</section>
</main>
{/* Minimal static footer — the full Footer reads live CMS data,
{/* Minimal static footer - the full Footer reads live CMS data,
which isn't available on this always-static 404 page. */}
<footer className="bg-anouma-plum py-8 text-center text-sm text-anouma-cream-light">
&copy; {new Date().getFullYear()} {siteConfig.name}
+4 -4
View File
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
// Reads the CMS's SEO settings (site URL override, robots kill switch) on
// every request — must not be statically prerendered at build time.
// every request - must not be statically prerendered at build time.
export const dynamic = "force-dynamic";
export default async function robots(): Promise<MetadataRoute.Robots> {
@@ -10,7 +10,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
if (!indexable) {
// Global kill switch (SEO-Einstellungen → "Website für Suchmaschinen
// sichtbar") — block everything rather than list exceptions.
// sichtbar") - block everything rather than list exceptions.
return { rules: [{ userAgent: "*", disallow: "/" }] };
}
@@ -24,7 +24,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
"/admin",
"/admin/*",
"/api/*",
// Customer accounts — never public.
// Customer accounts - never public.
"/konto",
"/konto/*",
"/login",
@@ -32,7 +32,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
"/auth/*",
// Private calendar-subscription feed (secret-token URLs).
"/calendar/*",
// Meeting join/call flow — session-gated, never a page worth indexing.
// Meeting join/call flow - session-gated, never a page worth indexing.
"/termine/*/beitreten",
"/termine/*/call",
],
+3 -3
View File
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
import { getAllEvents, getOffers, getPosts } from "@/lib/payload/content";
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
// Reads live CMS content on every request — must not be statically
// Reads live CMS content on every request - must not be statically
// prerendered at build time (no DB is available during `next build`; see
// AGENTS.md / the same convention used by every other CMS-backed route).
export const dynamic = "force-dynamic";
@@ -10,7 +10,7 @@ export const dynamic = "force-dynamic";
// Static, always-public routes that aren't backed by a dynamic [slug]
// collection. Deliberately excludes: /login, /registrieren, /konto*,
// /admin*, /calendar/*, /auth/*, /termine/*/beitreten, /termine/*/call,
// /api/* — none of those are public content (see app/robots.ts, which
// /api/* - none of those are public content (see app/robots.ts, which
// mirrors this same exclusion list).
const staticRoutes: { path: string; changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; priority: number }[] = [
{ path: "", changeFrequency: "weekly", priority: 1 },
@@ -40,7 +40,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
}));
// getOffers/getAllEvents/getPosts already only return published,
// publicly-readable documents (overrideAccess: false — see
// publicly-readable documents (overrideAccess: false - see
// lib/payload/content.ts's header comment).
const [offers, events, posts] = await Promise.all([getOffers(), getAllEvents(), getPosts()]);
+53
View File
@@ -0,0 +1,53 @@
import type { CollectionConfig } from "payload";
import { canViewAnalytics } from "@/access";
/**
* One row per calendar day, upserted by the cleanup cron
* (lib/analytics/cleanup.ts) from raw analytics-events BEFORE those raw rows
* age out. This is what lets aggregated history survive well beyond the raw
* retention window (default 90 days raw vs. 12 months aggregated - see
* globals/AnalyticsSettings.ts) without keeping individual events around.
*
* Not currently read by the main Analytics dashboard filters (those all
* stay within raw retention by design - see the implementation plan); this
* exists so long-term aggregated history isn't silently lost once raw
* events are purged, and is available for a future longer-range view.
*/
export const AnalyticsDailyStats: CollectionConfig = {
slug: "analytics-daily-stats",
labels: {
singular: "Tagesstatistik",
plural: "Tagesstatistiken",
},
admin: {
group: "Auswertung",
hidden: true,
},
access: {
create: () => false,
read: canViewAnalytics,
update: () => false,
delete: () => false,
},
timestamps: true,
fields: [
{
name: "date",
type: "date",
required: true,
unique: true,
index: true,
admin: { date: { pickerAppearance: "dayOnly" } },
},
{ name: "sessions", type: "number", required: true, defaultValue: 0 },
{ name: "pageViews", type: "number", required: true, defaultValue: 0 },
{ name: "uniqueVisitors", type: "number", required: true, defaultValue: 0 },
// Each a { [key: string]: number } map, e.g. eventCounts.booking_started.
{ name: "eventCounts", type: "json" },
{ name: "deviceCounts", type: "json" },
{ name: "browserCounts", type: "json" },
{ name: "topPaths", type: "json" },
{ name: "entryPaths", type: "json" },
{ name: "exitPaths", type: "json" },
],
};
+93
View File
@@ -0,0 +1,93 @@
import type { CollectionConfig } from "payload";
import { canViewAnalytics } from "@/access";
/**
* One row per tracked event (page_view, booking_started, ...). Deliberately
* append-only and never writable via the public/admin API - the only writer
* is app/(payload)/api/analytics/track/route.ts, which uses the trusted
* Local API (overrideAccess: true by default) to insert rows despite
* `create` being locked to `() => false` here. The only deleter is the
* cleanup cron (lib/analytics/cleanup.ts), same mechanism.
*
* No name/email/IP/UA is ever stored - see lib/analytics/salt.ts for how
* `visitorHash` is derived without persisting anything identifying.
*/
export const AnalyticsEvents: CollectionConfig = {
slug: "analytics-events",
labels: {
singular: "Messereignis",
plural: "Messereignisse",
},
admin: {
group: "Auswertung",
hidden: true,
// High-volume, low-signal raw data - not meant for manual browsing.
// The Analytics dashboard (a custom admin view) reads it programmatically.
},
access: {
create: () => false,
read: canViewAnalytics,
update: () => false,
delete: () => false,
},
timestamps: true,
indexes: [{ fields: ["visitorHash", "createdAt"] }, { fields: ["createdAt"] }],
fields: [
{
name: "sessionId",
type: "text",
required: true,
index: true,
},
{
name: "visitorHash",
type: "text",
required: true,
// Daily-rotating pseudonymous hash (see lib/analytics/salt.ts) - never
// a raw IP/UA, never stable across days, never exposed to the client.
},
{
name: "path",
type: "text",
required: true,
index: true,
},
{
name: "eventName",
type: "text",
required: true,
index: true,
// Deliberately free-text, not a select/enum: new event names (see
// lib/analytics/events.ts) never require a schema migration.
defaultValue: "page_view",
},
{
name: "referrerHost",
type: "text",
// Hostname only (never a full URL/query string). document.referrer is
// fixed for a tab's whole lifetime (it doesn't change on client-side
// route transitions), so this naturally reflects how the visitor
// originally arrived, sent unchanged with every event in the session.
},
{
name: "device",
type: "select",
options: [
{ label: "Mobile", value: "mobile" },
{ label: "Desktop", value: "desktop" },
{ label: "Tablet", value: "tablet" },
],
},
{
name: "browser",
type: "select",
options: [
{ label: "Chrome", value: "chrome" },
{ label: "Firefox", value: "firefox" },
{ label: "Safari", value: "safari" },
{ label: "Edge", value: "edge" },
{ label: "Sonstige", value: "other" },
],
},
],
};
+2 -2
View File
@@ -11,10 +11,10 @@ export const AvailabilityOverrides: CollectionConfig = {
useAsTitle: "date",
defaultColumns: ["date", "type", "reason"],
group: "Buchungen",
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten - Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
},
access: {
read: () => true,
read: isAdmin,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
+84 -83
View File
@@ -1,6 +1,8 @@
import { randomUUID } from "node:crypto";
import { APIError } from "payload";
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload";
import { isAdmin, isAdminFieldLevel } from "@/access";
import { combineDateAndTime } from "@/lib/meeting/status";
import { acquireBookingDayLock } from "@/lib/booking/lock";
import {
alternativeProposedEmail,
@@ -12,6 +14,7 @@ import {
} from "@/lib/email/bookingTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import type { Customer, Event, Offer } from "@/payload-types";
import { berlinDateKey, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
export const APPOINTMENT_TYPES = [
{ label: "Vor Ort", value: "onsite" },
@@ -26,15 +29,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) =>
@@ -48,87 +51,73 @@ async function notifyAdmins(
await Promise.all(admins.filter((admin) => admin.email).map((admin) => sendEmail(admin.email, template)));
}
// Runs whenever a booking is about to become "confirmed": locks the day
// (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings
// inside that same lock/transaction, and — for online appointments — creates
// the linked video-call Event (reusing the existing meeting/password system)
// before the booking itself is written.
const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
// Covers both paths: an existing request being confirmed, AND Anna
// manually creating a booking that's already "confirmed" from the start
// (e.g. a phone booking) — both must go through the same conflict check.
const wasAlreadyConfirmed = originalDoc?.status === "confirmed";
if (data.status !== "confirmed" || wasAlreadyConfirmed) {
return data;
}
// Normalize Payload time-only inputs before conflict checks and synchronize
// the private meeting whenever a booking is moved, cancelled or reactivated.
export const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
const status = data.status ?? originalDoc?.status ?? "pending";
const date = data.date ?? originalDoc?.date;
const startTime = data.startTime ?? originalDoc?.startTime;
const endTime = data.endTime ?? originalDoc?.endTime;
const appointmentType = data.appointmentType ?? originalDoc?.appointmentType;
if (!date || !startTime || !endTime) return data; // Field-level validation will reject this anyway.
if (!date || !startTime || !endTime) return data;
const start = combineDateAndTime(date, startTime);
const end = combineDateAndTime(date, endTime);
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start) {
throw new APIError("Das Ende muss nach dem Beginn am selben Tag liegen.", 400, undefined, true);
}
data.date = start.toISOString();
data.startTime = start.toISOString();
data.endTime = end.toISOString();
await acquireBookingDayLock(req, dateKey(date));
const 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 conflictClauses: Where[] = [
{ status: { equals: "confirmed" } },
{ date: { greater_than_equal: dayStart } },
{ date: { less_than_equal: dayEnd } },
];
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
const { docs: sameDayConfirmed } = await req.payload.find({
collection: "booking-requests",
where: { and: conflictClauses },
req,
limit: 200,
});
const newStart = new Date(startTime).getTime();
const newEnd = new Date(endTime).getTime();
const conflict = sameDayConfirmed.some((doc) => {
const s = new Date(doc.startTime).getTime();
const e = new Date(doc.endTime).getTime();
return newStart < e && s < newEnd;
});
if (conflict) {
throw new APIError(
"Dieser Zeitraum ist bereits mit einem anderen bestätigten Termin belegt. Bitte wähle eine andere Zeit oder lehne den überschneidenden Termin zuerst ab.",
409,
undefined,
true,
);
const alternative = data.proposedAlternative;
if (alternative && (alternative.date || alternative.startTime || alternative.endTime)) {
if (!alternative.date || !alternative.startTime || !alternative.endTime) {
throw new APIError("Bitte den alternativen Termin vollständig angeben.", 400, undefined, true);
}
const altStart = combineDateAndTime(alternative.date, alternative.startTime);
const altEnd = combineDateAndTime(alternative.date, alternative.endTime);
if (!Number.isFinite(altStart.getTime()) || !Number.isFinite(altEnd.getTime()) || altEnd <= altStart) {
throw new APIError("Der alternative Zeitraum ist ungültig.", 400, undefined, true);
}
data.proposedAlternative = { date: altStart.toISOString(), startTime: altStart.toISOString(), endTime: altEnd.toISOString() };
}
if (appointmentType === "online" && !data.linkedEvent && !originalDoc?.linkedEvent) {
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc?.offer, req })) as Offer;
const event = (await req.payload.create({
collection: "events",
req,
data: {
title: offer.title,
date,
startTime,
endTime,
category: "sonstiges",
isOnline: true, // Events' own beforeChange hook auto-generates the password.
isPrivateBooking: true,
// Only set on the update-to-confirmed path — on create-as-confirmed
// this booking doesn't have an id yet (assigned after insert), so
// the back-reference is simply left blank in that one case.
...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
},
// Created as a draft so it never appears in public /termine listings
// (see lib/payload/content.ts) — it's still reachable via its direct
// join link, which is exactly what a private 1:1 booking needs.
draft: true,
})) as Event;
if (status === "confirmed") {
await acquireBookingDayLock(req, dateKey(data.date));
const conflictClauses: Where[] = [
{ status: { equals: "confirmed" } },
{ startTime: { less_than: data.endTime } },
{ endTime: { greater_than: data.startTime } },
];
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
const { totalDocs } = await req.payload.count({ collection: "booking-requests", where: { and: conflictClauses }, req });
if (totalDocs) throw new APIError("Dieser Zeitraum ist bereits mit einem bestätigten Termin belegt.", 409, undefined, true);
}
const linked = data.linkedEvent ?? originalDoc?.linkedEvent;
const linkedId = typeof linked === "object" ? linked?.id : linked;
if (linkedId) {
const existing = await req.payload.findByID({ collection: "events", id: linkedId, depth: 0, req });
if (existing.isPrivateBooking) {
const isOnline = status === "confirmed" && appointmentType === "online";
if (existing.date !== data.date || existing.startTime !== data.startTime || existing.endTime !== data.endTime || existing.isOnline !== isOnline) {
await req.payload.update({ collection: "events", id: existing.id, req, data: {
date: data.date, startTime: data.startTime, endTime: data.endTime, isOnline,
} });
}
}
} else if (status === "confirmed" && appointmentType === "online") {
const relatedOffer = data.offer ?? originalDoc?.offer;
const offer = await req.payload.findByID({ collection: "offers", id: typeof relatedOffer === "object" ? relatedOffer.id : relatedOffer, req });
const event = await req.payload.create({ collection: "events", req, draft: true, data: {
title: offer.title,
slug: `buchung-${randomUUID()}`,
date: data.date, startTime: data.startTime, endTime: data.endTime,
category: "sonstiges", isOnline: true, isPrivateBooking: true,
...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
} });
data.linkedEvent = event.id;
}
return data;
};
@@ -181,7 +170,8 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
const statusChanged = operation === "create" || previousDoc?.status !== doc.status;
if (statusChanged && doc.status === "confirmed") {
const scheduleChanged = previousDoc && ["date", "startTime", "endTime", "appointmentType"].some((field) => doc[field] !== previousDoc[field]);
if ((statusChanged || scheduleChanged) && doc.status === "confirmed") {
let online: { joinUrl: string; password: string } | undefined;
let onsite: { address: string; mapsUrl: string } | undefined;
@@ -193,7 +183,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
})) as Event;
online = {
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
password: event.meetingPassword || "—",
password: event.meetingPassword || "-",
};
} else if (doc.appointmentType === "onsite") {
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
@@ -208,7 +198,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
name: user.name,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: `${fmtTime(doc.startTime)} – ${fmtTime(doc.endTime)}`,
timeLabel: `${fmtTime(doc.startTime)} - ${fmtTime(doc.endTime)}`,
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
online,
onsite,
@@ -237,7 +227,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
);
}
const proposedNew = doc.proposedAlternative?.date && doc.proposedAlternative.date !== previousDoc?.proposedAlternative?.date;
const proposedNew = doc.proposedAlternative?.date && JSON.stringify(doc.proposedAlternative) !== JSON.stringify(previousDoc?.proposedAlternative);
if (proposedNew && doc.status === "pending") {
await sendEmail(
user.email,
@@ -270,17 +260,17 @@ export const BookingRequests: CollectionConfig = {
useAsTitle: "id",
defaultColumns: ["offer", "user", "date", "startTime", "status"],
group: "Buchungen",
description: "Terminanfragen von Nutzer:innen — bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
description: "Terminanfragen von Nutzern - bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
},
access: {
// Customer-initiated writes always go through the vetted /api/booking/*
// routes (Local API, elevated access, after the route verifies
// ownership) — never directly through REST/GraphQL. This keeps a
// ownership) - never directly through REST/GraphQL. This keeps a
// customer from ever reading/writing another customer's booking, or
// setting their own status to "confirmed" directly.
create: isAdmin,
read: ({ req }) => {
if (req.user?.collection === "users") return true;
if (req.user?.collection === "users") return req.user.role === "admin";
if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
return false;
},
@@ -288,15 +278,26 @@ export const BookingRequests: CollectionConfig = {
delete: isAdmin,
},
hooks: {
beforeOperation: [async ({ args, operation, req }) => {
if (operation === "create" || operation === "update" || operation === "delete") {
await acquireBookingDayLock(req, "booking-calendar");
}
return args;
}],
beforeChange: [handleConfirmation],
afterChange: [notifyByEmail],
afterDelete: [async ({ doc, req }) => {
const id = typeof doc.linkedEvent === "object" ? doc.linkedEvent?.id : doc.linkedEvent;
if (id) await req.payload.update({ collection: "events", id, req, data: { isOnline: false } });
return doc;
}],
},
fields: [
{
name: "user",
type: "relationship",
relationTo: "customers",
label: "Nutzer:in",
label: "Nutzer",
required: true,
index: true,
},
+103
View File
@@ -0,0 +1,103 @@
import type { CollectionAfterChangeHook, CollectionConfig } from "payload";
import { isAdmin } from "@/access";
import { conversationReplyEmail } from "@/lib/email/conversationTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
const sendAdminReply: CollectionAfterChangeHook = async ({ doc, operation, req }) => {
if (operation !== "create" || doc.senderType !== "admin") return doc;
try {
const conversation = await req.payload.findByID({
collection: "conversations",
id: typeof doc.conversation === "object" ? doc.conversation.id : doc.conversation,
depth: 0,
req,
});
const adminUser = req.user?.collection === "users" ? req.user : null;
await sendEmail(
conversation.email,
conversationReplyEmail({
name: conversation.name,
subject: conversation.subject,
body: doc.body,
senderName: doc.senderName || adminUser?.name || "Anouma",
threadKey: conversation.threadKey,
}),
{ replyTo: process.env.INBOUND_REPLY_TO || "reply-to@anouma.org" },
);
await req.payload.update({
collection: "conversations",
id: conversation.id,
req,
data: { status: "waiting", lastMessageAt: new Date().toISOString() },
});
} catch (err) {
req.payload.logger.error({ err, msg: "conversation admin reply email failed" });
}
return doc;
};
export const ConversationMessages: CollectionConfig = {
slug: "conversation-messages",
labels: {
singular: "Nachricht",
plural: "Nachrichten",
},
admin: {
useAsTitle: "body",
defaultColumns: ["conversation", "senderType", "senderName", "createdAt"],
group: "Kommunikation",
description: "Einzelne Nachrichten innerhalb einer Anfrage. Antworten aus der Verwaltung werden automatisch per E-Mail verschickt.",
hidden: true,
},
access: {
read: isAdmin,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
timestamps: true,
hooks: {
afterChange: [sendAdminReply],
},
fields: [
{
name: "conversation",
type: "relationship",
relationTo: "conversations",
label: "Anfrage",
required: true,
index: true,
},
{
name: "senderType",
type: "select",
label: "Absenderart",
required: true,
defaultValue: "admin",
options: [
{ label: "Anfragende Person", value: "visitor" },
{ label: "Anouma", value: "admin" },
],
admin: { description: "Für Antworten aus der Verwaltung „Anouma“ auswählen. Eingehende E-Mails werden automatisch als anfragende Person gespeichert." },
},
{ name: "senderName", type: "text", label: "Absendername" },
{ name: "senderEmail", type: "email", label: "Absender-E-Mail" },
{
name: "body",
type: "textarea",
label: "Nachricht",
required: true,
minLength: 1,
},
{
name: "externalMessageId",
type: "text",
label: "Externe Nachrichten-ID",
unique: true,
index: true,
admin: { position: "sidebar", readOnly: true },
},
],
};
+88
View File
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import type { CollectionConfig } from "payload";
import { isAdmin, isAdminFieldLevel } from "@/access";
export const Conversations: CollectionConfig = {
slug: "conversations",
labels: {
singular: "Anfrage",
plural: "Anfragen",
},
admin: {
useAsTitle: "subject",
defaultColumns: ["subject", "name", "email", "status", "lastMessageAt"],
group: "Kommunikation",
description: "Kontakt- und Terminanfragen als durchsuchbare Konversationen.",
},
access: {
read: isAdmin,
create: () => false,
update: isAdmin,
delete: isAdmin,
},
timestamps: true,
fields: [
{
name: "subject",
type: "text",
label: "Betreff",
required: true,
},
{
type: "row",
fields: [
{ name: "name", type: "text", label: "Name", required: true, admin: { width: "50%" } },
{ name: "email", type: "email", label: "E-Mail", required: true, admin: { width: "50%" } },
],
},
{
name: "type",
type: "select",
label: "Art",
required: true,
defaultValue: "contact",
options: [
{ label: "Kontakt", value: "contact" },
{ label: "Terminanfrage", value: "appointment" },
{ label: "Gruppenangebot", value: "group" },
],
},
{
name: "status",
type: "select",
label: "Status",
required: true,
defaultValue: "open",
options: [
{ label: "Offen", value: "open" },
{ label: "Wartet auf Antwort", value: "waiting" },
{ label: "Erledigt", value: "closed" },
],
},
{
name: "customer",
type: "relationship",
relationTo: "customers",
label: "Kundenkonto",
admin: { position: "sidebar", readOnly: true },
},
{
name: "threadKey",
type: "text",
label: "E-Mail-Thread-Schlüssel",
required: true,
unique: true,
index: true,
defaultValue: () => randomUUID(),
access: { read: isAdminFieldLevel, update: () => false },
admin: { position: "sidebar", readOnly: true },
},
{
name: "lastMessageAt",
type: "date",
label: "Letzte Nachricht",
index: true,
admin: { position: "sidebar", readOnly: true },
},
],
};
+41 -19
View File
@@ -1,24 +1,46 @@
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdminFieldLevel } from "@/access";
import { APIError } from "payload";
import { allowVerificationEmail } from "@/lib/auth/verificationRateLimit";
import { isAdmin, isAdminFieldLevel } from "@/access";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
const user = req.user;
// Routes through the shared isAdmin check (role === "admin", not just
// collection === "users") so this stays correct if a non-admin role is ever
// added to Users - see the comment on isAdmin in access/index.ts.
const isSelfOrAdmin: typeof isAdmin = (args) => {
const user = args.req.user;
if (!user) return false;
if (user.collection === "users") return true;
if (user.collection === "users") return isAdmin(args);
if (user.collection === "customers") return { id: { equals: user.id } };
return false;
};
// Runs on every new customer account: issues a random, single-use,
// time-limited email-verification token. Only its hash is ever persisted
// (see the emailVerificationTokenHash field below) — the plaintext exists
// only in req.context for the afterChange hook below to email out, and is
// never written to the database or logged.
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, req }) => {
if (operation !== "create") return data;
// Runs on every new customer account, and again whenever an existing
// customer changes their email address: issues a random, single-use,
// time-limited email-verification token and resets emailVerified to false.
// Only the token's hash is ever persisted (see emailVerificationTokenHash
// below) - the plaintext exists only in req.context for the afterChange
// hook below to email out, and is never written to the database or logged.
//
// The email-change case matters because `update` access on this collection
// is isSelfOrAdmin and the auto-added `email` field carries no field-level
// access of its own - without this, a customer could verify a throwaway
// address once and then repoint `email` at an address they don't own while
// keeping emailVerified: true (which gates booking-confirmation emails).
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, operation, req }) => {
const emailChanged =
operation === "update" &&
typeof data.email === "string" &&
typeof originalDoc?.email === "string" &&
data.email !== originalDoc.email;
if (operation !== "create" && !emailChanged) return data;
if (!allowVerificationEmail(req, String(data.email ?? originalDoc?.email ?? ""), originalDoc?.id)) {
throw new APIError("Zu viele Bestätigungs-E-Mails. Bitte versuche es später erneut.", 429, undefined, true);
}
const token = generateVerificationToken();
data.emailVerified = false;
data.emailVerificationTokenHash = hashVerificationToken(token);
@@ -27,10 +49,10 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, r
return data;
};
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, operation, req }) => {
if (operation !== "create") return doc;
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => {
const token = req.context.pendingVerificationToken;
if (typeof token !== "string") return doc;
delete req.context.pendingVerificationToken;
try {
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
await sendEmail(
@@ -51,13 +73,13 @@ export const Customers: CollectionConfig = {
slug: "customers",
labels: {
singular: "Kundin/Kunde",
plural: "Kund:innen",
plural: "Kunden",
},
admin: {
useAsTitle: "name",
defaultColumns: ["name", "email", "phone"],
group: "Buchungen",
description: "Konten der Website-Besucher:innen (nicht die Admin-Zugänge unter „Benutzer:innen“).",
description: "Konten der Website-Besucher (nicht die Admin-Zugänge unter „Benutzer“).",
},
auth: {
maxLoginAttempts: 8,
@@ -69,7 +91,7 @@ export const Customers: CollectionConfig = {
create: () => true,
read: isSelfOrAdmin,
update: isSelfOrAdmin,
delete: ({ req }) => req.user?.collection === "users",
delete: isAdmin,
},
hooks: {
beforeChange: [issueVerificationToken],
@@ -88,7 +110,7 @@ export const Customers: CollectionConfig = {
label: "Telefonnummer",
required: false,
admin: {
description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
description: "Optional - für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
},
},
{
@@ -96,7 +118,7 @@ export const Customers: CollectionConfig = {
type: "checkbox",
label: "E-Mail bestätigt",
defaultValue: false,
// "create" AND "update" must both be locked down — otherwise a
// "create" AND "update" must both be locked down - otherwise a
// customer could simply include emailVerified: true in their own
// registration or profile request and skip verification entirely.
access: { create: isAdminFieldLevel, update: isAdminFieldLevel },
@@ -108,7 +130,7 @@ export const Customers: CollectionConfig = {
{
name: "emailVerificationTokenHash",
type: "text",
// Never exposed or settable via any API — only the two hooks above
// Never exposed or settable via any API - only the two hooks above
// (running through the trusted Local API) ever touch this field.
access: { create: () => false, read: () => false, update: () => false },
admin: { hidden: true },
+8 -5
View File
@@ -11,11 +11,14 @@ export const EventRegistrations: CollectionConfig = {
useAsTitle: "name",
defaultColumns: ["name", "email", "event", "status", "registeredAt"],
group: "Inhalte",
description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.",
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,
@@ -62,14 +65,14 @@ export const EventRegistrations: CollectionConfig = {
{
name: "reminder60Sent",
type: "checkbox",
label: "Reminder 60 Min. gesendet",
label: "Erinnerung 60 Min. gesendet",
defaultValue: false,
admin: { width: "50%" },
},
{
name: "reminder30Sent",
type: "checkbox",
label: "Reminder 30 Min. gesendet",
label: "Erinnerung 30 Min. gesendet",
defaultValue: false,
admin: { width: "50%" },
},
+18 -9
View File
@@ -1,8 +1,9 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { isAdmin, isAdminFieldLevel, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
import { seoFields } from "@/fields/seo";
import { generateMeetingPassword } from "@/lib/meeting/password";
import { revokeChangedMeeting } from "@/lib/meeting/revoke";
export const EVENT_CATEGORIES = [
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
@@ -17,10 +18,10 @@ export const EVENT_CATEGORIES = [
// Auto-generates the meeting password for new online events, and
// regenerates it whenever the admin ticks "Neues Passwort generieren".
// Never derived from the event id/slug — always a fresh random value.
const setMeetingPassword: CollectionBeforeChangeHook = ({ data }) => {
if (!data?.isOnline) return data;
if (!data.meetingPassword || data.regeneratePassword) {
// Never derived from the event id/slug - always a fresh random value.
const setMeetingPassword: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
if (!(data.isOnline ?? originalDoc?.isOnline)) return data;
if (!(data.meetingPassword ?? originalDoc?.meetingPassword) || data.regeneratePassword || originalDoc?.isOnline === false) {
data.meetingPassword = generateMeetingPassword();
}
data.regeneratePassword = false;
@@ -37,7 +38,7 @@ export const Events: CollectionConfig = {
useAsTitle: "title",
defaultColumns: ["title", "date", "category", "_status"],
group: "Inhalte",
description: "Termine und Veranstaltungen — erscheinen automatisch unter /termine.",
description: "Termine und Veranstaltungen - erscheinen automatisch unter /termine.",
},
versions: {
drafts: {
@@ -52,6 +53,7 @@ export const Events: CollectionConfig = {
},
hooks: {
beforeChange: [setMeetingPassword],
afterChange: [revokeChangedMeeting],
},
fields: [
{
@@ -147,7 +149,7 @@ export const Events: CollectionConfig = {
},
{
type: "collapsible",
label: "Online-Termin (Video-Call)",
label: "Online-Termin (Videoanruf)",
fields: [
{
name: "isOnline",
@@ -162,6 +164,13 @@ export const Events: CollectionConfig = {
name: "meetingPassword",
type: "text",
label: "Meeting-Passwort",
// Events are publicly readable (publishedOrAdmin) via the REST/
// GraphQL API, so without this the plaintext password would be
// returned to any visitor fetching the event - defeating the
// password check in app/(payload)/api/meetings/[slug]/join. Only
// exposed to admins there; the join route reads it via the Local
// API, which bypasses field access by default.
access: { read: isAdminFieldLevel },
admin: {
readOnly: true,
condition: (data) => Boolean(data?.isOnline),
@@ -211,14 +220,14 @@ export const Events: CollectionConfig = {
{
name: "hostReminder60Sent",
type: "checkbox",
label: "Host-Reminder 60 Min. gesendet",
label: "Erinnerung an Anna 60 Min. gesendet",
defaultValue: false,
admin: { readOnly: true, width: "50%" },
},
{
name: "hostReminder30Sent",
type: "checkbox",
label: "Host-Reminder 30 Min. gesendet",
label: "Erinnerung an Anna 30 Min. gesendet",
defaultValue: false,
admin: { readOnly: true, width: "50%" },
},
+50
View File
@@ -0,0 +1,50 @@
import type { CollectionConfig } from "payload";
import { isAdmin } from "@/access";
export const GroupRegistrations: CollectionConfig = {
slug: "group-registrations",
labels: {
singular: "Gruppenanmeldung",
plural: "Gruppenanmeldungen",
},
admin: {
useAsTitle: "name",
defaultColumns: ["name", "email", "offer", "status", "registeredAt"],
group: "Buchungen",
description: "Anmeldungen und Bewerbungen für Gruppenangebote.",
},
access: {
read: isAdmin,
create: () => false,
update: isAdmin,
delete: isAdmin,
},
timestamps: true,
fields: [
{ name: "offer", type: "relationship", relationTo: "offers", label: "Angebot", required: true, index: true },
{
type: "row",
fields: [
{ name: "name", type: "text", label: "Name", required: true, admin: { width: "50%" } },
{ name: "email", type: "email", label: "E-Mail", required: true, admin: { width: "50%" } },
],
},
{ name: "phone", type: "text", label: "Telefon" },
{ name: "message", type: "textarea", label: "Nachricht / Motivation" },
{
name: "status",
type: "select",
label: "Status",
required: true,
defaultValue: "pending",
options: [
{ label: "Zu prüfen", value: "pending" },
{ label: "Angenommen", value: "accepted" },
{ label: "Warteliste", value: "waitlist" },
{ label: "Abgelehnt", value: "rejected" },
{ label: "Storniert", value: "cancelled" },
],
},
{ name: "registeredAt", type: "date", label: "Eingegangen am", defaultValue: () => new Date().toISOString(), admin: { position: "sidebar" } },
],
};
+9 -4
View File
@@ -12,8 +12,9 @@ export const Media: CollectionConfig = {
plural: "Bilder",
},
admin: {
group: "Inhalte",
useAsTitle: "alt",
description: "Bilder für die Website — Alt-Text ist für Barrierefreiheit und SEO Pflicht.",
description: "Bilder für die Website. Alt-Text und Bildbeschreibung sind optional. Bis zu 100 Dateien pro Upload.",
},
access: {
// Images are public so the website can display them without a login.
@@ -24,7 +25,11 @@ export const Media: CollectionConfig = {
},
upload: {
staticDir: path.resolve(dirname, "..", "media"),
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/svg+xml"],
bulkUpload: true,
// SVG deliberately excluded: it can carry inline <script>/event-handler
// payloads that execute if the raw file is ever opened directly (not via
// <img>) - not currently needed by any part of the site.
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
imageSizes: [
{ name: "thumbnail", width: 400, position: "centre" },
{ name: "card", width: 800, position: "centre" },
@@ -38,9 +43,9 @@ export const Media: CollectionConfig = {
name: "alt",
type: "text",
label: "Alt-Text",
required: true,
required: false,
admin: {
description: "Kurze Beschreibung des Bildes für Screenreader und Suchmaschinen.",
description: "Optional. Kann später für Barrierefreiheit und SEO ergänzt werden.",
},
},
{
+68 -2
View File
@@ -5,7 +5,7 @@ import { seoFields } from "@/fields/seo";
// Groups the 7 individual offers for the mega menu and the /angebote
// overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise
// bundles the three singing circles — matching the site's navigation).
// bundles the three singing circles - matching the site's navigation).
export const OFFER_CATEGORIES = [
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
{ label: "Doula-Begleitung", value: "doula-begleitung" },
@@ -23,7 +23,7 @@ export const Offers: CollectionConfig = {
useAsTitle: "title",
defaultColumns: ["title", "category", "order", "_status"],
group: "Inhalte",
description: "Die Angebote von Anouma — erscheinen im Menü und auf /angebote.",
description: "Die Angebote von Anouma - erscheinen im Menü und auf /angebote.",
},
defaultSort: "order",
versions: {
@@ -106,6 +106,72 @@ export const Offers: CollectionConfig = {
description: "Aktiviert die Terminbuchung mit Kalenderauswahl auf der Angebotsseite.",
},
},
{
name: "bookingMode",
type: "select",
label: "Buchungsart",
required: true,
defaultValue: "individual",
options: [
{ label: "Einzeltermin mit Kalender", value: "individual" },
{ label: "Gruppenangebot / Anmeldung", value: "group" },
],
admin: {
position: "sidebar",
description: "Gruppenangebote werden über Anmeldung, feste Wiederholung oder Bewerbungsfrist organisiert.",
},
},
{
name: "groupRegistrationMode",
type: "select",
label: "Gruppenmodus",
defaultValue: "fixed",
options: [
{ label: "Fester Termin (wöchentlich/monatlich)", value: "fixed" },
{ label: "Plätze mit Bewerbungsfrist", value: "capacity" },
{ label: "Unverbindliche Anmeldung", value: "request" },
],
admin: { condition: (data) => data?.bookingMode === "group" },
},
{
name: "groupSchedule",
type: "group",
label: "Wiederkehrender Termin",
admin: { condition: (data) => data?.bookingMode === "group" && data?.groupRegistrationMode === "fixed" },
fields: [
{
name: "recurrence",
type: "select",
label: "Rhythmus",
defaultValue: "weekly",
options: [
{ label: "Jede Woche", value: "weekly" },
{ label: "Jeden Monat", value: "monthly" },
],
},
{ name: "weekday", type: "select", label: "Wochentag", options: [
{ label: "Montag", value: "monday" }, { label: "Dienstag", value: "tuesday" }, { label: "Mittwoch", value: "wednesday" },
{ label: "Donnerstag", value: "thursday" }, { label: "Freitag", value: "friday" }, { label: "Samstag", value: "saturday" }, { label: "Sonntag", value: "sunday" },
] },
{ name: "monthDay", type: "number", label: "Tag im Monat", min: 1, max: 31, admin: { condition: (data) => data?.recurrence === "monthly" } },
{ name: "startTime", type: "text", label: "Beginn", admin: { description: "z. B. 18:00 Uhr" } },
{ name: "endTime", type: "text", label: "Ende", admin: { description: "z. B. 20:00 Uhr" } },
],
},
{
type: "row",
admin: { condition: (data) => data?.bookingMode === "group" },
fields: [
{ name: "groupCapacity", type: "number", label: "Plätze", min: 1, admin: { width: "50%" } },
{ name: "registrationDeadline", type: "date", label: "Anmelde-/Bewerbungsfrist", admin: { width: "50%", date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" } } },
],
},
{
name: "groupInfo",
type: "textarea",
label: "Hinweis zum Gruppenangebot",
admin: { condition: (data) => data?.bookingMode === "group", description: "Wird auf der Angebotsseite oberhalb des Anmeldeformulars angezeigt." },
},
{
type: "row",
fields: [
+1 -1
View File
@@ -68,7 +68,7 @@ export const Posts: CollectionConfig = {
{
name: "author",
type: "text",
label: "Autor:in",
label: "Autor",
defaultValue: "Anouma",
admin: { position: "sidebar" },
},
+8 -4
View File
@@ -2,7 +2,11 @@ import type { CollectionConfig } from "payload";
import { isAdmin } from "@/access";
function normalizePath(value: string): string {
const trimmed = value.trim();
// Lowercased to match the slugs it points at/from (fields/slug.ts always
// lowercases via slugify) - without this, "/Angebote/Doula" and
// "/angebote/doula" count as different values to the `unique` constraint
// on fromPath, letting near-duplicate redirects coexist.
const trimmed = value.trim().toLowerCase();
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
}
@@ -10,7 +14,7 @@ function normalizePath(value: string): string {
/**
* Simple slug-change redirect table (see lib/seo/redirects.ts). Deliberately
* scoped to the three dynamic [slug] detail routes (Angebote/Termine/
* Aktuelles) — that's the only place slugs actually live and can change, so
* Aktuelles) - that's the only place slugs actually live and can change, so
* looking this up only there avoids adding a database lookup to every
* single request the way a catch-all middleware would.
*/
@@ -62,8 +66,8 @@ export const Redirects: CollectionConfig = {
required: true,
defaultValue: "permanent",
options: [
{ label: "301 – Dauerhaft", value: "permanent" },
{ label: "302 – Vorübergehend", value: "temporary" },
{ label: "301 - Dauerhaft", value: "permanent" },
{ label: "302 - Vorübergehend", value: "temporary" },
],
},
{
+22 -5
View File
@@ -4,18 +4,19 @@ import { isAdmin, isAdminFieldLevel } from "@/access";
export const Users: CollectionConfig = {
slug: "users",
labels: {
singular: "Benutzer:in",
plural: "Benutzer:innen",
singular: "Benutzer",
plural: "Benutzer",
},
admin: {
useAsTitle: "name",
defaultColumns: ["name", "email", "role"],
group: "Verwaltung",
description: "Zugänge für den geschützten Admin-Bereich.",
},
auth: {
// Payload shows a "create first user" screen automatically when this
// collection is empty, bypassing normal access control just for that
// one-time setup — no hardcoded default password is ever needed.
// one-time setup - no hardcoded default password is ever needed.
maxLoginAttempts: 5,
lockTime: 10 * 60 * 1000,
},
@@ -40,16 +41,32 @@ export const Users: CollectionConfig = {
defaultValue: "admin",
// Only "admin" exists today; add further roles here later and extend
// the checks in access/index.ts to match.
options: [{ label: "Administrator:in", value: "admin" }],
options: [{ label: "Administrator", value: "admin" }],
access: {
update: isAdminFieldLevel,
},
},
{
name: "canViewAnalytics",
type: "checkbox",
label: "Auswertung sichtbar",
defaultValue: true,
// Admins always have access regardless of this flag (see
// access/index.ts's canViewAnalytics) - this only matters once a
// non-admin role exists, letting an admin grant/revoke Analytics
// visibility per account without changing anyone's role.
access: {
update: isAdminFieldLevel,
},
admin: {
description: "Steuert, ob dieser Zugang die Auswertung im Admin-Bereich sehen kann.",
},
},
{
name: "calendarFeedTokenHash",
type: "text",
// Only ever set by /api/admin/calendar-feed/regenerate (see
// lib/calendar/feedToken.ts) — never exposed or directly settable via
// lib/calendar/feedToken.ts) - never exposed or directly settable via
// any API, so the plaintext token is never retrievable again after
// it's shown once at generation time.
access: { create: () => false, read: () => false, update: () => false },
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { getConsent, hasCurrentConsentDecision, setAnalyticsConfig, setConsent, track } from "@/lib/analytics/client";
type CategoryText = { label: string; description: string };
type AnalyticsProps = {
consentVersion: string;
preferencesEnabled: boolean;
statisticsEnabled: boolean;
necessary: CategoryText;
preferences: CategoryText;
statistics: CategoryText;
};
const buttonBase =
"inline-flex items-center justify-center rounded-full px-6 py-3 text-sm font-medium tracking-wide transition-colors duration-300 focus-visible:outline-2 focus-visible:outline-offset-4";
/**
* Mounted once in app/(frontend)/layout.tsx. Combines two independent jobs:
* (1) the cookie-consent banner, and (2) automatic page_view tracking on
* every client-side route change. Both only ever act through track()/
* setAnalyticsConfig() from lib/analytics/client.ts - no network call is
* possible from this component before a visitor has actually consented.
*/
export function Analytics({ consentVersion, preferencesEnabled, statisticsEnabled, necessary, preferences, statistics }: AnalyticsProps) {
// Plain assignment during render (not an effect): must be set before the
// page_view effect below ever fires, on every render including the first.
setAnalyticsConfig({ statisticsEnabled, consentVersion });
const pathname = usePathname();
const [showBanner, setShowBanner] = useState(false);
const [expanded, setExpanded] = useState(false);
const [prefChoice, setPrefChoice] = useState(false);
const [statChoice, setStatChoice] = useState(false);
useEffect(() => {
if (!hasCurrentConsentDecision()) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowBanner(true);
const existing = getConsent();
setPrefChoice(existing?.preferences ?? false);
setStatChoice(existing?.statistics ?? false);
}
}, [consentVersion]);
useEffect(() => {
track("page_view", { path: pathname });
}, [pathname]);
function acceptAll() {
setConsent({ preferences: preferencesEnabled, statistics: statisticsEnabled }, consentVersion);
setShowBanner(false);
}
function acceptNecessaryOnly() {
setConsent({ preferences: false, statistics: false }, consentVersion);
setShowBanner(false);
}
function saveSelection() {
setConsent({ preferences: preferencesEnabled && prefChoice, statistics: statisticsEnabled && statChoice }, consentVersion);
setShowBanner(false);
}
if (!showBanner) return null;
return (
<div
role="dialog"
aria-modal="false"
aria-label="Cookie-Einstellungen"
className="fixed inset-x-0 bottom-0 z-[90] border-t border-anouma-taupe/30 bg-anouma-cream-light px-6 py-6 shadow-[0_-4px_24px_rgba(0,0,0,0.08)] sm:px-8"
>
<div className="mx-auto flex max-w-4xl flex-col gap-4">
<div>
<h2 className="font-serif text-xl font-medium text-anouma-plum">Cookie-Einstellungen</h2>
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">
Wir nutzen Cookies, um die Website technisch bereitzustellen{statisticsEnabled ? " und, mit deiner Zustimmung, datensparsame Statistiken zu erheben" : ""}. Du
kannst deine Auswahl jederzeit ändern.
</p>
</div>
{expanded && (
<div className="grid gap-3 rounded-2xl border border-anouma-taupe/30 bg-white/60 p-4 sm:grid-cols-3">
<label className="flex items-start gap-2 text-sm text-anouma-plum opacity-70">
<input type="checkbox" checked disabled className="mt-1" />
<span>
<strong className="block">{necessary.label}</strong>
{necessary.description}
</span>
</label>
{preferencesEnabled && (
<label className="flex items-start gap-2 text-sm text-anouma-plum">
<input
type="checkbox"
checked={prefChoice}
onChange={(e) => setPrefChoice(e.target.checked)}
className="mt-1"
/>
<span>
<strong className="block">{preferences.label}</strong>
{preferences.description}
</span>
</label>
)}
{statisticsEnabled && (
<label className="flex items-start gap-2 text-sm text-anouma-plum">
<input
type="checkbox"
checked={statChoice}
onChange={(e) => setStatChoice(e.target.checked)}
className="mt-1"
/>
<span>
<strong className="block">{statistics.label}</strong>
{statistics.description}
</span>
</label>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={acceptAll} className={`${buttonBase} bg-anouma-mauve-dark text-white hover:bg-anouma-plum`}>
Alle akzeptieren
</button>
<button
type="button"
onClick={acceptNecessaryOnly}
className={`${buttonBase} border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white`}
>
Nur notwendige
</button>
{!expanded && (
<button type="button" onClick={() => setExpanded(true)} className="text-sm text-anouma-plum underline underline-offset-4">
Einstellungen anpassen
</button>
)}
{expanded && (
<button type="button" onClick={saveSelection} className={`${buttonBase} border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white`}>
Auswahl speichern
</button>
)}
</div>
</div>
</div>
);
}
+2 -3
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { ArrowIcon } from "@/components/ArrowIcon";
import type { ImageMood } from "@/lib/angebote";
type AngebotCardProps = {
@@ -42,9 +43,7 @@ export function AngebotCard({
<p className="mt-2 text-sm leading-relaxed text-white/90">{tagline}</p>
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-white/95">
Mehr erfahren
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" aria-hidden="true" className="transition-transform duration-300 group-hover:translate-x-1">
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<ArrowIcon />
</span>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
/** The small forward-nudging arrow shared by ArrowLink and the "Mehr erfahren" affordance inside AngebotCard/EventTeaserCard (which can't use ArrowLink directly since those are already whole-tile links, and nesting an anchor inside an anchor is invalid). Purely visual - animates on the parent's `group-hover`. */
export function ArrowIcon({ className = "" }: { className?: string }) {
return (
<svg
width="14"
height="10"
viewBox="0 0 14 10"
fill="none"
aria-hidden="true"
className={`shrink-0 transition-transform duration-300 group-hover:translate-x-1 ${className}`}
>
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { ArrowIcon } from "@/components/ArrowIcon";
type Tone = "mauve" | "plum" | "white";
type Size = "sm" | "base";
const tones: Record<Tone, string> = {
mauve: "text-anouma-mauve-dark",
plum: "text-anouma-plum",
white: "text-white",
};
const sizes: Record<Size, string> = {
sm: "gap-1.5 text-sm",
base: "gap-2 text-base",
};
type ArrowLinkProps = {
href: string;
children: ReactNode;
tone?: Tone;
size?: Size;
className?: string;
};
/**
* The site's one secondary-action pattern: a text link with a small arrow
* that nudges forward on hover. Used instead of a pill button wherever the
* action isn't the single primary one on screen - keeps most CTAs feeling
* like an invitation to read on rather than another button to click.
*/
export function ArrowLink({ href, children, tone = "mauve", size = "sm", className = "" }: ArrowLinkProps) {
return (
<Link
href={href}
className={`group inline-flex items-center font-medium ${tones[tone]} ${sizes[size]} ${className}`}
>
{children}
<ArrowIcon />
</Link>
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
type Variant = "primary" | "secondary" | "ghost" | "invert" | "invertOutline";
// Text colors are chosen to meet WCAG AA (4.5:1) against every background
// tone the button appears on — see components/Section.tsx for the tones.
// tone the button appears on - see components/Section.tsx for the tones.
const variants: Record<Variant, string> = {
primary: "bg-anouma-mauve-dark text-white hover:bg-anouma-plum",
secondary:
+1 -4
View File
@@ -1,5 +1,4 @@
import { Button } from "@/components/Button";
import { OrganicBlob } from "@/components/OrganicBlob";
import { Container } from "@/components/Section";
type CTAProps = {
@@ -20,9 +19,7 @@ export function CTA({
secondaryLabel = "Kontakt aufnehmen",
}: CTAProps) {
return (
<section className="relative overflow-hidden bg-anouma-plum py-20 text-anouma-cream-light sm:py-24">
<OrganicBlob tone="mauve" className="-left-20 -top-20 h-80 w-80 opacity-30" />
<OrganicBlob tone="rose" className="-bottom-24 -right-16 h-72 w-72 opacity-20" />
<section className="border-y border-anouma-cream-light/15 bg-anouma-mauve-dark py-16 text-anouma-cream-light sm:py-20">
<Container className="relative text-center">
<h2 className="mx-auto max-w-2xl text-balance font-serif text-4xl font-medium leading-tight sm:text-5xl">
{title}
+46 -41
View File
@@ -1,73 +1,78 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-background px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { track } from "@/lib/analytics/client";
import { TextField, TextAreaField } from "@/components/FormField";
import { TurnstileWidget } from "@/components/TurnstileWidget";
type ContactFormProps = {
toEmail: string;
toEmail?: string;
subjectPrefix?: string;
type?: "contact" | "appointment";
submitLabel?: string;
};
/**
* Opens the visitor's email client with a pre-filled message. There is no
* backend configured yet — swap this for a Server Action once a mail
* provider is connected.
*/
export function ContactForm({
toEmail,
subjectPrefix = "Nachricht über anouma.org",
siteKey,
subjectPrefix = "Allgemeine Anfrage",
type = "contact",
submitLabel = "Nachricht senden",
}: ContactFormProps) {
const [sent, setSent] = useState(false);
}: ContactFormProps & { siteKey: string }) {
const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
const [submitting, setSubmitting] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
function handleSubmit(e: FormEvent<HTMLFormElement>) {
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const name = String(data.get("name") ?? "");
const email = String(data.get("email") ?? "");
const message = String(data.get("message") ?? "");
const subject = encodeURIComponent(`${subjectPrefix} von ${name}`);
const body = encodeURIComponent(`${message}\n\n— ${name} (${email})`);
window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
setSent(true);
setSubmitting(true);
setState({ sent: false });
try {
const response = await fetch("/api/contact/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, message, subject: `${subjectPrefix} von ${name}`, type, token }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
setState({ sent: false, error: result.error || "Die Nachricht konnte nicht gesendet werden." });
setSubmitting(false);
return;
}
track("contact_clicked");
setState({ sent: true });
e.currentTarget.reset();
} catch {
setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." });
} finally {
setSubmitting(false);
setResetKey((key) => key + 1);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
</div>
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="message" className="mb-2 block text-sm font-medium text-anouma-plum">
Deine Nachricht
</label>
<textarea id="message" name="message" required rows={5} className={fieldClass} />
</div>
<TextField label="Name" id="name" name="name" type="text" required autoComplete="name" />
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextAreaField label="Deine Nachricht" id="message" name="message" required rows={5} />
<TurnstileWidget siteKey={siteKey} action="contact" resetKey={resetKey} onTokenChange={setToken} />
<button
type="submit"
disabled={submitting || !token}
className="inline-flex items-center justify-center rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum"
>
{submitLabel}
{submitting ? "Wird gesendet …" : submitLabel}
</button>
{sent && (
{state.sent && (
<p className="text-sm text-anouma-olive" role="status">
Dein E-Mail-Programm sollte sich jetzt geöffnet haben. Falls nicht, schreib gerne direkt an{" "}
{toEmail}.
Danke, deine Nachricht ist angekommen. Anna antwortet dir per E-Mail; du kannst direkt auf ihre Antwort reagieren.
</p>
)}
{state.error && <p className="text-sm text-red-700" role="alert">{state.error}</p>}
</form>
);
}
+2 -16
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import type { Event } from "@/payload-types";
import { formatEventDateParts, formatTimeRange } from "@/lib/format";
import { ArrowIcon } from "@/components/ArrowIcon";
export function EventTeaserCard({ event }: { event: Event }) {
const { day, month } = formatEventDateParts(event.date);
@@ -21,22 +22,7 @@ export function EventTeaserCard({ event }: { event: Event }) {
{event.location && <p className="text-sm text-anouma-plum/80">{event.location}</p>}
<span className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-anouma-mauve-dark">
Mehr erfahren
<svg
width="12"
height="9"
viewBox="0 0 14 10"
fill="none"
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
>
<path
d="M1 5h11.5M8 1l4.5 4L8 9"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<ArrowIcon />
</span>
</div>
</Link>
+2 -2
View File
@@ -12,11 +12,11 @@ export async function Footer() {
<div className="mx-auto max-w-6xl px-6 py-16 sm:px-8 lg:px-12">
<div className="grid gap-12 sm:grid-cols-2 lg:grid-cols-4">
<div>
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.08em]">
<Link href="/" className="font-antropos text-2xl font-semibold tracking-[0.08em]">
{siteConfig.name.toUpperCase()}
</Link>
<p className="mt-4 max-w-xs text-sm leading-relaxed text-anouma-cream-light/90">
Räume für Verbindung — mit dir selbst, miteinander und mit der Natur.
Räume für Verbindung - mit dir selbst, miteinander und mit der Natur.
</p>
</div>
+62
View File
@@ -0,0 +1,62 @@
import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react";
/**
* The one field style used by every form on the site (previously each form
* declared its own near-identical `fieldClass` constant with small drifts in
* padding/size/background - see ContactForm, auth/*, meeting/* history).
* Functionality is untouched: both components just spread the native
* input/textarea props through, so controlled and uncontrolled forms,
* validation attributes, autoComplete, etc. all keep working exactly as
* before.
*/
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 }) {
return (
<label htmlFor={htmlFor} className={hidden ? "sr-only" : "mb-2 block text-sm font-medium text-anouma-plum"}>
{label}
</label>
);
}
type TextFieldProps = InputHTMLAttributes<HTMLInputElement> & {
label: string;
id: string;
/** Keeps the label for screen readers only - for compact inline fields identified by a placeholder instead (e.g. ResendVerificationButton's email prompt). */
hideLabel?: boolean;
wrapperClassName?: string;
};
export function TextField({ label, id, hideLabel = false, className = "", wrapperClassName = "", ...inputProps }: TextFieldProps) {
return (
<div className={wrapperClassName}>
<FieldLabel label={label} htmlFor={id} hidden={hideLabel} />
<input id={id} className={`${fieldClass} ${className}`} {...inputProps} />
</div>
);
}
type TextAreaFieldProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
label: string;
id: string;
wrapperClassName?: string;
};
export function TextAreaField({ label, id, className = "", wrapperClassName = "", ...textareaProps }: TextAreaFieldProps) {
return (
<div className={wrapperClassName}>
<FieldLabel label={label} htmlFor={id} />
<textarea id={id} className={`${fieldClass} ${className}`} {...textareaProps} />
</div>
);
}
/** Shared error-message treatment (`role="alert"`) used below every form field/submit button. */
export function FormError({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<p role="alert" className={`text-sm text-red-700 ${className}`}>
{children}
</p>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useState, type FormEvent } from "react";
import { TextField, TextAreaField } from "@/components/FormField";
import { TurnstileWidget } from "@/components/TurnstileWidget";
export function GroupRegistrationForm({ offerSlug, siteKey }: { offerSlug: string; siteKey: string }) {
const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
const [busy, setBusy] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setState({ sent: false });
const data = new FormData(event.currentTarget);
try {
const response = await fetch("/api/group-registration", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
offerSlug,
name: data.get("name"),
email: data.get("email"),
phone: data.get("phone"),
message: data.get("message"),
token,
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) setState({ sent: false, error: result.error || "Die Anmeldung konnte nicht gesendet werden." });
else { setState({ sent: true }); event.currentTarget.reset(); }
} catch { setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." }); }
finally { setBusy(false); setResetKey((key) => key + 1); }
}
if (state.sent) return <p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-olive" role="status">Danke, deine Anmeldung ist angekommen. Anna meldet sich, sobald sie die Plätze geprüft hat.</p>;
return (
<form onSubmit={submit} className="space-y-5">
<TextField label="Name" id="group-name" name="name" type="text" required autoComplete="name" />
<TextField label="E-Mail" id="group-email" name="email" type="email" required autoComplete="email" />
<TextField label="Telefon (optional)" id="group-phone" name="phone" type="tel" autoComplete="tel" />
<TextAreaField label="Nachricht / Motivation (optional)" id="group-message" name="message" rows={4} />
<TurnstileWidget siteKey={siteKey} action="group-registration" resetKey={resetKey} onTokenChange={setToken} />
{state.error && <p className="text-sm text-red-700" role="alert">{state.error}</p>}
<button type="submit" disabled={busy || !token} className="rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum disabled:opacity-60">{busy ? "Wird gesendet …" : "Anmeldung senden"}</button>
</form>
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function Hero({ eyebrow, title, children, actions, mood = "rose", imageSr
<div className="relative mx-auto aspect-[4/5] w-full max-w-md animate-fade-in [animation-delay:200ms] lg:max-w-none">
<ImagePlaceholder
mood={mood}
label="Anouma — warme, naturverbundene Begleitung"
label="Anouma - warme, naturverbundene Begleitung"
src={imageSrc}
alt={imageAlt}
priority
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ImageMood } from "@/lib/angebote";
/**
* Renders a photo when `src` is provided, otherwise falls back to a warm,
* organic gradient placeholder in the given mood. Pages should always pass
* `src` once real photography exists — no other prop changes are needed.
* `src` once real photography exists - no other prop changes are needed.
*/
const moodGradients: Record<ImageMood, string> = {
+58 -49
View File
@@ -6,6 +6,8 @@ import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { groupOffersByCategory } from "@/lib/angebote";
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
import { track } from "@/lib/analytics/client";
import { OrganicBlob } from "@/components/OrganicBlob";
import type { Offer } from "@/payload-types";
export function Navbar({ offers, customerName }: { offers: Offer[]; customerName?: string | null }) {
@@ -63,11 +65,11 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
}
return (
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background/85 backdrop-blur-md">
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background">
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
<Link
href="/"
className="font-serif text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
className="font-antropos text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
>
{siteConfig.name.toUpperCase()}
</Link>
@@ -108,52 +110,55 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-xl shadow-anouma-plum/10"
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 overflow-hidden rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-lg shadow-anouma-plum/10"
>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category}>
<Link
href={groupHref}
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="mt-3 space-y-2">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
{isSingle && (
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
{group.offers[0].shortDescription}
</p>
)}
</div>
);
})}
</div>
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
<Link
href="/angebote"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Angebote im Überblick
</Link>
<OrganicBlob tone="cream" className="-right-16 -top-16 h-52 w-52 opacity-30" />
<div className="relative">
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category}>
<Link
href={groupHref}
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="mt-3 space-y-2">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
{isSingle && (
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
{group.offers[0].shortDescription}
</p>
)}
</div>
);
})}
</div>
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
<Link
href="/angebote"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Angebote im Überblick
</Link>
</div>
</div>
</motion.div>
)}
@@ -163,8 +168,9 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
<Link
key={item.href}
href={item.href}
className={`text-[15px] font-medium transition-colors hover:text-anouma-mauve-dark ${
pathname === item.href ? "text-anouma-mauve-dark" : "text-anouma-plum/90"
onClick={() => track("navigation_clicked", { path: item.href })}
className={`relative pb-1 text-[15px] font-medium transition-colors after:absolute after:bottom-0 after:left-0 after:h-px after:bg-anouma-mauve-dark after:transition-all after:duration-300 after:content-[''] hover:text-anouma-mauve-dark hover:after:w-full ${
pathname === item.href ? "text-anouma-mauve-dark after:w-full" : "text-anouma-plum/90 after:w-0"
}`}
>
{item.title}
@@ -182,6 +188,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
</Link>
<Link
href={ctaNav.href}
onClick={() => track("navigation_clicked", { path: ctaNav.href })}
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
>
{ctaNav.title}
@@ -300,6 +307,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
<Link
key={item.href}
href={item.href}
onClick={() => track("navigation_clicked", { path: item.href })}
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
>
{item.title}
@@ -314,6 +322,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
</Link>
<Link
href={ctaNav.href}
onClick={() => track("navigation_clicked", { path: ctaNav.href })}
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
>
{ctaNav.title}
+23 -4
View File
@@ -9,10 +9,23 @@ type PageHeaderProps = {
title: string;
lead?: ReactNode;
crumbs?: Crumb[];
/** Varies the header's decorative blob so every interior page doesn't open with the exact same rose-top-right treatment. */
blobTone?: "rose" | "sage" | "cream" | "mauve";
blobPosition?: "top-right" | "top-left";
/** Shorter vertical padding for pages that don't need much ceremony (e.g. Kontakt). */
compact?: boolean;
};
/** Renders the visual breadcrumb trail AND its matching BreadcrumbList JSON-LD from the same `crumbs` — every page that already passes `crumbs` gets structured breadcrumb data for free. */
export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) {
/** Renders the visual breadcrumb trail AND its matching BreadcrumbList JSON-LD from the same `crumbs` - every page that already passes `crumbs` gets structured breadcrumb data for free. */
export async function PageHeader({
eyebrow,
title,
lead,
crumbs,
blobTone = "rose",
blobPosition = "top-right",
compact = false,
}: PageHeaderProps) {
const breadcrumbData = crumbs?.length
? await (async () => {
const siteUrl = await getSiteUrl();
@@ -24,9 +37,15 @@ export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderPro
})()
: null;
const blobPositionClass = blobPosition === "top-left" ? "-left-20 -top-20" : "-right-20 -top-20";
return (
<section className="relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light pb-16 pt-14 sm:pb-20 sm:pt-20">
<OrganicBlob tone="rose" className="-right-20 -top-20 h-72 w-72" />
<section
className={`relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light ${
compact ? "pb-10 pt-10 sm:pb-12 sm:pt-12" : "pb-16 pt-14 sm:pb-20 sm:pt-20"
}`}
>
<OrganicBlob tone={blobTone} className={`${blobPositionClass} h-72 w-72`} />
<div className="relative mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
{breadcrumbData && <JsonLd data={breadcrumbData} />}
{crumbs && <Breadcrumbs items={crumbs} />}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
export function PlaceholderNote({ children }: { children: ReactNode }) {
return (
<div className="rounded-2xl border border-dashed border-anouma-dustyrose/50 bg-anouma-rose-pale/20 px-5 py-4 text-sm leading-relaxed text-anouma-plum">
<span className="font-medium">Platzhalter — noch zu ergänzen:</span> {children}
<span className="font-medium">Platzhalter - noch zu ergänzen:</span> {children}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { loadTurnstile, type TurnstileWidgetId } from "@/lib/turnstile/client";
type TurnstileWidgetProps = {
siteKey: string;
action: string;
resetKey: number;
onTokenChange: (token: string | null) => void;
};
export function TurnstileWidget({ siteKey, action, resetKey, onTokenChange }: TurnstileWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<TurnstileWidgetId | null>(null);
const onTokenChangeRef = useRef(onTokenChange);
const [loadError, setLoadError] = useState(false);
const [verificationStatus, setVerificationStatus] = useState<"idle" | "ready" | "expired">("idle");
useEffect(() => {
onTokenChangeRef.current = onTokenChange;
}, [onTokenChange]);
useEffect(() => {
if (resetKey === 0) return;
if (widgetIdRef.current !== null && window.turnstile) {
window.turnstile.reset(widgetIdRef.current);
}
onTokenChangeRef.current(null);
}, [resetKey]);
useEffect(() => {
if (!siteKey || !containerRef.current || widgetIdRef.current !== null) return;
let cancelled = false;
loadTurnstile()
.then((turnstile) => {
if (cancelled || !containerRef.current || widgetIdRef.current !== null) return;
try {
widgetIdRef.current = turnstile.render(containerRef.current, {
sitekey: siteKey,
action,
callback: (token) => {
setVerificationStatus("ready");
onTokenChangeRef.current(token);
},
"expired-callback": () => {
setVerificationStatus("expired");
onTokenChangeRef.current(null);
},
"error-callback": () => {
onTokenChangeRef.current(null);
setLoadError(true);
},
});
} catch {
setLoadError(true);
onTokenChangeRef.current(null);
}
})
.catch(() => {
if (cancelled) return;
setLoadError(true);
onTokenChangeRef.current(null);
});
return () => {
cancelled = true;
};
}, [action, siteKey]);
return (
<div className="space-y-2" aria-live="polite">
<div ref={containerRef} data-turnstile-container="true" />
{!siteKey || loadError ? (
<p className="text-sm text-red-700" role="alert">
Die Sicherheitsprüfung ist momentan nicht verfügbar. Bitte lade die Seite neu und versuche es erneut.
</p>
) : verificationStatus === "expired" ? (
<p className="text-sm text-red-700" role="alert">
Die Sicherheitsprüfung ist abgelaufen. Bitte bestätige sie erneut.
</p>
) : null}
</div>
);
}
@@ -0,0 +1,188 @@
.page {
padding: 24px;
max-width: 1100px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1f1f1f;
}
.title {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.subtitle {
color: #666;
font-size: 14px;
margin: 0 0 24px;
}
.filterBar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 24px;
}
.rangeLink {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 999px;
border: 1px solid #e2e2e2;
color: #333;
text-decoration: none;
font-size: 13px;
font-weight: 500;
}
.rangeLink:hover {
background: #f5f5f5;
}
.rangeLinkActive {
background: #8b616d;
border-color: #8b616d;
color: #fff;
}
.customForm {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #555;
}
.customForm input[type="date"] {
border: 1px solid #e2e2e2;
border-radius: 6px;
padding: 5px 8px;
font-size: 13px;
}
.customForm button {
border: 1px solid #e2e2e2;
border-radius: 999px;
padding: 6px 14px;
font-size: 13px;
background: #fff;
cursor: pointer;
}
.compareToggle {
margin-left: auto;
font-size: 13px;
}
.compareToggle a {
color: #8b616d;
text-decoration: none;
}
.compareToggle a:hover {
text-decoration: underline;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 28px;
}
.tile {
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 14px 16px;
background: #fafafa;
}
.tileLabel {
font-size: 12px;
color: #888;
margin: 0 0 6px;
}
.tileValue {
font-size: 24px;
font-weight: 700;
margin: 0;
}
.tileDelta {
font-size: 12px;
font-weight: 600;
margin-top: 4px;
}
.deltaUp {
color: #3a4f30;
}
.deltaDown {
color: #7a2020;
}
.deltaFlat {
color: #888;
}
.chartCard {
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 18px;
margin-bottom: 28px;
}
.sectionTitle {
font-size: 15px;
font-weight: 600;
margin: 0 0 12px;
}
.tableGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-bottom: 28px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
text-align: left;
font-size: 11px;
text-transform: uppercase;
color: #888;
padding: 4px 8px;
border-bottom: 1px solid #e6e6e6;
}
.table td {
padding: 6px 8px;
border-bottom: 1px solid #f0f0f0;
}
.table tr:last-child td {
border-bottom: none;
}
.tableEmpty {
color: #888;
font-size: 13px;
padding: 8px;
}
.privacyPanel {
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 18px;
background: #f7f4f1;
font-size: 13px;
color: #444;
}
.privacyPanel h3 {
margin: 0 0 8px;
font-size: 14px;
color: #1f1f1f;
}
.privacyPanel ul {
margin: 8px 0;
padding-left: 18px;
}
.privacyPanel p {
margin: 6px 0;
}
+199
View File
@@ -0,0 +1,199 @@
import type { AdminViewServerProps } from "payload";
import Link from "next/link";
import { canViewAnalytics } from "@/access";
import { RANGE_OPTIONS, resolveRange } from "@/lib/analytics/range";
import { getDashboardComparison, percentDelta, type DashboardStats } from "@/lib/analytics/dashboardQueries";
import { topEntries } from "@/lib/analytics/aggregate";
import { analyticsEventLabel } from "@/lib/analytics/events";
import { AnalyticsChart } from "./AnalyticsChart";
import styles from "./AdminAnalyticsView.module.css";
const DEVICE_LABELS: Record<string, string> = { mobile: "Handy", desktop: "Computer", tablet: "Tablet" };
const BROWSER_LABELS: Record<string, string> = { chrome: "Chrome", firefox: "Firefox", safari: "Safari", edge: "Edge", other: "Sonstige" };
function fmtInt(n: number): string {
return n.toLocaleString("de-DE");
}
function DeltaBadge({ delta }: { delta: number | null }) {
if (delta === null) return null;
const cls = delta > 0 ? styles.deltaUp : delta < 0 ? styles.deltaDown : styles.deltaFlat;
const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
return (
<p className={`${styles.tileDelta} ${cls}`}>
{arrow} {Math.abs(delta).toLocaleString("de-DE")}%
</p>
);
}
function Tile({ label, value, delta }: { label: string; value: number; delta: number | null | undefined }) {
return (
<div className={styles.tile}>
<p className={styles.tileLabel}>{label}</p>
<p className={styles.tileValue}>{fmtInt(value)}</p>
{delta !== undefined && <DeltaBadge delta={delta} />}
</div>
);
}
function EntryTable({ title, entries }: { title: string; entries: [string, number][] }) {
return (
<div>
<h2 className={styles.sectionTitle}>{title}</h2>
{entries.length === 0 ? (
<p className={styles.tableEmpty}>Keine Daten in diesem Zeitraum.</p>
) : (
<table className={styles.table}>
<tbody>
{entries.map(([key, count]) => (
<tr key={key}>
<td>{key}</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{fmtInt(count)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function buildHref(base: URLSearchParams, overrides: Record<string, string | null>): string {
const params = new URLSearchParams(base);
for (const [key, value] of Object.entries(overrides)) {
if (value === null) params.delete(key);
else params.set(key, value);
}
const qs = params.toString();
return qs ? `?${qs}` : "?";
}
export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProps) {
const req = initPageResult.req;
if (!canViewAnalytics({ req })) {
return (
<div className={styles.page}>
<Link href="/admin" className="anouma-admin-back">Zur Übersicht</Link>
<h1 className={styles.title}>Auswertung</h1>
<p className={styles.subtitle}>Du hast keine Berechtigung, diesen Bereich zu sehen.</p>
</div>
);
}
const settings = await req.payload.findGlobal({ slug: "analytics-settings" });
const maxDaysBack = settings.rawRetentionDays ?? 90;
const searchParams = req.searchParams;
const compareEnabled = searchParams.get("compare") === "1";
const { key: activeRange, range, compareRange } = resolveRange(searchParams, maxDaysBack);
const { current, previous }: { current: DashboardStats; previous: DashboardStats } = await getDashboardComparison(
req.payload,
range,
compareRange,
);
const delta = (metric: keyof Pick<DashboardStats, "sessions" | "pageViews" | "uniqueVisitors" | "avgPagesPerSession">) =>
compareEnabled ? percentDelta(current[metric], previous[metric]) : undefined;
const eventDelta = (name: string) =>
compareEnabled ? percentDelta(current.eventCounts[name] ?? 0, previous.eventCounts[name] ?? 0) : undefined;
const fromParam = range.from.toISOString().slice(0, 10);
const toParam = range.to.toISOString().slice(0, 10);
return (
<div className={styles.page}>
<Link href="/admin" className="anouma-admin-back">Zur Übersicht</Link>
<h1 className={styles.title}>Auswertung</h1>
<p className={styles.subtitle}>Datensparsame Reichweitenmessung ohne personenbezogene Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
<div className={styles.filterBar}>
{RANGE_OPTIONS.map((option) => (
<a
key={option.key}
href={buildHref(searchParams, { range: option.key, from: null, to: null })}
className={`${styles.rangeLink} ${activeRange === option.key ? styles.rangeLinkActive : ""}`}
>
{option.label}
</a>
))}
<form method="get" className={styles.customForm}>
<input type="hidden" name="range" value="custom" />
{compareEnabled && <input type="hidden" name="compare" value="1" />}
<input type="date" name="from" defaultValue={fromParam} max={toParam} />
<span>bis</span>
<input type="date" name="to" defaultValue={toParam} max={new Date().toISOString().slice(0, 10)} />
<button type="submit">Anwenden</button>
</form>
<div className={styles.compareToggle}>
<a href={buildHref(searchParams, { compare: compareEnabled ? null : "1" })}>
{compareEnabled ? "Vergleich ausblenden" : "Mit Vorperiode vergleichen"}
</a>
</div>
</div>
{RANGE_OPTIONS.some((o) => typeof o.days === "number" && o.days > maxDaysBack) && (
<p style={{ color: "#888", fontSize: 12, marginTop: -16, marginBottom: 20 }}>
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Auswertungs-Einstellungen“).
</p>
)}
<div className={styles.tiles}>
<Tile label="Besuche" value={current.sessions} delta={delta("sessions")} />
<Tile label="Seitenaufrufe" value={current.pageViews} delta={delta("pageViews")} />
<Tile label="Eindeutige Besucher" value={current.uniqueVisitors} delta={delta("uniqueVisitors")} />
<Tile label="Ø Seiten / Session" value={current.avgPagesPerSession} delta={delta("avgPagesPerSession")} />
<Tile label="Buchungsstarts" value={current.eventCounts.booking_started ?? 0} delta={eventDelta("booking_started")} />
<Tile label="Buchungen abgeschlossen" value={current.eventCounts.booking_completed ?? 0} delta={eventDelta("booking_completed")} />
</div>
<div className={styles.chartCard}>
<h2 className={styles.sectionTitle}>Seitenaufrufe und Besuche</h2>
<AnalyticsChart series={current.series} />
</div>
<div className={styles.tableGrid}>
<EntryTable title="Beliebteste Seiten" entries={topEntries(current.topPaths)} />
<EntryTable title="Einstiegsseiten" entries={topEntries(current.entryPaths)} />
<EntryTable title="Ausstiegsseiten" entries={topEntries(current.exitPaths)} />
<EntryTable
title="Ereignisse"
entries={topEntries(current.eventCounts).map(([name, count]) => [analyticsEventLabel(name), count])}
/>
<EntryTable
title="Geräte"
entries={topEntries(current.deviceCounts).map(([key, count]) => [DEVICE_LABELS[key] ?? key, count])}
/>
<EntryTable
title="Browser"
entries={topEntries(current.browserCounts).map(([key, count]) => [BROWSER_LABELS[key] ?? key, count])}
/>
</div>
<div className={styles.privacyPanel}>
<h3>Auswertung und Datenschutz</h3>
<p>Erhobene Daten:</p>
<ul>
<li>Seitenaufrufe und Ereignisse (siehe Tabelle „Ereignisse“)</li>
<li>Gerätetyp (Handy/Computer/Tablet)</li>
<li>Browser-Familie</li>
<li>Referrer-Hostname (nur die Domain, keine vollständige URL)</li>
</ul>
<p>
Keine Namen, E-Mail-Adressen, Telefonnummern, vollständigen IP-Adressen oder Formularinhalte. Kein Fingerprinting, keine
dauerhaften individuellen Nutzerprofile - Besucher werden über einen täglich rotierenden, nicht zurückverfolgbaren Hash
gruppiert.
</p>
<p>
<strong>Aufbewahrung:</strong> {settings.rawRetentionDays} Tage für Rohdaten, {settings.aggregateRetentionMonths} Monate für
aggregierte Tagesstatistiken (konfigurierbar unter „Auswertungs-Einstellungen“).
</p>
<p style={{ fontStyle: "italic", color: "#777" }}>
Rechtliche Einordnung (z. B. ob dies für eure Website ausreichend ist) ist keine technische Aussage dieses Panels - bitte im
Zweifel rechtlich prüfen lassen, wie auch beim Impressum/Datenschutztext der Website.
</p>
</div>
</div>
);
}
@@ -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;

Some files were not shown because too many files have changed in this diff Show More