feat: Speiseplan-Inhalte variabel mehrsprachig (Teil 2)

Gerichtnamen, Essenszeiten und Allergen-/Zusatzstoff-Bezeichnungen sind
jetzt genauso dynamisch mehrsprachig wie die Bedienoberfläche aus
v1.10.0, statt fest auf Englisch/Polnisch begrenzt zu sein. Neue
Übersetzungstabellen ersetzen die alten name_en/name_pl-Spalten,
DeepL-Auto-Übersetzung und CSV-Import/-Export unterstützen jetzt
beliebig viele aktive Sprachen.
This commit is contained in:
2026-07-20 11:10:06 +02:00
parent 0d69d77d4e
commit cd8b0fab9f
24 changed files with 723 additions and 290 deletions
+8 -3
View File
@@ -1,14 +1,19 @@
import { listCodes } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
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 [items, activeCodes] = await Promise.all([
listCodes("additives"),
listActiveLanguageCodes(),
]);
const langCodes = activeCodes.filter((c) => c !== "de");
const rows = [
["code", "name", "name_en", "name_pl"],
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
["code", "name", ...langCodes.map((c) => `name_${c}`)],
...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]),
];
return new Response(toCsv(rows), {
headers: {
+11 -8
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader } from "@/lib/csv";
@@ -22,8 +23,10 @@ export async function POST(request: Request) {
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 activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const langCols = activeCodes
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
.filter((c) => c.idx !== -1);
const rows: CodeImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
@@ -35,12 +38,12 @@ export async function POST(request: Request) {
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 translations: Record<string, string> = {};
for (const { code: lang, idx } of langCols) {
const value = c[idx]?.trim();
if (value) translations[lang] = value;
}
rows.push({ code, name, translations });
});
const result = rows.length > 0 ? await importCodes("additives", rows) : { created: 0, updated: 0 };
+8 -3
View File
@@ -1,14 +1,19 @@
import { listCodes } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
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 [items, activeCodes] = await Promise.all([
listCodes("allergens"),
listActiveLanguageCodes(),
]);
const langCodes = activeCodes.filter((c) => c !== "de");
const rows = [
["code", "name", "name_en", "name_pl"],
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
["code", "name", ...langCodes.map((c) => `name_${c}`)],
...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]),
];
return new Response(toCsv(rows), {
headers: {
+11 -8
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader } from "@/lib/csv";
@@ -22,8 +23,10 @@ export async function POST(request: Request) {
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 activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const langCols = activeCodes
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
.filter((c) => c.idx !== -1);
const rows: CodeImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
@@ -35,12 +38,12 @@ export async function POST(request: Request) {
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 translations: Record<string, string> = {};
for (const { code: lang, idx } of langCols) {
const value = c[idx]?.trim();
if (value) translations[lang] = value;
}
rows.push({ code, name, translations });
});
const result = rows.length > 0 ? await importCodes("allergens", rows) : { created: 0, updated: 0 };
+24 -8
View File
@@ -1,9 +1,18 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Dish } from "@/lib/types";
interface DishRow {
id: string;
name: string;
price: string;
allergens: string[];
additives: string[];
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -16,19 +25,26 @@ export async function PATCH(
const { id } = await params;
const body = (await request.json()) as {
name: string;
name_en: string | null;
name_pl: string | null;
price: number;
allergens: string[];
additives: string[];
translations?: Record<string, string | null>;
};
const dish = await queryOne<Dish>(
`update dishes set name = $1, name_en = $2, name_pl = $3, price = $4,
allergens = $5, additives = $6
where id = $7 returning *`,
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives, id]
const row = await queryOne<DishRow>(
`update dishes set name = $1, price = $2, allergens = $3, additives = $4
where id = $5 returning id, name, price, allergens, additives`,
[body.name, body.price, body.allergens, body.additives, id]
);
if (!dish) return NextResponse.json({ error: "not_found" }, { status: 404 });
if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });
if (body.translations) {
await replaceTranslations("dish_translations", row.id, body.translations);
}
const translations = await loadTranslationsFor("dish_translations", [row.id]);
const dish: Dish = {
...row,
price: Number(row.price),
translations: translations.get(row.id) ?? {},
};
return NextResponse.json(dish);
} catch (err) {
const res = authErrorResponse(err);
+22 -6
View File
@@ -1,19 +1,35 @@
import { query } from "@/lib/db";
import { loadTranslationsFor } from "@/lib/db/translations";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv";
import type { Dish } from "@/lib/types";
interface DishRow {
id: string;
name: string;
price: string;
allergens: string[];
additives: string[];
}
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const dishes = await query<Dish>("select * from dishes order by name");
const [dishes, activeCodes] = await Promise.all([
query<DishRow>("select id, name, price, allergens, additives from dishes order by name"),
listActiveLanguageCodes(),
]);
const langCodes = activeCodes.filter((c) => c !== "de");
const translations = await loadTranslationsFor(
"dish_translations",
dishes.map((d) => d.id)
);
const rows = [
["name", "name_en", "name_pl", "price", "allergens", "additives"],
["name", ...langCodes.map((c) => `name_${c}`), "price", "allergens", "additives"],
...dishes.map((d) => [
d.name,
d.name_en ?? "",
d.name_pl ?? "",
String(d.price),
...langCodes.map((c) => translations.get(d.id)?.[c] ?? ""),
d.price,
(d.allergens ?? []).join(";"),
(d.additives ?? []).join(";"),
]),
+27 -13
View File
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { withTransaction } from "@/lib/db";
import { replaceTranslations } from "@/lib/db/translations";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
@@ -7,8 +9,7 @@ import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
interface ImportRow {
rowNum: number;
name: string;
name_en: string | null;
name_pl: string | null;
translations: Record<string, string>;
price: number;
allergens: string[];
additives: string[];
@@ -32,8 +33,10 @@ export async function POST(request: Request) {
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 activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const langCols = activeCodes
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
.filter((c) => c.idx !== -1);
const allergensIdx = header.indexOf("allergens");
const additivesIdx = header.indexOf("additives");
@@ -52,11 +55,15 @@ export async function POST(request: Request) {
skipped.push({ row: rowNum, reason: `Ungültiger Preis „${c[priceIdx] ?? ""}“` });
return;
}
const translations: Record<string, string> = {};
for (const { code, idx } of langCols) {
const value = c[idx]?.trim();
if (value) translations[code] = value;
}
rows.push({
rowNum,
name,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
translations,
price,
allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [],
additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [],
@@ -65,6 +72,7 @@ export async function POST(request: Request) {
let created = 0;
let updated = 0;
const savedTranslations: { id: string; translations: Record<string, string> }[] = [];
if (rows.length > 0) {
await withTransaction(async (client) => {
for (const row of rows) {
@@ -84,21 +92,27 @@ export async function POST(request: Request) {
}
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]
`update dishes set price = $1, allergens = $2, additives = $3 where id = $4`,
[row.price, row.allergens, row.additives, existing.rows[0].id]
);
savedTranslations.push({ id: existing.rows[0].id, translations: row.translations });
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]
const inserted = await client.query<{ id: string }>(
`insert into dishes (name, price, allergens, additives)
values ($1, $2, $3, $4) returning id`,
[row.name, row.price, row.allergens, row.additives]
);
savedTranslations.push({ id: inserted.rows[0].id, translations: row.translations });
created++;
}
}
});
for (const { id, translations } of savedTranslations) {
if (Object.keys(translations).length > 0) {
await replaceTranslations("dish_translations", id, translations);
}
}
}
return NextResponse.json({ created, updated, skipped });
+35 -9
View File
@@ -1,12 +1,35 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Dish } from "@/lib/types";
interface DishRow {
id: string;
name: string;
price: string;
allergens: string[];
additives: string[];
}
async function withTranslations(rows: DishRow[]): Promise<Dish[]> {
const translations = await loadTranslationsFor(
"dish_translations",
rows.map((r) => r.id)
);
return rows.map((r) => ({
...r,
price: Number(r.price),
translations: translations.get(r.id) ?? {},
}));
}
export async function GET() {
const dishes = await query<Dish>("select * from dishes order by name");
return NextResponse.json(dishes);
const rows = await query<DishRow>(
"select id, name, price, allergens, additives from dishes order by name"
);
return NextResponse.json(await withTranslations(rows));
}
export async function POST(request: Request) {
@@ -17,17 +40,20 @@ export async function POST(request: Request) {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
name: string;
name_en: string | null;
name_pl: string | null;
price: number;
allergens: string[];
additives: string[];
translations?: Record<string, string | null>;
};
const dish = await queryOne<Dish>(
`insert into dishes (name, name_en, name_pl, price, allergens, additives)
values ($1, $2, $3, $4, $5, $6) returning *`,
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives]
);
const row = (await queryOne<DishRow>(
`insert into dishes (name, price, allergens, additives)
values ($1, $2, $3, $4) returning id, name, price, allergens, additives`,
[body.name, body.price, body.allergens, body.additives]
))!;
if (body.translations) {
await replaceTranslations("dish_translations", row.id, body.translations);
}
const [dish] = await withTranslations([row]);
return NextResponse.json(dish, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
+31 -11
View File
@@ -1,9 +1,17 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Section } from "@/lib/types";
interface SectionRow {
key: string;
label: string;
time_range: string;
sort: number;
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ key: string }> }
@@ -15,25 +23,37 @@ export async function PATCH(
await requireRole(["staff", "admin"]);
const { key } = await params;
const body = (await request.json()) as Partial<
Pick<Section, "label" | "label_en" | "label_pl" | "time_range" | "sort">
>;
Pick<Section, "label" | "time_range" | "sort">
> & { translations?: Record<string, string | null> };
const fields: string[] = [];
const values: unknown[] = [];
for (const col of ["label", "label_en", "label_pl", "time_range", "sort"] as const) {
for (const col of ["label", "time_range", "sort"] as const) {
if (body[col] !== undefined) {
values.push(body[col]);
fields.push(`${col} = $${values.length}`);
}
}
if (fields.length === 0) return NextResponse.json({ error: "empty_update" }, { status: 400 });
values.push(key);
const section = await queryOne<Section>(
`update sections set ${fields.join(", ")} where key = $${values.length} returning *`,
values
);
if (!section) return NextResponse.json({ error: "not_found" }, { status: 404 });
let row: SectionRow | null;
if (fields.length > 0) {
values.push(key);
row = await queryOne<SectionRow>(
`update sections set ${fields.join(", ")} where key = $${values.length}
returning key, label, time_range, sort`,
values
);
} else {
row = await queryOne<SectionRow>(
"select key, label, time_range, sort from sections where key = $1",
[key]
);
}
if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });
if (body.translations) {
await replaceTranslations("section_translations", row.key, body.translations);
}
const translations = await loadTranslationsFor("section_translations", [row.key]);
const section: Section = { ...row, translations: translations.get(row.key) ?? {} };
return NextResponse.json(section);
} catch (err) {
const res = authErrorResponse(err);
+30 -9
View File
@@ -1,12 +1,30 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Section } from "@/lib/types";
interface SectionRow {
key: string;
label: string;
time_range: string;
sort: number;
}
async function withTranslations(rows: SectionRow[]): Promise<Section[]> {
const translations = await loadTranslationsFor(
"section_translations",
rows.map((r) => r.key)
);
return rows.map((r) => ({ ...r, translations: translations.get(r.key) ?? {} }));
}
export async function GET() {
const sections = await query<Section>("select * from sections order by sort");
return NextResponse.json(sections);
const rows = await query<SectionRow>(
"select key, label, time_range, sort from sections order by sort"
);
return NextResponse.json(await withTranslations(rows));
}
export async function POST(request: Request) {
@@ -17,19 +35,22 @@ export async function POST(request: Request) {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
label: string;
label_en: string | null;
label_pl: string | null;
time_range: string;
translations?: Record<string, string | null>;
};
const maxRow = await queryOne<{ sort: number }>(
"select sort from sections order by sort desc limit 1"
);
const section = await queryOne<Section>(
`insert into sections (label, label_en, label_pl, time_range, sort)
values ($1, $2, $3, $4, $5) returning *`,
[body.label, body.label_en, body.label_pl, body.time_range, (maxRow?.sort ?? 0) + 1]
);
const row = (await queryOne<SectionRow>(
`insert into sections (label, time_range, sort)
values ($1, $2, $3) returning key, label, time_range, sort`,
[body.label, body.time_range, (maxRow?.sort ?? 0) + 1]
))!;
if (body.translations) {
await replaceTranslations("section_translations", row.key, body.translations);
}
const [section] = await withTranslations([row]);
return NextResponse.json(section, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
+43 -27
View File
@@ -1,21 +1,31 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { replaceTranslations, type TranslationTable } from "@/lib/db/translations";
import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Übersetzt einen deutschen Text nach Englisch/Polnisch via DeepL und
// schreibt das Ergebnis in die _en/_pl-Spalte der angegebenen Zeile.
// Ohne DEEPL_API_KEY läuft die Route kontrolliert ins Leere (configured:
// false), damit das UI die manuelle Eingabe anbieten kann.
// Übersetzt einen deutschen Text via DeepL in alle aktiven, von DeepL
// unterstützten Sprachen und schreibt das Ergebnis in die passende
// *_translations-Tabelle. Ohne DEEPL_API_KEY läuft die Route kontrolliert
// ins Leere (configured: false), damit das UI die manuelle Eingabe anbietet.
const ALLOWED_TABLES: Record<string, { idColumn: string; textColumn: string }> = {
dishes: { idColumn: "id", textColumn: "name" },
sections: { idColumn: "key", textColumn: "label" },
allergens: { idColumn: "code", textColumn: "name" },
additives: { idColumn: "code", textColumn: "name" },
const ALLOWED_TABLES: Record<string, { idColumn: string; translationTable: TranslationTable }> = {
dishes: { idColumn: "id", translationTable: "dish_translations" },
sections: { idColumn: "key", translationTable: "section_translations" },
allergens: { idColumn: "code", translationTable: "allergen_translations" },
additives: { idColumn: "code", translationTable: "additive_translations" },
};
async function translateWithDeepL(text: string, targetLang: "EN" | "PL", apiKey: string) {
// Unsere Sprachcodes -> DeepL target_lang. Sprachen ohne Eintrag hier sind
// von DeepL nicht unterstützt und bleiben bei manueller Eingabe.
const DEEPL_TARGET_LANG: Record<string, string> = {
en: "EN", pl: "PL", fr: "FR", es: "ES", it: "IT", nl: "NL", pt: "PT-PT",
ru: "RU", ja: "JA", zh: "ZH", cs: "CS", da: "DA", el: "EL", fi: "FI",
hu: "HU", id: "ID", ko: "KO", nb: "NB", ro: "RO", sk: "SK", sl: "SL",
sv: "SV", tr: "TR", uk: "UK", bg: "BG", et: "ET", lv: "LV", lt: "LT",
};
async function translateWithDeepL(text: string, targetLang: string, apiKey: string) {
const host = apiKey.endsWith(":fx")
? "https://api-free.deepl.com"
: "https://api.deepl.com";
@@ -71,25 +81,31 @@ export async function POST(request: Request) {
return NextResponse.json({ configured: false });
}
try {
const [en, pl] = await Promise.all([
translateWithDeepL(text, "EN", apiKey),
translateWithDeepL(text, "PL", apiKey),
]);
if (!en || !pl) throw new Error("Keine Übersetzung erhalten.");
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const targets = activeCodes
.map((code) => ({ code, target: DEEPL_TARGET_LANG[code.toLowerCase()] }))
.filter((t): t is { code: string; target: string } => Boolean(t.target));
if (persist) {
// table/idColumn/textColumn kommen ausschließlich aus dem oben
// geprüften ALLOWED_TABLES-Whitelist, nie aus Nutzereingaben –
// sicher für die String-Interpolation in der Query.
await query(
`update ${table} set ${config.textColumn}_en = $1, ${config.textColumn}_pl = $2
where ${config.idColumn} = $3`,
[en, pl, id]
);
try {
const results = await Promise.all(
targets.map(async (t) => ({
code: t.code,
text: await translateWithDeepL(text, t.target, apiKey),
}))
);
const translations: Record<string, string> = {};
for (const r of results) {
if (r.text) translations[r.code] = r.text;
}
if (Object.keys(translations).length === 0) {
throw new Error("Keine Übersetzung erhalten.");
}
return NextResponse.json({ configured: true, en, pl });
if (persist) {
await replaceTranslations(config.translationTable, id, translations);
}
return NextResponse.json({ configured: true, translations });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." },