Manizales de Pie

Next.jsPostgreSQLTailwind CSS
Manizales de Pie — 1
Manizales de Pie — 2
Manizales de Pie — 3

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_at y expires_at en lugar de DELETE; 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)TablaQué es
Necesidadwork_orderEscombros, riesgo estructural, animales, suministros, agua
PuntositeAcopio, albergue, donación de sangre, veterinaria, agua, puesto de salud, censo
Servicioresource_offerVolquetas, camionetas, herramientas, bodega, transporte gratuito, maquinaria
Mascotaanimal_reportPerdida, encontrada o avistada
Grupovolunteer_callConvocatoria efímera de vecinos
Barrioneighborhood114 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() con pg_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_id y 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.