Compare commits

...
11 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
20 changed files with 902 additions and 16 deletions
+16
View File
@@ -7,6 +7,22 @@
# 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
+81
View File
@@ -4,6 +4,87 @@ 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
+78 -2
View File
@@ -39,7 +39,7 @@ cp .env.example .env.local
| Variable | Was rein muss |
| --- | --- |
| `LOCAL_PG_PASSWORD` | Ein neues, zufälliges Passwort (z. B. `openssl rand -base64 24`) – wird beim ersten Start zum Datenbank-Passwort des mitgelieferten PostgreSQL-Containers |
| `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>` |
@@ -51,14 +51,25 @@ 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:
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
```
@@ -138,6 +149,71 @@ docker compose --env-file .env.local up -d
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
+1 -1
View File
@@ -110,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
+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;
}
}
+9 -1
View File
@@ -80,12 +80,20 @@ export async function GET() {
<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">
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${escapeXml(SITE_TITLE)} – Speiseplan</title>
<link>${appUrl}</link>
+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">
+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
+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 };
});
}
+42
View File
@@ -106,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",
@@ -119,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",
@@ -362,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",
@@ -375,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",
@@ -616,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",
@@ -629,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.6.0",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "canteen-menu",
"version": "1.6.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.6.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",
+19 -2
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`;
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();
}