feat: redesign CMS and unify German admin UI

This commit is contained in:
2026-09-17 16:51:24 +02:00
parent a2b3e99174
commit 7ec0c51ff5
129 changed files with 952 additions and 550 deletions
+8 -8
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
@@ -40,11 +40,11 @@ CRON_SECRET=replace-with-a-long-random-secret
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# 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=
@@ -79,7 +79,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) ─────────────────────────────────────────
@@ -87,7 +87,7 @@ NPM_NETWORK=
BACKUP_RETENTION_DAYS=14
# ── Container registry & deployment (used by deploy.sh) ────────────────
# GitHub Container Registry. deploy.sh builds nothing — it only ever pulls
# 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
@@ -96,10 +96,10 @@ REGISTRY_REPO=whitehai11/anouma
# 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
# 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
+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 -->
+26 -26
View File
@@ -1,6 +1,6 @@
# ANOUMA — Deployment
# ANOUMA - Deployment
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.
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 main → GitHub Actions (CI)
@@ -9,7 +9,7 @@ Developer → git push main → GitHub Actions (CI)
→ SSH → deploy.sh → docker compose pull → migrate → up -d → Healthcheck → LIVE
```
Building and testing happen exactly once, on `main` — tagging a release never
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
@@ -27,18 +27,18 @@ 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 **GitHub → whitehai11/anouma → Settings → Secrets and variables → 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 GitHub secrets
@@ -50,19 +50,19 @@ Copy the deployment values `setup.sh` printed into **GitHub → whitehai11/anoum
| `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) |
| `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 github v1.0.0 # GitHub Actions
```
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`.
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`.
## GitHub Actions
@@ -91,7 +91,7 @@ Release-Workflows ausführen.
## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`)
Not a manual tool — it's invoked exclusively by GitHub 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)
@@ -105,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 — 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.
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:
@@ -137,7 +137,7 @@ 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 LiveKit signal connection |
| **Websockets Support** | **enabled** - required for the LiveKit signal connection |
Für LiveKit legst du zusätzlich einen zweiten Proxy Host an:
@@ -163,7 +163,7 @@ Client C ── WebRTC ──┘ │
```
`docker-compose.yml` startet LiveKit mit Host-Networking, dem UDP-Medienbereich
`50000–60000`, dem TCP-Fallback `7881` und eingebettetem TURN/UDP auf `3478`.
`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.
@@ -196,30 +196,30 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file
| `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 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. The GHCR token is needed only on the production server for pulling the private image; GitHub Actions uses its automatic `GITHUB_TOKEN`.
@@ -228,10 +228,10 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu
| Symptom | Check |
| --- | --- |
| `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` (from `/opt/anouma`) — usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` |
| Release workflow fails at "Pull the tested image" | The tagged commit was never pushed to `main`, or `main`'s CI/build-image run for it hasn't finished yet — push to `main`, wait for CI to go green, then tag |
| `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 | Check LiveKit logs, the UDP 3478/50000–60000 firewall rules and the public `LIVEKIT_URL` |
| 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 |
+4 -4
View File
@@ -1,8 +1,8 @@
# 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
# 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
@@ -32,12 +32,12 @@ 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
+20 -20
View File
@@ -1,20 +1,20 @@
# 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 selbst gehostetem [LiveKit](https://livekit.io) SFU-Video-Call, 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, 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
- `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`).
@@ -47,11 +47,11 @@ 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 GitHub Actions CI/CD — der Server baut nichts selbst, er zieht ein fertiges, getestetes Image aus GitHub Container 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://github.com/whitehai11/anouma.git
@@ -68,7 +68,7 @@ Jedes weitere Deployment läuft danach ausschließlich über einen Git-Tag:
git tag v1.2.0 && git push origin v1.2.0
```
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.
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, GitHub Secrets, CI/Release-Workflows, Reverse-Proxy- und LiveKit-Einrichtung, Environment-Variablen, Backups, Rollback, Security) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
@@ -88,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
@@ -97,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
@@ -105,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
@@ -125,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
+7 -7
View File
@@ -1,4 +1,4 @@
# Security-Audit — 17.09.2026
# 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.
@@ -8,7 +8,7 @@ Quellcodeprüfung von Payload-Zugriffsregeln, Kunden-/Admin-Authentifizierung, B
`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
## 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`.
@@ -24,7 +24,7 @@ Die Kontoseiten übergeben das gesamte Ergebnis als `booking` an die Client Comp
**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
## 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`.
@@ -36,7 +36,7 @@ Die Stornierung setzt nur den Buchungsstatus. Der verknüpfte Online-Event und s
**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
## 3. P2 - Token-Austausch umgeht das Meeting-Ende
**Stelle:** `app/(payload)/api/meetings/[slug]/connect/route.ts:27`.
@@ -48,7 +48,7 @@ Die Stornierung setzt nur den Buchungsstatus. Der verknüpfte Online-Event und s
**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
## 4. P2 - Krankheits- und Urlaubsgründe sind öffentlich abrufbar
**Stelle:** `collections/AvailabilityOverrides.ts:17`.
@@ -60,7 +60,7 @@ Die Collection erlaubt anonymes Lesen aller Dokumente. Das Feld `reason` enthäl
**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
## 5. P2 - Registrierung und E-Mail-Wechsel umgehen die Versandlimits
**Stellen:** `collections/Customers.ts:84`, `collections/Customers.ts:46`.
@@ -72,7 +72,7 @@ Die öffentliche Kundenerstellung löst unmittelbar eine Bestätigungs-E-Mail au
**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
## 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`.
+2 -2
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
@@ -15,7 +15,7 @@ export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
/**
* Admins always see Analytics; any other "users" account additionally needs
* its own `canViewAnalytics` flag (see collections/Users.ts) — this is the
* 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
+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,
+2 -2
View File
@@ -45,7 +45,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,
@@ -133,7 +133,7 @@ export default async function OfferDetailPage({ params }: Args) {
{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>
<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>}
+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."
/>
</>
);
+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
+3 -3
View File
@@ -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>
+5 -1
View File
@@ -2,7 +2,7 @@
@font-face {
font-family: "Antropos";
src: url("/fonts/antropos.ttf") format("truetype");
src: url("/fonts/baar-antropos.ttf") format("truetype");
font-display: swap;
font-style: normal;
font-weight: 100 900;
@@ -80,6 +80,10 @@ body {
font-family: "Antropos", ui-sans-serif, system-ui, sans-serif;
}
.font-antropos {
font-family: "Antropos", ui-serif, Georgia, serif;
}
::selection {
background: var(--color-anouma-rose-soft);
color: var(--color-anouma-plum);
+2 -2
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,7 +36,7 @@ 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">
+6 -6
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` },
@@ -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>
+1 -1
View File
@@ -19,7 +19,7 @@ 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,
});
+1 -1
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 = [
+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>
+2 -2
View File
@@ -31,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: {
@@ -47,7 +47,7 @@ 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 } } : {}),
};
+7 -7
View File
@@ -27,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,
});
@@ -42,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 } };
}
@@ -93,10 +93,10 @@ 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 space-y-6">
{/* Prozessbegleitung ist das Kernangebot – bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
{/* Prozessbegleitung ist das Kernangebot - bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
{featuredGroup && (
<Reveal>
<AngebotCard
@@ -263,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."
}
/>
</>
+2 -2
View File
@@ -22,7 +22,7 @@ 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,
});
@@ -115,7 +115,7 @@ 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"
@@ -30,7 +30,7 @@ export default async function BeitretenPage({ params }: Args) {
<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">
+2 -2
View File
@@ -47,7 +47,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,7 +74,7 @@ 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" && user.role === "admin";
const settings = await payload.findGlobal({ slug: "meeting-settings" });
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function TerminePage() {
<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.
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>
+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,
+2
View File
@@ -24,6 +24,7 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0
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 { 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'
@@ -57,6 +58,7 @@ export const importMap = {
"/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251,
"/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962,
"/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181,
"/components/admin/AdminWelcome#AdminWelcome": AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6,
"/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a,
"/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0,
"/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055,
@@ -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) {
+2 -2
View File
@@ -23,7 +23,7 @@ function normalizeEventName(value: unknown): string | null {
return trimmed;
}
/** Hostname only (never path/query) — see collections/AnalyticsEvents.ts. */
/** Hostname only (never path/query) - see collections/AnalyticsEvents.ts. */
function referrerHostFrom(referrer: unknown): string | undefined {
if (typeof referrer !== "string" || !referrer) return undefined;
try {
@@ -35,7 +35,7 @@ function referrerHostFrom(referrer: unknown): string | 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
* 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.
@@ -11,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,
@@ -60,7 +60,7 @@ export async function POST(request: Request) {
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 || email.length > 254) {
@@ -38,7 +38,7 @@ export async function POST(request: Request, { params }: Args) {
}
// The beforeChange hook on booking-requests re-checks for conflicts
// (with an advisory lock) before allowing this to become "confirmed" —
// (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({
@@ -16,7 +16,7 @@ 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.
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
+1 -1
View File
@@ -67,7 +67,7 @@ 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 dayBounds = berlinDayBounds(start);
return await withBookingTransaction(payload, dayBounds.key, async (req) => {
@@ -16,7 +16,7 @@ function safeEqual(a: string, b: string): boolean {
/**
* 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
* (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) {
+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 {
@@ -57,7 +57,7 @@ export async function POST(request: Request, { params }: Args) {
}
const { user } = await payload.auth({ headers: request.headers });
// Must check the collection, not just presence — a logged-in customer
// Must check the collection, not just presence - a logged-in customer
// (collection "customers") must never be treated as host.
const isHost = user?.collection === "users" && user.role === "admin";
@@ -81,7 +81,7 @@ export async function POST(request: Request, { params }: Args) {
role: isHost ? "host" : "participant",
accessVersion: access.accessVersion,
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
// Only meaningful for participants — a host authenticates via a real
// Only meaningful for participants - a host authenticates via a real
// session already, so there's no need to anchor a kick to it (and
// hosts can't be kicked in the first place.
clientId: isHost ? undefined : clientId,
+383 -3
View File
@@ -1,3 +1,383 @@
/* 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;
}
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);
}
/* 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);
}
.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 {
color: var(--anouma-ink);
font-size: clamp(26px, 3vw, 38px);
font-weight: 600;
letter-spacing: -0.025em;
}
.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 {
--bg-color: var(--anouma-mauve);
--hover-bg: var(--anouma-plum);
}
input,
textarea,
select,
.rs__control {
border-color: #d7c9c1;
border-radius: 4px;
}
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;
}
.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;
}
.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()]);
+5 -4
View File
@@ -5,21 +5,22 @@ 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
* 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
* 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: "Analytics-Tagesstatistik",
plural: "Analytics-Tagesstatistiken",
singular: "Tagesstatistik",
plural: "Tagesstatistiken",
},
admin: {
group: "Auswertung",
hidden: true,
},
access: {
+8 -7
View File
@@ -3,25 +3,26 @@ 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
* 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
* 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: "Analytics-Event",
plural: "Analytics-Events",
singular: "Messereignis",
plural: "Messereignisse",
},
admin: {
// High-volume, low-signal raw data — not meant for manual browsing.
// The Analytics dashboard (a custom admin view) reads it programmatically.
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,
@@ -42,7 +43,7 @@ export const AnalyticsEvents: CollectionConfig = {
name: "visitorHash",
type: "text",
required: true,
// Daily-rotating pseudonymous hash (see lib/analytics/salt.ts) — never
// Daily-rotating pseudonymous hash (see lib/analytics/salt.ts) - never
// a raw IP/UA, never stable across days, never exposed to the client.
},
{
+1 -1
View File
@@ -11,7 +11,7 @@ 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: isAdmin,
+4 -4
View File
@@ -183,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 });
@@ -198,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,
@@ -260,12 +260,12 @@ export const BookingRequests: CollectionConfig = {
useAsTitle: "id",
defaultColumns: ["offer", "user", "date", "startTime", "status"],
group: "Buchungen",
description: "Terminanfragen von Nutzern — 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,
+3 -2
View File
@@ -48,7 +48,8 @@ export const ConversationMessages: CollectionConfig = {
useAsTitle: "body",
defaultColumns: ["conversation", "senderType", "senderName", "createdAt"],
group: "Kommunikation",
description: "Einzelne Nachrichten innerhalb einer Anfrage. Antworten aus dem CMS werden automatisch per E-Mail verschickt.",
description: "Einzelne Nachrichten innerhalb einer Anfrage. Antworten aus der Verwaltung werden automatisch per E-Mail verschickt.",
hidden: true,
},
access: {
read: isAdmin,
@@ -79,7 +80,7 @@ export const ConversationMessages: CollectionConfig = {
{ label: "Anfragende Person", value: "visitor" },
{ label: "Anouma", value: "admin" },
],
admin: { description: "Für Antworten im CMS „Anouma“ auswählen. Eingehende E-Mails werden automatisch als Anfragende Person gespeichert." },
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" },
+6 -6
View File
@@ -8,7 +8,7 @@ import { sendEmail } from "@/lib/email/sendBookingEmails";
// 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.
// 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;
@@ -21,12 +21,12 @@ const isSelfOrAdmin: typeof isAdmin = (args) => {
// 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
// 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
// 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 }) => {
@@ -110,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.",
},
},
{
@@ -118,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 },
@@ -130,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 },
+3 -3
View File
@@ -11,7 +11,7 @@ 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: {
// Public registrations must go through /api/meetings/[slug]/register,
@@ -65,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%" },
},
+6 -6
View File
@@ -18,7 +18,7 @@ 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.
// 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) {
@@ -38,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: {
@@ -149,7 +149,7 @@ export const Events: CollectionConfig = {
},
{
type: "collapsible",
label: "Online-Termin (Video-Call)",
label: "Online-Termin (Videoanruf)",
fields: [
{
name: "isOnline",
@@ -166,7 +166,7 @@ export const Events: CollectionConfig = {
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
// 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.
@@ -220,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%" },
},
+3 -2
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 ist für Barrierefreiheit und SEO Pflicht.",
},
access: {
// Images are public so the website can display them without a login.
@@ -26,7 +27,7 @@ export const Media: CollectionConfig = {
staticDir: path.resolve(dirname, "..", "media"),
// 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.
// <img>) - not currently needed by any part of the site.
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
imageSizes: [
{ name: "thumbnail", width: 400, position: "centre" },
+2 -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: {
+4 -4
View File
@@ -3,7 +3,7 @@ import { isAdmin } from "@/access";
function normalizePath(value: string): string {
// Lowercased to match the slugs it points at/from (fields/slug.ts always
// lowercases via slugify) — without this, "/Angebote/Doula" and
// 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();
@@ -14,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.
*/
@@ -66,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" },
],
},
{
+6 -5
View File
@@ -10,12 +10,13 @@ export const Users: CollectionConfig = {
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,
},
@@ -48,24 +49,24 @@ export const Users: CollectionConfig = {
{
name: "canViewAnalytics",
type: "checkbox",
label: "Analytics sichtbar",
label: "Auswertung sichtbar",
defaultValue: true,
// Admins always have access regardless of this flag (see
// access/index.ts's canViewAnalytics) — this only matters once a
// 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 das Analytics-Dashboard im Admin-Bereich sehen kann.",
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 },
+1 -1
View File
@@ -22,7 +22,7 @@ const buttonBase =
* 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
* 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) {
+1 -1
View File
@@ -1,4 +1,4 @@
/** 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`. */
/** 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
+1 -1
View File
@@ -27,7 +27,7 @@ type ArrowLinkProps = {
/**
* 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
* 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) {
+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:
+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>
+2 -2
View File
@@ -3,7 +3,7 @@ import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "rea
/**
* 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).
* 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
@@ -23,7 +23,7 @@ function FieldLabel({ label, htmlFor, hidden = false }: { label: string; htmlFor
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). */
/** 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;
};
+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> = {
+1 -1
View File
@@ -69,7 +69,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
<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>
+1 -1
View File
@@ -16,7 +16,7 @@ type PageHeaderProps = {
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. */
/** 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,
+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>
);
}
+14 -14
View File
@@ -7,7 +7,7 @@ import { analyticsEventLabel } from "@/lib/analytics/events";
import { AnalyticsChart } from "./AnalyticsChart";
import styles from "./AdminAnalyticsView.module.css";
const DEVICE_LABELS: Record<string, string> = { mobile: "Mobile", desktop: "Desktop", tablet: "Tablet" };
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 {
@@ -73,7 +73,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
if (!canViewAnalytics({ req })) {
return (
<div className={styles.page}>
<h1 className={styles.title}>Analytics</h1>
<h1 className={styles.title}>Auswertung</h1>
<p className={styles.subtitle}>Du hast keine Berechtigung, diesen Bereich zu sehen.</p>
</div>
);
@@ -102,8 +102,8 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
return (
<div className={styles.page}>
<h1 className={styles.title}>Analytics</h1>
<p className={styles.subtitle}>Datensparsame Reichweitenmessung — keine personenbezogenen Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
<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) => (
@@ -132,12 +132,12 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
{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 „Analytics-Einstellungen“).
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Auswertungs-Einstellungen“).
</p>
)}
<div className={styles.tiles}>
<Tile label="Sessions" value={current.sessions} delta={delta("sessions")} />
<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")} />
@@ -146,7 +146,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
</div>
<div className={styles.chartCard}>
<h2 className={styles.sectionTitle}>Seitenaufrufe &amp; Sessions</h2>
<h2 className={styles.sectionTitle}>Seitenaufrufe und Besuche</h2>
<AnalyticsChart series={current.series} />
</div>
@@ -155,7 +155,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
<EntryTable title="Einstiegsseiten" entries={topEntries(current.entryPaths)} />
<EntryTable title="Ausstiegsseiten" entries={topEntries(current.exitPaths)} />
<EntryTable
title="Events"
title="Ereignisse"
entries={topEntries(current.eventCounts).map(([name, count]) => [analyticsEventLabel(name), count])}
/>
<EntryTable
@@ -169,25 +169,25 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
</div>
<div className={styles.privacyPanel}>
<h3>Analytics &amp; Datenschutz</h3>
<h3>Auswertung und Datenschutz</h3>
<p>Erhobene Daten:</p>
<ul>
<li>Seitenaufrufe und Events (siehe „Events“-Tabelle)</li>
<li>Gerätetyp (Mobile/Desktop/Tablet)</li>
<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
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 „Analytics-Einstellungen“).
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
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>
+2 -2
View File
@@ -71,7 +71,7 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
// Local API (overrideAccess: true by default) so this can see
// calendarFeedTokenHash despite its field-level access being locked to
// "nobody" for every real API path — see collections/Users.ts.
// "nobody" for every real API path - see collections/Users.ts.
const currentUser = req.user ? await req.payload.findByID({ collection: "users", id: req.user.id, req }) : null;
const calendarFeedConfigured = Boolean((currentUser as { calendarFeedTokenHash?: string | null } | null)?.calendarFeedTokenHash);
const itemsByDay = new Map<string, AdminCalendarItem[]>();
@@ -181,7 +181,7 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
</span>
<strong>{entry.title}</strong>
<span>
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
{fmtTime(entry.startTime)}-{fmtTime(entry.endTime)}
</span>
{entry.subtitle && <span>· {entry.subtitle}</span>}
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
+2 -2
View File
@@ -21,7 +21,7 @@ export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
setError(data.error || "Der Video-Call konnte nicht gestartet werden.");
setError(data.error || "Der Videoanruf konnte nicht gestartet werden.");
return;
}
setUrl(data.testUrl);
@@ -46,7 +46,7 @@ export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
disabled={loading}
className="rounded-full bg-anouma-mauve-dark px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-anouma-plum disabled:cursor-wait disabled:opacity-60"
>
{loading ? "Test-Link wird erzeugt …" : copied ? "Test-Link kopiert" : "Call testen"}
{loading ? "Test-Link wird erzeugt ..." : copied ? "Test-Link kopiert" : "Videoanruf testen"}
</button>
{url && !copied && (
<input
+6 -17
View File
@@ -19,7 +19,7 @@ export function AdminTestCallButton() {
});
const data = (await response.json().catch(() => ({}))) as { testUrl?: string; error?: string };
if (!response.ok || !data.testUrl) {
setError(data.error || "Der Test-Call konnte nicht gestartet werden.");
setError(data.error || "Der Test-Videoanruf konnte nicht gestartet werden.");
return;
}
@@ -38,25 +38,14 @@ export function AdminTestCallButton() {
}
return (
<div style={{ padding: "0 8px 8px" }}>
<div className="anouma-custom-nav-action">
<button
type="button"
onClick={() => void createTestCall()}
disabled={loading}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
color: "#fff",
background: "#8b616d",
border: "1px solid #8b616d",
cursor: loading ? "wait" : "pointer",
opacity: loading ? 0.7 : 1,
}}
className="anouma-custom-nav-button"
>
{loading ? "Test-Link wird erzeugt …" : copied ? "✓ Test-Link kopiert" : "▶ Test-Videoanruf"}
{loading ? "Test-Link wird erzeugt ..." : copied ? "Test-Link kopiert" : "Videoanruf testen"}
</button>
{url && (
<input
@@ -65,10 +54,10 @@ export function AdminTestCallButton() {
value={url}
aria-label="Test-Videoanruf-Link"
onFocus={(event) => event.currentTarget.select()}
style={{ width: "100%", marginTop: 6, padding: "5px 7px", border: "1px solid #e2e2e2", borderRadius: 5, fontSize: 11 }}
className="anouma-custom-nav-input"
/>
)}
{error && <p style={{ margin: "6px 0 0", color: "#a33", fontSize: 11 }}>{error}</p>}
{error && <p className="anouma-custom-nav-error">{error}</p>}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
const links = [
{ href: "/admin/anfragen", kicker: "Kommunikation", title: "Anfragen", text: "Kontakt aufnehmen, antworten und E-Mail-Verläufe bearbeiten." },
{ href: "/admin/kalender", kicker: "Planung", title: "Kalender", text: "Freie Zeiten, Buchungen und Veranstaltungen im Blick behalten." },
{ href: "/admin/collections/offers", kicker: "Website", title: "Angebote", text: "Angebote, Gruppen und Buchungsarten pflegen." },
{ href: "/admin/collections/events", kicker: "Termine", title: "Veranstaltungen", text: "Termine anlegen, bearbeiten und Anmeldungen verwalten." },
];
export function AdminWelcome() {
return (
<section className="anouma-admin-welcome" aria-labelledby="anouma-admin-welcome-title">
<div className="anouma-admin-welcome__intro">
<p className="anouma-admin-eyebrow">Anouma Verwaltung</p>
<h1 id="anouma-admin-welcome-title">Was möchtest du heute erledigen?</h1>
<p>Hier findest du die wichtigsten Bereiche für Website, Anfragen und Termine an einem Ort.</p>
</div>
<div className="anouma-admin-quicklinks">
{links.map((link, index) => (
<Link key={link.href} href={link.href} className="anouma-admin-quicklink">
<span className="anouma-admin-quicklink__number">0{index + 1}</span>
<span className="anouma-admin-quicklink__kicker">{link.kicker}</span>
<strong>{link.title}</strong>
<span>{link.text}</span>
</Link>
))}
</div>
</section>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function AnalyticsChart({ series }: { series: DailyPoint[] }) {
labelFormatter={(label) => `Tag: ${label}`}
/>
<Line type="monotone" dataKey="pageViews" name="Seitenaufrufe" stroke={ACCENT} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="sessions" name="Sessions" stroke={ACCENT_MUTED} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="sessions" name="Besuche" stroke={ACCENT_MUTED} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
+5 -16
View File
@@ -3,27 +3,16 @@ import type { ServerProps } from "payload";
import { canViewAnalytics } from "@/access";
export function AnalyticsNavLink({ user }: ServerProps) {
// Payload's custom views/nav links have no declarative `access` option —
// Payload's custom views/nav links have no declarative `access` option -
// this component must gate itself (see also AdminAnalyticsView.tsx, which
// re-checks the same thing for direct navigation to /admin/analytics).
if (!canViewAnalytics({ req: { user } } as Parameters<typeof canViewAnalytics>[0])) return null;
return (
<div style={{ padding: "0 8px 8px" }}>
<Link
href="/admin/analytics"
style={{
display: "block",
padding: "8px 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
color: "#8b616d",
textDecoration: "none",
border: "1px solid #e2e2e2",
}}
>
📊 Analytics
<div className="anouma-custom-nav-item">
<Link href="/admin/analytics" className="anouma-custom-nav-link">
<span className="anouma-custom-nav-link__icon">03</span>
<span>Auswertung</span>
</Link>
</div>
);
+3 -3
View File
@@ -36,7 +36,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
await navigator.clipboard.writeText(url);
setCopied(true);
} catch {
// Clipboard API can be unavailable (e.g. insecure context) — the
// Clipboard API can be unavailable (e.g. insecure context) - the
// input is still selectable/copyable by hand in that case.
}
}
@@ -46,7 +46,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
<h2 className={styles.heading}>Kalender synchronisieren</h2>
<p className={styles.hint}>
Abonniere deinen persönlichen Kalender in Apple Kalender, Google Kalender, Outlook, Thunderbird oder einer
anderen iCalendar-kompatiblen App. Der Link enthält ein geheimes Zugriffstoken — teile ihn nicht.
anderen iCalendar-kompatiblen App. Der Link enthält ein geheimes Zugriffstoken - teile ihn nicht.
</p>
{!url && (
@@ -65,7 +65,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
{url && (
<div className={styles.result}>
<p className={styles.resultWarning}>Wird nur jetzt einmal angezeigt — bitte gleich kopieren.</p>
<p className={styles.resultWarning}>Wird nur jetzt einmal angezeigt - bitte gleich kopieren.</p>
<div className={styles.urlRow}>
<input className={styles.urlInput} type="text" readOnly value={url} onFocus={(e) => e.currentTarget.select()} />
<button type="button" className={styles.secondaryButton} onClick={copy}>
+4 -15
View File
@@ -4,21 +4,10 @@ import { AdminTestCallButton } from "./AdminTestCallButton";
export function CalendarNavLink() {
return (
<>
<div style={{ padding: "0 8px 8px" }}>
<Link
href="/admin/kalender"
style={{
display: "block",
padding: "8px 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
color: "#8b616d",
textDecoration: "none",
border: "1px solid #e2e2e2",
}}
>
📅 Kalender
<div className="anouma-custom-nav-item">
<Link href="/admin/kalender" className="anouma-custom-nav-link">
<span className="anouma-custom-nav-link__icon">02</span>
<span>Kalender</span>
</Link>
</div>
<AdminTestCallButton />
+8 -1
View File
@@ -1,5 +1,12 @@
import Link from "next/link";
export function ConversationsNavLink() {
return <div style={{ padding: "0 8px 8px" }}><Link href="/admin/anfragen" style={{ display: "block", padding: "8px 12px", borderRadius: 6, fontSize: 13, fontWeight: 600, color: "#8b616d", textDecoration: "none", border: "1px solid #e2e2e2" }}>💬 Anfragen</Link></div>;
return (
<div className="anouma-custom-nav-item">
<Link href="/admin/anfragen" className="anouma-custom-nav-link">
<span className="anouma-custom-nav-link__icon">01</span>
<span>Anfragen</span>
</Link>
</div>
);
}
+3 -3
View File
@@ -41,7 +41,7 @@ export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooki
<div>
<h3 className="font-serif text-xl font-medium text-anouma-plum">{offer?.title ?? "Termin"}</h3>
<p className="mt-1 text-sm text-anouma-plum/80">
{formatBerlinDate(new Date(booking.date), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.startTime), { hour: "2-digit", minute: "2-digit" })} – {formatBerlinTime(new Date(booking.endTime), { hour: "2-digit", minute: "2-digit" })}
{formatBerlinDate(new Date(booking.date), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.startTime), { hour: "2-digit", minute: "2-digit" })} - {formatBerlinTime(new Date(booking.endTime), { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
<BookingStatusBadge status={displayStatus} />
@@ -57,7 +57,7 @@ export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooki
<div className="mt-4 rounded-2xl bg-anouma-cream-light p-4">
<p className="text-xs font-medium uppercase tracking-wide text-anouma-plum/70">Alternativer Termin</p>
<p className="mt-1 text-base text-anouma-plum">
{formatBerlinDate(new Date(booking.proposedAlternative.date!), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.proposedAlternative.startTime!), { hour: "2-digit", minute: "2-digit" })} –{" "}
{formatBerlinDate(new Date(booking.proposedAlternative.date!), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.proposedAlternative.startTime!), { hour: "2-digit", minute: "2-digit" })} -{" "}
{formatBerlinTime(new Date(booking.proposedAlternative.endTime!), { hour: "2-digit", minute: "2-digit" })}
</p>
<div className="mt-3 flex flex-wrap gap-3">
@@ -95,7 +95,7 @@ export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooki
<p className="text-sm text-anouma-plum/75">Dieser Video-Call ist bereits beendet.</p>
)}
{booking.appointmentType === "onsite" && (
<p className="text-sm text-anouma-plum/80">Vor Ort – Details siehe Bestätigungs-E-Mail.</p>
<p className="text-sm text-anouma-plum/80">Vor Ort - Details siehe Bestätigungs-E-Mail.</p>
)}
</div>
)}
+1 -1
View File
@@ -119,7 +119,7 @@ export function BookingWidget({
return;
}
setResult({ ok: true });
// Event only — never the offer/date/message content.
// Event only - never the offer/date/message content.
track("booking_started");
} catch {
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
+2 -2
View File
@@ -9,7 +9,7 @@ const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
// A stable per-browser identity so a host's "kick" survives the removed
// participant simply reconnecting (which always gets a fresh participantId
// — see lib/meeting/token.ts). Deliberately in localStorage, not
// - see lib/meeting/token.ts). Deliberately in localStorage, not
// sessionStorage: it must outlive the "kicked" screen clearing the session
// and the "Erneut beitreten" link going back through this form again.
function getOrCreateClientId(): string {
@@ -20,7 +20,7 @@ function getOrCreateClientId(): string {
localStorage.setItem(MEETING_CLIENT_ID_KEY, created);
return created;
} catch {
// Storage unavailable (private browsing, etc.) — fall back to a
// Storage unavailable (private browsing, etc.) - fall back to a
// one-off id; kicks just won't stick across reconnects in that case.
return crypto.randomUUID();
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function RegisterForm({ slug }: { slug: string }) {
if (status === "done") {
return (
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
Danke für deine Anmeldung – wir freuen uns auf dich!
Danke für deine Anmeldung - wir freuen uns auf dich!
</p>
);
}
+15 -15
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# ANOUMA — server-side deployment script.
# ANOUMA - server-side deployment script.
#
# This is NOT a manual update tool. It is invoked exclusively by GitHub
# Actions over SSH as the restricted `anouma-deploy` user (see setup.sh,
# which installs a forced-command SSH key that can only ever run this
# script). The production server never builds an image — it only ever
# script). The production server never builds an image - it only ever
# pulls an already-built, already-tested tag from the registry.
#
# deploy.sh deploy vX.Y.Z
@@ -26,10 +26,10 @@ fail() { log_error "$1"; exit 1; }
# ---------------------------------------------------------------------------
# When invoked through the restricted SSH key (see setup.sh), OpenSSH's
# forced "command=" ignores whatever the SSH client asked for and runs this
# script with NO arguments — the client's actual request arrives in
# script with NO arguments - the client's actual request arrives in
# $SSH_ORIGINAL_COMMAND instead. Re-split it into "$@" so the rest of this
# script doesn't need to care which path it came from. This is intentionally
# unquoted (word-splitting, not `eval`) — no shell metacharacter in
# unquoted (word-splitting, not `eval`) - no shell metacharacter in
# SSH_ORIGINAL_COMMAND is ever interpreted as code, it just becomes literal
# argument text, which the strict version regex below then validates.
# ---------------------------------------------------------------------------
@@ -41,13 +41,13 @@ fi
ACTION="${1:-}"
VERSION="${2:-}"
[ -f .env ] || fail ".env not found — run setup.sh on this server first."
[ -f .env ] || fail ".env not found - run setup.sh on this server first."
COMPOSE="docker compose"
docker compose version >/dev/null 2>&1 || COMPOSE="docker-compose"
# ---------------------------------------------------------------------------
# Deployment lock — never run two deployments concurrently.
# Deployment lock - never run two deployments concurrently.
# ---------------------------------------------------------------------------
LOCK_FILE="$(pwd)/.deploy.lock"
if [ -f "$LOCK_FILE" ] && kill -0 "$(cat "$LOCK_FILE")" 2>/dev/null; then
@@ -58,10 +58,10 @@ echo $$ > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
# ---------------------------------------------------------------------------
# .env helpers — same idempotent read/write approach as setup.sh.
# .env helpers - same idempotent read/write approach as setup.sh.
# ---------------------------------------------------------------------------
env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
# Quoted the same way as setup.sh's env_set — this file is `source`d as a
# Quoted the same way as setup.sh's env_set - this file is `source`d as a
# bash script below, so an unquoted value with shell metacharacters would
# break parsing.
env_set() {
@@ -98,7 +98,7 @@ backup_database() {
$COMPOSE exec -T postgres pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-anouma}" | gzip > "$file"
log_success "Backup created ($(du -h "$file" | cut -f1))."
else
log_warn "Postgres is not running yet — skipping backup (first-ever deploy?)."
log_warn "Postgres is not running yet - skipping backup (first-ever deploy?)."
fi
local retention="${BACKUP_RETENTION_DAYS:-14}"
find backups -name 'database-*.sql.gz' -mtime "+${retention}" -not -name "$(basename "$file")" -delete 2>/dev/null || true
@@ -108,7 +108,7 @@ registry_login() {
if [ -n "${REGISTRY_USERNAME:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
echo "$REGISTRY_PASSWORD" | docker login "${REGISTRY:-ghcr.io}" -u "$REGISTRY_USERNAME" --password-stdin >/dev/null 2>&1 \
&& log_success "Registry login OK." \
|| fail "Registry login failed — check REGISTRY_USERNAME/REGISTRY_PASSWORD in .env."
|| fail "Registry login failed - check REGISTRY_USERNAME/REGISTRY_PASSWORD in .env."
fi
}
@@ -122,7 +122,7 @@ set_image_version() {
# One full deploy attempt for a given version tag: pull → migrate → start →
# healthcheck. Returns non-zero on any failure without partially cleaning up
# — the caller decides whether to retry with the previous version.
# - the caller decides whether to retry with the previous version.
deploy_version() {
local version="$1"
set_image_version "$version"
@@ -159,18 +159,18 @@ do_deploy() {
log_error "${version} FAILED post-deploy healthcheck."
if [ -z "$previous_version" ]; then
fail "No previous version on record — nothing to roll back to. Check: $COMPOSE logs app"
fail "No previous version on record - nothing to roll back to. Check: $COMPOSE logs app"
fi
log_warn "Rolling back to ${previous_version} …"
if deploy_version "$previous_version"; then
log_warn "Rollback to ${previous_version} succeeded — production is back on the previous version."
log_warn "Database migrations from the failed deploy were NOT reverted (see DEPLOYMENT.md) — restore the backup above manually if ${version} shipped an incompatible migration."
log_warn "Rollback to ${previous_version} succeeded - production is back on the previous version."
log_warn "Database migrations from the failed deploy were NOT reverted (see DEPLOYMENT.md) - restore the backup above manually if ${version} shipped an incompatible migration."
echo "$previous_version" > .current-version
exit 1
fi
fail "Rollback to ${previous_version} ALSO failed — manual intervention required: $COMPOSE logs app"
fail "Rollback to ${previous_version} ALSO failed - manual intervention required: $COMPOSE logs app"
}
case "$ACTION" in
+2 -2
View File
@@ -2,7 +2,7 @@
# Nginx Proxy Manager) that has its own Docker network.
#
# setup.sh generates docker-compose.override.yml automatically when it
# detects such a network — this file is just the committed reference/manual
# detects such a network - this file is just the committed reference/manual
# fallback. Docker Compose picks up docker-compose.override.yml automatically
# alongside docker-compose.yml, no extra -f flags needed.
#
@@ -21,5 +21,5 @@ services:
- anouma-network
- npm-network
# No need to publish the port to the host when a reverse proxy reaches
# the container directly over the shared network — remove/comment the
# the container directly over the shared network - remove/comment the
# "ports" mapping in docker-compose.yml if so.
+5 -5
View File
@@ -17,7 +17,7 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-anouma}
volumes:
- pgdata:/var/lib/postgresql/data
# Not published to the host by default — only reachable from other
# Not published to the host by default - only reachable from other
# containers on anouma-network. Uncomment to reach it from the host too
# (e.g. with a GUI DB client) during development:
# ports:
@@ -29,7 +29,7 @@ services:
retries: 20
app:
# Production never builds from source — the image is built once by
# Production never builds from source - the image is built once by
# GitHub Actions on release and pushed to GHCR; this host only
# ever pulls a specific, already-tested tag (see deploy.sh, which is the
# only thing that ever changes ANOUMA_IMAGE in .env).
@@ -40,7 +40,7 @@ services:
env_file:
- .env
# DATABASE_URI in .env must point at the "postgres" service name, e.g.
# postgresql://postgres:<password>@postgres:5432/anouma — setup.sh sets
# postgresql://postgres:<password>@postgres:5432/anouma - setup.sh sets
# this up for you automatically.
ports:
- "127.0.0.1:${APP_PORT:-3000}:3000"
@@ -51,7 +51,7 @@ services:
condition: service_healthy
# Container-level healthcheck is inherited from the image's own
# HEALTHCHECK instruction (GET /api/health, which itself checks Postgres
# connectivity) — nothing to duplicate here.
# connectivity) - nothing to duplicate here.
# Self-hosted LiveKit SFU. Host networking is intentional: WebRTC media
# needs the UDP port range below to be reachable directly from the internet.
@@ -78,5 +78,5 @@ services:
# Optional external network to a reverse proxy (e.g. Nginx Proxy Manager)
# is attached via docker-compose.override.yml, generated by setup.sh only
# when such a network is actually detected — see DEPLOYMENT.md. Nothing
# when such a network is actually detected - see DEPLOYMENT.md. Nothing
# here assumes NPM (or any reverse proxy) exists.
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Field } from "payload";
/**
* Reusable, always-optional SEO field group — added to every public-facing
* Reusable, always-optional SEO field group - added to every public-facing
* collection/global. Never required: every consumer falls back to a
* sensible, automatically derived value when a field is left empty (see
* lib/seo/resolve.ts), per the "SEO fields are optional with fallbacks"
@@ -12,7 +12,7 @@ export function seoFields(): Field {
type: "collapsible",
label: "SEO",
admin: {
description: "Optional — wird automatisch aus dem Inhalt abgeleitet, wenn hier nichts eingetragen ist.",
description: "Optional - wird automatisch aus dem Inhalt abgeleitet, wenn hier nichts eingetragen ist.",
},
fields: [
{
+1 -1
View File
@@ -20,7 +20,7 @@ export function slugField(sourceField = "title"): Field {
return {
name: "slug",
type: "text",
label: "Slug (URL)",
label: "URL-Kürzel",
unique: true,
index: true,
admin: {
+3 -3
View File
@@ -8,9 +8,9 @@ import { canViewAnalytics, isAdmin } from "@/access";
*/
export const AnalyticsSettings: GlobalConfig = {
slug: "analytics-settings",
label: "Analytics-Einstellungen",
label: "Auswertungs-Einstellungen",
admin: {
description: "Aufbewahrungsdauer für Analytics-Rohdaten und aggregierte Tagesstatistiken.",
description: "Aufbewahrungsdauer für Rohdaten und zusammengefasste Tagesstatistiken.",
group: "Datenschutz",
},
access: {
@@ -27,7 +27,7 @@ export const AnalyticsSettings: GlobalConfig = {
min: 7,
admin: {
description:
"Wie lange einzelne Analytics-Events gespeichert bleiben. Begrenzt auch den wählbaren Zeitraum im Analytics-Dashboard.",
"Wie lange einzelne Messwerte gespeichert bleiben. Begrenzt auch den wählbaren Zeitraum in der Auswertung.",
},
},
{
+5 -5
View File
@@ -3,12 +3,12 @@ import { isAdmin } from "@/access";
/**
* Consent-category configuration shown in the public cookie banner
* (components/Analytics.tsx). Read access is intentionally public — the
* (components/Analytics.tsx). Read access is intentionally public - the
* unauthenticated frontend needs the labels/descriptions and the
* enabled/disabled flags before a visitor has logged in anywhere.
*
* "necessary" deliberately has no `enabled` field at all, anywhere in this
* schema — there is no toggle to accidentally switch off, so it can never
* schema - there is no toggle to accidentally switch off, so it can never
* be disabled through the admin UI (see requirement: technically required
* functionality must not be disable-able by an admin).
*/
@@ -32,14 +32,14 @@ export const CookieSettings: GlobalConfig = {
defaultValue: "1.0",
admin: {
description:
"Bei Änderung müssen alle Besucher erneut zustimmen — bereits gespeicherte Einwilligungen mit einer älteren Version werden ignoriert und der Banner erscheint erneut.",
"Bei Änderung müssen alle Besucher erneut zustimmen - bereits gespeicherte Einwilligungen mit einer älteren Version werden ignoriert und der Banner erscheint erneut.",
},
},
{
type: "group",
name: "necessary",
label: "Notwendig",
admin: { description: "Immer aktiv — technisch erforderlich, kann nicht deaktiviert werden." },
admin: { description: "Immer aktiv - technisch erforderlich, kann nicht deaktiviert werden." },
fields: [
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Notwendig" },
{
@@ -93,7 +93,7 @@ export const CookieSettings: GlobalConfig = {
defaultValue: true,
admin: {
description:
"Globaler Schalter für das gesamte Analytics-System. Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten und Analytics-Daten werden serverseitig abgelehnt — auch bei einer noch gespeicherten alten Zustimmung.",
"Globaler Schalter für die gesamte Reichweitenmessung. Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten und Messdaten werden serverseitig abgelehnt - auch bei einer noch gespeicherten alten Zustimmung.",
},
},
],
+8 -8
View File
@@ -13,7 +13,7 @@ const WEEKDAYS = [
/**
* Central SEO settings, edited once by Anna, applied automatically across
* the whole website (see lib/seo/*). Everything here is optional — nothing
* the whole website (see lib/seo/*). Everything here is optional - nothing
* is invented if a field is left blank (see lib/seo/jsonld.ts, which only
* emits LocalBusiness structured data once real address data exists).
*/
@@ -21,7 +21,7 @@ export const SEOSettings: GlobalConfig = {
slug: "seo-settings",
label: "SEO-Einstellungen",
admin: {
description: "Globale SEO-Vorgaben für die gesamte Website — greift automatisch überall dort, wo eine Seite keine eigenen SEO-Angaben hat.",
description: "Globale SEO-Vorgaben für die gesamte Website - greift automatisch überall dort, wo eine Seite keine eigenen SEO-Angaben hat.",
group: "SEO",
},
access: {
@@ -40,7 +40,7 @@ export const SEOSettings: GlobalConfig = {
type: "text",
label: "Website-URL",
admin: {
description: "Nur bei Bedarf setzen (z. B. https://anouma.org) — überschreibt die Server-Konfiguration. Leer lassen, um die technische Standardeinstellung zu verwenden.",
description: "Nur bei Bedarf setzen (z. B. https://anouma.org) - überschreibt die Server-Konfiguration. Leer lassen, um die technische Standardeinstellung zu verwenden.",
},
validate: (value: string | null | undefined) => {
if (!value) return true;
@@ -58,8 +58,8 @@ export const SEOSettings: GlobalConfig = {
name: "defaultOgImage",
type: "upload",
relationTo: "media",
label: "Standard Social-Media-Bild",
admin: { description: "Wird verwendet, wenn eine Seite kein eigenes Open-Graph-Bild hat." },
label: "Standardbild für soziale Netzwerke",
admin: { description: "Wird verwendet, wenn eine Seite kein eigenes Bild für soziale Netzwerke hat." },
},
{
name: "robotsIndexable",
@@ -67,14 +67,14 @@ export const SEOSettings: GlobalConfig = {
label: "Website für Suchmaschinen sichtbar",
defaultValue: true,
admin: {
description: "Deaktivieren, um die gesamte öffentliche Website vorübergehend für Suchmaschinen zu sperren (z. B. während des Aufbaus). Betrifft nicht Admin/Konto — die sind ohnehin immer gesperrt.",
description: "Deaktivieren, um die gesamte öffentliche Website vorübergehend für Suchmaschinen zu sperren (z. B. während des Aufbaus). Betrifft nicht Admin/Konto - die sind ohnehin immer gesperrt.",
},
},
],
},
{
label: "Lokale Informationen",
admin: { description: "Werden nur verwendet, wenn tatsächlich ausgefüllt — es werden keine Angaben erfunden." },
admin: { description: "Werden nur verwendet, wenn tatsächlich ausgefüllt - es werden keine Angaben erfunden." },
fields: [
{
name: "businessName",
@@ -99,7 +99,7 @@ export const SEOSettings: GlobalConfig = {
name: "openingHours",
type: "array",
label: "Öffnungs-/Erreichbarkeitszeiten",
admin: { description: "Optional — nur für tatsächlich feste Zeiten (z. B. Sprechzeiten)." },
admin: { description: "Optional - nur für tatsächlich feste Zeiten (z. B. Sprechzeiten)." },
fields: [
{
type: "row",
+2 -2
View File
@@ -18,7 +18,7 @@ function bump(map: Record<string, number>, key: string | null | undefined) {
map[key] = (map[key] ?? 0) + 1;
}
/** Sorted (descending) [key, count] pairs, capped at `limit` — for dashboard tables. */
/** Sorted (descending) [key, count] pairs, capped at `limit` - for dashboard tables. */
export function topEntries(map: Record<string, number>, limit = 10): [string, number][] {
return Object.entries(map)
.sort(([, a], [, b]) => b - a)
@@ -26,7 +26,7 @@ export function topEntries(map: Record<string, number>, limit = 10): [string, nu
}
/**
* Pure aggregation over a set of raw AnalyticsEvents rows (any date range —
* Pure aggregation over a set of raw AnalyticsEvents rows (any date range -
* one day for the cleanup rollup, or a whole dashboard-selected range for
* live queries). Kept side-effect free and DB-agnostic so both callers
* (lib/analytics/cleanup.ts and lib/analytics/dashboardQueries.ts) share
+3 -3
View File
@@ -14,7 +14,7 @@ function addDays(date: Date, days: number): Date {
}
/**
* Rolls up every not-yet-aggregated, fully-completed calendar day (UTC —
* Rolls up every not-yet-aggregated, fully-completed calendar day (UTC -
* matching the UTC-day salt rotation in lib/analytics/salt.ts) from raw
* analytics-events into analytics-daily-stats, THEN purges raw events older
* than the configured retention, THEN purges old daily-stats rows. Order
@@ -22,7 +22,7 @@ function addDays(date: Date, days: number): Date {
* deleted, or that history is gone for good.
*
* Only aggregates days that don't already have a daily-stats row (each day
* is aggregated exactly once, ever) — safe to call this daily via
* is aggregated exactly once, ever) - safe to call this daily via
* app/(payload)/api/cron/analytics-cleanup/route.ts, and safe to re-run.
*/
export async function runAnalyticsCleanup(payload: Payload) {
@@ -60,7 +60,7 @@ export async function runAnalyticsCleanup(payload: Payload) {
{ createdAt: { less_than: dayEnd.toISOString() } },
],
},
limit: 0, // no limit — pull the whole day, small-site scale
limit: 0, // no limit - pull the whole day, small-site scale
depth: 0,
});
+3 -3
View File
@@ -2,15 +2,15 @@
/**
* Public developer API + consent storage for the analytics system. Importing
* this module has zero side effects — no network call, nothing initialized
* — until `track()` is actually called, and even then it's a no-op unless
* this module has zero side effects - no network call, nothing initialized
* - until `track()` is actually called, and even then it's a no-op unless
* consent was actually granted. Callers never check consent themselves:
*
* track("booking_started");
*
* The CMS-configured part of consent (whether the "statistics" category is
* enabled at all, and the current consent version) is pushed in once by
* components/Analytics.tsx via setAnalyticsConfig() — see that file. Until
* components/Analytics.tsx via setAnalyticsConfig() - see that file. Until
* that happens, `cmsConfig` is null and track() fails safe (no-op), so a
* component whose effects happen to run before Analytics.tsx's can never
* accidentally send data before the live config is known.
+3 -3
View File
@@ -1,7 +1,7 @@
/**
* Central, extensible registry of known analytics event names. Adding a new
* event elsewhere in the app never requires a migration — the database
* column (collections/AnalyticsEvents.ts#eventName) is free-text — this
* event elsewhere in the app never requires a migration - the database
* column (collections/AnalyticsEvents.ts#eventName) is free-text - this
* list exists purely so `track()` calls get autocomplete/type-checking and
* the dashboard can show a nice German label instead of a raw event name.
*
@@ -18,7 +18,7 @@ export const ANALYTICS_EVENTS = {
export type KnownAnalyticsEvent = keyof typeof ANALYTICS_EVENTS;
/** Any string works (see the collection comment above) — known ones just get a label. */
/** Any string works (see the collection comment above) - known ones just get a label. */
export type AnalyticsEventName = KnownAnalyticsEvent | (string & {});
export function analyticsEventLabel(eventName: string): string {
+1 -1
View File
@@ -27,7 +27,7 @@ function parseDateParam(value: string | null): Date | null {
/**
* Resolves the dashboard's period filter (?range=&from=&to=) into an actual
* date range, clamped to `maxDaysBack` (the configured raw-event retention —
* date range, clamped to `maxDaysBack` (the configured raw-event retention -
* see globals/AnalyticsSettings.ts) so the UI never claims to show data that
* has already been purged. Also computes the matching "previous period" for
* comparison (same length, immediately preceding).
+3 -3
View File
@@ -2,14 +2,14 @@ import { createHmac } from "node:crypto";
/**
* Derives a daily-rotating, non-reversible pseudonymous visitor identifier
* for session grouping — the same approach used by privacy-friendly
* for session grouping - the same approach used by privacy-friendly
* analytics tools (Plausible/Fathom-style): hash(ip + userAgent + host)
* salted with a value that changes every calendar day. Raw IP/UA are never
* persisted anywhere, only this hash — and since the salt rotates daily,
* persisted anywhere, only this hash - and since the salt rotates daily,
* the same visitor hashes differently tomorrow, so there is no cross-day
* linkage and no long-term individual profile.
*
* ANALYTICS_SALT_SECRET is a dedicated secret (not PAYLOAD_SECRET) — this
* ANALYTICS_SALT_SECRET is a dedicated secret (not PAYLOAD_SECRET) - this
* codebase keeps one secret per purpose on purpose (see
* MEETING_SESSION_SECRET's comment in .env.example).
*/
+1 -1
View File
@@ -7,7 +7,7 @@ const SESSION_IDLE_WINDOW_MS = 30 * 60 * 1000;
* Groups events into sessions without any client-side identifier: looks up
* the most recent event from the same `visitorHash` within the last 30
* minutes and reuses its sessionId, or mints a new one. One extra indexed
* read per tracked request — acceptable at this site's traffic volume, and
* read per tracked request - acceptable at this site's traffic volume, and
* simpler/safer than trying to hold a lock across an unauthenticated,
* high-frequency public endpoint (unlike lib/booking/lock.ts's advisory
* lock, an occasional extra spurious session here has no correctness
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* Coarse device/browser classification from the User-Agent header. No
* library, no raw UA string ever stored — just one bucket per request,
* library, no raw UA string ever stored - just one bucket per request,
* computed on the fly and discarded after classification. Not meant to be
* precise (e.g. WebView vs. real browser); good enough for the "device
* type" / "browser" breakdown in the Analytics dashboard.
@@ -19,7 +19,7 @@ export function classifyDevice(userAgent: string): DeviceType {
export function classifyBrowser(userAgent: string): BrowserFamily {
const ua = userAgent.toLowerCase();
// Order matters: Edge/Chrome UAs also contain "safari"; Chrome UAs also
// contain "chrome" but so do Edge/Opera — check the most specific tokens first.
// contain "chrome" but so do Edge/Opera - check the most specific tokens first.
if (/edg\//.test(ua)) return "edge";
if (/chrome\/|crios\//.test(ua)) return "chrome";
if (/firefox\/|fxios\//.test(ua)) return "firefox";
+2 -2
View File
@@ -12,7 +12,7 @@ export type ImageMood =
| "peach";
// Purely a presentation fallback for offers/events without an uploaded photo
// yet — content itself always comes from the CMS.
// yet - content itself always comes from the CMS.
const moodBySlug: Record<string, ImageMood> = {
prozessbegleitung: "plum",
"doula-begleitung": "dustyrose",
@@ -50,7 +50,7 @@ const categoryHref: Record<string, string> = {
/**
* Groups the flat `offers` collection into the four nav/overview sections.
* Private offers (e.g. "Einzelbegleitung") are deliberately excluded — they
* Private offers (e.g. "Einzelbegleitung") are deliberately excluded - they
* stay reachable only via their own direct booking link.
*/
export function groupOffersByCategory(offers: Offer[]): OfferCategoryGroup[] {
+3 -3
View File
@@ -1,6 +1,6 @@
/**
* Minimal in-memory fixed-window rate limiter for a single Node process
* (this app always runs as one process — see server.ts). Good enough to
* (this app always runs as one process - see server.ts). Good enough to
* blunt abuse of the email-verification resend endpoint without adding an
* external store; resets on deploy/restart, which is an acceptable
* trade-off for this use case.
@@ -34,12 +34,12 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe
export function getClientIp(request: { headers: Headers }): string {
// Deployment is always behind exactly one trusted reverse proxy (see
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
// DEPLOYMENT.md - Nginx Proxy Manager; the app port isn't published
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
// *appends* the real client IP to any X-Forwarded-For it already
// received rather than replacing it, so a directly-connecting client
// could prepend arbitrary spoofed entries of their own. Only the
// right-most (last) entry — the one the trusted proxy itself appended —
// right-most (last) entry - the one the trusted proxy itself appended -
// is trustworthy; taking the first entry would let a client bypass this
// rate limit entirely by sending a fake X-Forwarded-For header.
const forwarded = request.headers.get("x-forwarded-for");
+2 -2
View File
@@ -3,13 +3,13 @@ import { createHash, randomBytes } from "node:crypto";
/** How long a freshly issued email-verification link stays valid. */
export const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
/** 256 bits of randomness, hex-encoded — not guessable, never derived from user data. */
/** 256 bits of randomness, hex-encoded - not guessable, never derived from user data. */
export function generateVerificationToken(): string {
return randomBytes(32).toString("hex");
}
/**
* Only this hash is ever persisted (see collections/Customers.ts) — the
* Only this hash is ever persisted (see collections/Customers.ts) - the
* plaintext token exists only in the URL sent by email and briefly in
* memory while that email is being sent.
*/
+1 -1
View File
@@ -4,7 +4,7 @@ import { customerBookingView, type CustomerBooking } from "./customerView";
/**
* These are called only after the caller has already verified who the
* current customer is (see lib/auth/customer.ts) — the `where: { user }`
* current customer is (see lib/auth/customer.ts) - the `where: { user }`
* filter below is what actually scopes results to that one customer.
*/
export const getCustomerBookings = cache(async (customerId: number): Promise<CustomerBooking[]> => {
+3 -3
View File
@@ -19,7 +19,7 @@ const WEEKDAY_BY_INDEX = [
// and writes here go through Intl's Berlin timezone data (not local Date
// accessors, and not manual UTC+1/+2 arithmetic), so the server process's
// own timezone (this app runs in UTC in production) never leaks into the
// computed availability — see lib/calendar/timezone.ts for the same pattern.
// computed availability - see lib/calendar/timezone.ts for the same pattern.
function dateKey(date: Date): string {
const { year, month, day } = berlinParts(date);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
@@ -31,7 +31,7 @@ function calendarDate(date: Date): CalendarDate {
}
function addCalendarDays({ year, month, day }: CalendarDate, delta: number): CalendarDate {
// Pure calendar-rollover arithmetic (month/year boundaries) — no timezone
// Pure calendar-rollover arithmetic (month/year boundaries) - no timezone
// conversion happens here, so using the UTC constructor is safe and simple.
const utc = new Date(Date.UTC(year, month - 1, day + delta));
return { year: utc.getUTCFullYear(), month: utc.getUTCMonth() + 1, day: utc.getUTCDate() };
@@ -58,7 +58,7 @@ function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
/**
* Computes bookable slots for one offer across a date range, from the
* configured weekly Availability (plus per-date AvailabilityOverrides),
* minus anything already pending/confirmed on Anna's schedule — she can
* minus anything already pending/confirmed on Anna's schedule - she can
* only do one thing at a time, regardless of which offer it's for.
*/
export async function getAvailableSlots(

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