Compare commits

..
16 Commits
Author SHA1 Message Date
florian.wilde 96eaf3989e Merge feature/v1.7: CSV-Import/-Export, RSS-Fix für mehrere Essenszeiten 2026-07-19 23:14:02 +02:00
florian.wildeandClaude Sonnet 5 474467811c feat: CSV-Import/-Export für Gerichte/Allergene/Zusatzstoffe, RSS-Fix für mehrere Essenszeiten
CSV-Import/-Export (Gerichte, Allergene, Zusatzstoffe): neue Export-/
Import-Routen unter app/api/*/export und */import, hand-geschriebener
CSV-Parser/-Serializer (lib/csv.ts, keine neue Abhängigkeit), Upsert
über Kürzel (Allergene/Zusatzstoffe) bzw. exakten Namen (Gerichte,
mit Ambiguitäts-Check bei Namensdopplung). UI-Buttons in DishManager
und CodeListManager.

RSS-Feed: <content:encoded> zusätzlich zu <description> ausgeben,
da mehrere Feed-Reader description als kürzbare Vorschau behandeln
und dadurch bei mehreren Essenszeiten pro Tag nur die erste zeigten.

Version 1.7.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 23:10:36 +02:00
florian.wilde 5874eb31b6 Merge fix/branding-dir-env: BRANDING_DIR im app-Dienst setzen (Upload-500-Fix) 2026-07-18 15:11:27 +02:00
florian.wildeandClaude Sonnet 5 358872d56c fix: BRANDING_DIR im app-Dienst setzen, sonst 500 beim Logo/Favicon-Upload
Ohne die Variable weicht lib/branding.ts auf ${cwd}/data/branding
(= /app/data/branding) aus statt das im Dockerfile vorbereitete und
gemountete /data/branding zu nutzen. /app gehört root, der App-Prozess
läuft aber als nicht-root – das Anlegen des Fallback-Verzeichnisses
schlägt mit EACCES fehl.

Version 1.6.3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 15:10:46 +02:00
florian.wilde 36be08cea7 Merge fix/bootstrap-admin-file-permission: Admin-Passwort nicht mehr verlieren bei EACCES
bootstrap-admin.mjs verliert das generierte Admin-Passwort nicht mehr,
wenn ZUGANGSDATEN.md nicht beschreibbar ist (Fallback: Log statt
Absturz nach erfolgreichem DB-Insert). DEPLOYMENT.md ergänzt um
chmod 666 in Schritt 2 sowie Fehlerbehebung für ECONNREFUSED und
diesen EACCES-Fall.
2026-07-18 14:51:55 +02:00
florian.wilde b63822060d Version 1.6.2 2026-07-18 14:51:29 +02:00
florian.wilde 94a2c98252 bootstrap-admin.mjs: Zugangsdaten nicht mehr verlieren, wenn ZUGANGSDATEN.md nicht beschreibbar ist
Der Admin-Benutzer wird immer in der Datenbank angelegt, bevor
versucht wird, die generierten Zugangsdaten in ZUGANGSDATEN.md zu
schreiben. Schlug dieser Schreibvorgang fehl (z. B. EACCES, weil die
per Bind-Mount eingebundene Host-Datei root gehört und der
App-Container als nicht-root läuft), flog bisher eine ungefangene
Exception – das Skript brach ab, BEVOR das Passwort irgendwo
protokolliert wurde. Der Admin existierte dann in der DB, aber niemand
kannte sein Passwort mehr.

appendFileSync() läuft jetzt in einem try/catch; schlägt der
Dateizugriff fehl, werden E-Mail und Passwort stattdessen direkt ins
Log geschrieben, und das Skript beendet sich weiterhin mit Exitcode 0
(die eigentlich kritische Operation – der DB-Insert – ist ja bereits
erfolgreich). Lokal verifiziert: Skript bricht bei einer nicht
beschreibbaren ZUGANGSDATEN.md nicht mehr ab, Zugangsdaten landen im
Log, zweiter Lauf bleibt idempotent.

DEPLOYMENT.md: Schritt 2 ergänzt um "chmod 666 ZUGANGSDATEN.md" (der
eigentliche Auslöser), neue Fehlerbehebung-Einträge für
ECONNREFUSED-Startreihenfolge und diesen EACCES-Fall inkl.
Recovery-Anleitung für einen bereits verwaisten Admin-Account.
2026-07-18 14:44:38 +02:00
florian.wilde 0d9d26b885 Merge fix/deployment-guide-pg-password: DB-Port nicht mehr exponiert, Deployment-Doku-Fixes
Datenbank ist nur noch im internen Compose-Netzwerk erreichbar (kein
Host-Port mehr). DEPLOYMENT.md: Fehlerbehebung für "password
authentication failed" (PostgreSQL übernimmt das Passwort nur beim
allerersten Start), .env.example warnt vor Sonderzeichen in
LOCAL_PG_PASSWORD.
2026-07-18 14:30:00 +02:00
florian.wilde 233027fd17 Version 1.6.1 2026-07-18 14:29:13 +02:00
florian.wilde 469b93ee05 Datenbank-Port nicht mehr auf den Host exponieren
docker-compose.yml: "ports: 5433:5432" beim db-Dienst entfernt. Die
Datenbank ist damit nur noch innerhalb des Compose-Netzwerks unter
"db:5432" erreichbar (für app/dev), nicht mehr vom Host oder von außen
– reduziert die Angriffsfläche, ohne dass sich am App-Betrieb etwas
ändert (Verbindung läuft ohnehin über den internen Service-Namen).
Zugriff für Debugging/Backups weiterhin über "docker compose exec db
psql"/"pg_dump" möglich.

Lokal verifiziert: App verbindet sich weiterhin erfolgreich (Migration/
Bootstrap/Login laufen durch), Verbindungsversuch vom Host auf Port
5433 wird jetzt abgelehnt (connection refused).

README.md an die entfallene Portangabe angepasst.
2026-07-18 14:25:55 +02:00
florian.wilde 536f51d516 DEPLOYMENT.md: Fehlerbehebung für "password authentication failed"
Beim Ausrollen auf einem echten Server aufgetreten: PostgreSQL setzt
sein Passwort nur beim allerersten Start auf leerem Datenverzeichnis –
lief "docker compose up" vorher schon einmal ohne "--env-file
.env.local" (oder wurde LOCAL_PG_PASSWORD nachträglich geändert),
bleibt im Volume ein anderes Passwort stehen als die App danach
verwendet.

Neue Fehlerbehebung-Sektion mit der konkreten Fehlermeldung und zwei
Fixes (Volume neu anlegen bei frischem Deployment, sonst Passwort im
laufenden Container angleichen). Warnhinweis in Schritt 3 ergänzt.

.env.example: Hinweis, dass LOCAL_PG_PASSWORD nur aus Buchstaben/
Zahlen bestehen sollte (#, $, Leerzeichen, Anführungszeichen können
beim .env-Parsing zu stillen Fehlern führen).
2026-07-18 14:20:01 +02:00
florian.wilde 3b8f7de5e4 Merge feature/v1.6: RSS-Feed, Vorschläge-Badge, Deployment-Anleitung
RSS-Feed des Speiseplans (/feed.xml, ein Eintrag pro Tag) mit
Autodiscovery-Link und sichtbarem Icon im Header. Anzahl offener
Rezeptvorschläge als Badge in der internen Navigation. Letzte
Supabase-Erwähnungen aus aktiver Dokumentation entfernt. Neues
DEPLOYMENT.md mit Schritt-für-Schritt-Anleitung für einen neuen Server.
2026-07-18 12:32:18 +02:00
florian.wilde c986327c1c Version 1.6.0
Verifiziert: RSS-Feed (/feed.xml, gültiges RSS 2.0, korrekte Tages-
Einträge inkl. Essenszeiten/Preisen), Autodiscovery-Link + sichtbares
Icon auf Desktop und Mobile, Vorschläge-Badge (Anzahl offener
Rezeptvorschläge, aktualisiert bei Navigation), keine verbliebenen
Supabase-Erwähnungen in aktiver Doku/Kommentaren.
2026-07-18 12:26:20 +02:00
florian.wilde 65bfd931cc Schritt-für-Schritt-Deployment-Anleitung für neuen Server
Neues DEPLOYMENT.md: Voraussetzungen, Repository holen, .env.local
konfigurieren, docker compose up --build, Admin-Zugangsdaten aus
ZUGANGSDATEN.md abholen, Reverse-Proxy/HTTPS (Caddy-Beispiel), Updates,
Backup der Docker-Volumes. Aus README.md verlinkt.

.env.example: LOCAL_PG_PASSWORD war bisher nirgends dokumentiert,
obwohl docker-compose.yml die DATABASE_URL des App-Containers daraus
zusammenbaut (die dortige DATABASE_URL-Zeile wird beim
Docker-Compose-Betrieb ignoriert) – beim Schreiben der Anleitung
aufgefallen und ergänzt.

README.md: RSS-Feed und Vorschläge-Badge in der Funktionstabelle
ergänzt.
2026-07-18 12:23:46 +02:00
florian.wilde 6f78762e27 Letzte Supabase-Erwähnungen aus aktiver Doku/Kommentaren entfernt
README.md, db/migrations/0001_init.sql, lib/auth/webauthn-config.ts,
lib/branding.ts nannten Supabase noch in Kommentaren, die erklären,
was diese Stellen ersetzen. Umformuliert, ohne den alten Anbieter beim
Namen zu nennen. CHANGELOG.md bleibt unverändert – die Erwähnungen dort
sind historischer Bestand (u. a. der v1.5.0-Eintrag über die
Supabase-Ablösung selbst) und sollen als Zeitdokument stehen bleiben.
2026-07-18 12:21:21 +02:00
florian.wilde 08a5ef8a2b v1.6: RSS-Feed für den Speiseplan + Badge für offene Rezeptvorschläge
app/feed.xml: RSS-2.0-Feed mit einem Eintrag pro Tag (aktuelle +
nächste Woche), Gerichte je Essenszeit im CDATA-Beschreibungstext.
Immer auf Deutsch, da Feedreader kein Sprach-Cookie mitschicken.

Autodiscovery-Link im <head> (app/layout.tsx) sowie sichtbares
RSS-Icon im Header der öffentlichen Speisekarte (neben Sprachumschalter/
Anmelden – die einzige Stelle, die auf allen Bildschirmgrößen sichtbar
ist).

AppHeader: "Vorschläge"-Navigationspunkt zeigt jetzt die Anzahl
offener Rezeptvorschläge als Zahl im roten Kreis (GET
/api/dish-suggestions, bereits staff/admin-beschränkt), aktualisiert
sich bei Navigation innerhalb des internen Bereichs.
2026-07-18 12:18:46 +02:00
26 changed files with 1274 additions and 28 deletions
+26 -1
View File
@@ -1,7 +1,32 @@
# Vorlage für .env.local (Entwicklung) bzw. die Umgebungsvariablen
# beim Hoster (Live-Betrieb). Echte Werte niemals committen!
# Verbindung zur (selbst gehosteten) PostgreSQL-Datenbank.
# Nur für Docker Compose relevant: Passwort für den mitgelieferten
# PostgreSQL-Container (Dienst "db" in docker-compose.yml). Daraus baut
# docker-compose.yml die DATABASE_URL für die App-Container selbst
# zusammen – die Zeile unten wird beim Betrieb per Docker Compose
# NICHT verwendet, nur bei manuellem "npm run dev"/"npm run start"
# gegen eine selbst betriebene PostgreSQL-Instanz.
#
# Nur Buchstaben/Zahlen verwenden (z. B. mit "openssl rand -hex 24"
# erzeugen) – Zeichen wie #, $, Leerzeichen oder Anführungszeichen
# werden von Docker Compose beim Einlesen von .env-Dateien speziell
# behandelt (# beginnt z. B. einen Kommentar) und können das Passwort
# unbemerkt verstümmeln.
#
# WICHTIG: PostgreSQL setzt dieses Passwort nur EINMALIG beim allerersten
# Start auf einem leeren Datenverzeichnis. Wird der Wert hier später
# geändert (oder "docker compose up" auch nur einmal ohne
# "--env-file .env.local" ausgeführt, bevor er gesetzt war), bleibt im
# bereits angelegten Docker-Volume das alte Passwort stehen – die App
# bekommt dann "password authentication failed for user postgres". Fix:
# "docker compose down && docker volume rm canteen-menu_pgdata", dann
# neu starten (nur unbedenklich, solange noch keine echten Daten drin
# sind – siehe DEPLOYMENT.md).
LOCAL_PG_PASSWORD=<beliebiges-sicheres-passwort>
# Verbindung zur PostgreSQL-Datenbank (nur für den Betrieb OHNE Docker
# Compose relevant, s. o.).
DATABASE_URL=postgres://postgres:<passwort>@localhost:5432/canteen_menu
# Öffentliche URL, unter der die App erreichbar ist (Schema + Domain).
+107
View File
@@ -4,6 +4,113 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/).
## [1.7.0] – 2026-07-19
### Hinzugefügt
- CSV-Import/-Export für Gerichte, Allergene und Zusatzstoffe (Buttons
„Exportieren“/„Importieren“ auf den jeweiligen Verwaltungsseiten).
Export lädt die aktuelle Liste als CSV herunter (Gerichte:
`name,name_en,name_pl,price,allergens,additives`, Allergene/
Zusatzstoffe: `code,name,name_en,name_pl`; Kürzel bei Gerichten
Semikolon-getrennt in einer Zelle, z. B. `A;G`). Import liest dieselbe
Spaltenstruktur (Reihenfolge egal, Groß-/Kleinschreibung der
Kopfzeile wird ignoriert) und aktualisiert bestehende Einträge
(Abgleich über Kürzel bei Allergenen/Zusatzstoffen, über den exakten
Namen bei Gerichten) bzw. legt neue an. Zeilen mit fehlenden
Pflichtfeldern, ungültigem Preis oder mehrdeutigem Gerichtsnamen
(mehrere Gerichte mit demselben Namen) werden übersprungen und mit
Grund in der Erfolgsmeldung aufgelistet, statt den ganzen Import
abzubrechen.
### Behoben
- RSS-Feed (`/feed.xml`): Mehrere Feed-Reader (u. a. Outlook,
Thunderbird) behandeln `<description>` als kurze Vorschau und kürzen
sie eigenmächtig – dadurch war bei Tagen mit mehreren Essenszeiten
oft nur die erste sichtbar, obwohl der Server bereits alle
Essenszeiten des Tages in der Beschreibung lieferte. Zusätzlich zu
`<description>` wird jetzt auch `<content:encoded>` (RSS-Content-
Modul) mit demselben vollständigen Inhalt ausgeliefert – von den
meisten Readern als vollständiger Artikeltext erkannt und ungekürzt
angezeigt.
## [1.6.3] – 2026-07-18
### Behoben
- `docker-compose.yml`: Der `app`-Dienst setzte `BRANDING_DIR` nicht, sodass
`lib/branding.ts` beim Hochladen von Logo/Favicon auf den Fallback-Pfad
`${cwd}/data/branding` (= `/app/data/branding`) statt auf das im
Dockerfile vorbereitete, gemountete Verzeichnis `/data/branding` auswich.
`/app` gehört im Container `root`, der App-Prozess läuft aber als
nicht-root-Nutzer `nextjs` – das Anlegen des Fallback-Verzeichnisses
scheiterte mit `EACCES`, der Upload endete mit „Upload fehlgeschlagen:
500". Jetzt wird `BRANDING_DIR=/data/branding` explizit gesetzt, passend
zum Volume-Mount und zum in `Dockerfile` per `chown` freigegebenen
Verzeichnis.
## [1.6.2] – 2026-07-18
### Behoben
- `scripts/bootstrap-admin.mjs`: Konnte `ZUGANGSDATEN.md` nicht
geschrieben werden (z. B. `EACCES`, weil die per Bind-Mount
eingebundene Host-Datei root gehört und der App-Container als
nicht-root läuft), brach das Skript ab, nachdem der Admin-Benutzer
bereits in der Datenbank angelegt war – das generierte Passwort war
dann nirgends mehr zu finden. Schreibt jetzt bei einem Fehler
stattdessen E-Mail/Passwort ins Log und beendet sich weiterhin
erfolgreich.
- DEPLOYMENT.md: Schritt 2 fehlte `chmod 666 ZUGANGSDATEN.md` (der
eigentliche Auslöser des Bugs oben). Neue Fehlerbehebung-Einträge für
`ECONNREFUSED` beim Start (harmlose Start-Reihenfolge, löst sich
durch den automatischen Neustart von selbst) und für diesen
`EACCES`-Fall inkl. Recovery-Anleitung für einen bereits verwaisten
Admin-Account.
## [1.6.1] – 2026-07-18
### Geändert
- PostgreSQL-Container ist nicht mehr über einen Host-Port erreichbar
(`docker-compose.yml`: `ports: 5433:5432` beim `db`-Dienst entfernt).
App und Dev-Server erreichen die Datenbank weiterhin über das von
Docker Compose automatisch angelegte interne Netzwerk (Hostname
`db`) – reduziert die Angriffsfläche, ohne den Betrieb zu ändern.
Debugging/Backup weiterhin über `docker compose exec db psql`/
`pg_dump` möglich.
### Behoben
- DEPLOYMENT.md: Fehlerbehebung-Abschnitt für
`password authentication failed for user "postgres"` ergänzt (tritt
auf, wenn `docker compose up` vor dem Setzen von `LOCAL_PG_PASSWORD`
läuft oder ohne `--env-file .env.local` – PostgreSQL übernimmt das
Passwort nur beim allerersten Start auf leerem Datenverzeichnis).
`.env.example` warnt jetzt vor Sonderzeichen in `LOCAL_PG_PASSWORD`,
die beim `.env`-Parsing zu stillen Fehlern führen können.
## [1.6.0] – 2026-07-18
### Hinzugefügt
- RSS-Feed des Speiseplans (`/feed.xml`, ein Eintrag pro Tag mit den
Gerichten je Essenszeit für die aktuelle und die nächste Woche) zum
Abonnieren in einem Feedreader. Autodiscovery-Link im Seiten-`<head>`
sowie sichtbares RSS-Icon im Kopfbereich der öffentlichen
Speisekarte.
- Kantinen-Bereich: „Vorschläge“-Navigationspunkt zeigt jetzt die
Anzahl offener Rezeptvorschläge als Zahl im roten Kreis.
- Neues [DEPLOYMENT.md](DEPLOYMENT.md): Schritt-für-Schritt-Anleitung
für die Inbetriebnahme auf einem neuen Server (Docker Compose,
Konfiguration, Reverse-Proxy/HTTPS, Updates, Backup).
### Geändert
- Letzte Erwähnungen von Supabase in aktiven Doku-Texten und
Code-Kommentaren entfernt/umformuliert (README, Migrationsschema,
Passkey-Konfiguration, Branding-Ablage) – die App läuft seit v1.5
vollständig ohne Supabase, das sollte sich jetzt auch durchgängig in
der Dokumentation widerspiegeln. Historische CHANGELOG-Einträge
bleiben unverändert.
- `.env.example`: `LOCAL_PG_PASSWORD` (von `docker-compose.yml`
verwendet, um die Datenbank-Verbindung der App-Container
zusammenzubauen) war bisher nirgends dokumentiert – beim Schreiben
der Deployment-Anleitung aufgefallen und ergänzt.
## [1.5.0] – 2026-07-18
### Hinzugefügt
+241
View File
@@ -0,0 +1,241 @@
# Deployment auf einem neuen Server
Schritt-für-Schritt-Anleitung, um Canteen Menu auf einem frischen
(Linux-)Server produktiv zu betreiben. Die App läuft vollständig selbst
gehostet (PostgreSQL + eigene Auth), einzige optionale externe
Abhängigkeit ist DeepL für automatische Übersetzungen.
## Voraussetzungen
- Ein Server (VM oder Root-Server) mit Docker und Docker Compose v2
(`docker compose version` sollte funktionieren; auf den meisten
aktuellen Distributionen per `apt install docker.io
docker-compose-v2` bzw. dem [offiziellen Docker-Install-Skript](https://docs.docker.com/engine/install/)
installierbar).
- Eine Domain, deren DNS-A/AAAA-Eintrag auf die Server-IP zeigt (z. B.
`menu.example.de`). Ohne eigene Domain funktioniert die App auch
unter der reinen Server-IP, dann aber ohne gültiges HTTPS-Zertifikat
und ohne funktionierende Passkeys (siehe unten).
- Mindestens 1 GB RAM frei (App + PostgreSQL sind genügsam, für ~250
Mitarbeiter reicht die kleinste übliche VM-Größe).
## 1. Repository auf den Server holen
```bash
git clone https://github.com/<dein-fork-oder-repo>/canteen-menu.git
cd canteen-menu
```
(Oder per `git pull`/`scp`, falls kein direkter Git-Zugriff vom Server
aus gewünscht ist.)
## 2. Konfiguration anlegen
```bash
cp .env.example .env.local
```
`.env.local` mit einem Editor öffnen und ausfüllen:
| Variable | Was rein muss |
| --- | --- |
| `LOCAL_PG_PASSWORD` | Ein neues, zufälliges Passwort **nur aus Buchstaben/Zahlen** (z. B. `openssl rand -hex 24`) – wird beim ersten Start zum Datenbank-Passwort des mitgelieferten PostgreSQL-Containers. Zeichen wie `#`, `$`, Leerzeichen oder Anführungszeichen können beim Einlesen der `.env`-Datei zu Problemen führen (`#` beginnt z. B. einen Kommentar) |
| `APP_URL` | Die öffentliche URL mit Schema, z. B. `https://menu.example.de` (auch wenn HTTPS erst in Schritt 5 eingerichtet wird – schon jetzt korrekt eintragen, da daraus u. a. die Passkey-Domain abgeleitet wird) |
| `SITE_TITLE` | Optional, Titel im Browser-Tab/Überschrift ohne Logo |
| `ADMIN_EMAIL` | Optional, E-Mail des automatisch angelegten Admin-Erstnutzers. Ohne Angabe: `admin@<Hostname aus APP_URL>` |
| `DEEPL_API_KEY` | Optional, nur für automatische Übersetzung |
`DATABASE_URL` in `.env.local` **nicht** anpassen – die wird nur für
den Betrieb ohne Docker gebraucht (siehe README) und beim
Docker-Compose-Start ignoriert.
Zusätzlich eine leere Datei für die automatisch generierten
Admin-Zugangsdaten anlegen, da sie per Bind-Mount in den Container
eingebunden wird. Der App-Container läuft als nicht-root-Nutzer –
damit er die Datei beschreiben darf, muss sie für alle schreibbar sein:
```bash
touch ZUGANGSDATEN.md
chmod 666 ZUGANGSDATEN.md
```
## 3. App starten
**Wichtig:** `.env.local` muss vollständig ausgefüllt sein, *bevor*
dieser Befehl zum ersten Mal läuft – und danach immer mit
`--env-file .env.local` aufgerufen werden. PostgreSQL setzt sein
Passwort nur beim allerersten Start auf einem leeren Datenverzeichnis;
läuft der Befehl vorher schon einmal ohne (oder mit einem anderen)
`--env-file`, landet ein falsches Passwort dauerhaft im Datenbank-Volume
(Symptom: `password authentication failed for user "postgres"` in den
App-Logs, siehe [Fehlerbehebung](#fehlerbehebung)).
```bash
docker compose --env-file .env.local up -d --build
```
Das baut das Docker-Image, startet PostgreSQL und die App, wendet beim
ersten Start automatisch das Datenbankschema an und legt – da die
Datenbank leer ist – einen einzigen Admin-Benutzer mit zufällig
generiertem Passwort an.
Kurz prüfen, ob beides läuft:
```bash
docker compose ps
docker compose logs app --tail 30
```
In den Logs sollte u. a. `bootstrap-admin – Admin angelegt: ...`
stehen.
## 4. Admin-Zugangsdaten abholen
```bash
cat ZUGANGSDATEN.md
```
Enthält E-Mail-Adresse und Passwort des automatisch angelegten
Admin-Kontos. Direkt nach dem ersten Login unter „Mein Konto“ das
Passwort ändern und optional einen Passkey registrieren.
Die App ist jetzt unter `http://<server-ip>:3000` erreichbar – für den
produktiven Betrieb fehlt noch HTTPS/die Domain (nächster Schritt).
## 5. Reverse-Proxy + HTTPS einrichten
Die App selbst spricht nur HTTP auf Port 3000; TLS/HTTPS übernimmt ein
Reverse-Proxy davor. Am einfachsten mit [Caddy](https://caddyserver.com/)
(automatisches Let’s-Encrypt-Zertifikat, keine manuelle Zertifikats-
verwaltung nötig):
```bash
sudo apt install caddy # oder: siehe caddyserver.com/docs/install
```
`/etc/caddy/Caddyfile`:
```
menu.example.de {
reverse_proxy localhost:3000
}
```
```bash
sudo systemctl reload caddy
```
Caddy holt beim ersten Aufruf automatisch ein Zertifikat und leitet
danach HTTP→HTTPS um. Alternative: nginx + certbot, falls bereits ein
nginx-Setup existiert (Standard-`proxy_pass`-Konfiguration auf
`http://127.0.0.1:3000`, Zertifikat separat per `certbot --nginx`).
Danach in `.env.local` sicherstellen, dass `APP_URL=https://menu.example.de`
gesetzt ist (Schema `https`, keine Portangabe), und die App neu
starten, falls diese Variable nach Schritt 2 noch geändert wurde:
```bash
docker compose --env-file .env.local up -d
```
## 6. Danach
- Passkeys funktionieren erst mit korrekt gesetzter `APP_URL` (RP-Domain
wird direkt daraus abgeleitet) und über HTTPS (Passkeys verlangen eine
sichere Origin, `localhost` ausgenommen).
- Weitere Kantinenmitarbeiter/Admins unter „Benutzer“ (nur als Admin
sichtbar) anlegen.
- Essenszeiten, Gerichte, Allergene/Zusatzstoffe sind zu Beginn leer und
werden im Kantinen-Bereich gepflegt.
- Logo/Favicon/Farben unter „Design“ (nur Admin) anpassen.
## Fehlerbehebung
### `password authentication failed for user "postgres"` in den App-Logs
Ursache: Das Docker-Volume der Datenbank wurde bereits mit einem
anderen Passwort initialisiert, als aktuell in `.env.local` steht (z. B.
weil `docker compose up` einmal ohne `--env-file .env.local` lief, bevor
das Passwort gesetzt war, oder `LOCAL_PG_PASSWORD` nachträglich geändert
wurde – PostgreSQL übernimmt das Passwort nur beim allerersten Start).
Fix, solange noch keine echten Daten in der Datenbank sind (bei einem
frischen Deployment immer der Fall):
```bash
docker compose --env-file .env.local down
docker volume rm canteen-menu_pgdata
docker compose --env-file .env.local up -d --build
docker compose logs app --tail 30
```
Existieren bereits echte Daten, stattdessen das Passwort direkt im
laufenden Datenbank-Container an `.env.local` angleichen, statt das
Volume zu löschen:
```bash
docker compose exec db psql -U postgres -c "ALTER USER postgres WITH PASSWORD '<Wert aus LOCAL_PG_PASSWORD>';"
docker compose --env-file .env.local up -d
```
### `Error: connect ECONNREFUSED ...:5432` beim Start
Normal beim allerersten Start: Der App-Container versucht sich zu
verbinden, bevor PostgreSQL im `db`-Container fertig hochgefahren ist.
Dank `restart: unless-stopped` startet der App-Container automatisch
neu und verbindet sich, sobald die Datenbank bereit ist (kann ein paar
Sekunden bis wenige Minuten dauern). Mit `docker compose logs app -f`
verfolgen – taucht der Fehler dauerhaft (mehrere Minuten) weiter auf,
`docker compose ps` prüfen, ob der `db`-Container tatsächlich dauerhaft
läuft.
### `EACCES: permission denied, open '/app/ZUGANGSDATEN.md'`
Die Datei wurde ohne Schreibrecht für alle angelegt (Schritt 2 oben,
`chmod 666` fehlte). Der Admin-Benutzer wurde in diesem Fall trotzdem
in der Datenbank angelegt, nur die Zugangsdaten konnten nicht in die
Datei geschrieben werden – sie stehen dann stattdessen im Log
(`docker compose logs app` nach `bootstrap-admin – Admin-Passwort:`
durchsuchen). Für zukünftige Deployments zusätzlich die Berechtigung
korrigieren:
```bash
chmod 666 ZUGANGSDATEN.md
```
Falls das Passwort auch im Log nicht mehr auffindbar ist (z. B. weil
Logs schon rotiert wurden), Admin-Konto neu anlegen lassen:
```bash
docker compose exec db psql -U postgres -d canteen_menu -c "delete from users;"
chmod 666 ZUGANGSDATEN.md
docker compose restart app
docker compose logs app --tail 20
cat ZUGANGSDATEN.md
```
## Updates einspielen
```bash
git pull
docker compose --env-file .env.local up -d --build
```
Neue Datenbank-Migrationen (`db/migrations/*.sql`) werden beim Start
automatisch und idempotent angewendet, bestehende Daten bleiben
erhalten.
## Backup
Zwei Docker-Volumes enthalten alle persistenten Daten:
- `canteen-menu_pgdata` – die komplette PostgreSQL-Datenbank
- `canteen-menu_branding_data` – hochgeladenes Logo/Favicon
```bash
docker run --rm -v canteen-menu_pgdata:/data -v "$PWD":/backup alpine \
tar czf /backup/pgdata-backup.tar.gz -C /data .
```
Für ein reines Datenbank-Backup eignet sich auch `docker compose exec db
pg_dump -U postgres canteen_menu > backup.sql`.
+8 -5
View File
@@ -11,14 +11,16 @@ 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](CHANGELOG.md), Git-Tags
markieren die jeweiligen Versionsstände (`git tag`).
markieren die jeweiligen Versionsstände (`git tag`). Schritt-für-Schritt-
Anleitung für die Inbetriebnahme auf einem neuen Server:
[DEPLOYMENT.md](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 |
| 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 Tagen/Essenszeiten zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen (ebenfalls mehrsprachig); eingereichte Rezeptvorschläge sichten, übernehmen oder ablehnen; eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) |
| Ö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**
@@ -37,6 +39,7 @@ mit optionaler automatischer Übersetzung über DeepL (siehe
```
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)
@@ -107,7 +110,7 @@ PostgreSQL müssen nicht installiert sein):
```bash
docker compose --env-file .env.local up -d --build
# App: Port 3000 (hinter Reverse-Proxy mit HTTPS betreiben)
# PostgreSQL: Port 5433
# PostgreSQL: kein Host-Port, nur intern im Compose-Netzwerk erreichbar
```
Vor dem ersten Start: eine leere `ZUGANGSDATEN.md` im Projektverzeichnis
@@ -131,7 +134,7 @@ müssen neu registriert werden.
## Datenbank
Reines PostgreSQL (kein Supabase, keine Row-Level-Security-Policies) –
Reines, selbst gehostetes PostgreSQL (keine Row-Level-Security-Policies) –
alle Zugriffsrechte werden in den Route-Handlern geprüft (`lib/auth/session.ts`,
`requireRole`).
+24
View File
@@ -0,0 +1,24 @@
import { listCodes } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv";
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const items = await listCodes("additives");
const rows = [
["code", "name", "name_en", "name_pl"],
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
];
return new Response(toCsv(rows), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="zusatzstoffe.csv"',
},
});
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader } from "@/lib/csv";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const { header, rows: cells } = parseCsvWithHeader(await file.text());
const codeIdx = header.indexOf("code");
const nameIdx = header.indexOf("name");
if (codeIdx === -1 || nameIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const nameEnIdx = header.indexOf("name_en");
const namePlIdx = header.indexOf("name_pl");
const rows: CodeImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
cells.forEach((c, i) => {
const rowNum = i + 2; // 1-indexiert + Kopfzeile
const code = (c[codeIdx] ?? "").trim();
const name = (c[nameIdx] ?? "").trim();
if (!code || !name) {
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
return;
}
rows.push({
code,
name,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
});
});
const result = rows.length > 0 ? await importCodes("additives", rows) : { created: 0, updated: 0 };
return NextResponse.json({ ...result, skipped });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { listCodes } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv";
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const items = await listCodes("allergens");
const rows = [
["code", "name", "name_en", "name_pl"],
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
];
return new Response(toCsv(rows), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="allergene.csv"',
},
});
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader } from "@/lib/csv";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const { header, rows: cells } = parseCsvWithHeader(await file.text());
const codeIdx = header.indexOf("code");
const nameIdx = header.indexOf("name");
if (codeIdx === -1 || nameIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const nameEnIdx = header.indexOf("name_en");
const namePlIdx = header.indexOf("name_pl");
const rows: CodeImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
cells.forEach((c, i) => {
const rowNum = i + 2; // 1-indexiert + Kopfzeile
const code = (c[codeIdx] ?? "").trim();
const name = (c[nameIdx] ?? "").trim();
if (!code || !name) {
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
return;
}
rows.push({
code,
name,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
});
});
const result = rows.length > 0 ? await importCodes("allergens", rows) : { created: 0, updated: 0 };
return NextResponse.json({ ...result, skipped });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { query } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv";
import type { Dish } from "@/lib/types";
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const dishes = await query<Dish>("select * from dishes order by name");
const rows = [
["name", "name_en", "name_pl", "price", "allergens", "additives"],
...dishes.map((d) => [
d.name,
d.name_en ?? "",
d.name_pl ?? "",
String(d.price),
(d.allergens ?? []).join(";"),
(d.additives ?? []).join(";"),
]),
];
return new Response(toCsv(rows), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="gerichte.csv"',
},
});
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextResponse } from "next/server";
import { withTransaction } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
interface ImportRow {
rowNum: number;
name: string;
name_en: string | null;
name_pl: string | null;
price: number;
allergens: string[];
additives: string[];
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const { header, rows: cells } = parseCsvWithHeader(await file.text());
const nameIdx = header.indexOf("name");
const priceIdx = header.indexOf("price");
if (nameIdx === -1 || priceIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const nameEnIdx = header.indexOf("name_en");
const namePlIdx = header.indexOf("name_pl");
const allergensIdx = header.indexOf("allergens");
const additivesIdx = header.indexOf("additives");
const rows: ImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
cells.forEach((c, i) => {
const rowNum = i + 2; // 1-indexiert + Kopfzeile
const name = (c[nameIdx] ?? "").trim();
if (!name) {
skipped.push({ row: rowNum, reason: "Name fehlt" });
return;
}
const priceRaw = (c[priceIdx] ?? "").trim().replace(",", ".");
const price = Number.parseFloat(priceRaw);
if (Number.isNaN(price) || price < 0) {
skipped.push({ row: rowNum, reason: `Ungültiger Preis „${c[priceIdx] ?? ""}“` });
return;
}
rows.push({
rowNum,
name,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
price,
allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [],
additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [],
});
});
let created = 0;
let updated = 0;
if (rows.length > 0) {
await withTransaction(async (client) => {
for (const row of rows) {
// Gerichtsnamen sind nicht eindeutig (keine DB-Constraint) – bei
// mehreren Treffern lieber überspringen als versehentlich alle
// gleichnamigen Gerichte mit denselben Werten zu überschreiben.
const existing = await client.query<{ id: string }>(
"select id from dishes where name = $1",
[row.name]
);
if (existing.rows.length > 1) {
skipped.push({
row: row.rowNum,
reason: `„${row.name}“ ist mehrdeutig (mehrere Gerichte mit diesem Namen) – übersprungen`,
});
continue;
}
if (existing.rows.length === 1) {
await client.query(
`update dishes set name_en = $1, name_pl = $2, price = $3, allergens = $4, additives = $5
where id = $6`,
[row.name_en, row.name_pl, row.price, row.allergens, row.additives, existing.rows[0].id]
);
updated++;
} else {
await client.query(
`insert into dishes (name, name_en, name_pl, price, allergens, additives)
values ($1, $2, $3, $4, $5, $6)`,
[row.name, row.name_en, row.name_pl, row.price, row.allergens, row.additives]
);
created++;
}
}
});
}
return NextResponse.json({ created, updated, skipped });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+114
View File
@@ -0,0 +1,114 @@
import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu";
import { localized, SITE_TITLE } from "@/lib/types";
import type { Section } from "@/lib/types";
// RSS-Feed mit dem Speiseplan – ein Eintrag pro Tag mit Gerichten,
// damit man ihn in einem Feedreader abonnieren kann und täglich sieht,
// was es gibt. Immer auf Deutsch: Feedreader senden kein Sprach-Cookie,
// eine feste Sprache ist hier verständlicher als ein zufälliger Locale-Default.
export const dynamic = "force-dynamic";
function escapeXml(text: string): string {
return text.replace(/[&<>"']/g, (c) => {
switch (c) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&apos;";
}
});
}
function formatFeedDate(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
return d.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
});
}
export async function GET() {
const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const monday = mondayOf(todayISO());
// Aktuelle + nächste Woche, wie auch die öffentliche Speisekarte per
// Wochennavigation anzeigt.
const rangeEnd = addDays(monday, 11);
const [sections, entries] = await Promise.all([
query<Section>("select * from sections order by sort"),
loadMenuEntries(monday, rangeEnd),
]);
const days = Array.from(new Set(entries.map((e) => e.menu_date))).sort();
const items = days
.map((iso) => {
const dayContent = sections
.map((s) => {
const dishes = entriesFor(entries, iso, s.key);
if (dishes.length === 0) return "";
const list = dishes
.map(
(e) =>
`<li>${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}</li>`
)
.join("");
return `<p><strong>${escapeXml(localized(s, "de"))} (${escapeXml(
s.time_range
)})</strong></p><ul>${list}</ul>`;
})
.join("");
if (!dayContent) return "";
const [y, m, d] = iso.split("-").map(Number);
const pubDate = new Date(Date.UTC(y, m - 1, d, 6, 0, 0)).toUTCString();
return ` <item>
<title>${escapeXml(formatFeedDate(iso))}</title>
<link>${appUrl}/?woche=${mondayOf(iso)}</link>
<guid isPermaLink="false">${appUrl}/feed.xml#${iso}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${dayContent}]]></description>
<content:encoded><![CDATA[${dayContent}]]></content:encoded>
</item>`;
})
.join("\n");
// "description" gilt bei vielen Feed-Readern (Thunderbird, Outlook,
// etliche Apps) als reine Kurzfassung/Vorschau und wird eigenmächtig
// gekürzt – dadurch war z. B. bei mehreren Essenszeiten pro Tag oft nur
// die erste sichtbar. "content:encoded" (RSS-Content-Modul) gilt als
// vollständiger Artikeltext und wird von den meisten Readern
// ungekürzt angezeigt, wenn vorhanden – redundant zu description für
// Reader ohne Unterstützung des Moduls.
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${escapeXml(SITE_TITLE)} – Speiseplan</title>
<link>${appUrl}</link>
<description>Täglicher Speiseplan der Kantine</description>
<language>de</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>
`;
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=1800",
},
});
}
+8
View File
@@ -25,6 +25,14 @@ export async function generateMetadata(): Promise<Metadata> {
// einem eigenen Favicon eingebunden (Next.js kombiniert Datei-
// Konvention und Metadata statt sie zu ersetzen).
icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" },
// Autodiscovery für Feedreader (Browser zeigen dafür kein eigenes
// Icon mehr an, daher zusätzlich ein sichtbares RSS-Icon im Header
// der öffentlichen Speisekarte, s. app/page.tsx).
alternates: {
types: {
"application/rss+xml": [{ url: "/feed.xml", title: `${SITE_TITLE} – Speiseplan` }],
},
},
};
}
+19
View File
@@ -56,6 +56,25 @@ export default async function Home({
<div className="hidden sm:block">
<LanguageSwitcher variant="light" />
</div>
<a
href="/feed.xml"
title={dict.publicMenu.rssFeed}
aria-label={dict.publicMenu.rssFeed}
className="text-gray-400 hover:text-[#ee802f]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18"
height="18"
fill="currentColor"
aria-hidden="true"
>
<circle cx="5.5" cy="18.5" r="2.5" />
<path d="M4 11a9 9 0 0 1 9 9h-3a6 6 0 0 0-6-6v-3z" />
<path d="M4 4a16 16 0 0 1 16 16h-3A13 13 0 0 0 4 7V4z" />
</svg>
</a>
<Link
href="/login"
className="text-sm text-gray-500 hover:text-[var(--c-primary)]"
+21 -1
View File
@@ -7,7 +7,9 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import type { Role } from "@/lib/types";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
@@ -20,6 +22,14 @@ export default function AppHeader({
}) {
const pathname = usePathname();
const dict = useDict();
const [suggestionCount, setSuggestionCount] = useState(0);
useEffect(() => {
fetch("/api/dish-suggestions")
.then((res) => (res.ok ? res.json() : []))
.then((data) => setSuggestionCount(Array.isArray(data) ? data.length : 0))
.catch(() => {});
}, [pathname]);
const menuLinks = [
{ href: "/intern", label: dict.header.weekPlan },
@@ -51,8 +61,18 @@ export default function AppHeader({
{dict.header.menuGroup}
</span>
{menuLinks.map((l) => (
<Link key={l.href} href={l.href} className={linkClass(l.href)}>
<Link key={l.href} href={l.href} className={`relative ${linkClass(l.href)}`}>
{l.label}
{l.href === "/intern/vorschlaege" && suggestionCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--c-del)] px-1 text-[10px] font-bold leading-none text-white"
aria-label={formatMessage(dict.header.openSuggestions, {
count: suggestionCount,
})}
>
{suggestionCount}
</span>
)}
</Link>
))}
</nav>
+68
View File
@@ -34,6 +34,8 @@ export default function CodeListManager({
namePl: string;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [ioBusy, setIoBusy] = useState(false);
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
@@ -125,12 +127,77 @@ export default function CodeListManager({
}
}
async function exportItems() {
setError(null);
setNotice(null);
const res = await fetch(`/api/${table}/export`);
if (!res.ok) {
setError(formatMessage(dict.codeList.exportError, { message: String(res.status) }));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = table === "allergens" ? "allergene.csv" : "zusatzstoffe.csv";
a.click();
URL.revokeObjectURL(url);
}
async function importItems(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setNotice(null);
setIoBusy(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/${table}/import`, { method: "POST", body: fd });
setIoBusy(false);
if (!res.ok) {
setError(formatMessage(dict.codeList.importError, { message: String(res.status) }));
return;
}
const data = (await res.json()) as {
created: number;
updated: number;
skipped: { row: number; reason: string }[];
};
let msg = formatMessage(dict.codeList.importSuccess, {
created: data.created,
updated: data.updated,
});
if (data.skipped.length > 0) {
const reasons = data.skipped
.map((s) => formatMessage(dict.codeList.importSkippedRow, s))
.join("; ");
msg +=
" " + formatMessage(dict.codeList.importSkipped, { count: data.skipped.length, reasons });
}
setNotice(msg);
await load();
}
return (
<section className="card">
<div className="flex items-center gap-3 mb-3">
<h2 className="text-lg font-bold text-[var(--c-primary)] mr-auto">
{title}
</h2>
<button type="button" className="btn btn-neutral" onClick={exportItems}>
{dict.codeList.exportButton}
</button>
<label className="btn btn-neutral cursor-pointer">
{dict.codeList.importButton}
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={importItems}
disabled={ioBusy}
/>
</label>
<button
type="button"
className="btn btn-add"
@@ -148,6 +215,7 @@ export default function CodeListManager({
{error}
</p>
)}
{notice && <p className="mb-2 text-sm text-green-700">{notice}</p>}
{editing && (
<form onSubmit={save} className="mb-3 space-y-2 border-b border-gray-200 pb-3">
+68
View File
@@ -40,6 +40,8 @@ export default function DishManager({
const [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [ioBusy, setIoBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
@@ -219,6 +221,58 @@ export default function DishManager({
}
}
async function exportDishes() {
setError(null);
setNotice(null);
const res = await fetch("/api/dishes/export");
if (!res.ok) {
setError(formatMessage(dict.dishManager.exportError, { message: String(res.status) }));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "gerichte.csv";
a.click();
URL.revokeObjectURL(url);
}
async function importDishes(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setNotice(null);
setIoBusy(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/dishes/import", { method: "POST", body: fd });
setIoBusy(false);
if (!res.ok) {
setError(formatMessage(dict.dishManager.importError, { message: String(res.status) }));
return;
}
const data = (await res.json()) as {
created: number;
updated: number;
skipped: { row: number; reason: string }[];
};
let msg = formatMessage(dict.dishManager.importSuccess, {
created: data.created,
updated: data.updated,
});
if (data.skipped.length > 0) {
const reasons = data.skipped
.map((s) => formatMessage(dict.dishManager.importSkippedRow, s))
.join("; ");
msg +=
" " + formatMessage(dict.dishManager.importSkipped, { count: data.skipped.length, reasons });
}
setNotice(msg);
await load();
}
const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id));
@@ -228,6 +282,19 @@ export default function DishManager({
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.dishManager.title}
</h1>
<button type="button" className="btn btn-neutral" onClick={exportDishes}>
{dict.dishManager.exportButton}
</button>
<label className="btn btn-neutral cursor-pointer">
{dict.dishManager.importButton}
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={importDishes}
disabled={ioBusy}
/>
</label>
<button
type="button"
className="btn btn-add"
@@ -245,6 +312,7 @@ export default function DishManager({
{error}
</p>
)}
{notice && <p className="mb-3 text-sm text-green-700">{notice}</p>}
{form && (
<form onSubmit={save} className="card mb-4 space-y-4">
+3 -3
View File
@@ -1,5 +1,5 @@
-- =============================================================
-- Canteen Menu – Initiales Schema (selbst gehostet, ohne Supabase)
-- Canteen Menu – Initiales Schema (vollständig selbst gehostet)
-- Rollen: admin (Benutzerverwaltung, Branding) / staff (Kantine)
-- Öffentliche Speisekarte: lesbar ohne Login
-- =============================================================
@@ -27,8 +27,8 @@ create table sessions (
);
create index sessions_user_id_idx on sessions (user_id);
-- WebAuthn-Credentials (ersetzt Supabase's internes, für die App bisher
-- unsichtbares Passkey-Schema).
-- WebAuthn-Credentials (Passkeys) – eigenes Schema, wird von
-- @simplewebauthn/server geprüft (lib/auth/).
create table passkey_credentials (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users (id) on delete cascade,
+10 -4
View File
@@ -34,6 +34,12 @@ services:
# (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key
# bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer.
DEEPL_API_KEY: ${DEEPL_API_KEY:-}
# Muss auf das "volumes:"-Ziel unten UND das im Dockerfile per
# "mkdir/chown" vorbereitete Verzeichnis zeigen – ohne diese
# Variable würde lib/branding.ts auf ${PWD}/data/branding
# zurückfallen, das weder gemountet noch für den nicht-root
# App-Nutzer beschreibbar ist (Upload schlägt mit 500 fehl).
BRANDING_DIR: /data/branding
ports:
- "0.0.0.0:3000:3000"
volumes:
@@ -50,10 +56,10 @@ services:
environment:
POSTGRES_DB: canteen_menu
POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD:-canteen_menu}
ports:
# Host-Port 5433, da 5432 meist schon von einer nativen
# PostgreSQL-Installation belegt ist.
- "5433:5432"
# Bewusst kein "ports:"-Mapping: die Datenbank ist nur innerhalb des
# Compose-Netzwerks unter "db:5432" erreichbar (für app/dev), nicht
# vom Host oder von außen. Zugriff für Debugging/Backups über
# "docker compose exec db psql -U postgres canteen_menu".
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
+2 -4
View File
@@ -1,9 +1,7 @@
import "server-only";
// RP-Konfiguration wird direkt aus APP_URL/SITE_TITLE abgeleitet – kein
// externer Konfigurationsschritt mehr nötig (ersetzt das bisherige
// scripts/configure-passkeys.mjs, das die Supabase-Management-API
// aufrief).
// RP-Konfiguration wird direkt aus APP_URL/SITE_TITLE abgeleitet –
// kein externer Konfigurationsschritt oder Cloud-API-Aufruf mehr nötig.
function appUrl(): URL {
return new URL(process.env.APP_URL ?? "http://localhost:3000");
}
+2 -2
View File
@@ -2,8 +2,8 @@ import "server-only";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
// Ersetzt den früheren Supabase-Storage-Bucket "branding" durch ein
// lokales Verzeichnis (Docker-Volume in Produktion, s. docker-compose.yml).
// Logo/Favicon liegen als Dateien in einem lokalen Verzeichnis
// (Docker-Volume in Produktion, s. docker-compose.yml).
export function brandingDir(): string {
// turbopackIgnore: process.cwd() ist hier dynamisch, aber der Pfad
// bleibt statisch unter "data/branding" – ohne den Kommentar versucht
+98
View File
@@ -0,0 +1,98 @@
import "server-only";
// Minimaler RFC-4180-artiger CSV-Parser/-Serializer für Import/Export der
// Gerichte-/Allergen-/Zusatzstofflisten. Bewusst ohne npm-Abhängigkeit
// (Projekt-Stil: kein ORM, keine Validierungs-Library, minimale Deps) –
// deckt quoted Felder mit eingebetteten Kommas/Anführungszeichen/
// Zeilenumbrüchen ab, da Gerichtsnamen Kommas enthalten können.
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
let i = 0;
const len = text.length;
function pushField() {
row.push(field);
field = "";
}
function pushRow() {
pushField();
rows.push(row);
row = [];
}
while (i < len) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
inQuotes = false;
i++;
continue;
}
field += c;
i++;
continue;
}
if (c === '"') {
inQuotes = true;
i++;
continue;
}
if (c === ",") {
pushField();
i++;
continue;
}
if (c === "\r") {
i++;
continue;
}
if (c === "\n") {
pushRow();
i++;
continue;
}
field += c;
i++;
}
// Letzte Zeile ohne abschließenden Zeilenumbruch noch anhängen.
if (field !== "" || row.length > 0) pushRow();
// Leere Zeilen überspringen (z. B. durch einen abschließenden Zeilenumbruch).
return rows.filter((r) => !(r.length === 1 && r[0].trim() === ""));
}
function csvField(value: string): string {
if (/[",\r\n]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
export function toCsv(rows: string[][]): string {
return rows.map((row) => row.map(csvField).join(",")).join("\r\n") + "\r\n";
}
/** Kopfzeile in Kleinbuchstaben, damit der Import tolerant gegenüber Groß-/
* Kleinschreibung und Spaltenreihenfolge ist. */
export function parseCsvWithHeader(text: string): { header: string[]; rows: string[][] } {
const table = parseCsv(text);
if (table.length === 0) return { header: [], rows: [] };
const [header, ...rows] = table;
return { header: header.map((h) => h.trim().toLowerCase()), rows };
}
/** Codes einer Zelle wie "A;G;2" in einzelne, getrimmte Kürzel zerlegen. */
export function splitCodes(cell: string | undefined): string[] {
return (cell ?? "")
.split(";")
.map((c) => c.trim())
.filter(Boolean);
}
+45 -1
View File
@@ -1,5 +1,5 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
import { query, queryOne, withTransaction } from "@/lib/db";
import type { CodeItem } from "@/lib/types";
// "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel +
@@ -39,3 +39,47 @@ export async function updateCode(
export async function deleteCode(table: CodeTable, code: string): Promise<void> {
await query(`delete from ${table} where code = $1`, [code]);
}
export interface CodeImportRow {
code: string;
name: string;
name_en: string | null;
name_pl: string | null;
}
/** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden
* aktualisiert – ein doppelter Code innerhalb derselben Datei
* aktualisiert also einfach zweimal statt einen Fehler zu werfen. */
export async function importCodes(
table: CodeTable,
rows: CodeImportRow[]
): Promise<{ created: number; updated: number }> {
return withTransaction(async (client) => {
const existing = await client.query<{ code: string }>(`select code from ${table}`);
const known = new Set(existing.rows.map((r) => r.code));
const maxRow = await client.query<{ sort: number }>(
`select coalesce(max(sort), 0) as sort from ${table}`
);
let nextSort = (maxRow.rows[0]?.sort ?? 0) + 1;
let created = 0;
let updated = 0;
for (const row of rows) {
if (known.has(row.code)) {
await client.query(
`update ${table} set name = $1, name_en = $2, name_pl = $3 where code = $4`,
[row.name, row.name_en, row.name_pl, row.code]
);
updated++;
} else {
await client.query(
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5)`,
[row.code, row.name, row.name_en, row.name_pl, nextSort++]
);
known.add(row.code);
created++;
}
}
return { created, updated };
});
}
+48
View File
@@ -37,6 +37,7 @@ const de = {
users: "Benutzer",
design: "Design",
suggestions: "Vorschläge",
openSuggestions: "{count} offene Rezeptvorschläge",
viewMenu: "Speisekarte ansehen",
myAccount: "Mein Konto",
},
@@ -64,6 +65,7 @@ const de = {
additives: "Zusatzstoffe",
suggestDish: "Rezept vorschlagen",
noEntry: "–",
rssFeed: "RSS-Feed abonnieren",
},
weekEditor: {
title: "Wochenplan",
@@ -104,6 +106,13 @@ const de = {
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
exportButton: "Exportieren",
importButton: "Importieren",
exportError: "Export fehlgeschlagen: {message}",
importError: "Import fehlgeschlagen: {message}",
importSuccess: "{created} neu angelegt, {updated} aktualisiert.",
importSkipped: "{count} Zeile(n) übersprungen: {reasons}",
importSkippedRow: "Zeile {row}: {reason}",
},
codeList: {
allergensTitle: "Allergene",
@@ -117,6 +126,13 @@ const de = {
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
exportButton: "Exportieren",
importButton: "Importieren",
exportError: "Export fehlgeschlagen: {message}",
importError: "Import fehlgeschlagen: {message}",
importSuccess: "{created} neu angelegt, {updated} aktualisiert.",
importSkipped: "{count} Zeile(n) übersprungen: {reasons}",
importSkippedRow: "Zeile {row}: {reason}",
},
sectionManager: {
title: "Essenszeiten",
@@ -291,6 +307,7 @@ const en: Dict = {
users: "Users",
design: "Design",
suggestions: "Suggestions",
openSuggestions: "{count} open dish suggestions",
viewMenu: "View menu",
myAccount: "My account",
},
@@ -318,6 +335,7 @@ const en: Dict = {
additives: "Additives",
suggestDish: "Suggest a dish",
noEntry: "–",
rssFeed: "Subscribe to RSS feed",
},
weekEditor: {
title: "Weekly plan",
@@ -358,6 +376,13 @@ const en: Dict = {
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
exportButton: "Export",
importButton: "Import",
exportError: "Export failed: {message}",
importError: "Import failed: {message}",
importSuccess: "{created} created, {updated} updated.",
importSkipped: "{count} row(s) skipped: {reasons}",
importSkippedRow: "Row {row}: {reason}",
},
codeList: {
allergensTitle: "Allergens",
@@ -371,6 +396,13 @@ const en: Dict = {
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
exportButton: "Export",
importButton: "Import",
exportError: "Export failed: {message}",
importError: "Import failed: {message}",
importSuccess: "{created} created, {updated} updated.",
importSkipped: "{count} row(s) skipped: {reasons}",
importSkippedRow: "Row {row}: {reason}",
},
sectionManager: {
title: "Meal times",
@@ -543,6 +575,7 @@ const pl: Dict = {
users: "Użytkownicy",
design: "Wygląd",
suggestions: "Propozycje",
openSuggestions: "{count} otwartych propozycji dań",
viewMenu: "Zobacz jadłospis",
myAccount: "Moje konto",
},
@@ -570,6 +603,7 @@ const pl: Dict = {
additives: "Dodatki",
suggestDish: "Zaproponuj danie",
noEntry: "–",
rssFeed: "Subskrybuj kanał RSS",
},
weekEditor: {
title: "Plan tygodnia",
@@ -610,6 +644,13 @@ const pl: Dict = {
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
exportButton: "Eksportuj",
importButton: "Importuj",
exportError: "Eksport nie powiódł się: {message}",
importError: "Import nie powiódł się: {message}",
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
importSkipped: "Pominięto wierszy: {count}: {reasons}",
importSkippedRow: "Wiersz {row}: {reason}",
},
codeList: {
allergensTitle: "Alergeny",
@@ -623,6 +664,13 @@ const pl: Dict = {
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
exportButton: "Eksportuj",
importButton: "Importuj",
exportError: "Eksport nie powiódł się: {message}",
importError: "Import nie powiódł się: {message}",
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
importSkipped: "Pominięto wierszy: {count}: {reasons}",
importSkippedRow: "Wiersz {row}: {reason}",
},
sectionManager: {
title: "Pory posiłków",
+68 -2
View File
@@ -1,12 +1,12 @@
{
"name": "canteen-menu",
"version": "1.5.0",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "canteen-menu",
"version": "1.5.0",
"version": "1.7.0",
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
@@ -1767,6 +1767,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canteen-menu",
"version": "1.5.0",
"version": "1.7.0",
"private": true,
"scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",
+21 -4
View File
@@ -58,19 +58,36 @@ async function main() {
[email, passwordHash]
);
// Der Admin-Datensatz steht ab hier in der Datenbank – das darf durch
// ein Problem beim Ablegen der Zugangsdaten nicht mehr verloren gehen
// (sonst gibt es einen Benutzer, dessen Passwort niemand kennt).
// appendFileSync() also bewusst nicht mehr ungefangen werfen lassen,
// sondern im Fehlerfall die Zugangsdaten direkt ins Log schreiben.
const zugangsdatenPath = join(
dirname(dirname(fileURLToPath(import.meta.url))),
"ZUGANGSDATEN.md"
);
const entry = `\n## Admin-Zugang (automatisch angelegt, ${new Date().toISOString().slice(0, 10)})\n\n- E-Mail: \`${email}\`\n- Passwort: \`${password}\`\n\nBitte nach dem ersten Login ändern.\n`;
if (existsSync(zugangsdatenPath)) {
appendFileSync(zugangsdatenPath, entry);
let wroteFile = false;
try {
if (existsSync(zugangsdatenPath)) {
appendFileSync(zugangsdatenPath, entry);
wroteFile = true;
}
} catch (err) {
console.warn(`bootstrap-admin – Konnte ${zugangsdatenPath} nicht beschreiben: ${err.message}`);
}
if (wroteFile) {
console.log(`bootstrap-admin – Admin angelegt: ${email} (Passwort siehe ZUGANGSDATEN.md)`);
} else {
console.warn(
`bootstrap-admin – ${zugangsdatenPath} existiert nicht, Zugangsdaten werden nur geloggt:`
`bootstrap-admin – ${zugangsdatenPath} existiert nicht oder ist nicht beschreibbar. ` +
`Zugangsdaten NUR HIER im Log, bitte sofort notieren:`
);
console.log(`bootstrap-admin – Admin-E-Mail: ${email}`);
console.log(`bootstrap-admin – Admin-Passwort: ${password}`);
}
console.log(`bootstrap-admin – Admin angelegt: ${email} (Passwort siehe ZUGANGSDATEN.md)`);
} finally {
await client.end();
}