stadtwerke/API_DESIGN.md

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

POST /auth/v1/otp
Content-Type: application/json

{
  "email": "max.muster@muellerszdk.de",
  "options": {
    "emailRedirectTo": "innungsapp://auth/verify"
  }
}
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.');
  }
}