Skip to content

omar-brome/kharta

Repository files navigation

Kharta — خارطة

A crowdsourced, real-time map of essential services in Lebanon.

Kharta (Arabic for map) lets people report and verify the live status of the services that matter most during shortages and crises: fuel stations, pharmacies, bakeries, hospitals, and water sources. Anyone can check the map; anyone with a Lebanese phone number can report "this station is open, 95 and diesel available" or "this bakery is out of bread" — and neighbors confirm or dispute each report.

Demo

Demo: zooming into Beirut, opening a fuel station, reporting low stock with a note, the report appearing live, then flipping the whole UI to Arabic RTL

🎬 Desktop demo video (MP4, 35 s)  ·  📱 Mobile demo video (MP4, 35 s)

Screenshots

Map — English Map — Arabic (full RTL)
Map view in English Map view in Arabic RTL
Service detail + community reports Status report form
Service detail panel Report status form

Mobile map view in Arabic    Mobile bottom sheet with service detail

Regenerate these assets anytime with the stack running: cd frontend && npm i --no-save playwright && npx playwright install chromium && node scripts/capture-media.mjs (then convert with ffmpeg — see scripts/capture-media.mjs header).

Why

Lebanon's compounding crises made basic logistics a daily struggle: which station has fuel today, which pharmacy still stocks medication, which bakery has bread, which hospital is taking patients. That information exists — distributed across millions of people — but evaporates in WhatsApp groups within minutes. Kharta turns it into a live, self-correcting public map:

  • Crowdsourced: every report comes from someone on the ground.
  • Self-expiring: a fuel report dies after 3 hours; a hospital report after 12. The map never silently lies about freshness — stale statuses turn grey and ask for an update.
  • Self-moderating: confirmations build reporter reputation; trusted users' downvotes and flags auto-hide junk.
  • Bilingual: full Arabic (RTL) and English UI.
  • Built for bad networks: PWA with offline caching, tiny app shell, skeleton screens, compressed GeoJSON.

Stack

Layer Tech
Frontend React 18, TypeScript, Vite, Tailwind CSS, MapLibre GL JS + OpenFreeMap tiles (no API key), TanStack Query, Zustand, i18next, vite-plugin-pwa
Backend Python 3.11, FastAPI, SQLAlchemy 2.0 (async), Pydantic v2, python-jose (JWT)
Data PostgreSQL 15 + PostGIS (geospatial), Redis 7 (cache + rate limiting + OTP)
Infra Docker Compose, Alembic migrations, Gunicorn/Uvicorn
frontend (5173) ──/api──▶ backend (8000) ──▶ postgres + postgis (5432)
                                   └───────▶ redis (6379)

Prerequisites

  • Docker Desktop (or Docker Engine + Compose v2) — the easy path; nothing else needed.
  • For manual (non-Docker) setup: Python 3.11+, Node 20+, PostgreSQL 15 with the PostGIS extension available, Redis 7.

No map API key is required: tiles come from OpenFreeMap (free, no account, no usage limits).

Quick start (Docker)

cp .env.example .env
make up                       # build + start postgres, redis, backend, frontend
make migrate                  # apply database migrations
make seed                     # 30 demo service points across Lebanon

Open http://localhost:5173 — the map of Lebanon with seeded fuel stations, pharmacies, bakeries, hospitals and water points. API docs: http://localhost:8000/docs.

Log in with any Lebanese-format number (e.g. 70 123 456); in development the OTP code is always 1234 — no SMS is sent.

No make on Windows? Use the underlying commands:

docker compose up -d --build --wait
docker compose exec backend alembic upgrade head
docker compose exec backend python -m scripts.seed

Other targets: make logs, make down, make reset (wipes volumes, rebuilds, re-migrates, re-seeds).

Map tiles (no API key needed)

The map uses MapLibre GL JS (the open-source fork of Mapbox GL) with vector tiles from OpenFreeMap — free for any use, no account, no credit card, no request limits. Attribution to OpenStreetMap contributors is rendered automatically by the map style.

Want a different look or provider? Set VITE_MAP_STYLE to any MapLibre-compatible style URL:

  • OpenFreeMap variants (still keyless): https://tiles.openfreemap.org/styles/bright, …/styles/positron
  • MapTiler / Mapbox / Stadia styles (these need their own API keys)
  • A fully self-hosted style — OpenFreeMap publishes its tile data if you want zero third-party dependencies in production.

Running without Docker (manual setup)

1. Postgres + Redis. Install PostgreSQL 15 with PostGIS and Redis 7, then:

CREATE USER kharta WITH PASSWORD 'kharta_dev_password';
CREATE DATABASE kharta OWNER kharta;
\c kharta
CREATE EXTENSION postgis;
CREATE EXTENSION "uuid-ossp";

2. Backend.

cd backend
python -m venv .venv && . .venv/bin/activate    # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env                            # adjust DATABASE_URL / REDIS_URL
alembic upgrade head
python -m scripts.seed
uvicorn app.main:app --reload                   # http://localhost:8000

3. Frontend.

cd frontend
npm install
cp .env.example .env                            # add VITE_MAPBOX_TOKEN
npm run dev                                     # http://localhost:5173

The dev server proxies /api/* to http://localhost:8000 (configurable via VITE_API_PROXY_TARGET).

Environment variables

Root .env (consumed by docker-compose.yml):

Variable Default Purpose
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB kharta / kharta_dev_password / kharta Database credentials
JWT_SECRET dev placeholder Change for any real deployment. Signs access tokens
ADMIN_PHONES +96170000001 Comma-separated phones granted admin on login
ENV development production disables fixed OTP + /docs
VITE_MAP_STYLE OpenFreeMap liberty Optional override for the map style URL

Backend-only (see backend/.env.example): DATABASE_URL, REDIS_URL, CORS_ORIGINS, JWT_EXPIRES_DAYS, OTP_DEV_CODE, OTP_TTL_SECONDS, MAP_CACHE_TTL_SECONDS, RATE_LIMIT_ENABLED, SQL_ECHO.

Frontend-only (see frontend/.env.example): VITE_MAP_STYLE, VITE_API_PROXY_TARGET, VITE_API_URL.

How it works

Status reports and expiry

Every report carries expires_at, set server-side by category — the half-life of the information:

Category Report lives for
Fuel 3 h
Bakery 4 h
Pharmacy 6 h
Water 8 h
Hospital 12 h

Expired reports stop coloring the map (the point goes grey/"unknown") and the service sheet shows a stale warning asking visitors to submit an update.

Reputation

  • A report confirmed by 2+ upvotes earns its author +1 reputation (awarded once per report, processed as a background task after votes land).
  • At 20 reputation a user becomes trusted.

Moderation

  • 3 downvotes from trusted users auto-hide a report.
  • 2 flags from trusted users auto-hide a report.
  • Admins (ADMIN_PHONES) can list flagged reports (GET /admin/flags), hide/unhide any report (PATCH /admin/reports/{id}), and verify service points (PATCH /admin/services/{id}/verify) so they show a "verified" badge.
  • Users can't vote on their own reports. Banned users (is_banned) cannot log in.

Abuse resistance

  • All POST endpoints are rate-limited via Redis: 10/min per IP anonymous, 30/min per user authenticated, plus 10 reports/user/hour and 3 OTP requests/phone/10 min. Redis outages fail open (availability first).
  • OTP codes are stored hashed with a 5-minute TTL and 5 attempts max.
  • Coordinates for new locations must fall inside Lebanon's bounding box.

Performance (the /map/services hot path)

  • PostGIS GIST index + ST_DWithin geography query, single round-trip with a LEFT JOIN LATERAL for each point's latest active report, capped at 1000 features.
  • Responses cached in Redis for 60 s per quantized viewport (center rounded to ~1 km, radius to 1 km, categories sorted), gzip-compressed.
  • Frontend: map stays mounted across navigation, data refetches are quantized and deduplicated by TanStack Query, statuses auto-refresh every 60 s.
  • PWA service worker: app shell precached, map tiles cache-first (7 days), map data network-first with offline fallback to the last snapshot.

Privacy

Phone numbers are the only PII collected. They are never exposed in any public API payload (reports show display names only), never written to client-side logs, and masked (+961*****123) in server logs.

API overview

Method & path Auth Description
POST /auth/request-otp {phone}{otp_id}; dev code is 1234
POST /auth/verify-otp {otp_id, code}{access_token, user}
GET /auth/me Current profile
GET /map/services?lat&lng&radius_km&categories GeoJSON FeatureCollection with latest statuses (cached 60 s)
GET /map/services/{id} Full detail + last 5 reports
POST /map/services Submit new (unverified) service point
POST /reports Submit status report (10/h per user)
POST /reports/{id}/vote {vote: "up"|"down"}
POST /reports/{id}/flag Flag for moderation
GET /admin/flags admin List flagged reports
PATCH /admin/reports/{id} admin Set is_hidden
PATCH /admin/services/{id}/verify admin Mark verified

Interactive docs with schemas: http://localhost:8000/docs (development only).

Contributing

Adding a new service category

Example: adding clinic.

  1. Backend constants — add CLINIC = "clinic" to Category in backend/app/constants.py, and its expiry window to EXPIRY_HOURS in backend/app/services/expiry.py.
  2. Migration — the category list is enforced by a DB check constraint: docker compose exec backend alembic revision -m "add clinic category", then in the migration drop and recreate ck_service_points_category with the new tuple.
  3. Frontend — add 'clinic' to the Category union in frontend/src/types/index.ts and to CATEGORIES, CATEGORY_HEX, CATEGORY_BG, CATEGORY_TEXT in frontend/src/lib/constants.ts; add an icon in components/icons.tsx (CATEGORY_ICONS).
  4. Translations — add categories.clinic to all four files under frontend/src/i18n/{en,ar}/map.json. Both languages are required.
  5. Seed (optional) — add demo points in backend/scripts/seed.py.

The map layers, filters, pins, and forms all derive from those constants — no other code changes needed.

Ground rules

  • Every user-facing string goes through i18next, in both en and ar files. Test with the language toggled — RTL layout uses logical (start/end) utilities.
  • All DB access through SQLAlchemy bound parameters — never interpolate values into SQL.
  • Never log phone numbers (client or server); use mask_phone server-side if needed.
  • Keep /map/services fast: anything added there must stay index-friendly and cacheable.

Going to production

The dev setup makes two deliberate simplifications you must replace:

  1. SMS deliverybackend/app/services/otp.py:send_sms is a stub. Wire it to Twilio (or a local SMS gateway with better Lebanon delivery rates) and set ENV=production so codes are random instead of 1234.
  2. Secrets — generate a strong JWT_SECRET and front the API with a reverse proxy (the backend image already runs Gunicorn with Uvicorn workers). Tiles need no key; for full independence you can self-host OpenFreeMap's published tile data.

Also worth doing: pin CORS_ORIGINS to your real origin, schedule a periodic DELETE FROM status_reports WHERE expires_at < now() - interval '30 days' if you don't want history to grow unbounded, and add monitoring on /health.

Known trade-offs / next steps

  • Two columns were added beyond the original schema sketch: users.is_admin (admin authorization) and status_reports.reputation_awarded (idempotent reputation awards).
  • No automated test suite yet — the highest-value additions would be API tests for the vote/moderation/reputation flows and the expiry utility.
  • Admin actions are API-only (use /docs); a small admin UI is a natural next step.
  • WebSocket/SSE push for live status changes would remove the 60 s polling interval.

License

MIT © 2026 Omar Brome

About

Crowdsourced live map of essential services in Lebanon — fuel, pharmacies, bakeries, hospitals, water. خارطة: خريطة حية للخدمات الأساسية في لبنان

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors