11 KiB
11 KiB
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://<project-id>.supabase.co
REST API: /rest/v1/
Auth API: /auth/v1/
Storage API: /storage/v1/
Edge Functions: /functions/v1/
Headers:
apikey: <anon-key> # Öffentliche Requests
Authorization: Bearer <jwt> # Authentifizierte Requests
2. Auth Endpoints
Magic Link anfordern
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)
const { data, error } = await supabase.auth.verifyOtp({
token_hash: params.token_hash,
type: 'magiclink'
});
Aktuelle Session
const { data: { session } } = await supabase.auth.getSession();
Logout
await supabase.auth.signOut();
3. Members API
Alle Mitglieder einer Innung abrufen
// 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)
const { data } = await supabase
.from('members')
.select('*')
.or(`nachname.ilike.%${query}%,betrieb.ilike.%${query}%,ort.ilike.%${query}%`)
.eq('status', 'aktiv');
Mitglied nach Sparte filtern
const { data } = await supabase
.from('members')
.select('*')
.eq('sparte', 'Elektrotechnik')
.eq('status', 'aktiv');
Mitglied anlegen (Admin only)
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
const { error } = await supabase
.from('members')
.update({ status: 'ausgetreten' })
.eq('id', memberId);
4. News API
News Feed (veröffentlichte Beiträge)
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
const { error } = await supabase
.from('news_reads')
.upsert({
news_id: newsId,
user_id: userId
}, { onConflict: 'news_id,user_id' });
Beitrag mit Leserate (Admin)
const { data } = await supabase
.from('news_with_stats') // VIEW
.select('*')
.order('published_at', { ascending: false });
Beitrag erstellen (Admin)
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
const { data } = await supabase
.from('termine_with_counts') // VIEW
.select('*')
.gte('datum', new Date().toISOString().split('T')[0])
.order('datum');
Anmeldung für Termin
const { error } = await supabase
.from('termine_anmeldungen')
.insert({
termin_id: terminId,
member_id: memberId
});
Abmeldung von Termin
const { error } = await supabase
.from('termine_anmeldungen')
.delete()
.match({ termin_id: terminId, member_id: memberId });
Anmeldestatus prüfen
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)
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)
// 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
const { data } = await supabase
.from('stellen')
.select('*')
.eq('aktiv', true)
.eq('sparte', 'SHK')
.ilike('member.ort', '%Stuttgart%');
Stelle anlegen (Mitglied)
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)
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)
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
// 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
// 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
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
// 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
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
// 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.');
}
}