Compare commits

...
7 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
18 changed files with 822 additions and 10 deletions
+61
View File
@@ -4,6 +4,67 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/). 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 ## [1.6.1] – 2026-07-18
### Geändert ### Geändert
+39 -1
View File
@@ -51,10 +51,12 @@ Docker-Compose-Start ignoriert.
Zusätzlich eine leere Datei für die automatisch generierten Zusätzlich eine leere Datei für die automatisch generierten
Admin-Zugangsdaten anlegen, da sie per Bind-Mount in den Container 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 ```bash
touch ZUGANGSDATEN.md touch ZUGANGSDATEN.md
chmod 666 ZUGANGSDATEN.md
``` ```
## 3. App starten ## 3. App starten
@@ -176,6 +178,42 @@ docker compose exec db psql -U postgres -c "ALTER USER postgres WITH PASSWORD '<
docker compose --env-file .env.local up -d 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 ## Updates einspielen
```bash ```bash
+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> <guid isPermaLink="false">${appUrl}/feed.xml#${iso}</guid>
<pubDate>${pubDate}</pubDate> <pubDate>${pubDate}</pubDate>
<description><![CDATA[${dayContent}]]></description> <description><![CDATA[${dayContent}]]></description>
<content:encoded><![CDATA[${dayContent}]]></content:encoded>
</item>`; </item>`;
}) })
.join("\n"); .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"?> 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> <channel>
<title>${escapeXml(SITE_TITLE)} – Speiseplan</title> <title>${escapeXml(SITE_TITLE)} – Speiseplan</title>
<link>${appUrl}</link> <link>${appUrl}</link>
+68
View File
@@ -34,6 +34,8 @@ export default function CodeListManager({
namePl: string; namePl: string;
} | null>(null); } | null>(null);
const [error, setError] = useState<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">( const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle" "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 ( return (
<section className="card"> <section className="card">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<h2 className="text-lg font-bold text-[var(--c-primary)] mr-auto"> <h2 className="text-lg font-bold text-[var(--c-primary)] mr-auto">
{title} {title}
</h2> </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 <button
type="button" type="button"
className="btn btn-add" className="btn btn-add"
@@ -148,6 +215,7 @@ export default function CodeListManager({
{error} {error}
</p> </p>
)} )}
{notice && <p className="mb-2 text-sm text-green-700">{notice}</p>}
{editing && ( {editing && (
<form onSubmit={save} className="mb-3 space-y-2 border-b border-gray-200 pb-3"> <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 [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | null>(null); const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | 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 [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false); 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 = const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id)); 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"> <h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.dishManager.title} {dict.dishManager.title}
</h1> </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 <button
type="button" type="button"
className="btn btn-add" className="btn btn-add"
@@ -245,6 +312,7 @@ export default function DishManager({
{error} {error}
</p> </p>
)} )}
{notice && <p className="mb-3 text-sm text-green-700">{notice}</p>}
{form && ( {form && (
<form onSubmit={save} className="card mb-4 space-y-4"> <form onSubmit={save} className="card mb-4 space-y-4">
+6
View File
@@ -34,6 +34,12 @@ services:
# (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key # (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key
# bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer. # bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer.
DEEPL_API_KEY: ${DEEPL_API_KEY:-} 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: ports:
- "0.0.0.0:3000:3000" - "0.0.0.0:3000:3000"
volumes: volumes:
+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 "server-only";
import { query, queryOne } from "@/lib/db"; import { query, queryOne, withTransaction } from "@/lib/db";
import type { CodeItem } from "@/lib/types"; import type { CodeItem } from "@/lib/types";
// "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel + // "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> { export async function deleteCode(table: CodeTable, code: string): Promise<void> {
await query(`delete from ${table} where code = $1`, [code]); 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}", loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}", saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen 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: { codeList: {
allergensTitle: "Allergene", allergensTitle: "Allergene",
@@ -119,6 +126,13 @@ const de = {
loadError: "Laden fehlgeschlagen: {message}", loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}", saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen 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: { sectionManager: {
title: "Essenszeiten", title: "Essenszeiten",
@@ -362,6 +376,13 @@ const en: Dict = {
loadError: "Loading failed: {message}", loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}", saveError: "Saving failed: {message}",
deleteError: "Deleting 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: { codeList: {
allergensTitle: "Allergens", allergensTitle: "Allergens",
@@ -375,6 +396,13 @@ const en: Dict = {
loadError: "Loading failed: {message}", loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}", saveError: "Saving failed: {message}",
deleteError: "Deleting 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: { sectionManager: {
title: "Meal times", title: "Meal times",
@@ -616,6 +644,13 @@ const pl: Dict = {
loadError: "Wczytywanie nie powiodło się: {message}", loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}", saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie 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: { codeList: {
allergensTitle: "Alergeny", allergensTitle: "Alergeny",
@@ -629,6 +664,13 @@ const pl: Dict = {
loadError: "Wczytywanie nie powiodło się: {message}", loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}", saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie 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: { sectionManager: {
title: "Pory posiłków", title: "Pory posiłków",
+68 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.6.1", "version": "1.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.6.1", "version": "1.7.0",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
@@ -1767,6 +1767,72 @@
"node": ">=14.0.0" "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": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", "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", "name": "canteen-menu",
"version": "1.6.1", "version": "1.7.0",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs", "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] [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( const zugangsdatenPath = join(
dirname(dirname(fileURLToPath(import.meta.url))), dirname(dirname(fileURLToPath(import.meta.url))),
"ZUGANGSDATEN.md" "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`; 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)) { if (existsSync(zugangsdatenPath)) {
appendFileSync(zugangsdatenPath, entry); 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 { } else {
console.warn( 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 { } finally {
await client.end(); await client.end();
} }