~/jonasfink.dev
← cd ..

KSoKo – Brújula Social

2026 · Full-Stack Developer
ReactTypeScriptNode.jsExpress.jsMongoDBGemini APIAWS S3Docker
KSoKo – Brújula Social — screenshotKSoKo – Brújula Social — screenshotKSoKo – Brújula Social — screenshotKSoKo – Brújula Social — screenshot

Situación

En Kassel y en ciudades comparables, el conocimiento sobre ofertas familiares asequibles y servicios sociales de asesoramiento está disperso. Asesoramiento de deudas, ayuda contra adicciones, apoyo familiar, asesoramiento en asilo, oficinas públicas: repartidos en decenas de páginas individuales, portales municipales y PDFs. Precisamente las personas que más urgentemente necesitan esta información, familias y hogares en desventaja económica, son las que menos tiempo y conocimiento institucional tienen para reunirla. No existía una única superficie de bajo umbral que conectara «qué está pasando» (eventos, actividades) con «quién ayuda» (servicios de asesoramiento), y mucho menos una que permitiera guardar ambos en un calendario personal.

Tarea

El objetivo era una aplicación web, empezando por Kassel como ciudad piloto, que hiciera accesibles eventos y servicios de asesoramiento en un mapa y en listas filtrables, permitiera guardarlos en una biblioteca personal y un calendario, y ofreciera datos del proveedor como horarios de apertura, dirección y teléfono sin muro de pago ni fricción de ticketing. Los no-objetivos definieron la tarea casi tanto como los objetivos: nada de un «segundo Eventim» con enfoque comercial, ninguna interfaz fríamente burocrática — la aplicación tenía que resultar cálida y accesible. Técnicamente, la tarea consistía en diseñar un modelo de datos y una arquitectura que uniera tres fuentes de datos radicalmente distintas — actividades creadas manualmente, un calendario municipal de eventos obtenido por scraping y datos de asesoramiento aportados por socios — en una API de lectura consistente, sin duplicar lógica por tipo de contenido.

Acción

El sistema es una arquitectura MERN clásica de tres capas (Express 5, MongoDB/Mongoose 9, React 19 + TypeScript, Node.js) en la que una canalización de scraping/importación funciona junto a la API como productor de datos independiente y asíncrono, en lugar de formar parte de la ruta de petición. El cliente habla exclusivamente con la API de Express — nunca directamente con MongoDB, Cloudinary, S3 o Gemini —, de modo que la autorización, la validación (Zod) y el rate limiting se aplican en un único punto. En el modelo de datos, los tres tipos de contenido Activity, ScrapedEvent y Beratung se mantuvieron deliberadamente como modelos Mongoose independientes en lugar de una colección base común: comparten demasiado pocos campos como para justificar la herencia. Se unifican en cambio en la frontera de la API: GET /events fusiona Activity y ScrapedEvent en una lista ordenada por fecha, y un único modelo polimórfico Favorite (itemType + itemId mediante refPath de Mongoose, índice único compuesto) hace los tres igualmente guardables — un elemento guardado con fecha es así directamente la entrada de calendario, y un modelo Appointment separado se omitió a propósito. Las categorías funcionan como una lista blanca validada en lugar de claves foráneas, para que el filtrado siga siendo una simple consulta $in en vez de un populate() en cada controlador. Los geodatos son campo obligatorio en Activity y Beratung (GeoJSON Point con índice 2dsphere); en ScrapedEvent, donde la ciudad solo entrega el nombre del lugar como texto, la coordenada se completa de forma asíncrona mediante Nominatim. La autenticación usa un token de acceso JWT de vida corta (15 minutos, solo en memoria del cliente, nunca en localStorage) junto con una cookie httpOnly de refresh token rotatoria (7 días); cada refresh token pertenece a una familia, y reutilizar un token ya rotado revoca de inmediato toda la familia, la protección estándar contra un refresh token robado pero usado con retraso. Las contraseñas se cifran con bcrypt, los roles (user/creator/admin) pasan por una factoría de middleware requireRole(...) más un isDocOwner(Model) genérico para reglas de propietario-o-admin; los datos de los servicios de asesoramiento están deliberadamente restringidos a escritura adminOnly en lugar del rol creator más laxo usado para las actividades, porque un dato erróneo sobre un servicio de asesoramiento de deudas conlleva un riesgo distinto al de un evento erróneo. En el lado de los datos, tres canalizaciones independientes alimentan la misma superficie de lectura: un scraper cron diario contra el calendario municipal de eventos con upsert idempotente sobre externalId, un relleno de geocodificación contra Nominatim (actualmente alrededor del 60 % de los cerca de 3.180 lugares obtenidos por scraping se resuelven automáticamente) y una importación CSV de socios con su propio parser para organizaciones de asesoramiento. Un chatbot (POST /chat) ayuda a los usuarios a formular una necesidad con sus propias palabras y es deliberadamente utilizable también por invitados, porque el momento en que alguien pide ayuda suele ser exactamente el momento en que no quiere crear una cuenta. La estructura de respuesta convierte las salvaguardas en campos obligatorios en lugar de extensiones opcionales: cada respuesta lleva un handoff (un siguiente paso humano concreto), un disclaimer es obligatorio en temas financieros, de asilo o de salud, y una detección determinista de palabras clave para números de emergencia se ejecuta antes de cada llamada al modelo — si coincide, no se consulta ni la base de datos ni Gemini. Un filtro knownOnly() descarta cualquier ID devuelta por el modelo que no exista realmente, lo que excluye estructuralmente las recomendaciones alucinadas; si Gemini falla, una tabla determinista de palabras clave actúa como respaldo. La entrada por voz devuelve una transcripción literal y sin traducir para revisarla antes de enviarla — una traducción al alemán sería exactamente lo que el público objetivo de la función no podría verificar. Las cuentas nuevas aterrizan en un asistente de onboarding omitible; preferencesSetAt se fija también al omitirlo, para que «deliberadamente sin responder» siga siendo distinguible de «nunca preguntado», y el estado de los filtros vive por completo en la URL. Una revisión de seguridad destapó tres fallos silenciosos que no se notaban en funcionamiento normal: una configuración errónea de trust proxy que metía a todos los usuarios en un mismo saco para el rate limiter basado en IP detrás del proxy inverso, archivos temporales nunca limpiados en la ruta de error durante la subida de archivos, y un manejo de errores en cliente que reducía distintos errores de API a un único mensaje sin significado. La aplicación funciona como un stack Docker Compose de tres contenedores (MongoDB sin puerto publicado, cliente detrás de nginx), automatizado con GitHub Actions en un runner autoalojado en cada push a main.

Resultado

La aplicación es plenamente funcional para el alcance del MVP y está en funcionamiento productivo: los usuarios descubren actividades, eventos municipales y servicios de asesoramiento en el mapa y en listas filtradas por categoría, idioma, público objetivo y precio, los guardan en una biblioteca personal que sirve a la vez de calendario, y los servicios de asesoramiento curados por administradores ofrecen horarios de apertura, vías de contacto preferidas y documentos de solicitud descargables mediante enlaces S3 prefirmados de duración limitada. El chatbot ofrece tanto a invitados como a usuarios registrados un punto de entrada conversacional por texto o voz, con salvaguardas estructurales contra recomendaciones erróneas y una retención del historial de 90 días para usuarios registrados. Una suite de 80 tests sobre el ejecutor de pruebas integrado de Node.js cubre exactamente los puntos donde una regresión silenciosa sería cara: composición de filtros, mapeo de categorías, vocabularios cerrados, guardrails del chat, parsing de importación/scraping y matemática de calendario. Abierto y deliberadamente no ocultado: Gemini todavía funciona en el nivel gratuito, lo que debe cambiarse a un nivel de pago antes de tener usuarios reales por las posibles categorías especiales de datos personales (art. 9 RGPD); la política de privacidad existe como borrador sin revisión jurídica; las entradas de feedback actualmente solo llegan a la base de datos sin vía de entrega; y MongoDB funciona sin autenticación, algo solo defendible porque el puerto no está publicado y el host no es accesible públicamente.

Conclusiones clave

El ejecutor de pruebas integrado de Node.js resultó suficiente para un proyecto de este tamaño: ningún framework adicional, ninguna capa de configuración adicional. Patrones recurrentes como el elemento nativo <dialog> para el panel de filtros y el modal del chat ahorraron una librería de modales propia, y un cambio de tema sin ningún contexto de React (el propio DOM como estado, fijado mediante un script inline contra el flash of unstyled content) mostró que no todo estado global necesita un contexto. La mayor lección vino de la revisión de seguridad: la configuración errónea de trust proxy pasó desapercibida durante mucho tiempo porque solo se hace visible bajo carga multiusuario — una señal de que el rate limiting y middleware de infraestructura similar necesitan tests explícitos, no solo una mirada al código. Para un número creciente de usuarios, los siguientes pasos serían pasar la integración de Gemini a una cuota de pago con un tope de coste real, introducir un módulo central de validación de entorno que informe de credenciales de S3 o Gemini ausentes al arrancar en lugar de en la primera subida, y mejorar la tasa de acierto de la geocodificación mediante mejores consultas de respaldo en vez de mediante más código.

© 2026 Jonas Fink