florian.wilde c98a3617a4 fix: Sprach-Fallback ignoriert deaktiviertes Deutsch
getLocale() fiel bei fehlendem/ungültigem Cookie bisher immer fest auf
Deutsch zurück, auch wenn Deutsch im Sprachen-Admin deaktiviert wurde.
Da der Sprachumschalter sich bei nur einer aktiven Sprache zusätzlich
ausblendet, gab es keine Möglichkeit, das über die Oberfläche zu
korrigieren. Fallback nutzt jetzt Deutsch nur noch, wenn es aktiv ist.
2026-07-20 11:22:49 +02:00
2026-07-17 17:13:05 +02:00
2026-07-17 17:13:05 +02:00

Canteen Menu – Speisekarte für die Betriebskantine

Digitale Speisekarte für eine Firmenkantine (ca. 250 Mitarbeiter) mit öffentlicher Wochenansicht, Kantinen-Bereich zur Pflege der Gerichte und Admin-Bereich für Benutzerverwaltung und Branding.

Stack: Next.js 16 (App Router, TypeScript, Tailwind CSS) · selbst gehostetes PostgreSQL (via pg, eigene Migrationen) · eigene Session-Auth (Passwort via node:crypto scrypt, Passkeys via @simplewebauthn) · DeepL als einzige externe API (optional). Läuft vollständig selbst gehostet, keine Cloud-Abhängigkeit außer optional DeepL.

Änderungen je Version stehen in CHANGELOG.md, Git-Tags markieren die jeweiligen Versionsstände (git tag). Schritt-für-Schritt- Anleitung für die Inbetriebnahme auf einem neuen Server: DEPLOYMENT.md.

Funktionen

Bereich Funktionen
Öffentlich (ohne Login) Wochenplan Mo–Fr mit frei konfigurierbaren Essenszeiten; Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte; Sprachumschalter Deutsch/Englisch/Polnisch; Rezeptvorschläge einreichen; RSS-Feed (/feed.xml, Icon im Kopfbereich) mit einem Eintrag pro Tag zum Abonnieren
Kantinen-Team (/intern) Essenszeiten anlegen/bearbeiten/löschen/neu anordnen; Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche, Mehrfachauswahl zum Batch-Löschen, englischem/polnischem Namen mit automatischer DeepL-Übersetzung) mit Allergenen/Zusatzstoffen je Gericht; Gerichte per Suche Tagen/Essenszeiten zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen (ebenfalls mehrsprachig); eingereichte Rezeptvorschläge sichten, übernehmen oder ablehnen (Anzahl offener Vorschläge als Badge in der Navigation); eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus)
Admin (/admin) Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset mit Wiederholung und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo und Favicon hochladen/entfernen

Anmeldung mit E-Mail + Passwort oder alternativ per Passkey (Registrierung im eigenen Konto nach dem ersten Login). Die Passwort-Anmeldung kann pro Konto deaktiviert werden, sobald mindestens ein Passkey registriert ist (serverseitig erzwungen).

Die gesamte Oberfläche ist auf Deutsch, Englisch und Polnisch verfügbar (Sprachumschalter, Cookie-basiert). Gerichte, Essenszeiten, Allergene und Zusatzstoffe lassen sich zusätzlich auf Englisch/Polnisch benennen – mit optionaler automatischer Übersetzung über DeepL (siehe DEEPL_API_KEY unten) und jederzeit manuell korrigierbar.

Projektstruktur

app/                    Next.js-Routen
  page.tsx              Öffentliche Speisekarte
  feed.xml/              RSS-Feed des Speiseplans (öffentlich)
  login/                Login (Passwort + Passkey)
  vorschlag/             Öffentliches Rezeptvorschlag-Formular
  intern/               Kantinen-Bereich (Rolle: staff oder admin)
    vorschlaege/         Rezeptvorschläge sichten/übernehmen
  admin/                Verwaltung (Rolle: admin)
  branding/[filename]/   Logo/Favicon ausliefern (öffentlich)
  api/auth/              Login, Logout, Passkeys
  api/account/           Eigenes Konto (Name, E-Mail, Passwort, Toggle)
  api/admin/users/       Benutzerverwaltung (nur Admin)
  api/admin/branding/    Logo-/Favicon-Upload (nur Admin)
  api/dishes/, api/sections/, api/allergens/, api/additives/,
  api/menu-entries/, api/dish-suggestions/, api/settings/
                          CRUD-Routen für den Kantinen-/Admin-Bereich
  api/translate/          DeepL-Anbindung (serverseitig)
  api/locale/             Sprach-Cookie setzen
components/             Client-Komponenten (WeekEditor, DishManager, …)
components/i18n/        Sprachkontext, Sprachumschalter
lib/i18n/               Wörterbücher (de/en/pl), Locale-Helfer
lib/auth/               Sessions, Passwort-Hashing, WebAuthn-Konfiguration
lib/db/                 pg-Pool, Query-Helfer, gemeinsame Ladefunktionen
lib/branding.ts         Lokale Datei-Ablage für Logo/Favicon
lib/                    Typen, Datums-/Formatierungshelfer
proxy.ts                Session-Prüfung + Schutz von /intern, /admin, /konto
db/migrations/          Datenbankschema (reines SQL, nummeriert)
db/migrate.mjs          Wendet neue Migrationen an (idempotent, bei jedem Start)
scripts/bootstrap-admin.mjs
                        Legt bei leerer Datenbank automatisch einen
                        Admin mit Zufallspasswort an (siehe ZUGANGSDATEN.md)

Entwicklung

Voraussetzung in beiden Varianten: .env.local mit den Werten aus .env.example (liegt lokal bereits vor und ist per .gitignore vom Repo ausgeschlossen).

Variante A – Docker (nichts vorinstallieren außer Docker):

docker compose --env-file .env.local --profile dev up dev
# http://localhost:3000 (Hot-Reload, Quellcode wird gemountet)

Variante B – lokal installiertes Node.js + PostgreSQL:

npm install
npm run dev            # http://localhost:3000 (bindet an 0.0.0.0, auch im LAN erreichbar)

In beiden Varianten wird die Datenbank beim Start automatisch auf den neuesten Stand migriert und – falls noch kein Benutzer existiert – ein Admin-Konto mit Zufallspasswort angelegt (siehe Zugangsdaten).

Umgebungsvariablen (Live-Betrieb)

Variable Bedeutung Geheim?
DATABASE_URL Verbindung zur PostgreSQL-Datenbank, z. B. postgres://postgres:<passwort>@db:5432/canteen_menu ja
APP_URL Öffentliche URL der App, z. B. https://menu.example.de (Origin-Check hinter Reverse-Proxy; zugleich Passkey-RP-Domain – wird direkt daraus abgeleitet, kein separater Konfigurationsschritt nötig) nein
SITE_TITLE Titel im Browser-Tab und Überschrift ohne Logo, Default Canteen Menu nein
ADMIN_EMAIL Optional: E-Mail des automatisch angelegten Admin-Erstnutzers (nur relevant, solange die Datenbank leer ist). Ohne Angabe: admin@<Hostname aus APP_URL> nein
DEEPL_API_KEY Optional: aktiviert „Automatisch übersetzen“ für Gerichte/Allergene/Zusatzstoffe (DeepL, kostenloser Free-Tier). Ohne Key funktioniert alles weiter, nur automatisch übersetzen ist deaktiviert ja – niemals im Browser/Repo!

Build & Start auf dem Server – mit Docker (empfohlen, Node.js und PostgreSQL müssen nicht installiert sein):

docker compose --env-file .env.local up -d --build
# App: Port 3000 (hinter Reverse-Proxy mit HTTPS betreiben)
# PostgreSQL: kein Host-Port, nur intern im Compose-Netzwerk erreichbar

Vor dem ersten Start: eine leere ZUGANGSDATEN.md im Projektverzeichnis anlegen (touch ZUGANGSDATEN.md), da diese Datei per Bind-Mount in den Container eingebunden wird und die automatisch generierten Admin- Zugangsdaten dort landen.

Oder klassisch ohne Docker (mit lokal erreichbarem PostgreSQL):

npm ci
npm run build
npm run start           # Port 3000, bindet an 0.0.0.0 (hinter Reverse-Proxy betreiben)

Passkeys sind an die Domain gebunden (RP-ID = Hostname aus APP_URL). Vor dem Livegang APP_URL auf die öffentliche URL setzen – ein zusätzlicher Konfigurationsschritt ist nicht nötig. Bereits registrierte Passkeys einer vorherigen Domain verlieren dabei ihre Gültigkeit und müssen neu registriert werden.

Datenbank

Reines, selbst gehostetes PostgreSQL (keine Row-Level-Security-Policies) – alle Zugriffsrechte werden in den Route-Handlern geprüft (lib/auth/session.ts, requireRole).

  • Schema: db/migrations/*.sql, nummeriert, wird von db/migrate.mjs automatisch angewendet (idempotent, bei jedem Start des Servers bzw. Docker-Containers).
  • Neue Migration hinzufügen: db/migrations/000N_beschreibung.sql anlegen, wird beim nächsten Start automatisch eingespielt.
  • Manuell migrieren: npm run db:migrate

Rechte-Modell (in Anwendungscode statt Datenbank-RLS):

  • Öffentlich lesbar (kein Login nötig): Speisekarte, Essenszeiten, Allergene, Zusatzstoffe, Logo/Favicon.
  • staff/admin pflegen Gerichte, Essenszeiten, Wochenplan und Kennzeichnungen.
  • Nur admin ändert Einstellungen (Farben/Logo) und verwaltet Benutzer.
  • Logo/Favicon liegen als Dateien in BRANDING_DIR (Docker-Volume branding_data), öffentlich ausgeliefert über /branding/[filename].

Zugangsdaten

Beim allerersten Start mit leerer Datenbank legt scripts/bootstrap-admin.mjs automatisch einen Admin-Benutzer mit zufällig generiertem Passwort an und schreibt die Zugangsdaten nach ZUGANGSDATEN.md – diese Datei ist bewusst nicht im Repository (.gitignore). Nach dem ersten Login bitte das Startpasswort ändern.

S
Description
No description provided
Readme
465 KiB
Languages
TypeScript 94.3%
JavaScript 2.1%
PLpgSQL 1.6%
CSS 1.1%
Shell 0.5%
Other 0.4%