Rebuild as InnungsApp project: replace stadtwerke analysis with full documentation
- PRD: vollständige Produktspezifikation (5 Module, Scope, Akzeptanzkriterien) - ARCHITECTURE: Tech Stack, Ordnerstruktur, Multi-Tenancy, Push, Kosten - DATABASE_SCHEMA: Vollständiges SQL-Schema mit RLS Policies und Views - USER_STORIES: 40+ Stories nach Rolle (Admin, Mitglied, Azubi, Obermeister) - PERSONAS: 5 detaillierte Nutzerprofile mit Alltag, Zitaten und Erwartungen - BUSINESS_MODEL: Preistabellen, Unit Economics, Revenue-Projektionen, Distribution - ROADMAP: 6 Phasen, Sprint-Planung, Meilensteine und KPIs - COMPETITIVE_ANALYSIS: Wettbewerbsmatrix, USPs, Preispositionierung - API_DESIGN: Supabase Query Patterns, Edge Functions, Realtime Subscriptions - ONBOARDING_FLOWS: 7 User Flows von Setup bis Fehlerfall - GTM_STRATEGY: 3-Phasen-Vertrieb, Outreach-Sequenz, Einwandbehandlung - AZUBI_MODULE: Video-Feed, 1-Click-Apply, Chat, Berichtsheft, Quiz - DSGVO_KONZEPT: Rechtsgrundlagen, TOMs, AVV, Minderjährige, Incident Response - FEATURES_BACKLOG: 72 Features nach MoSCoW + Technische Schulden Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
492
API_DESIGN.md
Normal file
492
API_DESIGN.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# 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.');
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user