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.
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 vondb/migrate.mjsautomatisch angewendet (idempotent, bei jedem Start des Servers bzw. Docker-Containers). - Neue Migration hinzufügen:
db/migrations/000N_beschreibung.sqlanlegen, 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/adminpflegen Gerichte, Essenszeiten, Wochenplan und Kennzeichnungen.- Nur
adminändert Einstellungen (Farben/Logo) und verwaltet Benutzer. - Logo/Favicon liegen als Dateien in
BRANDING_DIR(Docker-Volumebranding_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.