Zum Inhalt springen

App & Konten

Seit Version 2.0 hat Ratslotse eine eigene SwiftUI-App für iPhone und iPad. Sie spricht direkt mit demselben FastAPI-Backend wie die Website; die Sitzung läuft im Web über ein Cookie und in der App über ein Bearer-Token im Keychain. Eine WebView gehört nicht mehr zum nativen Produkt.

Das Projekt liegt unter ios/, sein technischer Einstiegspunkt ist ios/README.md. Es besteht aus drei lokalen Swift Packages:

Paket Verantwortung
RatslotseAPI URLSession, Codable-Verträge, Keychain, SSE und Link-Routing
RatslotseDesign Farben, Schriften und SwiftUI-Bausteine der Ratslotse-Designsprache
RatslotseFeatures Auth, Heute, Fragen, Rat, Themen, Quiz und Konto

Die App läuft ab iOS 17 auf iPhone und iPad. Sie nutzt native Systembausteine: Sign in with Apple, APNs, Universal Links, NWPathMonitor, MapKit, EventKit und das Share-Sheet. Der Ratsdialog spricht als POST-SSE direkt mit FastAPI; gründliche Recherchen werden nach einem Verbindungsabriss ab dem letzten Event fortgesetzt.

Auch der Erststart ist nativ: Lotti begrüßt vor der Anmeldung und führt danach durch Gremien-Abos, automatisch beschriebene Themen und Push. Alle drei Schritte sind überspringbar. UserDefaults merkt den lokalen Schritt; GET/POST /api/onboarding/setup hält ihn zusätzlich am Konto, sodass die Einrichtung nach Gerätewechsel oder Neuinstallation fortgesetzt wird. Im Konto kann der Ablauf absichtlich erneut geöffnet werden.

Der Release-Build behält de.ratslotse.app. Debug verwendet de.ratslotse.dev, damit Entwicklungs- und Release-Build getrennt installiert sein können. Vorhandene TestFlight-Anmeldungen werden einmalig aus CapacitorStorage.access_token in den Keychain übernommen.

Terminal-Fenster
xcodegen generate --spec ios/project.yml
swift test --package-path ios/Packages/RatslotseAPI
xcodebuild -project ios/Ratslotse.xcodeproj -scheme Ratslotse \
-configuration Debug -destination 'platform=iOS Simulator,name=Ratslotse iPhone 17' \
CODE_SIGNING_ALLOWED=NO build

Die frühere Capacitor/WebView-App für iOS wurde nach dem Paritätsabgleich entfernt. iOS-Builds entstehen ausschließlich aus dem SwiftUI-Projekt unter ios/. Das unveröffentlichte Android-Gerüst unter web/frontend/android/ bleibt vorerst separat baubar; seine Anleitung steht in web/frontend/MOBILE.md.

Terminal-Fenster
cd web/frontend
npm run build:mobile # = node scripts/build-mobile.mjs
npm run cap:sync # kopiert ./out in das Android-Projekt
npm run cap:android # öffnet Android Studio

scripts/build-mobile.mjs erledigt dabei drei Dinge, die ein nacktes next build nicht kann:

  1. Statischer Export statt Server: MOBILE=1 next build schaltet in next.config.mjs auf output: "export" (+ trailingSlash, unoptimierte Bilder) und schreibt nach ./out — das ist webDir in capacitor.config.ts.
  2. Der web-only Route-Handler unter app/api/ (SSE-Proxy) wird für den Build beiseitegeschoben und danach zurückgelegt — Route Handler lassen sich nicht statisch exportieren.
  3. Eine Content-Security-Policy wird als <meta http-equiv> in jede exportierte .html injiziert, weil der Export keine Header setzen kann.

Es gibt keine server.url in der Capacitor-Konfiguration: Die App lädt ihre Assets lokal aus der WebView und ruft das Backend an einem absoluten Origin auf — NEXT_PUBLIC_API_BASE, sonst https://ratslotse.de (lib/platform.ts). Damit das ohne .env-Änderung funktioniert, hängt das Backend die festen App-WebView-Origins immer an die CORS-Liste an (capacitor://localhost, https://localhost — app_cors_origins in web/backend/app/config.py).

Datei Rolle
web/frontend/capacitor.config.ts appId de.ratslotse.app, appName „Ratslotse“, webDir: "out", androidScheme: "https", Push-Präsentationsoptionen
web/frontend/scripts/build-mobile.mjs Export-Build + CSP-Injektion
web/frontend/lib/platform.ts isNativeApp(), nativePlatform(), apiBase()
web/frontend/MOBILE.md Android-Sonderbuild und gemeinsame produktive Voraussetzungen
Verhalten Umsetzung Ort
Safe-Area / Notch viewportFit: "cover" liefert echte env(safe-area-inset-*)-Werte; Topbar, Tab-Leiste, Hauptbereich, Offline-Pille und Intro rechnen damit app/layout.tsx, components/nav.tsx, app/(app)/layout.tsx
Zurückwischen vom Bildschirmrand MainViewController setzt webView.allowsBackForwardNavigationGestures = true; da der Next-Router über die History-API navigiert, entspricht das exakt der Zurück-Navigation (als customClass in Main.storyboard eingehängt) ios/App/App/AppDelegate.swift
Zoom-Sperre nur nativ: das Viewport-Meta wird auf maximum-scale=1, user-scalable=no umgeschrieben (der System-Zoom der Bedienungshilfen bleibt); im Web bleibt Pinch-Zoom unangetastet app/providers.tsx
Tab-Leiste unten statt Sidebar MobileBottomNav (md:hidden) mit 4 Zielen + angehobener „Fragen“-Taste vs. DesktopSidebar (hidden md:flex) — greift auf allen schmalen Viewports, in der App also immer components/nav.tsx
Pull-to-Refresh nur App: Touch-Handler am Seitenanfang, Schwelle 70 px, danach invalidateQueries() (kein harter Reload) components/pull-to-refresh.tsx
Startseite überspringen / ersetzt sich in der App sofort durch /dashboard components/native-redirect.tsx
Universal / App Links appUrlOpen → In-App-Route (E-Mail-Bestätigung, Passwort-Reset, Push-Tap) lib/app-links.ts
Anmeldung Bearer-Token aus @capacitor/preferences statt httpOnly-Cookie lib/token.ts, lib/api.ts

Zwei getrennte Bausteine:

  • Offline-Pille (components/offline-pill.tsx): hört auf navigator.onLine und die online/offline-Events und blendet unten „Offline — du siehst gespeicherte Inhalte“ ein. Bewusst auch im Web aktiv.
  • Persistenter Query-Cache (app/providers.tsx): nur in der App wird der React-Query-Client in einen PersistQueryClientProvider gehängt.
Storage window.localStorage, Schlüssel „ratslotse.query-cache"
maxAge 24 h (gcTime der Queries ist auf denselben Wert gehoben,
sonst räumt der Garbage Collector vor dem Persist auf)
buster "v2" (verwirft ältere, inkompatible Caches)
staleTime 30 s, retry 1 (Query-Defaults, web wie app)

Gecacht werden damit genau die API-Antworten der besuchten Seiten, die über React Query laufen — keine PDFs, keine Kartenkacheln. Beim Start im Funkloch zeigt die App den letzten Stand statt Skeletons oder Fehlern.

Das verbleibende Android-Gerüst verwendet weiterhin den Web-Erststart aus components/onboarding-flow.tsx. Die native iOS-Variante und ihre Lotti- Animationen liegen vollständig im SwiftUI-Projekt unter ios/.

Passwörter werden mit scrypt gehasht, Sitzungs-Token sind HS256-JWTs, signiert mit WEB_JWT_SECRET — beides stdlib-pur in web/backend/app/security.py (kein passlib/bcrypt/cryptography).

Web: Login/Registrierung setzen ein access_token-Cookie — httponly, samesite=lax, secure gesteuert über COOKIE_SECURE (Default True), Laufzeit ACCESS_TOKEN_EXPIRE_MINUTES (Default 90 Tage). Page-JS sieht das Token nie.

App: Der Client schickt den Header X-Client und nennt darin seine Plattform — ios aus der nativen App, android aus der Capacitor-Hülle; Browser schicken den Header gar nicht. app bleibt als Altwert gültig, weil im Feld Stände laufen, die ihn noch senden. Die Zuordnung macht web/backend/app/clients.py; Fremdwerte werden dort nicht durchgereicht, sondern zu web, damit über den Header nichts Beliebiges in die Statistiktabelle wandert. Erkennt das Backend einen der nativen Werte, liefert es zusätzlich ein langlebiges Token im Antwort-Body (app_access_token_expire_minutes, Default 90 Tage), das die SwiftUI-App im Keychain ablegt und als Authorization: Bearer … mitschickt. deps.get_current_user akzeptiert beides — Bearer zuerst, sonst Cookie. Reset und Passwortwechsel liefern unmittelbar ein Ersatz-Token, weil beide die token_version erhöhen.

Angemeldet bleiben: Beide Sitzungen verlängern sich still bei Nutzung, sonst stünde man nach Ablauf der Laufzeit trotz täglicher Nutzung wieder vor dem Login.

  • Web: Die Middleware SitzungsVerlaengerung (web/backend/app/session.py, rohes ASGI, damit der SSE-Strom der KI-Frage unberührt bleibt) hängt ein frisches Cookie an die Antwort, sobald weniger als SESSION_RENEW_WITHIN_MINUTES (Default 45 Tage, 0 schaltet ab) Restlaufzeit übrig sind. Weil das erneuerte Cookie wieder voll läuft, fällt die nächste Erneuerung erst eine halbe Laufzeit später an — kein Set-Cookie an jeder Antwort. Sie greift auf allen Routen, auch den öffentlichen; wer nur Beschluss-Seiten liest, behält seine Sitzung trotzdem. Ausgenommen sind Antworten, die selbst schon ein Cookie setzen (Login, Logout, Passwortwechsel — sonst überschriebe die Verlängerung das Abmelden), sowie 401-Antworten.
  • App: Cookies helfen dort nicht. Stattdessen liefert GET /api/auth/me an native Clients ein frisch datiertes Token, das der native APIClient im Keychain ersetzt — die App fragt den Endpunkt bei jedem Start.

Der Widerruf bleibt davon unberührt: Das erneuerte Token trägt dieselbe token_version wie das alte.

Widerruf läuft über web_users.token_version: Der Wert steckt als ver im Token; passt er nicht mehr zur Zeile, ist die Sitzung ungültig. Erhöht wird er bei Passwort-Änderung und Passwort-Reset. POST /api/auth/logout löscht nur das Cookie — die App entfernt ihr Token zusätzlich lokal und meldet vorher ihren Push-Token ab (lib/auth.tsx, lib/push.ts).

Endpunkt Zweck Limit
POST /api/auth/register Konto anlegen (E-Mail, Passwort ≥ 8 Zeichen, optional display_name) 5 / 5 min
POST /api/auth/login Anmelden 10 / min
POST /api/auth/logout Session-Cookie löschen —
GET /api/auth/me aktuelles Konto (UserOut) —
POST /api/auth/forgot-password Reset-Link (1 h gültig); antwortet immer 200, verrät also nicht, ob ein Konto existiert 5 / 15 min
POST /api/auth/reset-password neues Passwort setzen, danach alle Sitzungen ungültig —
POST /api/auth/verify-email Adresse bestätigen (Link 24 h gültig) → Konto wird aktiv; derselbe Endpunkt schließt auch einen Adresswechsel ab —
POST /api/auth/resend-verification Bestätigungslink erneut senden — an die neue Adresse, wenn ein Wechsel schwebt 5 / 15 min
POST /api/account/change-email Adresswechsel anstoßen (Passwort bzw. Apple-Re-Auth) 5 / 15 min je Konto
DELETE /api/account/change-email schwebenden Wechsel verwerfen —
POST /api/auth/apple Sign in with Apple 10 / min

Die Registrierung braucht keine Admin-Freigabe: Wer die E-Mail bestätigt, ist aktiv; die Admins bekommen nur eine FYI-Mail. Ohne konfigurierten E-Mail-Versand (RESEND_API_KEY fehlt) wird die Verifikation übersprungen — sonst ließe sich das Konto nie bestätigen. Solange ein Konto nicht aktiv ist, zeigt die Oberfläche einen Hinweis statt der Inhalte und pollt /auth/me (app/(app)/layout.tsx); serverseitig blockt require_active.

Wegwerf-Adressen werden abgewiesen (seit 09/2026). Die Bestätigung hält sie nicht ab — ein Zehn-Minuten-Postfach empfängt den Link genauso. Deshalb prüfen Registrierung und Adresswechsel die Domain (samt Eltern-Domains) gegen kern/disposable_email_domains.txt, eine öffentlich gepflegte Liste mit rund 8.800 Anbietern (CC0), und antworten mit 400 und einem Satz ohne Grund („Die Registrierung konnte nicht abgeschlossen werden.“) — wer abgewiesen wird, soll nicht erfahren, welche Prüfung angeschlagen hat. Die Domain steht im Server-Log (ratslotse.web.auth bzw. ratslotse.web.account). PROTECTED_DOMAINS in kern/disposable_email.py nennt Anbieter, die nie gesperrt werden, darunter Apples privaterelay.appleid.com. Was Sign in with Apple selbst liefert, ist von Apple bestätigt und wird nicht geprüft. Nachziehen der Liste: scripts/update_disposable_domains.py --schreiben.

Abgewiesene Registrierungen werden gezählt. Sichtbar war bis 09/2026 nur, wer durchkam — wer an der Bremse oder am Wegwerf-Riegel hängenblieb, hinterließ nirgends eine Spur. Die Tabelle signup_rejections zählt deshalb je Tag und Grund (rate_limit, disposable_email, duplicate_email aus kern.store.SIGNUP_REJECTION_REASONS), und zwar nur das: keine Adresse, keine Domain, keine Netzadresse, kein Konto — dieselbe Haltung wie bei page_views. Sichtbar unter GET /api/admin/stats/signups und im Admin-Panel unter Statistik → Registrierungen.

scripts/check_herzschlag.py schlägt einmal täglich Alarm, wenn in 24 Stunden zehn oder mehr Konten dazukommen, ebenso viele davon unbestätigt bleiben oder gestern und heute zusammen zwanzig Versuche abgewiesen wurden. Der Grund für diese Schwelle: Die FYI-Mail an die Admins geht erst raus, wenn jemand seine Adresse bestätigt hat — ein Skript, das tausend Konten anlegt und nie einen Link klickt, löst ohne den Herzschlag keine einzige Mail aus. duplicate_email löst bewusst keinen Alarm aus: Wer sein Konto vergessen hat, landet dort genauso wie jemand, der Adressen durchprobiert.

Vier Endpunkte antworten ohne Anmeldung. Nicht aus Versehen, sondern weil Teilen die Kernhandlung der App ist: Wer einen Beschluss weiterreichte, schickte die Empfänger*innen vorher ins Registrierungsformular — bevor sie überhaupt gesehen hatten, worum es geht.

Endpunkt Seite
GET /api/council/decision/{id} Beschluss
GET /api/council/entity/{slug} Thema
GET /api/council/person/{slug} Person
GET /api/council/session/{ksinr} Sitzung (die Beschluss-Seite zieht Gremium + Datum daraus)
GET /api/council/preview/{art}/{key} nur Titel + Kurzfassung für die Link-Vorschau

Genau die Seiten mit Teilen-Knopf und Link-Vorschau. Alles davon bereitet das amtliche Ratsinformationssystem auf und ist dort ohnehin für alle einsehbar — es entsteht keine neue Öffentlichkeit, nur eine lesbare.

Gäste sehen components/public-shell.tsx statt der App-Navigation — deren Ziele verlangen ausnahmslos ein Konto. Die Einladung zum Registrieren steht am Ende der Seite: Erst wer gelesen hat, weiß, wofür sich ein Konto lohnt. ?weiter=<pfad> bringt nach Anmeldung, Registrierung oder Apple-Login zurück zum Ausgangspunkt (nur seiteneigene Pfade, siehe sicheresZiel).

Der „Zurück“-Knopf fehlt Gästen bewusst: Bei einem frisch aus einem Messenger geöffneten Tab führt router.back() aus der Seite heraus, und der Rückfall auf die Sitzungs-Übersicht landet an der Anmeldewand. history.length > 1 unterscheidet die Fälle nicht — es zählt auch fremde Einträge.

web/backend/app/routers/auth_apple.py. Die App holt über das native AuthenticationServices-Framework ein Identity-Token, im Browser tut das „Sign in with Apple JS“ als Popup-Flow (lib/apple.ts). Beide Wege schicken dasselbe Token an POST /api/auth/apple — Secrets oder Schlüssel braucht keine Seite.

Geprüft wird das RS256-Token gegen Apples JWKS (https://appleid.apple.com/auth/keys, 24 h gecacht, bei unbekannter kid einmal Zwangs-Refresh): Signatur, exp, iss und aud.

Variable Bedeutung
APPLE_BUNDLE_ID erlaubte aud der nativen App (Default de.ratslotse.app)
APPLE_SERVICE_ID erlaubte aud des Web-Flows (Services ID, z. B. de.ratslotse.web); leer = Web-Flow aus, weil dann keine passende aud akzeptiert wird
APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY signieren das kurzlebige Client-Secret, mit dem das Backend bei einer Kontolöschung Apples Token-Widerrufs-API aufruft

Danach entscheidet die Kontozuordnung:

  • apple_sub bereits bekannt → Anmeldung in dieses Konto.
  • sonst: gleiche, von Apple bestätigte E-Mail vorhanden → verknüpfen (apple_sub setzen, offene Verifikation gilt als erledigt, pending wird active). Private-Relay-Adressen sind dabei normale Adressen.
  • sonst: neues Konto, sofort active und email_verified, mit Zufalls-Passwort-Hash und password_set = 0 — ein eigenes Passwort lässt sich über „Passwort vergessen“ nachrüsten.

Nur die E-Mail aus dem signierten Token zählt; eine Client-Angabe wäre fälschbar. Liefert Apple keine E-Mail und ist die sub unbekannt, kann kein Konto zugeordnet werden — die API antwortet mit 400 und dem Hinweis, Ratslotse in den Apple-ID-Einstellungen unter „Mit Apple anmelden“ zu entfernen und es erneut zu versuchen (Apple sendet die Adresse nur bei der Erstautorisierung).

Spalte Werte Bedeutung
web_users.role user, admin admin sieht den Admin-Bereich (require_admin) und ist immer aktiv
web_users.status pending, active, disabled pending = wartet auf die eigene E-Mail-Bestätigung; disabled = von einem Admin abgeschaltet
web_users.email_verified 0/1 gesetzt durch Verifikationslink oder Apple-Login
web_users.password_set 0/1 0 = Apple-Konto ohne selbst gesetztes Passwort

Die beiden Wartezustände sind seit 09/2026 getrennt. Bis dahin trug pending beide: „E-Mail noch nicht bestätigt“ und „von einem Admin abgeschaltet“. Das war nicht nur unscharf, sondern hatte zwei Folgen:

  • Der Apple-Verknüpfungspfad las pending als „unbestätigt“ und schaltete das Konto frei. Ein gesperrtes Konto hob damit seine Sperre selbst auf, sobald die Apple-ID dieselbe bestätigte Adresse trug.
  • Die iOS-App zeigte einer gesperrten Person „Bestätige deine E-Mail-Adresse“, die sie längst bestätigt hatte, samt eines Knopfes, der Erfolg meldete und nichts verschickte.

Der Bestand wurde einmalig nachgezogen: bestätigt und nicht aktiv heißt rückwirkend disabled. Der Schritt trägt eine Migrationsmarke und läuft deshalb genau einmal je Datenbank — verify_email setzt erst email_verified, dann den Status, und in diesem Moment sähe eine ganz normale Bestätigung wie ein abgeschaltetes Konto aus.

PUT /api/admin/users/{id}/status nimmt active und disabled. Der alte Wert pending wird weiterhin angenommen und als disabled gespeichert: Die im App Store ausgelieferte Admin-Ansicht schickt beim „Sperren“ genau ihn. Die Registrierung vergibt keine Rollen: Jedes über /api/auth/register angelegte Konto ist user — auch die Adresse aus WEB_ADMIN_EMAIL und auch das erste Konto einer leeren Datenbank. Andernfalls bekäme Adminrechte, wer die konfigurierte Adresse als Erstes ins Formular tippt, ohne Zugriff auf dieses Postfach nachzuweisen.

Admin wird die Adresse aus WEB_ADMIN_EMAIL, sobald sie ihre E-Mail bestätigt hat (/api/auth/verify-email, nach verbrauchtem Einmal-Token) — und nur, solange es im Deployment noch gar keinen Admin gibt. Damit holt sich ein bewusst degradiertes oder gesperrtes Konto die Rechte nicht über einen neuen Bestätigungslink zurück. Ohne RESEND_API_KEY gibt es keinen Link: dann vergibt scripts/grant_admin.py <adresse> die Rechte an ein bestehendes Konto (das Backend weist bei Registrierung und bei jedem Start im Log darauf hin).

Beim Apple-Login gilt eine eigene Regel: Wird dabei ein Konto neu angelegt und entspricht die Adresse WEB_ADMIN_EMAIL, ist es sofort Admin — ohne Bestätigungslink und ohne die „noch kein Admin vorhanden“-Bedingung. Das ist vertretbar, weil Apple die Adresse im signierten Token bereits nachweist; der Nachweis, den die klassische Registrierung erst über den Link erbringt, liegt hier schon vor. Der „erstes Konto einer leeren Datenbank“-Notnagel entfällt aber auch hier.

Zweistufig, und zwar aus zwei verschiedenen Gründen:

  1. Passwort jetzt. Eine offen liegende Sitzung (fremdes Gerät, gestohlenes Cookie) darf die Adresse nicht wechseln können — sonst übernimmt, wer die Sitzung hat, per „Passwort vergessen“ gleich das ganze Konto. Apple-Konten ohne eigenes Passwort weisen sich mit einem frischen Apple-Identity-Token aus, dessen sub zum Konto gehören muss.
  2. Link an die neue Adresse. Bis er geklickt ist, ändert sich nichts: Anmeldung, Benachrichtigungen und Passwort-Reset laufen weiter über die bisherige Adresse. Erst der Klick schreibt sie um.

Der Token liegt in derselben Tabelle wie die Erstbestätigung (email_verification_tokens.new_email), und bestätigt wird über denselben Endpunkt. Das ist Absicht: Die im App Store ausgelieferte App kennt den Pfad /verify-email und schickt jeden anderen nach Safari — so kann auch eine alte App-Fassung einen Wechsel abschließen. Weil je Konto nur ein Token gilt, macht ein Wechsel nebenbei einen älteren Bestätigungslink ungültig.

Die bisherige Adresse wird zweimal angeschrieben: beim Anstoßen („wenn du das nicht warst, ändere jetzt dein Passwort“ — solange der Wechsel schwebt, gehen Reset-Mails noch dorthin) und nach dem Wechsel als Quittung.

UserOut.pending_email trägt einen schwebenden Wechsel; die Sitzungen bleiben gültig (token_version steigt nicht). Ein unbestätigtes Konto darf wechseln — der Tippfehler bei der Registrierung ist der häufigste Anlass, und der Hinweisbildschirm bietet die Korrektur deshalb selbst an. Ein vom Admin deaktiviertes Konto darf es nicht, und ein alter Token schaltet es auch nicht frei. Ohne RESEND_API_KEY (dev, feature, Tests) gilt der Wechsel sofort, dieselbe Regel wie bei der Registrierung.

Alle Konto-Daten liegen in ratslotse.sqlite (siehe Architektur); Eigentum ist durchgängig über owner_id = web_users.id modelliert.

web_users.delivery_channel ∈ email | push | both | off (neue Konten starten auf email).

off heißt: gar keine Benachrichtigungen. Kein eigenes Feld, weil es dieselbe Frage beantwortet wie die anderen drei — wohin? — nur mit „nirgendwohin“. Die Prüfung sitzt in kern.notify.gewuenscht(), also vor der Warteschlange: Bei off wird nichts eingereiht, sonst zählten unzustellbare Meldungen gegen die Tagesgrenze und kämen beim Wiedereinschalten als Nachlieferung an. Zusätzlich verwirft PUT /api/account/delivery beim Umschalten auf off, was noch offen in der Warteschlange liegt, und setups_to_remind() überspringt diese Konten — auch die freundlich gemeinte Einrichtungs-Erinnerung schweigt.

Endpunkt Zweck
PUT /api/account/delivery Kanal setzen; email/both scheitern, wenn keine echte Adresse hinterlegt ist; off räumt zusätzlich die Warteschlange
POST /api/account/test-notification Test über alle aktiven Kanäle, exakt über den Cron-Versandpfad kern.delivery.deliver_message; gibt die tatsächlich bedienten Kanäle zurück
  • E-Mail über Resend (kern/email.py). Ohne RESEND_API_KEY wird der Versand still übersprungen.
  • Push über APNs (iOS, token-basiert mit .p8 — kein Firebase) und FCM v1 (Android) in kern/push.py. Geräte-Token, die die Gateways als ungültig melden, werden ausgesortiert.
  • In der App führt der Push-Primer (components/push-primer.tsx) vor den System-Dialog: Er erscheint erst, wenn es mindestens ein Thema oder ein Ausschuss-Abo gibt, und schlummert nach „Später“ 7 Tage.

Geräte-Token registriert die App selbst:

Endpunkt Zweck
POST /api/push/register Token + Plattform (ios/android) speichern; idempotent, die App registriert bei jedem Start neu
POST /api/push/unregister Token entfernen (Logout, Push abschalten) — nur eigene Token
push_tokens
token PK, owner_id, platform, created_at, last_seen

web/backend/app/routers/topics.py. Themen sind die Watchlist des Kontos; Ausschuss-Abos liegen daneben in committee_subscriptions.

Endpunkt Zweck
GET /api/topics Themen inkl. Trefferzahl, jüngstem Treffer und unread_count
POST /api/topics · PUT /api/topics/{id} · DELETE /api/topics/{id} anlegen, ändern, löschen
GET /api/topics/suggestions anklickbare Vorschläge aus echten Entitäten mit jüngster Ratsaktivität (Ähnlichkeits-Dedupe gegen vorhandene Themen)
GET /api/topics/{id}/decisions gematchte Beschlüsse mit Score
GET /api/topics/latest-hits jüngste Treffer über alle Themen (Heute-Briefing)
GET /api/topics/unread-count Summe ungesehener Treffer
POST /api/topics/{id}/seen alle aktuellen Treffer eines Themas als gesehen markieren
GET/POST/DELETE /api/subscriptions Ausschuss-Abos lesen, anlegen, entfernen

Der „Neu“-Zähler speist sich aus topic_hits_seen (owner_id, topic_id, decision_id, seen_at): Alles, was nicht darin steht, gilt als ungesehen. Die Navigation pollt unread-count im Minutentakt und zeigt die Zahl an „Meine Themen“ bzw. einen orangen Punkt am Themen-Tab; das Öffnen der Beschlussliste eines Themas ruft /seen (components/nav.tsx).

Wie Themen gegen Tagesordnungen und Beschlüsse gematcht werden, steht in KI-Pipeline und Ratsdokumente & Beschlüsse.

Der Zustellkanal sagt wo, die Anlässe sagen wofür. Beides steht in „Mein Konto“; die Anlass-Schalter liegen als JSON in web_users.notify_prefs (leer = Vorgaben aus kern/notify.py).

Anlass wann Vorgabe Auslöser
n1_tagesordnung Tagesordnung eines abonnierten Gremiums erscheint aus — an mit Recht mandate (Ratsmitglied) scripts/check_committees.py (7 Uhr)
n2_thema ein eigenes Thema steht auf einer Tagesordnung an council/watcher.py über check_council.py (8/14 Uhr)
n3_result zu gemeldeten oder gemerkten TOPs liegen Ergebnisse vor — ein Brief je Protokoll-Schub an council/ergebnisse.py am Protokoll-Import (check_protocols.py, 9 Uhr)
n4_vorgang eine verfolgte Vorlage bewegt sich an scripts/check_vorlage_follows.py
n5_vorabend morgen tagt ein Gremium, das dich betrifft aus scripts/abendmeldungen.py (18 Uhr)
n6_woche Wochenüberblick an dasselbe Skript, nur sonntags

Nicht jede Mail ist eine Benachrichtigung. Die sechs Anlässe oben laufen über kern.notify.einreihen und hängen damit an Aus-Schalter, Nachtruhe und Tagesgrenze. Daneben gibt es Service-Mails zum eigenen Konto, die direkt verschickt werden: Bestätigungslink, Passwort-Reset, Freischaltung durch einen Admin, die Abschieds-Mail bei der Löschung, die Hinweise beim Adresswechsel — und seit 10.09.2026 die Rückmeldung auf eigenes Feedback („Dein Vorschlag ist umgesetzt“). Der Unterschied ist nicht formal: Wer uns schreibt, bekommt eine Antwort, und eine Antwort an einer Nachtruhe scheitern zu lassen wäre die falsche Sparsamkeit.

Die Rückmeldung löst ein Admin im Panel unter Feedback aus, mit optionalen eigenen Zeilen dazu. Vier Riegel sitzen davor: gemeldete Inhalte (qa_share) lösen nie Post aus, ohne hinterlegte Adresse geht nichts, ein zweites Mal gibt es nicht (feedback.notified_at), und scheitert der Versand, wird weder der Vermerk gesetzt noch abgehakt.

Vorgaben nach Nutzerart (seit 06.09.2026). Ein gewöhnliches Konto bekommt die Tagesordnung je Gremium nur, wenn es den Schalter ausdrücklich einschaltet — ein Abo ohne Sofort-Meldung ist kein Widerspruch, das Gremium steht in der Ratswoche und im Wochenüberblick, der jetzt ab Werk kommt. Ein Konto mit Ratsmandat (Recht mandate, Rolle Ratsmitglied) bekommt alle abonnierten Gremien sofort. Entschieden wird über das Recht (kern.notify.vorgaben_fuer), nie über den Rollennamen — weshalb die Rolle Fachpublikum (seit 09/2026) den Haushalt öffnet, ohne an diesen Vorgaben etwas zu ändern: Sie trägt budget, aber nicht mandate. Gemessen war das Gegenteil im Bestand: Die Tagesordnungs-Meldungen machten 41 % aller Posten aus, getrieben von Konten mit im Mittel neun Abos, während der Wochenüberblick bei fast allen aus war.

Bestandskonten bleiben, wie sie waren. Beim ersten Start nach dem Umbau schreibt Store._notify_vorgaben_einfrieren jedem vorhandenen Konto die alten Vorgaben (n1_tagesordnung an, n6_woche aus) ausdrücklich in die Spalte — nur für diese zwei Schalter und nur, wo noch nichts stand. Marke notify_vorgaben_2026_09 in migration_marks.

Die Tagesgrenze ist durchlässig. Eine Meldung mit der Marke wichtig (Spalte in notification_queue, gesetzt über notify.einreihen(..., wichtig=True)) geht auch dann einzeln raus, wenn die zwei des Tages verbraucht sind; sie zählt danach mit und wartet wie alles auf das Ende der Nachtruhe. Gesetzt wird sie nur mit gemessener Tragweite ab CouncilStore.TOP_MINDEST (60): bei N1, wenn die Tagesordnung so einen Punkt trägt, bei N3, wenn ein Beschluss des Briefs ihn erreicht.

Kein Anlass sendet selbst. Alle reihen über kern.notify.einreihen() in notification_queue ein; zugestellt wird zentral in notify.zustellen(), das die Cron-Jobs am Ende ihres Laufs aufrufen. Ein eigener Cron dafür ist nicht nötig — check_committees läuft um 7 Uhr und leert damit, was über Nacht liegen blieb.

Grenze Regel
Zwei am Tag pro Person, nicht pro Anlass. Was darüber hinausgeht, nimmt die letzte freie Zustellung als ein Bündel mit; ein Bündel zählt als eine Zustellung. Nichts geht verloren — der Rest kommt morgen.
… außer termingebunden kern.notify.TERMINGEBUNDEN (derzeit nur n5_vorabend) hat einen eigenen Vorrat von zwei am Tag. Eine Vorabend-Erinnerung, die einen Tag später kommt, ist nicht verspätet, sondern wertlos — und weil der 18-Uhr-Lauf der letzte des Tages ist, verlor sie den Wettlauf um den gemeinsamen Topf regelmäßig (Prod, 16.08.2026: ab 18 Uhr fertig, zugestellt am Sitzungstag selbst). Innerhalb ihres Topfes gelten dieselben Regeln, also auch das Bündeln ab der dritten. Weil notifications_sent_on() je Topf nach kind zählt, bündelt _zustellen_fuer() nie über die Topfgrenze hinweg — ein gemischtes Bündel wäre in beiden Töpfen eine Zustellung.
Nachtruhe 21–7 Uhr Ortszeit (zoneinfo, Europe/Berlin). Was abends anfällt, bekommt deliver_after auf 7 Uhr.
Nie ohne Ereignis Es gibt keine Funktion, die ohne Ratsvorgang einreiht. Abzeichen und Quiz-Serien bleiben in der App.
Immer ein Ziel url ist Pflichtfeld; einreihen() wirft ohne. Antippen öffnet den Beschluss oder die Tagesordnung, nie die Startseite.
notification_queue
id PK, owner_id, kind, title, body_html, url,
created_at, deliver_after, sent_at, bundled

Zwei Regeln verhindern Dubletten: check_committees überspringt ein Konto, das für dieselbe Sitzung schon einen Themen-Treffer hat (Themen-Treffer gewinnt), und eine Änderungsmeldung geht nur noch ≤ 48 h vor der Sitzung raus.

Endpunkt Zweck
GET /api/account/notifications Anlässe mit Beschriftung, Vorgabe und Zustand + die geltenden Grenzen
PUT /api/account/notifications Schalter setzen; unbekannte Schlüssel werden verworfen

Die Oberfläche pflegt keine eigene Anlass-Liste — sie rendert, was der Endpunkt liefert. Sonst fiele ein neu dazugekommener Anlass erst auf, wenn sich jemand über eine unabschaltbare Meldung ärgert.

web/backend/app/routers/badges.py — acht Lotsen-Abzeichen, kein Ranking, keine Serien, die reißen können: erste-frage, themen-lotse, quiz-serie (5 Tage), kartograf (3 Orte), analyst, sitzungsgast, fruehwarner, kompass.

Der Stand wird bei jedem GET /api/badges neu berechnet, teils aus vorhandenen Daten (Themen vorhanden, Quiz-Serie, Push-Gerät registriert, Onboarding-Schritt „analyse“), teils aus Ereignis-Flags, die das Frontend gemeldet hat. Persistiert wird in der JSON-Spalte web_users.badges:

{"earned": [ids], "map_places": [slugs], "flags": ["frage","sitzung","tour"]}
  • POST /api/badges/event meldet ein Ereignis (frage, sitzung, tour oder map_place mit key) — idempotent, Unbekanntes wird still verworfen. Das Frontend ruft das fire-and-forget über reportBadgeEvent() (components/badges.tsx).
  • newly_earned enthält die Abzeichen, die in genau diesem GET neu dazukamen; sie werden dabei in earned geschrieben und tauchen danach nie wieder auf. Das ist der Auslöser für die Feier-Karte (BadgeCelebrator) — genau einmal.
  • Einmal verdient bleibt verdient: Wer ein Thema löscht oder die Quiz-Serie reißt, behält das Abzeichen.

Die Feier selbst ist reines Frontend: eine Karte, die unten über den laufenden Screen fährt (bewusst kein Vollbild — sie blockiert nichts und geht nach 6 s von selbst), mit Konfetti innerhalb der Karte und einem Weg zur Sammlung. Mehrere gleichzeitig verdiente Abzeichen laufen nacheinander statt gestapelt. Welche gerade neu sind, merkt sich der Browser unter ratslotse:badges-neu und zeigt sie in der Konto-Karte als „NEU“ — bis die Sammlung einmal offen war. Das ist bewusst gerätelokal: Es markiert „hier noch nicht angesehen“, nicht einen Kontostand.

web/backend/app/routers/onboarding.py, Spalte web_users.onboarding (JSON: {"steps": [...], "celebrated": bool}). Bewusst serverseitig am Konto statt im localStorage, damit der Kurs „Erste Schritte mit Lotti“ auf jedem Gerät denselben Stand hat und nach Abschluss überall verschwindet.

  • GET /api/onboarding liefert den Stand, POST /api/onboarding merged erledigte Schritte dazu und/oder setzt celebrated.
  • Erlaubt sind nur die bekannten Schritte frag, beschluesse, analyse, karten — alles andere wird verworfen, damit die Spalte nicht zuwuchert. Schritte gelten schon beim Besuch der jeweiligen Seite als erledigt (components/onboarding.tsx).
  • Davon getrennt speichert GET/POST /api/onboarding/setup den erreichten Schritt des Ersteinrichtungs-Assistenten sowie Start und Abschluss. Dieser Stand dient der Wiederaufnahme (nach Neuinstallation, auf einem anderen Gerät) und dem Einrichtungs-Reminder (scripts/remind_setup.py).

Der Assistent lief bis 09/2026 nur in der App; im Browser existierte er im Code, war aber hinter isNativeApp() unsichtbar. Seither läuft er auf beiden Plattformen — und die Entscheidung, ob er dran ist, fällt im Backend (Store.get_setup, Feld pending in der Antwort). Nicht im Frontend, aus zwei Gründen: Beide Clients sollen dieselbe Regel benutzen, und sie hängt an Daten (Themen, Abos), die ein Frontend erst in zwei zusätzlichen Requests holen müsste, bevor es überhaupt weiß, ob es etwas anzeigen soll.

Zustand pending
setup_done_at gesetzt nein — auch wenn nur „Überspringen“ geklickt wurde. Ein weggeklickter Assistent, der wiederkommt, wäre keiner.
setup_step ≥ 1, nicht abgeschlossen ja, und zwar bei genau diesem Schritt weiter
nie angefangen, Konto hat weder Thema noch Abo ja
nie angefangen, Konto hat Themen oder Abos nein — hier hat sich jemand erkennbar selbst eingerichtet

Im Browser kommt eine Ortsprüfung dazu: Der Assistent hängt global in app/providers.tsx (in der App muss er das, dort liegt er über dem Login), darf im Web aber nur innerhalb von app/(app)/ erscheinen — sonst deckte er Landingpage, Changelog oder Impressum zu.

# Browser App
1 Gremien Gremien
2 Stadtteile (Karte + Liste, mehrfach) —
3 Themen (je Stadtteil + stadtweit) Themen
4 E-Mail-Zustellung Push-Erlaubnis

Zwei Unterschiede, beide plattformbedingt:

Der letzte Schritt. Die App holt die Push-Erlaubnis, der Browser fragt nach der E-Mail. Web-Push (VAPID) gibt es nicht — kern/push.py spricht nur APNs und FCM. Weil die Zustellung der Punkt ist, an dem die ganze Idee steht oder fällt (ohne Mitteilung erfährt niemand, dass sein Thema auf einer Tagesordnung steht), wirbt der Schritt dafür statt bloß zu fragen — mit dem, was konkret käme, und mit den echten Mengen aus kern/notify.py.

Die Reihenfolge. Im Browser kommen die Themen (Schritt 2) VOR den Stadtteilen (Schritt 3). Bis 30.09.2026 war es umgekehrt, und neue Konten nahmen fast nur Stadtteile: Eine Karte mit einem Klick je Fläche ist die leichteste Wahl im ganzen Ablauf. Die Themen stehen als Kacheln mit einem kleinen, flach gezeichneten Bild, in dem manchmal Lotti, ein Küken oder Krissi mitspielen (public/themen/<key>.webp, erzeugt von scripts/themen_grafiken.py), in einer von Hand gemischten Reihenfolge (ANZEIGE in council/city_topics.py) statt nach Zahl. Die Gremien im ersten Schritt tragen je eine Lotti mit einem Requisit (public/gremien/<key>.webp, Zuordnung in lib/committees.ts, erzeugt mit scripts/themen_grafiken.py --satz gremien); GET /api/topics liefert für ein Thema, das so heißt wie ein Stadtthema, image_key — daran hängt das Bild auf der Themen-Karte. Der Assistent nimmt höchstens drei Stadtteile an; mehr lassen sich später unter „Themen“ anlegen.

Was angeklickt wird, steht in onboarding_chip_stats. Je Tag und Chip ein Zähler für „angezeigt“ und „gewählt“ — ohne Konto, Sitzung oder Namen (POST /api/onboarding/chips, Positivliste: city_topic:<key>, district, district_suggestion, own). Gelesen wird mit python scripts/onboarding_chips.py --tage 14.

Die Stadtteile. Sie sind im Browser ein eigener Schritt NACH den Themen; dort erscheinen auch lokale Vorschläge: GET /api/topics/suggestions?district=<place_id> (mehrfach erlaubt) liefert districts — je gefragtem Ortsbereich eine Gruppe, in der gefragten Reihenfolge — plus suggestions (stadtweit). Keine Liste wiederholt, was in einer anderen schon steht.

Gefragt wird nach Interesse, nicht nach der Wohnadresse („Welche Stadtteile interessieren dich?“). Das ist keine Kosmetik: Ratslotse braucht keine Meldedaten, und wer im Dobbenviertel wohnt und die Baustelle in Osternburg verfolgt, soll beides angeben können — deshalb ist die Auswahl mehrfach. Der Ortsbezug hängt am Beschluss, nicht an der Entität: Ein Thema erbt seinen Ort von den Beschlüssen, in denen es vorkommt (suggested_entity_topics(place_id=…), dieselbe Bedingung wie im Beschlussfilter). Gewählt wird auf einer Inline-SVG-Karte aus public/geo/stadtteile-oldenburg.json; sie braucht keinen Kachel-Dienst und keinen CARTO-Key. Die Namensliste daneben ist nicht bloß der Ersatz fürs kleine Fenster, sondern der Weg für Tastatur und Screenreader.

Der gewählte Stadtteil wird als Thema angelegt — nur so löst er Hinweise aus. In „Deine Themen“ trägt er dafür eine eigene Beschriftung, sonst wirkte die Trennung der beiden Schritte hinterher hinfällig.

Die App kennt den Stadtteil-Schritt (noch) nicht und läuft mit drei Schritten; set_setup_step deckelt bei 4, was ihr nichts abschneidet. Im Themen-Schritt der App stehen dafür seit 10/2026 dieselben Stadtthemen-Kacheln wie im Web, über dem (einzelnen) Stadtteil-Menü; Bilder liegen als Imagesets im Asset-Katalog (scripts/ios_themenbilder.py, ein Test hält sie gegen public/ und die Registry).

web_users.display_name (max. 60 Zeichen, optional) wird bei der Registrierung abgefragt und ist über POST /api/account/display-name änderbar — auch für Apple-Konten und Altbestand, die bei der Anmeldung keinen angeben konnten. Er dient der persönlichen Ansprache, u. a. in der Begrüßung der Benachrichtigungs-Mails.

DELETE /api/account löscht das Konto endgültig (Recht auf Löschung nach DSGVO). Verlangt wird eine frische Bestätigung — eine offene Sitzung allein darf ein Konto nicht zerstören können:

  • Konten mit Passwort bestätigen mit dem aktuellen Passwort,
  • Apple-only-Konten (password_set = 0) mit einem frischen Apple-Identity-Token, dessen sub zum Konto passt (Re-Auth in der App).

Store.delete_web_user räumt jede Tabelle aus USER_OWNED_TABLES (kern/store.py) ab und löscht zuletzt die Zeile in web_users — derzeit 18 Tabellen. Diese Seite zählt sie bewusst nicht mehr einzeln auf: Die frühere Aufzählung nannte sechs und war damit lange falsch. Maßgeblich ist die Konstante, und dass sie vollständig bleibt, prüft test_delete_web_user_covers_every_user_table gegen das Schema — wer eine neue nutzerbezogene Tabelle anlegt, muss sie dort eintragen, sonst schlägt der Test fehl. Anschließend geht eine Bestätigungs-Mail raus (Best-Effort). Die Löschmöglichkeit in der App ist zugleich eine App-Store-Anforderung für Apps mit Registrierung.

Personenbezogen gespeichert werden ausschließlich Daten, die aus der Nutzung selbst entstehen:

Daten Wo
E-Mail-Adresse, Passwort-Hash (scrypt), Anzeigename web_users
Apple-Kennung (apple_sub) und die von Apple bestätigte Adresse — bei „E-Mail verbergen“ eine Weiterleitungsadresse web_users
Themen und Ausschuss-Abos (frei formulierte Interessen) topics, committee_subscriptions
gesehene Treffer, Onboarding-Fortschritt, Abzeichen-Stand topic_hits_seen, web_users.onboarding, web_users.badges
Quiz-Antworten (Punkte je Gebiet) quiz_answers
Push-Geräte-Token push_tokens
Aktivität für die Admin-Statistik: eine Zeile je Konto/Tag/Feature/Client mit Zähler user_activity
Womit das Konto angelegt wurde (Browser oder App) web_users.signup_client

Verarbeiter sind Resend (E-Mail-Versand), Apple/APNs bzw. Google/FCM (Push-Zustellung) und Apple beim „Sign in with Apple“ — jeweils nur, wenn der entsprechende Kanal aktiv ist. Auf dem Gerät liegen Design-Wahl, in der App das Anmelde-Token und der oben beschriebene Inhalts-Zwischenspeicher.

Nicht übernommen werden die Kontaktdaten der Mandatsträger*innen aus dem Ratsinformationssystem (Adresse, Telefon, Beruf auf den Personenseiten) — das ist eine bewusste Entscheidung der Stammdaten-Auswertung, siehe Ratsdokumente & Beschlüsse.

Die vollständige, verbindliche Fassung steht auf der Datenschutzseite der App: ratslotse.de/datenschutz (Quelle: web/frontend/app/datenschutz/page.tsx).