diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 19b61ea..47bc290 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; +import { getSession } from "@/lib/auth/session"; import AppHeader from "@/components/AppHeader"; export default async function AdminLayout({ @@ -7,24 +7,15 @@ export default async function AdminLayout({ }: { children: React.ReactNode; }) { - const supabase = await createClient(); - const { - 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?.role !== "admin") redirect("/intern"); + const session = await getSession(); + if (!session) redirect("/login"); + if (session.user.role !== "admin") redirect("/intern"); return (
{children} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 3f65034..c1052ba 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,5 +1,7 @@ +import { getSession } from "@/lib/auth/session"; import UserManager from "@/components/UserManager"; -export default function AdminPage() { - return ; +export default async function AdminPage() { + const session = await getSession(); + return ; } diff --git a/app/api/additives/[code]/route.ts b/app/api/additives/[code]/route.ts new file mode 100644 index 0000000..7b49ac9 --- /dev/null +++ b/app/api/additives/[code]/route.ts @@ -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; + } +} diff --git a/app/api/additives/route.ts b/app/api/additives/route.ts new file mode 100644 index 0000000..80d5f25 --- /dev/null +++ b/app/api/additives/route.ts @@ -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; + } +} diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts new file mode 100644 index 0000000..ed7afb0 --- /dev/null +++ b/app/api/admin/branding/route.ts @@ -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 = { + 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; + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index dbd820f..56064db 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -1,33 +1,48 @@ import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; -import { createAdminClient } from "@/lib/supabase/admin"; +import { query, queryOne } from "@/lib/db"; +import { hashPassword } from "@/lib/auth/password"; +import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; +import type { Profile } from "@/lib/types"; -// Benutzerverwaltung (nur Admin). Läuft serverseitig mit dem -// Service-Role-Key, da das Anlegen/Löschen von Auth-Benutzern -// nicht über den anon key möglich ist. +// Benutzerverwaltung (nur Admin). async function requireAdmin(request: Request) { - if (!isSameOrigin(request)) return null; - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return null; + if (!isSameOrigin(request)) throw new ForbiddenError(); + return requireRole(["admin"]); +} - const admin = createAdminClient(); - const { data: profile } = await admin - .from("profiles") - .select("role") - .eq("id", user.id) - .single(); - if (profile?.role !== "admin") return null; - return { user, admin }; +function errorResponse(err: unknown) { + if (err instanceof UnauthorizedError || err instanceof ForbiddenError) { + return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + } + return null; +} + +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( + "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) { - const ctx = await requireAdmin(request); - if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + let session; + 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 { 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({ - email, - password, - email_confirm: true, - user_metadata: { full_name: 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 } + try { + const user = await queryOne<{ id: string }>( + `insert into users (email, password_hash, full_name, role) + values ($1, $2, $3, $4) returning id`, + [String(email).trim().toLowerCase(), hashPassword(password), full_name ?? "", role] ); + 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) { - const ctx = await requireAdmin(request); - if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + let session; + 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 { 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, // 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( { error: "Du kannst dir nicht selbst die Admin-Rolle entziehen." }, { status: 400 } @@ -85,35 +105,49 @@ export async function PATCH(request: Request) { { status: 400 } ); } - const { error } = await ctx.admin.auth.admin.updateUserById(id, { password }); - if (error) return NextResponse.json({ error: error.message }, { status: 400 }); + await query("update users set password_hash = $1 where id = $2", [ + hashPassword(password), + id, + ]); } - const updates: Record = {}; - if (typeof full_name === "string") updates.full_name = full_name; - if (role) updates.role = role; - if (Object.keys(updates).length > 0) { - const { error } = await ctx.admin.from("profiles").update(updates).eq("id", id); - if (error) return NextResponse.json({ error: error.message }, { status: 400 }); + const fields: string[] = []; + const values: unknown[] = []; + if (typeof full_name === "string") { + values.push(full_name); + fields.push(`full_name = $${values.length}`); + } + 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 }); } export async function DELETE(request: Request) { - const ctx = await requireAdmin(request); - if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + let session; + 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 { id } = body ?? {}; if (!id) return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 }); - if (id === ctx.user.id) { + if (id === session.user.id) { return NextResponse.json( { error: "Du kannst dich nicht selbst löschen." }, { status: 400 } ); } - const { error } = await ctx.admin.auth.admin.deleteUser(id); - if (error) return NextResponse.json({ error: error.message }, { status: 400 }); + await query("delete from users where id = $1", [id]); return NextResponse.json({ ok: true }); } diff --git a/app/api/allergens/[code]/route.ts b/app/api/allergens/[code]/route.ts new file mode 100644 index 0000000..96fd547 --- /dev/null +++ b/app/api/allergens/[code]/route.ts @@ -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; + } +} diff --git a/app/api/allergens/route.ts b/app/api/allergens/route.ts new file mode 100644 index 0000000..0b563d5 --- /dev/null +++ b/app/api/allergens/route.ts @@ -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; + } +} diff --git a/app/api/dish-suggestions/[id]/accept/route.ts b/app/api/dish-suggestions/[id]/accept/route.ts new file mode 100644 index 0000000..e0e250c --- /dev/null +++ b/app/api/dish-suggestions/[id]/accept/route.ts @@ -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; + } +} diff --git a/app/api/dish-suggestions/[id]/route.ts b/app/api/dish-suggestions/[id]/route.ts new file mode 100644 index 0000000..b81aaf6 --- /dev/null +++ b/app/api/dish-suggestions/[id]/route.ts @@ -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; + } +} diff --git a/app/api/dish-suggestions/route.ts b/app/api/dish-suggestions/route.ts new file mode 100644 index 0000000..344617a --- /dev/null +++ b/app/api/dish-suggestions/route.ts @@ -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( + "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 }); +} diff --git a/app/api/dishes/[id]/route.ts b/app/api/dishes/[id]/route.ts new file mode 100644 index 0000000..1a6f59d --- /dev/null +++ b/app/api/dishes/[id]/route.ts @@ -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( + `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; + } +} diff --git a/app/api/dishes/bulk-delete/route.ts b/app/api/dishes/bulk-delete/route.ts new file mode 100644 index 0000000..c794a25 --- /dev/null +++ b/app/api/dishes/bulk-delete/route.ts @@ -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; + } +} diff --git a/app/api/dishes/route.ts b/app/api/dishes/route.ts new file mode 100644 index 0000000..caf0c7c --- /dev/null +++ b/app/api/dishes/route.ts @@ -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("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( + `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; + } +} diff --git a/app/api/menu-entries/[id]/route.ts b/app/api/menu-entries/[id]/route.ts new file mode 100644 index 0000000..7ff2d4e --- /dev/null +++ b/app/api/menu-entries/[id]/route.ts @@ -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; + } +} diff --git a/app/api/menu-entries/route.ts b/app/api/menu-entries/route.ts new file mode 100644 index 0000000..db1c34f --- /dev/null +++ b/app/api/menu-entries/route.ts @@ -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; + } +} diff --git a/app/api/sections/[key]/route.ts b/app/api/sections/[key]/route.ts new file mode 100644 index 0000000..d0e0ab2 --- /dev/null +++ b/app/api/sections/[key]/route.ts @@ -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 + >; + + 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
( + `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; + } +} diff --git a/app/api/sections/route.ts b/app/api/sections/route.ts new file mode 100644 index 0000000..87763c4 --- /dev/null +++ b/app/api/sections/route.ts @@ -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
("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
( + `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; + } +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..c3ddb9b --- /dev/null +++ b/app/api/settings/route.ts @@ -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 }; + 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; + } +} diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts index f0c916c..455c53d 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; -import { createAdminClient } from "@/lib/supabase/admin"; +import { query } from "@/lib/db"; +import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; // Übersetzt einen deutschen Text nach Englisch/Polnisch via DeepL und @@ -43,20 +43,16 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); } - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); - - const admin = createAdminClient(); - const { data: profile } = await admin - .from("profiles") - .select("role") - .eq("id", user.id) - .single(); - if (!profile || !["staff", "admin"].includes(profile.role)) { - return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + try { + await requireRole(["staff", "admin"]); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + if (err instanceof ForbiddenError) { + return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + } + throw err; } const body = await request.json().catch(() => null); @@ -83,11 +79,14 @@ export async function POST(request: Request) { if (!en || !pl) throw new Error("Keine Übersetzung erhalten."); if (persist) { - const { error } = await admin - .from(table) - .update({ [`${config.textColumn}_en`]: en, [`${config.textColumn}_pl`]: pl }) - .eq(config.idColumn, id); - if (error) throw new Error(error.message); + // table/idColumn/textColumn kommen ausschließlich aus dem oben + // geprüften ALLOWED_TABLES-Whitelist, nie aus Nutzereingaben – + // sicher für die String-Interpolation in der Query. + await query( + `update ${table} set ${config.textColumn}_en = $1, ${config.textColumn}_pl = $2 + where ${config.idColumn} = $3`, + [en, pl, id] + ); } return NextResponse.json({ configured: true, en, pl }); diff --git a/app/branding/[filename]/route.ts b/app/branding/[filename]/route.ts new file mode 100644 index 0000000..9b0ca80 --- /dev/null +++ b/app/branding/[filename]/route.ts @@ -0,0 +1,32 @@ +import { readBrandingFile } from "@/lib/branding"; + +const CONTENT_TYPES: Record = { + 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", + }, + }); +} diff --git a/app/globals.css b/app/globals.css index f378cda..1abad04 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,7 +1,7 @@ @import "tailwindcss"; /* Farb-Variablen: Defaults entsprechen r-hofmann.de; werden zur Laufzeit - aus den Einstellungen (Supabase settings.colors) überschrieben. */ + aus den Einstellungen (settings.colors) überschrieben. */ :root { --c-primary: #003388; --c-primary-dark: #0d315c; diff --git a/app/intern/layout.tsx b/app/intern/layout.tsx index 79bfa9d..718028f 100644 --- a/app/intern/layout.tsx +++ b/app/intern/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; +import { getSession } from "@/lib/auth/session"; import AppHeader from "@/components/AppHeader"; export default async function InternLayout({ @@ -7,24 +7,14 @@ export default async function InternLayout({ }: { children: React.ReactNode; }) { - const supabase = await createClient(); - const { - 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("/"); + const session = await getSession(); + if (!session) redirect("/login"); return (
{children} diff --git a/app/konto/page.tsx b/app/konto/page.tsx index 75fb402..1420eba 100644 --- a/app/konto/page.tsx +++ b/app/konto/page.tsx @@ -1,34 +1,24 @@ import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; +import { getSession } from "@/lib/auth/session"; import AppHeader from "@/components/AppHeader"; import AccountManager from "@/components/AccountManager"; export default async function KontoPage() { - const supabase = await createClient(); - const { - 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("/"); + const session = await getSession(); + if (!session) redirect("/login"); return (
diff --git a/app/layout.tsx b/app/layout.tsx index 868f023..171e108 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Geist } from "next/font/google"; 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 { getDictionary, getLocale } from "@/lib/i18n"; import { LocaleProvider } from "@/components/i18n/LocaleProvider"; @@ -16,12 +16,7 @@ const geistSans = Geist({ }); export async function generateMetadata(): Promise { - const supabase = await createClient(); - const { data: settings } = await supabase - .from("settings") - .select("favicon_path") - .eq("id", 1) - .maybeSingle(); + const settings = await getSettings(); return { title: SITE_TITLE, @@ -38,16 +33,12 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const supabase = await createClient(); - const [{ data: settings }, locale] = await Promise.all([ - supabase.from("settings").select("colors").eq("id", 1).maybeSingle(), - getLocale(), - ]); + const [settings, locale] = await Promise.all([getSettings(), getLocale()]); const dict = getDictionary(locale); const colors: Record = { ...DEFAULT_COLORS, - ...((settings?.colors as Record) ?? {}), + ...(settings?.colors ?? {}), }; const style = Object.fromEntries( Object.entries(COLOR_VARS).map(([key, cssVar]) => [cssVar, colors[key]]) diff --git a/app/page.tsx b/app/page.tsx index b326795..1948bc4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,7 @@ 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 { addDays, dishCodes, @@ -11,7 +13,7 @@ import { } from "@/lib/menu"; import { getDictionary, getLocale } from "@/lib/i18n"; 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 LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; @@ -31,29 +33,15 @@ export default async function Home({ iso: addDays(monday, i), })); - const supabase = await createClient(); - const [sectionsRes, entriesRes, allergensRes, additivesRes, settingsRes] = - await Promise.all([ - supabase.from("sections").select("*").order("sort"), - supabase - .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"), - supabase.from("allergens").select("*").order("sort"), - supabase.from("additives").select("*").order("sort"), - supabase.from("settings").select("logo_path").eq("id", 1).maybeSingle(), - ]); + const [sections, entries, allergens, additives, settings] = await Promise.all([ + query
("select * from sections order by sort"), + loadMenuEntries(monday, friday), + query("select * from allergens order by sort"), + query("select * from additives order by sort"), + getSettings(), + ]); - const sections = (sectionsRes.data ?? []) as Section[]; - 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); + const logo = logoUrl(settings?.logo_path); return (
diff --git a/components/CodeListManager.tsx b/components/CodeListManager.tsx index 1a677dd..0529ad4 100644 --- a/components/CodeListManager.tsx +++ b/components/CodeListManager.tsx @@ -3,8 +3,7 @@ // Pflege der globalen Allergen-/Zusatzstofflisten, die unterhalb // der öffentlichen Speisekarte angezeigt werden. -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { useCallback, useEffect, useState } from "react"; import { formatMessage } from "@/lib/i18n/shared"; import { localized } from "@/lib/types"; import type { CodeItem } from "@/lib/types"; @@ -17,7 +16,6 @@ export default function CodeListManager({ table: "allergens" | "additives"; translateEnabled: boolean; }) { - const supabase = useMemo(() => createClient(), []); const dict = useDict(); const locale = useLocale(); const title = @@ -41,10 +39,10 @@ export default function CodeListManager({ ); const load = useCallback(async () => { - const { data, error } = await supabase.from(table).select("*").order("sort"); - if (error) setError(formatMessage(dict.codeList.loadError, { message: error.message })); - else setItems(data as CodeItem[]); - }, [supabase, table, dict]); + const res = await fetch(`/api/${table}`); + if (!res.ok) setError(formatMessage(dict.codeList.loadError, { message: String(res.status) })); + else setItems(await res.json()); + }, [table, dict]); useEffect(() => { load(); @@ -64,23 +62,23 @@ export default function CodeListManager({ name_pl: editing.namePl.trim() || null, }; - let dbError; - if (editing.original) { - ({ error: dbError } = await supabase - .from(table) - .update(payload) - .eq("code", editing.original)); - } else { - const maxSort = items.reduce((m, i) => Math.max(m, i.sort), 0); - ({ error: dbError } = await supabase - .from(table) - .insert({ ...payload, sort: maxSort + 1 })); - } - if (dbError) { + const res = editing.original + ? await fetch(`/api/${table}/${editing.original}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + : await fetch(`/api/${table}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); setError( - /duplicate key/i.test(dbError.message) + data.error === "duplicate_code" ? formatMessage(dict.codeList.duplicateCode, { code }) - : formatMessage(dict.codeList.saveError, { message: dbError.message }) + : formatMessage(dict.codeList.saveError, { message: data.error ?? String(res.status) }) ); return; } @@ -95,9 +93,9 @@ export default function CodeListManager({ ) ) return; - const { error } = await supabase.from(table).delete().eq("code", item.code); - if (error) - setError(formatMessage(dict.codeList.deleteError, { message: error.message })); + const res = await fetch(`/api/${table}/${item.code}`, { method: "DELETE" }); + if (!res.ok) + setError(formatMessage(dict.codeList.deleteError, { message: String(res.status) })); await load(); } diff --git a/components/DesignEditor.tsx b/components/DesignEditor.tsx index 11b83c5..8c71063 100644 --- a/components/DesignEditor.tsx +++ b/components/DesignEditor.tsx @@ -1,21 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { createClient } from "@/lib/supabase/client"; import { formatMessage } from "@/lib/i18n/shared"; import { DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types"; import { useDict } from "@/components/i18n/LocaleProvider"; type AssetKind = "logo" | "favicon"; -const ASSET_COLUMN: Record = { - logo: "logo_path", - favicon: "favicon_path", -}; - export default function DesignEditor() { - const supabase = useMemo(() => createClient(), []); const router = useRouter(); const dict = useDict(); const colorLabels: Record = { @@ -36,19 +29,16 @@ export default function DesignEditor() { const [busy, setBusy] = useState(false); const load = useCallback(async () => { - const { data, error } = await supabase - .from("settings") - .select("colors, logo_path, favicon_path") - .eq("id", 1) - .maybeSingle(); - if (error) { - setError(formatMessage(dict.designEditor.loadError, { message: error.message })); + const res = await fetch("/api/settings"); + if (!res.ok) { + setError(formatMessage(dict.designEditor.loadError, { message: String(res.status) })); return; } + const data = await res.json(); setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) }); setLogoPath(data?.logo_path ?? null); setFaviconPath(data?.favicon_path ?? null); - }, [supabase, dict]); + }, [dict]); useEffect(() => { load(); @@ -58,12 +48,13 @@ export default function DesignEditor() { setBusy(true); setError(null); setInfo(null); - const { error } = await supabase - .from("settings") - .update({ colors }) - .eq("id", 1); - if (error) - setError(formatMessage(dict.designEditor.saveError, { message: error.message })); + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ colors }), + }); + if (!res.ok) + setError(formatMessage(dict.designEditor.saveError, { message: String(res.status) })); else { setInfo(dict.designEditor.colorsSaved); router.refresh(); @@ -87,28 +78,21 @@ export default function DesignEditor() { setError(null); setInfo(null); - const oldPath = kind === "logo" ? logoPath : faviconPath; - const ext = file.name.split(".").pop()?.toLowerCase() ?? "png"; - const path = `${kind}-${Date.now()}.${ext}`; - const { error: uploadError } = await supabase.storage - .from("branding") - .upload(path, file, { contentType: file.type }); - if (uploadError) { - setError(formatMessage(dict.designEditor.uploadError, { message: uploadError.message })); + const formData = new FormData(); + formData.append("kind", kind); + formData.append("file", file); + const res = await fetch("/api/admin/branding", { method: "POST", body: formData }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError( + formatMessage(dict.designEditor.uploadError, { + message: data.error ?? String(res.status), + }) + ); setBusy(false); return; } - - 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]); + const { path } = await res.json(); if (kind === "logo") setLogoPath(path); else setFaviconPath(path); @@ -130,14 +114,14 @@ export default function DesignEditor() { return; setBusy(true); setError(null); - const { error } = await supabase - .from("settings") - .update({ [ASSET_COLUMN[kind]]: null }) - .eq("id", 1); - if (error) { - setError(formatMessage(dict.designEditor.saveError, { message: error.message })); + const res = await fetch("/api/admin/branding", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind }), + }); + if (!res.ok) { + setError(formatMessage(dict.designEditor.saveError, { message: String(res.status) })); } else { - await supabase.storage.from("branding").remove([path]); if (kind === "logo") setLogoPath(null); else setFaviconPath(null); setInfo(kind === "logo" ? dict.designEditor.logoRemoved : dict.designEditor.faviconRemoved); diff --git a/components/DishManager.tsx b/components/DishManager.tsx index cd69b7f..820297a 100644 --- a/components/DishManager.tsx +++ b/components/DishManager.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; import { dishCodes, formatPrice } from "@/lib/menu"; import { formatMessage } from "@/lib/i18n/shared"; import { localized } from "@/lib/types"; @@ -34,7 +33,6 @@ export default function DishManager({ }: { translateEnabled: boolean; }) { - const supabase = useMemo(() => createClient(), []); const dict = useDict(); const locale = useLocale(); const [dishes, setDishes] = useState([]); @@ -58,20 +56,19 @@ export default function DishManager({ const load = useCallback(async () => { const [dishesRes, allergensRes, additivesRes] = await Promise.all([ - supabase.from("dishes").select("*").order("name"), - supabase.from("allergens").select("*").order("sort"), - supabase.from("additives").select("*").order("sort"), + fetch("/api/dishes"), + fetch("/api/allergens"), + fetch("/api/additives"), ]); - const problem = dishesRes.error ?? allergensRes.error ?? additivesRes.error; - if (problem) - setError(formatMessage(dict.dishManager.loadError, { message: problem.message })); - else { - setDishes(dishesRes.data as Dish[]); - setAllergens(allergensRes.data as CodeItem[]); - setAdditives(additivesRes.data as CodeItem[]); + if (!dishesRes.ok || !allergensRes.ok || !additivesRes.ok) { + setError(formatMessage(dict.dishManager.loadError, { message: "load failed" })); + } else { + setDishes(await dishesRes.json()); + setAllergens(await allergensRes.json()); + setAdditives(await additivesRes.json()); } setLoading(false); - }, [supabase, dict]); + }, [dict]); useEffect(() => { load(); @@ -94,11 +91,19 @@ export default function DishManager({ allergens: form.allergens, additives: form.additives, }; - const { error } = form.id - ? await supabase.from("dishes").update(payload).eq("id", form.id) - : await supabase.from("dishes").insert(payload); - if (error) { - setError(formatMessage(dict.dishManager.saveError, { message: error.message })); + const res = form.id + ? await fetch(`/api/dishes/${form.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + 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; } setForm(null); @@ -112,9 +117,9 @@ export default function DishManager({ ) ) return; - const { error } = await supabase.from("dishes").delete().eq("id", dish.id); - if (error) - setError(formatMessage(dict.dishManager.deleteError, { message: error.message })); + const res = await fetch(`/api/dishes/${dish.id}`, { method: "DELETE" }); + if (!res.ok) + setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) })); setSelected((prev) => { const next = new Set(prev); next.delete(dish.id); @@ -156,12 +161,13 @@ export default function DishManager({ return; setBulkBusy(true); setError(null); - const { error } = await supabase - .from("dishes") - .delete() - .in("id", Array.from(selected)); - if (error) - setError(formatMessage(dict.dishManager.deleteError, { message: error.message })); + const res = await fetch("/api/dishes/bulk-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: Array.from(selected) }), + }); + if (!res.ok) + setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) })); setSelected(new Set()); setBulkBusy(false); await load(); diff --git a/components/SectionManager.tsx b/components/SectionManager.tsx index 2c714e5..6ae91ac 100644 --- a/components/SectionManager.tsx +++ b/components/SectionManager.tsx @@ -3,8 +3,7 @@ // Verwaltung der Essenszeiten (Brotzeit, Mittagessen, …): // anlegen, umbenennen, Uhrzeit ändern, löschen, Reihenfolge anpassen. -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { useCallback, useEffect, useState } from "react"; import { formatMessage } from "@/lib/i18n/shared"; import { localized } 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() { - const supabase = useMemo(() => createClient(), []); const dict = useDict(); const locale = useLocale(); const [sections, setSections] = useState([]); @@ -44,12 +42,12 @@ export default function SectionManager() { const [busy, setBusy] = useState(false); const load = useCallback(async () => { - const { data, error } = await supabase.from("sections").select("*").order("sort"); - if (error) - setError(formatMessage(dict.sectionManager.loadError, { message: error.message })); - else setSections(data as Section[]); + const res = await fetch("/api/sections"); + if (!res.ok) + setError(formatMessage(dict.sectionManager.loadError, { message: String(res.status) })); + else setSections(await res.json()); setLoading(false); - }, [supabase, dict]); + }, [dict]); useEffect(() => { load(); @@ -68,27 +66,19 @@ export default function SectionManager() { time_range: `${form.timeFrom} - ${form.timeTo}`, }; - let dbError; - if (form.key) { - ({ error: dbError } = await supabase - .from("sections") - .update(payload) - .eq("key", form.key)); - } else { - // Frisch aus der DB lesen statt den lokalen State zu nutzen, damit - // ein veralteter Snapshot nicht zu doppelten sort-Werten führt. - const { data: maxRow } = await supabase - .from("sections") - .select("sort") - .order("sort", { ascending: false }) - .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 })); + const res = form.key + ? await fetch(`/api/sections/${form.key}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + : await fetch("/api/sections", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + setError(formatMessage(dict.sectionManager.saveError, { message: String(res.status) })); return; } setForm(null); @@ -102,9 +92,9 @@ export default function SectionManager() { ) ) return; - const { error } = await supabase.from("sections").delete().eq("key", section.key); - if (error) - setError(formatMessage(dict.sectionManager.deleteError, { message: error.message })); + const res = await fetch(`/api/sections/${section.key}`, { method: "DELETE" }); + if (!res.ok) + setError(formatMessage(dict.sectionManager.deleteError, { message: String(res.status) })); await load(); } @@ -115,12 +105,19 @@ export default function SectionManager() { setBusy(true); setError(null); const [r1, r2] = await Promise.all([ - supabase.from("sections").update({ sort: neighbor.sort }).eq("key", section.key), - supabase.from("sections").update({ sort: section.sort }).eq("key", neighbor.key), + fetch(`/api/sections/${section.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 (dbError) - setError(formatMessage(dict.sectionManager.saveError, { message: dbError.message })); + if (!r1.ok || !r2.ok) + setError(formatMessage(dict.sectionManager.saveError, { message: "move failed" })); setBusy(false); await load(); } diff --git a/components/SuggestionForm.tsx b/components/SuggestionForm.tsx index 390f0b3..34783a9 100644 --- a/components/SuggestionForm.tsx +++ b/components/SuggestionForm.tsx @@ -2,13 +2,11 @@ import { useState } from "react"; import Link from "next/link"; -import { createClient } from "@/lib/supabase/client"; import { formatMessage } from "@/lib/i18n/shared"; import { useDict } from "@/components/i18n/LocaleProvider"; import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; export default function SuggestionForm() { - const supabase = createClient(); const dict = useDict(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -34,23 +32,20 @@ export default function SuggestionForm() { } setBusy(true); - const { data: existing } = await supabase - .from("dishes") - .select("id") - .ilike("name", trimmedName) - .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, + const res = await fetch("/api/dish-suggestions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: trimmedName, description: description.trim() || null }), }); - if (insertError) { - setError(formatMessage(dict.suggestions.genericError, { message: insertError.message })); + if (!res.ok) { + 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); return; } diff --git a/components/SuggestionReview.tsx b/components/SuggestionReview.tsx index 17fa909..b036279 100644 --- a/components/SuggestionReview.tsx +++ b/components/SuggestionReview.tsx @@ -1,13 +1,11 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { useCallback, useEffect, useState } from "react"; import { formatMessage } from "@/lib/i18n/shared"; import { useDict } from "@/components/i18n/LocaleProvider"; import type { DishSuggestion } from "@/lib/types"; export default function SuggestionReview() { - const supabase = useMemo(() => createClient(), []); const dict = useDict(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); @@ -16,15 +14,12 @@ export default function SuggestionReview() { const [busyId, setBusyId] = useState(null); const load = useCallback(async () => { - const { data, error } = await supabase - .from("dish_suggestions") - .select("*") - .order("created_at", { ascending: false }); - if (error) - setError(formatMessage(dict.suggestions.loadError, { message: error.message })); - else setItems(data as DishSuggestion[]); + const res = await fetch("/api/dish-suggestions"); + if (!res.ok) + setError(formatMessage(dict.suggestions.loadError, { message: String(res.status) })); + else setItems(await res.json()); setLoading(false); - }, [supabase, dict]); + }, [dict]); useEffect(() => { load(); @@ -34,25 +29,9 @@ export default function SuggestionReview() { setBusyId(item.id); setError(null); setInfo(null); - const { error: insertError } = await supabase.from("dishes").insert({ - name: item.name, - price: 0, - }); - 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 }) - ); + const res = await fetch(`/api/dish-suggestions/${item.id}/accept`, { method: "POST" }); + if (!res.ok) { + setError(formatMessage(dict.suggestions.actionError, { message: String(res.status) })); setBusyId(null); return; } @@ -67,9 +46,9 @@ export default function SuggestionReview() { setBusyId(item.id); setError(null); setInfo(null); - const { error } = await supabase.from("dish_suggestions").delete().eq("id", item.id); - if (error) { - setError(formatMessage(dict.suggestions.actionError, { message: error.message })); + const res = await fetch(`/api/dish-suggestions/${item.id}`, { method: "DELETE" }); + if (!res.ok) { + setError(formatMessage(dict.suggestions.actionError, { message: String(res.status) })); } else { setInfo(dict.suggestions.rejected); } diff --git a/components/UserManager.tsx b/components/UserManager.tsx index deeaf86..aac87b6 100644 --- a/components/UserManager.tsx +++ b/components/UserManager.tsx @@ -1,7 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { useCallback, useEffect, useState } from "react"; import { formatMessage } from "@/lib/i18n/shared"; import type { Profile, Role } from "@/lib/types"; import { useDict } from "@/components/i18n/LocaleProvider"; @@ -24,30 +23,23 @@ const EMPTY_FORM: UserForm = { passwordRepeat: "", }; -export default function UserManager() { - const supabase = useMemo(() => createClient(), []); +export default function UserManager({ meId }: { meId: string }) { const dict = useDict(); const roleLabels: Record = { admin: dict.common.roleAdmin, staff: dict.common.roleStaff, }; const [profiles, setProfiles] = useState([]); - const [me, setMe] = useState(null); const [form, setForm] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const load = useCallback(async () => { - const [{ data: profileData, error: profileError }, { data: userData }] = - await Promise.all([ - supabase.from("profiles").select("*").order("created_at"), - supabase.auth.getUser(), - ]); - if (profileError) - setError(formatMessage(dict.userManager.loadError, { message: profileError.message })); - else setProfiles(profileData as Profile[]); - setMe(userData.user?.id ?? null); - }, [supabase, dict]); + const res = await fetch("/api/admin/users"); + if (!res.ok) + setError(formatMessage(dict.userManager.loadError, { message: String(res.status) })); + else setProfiles(await res.json()); + }, [dict]); useEffect(() => { load(); @@ -235,7 +227,7 @@ export default function UserManager() { – {p.email} – {roleLabels[p.role]} - {p.id === me && {dict.common.you}} + {p.id === meId && {dict.common.you}} - {p.id !== me && ( + {p.id !== meId && (