Manizales de Pie



Manizales de Pie es un mapa en vivo que responde a una sola pregunta para voluntarios y vecinos tras el sismo de magnitud 7,4 que golpeó Manizales y Villamaría (Caldas, Colombia) el 10 de agosto de 2026: "¿Dónde ayudo hoy?" Todo en la base de código existe para que las necesidades reales, los puntos de acopio, los servicios disponibles y las mascotas perdidas aparezcan en el mapa en los primeros tres segundos — sin registro, sin instalar nada.
Arquitectura y Stack Tecnológico
Arquitectura Central
- Framework: Next.js 16.3 (App Router, React 19, PPR con cacheComponents)
- Lenguaje: TypeScript 5.x (strict)
- Frontend: React 19, maplibre-gl 5.24 (vía mapcn), shadcn/UI (base Radix) + partículas coss/Base UI
- Base de datos: PostgreSQL + PostGIS (Supabase), 12 tablas, RLS en todas, vistas públicas
security_invoker, RPCs de proximidad sobre índices GiST - Validación: Zod 4.4 (DTOs en la frontera de la capa de datos)
- UI: Tailwind CSS 4 (tokens semánticos, paleta de triaje, modo oscuro), iconos lucide-react
- Auth: Supabase SSR (Google OAuth, refresco de sesión server-side vía getClaims)
Arquitectura en Capas
La app sigue una regla de dependencia estricta: app/ → data/ → lib/. Páginas y componentes nunca consultan Supabase directamente; llaman a clases de la Data Access Layer (DAL) en data/<módulo>/. Cada módulo entrega cuatro archivos: .dto.ts (esquemas Zod), .policy.ts (predicados puros de autorización), .dal.ts (clase server-only con fábricas autenticada/pública), .actions.ts (server actions que orquestan). El módulo data/site/ es la implementación de referencia; nuevas entidades copian su forma exactamente. Row-level security es la única guarda entre el navegador y los datos, así que ninguna columna sensible llega jamás a una tabla o vista publicada.
Flujo de una Petición
Una petición típica "ayuda hoy": el navegador carga la página del mapa (Server Component) → llama SiteDAL.public().listPublished() → Supabase devuelve puntos PostGIS vía vista site_public (security_invoker=on) → los marcadores renderizan con chips de categoría → el usuario toca un marcador → hoja de detalle abre vía WorkOrderDAL.public().get() → si el visitante reacciona ("voy en camino", "ya ayudé"), una server action valida el input, autoriza vía policy, añade una fila a work_order_update y revalida el mapa. Un trigger de Postgres vuelve a derivar el estado del caso desde ese log append-only — la app nunca escribe status por su cuenta.
Características Principales
Funciones a Vista Rápida
- Mapa en vivo, cuatro capas — Puntos (acopio, albergue, sangre, veterinaria, agua, salud, censo), necesidades, servicios y mascotas, filtrables por barrio
- Reporte público — Cualquiera reporta una necesidad, una mascota perdida u ofrece un recurso sin cuenta; geocodificación con Nominatim y chequeo de duplicados a 50 m antes de insertar
- Hilo de actualizaciones por caso — Cuatro reacciones (
on_the_way,helped,still_needed,not_real) que cualquiera puede añadir; el estado del caso se deriva de ellas, nunca se escribe a mano - Nada se borra, solo se enfría —
published=false,confirmed_atyexpires_aten lugar deDELETE; lo obsoleto se degrada, no se elimina - Cierre solo de curador — Un caso nunca se cierra automáticamente: solo un curador lo congela, así ningún toque anónimo puede vaciar el mapa
- Login con Google para trazabilidad — Leer y reportar no piden cuenta; curar sí
Mapa en vivo con paleta de triaje
El mapa es el producto. Renderiza cuatro tipos de entidad — site (puntos), work_order (necesidades), resource_offer (servicios) y animal_report (mascotas) — bajo un sistema de color semántico: unclaimed (rojo), claimed (ámbar), attended (verde) y apagado para lo obsoleto. Ningún componente puede escribir un color de Tailwind a mano: solo tokens semánticos como bg-unclaimed o bg-layer-shelter, y una regla de lint rompe el build si no. Tocar un marcador abre una hoja de detalle con contacto, notas de acceso y los botones de reacción.
Albergues y necesidades verificados
Las coordenadas que vienen de prensa e instituciones (Alcaldía, Bomberos, Cruz Roja) son aproximadas a nivel de barrio. Cada fila seed entra con published=false y solo llega al mapa cuando alguien la geocodifica y la confirma. La frescura es una columna de primera clase: confirmed_at y expires_at viven en cada tabla perecedera, y una tabla append-only confirmation registra cada "sigue vigente / cambió / ya no aplica" sin sobrescribir jamás la respuesta anterior.
El hilo de actualizaciones de una necesidad
Una necesidad no es un ticket que alguien posee; es un hilo al que cualquiera puede sumar. Existen cuatro tipos de reacción: on_the_way pinta el pin de ámbar, helped lo pinta verde pero lo mantiene listado y contactable (que llegue ayuda ≠ que el hogar ya no la necesite), still_needed supera toda ayuda previa y lo devuelve a rojo, y not_real queda registrado sin cerrar nada. Los estados terminales son solo de curador. Esa asimetría es deliberada: un solo actor malintencionado tocando "ya ayudé" no puede hacer desaparecer a un hogar del mapa.
Reporte público con guarda contra duplicados
El formulario geocodifica vía Nominatim y llama a find_nearby_work_orders(lng, lat, 50) antes de insertar, para que el mismo muro caído no acabe siendo cinco pines. Los datos de contacto son públicos por diseño, y el formulario lo dice antes de que los escribas. Una versión anterior ocultaba el teléfono hasta que alguien reclamaba el caso; esa barrera no protegía a nadie e impedía que quien tiene una volqueta llamara antes de comprometerse, así que se quitó.
Destacados Técnicos
Regla de dependencia forzada por lint
Reglas ESLint custom prohíben @/lib/supabase/admin en app/, colores Tailwind literales en componentes, e imports entre capas. La regla app/ → data/ → lib/ está codificada en eslint.config.mjs; CI falla en violaciones.
Server actions como endpoints POST públicos
Ninguna action confía en el llamante. Validación → autorización (policy) → mutación → validación de salida corre en toda mutación. La DAL posee toda la autorización; las actions solo orquestan.
Estado derivado, nunca escritura directa
work_order.status no es escribible por la app. Un trigger Postgres (sync_work_order_state) lo deriva de filas work_order_update. Esto impide que un solo actor malintencionado vacíe el mapa.
Relocalización acotada al barrio
Mover un pin es corrección de un vecino, no privilegio de curador. canRelocate permite mover solo dentro del barrio resuelto; el trigger re-deriva neighborhood_id del nuevo punto.
Liberación de reclamaciones estancadas
release_stale_claims() devuelve un pin a unclaimed tras 48 h sin avance, para que una reclamación que no va a ningún lado no deje una necesidad aparcada para siempre. El RPC ya está en las migraciones; falta programarlo con pg_cron.
Estructura del Proyecto
app/ ├── (map)/ # Layout del mapa + tabs (sitios, necesidades, servicios, mascotas) │ ├── _components/ # Workspace del mapa, marcadores, paneles, popups │ ├── reportar/ # Formularios públicos (sitio, necesidad, servicio, mascota) │ └── servicio|punto|necesidad|mascota/[id]/ # Páginas de detalle ├── auth/ # Login Google, callback, error ├── layout.tsx # Layout raíz, providers, CSP ├── globals.css # Tokens semánticos, paleta de triaje └── proxy.ts # Middleware Next 16 (refresh de sesión Supabase) data/ ├── site/ # Módulo referencia: dto, policy, dal, actions ├── work_order/ # Flujo de reclamación, updates, estado derivado ├── resource_offer/ # Camiones, herramientas, transporte gratuito ├── animal/ # Mascotas perdidas/encontradas ├── neighborhood/ # Polígonos de barrio, límites ├── geo/ # Política de relocalización, geocodificación └── user/ # Helper require-user lib/ ├── supabase/ # client (navegador), server (RSC), admin (solo DAL) ├── labels.ts # Todos los strings user-facing (ES-CO) — única fuente de verdad ├── urgency.ts # Lógica de color de triaje ├── geo.ts # Helpers PostGIS ├── env.ts / env.server.ts # Config validada, fallo en build ├── log.ts # Logging estructurado, ofuscación PII └── tabs.ts # Definiciones de tabs para layout del mapa supabase/ ├── migrations/ # 30+ migraciones, PostGIS, RLS, RPCs, triggers ├── seed.sql # Nombres/necesidades reales de prensa (unpublished, coords no verificadas) └── barrios.sql # Polígonos oficiales de barrio (SIG Alcaldía) components/ui/ # Primitivas shadcn + mapcn (propiedad del CLI — wrap, no editar)
Vocabulario del Producto
La interfaz habla es-CO y la base de datos habla inglés; lib/labels.ts es el único sitio donde los dos se cruzan, así que ningún componente escribe a mano un texto que ve el usuario.
| UI (es-CO) | Tabla | Qué es |
|---|---|---|
| Necesidad | work_order | Escombros, riesgo estructural, animales, suministros, agua |
| Punto | site | Acopio, albergue, donación de sangre, veterinaria, agua, puesto de salud, censo |
| Servicio | resource_offer | Volquetas, camionetas, herramientas, bodega, transporte gratuito, maquinaria |
| Mascota | animal_report | Perdida, encontrada o avistada |
| Grupo | volunteer_call | Convocatoria efímera de vecinos |
| Barrio | neighborhood | 114 límites reales del SIG de la Alcaldía |
"Disponible" se eligió sobre "abierto/cerrado" a propósito: lo segundo se lee como horario de atención, que no es lo que significa el campo.
Qué Sigue
- Cola de curación en
/admin— la pieza que falta con más impacto: editar, verificar, fusionar y publicar en una sola pantalla - Convocatorias (
volunteer_call) y cierres viales (closed_road) existen en el esquema; sus capas en el mapa aún no están construidas - Suscripciones realtime: RLS ya las hace seguras de encender, el cliente todavía no se suscribe
- Programar
release_stale_claims()conpg_cron
Impacto y Escalabilidad
- Diseñado para una única emergencia; sin multi-tenancy, sin offline/PWA. Si el sismo tumba la red, esta app no sirve — se decidió deliberadamente, con el costo sobre la mesa.
- Supabase free-tier (límite 2 proyectos) — ruta de escape es Supabase Pro si hay pico de carga.
- MapLibre GL anclado a v5 (dependencia mapcn); v6 rompe el export default.
- Teléfonos declarados, no verificados (sin coste SMS OTP); cuenta Google da trazabilidad, curador llama antes de verificar.
- El nombre excluye a Villamaría, que también tiene víctimas. El municipio existe en el esquema, pero sin límites de barrio no obtiene
neighborhood_idy por tanto no tiene filtro — un hueco conocido, no un descuido.
Notas
Construido sobre Next.js 16, React 19, Supabase/PostgreSQL, PostGIS, Tailwind CSS 4, maplibre-gl 5, Zod 4. Código público en GitHub.