Files
florian.wilde 2a2c5affd5 feat: Tage als geschlossen markierbar (Feiertage o. Ä.)
Im Wochenplan lässt sich pro Tag ein optionaler, mehrsprachiger
Schließungsgrund hinterlegen. Geschlossene Tage werden auf der
öffentlichen Speisekarte (Desktop + mobil) und im RSS-Feed als
"Geschlossen" statt mit Essenszeiten angezeigt, im Wochenplan bleibt
"Mahlzeit hinzufügen" für diesen Tag deaktiviert.
2026-07-20 12:35:00 +02:00

117 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from "next/server";
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 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; 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" },
closed_days: { idColumn: "closed_date", translationTable: "closed_day_translations" },
};
// 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";
const res = await fetch(`${host}/v2/translate`, {
method: "POST",
headers: {
Authorization: `DeepL-Auth-Key ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text: [text],
target_lang: targetLang,
source_lang: "DE",
}),
});
if (!res.ok) {
throw new Error(`DeepL antwortete mit ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return data.translations?.[0]?.text as string | undefined;
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
if (err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
throw err;
}
const body = await request.json().catch(() => null);
const { table, id, text } = body ?? {};
const config = ALLOWED_TABLES[table];
if (!config || typeof text !== "string" || !text.trim()) {
return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
}
// Bei einem noch nicht gespeicherten (neuen) Eintrag gibt es keine
// Zeile zum Aktualisieren – nur übersetzen, das Speichern übernimmt
// anschließend das reguläre Formular.
const persist = typeof id === "string" && id.length > 0;
const apiKey = process.env.DEEPL_API_KEY;
if (!apiKey) {
return NextResponse.json({ configured: false });
}
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));
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.");
}
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." },
{ status: 502 }
);
}
}