Manizales de Pie

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

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 put actionable needs, collection points, available services and lost pets on the map within the first three seconds — no signup, no app install, no download.

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), 12 tables, RLS on every one, security_invoker public views, proximity RPCs over GiST indexes
  • 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 between the browser and the data, so no sensitive column ever lands in a published table or view.

Request Flow

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 the visitor reacts ("on my way", "I helped"), a server action validates input, authorizes via policy, appends a row to work_order_update, and revalidates the map. A Postgres trigger re-derives the case status from that append-only log — the app never writes status itself.

Key Features

Features at a Glance

  • Live map, four layers — Points (collection, shelter, blood, vet, water, medical, census), needs, services and pets, filterable by barrio
  • Public reporting — Anyone reports a need, a lost pet or offers a resource without an account; Nominatim geocoding and a 50 m duplicate check before insert
  • Update thread per case — Four reactions (on_the_way, helped, still_needed, not_real) that anyone can add; the case status is derived from them, never written by hand
  • Nothing is deleted, only cooled — published=false, confirmed_at and expires_at instead of DELETE; stale entries get demoted, not erased
  • Curator-only closure — A case never auto-closes: only a curator freezes it, so no anonymous tap can empty the map
  • Google sign-in for traceability — Reading and reporting need no account; curation does

Live map with triage palette

The map is the product. It renders four entity types — site (points), work_order (needs), resource_offer (services) and animal_report (pets) — under a semantic colour system: unclaimed (red), claimed (amber), attended (green), stale (muted). Components may not hardcode a Tailwind colour: only semantic tokens like bg-unclaimed or bg-layer-shelter, and a lint rule fails the build otherwise. Tapping a marker opens a detail sheet with contact, access notes and the reaction buttons.

Verified shelters & needs

Coordinates from press reports and institutions (Alcaldía, Bomberos, Red Cross) are approximate at barrio level. Every seed row inserts with published=false and only reaches the map once someone geocodes and confirms it. Freshness is a first-class column: confirmed_at and expires_at live on every perishable table, and an append-only confirmation table records each "still valid / it changed / no longer valid" without ever overwriting the previous answer.

The update thread on a need

A need is not a ticket someone owns; it is a thread anyone can add to. Four reaction kinds exist: on_the_way paints the pin amber, helped paints it green but keeps it listed and contactable (help arriving ≠ household no longer needing help), still_needed outranks every prior help and resets it to red, and not_real is recorded without closing anything. Terminal states are curator-only. That asymmetry is deliberate: a single bad actor tapping "helped" cannot make a household disappear from the map.

Public reporting with duplicate guard

The report form geocodes via Nominatim and calls find_nearby_work_orders(lng, lat, 50) before inserting, so the same collapsed wall does not become five pins. Contact details are public by design, and the form says so before you type them. An earlier version hid the phone number until someone claimed the case; that gate protected nobody and stopped the person with a dump truck from calling before committing, so it was removed.

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

release_stale_claims() returns a pin to unclaimed after 48 hours without progress, so a claim that goes nowhere does not park a need forever. The RPC ships in the migrations; scheduling it on pg_cron is still pending.

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)

Product Vocabulary

The UI speaks es-CO and the database speaks English; lib/labels.ts is the single place where the two meet, so no component ever hardcodes a user-facing string.

UI (es-CO)TableWhat it is
Necesidadwork_orderDebris, structural risk, animals, supplies, water
PuntositeCollection point, shelter, blood donation, vet, water, medical post, census
Servicioresource_offerDump trucks, pickups, tools, warehouse, free transport, machinery
Mascotaanimal_reportLost, found or sighted
Grupovolunteer_callEphemeral neighbour call-out
Barrioneighborhood114 real boundaries from the city's GIS

"Disponible" was chosen over "abierto/cerrado" on purpose: the latter reads as opening hours, which is not what the field means.

What's Next

  • /admin curation queue — the highest-leverage missing piece: edit, verify, merge and publish in one screen
  • Volunteer calls (volunteer_call) and road closures (closed_road) exist in the schema; their map layers are not built yet
  • Realtime subscriptions: RLS already makes them safe to turn on, the client is not subscribing yet
  • pg_cron scheduling for release_stale_claims()

Impact and Scalability

  • Designed for a single emergency event; no multi-tenancy, no offline/PWA. If the quake takes the network down, the app is useless — that was decided deliberately, with the trade-off on the table.
  • 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.
  • The name excludes Villamaría, which also has victims. The municipality exists in the schema, but without barrio boundaries it gets no neighborhood_id and therefore no filter — a known gap, not an oversight.

Notes

Built on Next.js 16, React 19, Supabase/PostgreSQL, PostGIS, Tailwind CSS 4, maplibre-gl 5, Zod 4. Code is public on GitHub.


© 2026 Felipe Giraldo