Merge feature/v1.11.0: Speiseplan-Inhalte variabel mehrsprachig

This commit is contained in:
2026-07-20 11:11:09 +02:00
24 changed files with 723 additions and 290 deletions
+34
View File
@@ -4,6 +4,40 @@ 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.11.0] – 2026-07-20
### Hinzugefügt
- Speiseplan-Inhalte variabel gemacht (Teil 2 des "mehrsprachig
variabel"-Umbaus): Gerichtnamen, Essenszeiten-Bezeichnungen sowie
Allergen-/Zusatzstoff-Namen sind jetzt genauso dynamisch mehrsprachig
wie die Bedienoberfläche aus Version 1.10.0, statt auf feste
Englisch-/Polnisch-Spalten begrenzt zu sein:
- In den Verwaltungsseiten (Gerichte, Essenszeiten, Allergene &
Zusatzstoffe) erscheint jetzt automatisch ein Eingabefeld je aktiv
geschalteter Sprache – wird im Sprachen-Admin eine neue Sprache
angelegt, taucht dort sofort ein passendes Feld auf, ohne
Codeänderung.
- Der "Automatisch übersetzen"-Button (DeepL) übersetzt künftig in
alle aktiven, von DeepL unterstützten Sprachen statt fix nur
Englisch/Polnisch.
- CSV-Export/-Import für Gerichte, Allergene und Zusatzstoffe hat
jetzt eine Spalte je aktiver Sprache (`name_en`, `name_fr`, …)
statt der bisher fest verdrahteten `name_en`/`name_pl`-Spalten.
- Reduziert der Admin das Sprachenfeature im Sprachen-Bereich auf nur
Deutsch, verschwinden diese Zusatzfelder ebenfalls automatisch aus
allen drei Verwaltungsseiten.
### Geändert
- Neue Migration `0006_content_translations.sql` ersetzt die festen
Spalten `name_en`/`name_pl` (Gerichte, Allergene, Zusatzstoffe) bzw.
`label_en`/`label_pl` (Essenszeiten) durch je eine
Übersetzungstabelle (`dish_translations`, `section_translations`,
`allergen_translations`, `additive_translations`); bestehende
Übersetzungen werden dabei automatisch übernommen.
**Vor dem Einspielen auf einem Produktivsystem unbedingt ein Backup
der Datenbank anlegen** – die alten Spalten werden nach der
Übernahme entfernt.
## [1.10.0] – 2026-07-20 ## [1.10.0] – 2026-07-20
### Hinzugefügt ### Hinzugefügt
+8 -3
View File
@@ -1,14 +1,19 @@
import { listCodes } from "@/lib/db/code-lists"; import { listCodes } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv"; import { toCsv } from "@/lib/csv";
export async function GET() { export async function GET() {
try { try {
await requireRole(["staff", "admin"]); 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 = [ const rows = [
["code", "name", "name_en", "name_pl"], ["code", "name", ...langCodes.map((c) => `name_${c}`)],
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]), ...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]),
]; ];
return new Response(toCsv(rows), { return new Response(toCsv(rows), {
headers: { headers: {
+11 -8
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists"; import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader } from "@/lib/csv"; import { parseCsvWithHeader } from "@/lib/csv";
@@ -22,8 +23,10 @@ export async function POST(request: Request) {
if (codeIdx === -1 || nameIdx === -1) { if (codeIdx === -1 || nameIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 }); return NextResponse.json({ error: "missing_columns" }, { status: 400 });
} }
const nameEnIdx = header.indexOf("name_en"); const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const namePlIdx = header.indexOf("name_pl"); const langCols = activeCodes
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
.filter((c) => c.idx !== -1);
const rows: CodeImportRow[] = []; const rows: CodeImportRow[] = [];
const skipped: { row: number; reason: string }[] = []; 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" }); skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
return; return;
} }
rows.push({ const translations: Record<string, string> = {};
code, for (const { code: lang, idx } of langCols) {
name, const value = c[idx]?.trim();
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null, if (value) translations[lang] = value;
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null, }
}); rows.push({ code, name, translations });
}); });
const result = rows.length > 0 ? await importCodes("additives", rows) : { created: 0, updated: 0 }; 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 { listCodes } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv"; import { toCsv } from "@/lib/csv";
export async function GET() { export async function GET() {
try { try {
await requireRole(["staff", "admin"]); 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 = [ const rows = [
["code", "name", "name_en", "name_pl"], ["code", "name", ...langCodes.map((c) => `name_${c}`)],
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]), ...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]),
]; ];
return new Response(toCsv(rows), { return new Response(toCsv(rows), {
headers: { headers: {
+11 -8
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists"; import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader } from "@/lib/csv"; import { parseCsvWithHeader } from "@/lib/csv";
@@ -22,8 +23,10 @@ export async function POST(request: Request) {
if (codeIdx === -1 || nameIdx === -1) { if (codeIdx === -1 || nameIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 }); return NextResponse.json({ error: "missing_columns" }, { status: 400 });
} }
const nameEnIdx = header.indexOf("name_en"); const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const namePlIdx = header.indexOf("name_pl"); const langCols = activeCodes
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
.filter((c) => c.idx !== -1);
const rows: CodeImportRow[] = []; const rows: CodeImportRow[] = [];
const skipped: { row: number; reason: string }[] = []; 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" }); skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
return; return;
} }
rows.push({ const translations: Record<string, string> = {};
code, for (const { code: lang, idx } of langCols) {
name, const value = c[idx]?.trim();
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null, if (value) translations[lang] = value;
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null, }
}); rows.push({ code, name, translations });
}); });
const result = rows.length > 0 ? await importCodes("allergens", rows) : { created: 0, updated: 0 }; 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 { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db"; import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
import type { Dish } from "@/lib/types"; import type { Dish } from "@/lib/types";
interface DishRow {
id: string;
name: string;
price: string;
allergens: string[];
additives: string[];
}
export async function PATCH( export async function PATCH(
request: Request, request: Request,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
@@ -16,19 +25,26 @@ export async function PATCH(
const { id } = await params; const { id } = await params;
const body = (await request.json()) as { const body = (await request.json()) as {
name: string; name: string;
name_en: string | null;
name_pl: string | null;
price: number; price: number;
allergens: string[]; allergens: string[];
additives: string[]; additives: string[];
translations?: Record<string, string | null>;
}; };
const dish = await queryOne<Dish>( const row = await queryOne<DishRow>(
`update dishes set name = $1, name_en = $2, name_pl = $3, price = $4, `update dishes set name = $1, price = $2, allergens = $3, additives = $4
allergens = $5, additives = $6 where id = $5 returning id, name, price, allergens, additives`,
where id = $7 returning *`, [body.name, body.price, body.allergens, body.additives, id]
[body.name, body.name_en, body.name_pl, 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); return NextResponse.json(dish);
} catch (err) { } catch (err) {
const res = authErrorResponse(err); const res = authErrorResponse(err);
+22 -6
View File
@@ -1,19 +1,35 @@
import { query } from "@/lib/db"; 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 { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv"; 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() { export async function GET() {
try { try {
await requireRole(["staff", "admin"]); 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 = [ const rows = [
["name", "name_en", "name_pl", "price", "allergens", "additives"], ["name", ...langCodes.map((c) => `name_${c}`), "price", "allergens", "additives"],
...dishes.map((d) => [ ...dishes.map((d) => [
d.name, d.name,
d.name_en ?? "", ...langCodes.map((c) => translations.get(d.id)?.[c] ?? ""),
d.name_pl ?? "", d.price,
String(d.price),
(d.allergens ?? []).join(";"), (d.allergens ?? []).join(";"),
(d.additives ?? []).join(";"), (d.additives ?? []).join(";"),
]), ]),
+27 -13
View File
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { withTransaction } from "@/lib/db"; 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 { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader, splitCodes } from "@/lib/csv"; import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
@@ -7,8 +9,7 @@ import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
interface ImportRow { interface ImportRow {
rowNum: number; rowNum: number;
name: string; name: string;
name_en: string | null; translations: Record<string, string>;
name_pl: string | null;
price: number; price: number;
allergens: string[]; allergens: string[];
additives: string[]; additives: string[];
@@ -32,8 +33,10 @@ export async function POST(request: Request) {
if (nameIdx === -1 || priceIdx === -1) { if (nameIdx === -1 || priceIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 }); return NextResponse.json({ error: "missing_columns" }, { status: 400 });
} }
const nameEnIdx = header.indexOf("name_en"); const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const namePlIdx = header.indexOf("name_pl"); const langCols = activeCodes
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
.filter((c) => c.idx !== -1);
const allergensIdx = header.indexOf("allergens"); const allergensIdx = header.indexOf("allergens");
const additivesIdx = header.indexOf("additives"); 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] ?? ""}“` }); skipped.push({ row: rowNum, reason: `Ungültiger Preis „${c[priceIdx] ?? ""}“` });
return; return;
} }
const translations: Record<string, string> = {};
for (const { code, idx } of langCols) {
const value = c[idx]?.trim();
if (value) translations[code] = value;
}
rows.push({ rows.push({
rowNum, rowNum,
name, name,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null, translations,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
price, price,
allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [], allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [],
additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [], additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [],
@@ -65,6 +72,7 @@ export async function POST(request: Request) {
let created = 0; let created = 0;
let updated = 0; let updated = 0;
const savedTranslations: { id: string; translations: Record<string, string> }[] = [];
if (rows.length > 0) { if (rows.length > 0) {
await withTransaction(async (client) => { await withTransaction(async (client) => {
for (const row of rows) { for (const row of rows) {
@@ -84,21 +92,27 @@ export async function POST(request: Request) {
} }
if (existing.rows.length === 1) { if (existing.rows.length === 1) {
await client.query( await client.query(
`update dishes set name_en = $1, name_pl = $2, price = $3, allergens = $4, additives = $5 `update dishes set price = $1, allergens = $2, additives = $3 where id = $4`,
where id = $6`, [row.price, row.allergens, row.additives, existing.rows[0].id]
[row.name_en, row.name_pl, row.price, row.allergens, row.additives, existing.rows[0].id]
); );
savedTranslations.push({ id: existing.rows[0].id, translations: row.translations });
updated++; updated++;
} else { } else {
await client.query( const inserted = await client.query<{ id: string }>(
`insert into dishes (name, name_en, name_pl, price, allergens, additives) `insert into dishes (name, price, allergens, additives)
values ($1, $2, $3, $4, $5, $6)`, values ($1, $2, $3, $4) returning id`,
[row.name, row.name_en, row.name_pl, row.price, row.allergens, row.additives] [row.name, row.price, row.allergens, row.additives]
); );
savedTranslations.push({ id: inserted.rows[0].id, translations: row.translations });
created++; 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 }); return NextResponse.json({ created, updated, skipped });
+35 -9
View File
@@ -1,12 +1,35 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db"; import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
import type { Dish } from "@/lib/types"; 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() { export async function GET() {
const dishes = await query<Dish>("select * from dishes order by name"); const rows = await query<DishRow>(
return NextResponse.json(dishes); "select id, name, price, allergens, additives from dishes order by name"
);
return NextResponse.json(await withTranslations(rows));
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -17,17 +40,20 @@ export async function POST(request: Request) {
await requireRole(["staff", "admin"]); await requireRole(["staff", "admin"]);
const body = (await request.json()) as { const body = (await request.json()) as {
name: string; name: string;
name_en: string | null;
name_pl: string | null;
price: number; price: number;
allergens: string[]; allergens: string[];
additives: string[]; additives: string[];
translations?: Record<string, string | null>;
}; };
const dish = await queryOne<Dish>( const row = (await queryOne<DishRow>(
`insert into dishes (name, name_en, name_pl, price, allergens, additives) `insert into dishes (name, price, allergens, additives)
values ($1, $2, $3, $4, $5, $6) returning *`, values ($1, $2, $3, $4) returning id, name, price, allergens, additives`,
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.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 }); return NextResponse.json(dish, { status: 201 });
} catch (err) { } catch (err) {
const res = authErrorResponse(err); const res = authErrorResponse(err);
+31 -11
View File
@@ -1,9 +1,17 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db"; import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
import type { Section } from "@/lib/types"; import type { Section } from "@/lib/types";
interface SectionRow {
key: string;
label: string;
time_range: string;
sort: number;
}
export async function PATCH( export async function PATCH(
request: Request, request: Request,
{ params }: { params: Promise<{ key: string }> } { params }: { params: Promise<{ key: string }> }
@@ -15,25 +23,37 @@ export async function PATCH(
await requireRole(["staff", "admin"]); await requireRole(["staff", "admin"]);
const { key } = await params; const { key } = await params;
const body = (await request.json()) as Partial< 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 fields: string[] = [];
const values: unknown[] = []; 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) { if (body[col] !== undefined) {
values.push(body[col]); values.push(body[col]);
fields.push(`${col} = $${values.length}`); fields.push(`${col} = $${values.length}`);
} }
} }
if (fields.length === 0) return NextResponse.json({ error: "empty_update" }, { status: 400 }); let row: SectionRow | null;
values.push(key); if (fields.length > 0) {
values.push(key);
const section = await queryOne<Section>( row = await queryOne<SectionRow>(
`update sections set ${fields.join(", ")} where key = $${values.length} returning *`, `update sections set ${fields.join(", ")} where key = $${values.length}
values returning key, label, time_range, sort`,
); values
if (!section) return NextResponse.json({ error: "not_found" }, { status: 404 }); );
} 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); return NextResponse.json(section);
} catch (err) { } catch (err) {
const res = authErrorResponse(err); const res = authErrorResponse(err);
+30 -9
View File
@@ -1,12 +1,30 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db"; import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
import type { Section } from "@/lib/types"; 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() { export async function GET() {
const sections = await query<Section>("select * from sections order by sort"); const rows = await query<SectionRow>(
return NextResponse.json(sections); "select key, label, time_range, sort from sections order by sort"
);
return NextResponse.json(await withTranslations(rows));
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -17,19 +35,22 @@ export async function POST(request: Request) {
await requireRole(["staff", "admin"]); await requireRole(["staff", "admin"]);
const body = (await request.json()) as { const body = (await request.json()) as {
label: string; label: string;
label_en: string | null;
label_pl: string | null;
time_range: string; time_range: string;
translations?: Record<string, string | null>;
}; };
const maxRow = await queryOne<{ sort: number }>( const maxRow = await queryOne<{ sort: number }>(
"select sort from sections order by sort desc limit 1" "select sort from sections order by sort desc limit 1"
); );
const section = await queryOne<Section>( const row = (await queryOne<SectionRow>(
`insert into sections (label, label_en, label_pl, time_range, sort) `insert into sections (label, time_range, sort)
values ($1, $2, $3, $4, $5) returning *`, values ($1, $2, $3) returning key, label, time_range, sort`,
[body.label, body.label_en, body.label_pl, body.time_range, (maxRow?.sort ?? 0) + 1] [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 }); return NextResponse.json(section, { status: 201 });
} catch (err) { } catch (err) {
const res = authErrorResponse(err); const res = authErrorResponse(err);
+43 -27
View File
@@ -1,21 +1,31 @@
import { NextResponse } from "next/server"; 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 { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
// Übersetzt einen deutschen Text nach Englisch/Polnisch via DeepL und // Übersetzt einen deutschen Text via DeepL in alle aktiven, von DeepL
// schreibt das Ergebnis in die _en/_pl-Spalte der angegebenen Zeile. // unterstützten Sprachen und schreibt das Ergebnis in die passende
// Ohne DEEPL_API_KEY läuft die Route kontrolliert ins Leere (configured: // *_translations-Tabelle. Ohne DEEPL_API_KEY läuft die Route kontrolliert
// false), damit das UI die manuelle Eingabe anbieten kann. // ins Leere (configured: false), damit das UI die manuelle Eingabe anbietet.
const ALLOWED_TABLES: Record<string, { idColumn: string; textColumn: string }> = { const ALLOWED_TABLES: Record<string, { idColumn: string; translationTable: TranslationTable }> = {
dishes: { idColumn: "id", textColumn: "name" }, dishes: { idColumn: "id", translationTable: "dish_translations" },
sections: { idColumn: "key", textColumn: "label" }, sections: { idColumn: "key", translationTable: "section_translations" },
allergens: { idColumn: "code", textColumn: "name" }, allergens: { idColumn: "code", translationTable: "allergen_translations" },
additives: { idColumn: "code", textColumn: "name" }, 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") const host = apiKey.endsWith(":fx")
? "https://api-free.deepl.com" ? "https://api-free.deepl.com"
: "https://api.deepl.com"; : "https://api.deepl.com";
@@ -71,25 +81,31 @@ export async function POST(request: Request) {
return NextResponse.json({ configured: false }); return NextResponse.json({ configured: false });
} }
try { const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const [en, pl] = await Promise.all([ const targets = activeCodes
translateWithDeepL(text, "EN", apiKey), .map((code) => ({ code, target: DEEPL_TARGET_LANG[code.toLowerCase()] }))
translateWithDeepL(text, "PL", apiKey), .filter((t): t is { code: string; target: string } => Boolean(t.target));
]);
if (!en || !pl) throw new Error("Keine Übersetzung erhalten.");
if (persist) { try {
// table/idColumn/textColumn kommen ausschließlich aus dem oben const results = await Promise.all(
// geprüften ALLOWED_TABLES-Whitelist, nie aus Nutzereingaben – targets.map(async (t) => ({
// sicher für die String-Interpolation in der Query. code: t.code,
await query( text: await translateWithDeepL(text, t.target, apiKey),
`update ${table} set ${config.textColumn}_en = $1, ${config.textColumn}_pl = $2 }))
where ${config.idColumn} = $3`, );
[en, pl, id] 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) { } catch (err) {
return NextResponse.json( return NextResponse.json(
{ error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." }, { error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." },
+30 -4
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries"; import { loadMenuEntries } from "@/lib/db/menu-entries";
import { loadTranslations } from "@/lib/db/translations";
import { getSettings } from "@/lib/db/settings"; import { getSettings } from "@/lib/db/settings";
import { import {
addDays, addDays,
@@ -33,14 +34,39 @@ export default async function Home({
iso: addDays(monday, i), iso: addDays(monday, i),
})); }));
const [sections, entries, allergens, additives, settings] = await Promise.all([ const [
query<Section>("select * from sections order by sort"), sectionRows,
entries,
allergenRows,
additiveRows,
settings,
sectionTranslations,
allergenTranslations,
additiveTranslations,
] = await Promise.all([
query<Omit<Section, "translations">>("select key, label, time_range, sort from sections order by sort"),
loadMenuEntries(monday, friday), loadMenuEntries(monday, friday),
query<CodeItem>("select * from allergens order by sort"), query<Omit<CodeItem, "translations">>("select code, name, sort from allergens order by sort"),
query<CodeItem>("select * from additives order by sort"), query<Omit<CodeItem, "translations">>("select code, name, sort from additives order by sort"),
getSettings(), getSettings(),
loadTranslations("section_translations"),
loadTranslations("allergen_translations"),
loadTranslations("additive_translations"),
]); ]);
const sections: Section[] = sectionRows.map((s) => ({
...s,
translations: sectionTranslations.get(s.key) ?? {},
}));
const allergens: CodeItem[] = allergenRows.map((a) => ({
...a,
translations: allergenTranslations.get(a.code) ?? {},
}));
const additives: CodeItem[] = additiveRows.map((a) => ({
...a,
translations: additiveTranslations.get(a.code) ?? {},
}));
const logo = logoUrl(settings?.logo_path); const logo = logoUrl(settings?.logo_path);
const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice"; const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice";
+28 -27
View File
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared"; import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types"; import { localized } from "@/lib/types";
import type { CodeItem } from "@/lib/types"; import type { CodeItem } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
export default function CodeListManager({ export default function CodeListManager({
table, table,
@@ -18,6 +18,7 @@ export default function CodeListManager({
}) { }) {
const dict = useDict(); const dict = useDict();
const locale = useLocale(); const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const title = const title =
table === "allergens" ? dict.codeList.allergensTitle : dict.codeList.additivesTitle; table === "allergens" ? dict.codeList.allergensTitle : dict.codeList.additivesTitle;
const codeLabel = const codeLabel =
@@ -30,8 +31,7 @@ export default function CodeListManager({
original: string | null; // code vor der Bearbeitung, null = neu original: string | null; // code vor der Bearbeitung, null = neu
code: string; code: string;
name: string; name: string;
nameEn: string; translations: Record<string, 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 [notice, setNotice] = useState<string | null>(null);
@@ -60,8 +60,7 @@ export default function CodeListManager({
const payload = { const payload = {
code, code,
name, name,
name_en: editing.nameEn.trim() || null, translations: editing.translations,
name_pl: editing.namePl.trim() || null,
}; };
const res = editing.original const res = editing.original
@@ -118,7 +117,9 @@ export default function CodeListManager({
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`); if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setEditing((prev) => setEditing((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev prev
? { ...prev, translations: { ...prev.translations, ...(data.translations ?? {}) } }
: prev
); );
setTranslateState("done"); setTranslateState("done");
} catch (err) { } catch (err) {
@@ -202,7 +203,7 @@ export default function CodeListManager({
type="button" type="button"
className="btn btn-add" className="btn btn-add"
onClick={() => { onClick={() => {
setEditing({ original: null, code: "", name: "", nameEn: "", namePl: "" }); setEditing({ original: null, code: "", name: "", translations: {} });
setTranslateState("idle"); setTranslateState("idle");
}} }}
> >
@@ -237,25 +238,26 @@ export default function CodeListManager({
required required
/> />
</div> </div>
<div className="grid gap-2 sm:grid-cols-2"> {languages.length > 0 && (
<div> <div className="grid gap-2 sm:grid-cols-2">
<label className="label">{dict.dishManager.nameEnLabel}</label> {languages.map((lang) => (
<input <div key={lang.code}>
className="input" <label className="label">{lang.label}</label>
value={editing.nameEn} <input
onChange={(e) => setEditing({ ...editing, nameEn: e.target.value })} className="input"
/> value={editing.translations[lang.code] ?? ""}
onChange={(e) =>
setEditing({
...editing,
translations: { ...editing.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</div> </div>
<div> )}
<label className="label">{dict.dishManager.namePlLabel}</label> {translateEnabled && languages.length > 0 && (
<input
className="input"
value={editing.namePl}
onChange={(e) => setEditing({ ...editing, namePl: e.target.value })}
/>
</div>
</div>
{translateEnabled && (
<div> <div>
<button <button
type="button" type="button"
@@ -304,8 +306,7 @@ export default function CodeListManager({
original: item.code, original: item.code,
code: item.code, code: item.code,
name: item.name, name: item.name,
nameEn: item.name_en ?? "", translations: { ...item.translations },
namePl: item.name_pl ?? "",
}); });
setTranslateState("idle"); setTranslateState("idle");
}} }}
+31 -34
View File
@@ -6,13 +6,12 @@ import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types"; import { localized } from "@/lib/types";
import type { CodeItem, Dish } from "@/lib/types"; import type { CodeItem, Dish } from "@/lib/types";
import ChipPicker from "@/components/ChipPicker"; import ChipPicker from "@/components/ChipPicker";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
interface DishForm { interface DishForm {
id: string | null; // null = neues Gericht id: string | null; // null = neues Gericht
name: string; name: string;
nameEn: string; translations: Record<string, string>;
namePl: string;
price: string; price: string;
allergens: string[]; allergens: string[];
additives: string[]; additives: string[];
@@ -21,8 +20,7 @@ interface DishForm {
const EMPTY_FORM: DishForm = { const EMPTY_FORM: DishForm = {
id: null, id: null,
name: "", name: "",
nameEn: "", translations: {},
namePl: "",
price: "", price: "",
allergens: [], allergens: [],
additives: [], additives: [],
@@ -35,6 +33,7 @@ export default function DishManager({
}) { }) {
const dict = useDict(); const dict = useDict();
const locale = useLocale(); const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const [dishes, setDishes] = useState<Dish[]>([]); const [dishes, setDishes] = useState<Dish[]>([]);
const [allergens, setAllergens] = useState<CodeItem[]>([]); const [allergens, setAllergens] = useState<CodeItem[]>([]);
const [additives, setAdditives] = useState<CodeItem[]>([]); const [additives, setAdditives] = useState<CodeItem[]>([]);
@@ -93,11 +92,10 @@ export default function DishManager({
} }
const payload = { const payload = {
name: form.name.trim(), name: form.name.trim(),
name_en: form.nameEn.trim() || null,
name_pl: form.namePl.trim() || null,
price, price,
allergens: form.allergens, allergens: form.allergens,
additives: form.additives, additives: form.additives,
translations: form.translations,
}; };
const res = form.id const res = form.id
? await fetch(`/api/dishes/${form.id}`, { ? await fetch(`/api/dishes/${form.id}`, {
@@ -218,7 +216,9 @@ export default function DishManager({
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`); if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setForm((prev) => setForm((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev prev
? { ...prev, translations: { ...prev.translations, ...(data.translations ?? {}) } }
: prev
); );
setTranslateState("done"); setTranslateState("done");
} catch (err) { } catch (err) {
@@ -352,31 +352,29 @@ export default function DishManager({
/> />
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> {languages.length > 0 && (
<div> <div className="grid gap-3 sm:grid-cols-2">
<label className="label" htmlFor="dish-name-en"> {languages.map((lang) => (
{dict.dishManager.nameEnLabel} <div key={lang.code}>
</label> <label className="label" htmlFor={`dish-name-${lang.code}`}>
<input {lang.label}
id="dish-name-en" </label>
className="input" <input
value={form.nameEn} id={`dish-name-${lang.code}`}
onChange={(e) => setForm({ ...form, nameEn: e.target.value })} className="input"
/> value={form.translations[lang.code] ?? ""}
onChange={(e) =>
setForm({
...form,
translations: { ...form.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</div> </div>
<div> )}
<label className="label" htmlFor="dish-name-pl"> {translateEnabled && languages.length > 0 && (
{dict.dishManager.namePlLabel}
</label>
<input
id="dish-name-pl"
className="input"
value={form.namePl}
onChange={(e) => setForm({ ...form, namePl: e.target.value })}
/>
</div>
</div>
{translateEnabled && (
<div> <div>
<button <button
type="button" type="button"
@@ -495,8 +493,7 @@ export default function DishManager({
setForm({ setForm({
id: dish.id, id: dish.id,
name: dish.name, name: dish.name,
nameEn: dish.name_en ?? "", translations: { ...dish.translations },
namePl: dish.name_pl ?? "",
price: String(dish.price).replace(".", ","), price: String(dish.price).replace(".", ","),
allergens: dish.allergens ?? [], allergens: dish.allergens ?? [],
additives: dish.additives ?? [], additives: dish.additives ?? [],
+27 -32
View File
@@ -7,13 +7,12 @@ import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared"; import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types"; import { localized } from "@/lib/types";
import type { Section } from "@/lib/types"; import type { Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
interface SectionForm { interface SectionForm {
key: string | null; // null = neue Essenszeit key: string | null; // null = neue Essenszeit
label: string; label: string;
labelEn: string; translations: Record<string, string>;
labelPl: string;
timeFrom: string; timeFrom: string;
timeTo: string; timeTo: string;
} }
@@ -21,8 +20,7 @@ interface SectionForm {
const EMPTY_FORM: SectionForm = { const EMPTY_FORM: SectionForm = {
key: null, key: null,
label: "", label: "",
labelEn: "", translations: {},
labelPl: "",
timeFrom: "", timeFrom: "",
timeTo: "", timeTo: "",
}; };
@@ -35,6 +33,7 @@ function splitTimeRange(range: string): { from: string; to: string } {
export default function SectionManager() { export default function SectionManager() {
const dict = useDict(); const dict = useDict();
const locale = useLocale(); const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const [sections, setSections] = useState<Section[]>([]); const [sections, setSections] = useState<Section[]>([]);
const [form, setForm] = useState<SectionForm | null>(null); const [form, setForm] = useState<SectionForm | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -61,9 +60,8 @@ export default function SectionManager() {
if (!label || !form.timeFrom || !form.timeTo) return; if (!label || !form.timeFrom || !form.timeTo) return;
const payload = { const payload = {
label, label,
label_en: form.labelEn.trim() || null,
label_pl: form.labelPl.trim() || null,
time_range: `${form.timeFrom} - ${form.timeTo}`, time_range: `${form.timeFrom} - ${form.timeTo}`,
translations: form.translations,
}; };
const res = form.key const res = form.key
@@ -160,30 +158,28 @@ export default function SectionManager() {
required required
/> />
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> {languages.length > 0 && (
<div> <div className="grid gap-3 sm:grid-cols-2">
<label className="label" htmlFor="section-label-en"> {languages.map((lang) => (
{dict.sectionManager.nameEnLabel} <div key={lang.code}>
</label> <label className="label" htmlFor={`section-label-${lang.code}`}>
<input {lang.label}
id="section-label-en" </label>
className="input" <input
value={form.labelEn} id={`section-label-${lang.code}`}
onChange={(e) => setForm({ ...form, labelEn: e.target.value })} className="input"
/> value={form.translations[lang.code] ?? ""}
onChange={(e) =>
setForm({
...form,
translations: { ...form.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</div> </div>
<div> )}
<label className="label" htmlFor="section-label-pl">
{dict.sectionManager.namePlLabel}
</label>
<input
id="section-label-pl"
className="input"
value={form.labelPl}
onChange={(e) => setForm({ ...form, labelPl: e.target.value })}
/>
</div>
</div>
<div className="flex gap-3"> <div className="flex gap-3">
<div className="max-w-32"> <div className="max-w-32">
<label className="label" htmlFor="section-time-from"> <label className="label" htmlFor="section-time-from">
@@ -267,8 +263,7 @@ export default function SectionManager() {
setForm({ setForm({
key: section.key, key: section.key,
label: section.label, label: section.label,
labelEn: section.label_en ?? "", translations: { ...section.translations },
labelPl: section.label_pl ?? "",
timeFrom: from, timeFrom: from,
timeTo: to, timeTo: to,
}); });
@@ -0,0 +1,71 @@
-- Teil 2 des "mehrsprachig variabel"-Umbaus: Speiseplan-Inhalte
-- (Gerichte, Essenszeiten, Allergene, Zusatzstoffe) bekommen eine
-- Übersetzungstabelle je Ressource statt fester name_en/name_pl-
-- Spalten (Teil 1, v1.10.0, hat dasselbe schon für die
-- Bedienoberfläche gemacht). Deutsch bleibt die Basis-Spalte
-- (name/label) auf der jeweiligen Haupttabelle.
--
-- WICHTIG bei Deployments mit echten Daten: vor dem Einspielen ein
-- Backup ziehen (siehe DEPLOYMENT.md, Abschnitt "Backup") – Schritt 3
-- entfernt die alten name_en/name_pl-Spalten unwiderruflich, nachdem
-- Schritt 2 ihren Inhalt in die neuen Tabellen übernommen hat.
-- ---------- Schritt 1: neue Tabellen ----------
create table dish_translations (
dish_id uuid not null references dishes(id) on delete cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (dish_id, language_code)
);
create table section_translations (
section_key text not null references sections(key) on delete cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (section_key, language_code)
);
-- allergens.code/additives.code sind vom Admin umbenennbar (Primary
-- Key), anders als dishes.id/sections.key – "on update cascade" nötig,
-- damit eine Umbenennung des Kürzels nicht die Übersetzungszeilen
-- verwaist.
create table allergen_translations (
allergen_code text not null references allergens(code) on delete cascade on update cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (allergen_code, language_code)
);
create table additive_translations (
additive_code text not null references additives(code) on delete cascade on update cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (additive_code, language_code)
);
-- ---------- Schritt 2: bestehende Übersetzungen übernehmen ----------
insert into dish_translations (dish_id, language_code, name)
select id, 'en', name_en from dishes where name_en is not null and trim(name_en) != '';
insert into dish_translations (dish_id, language_code, name)
select id, 'pl', name_pl from dishes where name_pl is not null and trim(name_pl) != '';
insert into section_translations (section_key, language_code, name)
select key, 'en', label_en from sections where label_en is not null and trim(label_en) != '';
insert into section_translations (section_key, language_code, name)
select key, 'pl', label_pl from sections where label_pl is not null and trim(label_pl) != '';
insert into allergen_translations (allergen_code, language_code, name)
select code, 'en', name_en from allergens where name_en is not null and trim(name_en) != '';
insert into allergen_translations (allergen_code, language_code, name)
select code, 'pl', name_pl from allergens where name_pl is not null and trim(name_pl) != '';
insert into additive_translations (additive_code, language_code, name)
select code, 'en', name_en from additives where name_en is not null and trim(name_en) != '';
insert into additive_translations (additive_code, language_code, name)
select code, 'pl', name_pl from additives where name_pl is not null and trim(name_pl) != '';
-- ---------- Schritt 3: alte Spalten entfernen ----------
alter table dishes drop column name_en, drop column name_pl;
alter table sections drop column label_en, drop column label_pl;
alter table allergens drop column name_en, drop column name_pl;
alter table additives drop column name_en, drop column name_pl;
+71 -32
View File
@@ -16,42 +16,81 @@ delete from allergens;
delete from additives; delete from additives;
-- ---------- Essenszeiten ---------- -- ---------- Essenszeiten ----------
insert into sections (key, label, label_en, label_pl, time_range, sort) values insert into sections (key, label, time_range, sort) values
('brotzeit', 'Brotzeit', 'Breakfast', 'Śniadanie', '09:00 - 10:00', 1), ('brotzeit', 'Brotzeit', '09:00 - 10:00', 1),
('mittagessen', 'Mittagessen', 'Lunch', 'Obiad', '11:30 - 13:30', 2), ('mittagessen', 'Mittagessen', '11:30 - 13:30', 2),
('mittagessen_veg', 'Mittagessen vegetarisch', 'Lunch (vegetarian)', 'Obiad (wegetariański)', '11:30 - 13:30', 3), ('mittagessen_veg', 'Mittagessen vegetarisch', '11:30 - 13:30', 3),
('suppe', 'Suppe', 'Soup', 'Zupa', '11:30 - 13:30', 4); ('suppe', 'Suppe', '11:30 - 13:30', 4);
insert into section_translations (section_key, language_code, name) values
('brotzeit', 'en', 'Breakfast'),
('brotzeit', 'pl', 'Śniadanie'),
('mittagessen', 'en', 'Lunch'),
('mittagessen', 'pl', 'Obiad'),
('mittagessen_veg', 'en', 'Lunch (vegetarian)'),
('mittagessen_veg', 'pl', 'Obiad (wegetariański)'),
('suppe', 'en', 'Soup'),
('suppe', 'pl', 'Zupa');
-- ---------- Allergene (EU-Kennzeichnung, Buchstaben) ---------- -- ---------- Allergene (EU-Kennzeichnung, Buchstaben) ----------
insert into allergens (code, name, name_en, name_pl, sort) values insert into allergens (code, name, sort) values
('A', 'Glutenhaltiges Getreide', 'Cereals containing gluten', 'Zboża zawierające gluten', 1), ('A', 'Glutenhaltiges Getreide', 1),
('B', 'Krebstiere', 'Crustaceans', 'Skorupiaki', 2), ('B', 'Krebstiere', 2),
('C', 'Eier', 'Eggs', 'Jaja', 3), ('C', 'Eier', 3),
('D', 'Fisch', 'Fish', 'Ryby', 4), ('D', 'Fisch', 4),
('E', 'Erdnüsse', 'Peanuts', 'Orzeszki ziemne', 5), ('E', 'Erdnüsse', 5),
('F', 'Sojabohnen', 'Soybeans', 'Soja', 6), ('F', 'Sojabohnen', 6),
('G', 'Milch (einschließlich Laktose)', 'Milk (including lactose)', 'Mleko (w tym laktoza)', 7), ('G', 'Milch (einschließlich Laktose)', 7),
('H', 'Schalenfrüchte (Nüsse)', 'Tree nuts', 'Orzechy', 8), ('H', 'Schalenfrüchte (Nüsse)', 8),
('I', 'Sellerie', 'Celery', 'Seler', 9), ('I', 'Sellerie', 9),
('J', 'Senf', 'Mustard', 'Gorczyca', 10), ('J', 'Senf', 10),
('K', 'Sesamsamen', 'Sesame seeds', 'Nasiona sezamu', 11), ('K', 'Sesamsamen', 11),
('L', 'Schwefeldioxid und Sulfite', 'Sulphur dioxide and sulphites', 'Dwutlenek siarki i siarczyny', 12), ('L', 'Schwefeldioxid und Sulfite', 12),
('M', 'Lupinen', 'Lupin', 'Łubin', 13), ('M', 'Lupinen', 13),
('N', 'Weichtiere', 'Molluscs', 'Mięczaki', 14); ('N', 'Weichtiere', 14);
insert into allergen_translations (allergen_code, language_code, name) values
('A', 'en', 'Cereals containing gluten'), ('A', 'pl', 'Zboża zawierające gluten'),
('B', 'en', 'Crustaceans'), ('B', 'pl', 'Skorupiaki'),
('C', 'en', 'Eggs'), ('C', 'pl', 'Jaja'),
('D', 'en', 'Fish'), ('D', 'pl', 'Ryby'),
('E', 'en', 'Peanuts'), ('E', 'pl', 'Orzeszki ziemne'),
('F', 'en', 'Soybeans'), ('F', 'pl', 'Soja'),
('G', 'en', 'Milk (including lactose)'), ('G', 'pl', 'Mleko (w tym laktoza)'),
('H', 'en', 'Tree nuts'), ('H', 'pl', 'Orzechy'),
('I', 'en', 'Celery'), ('I', 'pl', 'Seler'),
('J', 'en', 'Mustard'), ('J', 'pl', 'Gorczyca'),
('K', 'en', 'Sesame seeds'), ('K', 'pl', 'Nasiona sezamu'),
('L', 'en', 'Sulphur dioxide and sulphites'), ('L', 'pl', 'Dwutlenek siarki i siarczyny'),
('M', 'en', 'Lupin'), ('M', 'pl', 'Łubin'),
('N', 'en', 'Molluscs'), ('N', 'pl', 'Mięczaki');
-- ---------- Zusatzstoffe (Ziffern) ---------- -- ---------- Zusatzstoffe (Ziffern) ----------
insert into additives (code, name, name_en, name_pl, sort) values insert into additives (code, name, sort) values
('1', 'mit Farbstoff', 'with colouring', 'z barwnikiem', 1), ('1', 'mit Farbstoff', 1),
('2', 'mit Konservierungsstoff', 'with preservative', 'z konserwantem', 2), ('2', 'mit Konservierungsstoff', 2),
('3', 'mit Antioxidationsmittel', 'with antioxidant', 'z przeciwutleniaczem', 3), ('3', 'mit Antioxidationsmittel', 3),
('4', 'mit Geschmacksverstärker', 'with flavour enhancer', 'ze wzmacniaczem smaku', 4), ('4', 'mit Geschmacksverstärker', 4),
('5', 'geschwefelt', 'sulphured', 'siarkowany', 5), ('5', 'geschwefelt', 5),
('6', 'geschwärzt', 'blackened', 'barwiony na czarno', 6), ('6', 'geschwärzt', 6),
('7', 'gewachst', 'waxed', 'woskowany', 7), ('7', 'gewachst', 7),
('8', 'mit Phosphat', 'with phosphate', 'z fosforanem', 8), ('8', 'mit Phosphat', 8),
('9', 'mit Süßungsmittel(n)', 'with sweetener(s)', 'ze słodzikiem/słodzikami', 9), ('9', 'mit Süßungsmittel(n)', 9),
('10', 'enthält eine Phenylalaninquelle', 'contains a source of phenylalanine', 'zawiera źródło fenyloalaniny', 10), ('10', 'enthält eine Phenylalaninquelle', 10),
('11', 'koffeinhaltig', 'caffeinated', 'zawiera kofeinę', 11); ('11', 'koffeinhaltig', 11);
insert into additive_translations (additive_code, language_code, name) values
('1', 'en', 'with colouring'), ('1', 'pl', 'z barwnikiem'),
('2', 'en', 'with preservative'), ('2', 'pl', 'z konserwantem'),
('3', 'en', 'with antioxidant'), ('3', 'pl', 'z przeciwutleniaczem'),
('4', 'en', 'with flavour enhancer'), ('4', 'pl', 'ze wzmacniaczem smaku'),
('5', 'en', 'sulphured'), ('5', 'pl', 'siarkowany'),
('6', 'en', 'blackened'), ('6', 'pl', 'barwiony na czarno'),
('7', 'en', 'waxed'), ('7', 'pl', 'woskowany'),
('8', 'en', 'with phosphate'), ('8', 'pl', 'z fosforanem'),
('9', 'en', 'with sweetener(s)'), ('9', 'pl', 'ze słodzikiem/słodzikami'),
('10', 'en', 'contains a source of phenylalanine'), ('10', 'pl', 'zawiera źródło fenyloalaniny'),
('11', 'en', 'caffeinated'), ('11', 'pl', 'zawiera kofeinę');
-- ---------- Testgerichte ---------- -- ---------- Testgerichte ----------
insert into dishes (name, price) values insert into dishes (name, price) values
+68 -39
View File
@@ -1,39 +1,70 @@
import "server-only"; import "server-only";
import { query, queryOne, withTransaction } from "@/lib/db"; import { query, queryOne } from "@/lib/db";
import {
loadTranslations,
loadTranslationsFor,
replaceTranslations,
type TranslationTable,
} from "./translations";
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 +
// Bezeichnung in DE/EN/PL). Tabellenname kommt nie aus Nutzereingaben, // Bezeichnung in Deutsch, Übersetzungen in der jeweiligen
// *_translations-Tabelle). Tabellenname kommt nie aus Nutzereingaben,
// sondern immer aus dem literal getypten Parameter unten – sicher für // sondern immer aus dem literal getypten Parameter unten – sicher für
// String-Interpolation in der Query. // String-Interpolation in der Query.
export type CodeTable = "allergens" | "additives"; export type CodeTable = "allergens" | "additives";
const TRANSLATION_TABLE: Record<CodeTable, TranslationTable> = {
allergens: "allergen_translations",
additives: "additive_translations",
};
interface CodeRow {
code: string;
name: string;
sort: number;
}
export async function listCodes(table: CodeTable): Promise<CodeItem[]> { export async function listCodes(table: CodeTable): Promise<CodeItem[]> {
return query<CodeItem>(`select * from ${table} order by sort`); const rows = await query<CodeRow>(`select code, name, sort from ${table} order by sort`);
const translations = await loadTranslations(TRANSLATION_TABLE[table]);
return rows.map((r) => ({ ...r, translations: translations.get(r.code) ?? {} }));
} }
export async function createCode( export async function createCode(
table: CodeTable, table: CodeTable,
input: { code: string; name: string; name_en: string | null; name_pl: string | null } input: { code: string; name: string; translations?: Record<string, string | null> }
): Promise<CodeItem> { ): Promise<CodeItem> {
const maxRow = await queryOne<{ sort: number }>( const maxRow = await queryOne<{ sort: number }>(
`select sort from ${table} order by sort desc limit 1` `select sort from ${table} order by sort desc limit 1`
); );
return (await queryOne<CodeItem>( const row = (await queryOne<CodeRow>(
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5) returning *`, `insert into ${table} (code, name, sort) values ($1, $2, $3) returning code, name, sort`,
[input.code, input.name, input.name_en, input.name_pl, (maxRow?.sort ?? 0) + 1] [input.code, input.name, (maxRow?.sort ?? 0) + 1]
))!; ))!;
if (input.translations) {
await replaceTranslations(TRANSLATION_TABLE[table], row.code, input.translations);
}
const translations = await loadTranslationsFor(TRANSLATION_TABLE[table], [row.code]);
return { ...row, translations: translations.get(row.code) ?? {} };
} }
export async function updateCode( export async function updateCode(
table: CodeTable, table: CodeTable,
originalCode: string, originalCode: string,
input: { code: string; name: string; name_en: string | null; name_pl: string | null } input: { code: string; name: string; translations?: Record<string, string | null> }
): Promise<CodeItem | null> { ): Promise<CodeItem | null> {
return queryOne<CodeItem>( const row = await queryOne<CodeRow>(
`update ${table} set code = $1, name = $2, name_en = $3, name_pl = $4 where code = $5 returning *`, `update ${table} set code = $1, name = $2 where code = $3 returning code, name, sort`,
[input.code, input.name, input.name_en, input.name_pl, originalCode] [input.code, input.name, originalCode]
); );
if (!row) return null;
if (input.translations) {
await replaceTranslations(TRANSLATION_TABLE[table], row.code, input.translations);
}
const translations = await loadTranslationsFor(TRANSLATION_TABLE[table], [row.code]);
return { ...row, translations: translations.get(row.code) ?? {} };
} }
export async function deleteCode(table: CodeTable, code: string): Promise<void> { export async function deleteCode(table: CodeTable, code: string): Promise<void> {
@@ -43,8 +74,7 @@ export async function deleteCode(table: CodeTable, code: string): Promise<void>
export interface CodeImportRow { export interface CodeImportRow {
code: string; code: string;
name: string; name: string;
name_en: string | null; translations: Record<string, string>;
name_pl: string | null;
} }
/** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden /** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden
@@ -54,32 +84,31 @@ export async function importCodes(
table: CodeTable, table: CodeTable,
rows: CodeImportRow[] rows: CodeImportRow[]
): Promise<{ created: number; updated: number }> { ): Promise<{ created: number; updated: number }> {
return withTransaction(async (client) => { const existing = await query<{ code: string }>(`select code from ${table}`);
const existing = await client.query<{ code: string }>(`select code from ${table}`); const known = new Set(existing.map((r) => r.code));
const known = new Set(existing.rows.map((r) => r.code)); const maxRow = await queryOne<{ sort: number }>(
const maxRow = await client.query<{ sort: number }>( `select coalesce(max(sort), 0) as sort from ${table}`
`select coalesce(max(sort), 0) as sort from ${table}` );
); let nextSort = (maxRow?.sort ?? 0) + 1;
let nextSort = (maxRow.rows[0]?.sort ?? 0) + 1; let created = 0;
let created = 0; let updated = 0;
let updated = 0;
for (const row of rows) { for (const row of rows) {
if (known.has(row.code)) { if (known.has(row.code)) {
await client.query( await query(`update ${table} set name = $1 where code = $2`, [row.name, row.code]);
`update ${table} set name = $1, name_en = $2, name_pl = $3 where code = $4`, updated++;
[row.name, row.name_en, row.name_pl, row.code] } else {
); await query(`insert into ${table} (code, name, sort) values ($1, $2, $3)`, [
updated++; row.code,
} else { row.name,
await client.query( nextSort++,
`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++;
known.add(row.code);
created++;
}
} }
return { created, updated }; if (Object.keys(row.translations).length > 0) {
}); await replaceTranslations(TRANSLATION_TABLE[table], row.code, row.translations);
}
}
return { created, updated };
} }
+6 -5
View File
@@ -1,5 +1,6 @@
import "server-only"; import "server-only";
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import { loadTranslationsFor } from "./translations";
import type { MenuEntry } from "@/lib/types"; import type { MenuEntry } from "@/lib/types";
interface MenuEntryRow { interface MenuEntryRow {
@@ -9,8 +10,6 @@ interface MenuEntryRow {
position: number; position: number;
dish_id: string; dish_id: string;
name: string; name: string;
name_en: string | null;
name_pl: string | null;
price: string; price: string;
allergens: string[]; allergens: string[];
additives: string[]; additives: string[];
@@ -22,7 +21,7 @@ interface MenuEntryRow {
export async function loadMenuEntries(fromISO: string, toISO: string): Promise<MenuEntry[]> { export async function loadMenuEntries(fromISO: string, toISO: string): Promise<MenuEntry[]> {
const rows = await query<MenuEntryRow>( const rows = await query<MenuEntryRow>(
`select me.id, me.menu_date, me.section_key, me.position, `select me.id, me.menu_date, me.section_key, me.position,
d.id as dish_id, d.name, d.name_en, d.name_pl, d.price, d.allergens, d.additives d.id as dish_id, d.name, d.price, d.allergens, d.additives
from menu_entries me from menu_entries me
join dishes d on d.id = me.dish_id join dishes d on d.id = me.dish_id
where me.menu_date between $1 and $2 where me.menu_date between $1 and $2
@@ -30,6 +29,9 @@ export async function loadMenuEntries(fromISO: string, toISO: string): Promise<M
[fromISO, toISO] [fromISO, toISO]
); );
const dishIds = [...new Set(rows.map((r) => r.dish_id))];
const translations = await loadTranslationsFor("dish_translations", dishIds);
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
menu_date: r.menu_date, menu_date: r.menu_date,
@@ -38,8 +40,7 @@ export async function loadMenuEntries(fromISO: string, toISO: string): Promise<M
dish: { dish: {
id: r.dish_id, id: r.dish_id,
name: r.name, name: r.name,
name_en: r.name_en, translations: translations.get(r.dish_id) ?? {},
name_pl: r.name_pl,
price: Number(r.price), price: Number(r.price),
allergens: r.allergens, allergens: r.allergens,
additives: r.additives, additives: r.additives,
+85
View File
@@ -0,0 +1,85 @@
import "server-only";
import { query } from "@/lib/db";
// Gemeinsame Zugriffsschicht für die vier Übersetzungstabellen aus
// db/migrations/0006_content_translations.sql. Die Wert-Spalte heißt
// in allen vieren bewusst einheitlich "name" (auch bei
// section_translations, obwohl sections.label selbst anders heißt) –
// vereinfacht diese generischen Helfer, statt zusätzlich eine Spalten-
// Namens-Zuordnung pflegen zu müssen.
export type TranslationTable =
| "dish_translations"
| "section_translations"
| "allergen_translations"
| "additive_translations";
const ID_COLUMN: Record<TranslationTable, string> = {
dish_translations: "dish_id",
section_translations: "section_key",
allergen_translations: "allergen_code",
additive_translations: "additive_code",
};
/** Alle Übersetzungen einer Tabelle, gruppiert nach Eltern-ID. */
export async function loadTranslations(
table: TranslationTable
): Promise<Map<string, Record<string, string>>> {
const idCol = ID_COLUMN[table];
const rows = await query<{ id: string; language_code: string; name: string }>(
`select ${idCol} as id, language_code, name from ${table}`
);
return groupByParent(rows);
}
/** Übersetzungen nur für eine Teilmenge von Eltern-IDs laden (z. B. die
* Gerichte des aktuell angezeigten Wochenplan-Zeitraums). */
export async function loadTranslationsFor(
table: TranslationTable,
ids: string[]
): Promise<Map<string, Record<string, string>>> {
if (ids.length === 0) return new Map();
const idCol = ID_COLUMN[table];
const rows = await query<{ id: string; language_code: string; name: string }>(
`select ${idCol} as id, language_code, name from ${table} where ${idCol} = any($1)`,
[ids]
);
return groupByParent(rows);
}
function groupByParent(
rows: { id: string; language_code: string; name: string }[]
): Map<string, Record<string, string>> {
const map = new Map<string, Record<string, string>>();
for (const r of rows) {
if (!map.has(r.id)) map.set(r.id, {});
map.get(r.id)![r.language_code] = r.name;
}
return map;
}
/** Übersetzungen eines Eintrags für die übergebenen Sprachen setzen –
* ein leerer/nur-Leerzeichen-Wert löscht die Zeile für diese Sprache
* wieder (z. B. wenn ein Formularfeld geleert wird), statt eine leere
* Übersetzung zu speichern. */
export async function replaceTranslations(
table: TranslationTable,
id: string,
translations: Record<string, string | null | undefined>
): Promise<void> {
const idCol = ID_COLUMN[table];
for (const [languageCode, rawValue] of Object.entries(translations)) {
const value = rawValue?.trim();
if (!value) {
await query(`delete from ${table} where ${idCol} = $1 and language_code = $2`, [
id,
languageCode,
]);
continue;
}
await query(
`insert into ${table} (${idCol}, language_code, name) values ($1, $2, $3)
on conflict (${idCol}, language_code) do update set name = excluded.name`,
[id, languageCode, value]
);
}
}
-4
View File
@@ -93,8 +93,6 @@ const de = {
newDish: "Neues Gericht", newDish: "Neues Gericht",
editDish: "Gericht bearbeiten", editDish: "Gericht bearbeiten",
nameLabel: "Bezeichnung", nameLabel: "Bezeichnung",
nameEnLabel: "Name (Englisch)",
namePlLabel: "Name (Polnisch)",
autoTranslate: "Automatisch übersetzen", autoTranslate: "Automatisch übersetzen",
translating: "Übersetze…", translating: "Übersetze…",
translated: "Übersetzt.", translated: "Übersetzt.",
@@ -150,8 +148,6 @@ const de = {
newSection: "Neue Essenszeit", newSection: "Neue Essenszeit",
editSection: "Essenszeit bearbeiten", editSection: "Essenszeit bearbeiten",
nameLabel: "Bezeichnung", nameLabel: "Bezeichnung",
nameEnLabel: "Bezeichnung (Englisch)",
namePlLabel: "Bezeichnung (Polnisch)",
timeFrom: "Von", timeFrom: "Von",
timeTo: "Bis", timeTo: "Bis",
moveUp: "Nach oben", moveUp: "Nach oben",
+21 -7
View File
@@ -12,8 +12,9 @@ export interface Profile {
export interface Section { export interface Section {
key: string; key: string;
label: string; label: string;
label_en: string | null; // Sprachcode (z. B. "en", "fr") -> übersetzter Name. Enthält nie "de" -
label_pl: string | null; // Deutsch ist immer die "label"-Spalte selbst.
translations: Record<string, string>;
time_range: string; time_range: string;
sort: number; sort: number;
} }
@@ -21,8 +22,7 @@ export interface Section {
export interface Dish { export interface Dish {
id: string; id: string;
name: string; name: string;
name_en: string | null; translations: Record<string, string>;
name_pl: string | null;
price: number; price: number;
allergens: string[]; allergens: string[];
additives: string[]; additives: string[];
@@ -39,8 +39,7 @@ export interface MenuEntry {
export interface CodeItem { export interface CodeItem {
code: string; code: string;
name: string; name: string;
name_en: string | null; translations: Record<string, string>;
name_pl: string | null;
sort: number; sort: number;
} }
@@ -53,10 +52,25 @@ export interface DishSuggestion {
/** Übersetzten Namen für die aktuelle Sprache liefern, sonst Deutsch als Fallback. */ /** Übersetzten Namen für die aktuelle Sprache liefern, sonst Deutsch als Fallback. */
export function localized( export function localized(
item: { name?: string; label?: string; name_en?: string | null; name_pl?: string | null; label_en?: string | null; label_pl?: string | null }, item: {
name?: string;
label?: string;
// Neue, dynamische Form (Gerichte/Essenszeiten/Allergene/
// Zusatzstoffe, s. lib/db/translations.ts) – Sprachcode -> Text.
translations?: Record<string, string>;
// Ältere, feste Form – wird u. a. noch für den Hinweistext in den
// Design-Einstellungen genutzt (settings.labeling_notice_en/_pl),
// die kein Übersetzungstabellen-Konzept haben.
name_en?: string | null;
name_pl?: string | null;
label_en?: string | null;
label_pl?: string | null;
},
locale: string locale: string
): string { ): string {
const de = item.name ?? item.label ?? ""; const de = item.name ?? item.label ?? "";
if (locale === "de") return de;
if (item.translations) return item.translations[locale] || de;
if (locale === "en") return item.name_en || item.label_en || de; if (locale === "en") return item.name_en || item.label_en || de;
if (locale === "pl") return item.name_pl || item.label_pl || de; if (locale === "pl") return item.name_pl || item.label_pl || de;
return de; return de;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.10.0", "version": "1.11.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",