# InnungsApp — API Design > Supabase stellt die API automatisch via PostgREST bereit. Dieses Dokument beschreibt die wichtigsten Query-Patterns und Edge Functions. --- ## 1. Basis-URL & Auth ``` Base URL: https://.supabase.co REST API: /rest/v1/ Auth API: /auth/v1/ Storage API: /storage/v1/ Edge Functions: /functions/v1/ Headers: apikey: # Öffentliche Requests Authorization: Bearer # Authentifizierte Requests ``` --- ## 2. Auth Endpoints ### Magic Link anfordern ```http POST /auth/v1/otp Content-Type: application/json { "email": "max.muster@muellerszdk.de", "options": { "emailRedirectTo": "innungsapp://auth/verify" } } ``` ### Session mit Token verifizieren (nach Link-Klick) ```typescript const { data, error } = await supabase.auth.verifyOtp({ token_hash: params.token_hash, type: 'magiclink' }); ``` ### Aktuelle Session ```typescript const { data: { session } } = await supabase.auth.getSession(); ``` ### Logout ```typescript await supabase.auth.signOut(); ``` --- ## 3. Members API ### Alle Mitglieder einer Innung abrufen ```typescript // RLS filtert automatisch nach org_id des eingeloggten Users const { data, error } = await supabase .from('members') .select('id, vorname, nachname, betrieb, sparte, ort, telefon, email, status, ausbildungsbetrieb') .eq('status', 'aktiv') .order('nachname'); ``` ### Mitglieder suchen (Volltext) ```typescript const { data } = await supabase .from('members') .select('*') .or(`nachname.ilike.%${query}%,betrieb.ilike.%${query}%,ort.ilike.%${query}%`) .eq('status', 'aktiv'); ``` ### Mitglied nach Sparte filtern ```typescript const { data } = await supabase .from('members') .select('*') .eq('sparte', 'Elektrotechnik') .eq('status', 'aktiv'); ``` ### Mitglied anlegen (Admin only) ```typescript const { data, error } = await supabase .from('members') .insert({ org_id: currentOrgId, vorname: 'Max', nachname: 'Müller', betrieb: 'Müller Sanitär GmbH', sparte: 'SHK', email: 'max@mueller-sanitaer.de', telefon: '0711-123456', ort: 'Stuttgart', status: 'aktiv', ausbildungsbetrieb: true, mitglied_seit: 2015 }); ``` ### Mitglied aktualisieren ```typescript const { error } = await supabase .from('members') .update({ status: 'ausgetreten' }) .eq('id', memberId); ``` --- ## 4. News API ### News Feed (veröffentlichte Beiträge) ```typescript const { data } = await supabase .from('news') .select(` id, title, body, kategorie, published_at, pinned, author:members(vorname, nachname, betrieb), attachments:news_attachments(id, filename, storage_url), read:news_reads(read_at) `) .not('published_at', 'is', null) .lte('published_at', new Date().toISOString()) .order('pinned', { ascending: false }) .order('published_at', { ascending: false }) .limit(20); ``` ### Beitrag als gelesen markieren ```typescript const { error } = await supabase .from('news_reads') .upsert({ news_id: newsId, user_id: userId }, { onConflict: 'news_id,user_id' }); ``` ### Beitrag mit Leserate (Admin) ```typescript const { data } = await supabase .from('news_with_stats') // VIEW .select('*') .order('published_at', { ascending: false }); ``` ### Beitrag erstellen (Admin) ```typescript const { data: news } = await supabase .from('news') .insert({ org_id: currentOrgId, title: 'Wichtige Mitteilung: Jahreshauptversammlung', body: '## Einladung\n\nWir laden alle Mitglieder...', kategorie: 'Veranstaltung', published_at: new Date().toISOString() // oder null für Entwurf }) .select() .single(); ``` --- ## 5. Termine API ### Kommende Termine ```typescript const { data } = await supabase .from('termine_with_counts') // VIEW .select('*') .gte('datum', new Date().toISOString().split('T')[0]) .order('datum'); ``` ### Anmeldung für Termin ```typescript const { error } = await supabase .from('termine_anmeldungen') .insert({ termin_id: terminId, member_id: memberId }); ``` ### Abmeldung von Termin ```typescript const { error } = await supabase .from('termine_anmeldungen') .delete() .match({ termin_id: terminId, member_id: memberId }); ``` ### Anmeldestatus prüfen ```typescript const { data } = await supabase .from('termine_anmeldungen') .select('id, angemeldet_at') .match({ termin_id: terminId, member_id: memberId }) .maybeSingle(); const isAngemeldet = !!data; ``` ### iCal-Export generieren (Client-seitig) ```typescript function generateICalEvent(termin: Termin): string { return `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//InnungsApp//DE BEGIN:VEVENT UID:${termin.id}@innungsapp.de DTSTART:${termin.datum.replace(/-/g, '')}T${termin.uhrzeit_von?.replace(/:/g, '') || '080000'} DTEND:${termin.datum.replace(/-/g, '')}T${termin.uhrzeit_bis?.replace(/:/g, '') || '100000'} SUMMARY:${termin.titel} LOCATION:${termin.ort || ''} DESCRIPTION:${termin.beschreibung || ''} END:VEVENT END:VCALENDAR`; } ``` --- ## 6. Stellen (Lehrlingsbörse) API ### Alle aktiven Stellen (öffentlich, kein Login) ```typescript // Mit anon key — RLS erlaubt das für aktive Stellen const { data } = await supabase .from('stellen') .select(` id, sparte, berufsbezeichnung, stellen_anzahl, verguetung_1, verguetung_2, verguetung_3, verguetung_4, ausbildungsstart, lehrjahr, schulabschluss, kontakt_email, kontakt_tel, member:members(betrieb, ort, plz) `) .eq('aktiv', true) .order('created_at', { ascending: false }); ``` ### Stellen filtern ```typescript const { data } = await supabase .from('stellen') .select('*') .eq('aktiv', true) .eq('sparte', 'SHK') .ilike('member.ort', '%Stuttgart%'); ``` ### Stelle anlegen (Mitglied) ```typescript const { data, error } = await supabase .from('stellen') .insert({ org_id: currentOrgId, member_id: currentMemberId, sparte: 'Elektrotechnik', berufsbezeichnung: 'Elektroniker für Energie- und Gebäudetechnik', stellen_anzahl: 2, verguetung_1: 800, verguetung_2: 920, verguetung_3: 1020, ausbildungsstart: 'August 2026', lehrjahr: '1. Lehrjahr', schulabschluss: 'Hauptschule', kontakt_email: 'ausbildung@mueller-elektro.de', kontakt_tel: '0711-98765' }); ``` --- ## 7. Storage API ### PDF hochladen (Admin) ```typescript const { data, error } = await supabase.storage .from('news-attachments') .upload(`${orgId}/${newsId}/${file.name}`, file, { contentType: 'application/pdf', upsert: false }); const { data: urlData } = supabase.storage .from('news-attachments') .getPublicUrl(`${orgId}/${newsId}/${file.name}`); ``` ### Logo hochladen (Admin) ```typescript const { error } = await supabase.storage .from('org-assets') .upload(`${orgId}/logo.png`, file, { contentType: 'image/png', upsert: true }); ``` --- ## 8. Edge Functions ### `send-invitation` — Einladungsmail senden ```typescript // apps/supabase/functions/send-invitation/index.ts import { Resend } from 'npm:resend'; Deno.serve(async (req) => { const { member_id, org_id } = await req.json(); // Member und Org-Daten laden const { data: member } = await supabase .from('members') .select('*, org:organizations(name)') .eq('id', member_id) .single(); // Magic Link generieren const { data: { properties } } = await supabase.auth.admin .generateLink({ type: 'magiclink', email: member.email }); // E-Mail senden const resend = new Resend(Deno.env.get('RESEND_API_KEY')); await resend.emails.send({ from: 'noreply@innungsapp.de', to: member.email, subject: `Einladung zur ${member.org.name}`, html: invitationEmailTemplate(member, properties.hashed_token) }); return new Response(JSON.stringify({ success: true })); }); ``` ### `send-push-notification` — Push senden bei neuem Beitrag ```typescript // Wird via Database Webhook nach INSERT auf news getriggert Deno.serve(async (req) => { const { record: news } = await req.json(); if (!news.published_at) return new Response('Draft, skip'); // Alle Push Tokens der Innung laden const { data: tokens } = await supabase .from('push_tokens') .select('token') .eq('org_id', news.org_id); // via user_roles Join // Expo Push API const messages = tokens.map(({ token }) => ({ to: token, title: news.title, body: news.body.substring(0, 100) + '...', data: { type: 'news', id: news.id } })); await fetch('https://exp.host/--/api/v2/push/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(messages) }); return new Response(JSON.stringify({ sent: messages.length })); }); ``` ### `import-members` — CSV-Import ```typescript Deno.serve(async (req) => { const formData = await req.formData(); const file = formData.get('file') as File; const orgId = formData.get('org_id') as string; const csv = await file.text(); const rows = parseCSV(csv); // eigene Parse-Funktion const members = rows.map(row => ({ org_id: orgId, nachname: row['Nachname'], vorname: row['Vorname'], betrieb: row['Betrieb'], sparte: row['Sparte'], email: row['E-Mail'], telefon: row['Telefon'], ort: row['Ort'], status: 'aktiv' })); const { data, error } = await supabase .from('members') .insert(members); return new Response(JSON.stringify({ imported: members.length, error })); }); ``` --- ## 9. Realtime Subscriptions ### Live-Updates für News Feed ```typescript // Neuer Beitrag erscheint sofort in der App const subscription = supabase .channel('news-updates') .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'news', filter: `org_id=eq.${currentOrgId}` }, (payload) => { queryClient.invalidateQueries({ queryKey: ['news'] }); }) .subscribe(); // Cleanup return () => subscription.unsubscribe(); ``` ### Live-Updates für Teilnehmerzahl ```typescript const subscription = supabase .channel(`termin-${terminId}`) .on('postgres_changes', { event: '*', schema: 'public', table: 'termine_anmeldungen', filter: `termin_id=eq.${terminId}` }, () => { queryClient.invalidateQueries({ queryKey: ['termin', terminId] }); }) .subscribe(); ``` --- ## 10. Error Handling Patterns ```typescript // Zentrale Error-Handling Utility export function handleSupabaseError(error: PostgrestError | null): never | void { if (!error) return; switch (error.code) { case '23505': // unique_violation throw new Error('Dieser Eintrag existiert bereits.'); case '42501': // insufficient_privilege (RLS) throw new Error('Keine Berechtigung für diese Aktion.'); case 'PGRST116': // no rows returned throw new Error('Kein Eintrag gefunden.'); default: console.error('Supabase error:', error); throw new Error('Ein unbekannter Fehler ist aufgetreten.'); } } ```