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>
This commit is contained in:
2026-07-19 23:10:36 +02:00
co-authored by Claude Sonnet 5
parent 5874eb31b6
commit 474467811c
15 changed files with 658 additions and 5 deletions
+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;
}
}