493 lines
11 KiB
Markdown
493 lines
11 KiB
Markdown
# 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
|
|
|
|
```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.');
|
|
}
|
|
}
|
|
```
|