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.
Native App (SwiftUI)
Abschnitt betitelt „Native App (SwiftUI)“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.
xcodegen generate --spec ios/project.ymlswift test --package-path ios/Packages/RatslotseAPIxcodebuild -project ios/Ratslotse.xcodeproj -scheme Ratslotse \ -configuration Debug -destination 'platform=iOS Simulator,name=Ratslotse iPhone 17' \ CODE_SIGNING_ALLOWED=NO buildMobiler Plattformstand
Abschnitt betitelt „Mobiler Plattformstand“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.
Vom Next-Build zur App
Abschnitt betitelt „Vom Next-Build zur App“cd web/frontendnpm run build:mobile # = node scripts/build-mobile.mjsnpm run cap:sync # kopiert ./out in das Android-Projektnpm run cap:android # öffnet Android Studioscripts/build-mobile.mjs erledigt dabei drei Dinge, die ein nacktes
next build nicht kann:
- Statischer Export statt Server:
MOBILE=1 next buildschaltet innext.config.mjsaufoutput: "export"(+trailingSlash, unoptimierte Bilder) und schreibt nach./out— das istwebDirincapacitor.config.ts. - 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. - Eine Content-Security-Policy wird als
<meta http-equiv>in jede exportierte.htmlinjiziert, 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 |
Was die App vom Web unterscheidet
Abschnitt betitelt „Was die App vom Web unterscheidet“| 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 |
Offline
Abschnitt betitelt „Offline“Zwei getrennte Bausteine:
- Offline-Pille (
components/offline-pill.tsx): hört aufnavigator.onLineund dieonline/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 einenPersistQueryClientProvidergehä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.
Android-Erststart
Abschnitt betitelt „Android-Erststart“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/.
Anmeldung
Abschnitt betitelt „Anmeldung“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 alsSESSION_RENEW_WITHIN_MINUTES(Default 45 Tage,0schaltet 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 — keinSet-Cookiean 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), sowie401-Antworten. - App: Cookies helfen dort nicht. Stattdessen liefert
GET /api/auth/mean native Clients ein frisch datiertes Token, das der nativeAPIClientim 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.
Was ohne Konto sichtbar ist
Abschnitt betitelt „Was ohne Konto sichtbar ist“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.
Sign in with Apple
Abschnitt betitelt „Sign in with Apple“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_subbereits bekannt → Anmeldung in dieses Konto.- sonst: gleiche, von Apple bestätigte E-Mail vorhanden → verknüpfen
(
apple_subsetzen, offene Verifikation gilt als erledigt,pendingwirdactive). Private-Relay-Adressen sind dabei normale Adressen. - sonst: neues Konto, sofort
activeundemail_verified, mit Zufalls-Passwort-Hash undpassword_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).
Rollen und Status
Abschnitt betitelt „Rollen und Status“| 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
pendingals „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.
E-Mail-Adresse ändern
Abschnitt betitelt „E-Mail-Adresse ändern“Zweistufig, und zwar aus zwei verschiedenen Gründen:
- 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
subzum Konto gehören muss. - 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.
Was am Konto hängt
Abschnitt betitelt „Was am Konto hängt“Alle Konto-Daten liegen in ratslotse.sqlite (siehe
Architektur); Eigentum ist durchgängig über
owner_id = web_users.id modelliert.
Zustellkanal
Abschnitt betitelt „Zustellkanal“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). OhneRESEND_API_KEYwird der Versand still übersprungen. - Push über APNs (iOS, token-basiert mit
.p8— kein Firebase) und FCM v1 (Android) inkern/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_seenThemen und Ausschuss-Abos
Abschnitt betitelt „Themen und Ausschuss-Abos“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.
Benachrichtigungen: sechs Anlässe, vier Grenzen
Abschnitt betitelt „Benachrichtigungen: sechs Anlässe, vier Grenzen“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.
Die Warteschlange
Abschnitt betitelt „Die Warteschlange“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, bundledZwei 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.
Abzeichen
Abschnitt betitelt „Abzeichen“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/eventmeldet ein Ereignis (frage,sitzung,tourodermap_placemitkey) — idempotent, Unbekanntes wird still verworfen. Das Frontend ruft das fire-and-forget überreportBadgeEvent()(components/badges.tsx).newly_earnedenthält die Abzeichen, die in genau diesem GET neu dazukamen; sie werden dabei inearnedgeschrieben 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.
Onboarding
Abschnitt betitelt „Onboarding“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/onboardingliefert den Stand,POST /api/onboardingmerged erledigte Schritte dazu und/oder setztcelebrated.- 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/setupden 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).
Wer den Assistenten zu sehen bekommt
Abschnitt betitelt „Wer den Assistenten zu sehen bekommt“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.
Die Schritte
Abschnitt betitelt „Die Schritte“| # | 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).
Anzeigename und Konto löschen
Abschnitt betitelt „Anzeigename und Konto löschen“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, dessensubzum 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.
Datenschutz-relevante Punkte
Abschnitt betitelt „Datenschutz-relevante Punkte“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).