Manizales de Pie



Manizales de Pie is a live map answering one question for volunteers and neighbors after the 7.4 magnitude earthquake that struck Manizales and Villamaría (Caldas, Colombia) on 10 August 2026: "Where can I help today?" Everything in the codebase exists to surface actionable needs, verified shelters, and volunteer calls on the map within the first three seconds.
Architecture and Tech Stack
Core Architecture
- Framework: Next.js 16.3 (App Router, React 19, PPR with cacheComponents)
- Language: TypeScript 5.x (strict)
- Frontend: React 19, maplibre-gl 5.24 (via mapcn), shadcn/UI (Radix base) + coss/Base UI particles
- Database: PostgreSQL + PostGIS (Supabase), 11 tables, RLS policies, proximity RPCs
- Validation: Zod 4.4 (DTOs at the data layer boundary)
- UI: Tailwind CSS 4 (semantic tokens, triage palette, dark mode), lucide-react icons
- Auth: Supabase SSR (Google OAuth, server-side session refresh via getClaims)
Layered Architecture
The app follows a strict dependency rule: app/ → data/ → lib/. Pages and components never query Supabase directly; they call Data Access Layer (DAL) classes in data/<module>/. Each module ships four files: .dto.ts (Zod schemas), .policy.ts (pure authorization predicates), .dal.ts (server-only class with authenticated/public factories), .actions.ts (server actions that orchestrate). The data/site/ module is the reference implementation; new entities copy its shape exactly. Row-level security is the only guard for realtime channels—no sensitive column ever lands in a published table.
Request Flow
[diagram goes here — Phase 5 appends it as an HTML block right after this heading]
A typical "help today" request: browser loads the map page (Server Component) → calls SiteDAL.public().listPublished() → Supabase returns PostGIS points via site_public view (security_invoker=on) → map markers render with category chips → user taps a marker → detail sheet opens via WorkOrderDAL.public().get() → if claiming, a server action validates input, authorizes via policy, writes to work_order_update, revalidates the map. Realtime subscriptions push new pins to all clients without a server round-trip.
Key Features
Features at a Glance
- Live map — PostGIS-powered, category-filterable, "HOY" bar for active volunteer calls
- Verified shelters & needs — Curator queue approves geocoded pins before publishing; one-tap neighbor confirmation
- Work-order claim flow — Colour-coded triage (unclaimed/claimed/attended), 48-hour stale-claim release, contact revealed only to claimant
- Volunteer calls with slots — Ephemeral, time-boxed, auto-expiring
- Public reporting — Turnstile-protected forms, Nominatim geocoding, 50 m duplicate check
- Road-closure layer — Hand-curated (INVIAS API has no closures)
Live map with triage palette
The map is the product. It renders three entity types (sites, work orders, volunteer calls) with a semantic colour system: unclaimed (red), claimed (amber), attended (green), stale (muted). A fixed "HOY" bar surfaces volunteer calls ending today. Markers fan out on click to avoid overlap; detail sheets show contacts, access notes, and a claim button that writes a work_order_update row and triggers the derived-status RPC.
Verified shelters & needs
Coordinates from press reports and neighbour submissions are approximate. Every seed row inserts with published=false. A curator (or any neighbour via one tap, no account) confirms the location, flipping published and incrementing confirmed_count. No "curator-verified" badge exists—the project has no curator team; confirmed_count is the only trust signal.
Work-order claim flow
Claiming writes a work_order_update of kind claimed. A second claimed from a distinct phone paints the pin green (attended) but keeps it listed and contactable—help arriving ≠ household no longer needing help. still_needed outranks all prior help and resets to full red. Terminal states (closed_completed, closed_rejected) are curator-only and freeze the case against later entries.
Public reporting with duplicate guard
The report form geocodes via Nominatim, checks SiteDAL.findNearby(50m) before submitting, and requires Turnstile. Contact fields are optional but the form warns: "This address and phone will be public so volunteers can reach you."
Technical Highlights
Dependency-rule enforcement via lint
Custom ESLint rules forbid @/lib/supabase/admin in app/, literal Tailwind colours in components, and cross-layer imports. The rule app/ → data/ → lib/ is encoded in eslint.config.mjs; CI fails on violations.
Server actions as public POST endpoints
No action trusts the caller. Validation → authorization (policy) → mutation → output validation runs in every mutation. The DAL owns all authorization; actions only orchestrate.
Derived status, never direct writes
work_order.status is not writable by the app. A Postgres trigger (sync_work_order_state) derives it from work_order_update rows. This prevents a single bad actor from emptying the map.
Relocation bounded by barrio
Moving a pin is a neighbour's correction, not a curator privilege. canRelocate allows moves only inside the resolved barrio; the trigger re-derives neighborhood_id from the new point.
Stale-claim release via pg_cron
Claims auto-expire after 48 hours via a scheduled release_stale_claims() RPC, returning the pin to unclaimed.
Project Structure
app/ ├── (map)/ # Map layout + tabs (sites, needs, services, pets) │ ├── _components/ # Map workspace, markers, panels, popups │ ├── reportar/ # Public forms (site, need, service, pet) │ └── servicio|punto|necesidad|mascota/[id]/ # Detail pages ├── auth/ # Google login, callback, error ├── layout.tsx # Root layout, providers, CSP ├── globals.css # Semantic tokens, triage palette └── proxy.ts # Next 16 middleware (Supabase session refresh) data/ ├── site/ # Reference module: dto, policy, dal, actions ├── work_order/ # Claim flow, updates, derived status ├── resource_offer/ # Trucks, tools, free transport ├── animal/ # Lost/found pets ├── neighborhood/ # Barrio polygons, boundaries ├── geo/ # Relocation policy, geocoding └── user/ # require-user helper lib/ ├── supabase/ # client (browser), server (RSC), admin (DAL only) ├── labels.ts # All user-facing strings (ES-CO) — single source of truth ├── urgency.ts # Triage colour logic ├── geo.ts # PostGIS helpers ├── env.ts / env.server.ts # Validated config, build-time fail ├── log.ts # Structured logging, PII redaction └── tabs.ts # Tab definitions for map layout supabase/ ├── migrations/ # 30+ migrations, PostGIS, RLS, RPCs, triggers ├── seed.sql # Real names/needs from press (unpublished, coords unverified) └── barrios.sql # Official barrio polygons (SIG Alcaldía) components/ui/ # shadcn + mapcn primitives (owned by CLI — wrap, don't edit)
Impact and Scalability
- Designed for a single emergency event; no multi-tenancy, no offline/PWA (network failure is a documented accepted risk).
- Free-tier Supabase (2-project limit) — escape route is Supabase Pro if load spikes.
- MapLibre GL pinned to v5 (mapcn dependency); v6 breaks the default export.
- Phone numbers declared, not verified (no SMS OTP cost); Google account gives traceability, curator calls before verifying.
- Name excludes Villamaría deliberately; scope is Manizales proper.
Notes
Built on Next.js 16, React 19, Supabase/PostgreSQL, PostGIS, Tailwind CSS 4, maplibre-gl 5, Zod 4. Code is public on GitHub. For a deeper technical deep-dive, see the full documentation wiki.