Neue interne Route Handler unter app/api/ (dishes inkl. Bulk-Delete, sections, allergens, additives, menu-entries, dish-suggestions, settings, admin/branding) ersetzen den direkten Browser→Postgrest- Zugriff der bisherigen Supabase-Anbindung. Jede Route prüft Autorisierung selbst (lib/auth/session.ts requireRole) statt sich auf Postgres-RLS zu verlassen – ersetzt 1:1 die bisherigen Policies (öffentlich lesbar / staff+admin schreiben / nur admin für Einstellungen & Benutzer). Rezeptvorschlag-Dublettencheck (SuggestionForm) läuft jetzt zwingend serverseitig im POST-Handler, da es keinen Browser→Postgres-Pfad mehr gibt. "Passkey annehmen" (SuggestionReview) läuft transaktional (Gericht anlegen + Vorschlag löschen in einem Schritt). Logo/Favicon: neuer lokaler Dateispeicher (lib/branding.ts, Docker- Volume) statt Supabase-Storage-Bucket, ausgeliefert über app/branding/[filename]. logoUrl()/faviconUrl() liefern jetzt einfache relative Pfade statt einer fest verdrahteten Supabase-Storage-URL. DeepL-Route (api/translate) und Benutzerverwaltung (api/admin/users) laufen jetzt gegen die users-Tabelle statt Supabase Auth. Alle "use client"-Manager-Komponenten (DishManager, WeekEditor, SectionManager, CodeListManager, SuggestionForm/Review, UserManager, DesignEditor) rufen ihre jeweiligen /api/-Routen per fetch() statt den Supabase-Browser-Client zu nutzen.
100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { query } from "@/lib/db";
|
||
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.
|
||
|
||
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" },
|
||
};
|
||
|
||
async function translateWithDeepL(text: string, targetLang: "EN" | "PL", 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 });
|
||
}
|
||
|
||
try {
|
||
const [en, pl] = await Promise.all([
|
||
translateWithDeepL(text, "EN", apiKey),
|
||
translateWithDeepL(text, "PL", apiKey),
|
||
]);
|
||
if (!en || !pl) throw new Error("Keine Übersetzung erhalten.");
|
||
|
||
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]
|
||
);
|
||
}
|
||
|
||
return NextResponse.json({ configured: true, en, pl });
|
||
} catch (err) {
|
||
return NextResponse.json(
|
||
{ error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." },
|
||
{ status: 502 }
|
||
);
|
||
}
|
||
}
|