v1.5: Alle Manager-Komponenten auf eigene API-Routen umgestellt

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.
This commit is contained in:
2026-07-18 11:01:52 +02:00
parent 28c9ec15c8
commit a9a9cf7ba4
58 changed files with 1184 additions and 1858 deletions
+5 -14
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { getSession } from "@/lib/auth/session";
import AppHeader from "@/components/AppHeader"; import AppHeader from "@/components/AppHeader";
export default async function AdminLayout({ export default async function AdminLayout({
@@ -7,24 +7,15 @@ export default async function AdminLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const supabase = await createClient(); const session = await getSession();
const { if (!session) redirect("/login");
data: { user }, if (session.user.role !== "admin") redirect("/intern");
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: profile } = await supabase
.from("profiles")
.select("role, full_name")
.eq("id", user.id)
.single();
if (profile?.role !== "admin") redirect("/intern");
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<AppHeader <AppHeader
role="admin" role="admin"
displayName={profile.full_name || user.email || ""} displayName={session.user.full_name || session.user.email}
/> />
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1"> <main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
{children} {children}
+4 -2
View File
@@ -1,5 +1,7 @@
import { getSession } from "@/lib/auth/session";
import UserManager from "@/components/UserManager"; import UserManager from "@/components/UserManager";
export default function AdminPage() { export default async function AdminPage() {
return <UserManager />; const session = await getSession();
return <UserManager meId={session?.user.id ?? ""} />;
} }
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { deleteCode, updateCode } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { code } = await params;
const body = await request.json();
const item = await updateCode("additives", code, body);
if (!item) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json(item);
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { code } = await params;
await deleteCode("additives", code);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { createCode, listCodes } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET() {
return NextResponse.json(await listCodes("additives"));
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = await request.json();
const item = await createCode("additives", body);
return NextResponse.json(item, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { queryOne, query } from "@/lib/db";
import { removeBrandingFile, saveBrandingFile } from "@/lib/branding";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
type AssetKind = "logo" | "favicon";
const ASSET_COLUMN: Record<AssetKind, "logo_path" | "favicon_path"> = {
logo: "logo_path",
favicon: "favicon_path",
};
function isAssetKind(value: unknown): value is AssetKind {
return value === "logo" || value === "favicon";
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["admin"]);
const form = await request.formData();
const kind = form.get("kind");
const file = form.get("file");
if (!isAssetKind(kind) || !(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "invalid_image" }, { status: 400 });
}
const old = await queryOne<{ path: string | null }>(
`select ${ASSET_COLUMN[kind]} as path from settings where id = 1`
);
const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
const filename = `${kind}-${Date.now()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
await saveBrandingFile(filename, buffer);
await query(`update settings set ${ASSET_COLUMN[kind]} = $1 where id = 1`, [filename]);
if (old?.path) await removeBrandingFile(old.path);
return NextResponse.json({ path: filename });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
export async function DELETE(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["admin"]);
const { kind } = (await request.json()) as { kind?: unknown };
if (!isAssetKind(kind)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const old = await queryOne<{ path: string | null }>(
`select ${ASSET_COLUMN[kind]} as path from settings where id = 1`
);
await query(`update settings set ${ASSET_COLUMN[kind]} = null where id = 1`);
if (old?.path) await removeBrandingFile(old.path);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+86 -52
View File
@@ -1,33 +1,48 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server"; import { query, queryOne } from "@/lib/db";
import { createAdminClient } from "@/lib/supabase/admin"; import { hashPassword } from "@/lib/auth/password";
import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
import type { Profile } from "@/lib/types";
// Benutzerverwaltung (nur Admin). Läuft serverseitig mit dem // Benutzerverwaltung (nur Admin).
// Service-Role-Key, da das Anlegen/Löschen von Auth-Benutzern
// nicht über den anon key möglich ist.
async function requireAdmin(request: Request) { async function requireAdmin(request: Request) {
if (!isSameOrigin(request)) return null; if (!isSameOrigin(request)) throw new ForbiddenError();
const supabase = await createClient(); return requireRole(["admin"]);
const { }
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
const admin = createAdminClient(); function errorResponse(err: unknown) {
const { data: profile } = await admin if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
.from("profiles") return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
.select("role") }
.eq("id", user.id) return null;
.single(); }
if (profile?.role !== "admin") return null;
return { user, admin }; export async function GET(request: Request) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const profiles = await query<Profile>(
"select id, email, full_name, role, password_auth_enabled, created_at from users order by created_at"
);
return NextResponse.json(profiles);
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const ctx = await requireAdmin(request); let session;
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); try {
session = await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
void session;
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
const { email, password, full_name, role } = body ?? {}; const { email, password, full_name, role } = body ?? {};
@@ -41,28 +56,33 @@ export async function POST(request: Request) {
); );
} }
const { data, error } = await ctx.admin.auth.admin.createUser({ try {
email, const user = await queryOne<{ id: string }>(
password, `insert into users (email, password_hash, full_name, role)
email_confirm: true, values ($1, $2, $3, $4) returning id`,
user_metadata: { full_name: full_name ?? "", role }, [String(email).trim().toLowerCase(), hashPassword(password), full_name ?? "", role]
});
if (error) {
return NextResponse.json(
{
error: /already been registered/i.test(error.message)
? "Diese E-Mail-Adresse ist bereits registriert."
: error.message,
},
{ status: 400 }
); );
return NextResponse.json({ id: user?.id });
} catch (err) {
if (err instanceof Error && /unique/i.test(err.message)) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse ist bereits registriert." },
{ status: 400 }
);
}
throw err;
} }
return NextResponse.json({ id: data.user?.id });
} }
export async function PATCH(request: Request) { export async function PATCH(request: Request) {
const ctx = await requireAdmin(request); let session;
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); try {
session = await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
const { id, full_name, role, password } = body ?? {}; const { id, full_name, role, password } = body ?? {};
@@ -71,7 +91,7 @@ export async function PATCH(request: Request) {
} }
// Der Admin darf sich nicht selbst die Admin-Rolle entziehen, // Der Admin darf sich nicht selbst die Admin-Rolle entziehen,
// sonst sperrt er sich aus der Verwaltung aus. // sonst sperrt er sich aus der Verwaltung aus.
if (id === ctx.user.id && role === "staff") { if (id === session.user.id && role === "staff") {
return NextResponse.json( return NextResponse.json(
{ error: "Du kannst dir nicht selbst die Admin-Rolle entziehen." }, { error: "Du kannst dir nicht selbst die Admin-Rolle entziehen." },
{ status: 400 } { status: 400 }
@@ -85,35 +105,49 @@ export async function PATCH(request: Request) {
{ status: 400 } { status: 400 }
); );
} }
const { error } = await ctx.admin.auth.admin.updateUserById(id, { password }); await query("update users set password_hash = $1 where id = $2", [
if (error) return NextResponse.json({ error: error.message }, { status: 400 }); hashPassword(password),
id,
]);
} }
const updates: Record<string, string> = {}; const fields: string[] = [];
if (typeof full_name === "string") updates.full_name = full_name; const values: unknown[] = [];
if (role) updates.role = role; if (typeof full_name === "string") {
if (Object.keys(updates).length > 0) { values.push(full_name);
const { error } = await ctx.admin.from("profiles").update(updates).eq("id", id); fields.push(`full_name = $${values.length}`);
if (error) return NextResponse.json({ error: error.message }, { status: 400 }); }
if (role) {
values.push(role);
fields.push(`role = $${values.length}`);
}
if (fields.length > 0) {
values.push(id);
await query(`update users set ${fields.join(", ")} where id = $${values.length}`, values);
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
const ctx = await requireAdmin(request); let session;
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); try {
session = await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
const { id } = body ?? {}; const { id } = body ?? {};
if (!id) return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 }); if (!id) return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
if (id === ctx.user.id) { if (id === session.user.id) {
return NextResponse.json( return NextResponse.json(
{ error: "Du kannst dich nicht selbst löschen." }, { error: "Du kannst dich nicht selbst löschen." },
{ status: 400 } { status: 400 }
); );
} }
const { error } = await ctx.admin.auth.admin.deleteUser(id); await query("delete from users where id = $1", [id]);
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { deleteCode, updateCode } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { code } = await params;
const body = await request.json();
const item = await updateCode("allergens", code, body);
if (!item) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json(item);
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { code } = await params;
await deleteCode("allergens", code);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { createCode, listCodes } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET() {
return NextResponse.json(await listCodes("allergens"));
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = await request.json();
const item = await createCode("allergens", body);
return NextResponse.json(item, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { withTransaction } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { id } = await params;
const created = await withTransaction(async (client) => {
const suggestion = await client.query<{ name: string }>(
"select name from dish_suggestions where id = $1",
[id]
);
if (suggestion.rows.length === 0) return null;
await client.query("insert into dishes (name, price) values ($1, 0)", [
suggestion.rows[0].name,
]);
await client.query("delete from dish_suggestions where id = $1", [id]);
return true;
});
if (!created) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { id } = await params;
await query("delete from dish_suggestions where id = $1", [id]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { DishSuggestion } from "@/lib/types";
export async function GET() {
try {
await requireRole(["staff", "admin"]);
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
const items = await query<DishSuggestion>(
"select * from dish_suggestions order by created_at desc"
);
return NextResponse.json(items);
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const body = (await request.json().catch(() => ({}))) as {
name?: string;
description?: string;
};
const name = body.name?.trim();
if (!name) return NextResponse.json({ error: "name_required" }, { status: 400 });
// Serverseitig statt clientseitig (wie zuvor über den anon-Postgrest-
// Zugriff): es gibt keinen Browser→Postgres-Pfad mehr.
const existing = await queryOne<{ id: string }>(
"select id from dishes where name ilike $1 limit 1",
[name]
);
if (existing) return NextResponse.json({ error: "duplicate" }, { status: 409 });
await query("insert into dish_suggestions (name, description) values ($1, $2)", [
name,
body.description?.trim() || null,
]);
return NextResponse.json({ ok: true }, { status: 201 });
}
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Dish } from "@/lib/types";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
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[];
};
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]
);
if (!dish) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json(dish);
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { id } = await params;
await query("delete from dishes where id = $1", [id]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { ids } = (await request.json()) as { ids: string[] };
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
await query("delete from dishes where id = any($1::uuid[])", [ids]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Dish } from "@/lib/types";
export async function GET() {
const dishes = await query<Dish>("select * from dishes order by name");
return NextResponse.json(dishes);
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
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[];
};
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]
);
return NextResponse.json(dish, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { id } = await params;
await query("delete from menu_entries where id = $1", [id]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { queryOne } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const from = searchParams.get("from");
const to = searchParams.get("to");
if (!from || !to) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
return NextResponse.json(await loadMenuEntries(from, to));
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
menu_date: string;
section_key: string;
dish_id: string;
};
const entry = await queryOne<{ id: string }>(
"insert into menu_entries (menu_date, section_key, dish_id) values ($1, $2, $3) returning id",
[body.menu_date, body.section_key, body.dish_id]
);
return NextResponse.json(entry, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_entry" }, { status: 409 });
}
throw err;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Section } from "@/lib/types";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
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">
>;
const fields: string[] = [];
const values: unknown[] = [];
for (const col of ["label", "label_en", "label_pl", "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 });
return NextResponse.json(section);
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { key } = await params;
await query("delete from sections where key = $1", [key]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Section } from "@/lib/types";
export async function GET() {
const sections = await query<Section>("select * from sections order by sort");
return NextResponse.json(sections);
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
label: string;
label_en: string | null;
label_pl: string | null;
time_range: string;
};
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]
);
return NextResponse.json(section, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { getSettings } from "@/lib/db/settings";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET() {
return NextResponse.json(await getSettings());
}
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["admin"]);
const { colors } = (await request.json()) as { colors: Record<string, string> };
await query("update settings set colors = $1 where id = 1", [JSON.stringify(colors)]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+20 -21
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server"; import { query } from "@/lib/db";
import { createAdminClient } from "@/lib/supabase/admin"; 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 nach Englisch/Polnisch via DeepL und
@@ -43,20 +43,16 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
} }
const supabase = await createClient(); try {
const { await requireRole(["staff", "admin"]);
data: { user }, } catch (err) {
} = await supabase.auth.getUser(); if (err instanceof UnauthorizedError) {
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const admin = createAdminClient(); if (err instanceof ForbiddenError) {
const { data: profile } = await admin return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
.from("profiles") }
.select("role") throw err;
.eq("id", user.id)
.single();
if (!profile || !["staff", "admin"].includes(profile.role)) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
} }
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
@@ -83,11 +79,14 @@ export async function POST(request: Request) {
if (!en || !pl) throw new Error("Keine Übersetzung erhalten."); if (!en || !pl) throw new Error("Keine Übersetzung erhalten.");
if (persist) { if (persist) {
const { error } = await admin // table/idColumn/textColumn kommen ausschließlich aus dem oben
.from(table) // geprüften ALLOWED_TABLES-Whitelist, nie aus Nutzereingaben –
.update({ [`${config.textColumn}_en`]: en, [`${config.textColumn}_pl`]: pl }) // sicher für die String-Interpolation in der Query.
.eq(config.idColumn, id); await query(
if (error) throw new Error(error.message); `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 }); return NextResponse.json({ configured: true, en, pl });
+32
View File
@@ -0,0 +1,32 @@
import { readBrandingFile } from "@/lib/branding";
const CONTENT_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
svg: "image/svg+xml",
ico: "image/x-icon",
gif: "image/gif",
};
export async function GET(
request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
const { filename } = await params;
const data = await readBrandingFile(filename);
if (!data) return new Response("Not found", { status: 404 });
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
return new Response(new Uint8Array(data), {
headers: {
"Content-Type": contentType,
// Dateinamen tragen bereits einen Date.now()-Suffix, ein neuer
// Upload bekommt also automatisch einen neuen Namen.
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
/* Farb-Variablen: Defaults entsprechen r-hofmann.de; werden zur Laufzeit /* Farb-Variablen: Defaults entsprechen r-hofmann.de; werden zur Laufzeit
aus den Einstellungen (Supabase settings.colors) überschrieben. */ aus den Einstellungen (settings.colors) überschrieben. */
:root { :root {
--c-primary: #003388; --c-primary: #003388;
--c-primary-dark: #0d315c; --c-primary-dark: #0d315c;
+5 -15
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { getSession } from "@/lib/auth/session";
import AppHeader from "@/components/AppHeader"; import AppHeader from "@/components/AppHeader";
export default async function InternLayout({ export default async function InternLayout({
@@ -7,24 +7,14 @@ export default async function InternLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const supabase = await createClient(); const session = await getSession();
const { if (!session) redirect("/login");
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: profile } = await supabase
.from("profiles")
.select("role, full_name")
.eq("id", user.id)
.single();
if (!profile || !["staff", "admin"].includes(profile.role)) redirect("/");
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<AppHeader <AppHeader
role={profile.role} role={session.user.role}
displayName={profile.full_name || user.email || ""} displayName={session.user.full_name || session.user.email}
/> />
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1"> <main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
{children} {children}
+9 -19
View File
@@ -1,34 +1,24 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { getSession } from "@/lib/auth/session";
import AppHeader from "@/components/AppHeader"; import AppHeader from "@/components/AppHeader";
import AccountManager from "@/components/AccountManager"; import AccountManager from "@/components/AccountManager";
export default async function KontoPage() { export default async function KontoPage() {
const supabase = await createClient(); const session = await getSession();
const { if (!session) redirect("/login");
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: profile } = await supabase
.from("profiles")
.select("role, full_name, password_auth_enabled")
.eq("id", user.id)
.single();
if (!profile || !["staff", "admin"].includes(profile.role)) redirect("/");
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<AppHeader <AppHeader
role={profile.role} role={session.user.role}
displayName={profile.full_name || user.email || ""} displayName={session.user.full_name || session.user.email}
/> />
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1"> <main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
<AccountManager <AccountManager
role={profile.role} role={session.user.role}
initialFullName={profile.full_name ?? ""} initialFullName={session.user.full_name}
initialEmail={user.email ?? ""} initialEmail={session.user.email}
initialPasswordAuthEnabled={profile.password_auth_enabled} initialPasswordAuthEnabled={session.user.password_auth_enabled}
/> />
</main> </main>
</div> </div>
+4 -13
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { createClient } from "@/lib/supabase/server"; import { getSettings } from "@/lib/db/settings";
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types"; import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
import { getDictionary, getLocale } from "@/lib/i18n"; import { getDictionary, getLocale } from "@/lib/i18n";
import { LocaleProvider } from "@/components/i18n/LocaleProvider"; import { LocaleProvider } from "@/components/i18n/LocaleProvider";
@@ -16,12 +16,7 @@ const geistSans = Geist({
}); });
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const supabase = await createClient(); const settings = await getSettings();
const { data: settings } = await supabase
.from("settings")
.select("favicon_path")
.eq("id", 1)
.maybeSingle();
return { return {
title: SITE_TITLE, title: SITE_TITLE,
@@ -38,16 +33,12 @@ export default async function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const supabase = await createClient(); const [settings, locale] = await Promise.all([getSettings(), getLocale()]);
const [{ data: settings }, locale] = await Promise.all([
supabase.from("settings").select("colors").eq("id", 1).maybeSingle(),
getLocale(),
]);
const dict = getDictionary(locale); const dict = getDictionary(locale);
const colors: Record<string, string> = { const colors: Record<string, string> = {
...DEFAULT_COLORS, ...DEFAULT_COLORS,
...((settings?.colors as Record<string, string>) ?? {}), ...(settings?.colors ?? {}),
}; };
const style = Object.fromEntries( const style = Object.fromEntries(
Object.entries(COLOR_VARS).map(([key, cssVar]) => [cssVar, colors[key]]) Object.entries(COLOR_VARS).map(([key, cssVar]) => [cssVar, colors[key]])
+12 -24
View File
@@ -1,5 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { createClient } from "@/lib/supabase/server"; import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { getSettings } from "@/lib/db/settings";
import { import {
addDays, addDays,
dishCodes, dishCodes,
@@ -11,7 +13,7 @@ import {
} from "@/lib/menu"; } from "@/lib/menu";
import { getDictionary, getLocale } from "@/lib/i18n"; import { getDictionary, getLocale } from "@/lib/i18n";
import { localized, logoUrl, SITE_TITLE } from "@/lib/types"; import { localized, logoUrl, SITE_TITLE } from "@/lib/types";
import type { CodeItem, MenuEntry, Section } from "@/lib/types"; import type { CodeItem, Section } from "@/lib/types";
import PublicMobileMenu from "@/components/PublicMobileMenu"; import PublicMobileMenu from "@/components/PublicMobileMenu";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
@@ -31,29 +33,15 @@ export default async function Home({
iso: addDays(monday, i), iso: addDays(monday, i),
})); }));
const supabase = await createClient(); const [sections, entries, allergens, additives, settings] = await Promise.all([
const [sectionsRes, entriesRes, allergensRes, additivesRes, settingsRes] = query<Section>("select * from sections order by sort"),
await Promise.all([ loadMenuEntries(monday, friday),
supabase.from("sections").select("*").order("sort"), query<CodeItem>("select * from allergens order by sort"),
supabase query<CodeItem>("select * from additives order by sort"),
.from("menu_entries") getSettings(),
.select( ]);
"id, menu_date, section_key, position, dish:dishes(id, name, name_en, name_pl, price, allergens, additives)"
)
.gte("menu_date", monday)
.lte("menu_date", friday)
.order("position")
.order("created_at"),
supabase.from("allergens").select("*").order("sort"),
supabase.from("additives").select("*").order("sort"),
supabase.from("settings").select("logo_path").eq("id", 1).maybeSingle(),
]);
const sections = (sectionsRes.data ?? []) as Section[]; const logo = logoUrl(settings?.logo_path);
const entries = (entriesRes.data ?? []) as unknown as MenuEntry[];
const allergens = (allergensRes.data ?? []) as CodeItem[];
const additives = (additivesRes.data ?? []) as CodeItem[];
const logo = logoUrl(settingsRes.data?.logo_path);
return ( return (
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full"> <div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
+23 -25
View File
@@ -3,8 +3,7 @@
// Pflege der globalen Allergen-/Zusatzstofflisten, die unterhalb // Pflege der globalen Allergen-/Zusatzstofflisten, die unterhalb
// der öffentlichen Speisekarte angezeigt werden. // der öffentlichen Speisekarte angezeigt werden.
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
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";
@@ -17,7 +16,6 @@ export default function CodeListManager({
table: "allergens" | "additives"; table: "allergens" | "additives";
translateEnabled: boolean; translateEnabled: boolean;
}) { }) {
const supabase = useMemo(() => createClient(), []);
const dict = useDict(); const dict = useDict();
const locale = useLocale(); const locale = useLocale();
const title = const title =
@@ -41,10 +39,10 @@ export default function CodeListManager({
); );
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase.from(table).select("*").order("sort"); const res = await fetch(`/api/${table}`);
if (error) setError(formatMessage(dict.codeList.loadError, { message: error.message })); if (!res.ok) setError(formatMessage(dict.codeList.loadError, { message: String(res.status) }));
else setItems(data as CodeItem[]); else setItems(await res.json());
}, [supabase, table, dict]); }, [table, dict]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -64,23 +62,23 @@ export default function CodeListManager({
name_pl: editing.namePl.trim() || null, name_pl: editing.namePl.trim() || null,
}; };
let dbError; const res = editing.original
if (editing.original) { ? await fetch(`/api/${table}/${editing.original}`, {
({ error: dbError } = await supabase method: "PATCH",
.from(table) headers: { "Content-Type": "application/json" },
.update(payload) body: JSON.stringify(payload),
.eq("code", editing.original)); })
} else { : await fetch(`/api/${table}`, {
const maxSort = items.reduce((m, i) => Math.max(m, i.sort), 0); method: "POST",
({ error: dbError } = await supabase headers: { "Content-Type": "application/json" },
.from(table) body: JSON.stringify(payload),
.insert({ ...payload, sort: maxSort + 1 })); });
} if (!res.ok) {
if (dbError) { const data = await res.json().catch(() => ({}));
setError( setError(
/duplicate key/i.test(dbError.message) data.error === "duplicate_code"
? formatMessage(dict.codeList.duplicateCode, { code }) ? formatMessage(dict.codeList.duplicateCode, { code })
: formatMessage(dict.codeList.saveError, { message: dbError.message }) : formatMessage(dict.codeList.saveError, { message: data.error ?? String(res.status) })
); );
return; return;
} }
@@ -95,9 +93,9 @@ export default function CodeListManager({
) )
) )
return; return;
const { error } = await supabase.from(table).delete().eq("code", item.code); const res = await fetch(`/api/${table}/${item.code}`, { method: "DELETE" });
if (error) if (!res.ok)
setError(formatMessage(dict.codeList.deleteError, { message: error.message })); setError(formatMessage(dict.codeList.deleteError, { message: String(res.status) }));
await load(); await load();
} }
+32 -48
View File
@@ -1,21 +1,14 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { formatMessage } from "@/lib/i18n/shared"; import { formatMessage } from "@/lib/i18n/shared";
import { DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types"; import { DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types";
import { useDict } from "@/components/i18n/LocaleProvider"; import { useDict } from "@/components/i18n/LocaleProvider";
type AssetKind = "logo" | "favicon"; type AssetKind = "logo" | "favicon";
const ASSET_COLUMN: Record<AssetKind, "logo_path" | "favicon_path"> = {
logo: "logo_path",
favicon: "favicon_path",
};
export default function DesignEditor() { export default function DesignEditor() {
const supabase = useMemo(() => createClient(), []);
const router = useRouter(); const router = useRouter();
const dict = useDict(); const dict = useDict();
const colorLabels: Record<string, string> = { const colorLabels: Record<string, string> = {
@@ -36,19 +29,16 @@ export default function DesignEditor() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase const res = await fetch("/api/settings");
.from("settings") if (!res.ok) {
.select("colors, logo_path, favicon_path") setError(formatMessage(dict.designEditor.loadError, { message: String(res.status) }));
.eq("id", 1)
.maybeSingle();
if (error) {
setError(formatMessage(dict.designEditor.loadError, { message: error.message }));
return; return;
} }
const data = await res.json();
setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) }); setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) });
setLogoPath(data?.logo_path ?? null); setLogoPath(data?.logo_path ?? null);
setFaviconPath(data?.favicon_path ?? null); setFaviconPath(data?.favicon_path ?? null);
}, [supabase, dict]); }, [dict]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -58,12 +48,13 @@ export default function DesignEditor() {
setBusy(true); setBusy(true);
setError(null); setError(null);
setInfo(null); setInfo(null);
const { error } = await supabase const res = await fetch("/api/settings", {
.from("settings") method: "PATCH",
.update({ colors }) headers: { "Content-Type": "application/json" },
.eq("id", 1); body: JSON.stringify({ colors }),
if (error) });
setError(formatMessage(dict.designEditor.saveError, { message: error.message })); if (!res.ok)
setError(formatMessage(dict.designEditor.saveError, { message: String(res.status) }));
else { else {
setInfo(dict.designEditor.colorsSaved); setInfo(dict.designEditor.colorsSaved);
router.refresh(); router.refresh();
@@ -87,28 +78,21 @@ export default function DesignEditor() {
setError(null); setError(null);
setInfo(null); setInfo(null);
const oldPath = kind === "logo" ? logoPath : faviconPath; const formData = new FormData();
const ext = file.name.split(".").pop()?.toLowerCase() ?? "png"; formData.append("kind", kind);
const path = `${kind}-${Date.now()}.${ext}`; formData.append("file", file);
const { error: uploadError } = await supabase.storage const res = await fetch("/api/admin/branding", { method: "POST", body: formData });
.from("branding") if (!res.ok) {
.upload(path, file, { contentType: file.type }); const data = await res.json().catch(() => ({}));
if (uploadError) { setError(
setError(formatMessage(dict.designEditor.uploadError, { message: uploadError.message })); formatMessage(dict.designEditor.uploadError, {
message: data.error ?? String(res.status),
})
);
setBusy(false); setBusy(false);
return; return;
} }
const { path } = await res.json();
const { error: settingsError } = await supabase
.from("settings")
.update({ [ASSET_COLUMN[kind]]: path })
.eq("id", 1);
if (settingsError) {
setError(formatMessage(dict.designEditor.saveError, { message: settingsError.message }));
setBusy(false);
return;
}
if (oldPath) await supabase.storage.from("branding").remove([oldPath]);
if (kind === "logo") setLogoPath(path); if (kind === "logo") setLogoPath(path);
else setFaviconPath(path); else setFaviconPath(path);
@@ -130,14 +114,14 @@ export default function DesignEditor() {
return; return;
setBusy(true); setBusy(true);
setError(null); setError(null);
const { error } = await supabase const res = await fetch("/api/admin/branding", {
.from("settings") method: "DELETE",
.update({ [ASSET_COLUMN[kind]]: null }) headers: { "Content-Type": "application/json" },
.eq("id", 1); body: JSON.stringify({ kind }),
if (error) { });
setError(formatMessage(dict.designEditor.saveError, { message: error.message })); if (!res.ok) {
setError(formatMessage(dict.designEditor.saveError, { message: String(res.status) }));
} else { } else {
await supabase.storage.from("branding").remove([path]);
if (kind === "logo") setLogoPath(null); if (kind === "logo") setLogoPath(null);
else setFaviconPath(null); else setFaviconPath(null);
setInfo(kind === "logo" ? dict.designEditor.logoRemoved : dict.designEditor.faviconRemoved); setInfo(kind === "logo" ? dict.designEditor.logoRemoved : dict.designEditor.faviconRemoved);
+33 -27
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { dishCodes, formatPrice } from "@/lib/menu"; import { dishCodes, formatPrice } from "@/lib/menu";
import { formatMessage } from "@/lib/i18n/shared"; import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types"; import { localized } from "@/lib/types";
@@ -34,7 +33,6 @@ export default function DishManager({
}: { }: {
translateEnabled: boolean; translateEnabled: boolean;
}) { }) {
const supabase = useMemo(() => createClient(), []);
const dict = useDict(); const dict = useDict();
const locale = useLocale(); const locale = useLocale();
const [dishes, setDishes] = useState<Dish[]>([]); const [dishes, setDishes] = useState<Dish[]>([]);
@@ -58,20 +56,19 @@ export default function DishManager({
const load = useCallback(async () => { const load = useCallback(async () => {
const [dishesRes, allergensRes, additivesRes] = await Promise.all([ const [dishesRes, allergensRes, additivesRes] = await Promise.all([
supabase.from("dishes").select("*").order("name"), fetch("/api/dishes"),
supabase.from("allergens").select("*").order("sort"), fetch("/api/allergens"),
supabase.from("additives").select("*").order("sort"), fetch("/api/additives"),
]); ]);
const problem = dishesRes.error ?? allergensRes.error ?? additivesRes.error; if (!dishesRes.ok || !allergensRes.ok || !additivesRes.ok) {
if (problem) setError(formatMessage(dict.dishManager.loadError, { message: "load failed" }));
setError(formatMessage(dict.dishManager.loadError, { message: problem.message })); } else {
else { setDishes(await dishesRes.json());
setDishes(dishesRes.data as Dish[]); setAllergens(await allergensRes.json());
setAllergens(allergensRes.data as CodeItem[]); setAdditives(await additivesRes.json());
setAdditives(additivesRes.data as CodeItem[]);
} }
setLoading(false); setLoading(false);
}, [supabase, dict]); }, [dict]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -94,11 +91,19 @@ export default function DishManager({
allergens: form.allergens, allergens: form.allergens,
additives: form.additives, additives: form.additives,
}; };
const { error } = form.id const res = form.id
? await supabase.from("dishes").update(payload).eq("id", form.id) ? await fetch(`/api/dishes/${form.id}`, {
: await supabase.from("dishes").insert(payload); method: "PATCH",
if (error) { headers: { "Content-Type": "application/json" },
setError(formatMessage(dict.dishManager.saveError, { message: error.message })); body: JSON.stringify(payload),
})
: await fetch("/api/dishes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setError(formatMessage(dict.dishManager.saveError, { message: String(res.status) }));
return; return;
} }
setForm(null); setForm(null);
@@ -112,9 +117,9 @@ export default function DishManager({
) )
) )
return; return;
const { error } = await supabase.from("dishes").delete().eq("id", dish.id); const res = await fetch(`/api/dishes/${dish.id}`, { method: "DELETE" });
if (error) if (!res.ok)
setError(formatMessage(dict.dishManager.deleteError, { message: error.message })); setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) }));
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(dish.id); next.delete(dish.id);
@@ -156,12 +161,13 @@ export default function DishManager({
return; return;
setBulkBusy(true); setBulkBusy(true);
setError(null); setError(null);
const { error } = await supabase const res = await fetch("/api/dishes/bulk-delete", {
.from("dishes") method: "POST",
.delete() headers: { "Content-Type": "application/json" },
.in("id", Array.from(selected)); body: JSON.stringify({ ids: Array.from(selected) }),
if (error) });
setError(formatMessage(dict.dishManager.deleteError, { message: error.message })); if (!res.ok)
setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) }));
setSelected(new Set()); setSelected(new Set());
setBulkBusy(false); setBulkBusy(false);
await load(); await load();
+34 -37
View File
@@ -3,8 +3,7 @@
// Verwaltung der Essenszeiten (Brotzeit, Mittagessen, …): // Verwaltung der Essenszeiten (Brotzeit, Mittagessen, …):
// anlegen, umbenennen, Uhrzeit ändern, löschen, Reihenfolge anpassen. // anlegen, umbenennen, Uhrzeit ändern, löschen, Reihenfolge anpassen.
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
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";
@@ -34,7 +33,6 @@ function splitTimeRange(range: string): { from: string; to: string } {
} }
export default function SectionManager() { export default function SectionManager() {
const supabase = useMemo(() => createClient(), []);
const dict = useDict(); const dict = useDict();
const locale = useLocale(); const locale = useLocale();
const [sections, setSections] = useState<Section[]>([]); const [sections, setSections] = useState<Section[]>([]);
@@ -44,12 +42,12 @@ export default function SectionManager() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase.from("sections").select("*").order("sort"); const res = await fetch("/api/sections");
if (error) if (!res.ok)
setError(formatMessage(dict.sectionManager.loadError, { message: error.message })); setError(formatMessage(dict.sectionManager.loadError, { message: String(res.status) }));
else setSections(data as Section[]); else setSections(await res.json());
setLoading(false); setLoading(false);
}, [supabase, dict]); }, [dict]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -68,27 +66,19 @@ export default function SectionManager() {
time_range: `${form.timeFrom} - ${form.timeTo}`, time_range: `${form.timeFrom} - ${form.timeTo}`,
}; };
let dbError; const res = form.key
if (form.key) { ? await fetch(`/api/sections/${form.key}`, {
({ error: dbError } = await supabase method: "PATCH",
.from("sections") headers: { "Content-Type": "application/json" },
.update(payload) body: JSON.stringify(payload),
.eq("key", form.key)); })
} else { : await fetch("/api/sections", {
// Frisch aus der DB lesen statt den lokalen State zu nutzen, damit method: "POST",
// ein veralteter Snapshot nicht zu doppelten sort-Werten führt. headers: { "Content-Type": "application/json" },
const { data: maxRow } = await supabase body: JSON.stringify(payload),
.from("sections") });
.select("sort") if (!res.ok) {
.order("sort", { ascending: false }) setError(formatMessage(dict.sectionManager.saveError, { message: String(res.status) }));
.limit(1)
.maybeSingle();
({ error: dbError } = await supabase
.from("sections")
.insert({ ...payload, sort: (maxRow?.sort ?? 0) + 1 }));
}
if (dbError) {
setError(formatMessage(dict.sectionManager.saveError, { message: dbError.message }));
return; return;
} }
setForm(null); setForm(null);
@@ -102,9 +92,9 @@ export default function SectionManager() {
) )
) )
return; return;
const { error } = await supabase.from("sections").delete().eq("key", section.key); const res = await fetch(`/api/sections/${section.key}`, { method: "DELETE" });
if (error) if (!res.ok)
setError(formatMessage(dict.sectionManager.deleteError, { message: error.message })); setError(formatMessage(dict.sectionManager.deleteError, { message: String(res.status) }));
await load(); await load();
} }
@@ -115,12 +105,19 @@ export default function SectionManager() {
setBusy(true); setBusy(true);
setError(null); setError(null);
const [r1, r2] = await Promise.all([ const [r1, r2] = await Promise.all([
supabase.from("sections").update({ sort: neighbor.sort }).eq("key", section.key), fetch(`/api/sections/${section.key}`, {
supabase.from("sections").update({ sort: section.sort }).eq("key", neighbor.key), method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sort: neighbor.sort }),
}),
fetch(`/api/sections/${neighbor.key}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sort: section.sort }),
}),
]); ]);
const dbError = r1.error ?? r2.error; if (!r1.ok || !r2.ok)
if (dbError) setError(formatMessage(dict.sectionManager.saveError, { message: "move failed" }));
setError(formatMessage(dict.sectionManager.saveError, { message: dbError.message }));
setBusy(false); setBusy(false);
await load(); await load();
} }
+13 -18
View File
@@ -2,13 +2,11 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { formatMessage } from "@/lib/i18n/shared"; import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider"; import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
export default function SuggestionForm() { export default function SuggestionForm() {
const supabase = createClient();
const dict = useDict(); const dict = useDict();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@@ -34,23 +32,20 @@ export default function SuggestionForm() {
} }
setBusy(true); setBusy(true);
const { data: existing } = await supabase const res = await fetch("/api/dish-suggestions", {
.from("dishes") method: "POST",
.select("id") headers: { "Content-Type": "application/json" },
.ilike("name", trimmedName) body: JSON.stringify({ name: trimmedName, description: description.trim() || null }),
.limit(1);
if (existing && existing.length > 0) {
setError(dict.suggestions.duplicate);
setBusy(false);
return;
}
const { error: insertError } = await supabase.from("dish_suggestions").insert({
name: trimmedName,
description: description.trim() || null,
}); });
if (insertError) { if (!res.ok) {
setError(formatMessage(dict.suggestions.genericError, { message: insertError.message })); const data = await res.json().catch(() => ({}));
setError(
data.error === "duplicate"
? dict.suggestions.duplicate
: formatMessage(dict.suggestions.genericError, {
message: data.error ?? String(res.status),
})
);
setBusy(false); setBusy(false);
return; return;
} }
+12 -33
View File
@@ -1,13 +1,11 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { formatMessage } from "@/lib/i18n/shared"; import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider"; import { useDict } from "@/components/i18n/LocaleProvider";
import type { DishSuggestion } from "@/lib/types"; import type { DishSuggestion } from "@/lib/types";
export default function SuggestionReview() { export default function SuggestionReview() {
const supabase = useMemo(() => createClient(), []);
const dict = useDict(); const dict = useDict();
const [items, setItems] = useState<DishSuggestion[]>([]); const [items, setItems] = useState<DishSuggestion[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -16,15 +14,12 @@ export default function SuggestionReview() {
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase const res = await fetch("/api/dish-suggestions");
.from("dish_suggestions") if (!res.ok)
.select("*") setError(formatMessage(dict.suggestions.loadError, { message: String(res.status) }));
.order("created_at", { ascending: false }); else setItems(await res.json());
if (error)
setError(formatMessage(dict.suggestions.loadError, { message: error.message }));
else setItems(data as DishSuggestion[]);
setLoading(false); setLoading(false);
}, [supabase, dict]); }, [dict]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -34,25 +29,9 @@ export default function SuggestionReview() {
setBusyId(item.id); setBusyId(item.id);
setError(null); setError(null);
setInfo(null); setInfo(null);
const { error: insertError } = await supabase.from("dishes").insert({ const res = await fetch(`/api/dish-suggestions/${item.id}/accept`, { method: "POST" });
name: item.name, if (!res.ok) {
price: 0, setError(formatMessage(dict.suggestions.actionError, { message: String(res.status) }));
});
if (insertError) {
setError(
formatMessage(dict.suggestions.actionError, { message: insertError.message })
);
setBusyId(null);
return;
}
const { error: deleteError } = await supabase
.from("dish_suggestions")
.delete()
.eq("id", item.id);
if (deleteError) {
setError(
formatMessage(dict.suggestions.actionError, { message: deleteError.message })
);
setBusyId(null); setBusyId(null);
return; return;
} }
@@ -67,9 +46,9 @@ export default function SuggestionReview() {
setBusyId(item.id); setBusyId(item.id);
setError(null); setError(null);
setInfo(null); setInfo(null);
const { error } = await supabase.from("dish_suggestions").delete().eq("id", item.id); const res = await fetch(`/api/dish-suggestions/${item.id}`, { method: "DELETE" });
if (error) { if (!res.ok) {
setError(formatMessage(dict.suggestions.actionError, { message: error.message })); setError(formatMessage(dict.suggestions.actionError, { message: String(res.status) }));
} else { } else {
setInfo(dict.suggestions.rejected); setInfo(dict.suggestions.rejected);
} }
+9 -17
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { formatMessage } from "@/lib/i18n/shared"; import { formatMessage } from "@/lib/i18n/shared";
import type { Profile, Role } from "@/lib/types"; import type { Profile, Role } from "@/lib/types";
import { useDict } from "@/components/i18n/LocaleProvider"; import { useDict } from "@/components/i18n/LocaleProvider";
@@ -24,30 +23,23 @@ const EMPTY_FORM: UserForm = {
passwordRepeat: "", passwordRepeat: "",
}; };
export default function UserManager() { export default function UserManager({ meId }: { meId: string }) {
const supabase = useMemo(() => createClient(), []);
const dict = useDict(); const dict = useDict();
const roleLabels: Record<Role, string> = { const roleLabels: Record<Role, string> = {
admin: dict.common.roleAdmin, admin: dict.common.roleAdmin,
staff: dict.common.roleStaff, staff: dict.common.roleStaff,
}; };
const [profiles, setProfiles] = useState<Profile[]>([]); const [profiles, setProfiles] = useState<Profile[]>([]);
const [me, setMe] = useState<string | null>(null);
const [form, setForm] = useState<UserForm | null>(null); const [form, setForm] = useState<UserForm | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
const [{ data: profileData, error: profileError }, { data: userData }] = const res = await fetch("/api/admin/users");
await Promise.all([ if (!res.ok)
supabase.from("profiles").select("*").order("created_at"), setError(formatMessage(dict.userManager.loadError, { message: String(res.status) }));
supabase.auth.getUser(), else setProfiles(await res.json());
]); }, [dict]);
if (profileError)
setError(formatMessage(dict.userManager.loadError, { message: profileError.message }));
else setProfiles(profileData as Profile[]);
setMe(userData.user?.id ?? null);
}, [supabase, dict]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -235,7 +227,7 @@ export default function UserManager() {
<span className="text-gray-500"> <span className="text-gray-500">
– {p.email} – {roleLabels[p.role]} – {p.email} – {roleLabels[p.role]}
</span> </span>
{p.id === me && <span className="text-gray-400"> {dict.common.you}</span>} {p.id === meId && <span className="text-gray-400"> {dict.common.you}</span>}
</span> </span>
<button <button
type="button" type="button"
@@ -253,7 +245,7 @@ export default function UserManager() {
> >
{dict.common.edit} {dict.common.edit}
</button> </button>
{p.id !== me && ( {p.id !== meId && (
<button <button
type="button" type="button"
className="btn btn-del" className="btn btn-del"
+25 -31
View File
@@ -1,8 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { import {
addDays, addDays,
dishCodes, dishCodes,
@@ -18,7 +17,6 @@ import type { Dish, MenuEntry, Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
export default function WeekEditor({ monday }: { monday: string }) { export default function WeekEditor({ monday }: { monday: string }) {
const supabase = useMemo(() => createClient(), []);
const dict = useDict(); const dict = useDict();
const locale = useLocale(); const locale = useLocale();
const friday = addDays(monday, 4); const friday = addDays(monday, 4);
@@ -41,28 +39,19 @@ export default function WeekEditor({ monday }: { monday: string }) {
const load = useCallback(async () => { const load = useCallback(async () => {
setError(null); setError(null);
const [se, di, en] = await Promise.all([ const [se, di, en] = await Promise.all([
supabase.from("sections").select("*").order("sort"), fetch("/api/sections"),
supabase.from("dishes").select("*").order("name"), fetch("/api/dishes"),
supabase fetch(`/api/menu-entries?from=${monday}&to=${friday}`),
.from("menu_entries")
.select(
"id, menu_date, section_key, position, dish:dishes(id, name, name_en, name_pl, price, allergens, additives)"
)
.gte("menu_date", monday)
.lte("menu_date", friday)
.order("position")
.order("created_at"),
]); ]);
const problem = se.error ?? di.error ?? en.error; if (!se.ok || !di.ok || !en.ok) {
if (problem) { setError(formatMessage(dict.weekEditor.loadError, { message: "load failed" }));
setError(formatMessage(dict.weekEditor.loadError, { message: problem.message }));
} else { } else {
setSections(se.data as Section[]); setSections(await se.json());
setDishes(di.data as Dish[]); setDishes(await di.json());
setEntries(en.data as unknown as MenuEntry[]); setEntries(await en.json());
} }
setLoading(false); setLoading(false);
}, [supabase, monday, friday, dict]); }, [monday, friday, dict]);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
@@ -71,16 +60,21 @@ export default function WeekEditor({ monday }: { monday: string }) {
async function addEntry() { async function addEntry() {
if (!adding || !selectedDish) return; if (!adding || !selectedDish) return;
const { error } = await supabase.from("menu_entries").insert({ const res = await fetch("/api/menu-entries", {
menu_date: adding.iso, method: "POST",
section_key: adding.section, headers: { "Content-Type": "application/json" },
dish_id: selectedDish, body: JSON.stringify({
menu_date: adding.iso,
section_key: adding.section,
dish_id: selectedDish,
}),
}); });
if (error) { if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError( setError(
/duplicate key/i.test(error.message) data.error === "duplicate_entry"
? dict.weekEditor.duplicateEntry ? dict.weekEditor.duplicateEntry
: formatMessage(dict.weekEditor.saveError, { message: error.message }) : formatMessage(dict.weekEditor.saveError, { message: data.error ?? String(res.status) })
); );
} }
setAdding(null); setAdding(null);
@@ -89,9 +83,9 @@ export default function WeekEditor({ monday }: { monday: string }) {
} }
async function removeEntry(id: string) { async function removeEntry(id: string) {
const { error } = await supabase.from("menu_entries").delete().eq("id", id); const res = await fetch(`/api/menu-entries/${id}`, { method: "DELETE" });
if (error) if (!res.ok)
setError(formatMessage(dict.weekEditor.deleteError, { message: error.message })); setError(formatMessage(dict.weekEditor.deleteError, { message: String(res.status) }));
await load(); await load();
} }
+21
View File
@@ -107,4 +107,25 @@ export async function destroySession(): Promise<void> {
store.delete(SESSION_COOKIE); store.delete(SESSION_COOKIE);
} }
/** Wandelt UnauthorizedError/ForbiddenError in die passende JSON-Antwort
* um; gibt null zurück, wenn err ein anderer Fehler ist (dann soll der
* Aufrufer ihn weiterwerfen). Reduziert Wiederholung des immer gleichen
* try/catch-Musters in den Route-Handlern. */
export function authErrorResponse(err: unknown): Response | null {
if (err instanceof UnauthorizedError) {
return NextResponseJson({ error: "unauthorized" }, 401);
}
if (err instanceof ForbiddenError) {
return NextResponseJson({ error: "forbidden" }, 403);
}
return null;
}
function NextResponseJson(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
export { REFRESH_THRESHOLD_MS, hashToken, isSecureCookie }; export { REFRESH_THRESHOLD_MS, hashToken, isSecureCookie };
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
// Ersetzt den früheren Supabase-Storage-Bucket "branding" durch ein
// lokales Verzeichnis (Docker-Volume in Produktion, s. docker-compose.yml).
export function brandingDir(): string {
// turbopackIgnore: process.cwd() ist hier dynamisch, aber der Pfad
// bleibt statisch unter "data/branding" – ohne den Kommentar versucht
// Turbopack sonst, das gesamte Projektverzeichnis nach möglichen
// dynamischen require()-Zielen zu durchsuchen (harmlos, aber unnötig).
return (
process.env.BRANDING_DIR ??
path.join(/*turbopackIgnore: true*/ process.cwd(), "data", "branding")
);
}
// Nur einfache, von uns selbst vergebene Dateinamen zulassen
// (`${kind}-${Date.now()}.${ext}`) – verhindert Path-Traversal über
// Dateinamen aus der DB-Spalte logo_path/favicon_path.
const SAFE_FILENAME = /^[a-zA-Z0-9_.-]+$/;
export function isSafeFilename(filename: string): boolean {
return SAFE_FILENAME.test(filename) && !filename.includes("..");
}
export async function saveBrandingFile(filename: string, data: Buffer): Promise<void> {
const dir = brandingDir();
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, filename), data);
}
export async function readBrandingFile(filename: string): Promise<Buffer | null> {
if (!isSafeFilename(filename)) return null;
try {
return await readFile(path.join(brandingDir(), filename));
} catch {
return null;
}
}
export async function removeBrandingFile(filename: string): Promise<void> {
try {
await unlink(path.join(brandingDir(), filename));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
}
+41
View File
@@ -0,0 +1,41 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
import type { CodeItem } from "@/lib/types";
// "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel +
// Bezeichnung in DE/EN/PL). Tabellenname kommt nie aus Nutzereingaben,
// sondern immer aus dem literal getypten Parameter unten – sicher für
// String-Interpolation in der Query.
export type CodeTable = "allergens" | "additives";
export async function listCodes(table: CodeTable): Promise<CodeItem[]> {
return query<CodeItem>(`select * from ${table} order by sort`);
}
export async function createCode(
table: CodeTable,
input: { code: string; name: string; name_en: string | null; name_pl: string | null }
): Promise<CodeItem> {
const maxRow = await queryOne<{ sort: number }>(
`select sort from ${table} order by sort desc limit 1`
);
return (await queryOne<CodeItem>(
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5) returning *`,
[input.code, input.name, input.name_en, input.name_pl, (maxRow?.sort ?? 0) + 1]
))!;
}
export async function updateCode(
table: CodeTable,
originalCode: string,
input: { code: string; name: string; name_en: string | null; name_pl: string | null }
): Promise<CodeItem | null> {
return queryOne<CodeItem>(
`update ${table} set code = $1, name = $2, name_en = $3, name_pl = $4 where code = $5 returning *`,
[input.code, input.name, input.name_en, input.name_pl, originalCode]
);
}
export async function deleteCode(table: CodeTable, code: string): Promise<void> {
await query(`delete from ${table} where code = $1`, [code]);
}
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import { query } from "@/lib/db";
import type { MenuEntry } from "@/lib/types";
interface MenuEntryRow {
id: string;
menu_date: string;
section_key: string;
position: number;
dish_id: string;
name: string;
name_en: string | null;
name_pl: string | null;
price: string;
allergens: string[];
additives: string[];
}
/** Wochenplan-Einträge inkl. zugehörigem Gericht für den Zeitraum laden –
* gemeinsam von der öffentlichen Speisekarte (Server Component) und
* /api/menu-entries genutzt, damit der JOIN nur einmal gepflegt wird. */
export async function loadMenuEntries(fromISO: string, toISO: string): Promise<MenuEntry[]> {
const rows = await query<MenuEntryRow>(
`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
from menu_entries me
join dishes d on d.id = me.dish_id
where me.menu_date between $1 and $2
order by me.position, me.created_at`,
[fromISO, toISO]
);
return rows.map((r) => ({
id: r.id,
menu_date: r.menu_date,
section_key: r.section_key,
position: r.position,
dish: {
id: r.dish_id,
name: r.name,
name_en: r.name_en,
name_pl: r.name_pl,
price: Number(r.price),
allergens: r.allergens,
additives: r.additives,
},
}));
}
+12 -1
View File
@@ -1,5 +1,16 @@
import "server-only"; import "server-only";
import { Pool } from "pg"; import { Pool, types } from "pg";
// pg gibt "date"-Spalten sonst als lokale Date-Objekte zurück (Zeitzonen-
// Verschiebung möglich); die App vergleicht Datumswerte überall als reine
// "YYYY-MM-DD"-Strings (siehe lib/menu.ts), daher Rohstring beibehalten.
types.setTypeParser(types.builtins.DATE, (val) => val);
// pg gibt "numeric" (dishes.price) sonst als String zurück (Precision-
// Schutz), die App erwartet aber überall eine JS-Zahl (wie zuvor von
// Postgrest geliefert) – für Preise im niedrigen vierstelligen Bereich
// mit 2 Nachkommastellen unproblematisch.
types.setTypeParser(types.builtins.NUMERIC, (val) => Number(val));
// Next.js' Dev-Server lädt Module bei Hot-Reloads neu; ohne den // Next.js' Dev-Server lädt Module bei Hot-Reloads neu; ohne den
// globalThis-Cache würde bei jedem Speichern ein neuer Pool (und damit // globalThis-Cache würde bei jedem Speichern ein neuer Pool (und damit
+14
View File
@@ -0,0 +1,14 @@
import "server-only";
import { queryOne } from "@/lib/db";
export interface Settings {
colors: Record<string, string>;
logo_path: string | null;
favicon_path: string | null;
}
export async function getSettings(): Promise<Settings | null> {
return queryOne<Settings>(
"select colors, logo_path, favicon_path from settings where id = 1"
);
}
+3 -3
View File
@@ -185,7 +185,7 @@ const de = {
}, },
passkeys: { passkeys: {
title: "Passkeys", title: "Passkeys",
hint: "Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. Passkeys gelten pro Domain – für den Live-Betrieb muss die Domain in der Supabase-Konfiguration hinterlegt sein.", hint: "Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. Passkeys gelten pro Domain – nach einem Domainwechsel müssen sie neu registriert werden.",
register: "Neuen Passkey registrieren", register: "Neuen Passkey registrieren",
empty: "Noch keine Passkeys registriert.", empty: "Noch keine Passkeys registriert.",
createdOn: "– angelegt am {date}", createdOn: "– angelegt am {date}",
@@ -439,7 +439,7 @@ const en: Dict = {
}, },
passkeys: { passkeys: {
title: "Passkeys", title: "Passkeys",
hint: "With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – for production use, the domain must be configured in Supabase.", hint: "With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – after a domain change they need to be registered again.",
register: "Register new passkey", register: "Register new passkey",
empty: "No passkeys registered yet.", empty: "No passkeys registered yet.",
createdOn: "– created on {date}", createdOn: "– created on {date}",
@@ -691,7 +691,7 @@ const pl: Dict = {
}, },
passkeys: { passkeys: {
title: "Klucze dostępu", title: "Klucze dostępu",
hint: "Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – w środowisku produkcyjnym domena musi być skonfigurowana w Supabase.", hint: "Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – po zmianie domeny trzeba je zarejestrować ponownie.",
register: "Zarejestruj nowy klucz dostępu", register: "Zarejestruj nowy klucz dostępu",
empty: "Nie zarejestrowano jeszcze żadnych kluczy dostępu.", empty: "Nie zarejestrowano jeszcze żadnych kluczy dostępu.",
createdOn: "– utworzono {date}", createdOn: "– utworzono {date}",
-14
View File
@@ -1,14 +0,0 @@
import "server-only";
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
// Service-Role-Client: umgeht RLS. Ausschließlich serverseitig verwenden
// (Benutzerverwaltung durch den Admin).
export function createAdminClient() {
return createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: { persistSession: false, autoRefreshToken: false },
}
);
}
-15
View File
@@ -1,15 +0,0 @@
import { createBrowserClient } from "@supabase/ssr";
// Browser-Client (Client Components). Passkey-Support ist bei Supabase
// derzeit als experimentelles Feature markiert und muss aktiviert werden.
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
experimental: { passkey: true },
},
}
);
}
-30
View File
@@ -1,30 +0,0 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
// Server-Client (Server Components, Route Handler, Server Actions).
// Muss pro Request neu erzeugt werden.
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// In Server Components dürfen keine Cookies gesetzt werden –
// das Session-Refresh übernimmt proxy.ts.
}
},
},
}
);
}
+2 -2
View File
@@ -87,12 +87,12 @@ export const COLOR_VARS: Record<string, string> = {
export function logoUrl(path: string | null | undefined): string | null { export function logoUrl(path: string | null | undefined): string | null {
if (!path) return null; if (!path) return null;
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`; return `/branding/${path}`;
} }
export function faviconUrl(path: string | null | undefined): string | null { export function faviconUrl(path: string | null | undefined): string | null {
if (!path) return null; if (!path) return null;
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`; return `/branding/${path}`;
} }
export const SITE_TITLE = process.env.SITE_TITLE?.trim() || "Canteen Menu"; export const SITE_TITLE = process.env.SITE_TITLE?.trim() || "Canteen Menu";
-47
View File
@@ -1,47 +0,0 @@
// Konfiguriert die Passkey-Domain (WebAuthn Relying Party) des
// Supabase-Projekts auf die öffentliche App-URL. Passkeys sind an die
// Domain gebunden – nach einem Domain-Wechsel einmal ausführen.
//
// Aufruf:
// SUPABASE_ACCESS_TOKEN=... APP_URL=https://menu.example.de node scripts/configure-passkeys.mjs
// oder mit der URL als Argument:
// SUPABASE_ACCESS_TOKEN=... node scripts/configure-passkeys.mjs https://menu.example.de
const PROJECT_REF = "hvzikwjoyhoakedlywhj";
const token = process.env.SUPABASE_ACCESS_TOKEN;
const rawUrl = process.argv[2] ?? process.env.APP_URL;
if (!token || !rawUrl) {
console.error(
"Nutzung: SUPABASE_ACCESS_TOKEN=... [APP_URL=https://…] node scripts/configure-passkeys.mjs [url]"
);
process.exit(1);
}
const url = new URL(rawUrl);
const res = await fetch(
`https://api.supabase.com/v1/projects/${PROJECT_REF}/config/auth`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
passkey_enabled: true,
webauthn_rp_id: url.hostname,
webauthn_rp_display_name: "Canteen Menu",
webauthn_rp_origins: url.origin,
}),
}
);
if (!res.ok) {
console.error(`Fehler ${res.status}: ${await res.text()}`);
process.exit(1);
}
console.log(`Passkey-Domain gesetzt: rp_id=${url.hostname}, origin=${url.origin}`);
console.log(
"Hinweis: Bereits auf einer anderen Domain registrierte Passkeys verlieren ihre Gültigkeit."
);
-32
View File
@@ -1,32 +0,0 @@
// Führt eine SQL-Datei gegen das Supabase-Projekt aus (Management API).
// Aufruf: SUPABASE_ACCESS_TOKEN=... node scripts/db-apply.mjs <pfad-zur-sql-datei>
import { readFileSync } from "node:fs";
const PROJECT_REF = "hvzikwjoyhoakedlywhj";
const token = process.env.SUPABASE_ACCESS_TOKEN;
const file = process.argv[2];
if (!token || !file) {
console.error("Nutzung: SUPABASE_ACCESS_TOKEN=... node scripts/db-apply.mjs <datei.sql>");
process.exit(1);
}
const query = readFileSync(file, "utf8");
const res = await fetch(
`https://api.supabase.com/v1/projects/${PROJECT_REF}/database/query`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
}
);
const body = await res.text();
if (!res.ok) {
console.error(`Fehler ${res.status}: ${body}`);
process.exit(1);
}
console.log("OK:", body.slice(0, 500));
-47
View File
@@ -1,47 +0,0 @@
# Synchronisiert die lokale PostgreSQL-Kopie mit der Supabase-Datenbank.
# Voraussetzungen:
# - PostgreSQL 17 lokal installiert (Standardpfad, Dienst läuft)
# - Umgebungsvariablen:
# SUPABASE_DB_PASSWORD = Datenbank-Passwort des Supabase-Projekts
# LOCAL_PG_PASSWORD = Passwort des lokalen postgres-Benutzers
# Aufruf: powershell -File scripts\sync-local-db.ps1
$ErrorActionPreference = "Stop"
$PgBin = "C:\Program Files\PostgreSQL\17\bin"
$RemoteHost = "aws-0-eu-central-1.pooler.supabase.com"
$RemoteUser = "postgres.hvzikwjoyhoakedlywhj"
$LocalDir = Join-Path $PSScriptRoot "..\supabase\local"
if (-not $env:SUPABASE_DB_PASSWORD) { throw "SUPABASE_DB_PASSWORD ist nicht gesetzt." }
if (-not $env:LOCAL_PG_PASSWORD) { throw "LOCAL_PG_PASSWORD ist nicht gesetzt." }
# 1. Aktuellen Stand aus Supabase ziehen (Session-Pooler, Port 5432)
$env:PGPASSWORD = $env:SUPABASE_DB_PASSWORD
$SchemaFile = Join-Path $LocalDir "10_schema.sql"
& "$PgBin\pg_dump" -h $RemoteHost -p 5432 -U $RemoteUser -d postgres `
--schema=public --schema-only --no-owner --no-privileges `
-f $SchemaFile
& "$PgBin\pg_dump" -h $RemoteHost -p 5432 -U $RemoteUser -d postgres `
--schema=public --data-only --no-owner --disable-triggers `
-f (Join-Path $LocalDir "20_data.sql")
# pg_dump legt "CREATE SCHEMA public;" und \restrict/\unrestrict-Marker an.
# Das offizielle postgres-Docker-Image besitzt "public" bereits und bricht
# mit ON_ERROR_STOP=1 sonst das komplette Init-Skript ab.
(Get-Content $SchemaFile) `
-replace '^CREATE SCHEMA public;$', 'CREATE SCHEMA IF NOT EXISTS public;' `
-replace '^\\restrict .*$', '' `
-replace '^\\unrestrict .*$', '' |
Set-Content $SchemaFile
# 2. Lokale Datenbank neu aufbauen
$env:PGPASSWORD = $env:LOCAL_PG_PASSWORD
& "$PgBin\psql" -h localhost -U postgres -c "drop database if exists canteen_menu;"
& "$PgBin\psql" -h localhost -U postgres -c "create database canteen_menu;"
foreach ($file in @("00_stubs.sql", "10_schema.sql", "15_auth_users.sql", "20_data.sql")) {
& "$PgBin\psql" -h localhost -U postgres -d canteen_menu -q -f (Join-Path $LocalDir $file)
}
Write-Host "Lokale Datenbank 'canteen_menu' wurde aktualisiert."
-58
View File
@@ -1,58 +0,0 @@
-- =============================================================
-- Stubs für die LOKALE PostgreSQL-Kopie (ohne Supabase-Stack).
-- Stellt die minimalen Supabase-Objekte bereit, die das public-
-- Schema referenziert (auth.users, auth.uid(), Rollen, storage).
-- Reihenfolge: 00_stubs.sql -> Schema-Dump -> Daten-Dump
-- =============================================================
-- Rollen, die in den RLS-Policies referenziert werden
do $$
begin
if not exists (select from pg_roles where rolname = 'anon') then
create role anon nologin;
end if;
if not exists (select from pg_roles where rolname = 'authenticated') then
create role authenticated nologin;
end if;
if not exists (select from pg_roles where rolname = 'service_role') then
create role service_role nologin bypassrls;
end if;
end
$$;
-- ---------- auth-Schema (Minimal-Stub) ----------
create schema if not exists auth;
create table if not exists auth.users (
id uuid primary key,
email text,
raw_user_meta_data jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
-- Entspricht supabase auth.uid(); liefert lokal null,
-- sofern kein JWT-Claim gesetzt ist.
create or replace function auth.uid()
returns uuid
language sql
stable
as $$
select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid
$$;
-- ---------- storage-Schema (Minimal-Stub) ----------
create schema if not exists storage;
create table if not exists storage.buckets (
id text primary key,
name text not null,
public boolean not null default false
);
create table if not exists storage.objects (
id uuid primary key default gen_random_uuid(),
bucket_id text references storage.buckets (id),
name text,
owner uuid,
created_at timestamptz not null default now()
);
-502
View File
@@ -1,502 +0,0 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 17.6
-- Dumped by pg_dump version 17.10
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
--
CREATE SCHEMA IF NOT EXISTS public;
--
-- Name: SCHEMA public; Type: COMMENT; Schema: -; Owner: -
--
COMMENT ON SCHEMA public IS 'standard public schema';
--
-- Name: app_role(); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.app_role() RETURNS text
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
select role from public.profiles where id = auth.uid()
$$;
--
-- Name: handle_new_user(); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.handle_new_user() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
begin
insert into public.profiles (id, email, full_name, role)
values (
new.id,
new.email,
coalesce(new.raw_user_meta_data ->> 'full_name', ''),
coalesce(new.raw_user_meta_data ->> 'role', 'staff')
)
on conflict (id) do nothing;
return new;
end;
$$;
--
-- Name: handle_user_email_change(); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.handle_user_email_change() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
begin
update public.profiles set email = new.email where id = new.id;
return new;
end;
$$;
--
-- Name: touch_updated_at(); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.touch_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
new.updated_at := now();
return new;
end;
$$;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: additives; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.additives (
code text NOT NULL,
name text NOT NULL,
sort integer DEFAULT 0 NOT NULL,
name_en text,
name_pl text
);
--
-- Name: allergens; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.allergens (
code text NOT NULL,
name text NOT NULL,
sort integer DEFAULT 0 NOT NULL,
name_en text,
name_pl text
);
--
-- Name: dish_suggestions; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.dish_suggestions (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name text NOT NULL,
description text,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: dishes; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.dishes (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name text NOT NULL,
price numeric(6,2) DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
allergens text[] DEFAULT '{}'::text[] NOT NULL,
additives text[] DEFAULT '{}'::text[] NOT NULL,
name_en text,
name_pl text
);
--
-- Name: menu_entries; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.menu_entries (
id uuid DEFAULT gen_random_uuid() NOT NULL,
menu_date date NOT NULL,
section_key text NOT NULL,
dish_id uuid NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: profiles; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.profiles (
id uuid NOT NULL,
email text NOT NULL,
full_name text DEFAULT ''::text NOT NULL,
role text DEFAULT 'staff'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
password_auth_enabled boolean DEFAULT true NOT NULL,
CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['admin'::text, 'staff'::text])))
);
--
-- Name: sections; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.sections (
key text DEFAULT (gen_random_uuid())::text NOT NULL,
label text NOT NULL,
time_range text NOT NULL,
sort integer NOT NULL,
label_en text,
label_pl text
);
--
-- Name: settings; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.settings (
id integer NOT NULL,
colors jsonb DEFAULT '{}'::jsonb NOT NULL,
logo_path text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
favicon_path text,
CONSTRAINT settings_id_check CHECK ((id = 1))
);
--
-- Name: additives additives_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.additives
ADD CONSTRAINT additives_pkey PRIMARY KEY (code);
--
-- Name: allergens allergens_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.allergens
ADD CONSTRAINT allergens_pkey PRIMARY KEY (code);
--
-- Name: dish_suggestions dish_suggestions_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.dish_suggestions
ADD CONSTRAINT dish_suggestions_pkey PRIMARY KEY (id);
--
-- Name: dishes dishes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.dishes
ADD CONSTRAINT dishes_pkey PRIMARY KEY (id);
--
-- Name: menu_entries menu_entries_menu_date_section_key_dish_id_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.menu_entries
ADD CONSTRAINT menu_entries_menu_date_section_key_dish_id_key UNIQUE (menu_date, section_key, dish_id);
--
-- Name: menu_entries menu_entries_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.menu_entries
ADD CONSTRAINT menu_entries_pkey PRIMARY KEY (id);
--
-- Name: profiles profiles_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.profiles
ADD CONSTRAINT profiles_pkey PRIMARY KEY (id);
--
-- Name: sections sections_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.sections
ADD CONSTRAINT sections_pkey PRIMARY KEY (key);
--
-- Name: settings settings_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.settings
ADD CONSTRAINT settings_pkey PRIMARY KEY (id);
--
-- Name: menu_entries_date_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX menu_entries_date_idx ON public.menu_entries USING btree (menu_date);
--
-- Name: dishes dishes_touch_updated_at; Type: TRIGGER; Schema: public; Owner: -
--
CREATE TRIGGER dishes_touch_updated_at BEFORE UPDATE ON public.dishes FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
--
-- Name: settings settings_touch_updated_at; Type: TRIGGER; Schema: public; Owner: -
--
CREATE TRIGGER settings_touch_updated_at BEFORE UPDATE ON public.settings FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
--
-- Name: menu_entries menu_entries_dish_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.menu_entries
ADD CONSTRAINT menu_entries_dish_id_fkey FOREIGN KEY (dish_id) REFERENCES public.dishes(id) ON DELETE CASCADE;
--
-- Name: menu_entries menu_entries_section_key_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.menu_entries
ADD CONSTRAINT menu_entries_section_key_fkey FOREIGN KEY (section_key) REFERENCES public.sections(key) ON DELETE CASCADE;
--
-- Name: profiles profiles_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.profiles
ADD CONSTRAINT profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;
--
-- Name: additives; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.additives ENABLE ROW LEVEL SECURITY;
--
-- Name: additives additives_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY additives_public_read ON public.additives FOR SELECT TO authenticated, anon USING (true);
--
-- Name: additives additives_staff_write; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY additives_staff_write ON public.additives TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: allergens; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.allergens ENABLE ROW LEVEL SECURITY;
--
-- Name: allergens allergens_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY allergens_public_read ON public.allergens FOR SELECT TO authenticated, anon USING (true);
--
-- Name: allergens allergens_staff_write; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY allergens_staff_write ON public.allergens TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: dish_suggestions; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.dish_suggestions ENABLE ROW LEVEL SECURITY;
--
-- Name: dish_suggestions dish_suggestions_public_insert; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY dish_suggestions_public_insert ON public.dish_suggestions FOR INSERT TO authenticated, anon WITH CHECK (true);
--
-- Name: dish_suggestions dish_suggestions_staff_delete; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY dish_suggestions_staff_delete ON public.dish_suggestions FOR DELETE TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: dish_suggestions dish_suggestions_staff_select; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY dish_suggestions_staff_select ON public.dish_suggestions FOR SELECT TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: dishes; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.dishes ENABLE ROW LEVEL SECURITY;
--
-- Name: dishes dishes_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY dishes_public_read ON public.dishes FOR SELECT TO authenticated, anon USING (true);
--
-- Name: dishes dishes_staff_write; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY dishes_staff_write ON public.dishes TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: menu_entries; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.menu_entries ENABLE ROW LEVEL SECURITY;
--
-- Name: menu_entries menu_entries_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY menu_entries_public_read ON public.menu_entries FOR SELECT TO authenticated, anon USING (true);
--
-- Name: menu_entries menu_entries_staff_write; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY menu_entries_staff_write ON public.menu_entries TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: profiles; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
--
-- Name: profiles profiles_select_own_or_admin; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY profiles_select_own_or_admin ON public.profiles FOR SELECT TO authenticated USING (((id = ( SELECT auth.uid() AS uid)) OR (public.app_role() = 'admin'::text)));
--
-- Name: profiles profiles_update_own; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY profiles_update_own ON public.profiles FOR UPDATE TO authenticated USING ((id = ( SELECT auth.uid() AS uid))) WITH CHECK ((id = ( SELECT auth.uid() AS uid)));
--
-- Name: sections; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.sections ENABLE ROW LEVEL SECURITY;
--
-- Name: sections sections_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY sections_public_read ON public.sections FOR SELECT TO authenticated, anon USING (true);
--
-- Name: sections sections_staff_write; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY sections_staff_write ON public.sections TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: settings; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.settings ENABLE ROW LEVEL SECURITY;
--
-- Name: settings settings_admin_update; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY settings_admin_update ON public.settings FOR UPDATE TO authenticated USING ((public.app_role() = 'admin'::text)) WITH CHECK ((public.app_role() = 'admin'::text));
--
-- Name: settings settings_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY settings_public_read ON public.settings FOR SELECT TO authenticated, anon USING (true);
--
-- PostgreSQL database dump complete
--
-6
View File
@@ -1,6 +0,0 @@
-- Benutzer-Stammdaten für die lokale Kopie (ohne Passwort-Hashes –
-- Login ist nur gegen die Supabase-Instanz möglich).
insert into auth.users (id, email, raw_user_meta_data) values
('cee4160a-ab3a-4109-b706-b9f27d515596', 'fw@wildeflorian.de', '{"full_name": "Florian Wilde", "role": "admin"}'),
('102d405c-b010-41f6-9409-244004a3c567', 'kantine@example.com', '{"full_name": "Kantinen-Team", "role": "staff"}')
on conflict (id) do nothing;
-212
View File
@@ -1,212 +0,0 @@
--
-- PostgreSQL database dump
--
\restrict y6aLgjBTAX9fiLwtkHEGaN6baJ1dCr2Yjdb6nmrL5LPA6jgf5Ml1Mm0EpXO56Kk
-- Dumped from database version 17.6
-- Dumped by pg_dump version 17.10
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Data for Name: additives; Type: TABLE DATA; Schema: public; Owner: -
--
SET SESSION AUTHORIZATION DEFAULT;
ALTER TABLE public.additives DISABLE TRIGGER ALL;
COPY public.additives (code, name, sort, name_en, name_pl) FROM stdin;
1 mit Farbstoff 1 with colouring z barwnikiem
2 mit Konservierungsstoff 2 with preservative z konserwantem
3 mit Antioxidationsmittel 3 with antioxidant z przeciwutleniaczem
4 mit Geschmacksverstärker 4 with flavour enhancer ze wzmacniaczem smaku
5 geschwefelt 5 sulphured siarkowany
6 geschwärzt 6 blackened barwiony na czarno
7 gewachst 7 waxed woskowany
8 mit Phosphat 8 with phosphate z fosforanem
9 mit Süßungsmittel(n) 9 with sweetener(s) ze słodzikiem/słodzikami
10 enthält eine Phenylalaninquelle 10 contains a source of phenylalanine zawiera źródło fenyloalaniny
11 koffeinhaltig 11 caffeinated zawiera kofeinę
\.
ALTER TABLE public.additives ENABLE TRIGGER ALL;
--
-- Data for Name: allergens; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.allergens DISABLE TRIGGER ALL;
COPY public.allergens (code, name, sort, name_en, name_pl) FROM stdin;
A Glutenhaltiges Getreide 1 Cereals containing gluten Zboża zawierające gluten
B Krebstiere 2 Crustaceans Skorupiaki
C Eier 3 Eggs Jaja
D Fisch 4 Fish Ryby
E Erdnüsse 5 Peanuts Orzeszki ziemne
F Sojabohnen 6 Soybeans Soja
G Milch (einschließlich Laktose) 7 Milk (including lactose) Mleko (w tym laktoza)
H Schalenfrüchte (Nüsse) 8 Tree nuts Orzechy
I Sellerie 9 Celery Seler
J Senf 10 Mustard Gorczyca
K Sesamsamen 11 Sesame seeds Nasiona sezamu
L Schwefeldioxid und Sulfite 12 Sulphur dioxide and sulphites Dwutlenek siarki i siarczyny
M Lupinen 13 Lupin Łubin
N Weichtiere 14 Molluscs Mięczaki
\.
ALTER TABLE public.allergens ENABLE TRIGGER ALL;
--
-- Data for Name: dish_suggestions; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.dish_suggestions DISABLE TRIGGER ALL;
COPY public.dish_suggestions (id, name, description, created_at) FROM stdin;
\.
ALTER TABLE public.dish_suggestions ENABLE TRIGGER ALL;
--
-- Data for Name: dishes; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.dishes DISABLE TRIGGER ALL;
COPY public.dishes (id, name, price, created_at, updated_at, allergens, additives, name_en, name_pl) FROM stdin;
bf612ae7-8cdd-4149-8b21-f651631ccc02 Hackfleischbaguette 3.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
81c58cc6-7298-4d4a-9997-71d17df5aedf Knusperhähnchen Wrap 3.70 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 Leberkäse im Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
14ef6893-93a7-4649-afe3-d6b6bdf5903c Schnitzel Baguette 3.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 Penne mit Grünen Spargel und Garnelenspieße 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
dbe5ee6e-8b04-4bf7-99bb-f936f6564217 Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
c0613fe7-e5c6-489e-afe5-6da2533b2bd4 Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
c5cfa5b4-e502-4b56-b579-b9fbbf817f25 Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
f8ba1844-1f23-4b23-9e03-f59148f8b2ed Spaghetti mit fruchtiger Tomatensauce und Parmesan 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a Gebackener Feta mit griechischen Bauernsalat und Baguette 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 Gemüse-Curry mit Basmatireis 4.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 Gulaschsuppe 2.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
3c8cf0c2-b0a9-43e3-8233-723d5718a157 Tomatensuppe mit Croutons 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
2dd85835-5425-4271-9fc0-e5215bbce476 Kürbiscremesuppe 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
06df753a-65ed-4bd0-a325-d32c78ec88fa Linseneintopf mit Würstchen 2.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
a987c96f-25a0-4e5e-b8a8-aab683a01feb Chili con Carne mit Brötchen 3.90 2026-07-17 15:17:02.979184+00 2026-07-17 22:21:35.276672+00 {A} {4} \N \N
7ebdf56a-9482-4bde-bf60-e6e5424e397b Kürbisrisotto 0.00 2026-07-17 23:57:48.351754+00 2026-07-17 23:57:48.351754+00 {} {} \N \N
fd356340-e10f-42d7-ae5a-3521a9151bb8 Test 0.00 2026-07-17 23:58:04.141825+00 2026-07-17 23:58:19.411846+00 {} {} abc bca
\.
ALTER TABLE public.dishes ENABLE TRIGGER ALL;
--
-- Data for Name: sections; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.sections DISABLE TRIGGER ALL;
COPY public.sections (key, label, time_range, sort, label_en, label_pl) FROM stdin;
mittagessen_veg Mittagessen vegetarisch 11:30 - 13:30 4 Lunch (vegetarian) Obiad (wegetariański)
mittagessen Mittagessen 11:30 - 13:30 3 Lunch Obiad
brotzeit Brotzeit 09:00 - 10:00 2 Breakfast Śniadanie
suppe Suppe 11:30 - 13:30 6 Soup Zupa
\.
ALTER TABLE public.sections ENABLE TRIGGER ALL;
--
-- Data for Name: menu_entries; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.menu_entries DISABLE TRIGGER ALL;
COPY public.menu_entries (id, menu_date, section_key, dish_id, "position", created_at) FROM stdin;
70469091-417b-47ed-aab5-747ff97e4ad4 2026-07-14 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00
1e1f9b4e-75e7-413f-a747-c2884675d9f7 2026-07-15 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00
7b2089f7-7c01-4d6a-9e68-ebfa594bb713 2026-07-16 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00
d6e24a0d-cc32-438d-9d91-e39ecbb82d5d 2026-07-17 brotzeit a987c96f-25a0-4e5e-b8a8-aab683a01feb 0 2026-07-17 15:17:02.979184+00
c165765a-1eb3-4d7e-9566-3768e9007793 2026-07-13 mittagessen 14ef6893-93a7-4649-afe3-d6b6bdf5903c 0 2026-07-17 15:17:02.979184+00
78be0a94-1b31-47ef-af62-40548d75aa4e 2026-07-14 mittagessen ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 0 2026-07-17 15:17:02.979184+00
0635198c-6aa9-4811-a8fd-67e31174eb73 2026-07-15 mittagessen dbe5ee6e-8b04-4bf7-99bb-f936f6564217 0 2026-07-17 15:17:02.979184+00
f02305b6-7650-4b52-b440-3df7045e17a7 2026-07-16 mittagessen c0613fe7-e5c6-489e-afe5-6da2533b2bd4 0 2026-07-17 15:17:02.979184+00
b838a6a4-1a58-469e-9201-af686c7a780a 2026-07-14 mittagessen_veg c5cfa5b4-e502-4b56-b579-b9fbbf817f25 0 2026-07-17 15:17:02.979184+00
4886ee1a-f8fd-4548-8483-893a0f2dd9aa 2026-07-15 mittagessen_veg f8ba1844-1f23-4b23-9e03-f59148f8b2ed 0 2026-07-17 15:17:02.979184+00
1f6400bb-0b9a-444c-b793-e1cbd96ec215 2026-07-16 mittagessen_veg 7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a 0 2026-07-17 15:17:02.979184+00
7df88a12-6fb8-457e-8cf8-5ddc43e06583 2026-07-13 suppe ea26f18b-ddf3-4899-95e7-5dbd0878d90c 0 2026-07-17 15:17:02.979184+00
e2bfe62e-12c4-4f68-a7e7-ca1ee30a6039 2026-07-14 suppe 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:17:02.979184+00
662902e0-bb5e-4268-b977-cef2ac20dc35 2026-07-15 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00
6083a3f3-a54e-40c5-be1f-c96e474eb8a0 2026-07-16 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
36279cd1-b0e2-46a8-b46c-c23f9f5dbb1b 2026-07-17 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
bfa36cb8-82d6-466c-b954-bf82c6a67b95 2026-07-23 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00
b86caf93-34eb-46a3-bbe4-3a67dcf79be0 2026-07-21 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00
d8a962cd-f1cf-4f87-95fb-f5af79cbd0a5 2026-07-20 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00
c800ef28-70ba-4439-8f1d-e5dcec3ce1c6 2026-07-24 brotzeit a987c96f-25a0-4e5e-b8a8-aab683a01feb 0 2026-07-17 15:17:02.979184+00
a5c54296-1667-4d5d-a62d-3983e1643959 2026-07-22 mittagessen 14ef6893-93a7-4649-afe3-d6b6bdf5903c 0 2026-07-17 15:17:02.979184+00
4a06dd6c-7de8-4628-b491-3dafd409f344 2026-07-23 mittagessen ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 0 2026-07-17 15:17:02.979184+00
ea9a2936-95bf-4cbd-a013-db7ba995efce 2026-07-20 mittagessen dbe5ee6e-8b04-4bf7-99bb-f936f6564217 0 2026-07-17 15:17:02.979184+00
64bc501f-dbcd-474c-b47b-1e99670a3c3e 2026-07-21 mittagessen c0613fe7-e5c6-489e-afe5-6da2533b2bd4 0 2026-07-17 15:17:02.979184+00
dd4105b4-76b8-4ea1-8ee0-6db663a1ffa8 2026-07-23 mittagessen_veg c5cfa5b4-e502-4b56-b579-b9fbbf817f25 0 2026-07-17 15:17:02.979184+00
bac80cc9-ad0e-4cee-81ae-8aa405cad1b9 2026-07-21 mittagessen_veg f8ba1844-1f23-4b23-9e03-f59148f8b2ed 0 2026-07-17 15:17:02.979184+00
62bbb0f9-88e2-4b9f-a7e5-c0dab5ff54c8 2026-07-22 mittagessen_veg 7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a 0 2026-07-17 15:17:02.979184+00
167e0869-126d-4ab7-8e74-051e5c782865 2026-07-20 mittagessen_veg ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 0 2026-07-17 15:17:02.979184+00
34031197-4c3a-4b7e-bc05-7ad7254e8348 2026-07-21 suppe ea26f18b-ddf3-4899-95e7-5dbd0878d90c 0 2026-07-17 15:17:02.979184+00
a6f66639-7a0a-4129-bcea-c6ac9cbccac3 2026-07-20 suppe 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:17:02.979184+00
b0dbf487-c704-4ab2-af67-082addc88039 2026-07-23 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00
d7b5d414-85c2-495a-9945-2ffc10de32ea 2026-07-24 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
7a025e66-44b7-43e7-a51b-5564ab8150b1 2026-07-22 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
2eb81b52-3c50-4449-bef9-bb12aac9991e 2026-07-13 brotzeit 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:43:22.829239+00
\.
ALTER TABLE public.menu_entries ENABLE TRIGGER ALL;
--
-- Data for Name: profiles; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.profiles DISABLE TRIGGER ALL;
COPY public.profiles (id, email, full_name, role, created_at, password_auth_enabled) FROM stdin;
cee4160a-ab3a-4109-b706-b9f27d515596 fw@wildeflorian.de Florian Wilde admin 2026-07-17 15:17:33.46696+00 t
6e3ebbd4-a00a-4b08-a799-1a445a3f9eaa test@test.de Hans admin 2026-07-17 16:56:13.331443+00 t
102d405c-b010-41f6-9409-244004a3c567 kantine@example.com Kantinen-Team staff 2026-07-17 15:17:34.233848+00 t
952b21ad-65a0-4a12-aed1-6c86709e0eb2 kantine@kantine.de Kantine staff 2026-07-17 22:02:36.854844+00 t
\.
ALTER TABLE public.profiles ENABLE TRIGGER ALL;
--
-- Data for Name: settings; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.settings DISABLE TRIGGER ALL;
COPY public.settings (id, colors, logo_path, updated_at, favicon_path) FROM stdin;
1 {"rowBg": "#e3e4f1", "accent": "#ff6900", "addBtn": "#6da54c", "primary": "#003388", "rowBgAlt": "#f4f4fa", "deleteBtn": "#e2371d", "headerText": "#ffffff", "primaryDark": "#0d315c"} logo-1784305413466.webp 2026-07-17 22:40:52.997491+00 \N
\.
ALTER TABLE public.settings ENABLE TRIGGER ALL;
--
-- PostgreSQL database dump complete
--
\unrestrict y6aLgjBTAX9fiLwtkHEGaN6baJ1dCr2Yjdb6nmrL5LPA6jgf5Ml1Mm0EpXO56Kk
-217
View File
@@ -1,217 +0,0 @@
-- =============================================================
-- Speisekarte – Initiales Schema
-- Rollen: admin (Benutzerverwaltung, Branding) / staff (Kantine)
-- Öffentliche Speisekarte: lesbar ohne Login (anon)
-- =============================================================
-- ---------- Profile (1:1 zu auth.users) ----------
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
email text not null,
full_name text not null default '',
role text not null default 'staff' check (role in ('admin', 'staff')),
created_at timestamptz not null default now()
);
-- Rolle des eingeloggten Benutzers ermitteln.
-- security definer: umgeht RLS auf profiles und verhindert Policy-Rekursion.
create or replace function public.app_role()
returns text
language sql
stable
security definer
set search_path = public
as $$
select role from public.profiles where id = auth.uid()
$$;
-- Profil automatisch anlegen, wenn ein Auth-Benutzer erstellt wird.
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, email, full_name, role)
values (
new.id,
new.email,
coalesce(new.raw_user_meta_data ->> 'full_name', ''),
coalesce(new.raw_user_meta_data ->> 'role', 'staff')
)
on conflict (id) do nothing;
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- ---------- Essenszeiten der Speisekarte ----------
create table public.sections (
key text primary key,
label text not null,
time_range text not null,
sort int not null
);
insert into public.sections (key, label, time_range, sort) values
('brotzeit', 'Brotzeit', '09:00 - 10:00', 1),
('mittagessen', 'Mittagessen', '11:30 - 13:30', 2),
('mittagessen_veg', 'Mittagessen vegetarisch', '11:30 - 13:30', 3),
('suppe', 'Suppe', '11:30 - 13:30', 4);
-- ---------- Allergene & Zusatzstoffe ----------
-- Gelten für die komplette Speisekarte (keine Zuordnung pro Gericht)
-- und werden unterhalb der Karte angezeigt. Pflege durch Kantinen-Team.
create table public.allergens (
code text primary key,
name text not null,
sort int not null default 0
);
create table public.additives (
code text primary key,
name text not null,
sort int not null default 0
);
-- ---------- Gerichte ----------
create table public.dishes (
id uuid primary key default gen_random_uuid(),
name text not null,
price numeric(6, 2) not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create or replace function public.touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at := now();
return new;
end;
$$;
create trigger dishes_touch_updated_at
before update on public.dishes
for each row execute function public.touch_updated_at();
-- ---------- Zuweisung Gericht -> Tag/Essenszeit ----------
create table public.menu_entries (
id uuid primary key default gen_random_uuid(),
menu_date date not null,
section_key text not null references public.sections (key),
dish_id uuid not null references public.dishes (id) on delete cascade,
position int not null default 0,
created_at timestamptz not null default now(),
unique (menu_date, section_key, dish_id)
);
create index menu_entries_date_idx on public.menu_entries (menu_date);
-- ---------- Einstellungen (Farben, Logo) – genau eine Zeile ----------
create table public.settings (
id int primary key check (id = 1),
colors jsonb not null default '{}'::jsonb,
logo_path text,
updated_at timestamptz not null default now()
);
insert into public.settings (id, colors) values (
1,
'{
"primary": "#003388",
"primaryDark": "#0d315c",
"accent": "#ff6900",
"headerText": "#ffffff",
"rowBg": "#e3e4f1",
"rowBgAlt": "#f4f4fa",
"addBtn": "#6da54c",
"deleteBtn": "#e2371d"
}'::jsonb
);
create trigger settings_touch_updated_at
before update on public.settings
for each row execute function public.touch_updated_at();
-- =============================================================
-- Row Level Security
-- =============================================================
alter table public.profiles enable row level security;
alter table public.sections enable row level security;
alter table public.allergens enable row level security;
alter table public.additives enable row level security;
alter table public.dishes enable row level security;
alter table public.menu_entries enable row level security;
alter table public.settings enable row level security;
-- Profiles: eigenes Profil lesen, Admin liest alle.
-- Anlegen/Ändern/Löschen von Benutzern läuft serverseitig über den
-- Service-Role-Key (umgeht RLS) und wird dort auf Admins beschränkt.
create policy "profiles_select_own_or_admin"
on public.profiles for select
to authenticated
using (id = (select auth.uid()) or public.app_role() = 'admin');
-- Öffentlich lesbare Tabellen (Speisekarte ohne Login)
create policy "sections_public_read" on public.sections for select to anon, authenticated using (true);
create policy "allergens_public_read" on public.allergens for select to anon, authenticated using (true);
create policy "additives_public_read" on public.additives for select to anon, authenticated using (true);
create policy "dishes_public_read" on public.dishes for select to anon, authenticated using (true);
create policy "menu_entries_public_read" on public.menu_entries for select to anon, authenticated using (true);
create policy "settings_public_read" on public.settings for select to anon, authenticated using (true);
-- Kantinenmitarbeiter (staff) und Admin: Gerichte & Zuweisungen pflegen
create policy "dishes_staff_write" on public.dishes
for all to authenticated
using (public.app_role() in ('staff', 'admin'))
with check (public.app_role() in ('staff', 'admin'));
create policy "menu_entries_staff_write" on public.menu_entries
for all to authenticated
using (public.app_role() in ('staff', 'admin'))
with check (public.app_role() in ('staff', 'admin'));
create policy "allergens_staff_write" on public.allergens
for all to authenticated
using (public.app_role() in ('staff', 'admin'))
with check (public.app_role() in ('staff', 'admin'));
create policy "additives_staff_write" on public.additives
for all to authenticated
using (public.app_role() in ('staff', 'admin'))
with check (public.app_role() in ('staff', 'admin'));
-- Einstellungen: nur Admin ändert
create policy "settings_admin_update" on public.settings
for update to authenticated
using (public.app_role() = 'admin')
with check (public.app_role() = 'admin');
-- =============================================================
-- Storage: Bucket "branding" für das Logo (öffentlich lesbar)
-- =============================================================
insert into storage.buckets (id, name, public)
values ('branding', 'branding', true);
create policy "branding_public_read" on storage.objects
for select to anon, authenticated
using (bucket_id = 'branding');
create policy "branding_admin_insert" on storage.objects
for insert to authenticated
with check (bucket_id = 'branding' and public.app_role() = 'admin');
create policy "branding_admin_update" on storage.objects
for update to authenticated
using (bucket_id = 'branding' and public.app_role() = 'admin');
create policy "branding_admin_delete" on storage.objects
for delete to authenticated
using (bucket_id = 'branding' and public.app_role() = 'admin');
-114
View File
@@ -1,114 +0,0 @@
-- =============================================================
-- Seed-Daten: Allergene, Zusatzstoffe, Testgerichte, Beispielwochen
-- =============================================================
-- ---------- Allergene (EU-Kennzeichnung, Buchstaben) ----------
insert into public.allergens (code, name, sort) values
('A', 'Glutenhaltiges Getreide', 1),
('B', 'Krebstiere', 2),
('C', 'Eier', 3),
('D', 'Fisch', 4),
('E', 'Erdnüsse', 5),
('F', 'Sojabohnen', 6),
('G', 'Milch (einschließlich Laktose)', 7),
('H', 'Schalenfrüchte (Nüsse)', 8),
('I', 'Sellerie', 9),
('J', 'Senf', 10),
('K', 'Sesamsamen', 11),
('L', 'Schwefeldioxid und Sulfite', 12),
('M', 'Lupinen', 13),
('N', 'Weichtiere', 14);
-- ---------- Zusatzstoffe (Ziffern) ----------
insert into public.additives (code, name, sort) values
('1', 'mit Farbstoff', 1),
('2', 'mit Konservierungsstoff', 2),
('3', 'mit Antioxidationsmittel', 3),
('4', 'mit Geschmacksverstärker', 4),
('5', 'geschwefelt', 5),
('6', 'geschwärzt', 6),
('7', 'gewachst', 7),
('8', 'mit Phosphat', 8),
('9', 'mit Süßungsmittel(n)', 9),
('10', 'enthält eine Phenylalaninquelle', 10),
('11', 'koffeinhaltig', 11);
-- ---------- Testgerichte ----------
insert into public.dishes (name, price) values
-- Brotzeit
('1 Stk. Rindskäsewurst mit Brötchen', 2.30),
('Hackfleischbaguette', 3.20),
('Knusperhähnchen Wrap', 3.70),
('Leberkäse im Brötchen', 2.30),
('Chili con Carne mit Brötchen', 3.90),
-- Mittagessen
('Schnitzel Baguette', 3.50),
('Penne mit Grünen Spargel und Garnelenspieße', 5.90),
('Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip', 5.90),
('Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle', 5.90),
-- Mittagessen vegetarisch
('Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten', 5.30),
('Spaghetti mit fruchtiger Tomatensauce und Parmesan', 5.30),
('Gebackener Feta mit griechischen Bauernsalat und Baguette', 5.30),
('Gemüse-Curry mit Basmatireis', 4.90),
-- Suppen
('Kartoffelcremesuppe', 1.50),
('Gulaschsuppe', 2.50),
('Tomatensuppe mit Croutons', 1.80),
('Kürbiscremesuppe', 1.80),
('Linseneintopf mit Würstchen', 2.20);
-- ---------- Beispielwoche 13.–17. Juli 2026 (wie Screenshot) ----------
with d as (select id, name from public.dishes)
insert into public.menu_entries (menu_date, section_key, dish_id)
select x.menu_date::date, x.section_key, d.id
from (values
-- Brotzeit
('2026-07-13', 'brotzeit', '1 Stk. Rindskäsewurst mit Brötchen'),
('2026-07-14', 'brotzeit', 'Hackfleischbaguette'),
('2026-07-15', 'brotzeit', 'Knusperhähnchen Wrap'),
('2026-07-16', 'brotzeit', 'Leberkäse im Brötchen'),
('2026-07-17', 'brotzeit', 'Chili con Carne mit Brötchen'),
-- Mittagessen
('2026-07-13', 'mittagessen', 'Schnitzel Baguette'),
('2026-07-14', 'mittagessen', 'Penne mit Grünen Spargel und Garnelenspieße'),
('2026-07-15', 'mittagessen', 'Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip'),
('2026-07-16', 'mittagessen', 'Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle'),
-- Mittagessen vegetarisch
('2026-07-14', 'mittagessen_veg', 'Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten'),
('2026-07-15', 'mittagessen_veg', 'Spaghetti mit fruchtiger Tomatensauce und Parmesan'),
('2026-07-16', 'mittagessen_veg', 'Gebackener Feta mit griechischen Bauernsalat und Baguette'),
-- Suppe
('2026-07-13', 'suppe', 'Kartoffelcremesuppe'),
('2026-07-14', 'suppe', 'Gulaschsuppe'),
('2026-07-15', 'suppe', 'Tomatensuppe mit Croutons'),
('2026-07-16', 'suppe', 'Kürbiscremesuppe'),
('2026-07-17', 'suppe', 'Linseneintopf mit Würstchen')
) as x (menu_date, section_key, name)
join d on d.name = x.name;
-- ---------- Beispielwoche 20.–24. Juli 2026 ("Nächste Woche") ----------
with d as (select id, name from public.dishes)
insert into public.menu_entries (menu_date, section_key, dish_id)
select x.menu_date::date, x.section_key, d.id
from (values
('2026-07-20', 'brotzeit', 'Leberkäse im Brötchen'),
('2026-07-21', 'brotzeit', 'Knusperhähnchen Wrap'),
('2026-07-22', 'brotzeit', '1 Stk. Rindskäsewurst mit Brötchen'),
('2026-07-23', 'brotzeit', 'Hackfleischbaguette'),
('2026-07-24', 'brotzeit', 'Chili con Carne mit Brötchen'),
('2026-07-20', 'mittagessen', 'Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip'),
('2026-07-21', 'mittagessen', 'Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle'),
('2026-07-22', 'mittagessen', 'Schnitzel Baguette'),
('2026-07-23', 'mittagessen', 'Penne mit Grünen Spargel und Garnelenspieße'),
('2026-07-20', 'mittagessen_veg', 'Gemüse-Curry mit Basmatireis'),
('2026-07-21', 'mittagessen_veg', 'Spaghetti mit fruchtiger Tomatensauce und Parmesan'),
('2026-07-22', 'mittagessen_veg', 'Gebackener Feta mit griechischen Bauernsalat und Baguette'),
('2026-07-23', 'mittagessen_veg', 'Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten'),
('2026-07-20', 'suppe', 'Gulaschsuppe'),
('2026-07-21', 'suppe', 'Kartoffelcremesuppe'),
('2026-07-22', 'suppe', 'Linseneintopf mit Würstchen'),
('2026-07-23', 'suppe', 'Tomatensuppe mit Croutons'),
('2026-07-24', 'suppe', 'Kürbiscremesuppe')
) as x (menu_date, section_key, name)
join d on d.name = x.name;
@@ -1,36 +0,0 @@
-- =============================================================
-- Selbstverwaltung des eigenen Kontos (Name, E-Mail-Abgleich)
-- =============================================================
-- Jeder Benutzer darf seinen eigenen Anzeigenamen ändern. Die Spalte
-- "role" bleibt geschützt: Rechte werden nicht per RLS eingeschränkt,
-- sondern per Spalten-Grant – selbst ein manuell gebauter Request mit
-- {"role": "admin"} im Payload scheitert an fehlenden Rechten auf die
-- Spalte. Rollenänderungen laufen weiterhin ausschließlich über die
-- Admin-API mit dem Service-Role-Key.
revoke update on public.profiles from authenticated;
grant update (full_name) on public.profiles to authenticated;
create policy "profiles_update_own_name"
on public.profiles for update
to authenticated
using (id = (select auth.uid()))
with check (id = (select auth.uid()));
-- Hält profiles.email synchron, sobald eine E-Mail-Änderung in
-- auth.users bestätigt wurde (supabase.auth.updateUser({ email })).
create or replace function public.handle_user_email_change()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
update public.profiles set email = new.email where id = new.id;
return new;
end;
$$;
create trigger on_auth_user_email_updated
after update of email on auth.users
for each row execute function public.handle_user_email_change();
@@ -1,32 +0,0 @@
-- =============================================================
-- Allergene/Zusatzstoffe pro Gericht, Passwort-Login abschaltbar,
-- austauschbares Favicon
-- =============================================================
-- ---------- Allergene/Zusatzstoffe je Gericht ----------
-- Referenziert Kürzel aus den globalen Listen (allergens/additives),
-- die weiterhin unterhalb der Speisekarte als Legende dienen.
alter table public.dishes
add column allergens text[] not null default '{}',
add column additives text[] not null default '{}';
-- ---------- Passwort-Login pro Benutzer abschaltbar ----------
alter table public.profiles
add column password_auth_enabled boolean not null default true;
-- Selbstverwaltung: Nutzer dürfen zusätzlich zum Namen (Migration 0003)
-- jetzt auch dieses eine Flag für ihr eigenes Konto setzen. Weiterhin
-- kein Zugriff auf "role" oder "email" über diesen Weg.
grant update (password_auth_enabled) on public.profiles to authenticated;
-- Klarerer Name für die inzwischen zwei selbstverwaltbaren Spalten.
drop policy "profiles_update_own_name" on public.profiles;
create policy "profiles_update_own"
on public.profiles for update
to authenticated
using (id = (select auth.uid()))
with check (id = (select auth.uid()));
-- ---------- Austauschbares Favicon ----------
alter table public.settings
add column favicon_path text;
@@ -1,68 +0,0 @@
-- =============================================================
-- Mehrsprachigkeit (Inhalte) + Rezeptvorschläge
-- =============================================================
-- ---------- Übersetzungsspalten ----------
-- Nullable: ohne Übersetzung fällt die Anzeige auf den deutschen
-- Text zurück. Werden manuell oder per DeepL-Anbindung befüllt.
alter table public.dishes add column name_en text, add column name_pl text;
alter table public.sections add column label_en text, add column label_pl text;
alter table public.allergens add column name_en text, add column name_pl text;
alter table public.additives add column name_en text, add column name_pl text;
-- ---------- Referenz-Übersetzungen (feste EU-Kennzeichnung) ----------
update public.sections set label_en = 'Breakfast', label_pl = 'Śniadanie' where key = 'brotzeit';
update public.sections set label_en = 'Lunch', label_pl = 'Obiad' where key = 'mittagessen';
update public.sections set label_en = 'Lunch (vegetarian)', label_pl = 'Obiad (wegetariański)' where key = 'mittagessen_veg';
update public.sections set label_en = 'Soup', label_pl = 'Zupa' where key = 'suppe';
update public.allergens set name_en = 'Cereals containing gluten', name_pl = 'Zboża zawierające gluten' where code = 'A';
update public.allergens set name_en = 'Crustaceans', name_pl = 'Skorupiaki' where code = 'B';
update public.allergens set name_en = 'Eggs', name_pl = 'Jaja' where code = 'C';
update public.allergens set name_en = 'Fish', name_pl = 'Ryby' where code = 'D';
update public.allergens set name_en = 'Peanuts', name_pl = 'Orzeszki ziemne' where code = 'E';
update public.allergens set name_en = 'Soybeans', name_pl = 'Soja' where code = 'F';
update public.allergens set name_en = 'Milk (including lactose)', name_pl = 'Mleko (w tym laktoza)' where code = 'G';
update public.allergens set name_en = 'Tree nuts', name_pl = 'Orzechy' where code = 'H';
update public.allergens set name_en = 'Celery', name_pl = 'Seler' where code = 'I';
update public.allergens set name_en = 'Mustard', name_pl = 'Gorczyca' where code = 'J';
update public.allergens set name_en = 'Sesame seeds', name_pl = 'Nasiona sezamu' where code = 'K';
update public.allergens set name_en = 'Sulphur dioxide and sulphites', name_pl = 'Dwutlenek siarki i siarczyny' where code = 'L';
update public.allergens set name_en = 'Lupin', name_pl = 'Łubin' where code = 'M';
update public.allergens set name_en = 'Molluscs', name_pl = 'Mięczaki' where code = 'N';
update public.additives set name_en = 'with colouring', name_pl = 'z barwnikiem' where code = '1';
update public.additives set name_en = 'with preservative', name_pl = 'z konserwantem' where code = '2';
update public.additives set name_en = 'with antioxidant', name_pl = 'z przeciwutleniaczem' where code = '3';
update public.additives set name_en = 'with flavour enhancer', name_pl = 'ze wzmacniaczem smaku' where code = '4';
update public.additives set name_en = 'sulphured', name_pl = 'siarkowany' where code = '5';
update public.additives set name_en = 'blackened', name_pl = 'barwiony na czarno' where code = '6';
update public.additives set name_en = 'waxed', name_pl = 'woskowany' where code = '7';
update public.additives set name_en = 'with phosphate', name_pl = 'z fosforanem' where code = '8';
update public.additives set name_en = 'with sweetener(s)', name_pl = 'ze słodzikiem/słodzikami' where code = '9';
update public.additives set name_en = 'contains a source of phenylalanine', name_pl = 'zawiera źródło fenyloalaniny' where code = '10';
update public.additives set name_en = 'caffeinated', name_pl = 'zawiera kofeinę' where code = '11';
-- ---------- Rezeptvorschläge ----------
create table public.dish_suggestions (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
created_at timestamptz not null default now()
);
alter table public.dish_suggestions enable row level security;
-- Jeder darf einen Vorschlag einreichen, aber niemand außer dem
-- Kantinen-Team darf die Liste einsehen (kein Lesezugriff für anon).
create policy "dish_suggestions_public_insert" on public.dish_suggestions
for insert to anon, authenticated
with check (true);
create policy "dish_suggestions_staff_select" on public.dish_suggestions
for select to authenticated
using (public.app_role() in ('staff', 'admin'));
create policy "dish_suggestions_staff_delete" on public.dish_suggestions
for delete to authenticated
using (public.app_role() in ('staff', 'admin'));
@@ -1,25 +0,0 @@
-- =============================================================
-- Essenszeiten (Brotzeit/Mittagessen/…) vollständig verwaltbar machen:
-- anlegen, umbenennen, Uhrzeit ändern, löschen.
-- =============================================================
-- Neue Essenszeiten bekommen einen automatisch generierten Schlüssel
-- (wie bei dishes.id) statt eines von Hand vergebenen Slugs.
alter table public.sections
alter column key set default (gen_random_uuid())::text;
-- Löscht man eine Essenszeit, werden ihre Wochenplan-Einträge mit
-- entfernt (analog zu menu_entries_dish_id_fkey bei Gerichten) statt
-- die Löschung zu blockieren.
alter table public.menu_entries
drop constraint menu_entries_section_key_fkey;
alter table public.menu_entries
add constraint menu_entries_section_key_fkey
foreign key (section_key) references public.sections(key) on delete cascade;
-- Bisher gab es nur eine Lesepolicy für sections; jetzt dürfen
-- Kantinenmitarbeiter/Admins Essenszeiten auch anlegen/ändern/löschen.
create policy "sections_staff_write" on public.sections
for all to authenticated
using (public.app_role() in ('staff', 'admin'))
with check (public.app_role() in ('staff', 'admin'));