Manizales de Pie

Next.jsSupabasePostgreSQLTailwind 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 albergues verificados y las convocatorias de voluntarios aparezcan en el mapa en los primeros tres segundos.

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), 11 tablas, políticas RLS, RPCs de proximidad
  • 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

[diagram goes here — Phase 5 appends it as an HTML block right after this heading]

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 para canales realtime—ninguna columna sensible llega a una tabla publicada.

Flujo de una Petición

[diagram goes here — Phase 5 appends it as an HTML block right after this heading]

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() → al reclamar, una server action valida input, autoriza vía policy, escribe en work_order_update, revalida el mapa. Suscripciones realtime empujan nuevos pines a todos los clientes sin round-trip al servidor.

Características Principales

Funciones a Vista Rápida

  • Mapa en vivo — PostGIS, filtrable por categoría, barra "HOY" para convocatorias activas
  • Albergues y necesidades verificados — Cola de curador aprueba pines geocodificados antes de publicar; confirmación de vecino en un toque
  • Flujo de reclamación de órdenes — Triaje por color (no reclamada/reclamada/atendida), liberación automática a 48 h, contacto revelado solo al reclamante
  • Convocatorias de voluntarios con turnos — Efímeras, acotadas en tiempo, auto-expiran
  • Reporte público — Formularios con Turnstile, geocodificación Nominatim, chequeo de duplicados a 50 m
  • Capa de cierres viales — Curada a mano (la API de INVIAS no trae cierres)

Mapa en vivo con paleta de triaje

El mapa es el producto. Renderiza tres tipos de entidad (sitios, órdenes, convocatorias) con un sistema de color semántico: unclaimed (rojo), claimed (ámbar), attended (verde), stale (apagado). Una barra "HOY" fija muestra convocatorias que terminan hoy. Los marcadores se abren en abanico al click para evitar solape; las hojas de detalle muestran contactos, notas de acceso y un botón de reclamar que escribe una fila en work_order_update y dispara el RPC de estado derivado.

Albergues y necesidades verificados

Las coordenadas de reportes de prensa y envíos de vecinos son aproximadas. Cada fila seed inserta con published=false. Un curador (o cualquier vecino con un toque, sin cuenta) confirma la ubicación, volcando published e incrementando confirmed_count. No existe badge de "verificado por curador"—el proyecto no tiene equipo de curadores; confirmed_count es la única señal de confianza.

Flujo de reclamación de órdenes

Reclamar escribe un work_order_update de tipo claimed. Una segunda claimed de un teléfono distinto pinta el pin verde (attended) pero lo mantiene listado y contactable—que llegue ayuda ≠ que el hogar ya no la necesite. still_needed supera toda ayuda previa y vuelve a rojo intenso. Estados terminales (closed_completed, closed_rejected) son solo de curador y congelan el caso contra entradas posteriores.

Reporte público con guarda contra duplicados

El formulario geocodifica vía Nominatim, verifica SiteDAL.findNearby(50m) antes de enviar, y exige Turnstile. Los campos de contacto son opcionales pero el formulario advierte: "Esta dirección y teléfono serán públicos para que los voluntarios puedan contactarte."

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 vía pg_cron

Las reclamaciones expiran a 48 h vía RPC programado release_stale_claims(), devolviendo el pin a unclaimed.

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)

Impacto y Escalabilidad

  • Diseñado para una única emergencia; sin multi-tenancy, sin offline/PWA (fallo de red es riesgo aceptado documentado).
  • 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 Villamaría deliberadamente; el alcance es Manizales propiamente.

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. Para un deep-dive técnico completo, ver la wiki de documentación.