diff --git a/scripts/db-apply.mjs b/scripts/db-apply.mjs new file mode 100644 index 0000000..1516b16 --- /dev/null +++ b/scripts/db-apply.mjs @@ -0,0 +1,32 @@ +// Führt eine SQL-Datei gegen das Supabase-Projekt aus (Management API). +// Aufruf: SUPABASE_ACCESS_TOKEN=... node scripts/db-apply.mjs +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 "); + 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)); diff --git a/supabase/migrations/0001_init.sql b/supabase/migrations/0001_init.sql new file mode 100644 index 0000000..3201bd3 --- /dev/null +++ b/supabase/migrations/0001_init.sql @@ -0,0 +1,217 @@ +-- ============================================================= +-- 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(); + +-- ---------- Bereiche 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/Bereich ---------- +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'); diff --git a/supabase/migrations/0002_seed.sql b/supabase/migrations/0002_seed.sql new file mode 100644 index 0000000..43e03aa --- /dev/null +++ b/supabase/migrations/0002_seed.sql @@ -0,0 +1,114 @@ +-- ============================================================= +-- 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;