KSoKo – Sozialer Kompass
Situation
In Kassel und vergleichbaren Städten verteilt sich das Wissen über kostengünstige Familienangebote und soziale Beratungsstellen. Schuldnerberatung, Suchthilfe, Familienhilfe, Asylberatung, Ämter verteilt über Dutzende Einzelseiten, Amtsportale und PDFs. Gerade die Menschen, die diese Informationen am dringendsten brauchen, Familien und finanziell benachteiligte Haushalte, haben am wenigsten Zeit und institutionelles Wissen, um sie zusammenzusuchen. Es gab keine einzige, niedrigschwellige Oberfläche, die „was ist los” (Veranstaltungen, Aktivitäten) mit „wer hilft” (Beratungsangebote) verbindet, geschweige denn eine, die beides in einem persönlichen Kalender speicherbar macht.
Aufgabe
Ziel war eine Webanwendung, startend mit Kassel als Pilotstadt, die Veranstaltungen und Beratungsangebote auf einer Karte und in filterbaren Listen zugänglich macht, das Speichern in eine persönliche Bibliothek und einen Kalender erlaubt und Anbieterdetails, wie Öffnungszeiten, Adresse, Telefon ohne Bezahlschranke oder Ticketing-Reibung liefert. Die Nicht-Ziele bestimmten die Aufgabe fast so sehr wie die Ziele selbst: kein „zweites Eventim” mit Kommerz-Fokus, keine kalt-bürokratische Oberfläche — die Anwendung musste warm und barrierearm wirken. Technisch bestand die Aufgabe darin, ein Datenmodell und eine Architektur zu entwerfen, die drei grundverschiedene Datenquellen — manuell angelegte Aktivitäten, ein gescrapter städtischer Veranstaltungskalender und partnerübermittelte Beratungsdaten — in einer konsistenten Lese-API zusammenführt, ohne Logik pro Inhaltstyp zu duplizieren.
Umsetzung
Das System ist eine klassische Drei-Schichten-MERN-Architektur (Express 5, MongoDB/Mongoose 9, React 19 + TypeScript, Node.js), bei der eine Scraping-/Import-Pipeline als eigenständiger, asynchroner Datenproduzent neben der API steht statt als Teil des Request-Pfads. Der Client spricht ausschließlich mit der Express-API — nie direkt mit MongoDB, Cloudinary, S3 oder Gemini —, sodass Autorisierung, Validierung (Zod) und Rate-Limiting an genau einer Stelle durchgesetzt werden. Für das Datenmodell blieben die drei Inhaltstypen Activity, ScrapedEvent und Beratung bewusst eigenständige Mongoose-Modelle statt einer gemeinsamen Basis-Collection — sie teilen zu wenige Felder, um Vererbung zu rechtfertigen. Vereinheitlicht werden sie stattdessen an der API-Grenze: GET /events verschmilzt Activity und ScrapedEvent zu einer datumssortierten Liste, und ein einziges polymorphes Favorite-Modell (itemType + itemId über Mongoose refPath, Compound-Unique-Index) macht alle drei gleichermaßen merkbar — ein gemerktes Item mit Datum ist damit direkt der Kalendereintrag, ein separates Appointment-Modell blieb bewusst aus. Kategorien laufen als geprüfte Whitelist statt Fremdschlüssel, damit Filterung eine einfache $in-Query bleibt statt populate() in jedem Controller. Geodaten sind Pflichtfeld bei Activity und Beratung (GeoJSON Point mit 2dsphere-Index); bei ScrapedEvent, wo die Stadt nur einen Ortsnamen als Text liefert, wird die Koordinate asynchron per Nominatim nachgetragen. Authentifizierung nutzt einen kurzlebigen JWT-Access-Token (15 Minuten, nur im Speicher des Clients, nie in localStorage) zusammen mit einem rotierenden, httpOnly Refresh-Token-Cookie (7 Tage); jeder Refresh-Token gehört zu einer Familie, und die Wiederverwendung eines bereits rotierten Tokens widerruft die gesamte Familie sofort, der Standardschutz gegen einen gestohlenen, aber verzögert eingesetzten Refresh-Token. Passwörter werden mit bcrypt gehasht, Rollen (user/creator/admin) laufen über eine requireRole(...)-Middleware-Factory plus ein generisches isDocOwner(Model) für Owner-oder-Admin-Regeln; Beratungsstellen-Daten sind bewusst auf adminOnly-Schreibrechte beschränkt statt der laxeren creator-Rolle für Aktivitäten, weil eine falsche Angabe zu einer Schuldnerberatung ein anderes Risiko ist als ein falsches Event. Auf der Datenseite speisen drei unabhängige Pipelines dieselbe Lese-Oberfläche: ein täglicher Cron-Scraper gegen den städtischen Veranstaltungskalender mit idempotentem Upsert auf externalId, ein Geocoding-Backfill gegen Nominatim (aktuell rund 60 % der rund 3.180 gescrapten Orte automatisch aufgelöst), und ein CSV-Partnerimport mit eigenem Parser für Beratungsorganisationen. Ein Chatbot (POST /chat) hilft Nutzern, einen Bedarf in eigenen Worten zu formulieren, und ist bewusst auch für Gäste nutzbar, weil der Moment, in dem jemand um Hilfe bittet, oft genau der Moment ist, in dem er kein Konto anlegen will. Die Antwortstruktur macht Schutzmechanismen zu Pflichtfeldern statt zu optionalen Erweiterungen: jede Antwort trägt ein handoff (einen konkreten menschlichen nächsten Schritt), ein disclaimer ist bei Finanz-, Asyl- oder Gesundheitsthemen zwingend, und eine deterministische Keyword-Erkennung für Notfallnummern läuft vor jedem Modellaufruf — trifft sie, wird weder Datenbank noch Gemini befragt. Ein knownOnly()-Filter verwirft jede vom Modell zurückgegebene ID, die nicht wirklich existiert, was halluzinierte Empfehlungen strukturell ausschließt; fällt Gemini aus, übernimmt eine deterministische Keyword-Tabelle als Fallback. Spracheingabe liefert ein wortgetreues, unübersetztes Transkript zur Kontrolle vor dem Absenden — eine deutsche Übersetzung wäre genau das, was die Zielgruppe des Features nicht verifizieren könnte. Neue Konten landen in einem überspringbaren Onboarding-Wizard; preferencesSetAt wird auch beim Überspringen gesetzt, damit „bewusst nicht beantwortet” von „noch nie gefragt” unterscheidbar bleibt, und der Filterzustand lebt vollständig in der URL. Eine Sicherheitsdurchsicht deckte drei stille Fehler auf, die im Normalbetrieb nicht auffielen: eine falsch konfigurierte trust-proxy-Einstellung, die den IP-basierten Rate-Limiter hinter dem Reverse-Proxy für alle Nutzer in einen gemeinsamen Topf warf, nie aufgeräumte Temp-Dateien auf dem Fehlerpfad beim Datei-Upload, und clientseitige Fehlerbehandlung, die unterschiedliche API-Fehler zu einer nichtssagenden Meldung zusammenfasste. Die Anwendung läuft als Drei-Container-Docker-Compose-Stack (MongoDB ohne veröffentlichten Port, Client hinter nginx), automatisiert über GitHub Actions auf einem Self-Hosted-Runner bei jedem Push auf main.
Ergebnis
Die Anwendung ist für den MVP-Umfang vollständig funktionsfähig und im produktiven Betrieb: Nutzer entdecken Aktivitäten, städtische Veranstaltungen und Beratungsangebote auf Karte und in nach Kategorie, Sprache, Zielgruppe und Preis gefilterten Listen, speichern sie in eine persönliche Bibliothek, die zugleich als Kalender dient, und Admin-kuratierte Beratungsstellen liefern Öffnungszeiten, bevorzugte Kontaktwege und herunterladbare Antragsdokumente über zeitlich begrenzte, presignte S3-Links. Der Chatbot bietet Gästen wie eingeloggten Nutzern einen konversationellen Einstieg per Text oder Stimme, mit strukturellen Schutzmechanismen gegen Falschempfehlungen und einer 90-Tage-Aufbewahrung des Verlaufs für eingeloggte Nutzer. Eine 80 Tests umfassende Suite auf Node.js’ eingebautem Testrunner deckt genau die Stellen ab, an denen eine stille Regression teuer wäre: Filterkomposition, Kategorie-Mapping, geschlossene Vokabulare, Chat-Guardrails, Import-/Scrape-Parsing und Kalendermathematik. Offen und bewusst nicht verborgen sind: Gemini läuft noch im kostenlosen Kontingent, was vor echten Nutzern wegen möglicher besonderer Kategorien personenbezogener Daten (Art. 9 DSGVO) auf ein bezahltes Tier umgestellt werden muss; die Datenschutzerklärung liegt als juristisch ungeprüfter Entwurf vor; Feedback-Einträge landen aktuell nur in der Datenbank ohne Zustellweg; und MongoDB läuft ohne Authentifizierung, was nur durch den nicht veröffentlichten Port und den nicht öffentlich erreichbaren Host vertretbar ist.
Fazit & Lessons Learned
Node.js’ eingebauter Testrunner erwies sich für ein Projekt dieser Größe als ausreichend — kein zusätzliches Framework, keine zusätzliche Konfigurationsebene. Wiederkehrende Muster wie das native <dialog>-Element für Filter-Panel und Chat-Modal sparten eine eigene Modal-Bibliothek, und ein Theme-Switch ganz ohne React-Context (das DOM selbst als State, gesetzt per Inline-Script gegen Flash-of-Unstyled-Content) zeigte, dass nicht jeder globale Zustand einen Context braucht. Die größte Lektion kam aus der Sicherheitsdurchsicht: Die trust-proxy-Fehlkonfiguration lief lange unbemerkt mit, weil sie erst unter Mehrnutzer-Last sichtbar wird — ein Hinweis darauf, dass Rate-Limiting und ähnliche Infrastruktur-Middleware explizite Tests brauchen, nicht nur einen Blick auf den Code. Für wachsende Nutzerzahlen wäre der nächste Schritt, die Gemini-Anbindung auf ein kostenpflichtiges Kontingent mit echtem Kostendeckel umzustellen, ein zentrales Env-Validierungsmodul einzuführen, das fehlende S3- oder Gemini-Zugangsdaten beim Start statt beim ersten Upload meldet, und die Geocoding-Trefferquote durch bessere Fallback-Suchanfragen statt durch mehr Code zu verbessern.