From 8be71c8aeb916bb5e91aa2405e7cc6efedb73971 Mon Sep 17 00:00:00 2001 From: Prajnadeep Sarma Date: Thu, 18 Jun 2026 01:19:36 +0530 Subject: [PATCH 01/32] Implement backend: API, algorithms, data layer, docs, CI Build out the GoldenHour backend per the backend PRD, replacing the stub endpoints and empty service files with a working, tested service. Core: - Thin FastAPI routes (main.py) -> services/ -> store.py data layer - schemas.py mirrors API_CONTRACT.md exactly (incl. SMS "from" alias) - Three algorithms: hospital ranking (prox/dept/rel score), blood matching (ABO table + 5km radius + 90-day cooldown), confirmation race resolution (first-to-accept wins, already_confirmed) - Emergency orchestration + live status with simulated donor responses Data layer (two interchangeable stores behind get_store()): - InMemoryStore seeded with 25 Jaipur hospitals + 180 donors (demo/CI) - SupabaseStore using PostGIS donors_nearby RPC; auto-fallback on init failure so boot never breaks - sql/schema.sql: PostGIS tables, GiST indexes, donors_nearby() RPC - seed_supabase.py to load the same dataset into a real DB Infra & docs: - Dockerfile, docker-compose.yml, .env.example, .gitignore - CI wired to actually run pytest (3.11/3.12); pr-guard checks for conflict markers - README (root/backend/frontend), index.html meta/description/OG tags - 24 pytest tests (algorithms + endpoint contract + live flow + SupabaseStore via fake client), all passing offline Co-Authored-By: Claude Opus 4.8 --- .github/workflows/backend-tests.yml | 4 +- .github/workflows/pr-guard.yml | 10 +- README.md | 204 ++++++++++++- backend/.dockerignore | 9 + backend/.env.example | 35 +++ backend/.gitignore | 31 ++ backend/Dockerfile | 20 ++ backend/README.md | 224 ++++++++++++++ backend/blood.py | 33 ++ backend/config.py | 78 +++++ backend/geo.py | 28 ++ backend/main.py | 164 +++++++--- backend/middleware.py | 18 +- backend/pytest.ini | 8 + backend/requirements-dev.txt | 3 + backend/schemas.py | 101 +++++++ backend/seed_data.py | 129 ++++++++ backend/seed_supabase.py | 74 +++++ backend/services/__init__.py | 6 + backend/services/cache.py | Bin 6 -> 1715 bytes backend/services/confirm_service.py | Bin 6 -> 1648 bytes backend/services/donor_service.py | Bin 6 -> 1746 bytes backend/services/emergency_service.py | Bin 6 -> 4143 bytes backend/services/geocoder.py | Bin 6 -> 3049 bytes backend/services/hospital_service.py | Bin 6 -> 2490 bytes backend/services/phone_utils.py | Bin 6 -> 1525 bytes backend/services/rate_limiter.py | Bin 6 -> 1276 bytes backend/services/realtime_service.py | Bin 6 -> 1240 bytes backend/services/sms_service.py | Bin 6 -> 3708 bytes backend/sql/schema.sql | 113 +++++++ backend/store.py | 420 ++++++++++++++++++++++++++ backend/tests/__init__.py | 0 backend/tests/conftest.py | 26 ++ backend/tests/fake_supabase.py | 127 ++++++++ backend/tests/test_api.py | 124 ++++++++ backend/tests/test_supabase_store.py | 75 +++++ backend/tests/test_units.py | 75 +++++ docker-compose.yml | 17 ++ frontend/README.md | 110 ++++++- frontend/index.html | 33 +- frontend/package.json | 16 +- 41 files changed, 2235 insertions(+), 80 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/blood.py create mode 100644 backend/config.py create mode 100644 backend/geo.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-dev.txt create mode 100644 backend/schemas.py create mode 100644 backend/seed_data.py create mode 100644 backend/seed_supabase.py create mode 100644 backend/services/__init__.py create mode 100644 backend/sql/schema.sql create mode 100644 backend/store.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/fake_supabase.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_supabase_store.py create mode 100644 backend/tests/test_units.py create mode 100644 docker-compose.yml diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 6388447..1553d2f 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -27,8 +27,8 @@ jobs: run: | cd backend python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements-dev.txt - name: Run Tests run: | cd backend - # pytest (if tests are added) + pytest -q diff --git a/.github/workflows/pr-guard.yml b/.github/workflows/pr-guard.yml index 4b53465..26ca74d 100644 --- a/.github/workflows/pr-guard.yml +++ b/.github/workflows/pr-guard.yml @@ -9,7 +9,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Check for merge conflicts + - name: Fail if leftover conflict markers are present run: | - echo "Checking merge conflicts..." - # Add conflict check logic here + echo "Scanning for unresolved merge conflict markers..." + if git grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude).github/**'; then + echo "::error::Unresolved merge conflict markers found. Resolve them before merging." + exit 1 + fi + echo "No conflict markers found." diff --git a/README.md b/README.md index 9484b4e..9a0924d 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,201 @@ # GoldenHour -GoldenHour is a Progressive Web Application and feature-phone SMS service that converts a single GPS-triggered emergency request into two simultaneous parallel actions: -1. Locating and confirming a hospital with the correct department and a free bed. -2. Alerting nearby replacement-blood donors of a compatible blood group. +> One tap. Two lifelines. The right hospital and the right blood — before the car even moves. -It is built for the self-transporting family in India — the majority of emergency patients who travel to hospital by private car or auto, entirely outside any ambulance system. +**GoldenHour** is a Progressive Web App + feature-phone SMS service that converts +a single GPS-triggered emergency request into two simultaneous, parallel actions: -## Project Structure +1. **Locate and confirm a hospital** with the correct department and a free bed. +2. **Alert nearby replacement-blood donors** of a compatible blood group. -- `backend/`: FastAPI backend with PostgreSQL + PostGIS (via Supabase) orchestrating routing, confirmation logic, and donor matching. -- `frontend/`: React + Vite + Tailwind CSS PWA containing the Patient App, Hospital Confirmation Page, and Donor Registration interfaces. +It is built for the **self-transporting family in India** — the majority of +emergency patients who travel to hospital by private car or auto, entirely +outside any ambulance system. The "golden hour" after a trauma is when minutes +decide outcomes; GoldenHour spends those minutes finding a hospital that can +actually take the patient instead of driving to one that can't. -## Setup +**Tags:** `Healthcare Technology` · `Social Impact` · `Bharat Academix CodeQuest 2026` · `FastAPI` · `React` · `Supabase` · `PostGIS` -See `backend/README.md` and `frontend/README.md` (to be created) for specific setup instructions. +--- -## Tags -Healthcare Technology, Social Impact, Bharat Academix CodeQuest 2026, FastApi, React, Supabase, PostGIS +## How it works (end-to-end) + +``` + ┌──────────────────────────────────────────────────────────────────────┐ + │ PATIENT'S PHONE (PWA) │ + │ "Use my location" → emergency type + blood group → [ GET HELP ] │ + └───────────────────────────────────┬──────────────────────────────────┘ + │ POST /emergency { lat,lng,type,group } + ▼ + ┌──────────────────────────────────────────────────────────────────────┐ + │ GoldenHour API (FastAPI) │ + │ │ + │ ┌────────────────────────┐ ┌───────────────────────────┐ │ + │ │ Hospital ranking │ │ Blood matching │ │ + │ │ PostGIS radius + score │ │ ABO table + 5km + cooldown│ │ + │ └───────────┬────────────┘ └──────────────┬────────────┘ │ + │ │ top N hospitals │ compatible donors │ + │ ▼ ▼ │ + │ create confirmation_requests (1 link/hospital) count donors_alerted │ + └───────┬───────────────────────────────────┬───────────────────────────┘ + │ confirmation link │ live status + ▼ ▼ + ┌────────────────────────┐ ┌──────────────────────────────────────┐ + │ HOSPITAL CONTACT │ │ Back on the PATIENT'S phone │ + │ /confirm/:token │ │ /results/:id — cards flip live │ + │ [Accept] / [Decline] │──────► │ pending → CONFIRMED (poll/Realtime) │ + └────────────────────────┘ └──────────────────────────────────────┘ +``` + +The first hospital to **Accept** "takes" the patient; later acceptances are told +the patient is already routed. The patient's screen updates live the instant a +hospital confirms. + +--- + +## System architecture + +GoldenHour is a two-part system split cleanly by ownership, sharing one contract: + +``` +┌─────────────────────────────┐ ┌──────────────────────────────────────┐ +│ frontend/ (React PWA) │ HTTP │ backend/ (FastAPI) │ +│ 4 screens, mobile-first │ ──────► │ routes → services → data layer │ +│ React Router · Tailwind │ JSON │ │ +└─────────────────────────────┘ │ ┌────────────────────────────────┐ │ + ▲ │ │ store.py │ │ + │ Supabase Realtime / │ │ • in-memory (demo, seeded) │ │ + │ polling for live updates │ │ • Supabase + PostGIS (prod) │ │ + └──────────────────────────┤ └────────────────────────────────┘ │ + │ external (prod only): │ + │ Google Maps ETA · MSG91/Gupshup SMS │ + └──────────────────────────────────────┘ + shared: API_CONTRACT.md (exact request/response shapes) +``` + +- **`API_CONTRACT.md`** is the single source of truth both sides build against. + The backend's `schemas.py` and the frontend's `fetch()` calls mirror it exactly. +- **The frontend never talks to the database** — only to the API. +- **The backend runs with zero external services in demo mode** (in-memory store + seeded with Jaipur hospitals + donors), and swaps to Supabase/PostGIS, Google + Maps, and an SMS gateway when their env vars are present — no code changes. + +See [`backend/README.md`](backend/README.md) for the service-layer architecture +and the three algorithms, and [`frontend/README.md`](frontend/README.md) for the +screens and live-update wiring. + +--- + +## Repository structure + +``` +GoldenHour/ +├── API_CONTRACT.md # SHARED contract — exact API request/response shapes +├── README.md # You are here +├── LICENSE +├── render.yaml # Backend deploy config (Render) +├── vercel.json # Frontend deploy config (Vercel) +├── docker-compose.yml # One-command local backend (demo mode) +│ +├── backend/ # FastAPI service — DB, algorithms, integration +│ ├── main.py # Routes (thin HTTP layer) +│ ├── middleware.py # CORS +│ ├── config.py # Env-driven settings + demo fallbacks +│ ├── schemas.py # Pydantic models (= API_CONTRACT.md) +│ ├── blood.py # ABO/Rh compatibility table +│ ├── geo.py # Haversine distance +│ ├── store.py # Data layer (InMemoryStore + SupabaseStore) +│ ├── seed_data.py # ~25 hospitals + ~180 donors (deterministic) +│ ├── seed_supabase.py # Seed a real Supabase DB +│ ├── services/ # Business logic (ranking, matching, confirm, …) +│ ├── sql/schema.sql # PostGIS tables, indexes, RPC +│ ├── tests/ # pytest (algorithms + contract + live flow) +│ ├── Dockerfile +│ └── .env.example +│ +├── frontend/ # React + Vite + Tailwind PWA +│ ├── index.html # Shell + meta/description/OG/PWA tags +│ ├── src/ +│ │ ├── App.jsx # Mobile shell + routes +│ │ └── components/ # PatientIntake, PatientResults, HospitalConfirm, DonorRegistration +│ ├── vite.config.js +│ └── package.json +│ +└── .github/workflows/ # CI: backend tests, frontend CI, PR guard +``` + +--- + +## Tech stack + +| Layer | Technology | +| --- | --- | +| Frontend | React 19, Vite, Tailwind CSS, React Router, Supabase JS client | +| Backend | Python 3.12, FastAPI, Uvicorn, Pydantic v2 | +| Database | PostgreSQL + PostGIS (via Supabase), Supabase Realtime | +| Maps / ETA | Google Maps Distance Matrix API | +| Messaging | Console / Telegram (demo), MSG91 / Gupshup + DLT (production) | +| Infra | Docker, Render (API), Vercel (PWA), GitHub Actions CI | + +--- + +## Quickstart + +### Backend (demo mode — no setup, no database) + +```bash +cd backend +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 +``` + +Open , trigger an emergency, and watch the hospital +confirmation links print to the console (or hit `GET /dev/links`). Run the tests +with `pytest`. + +Or, with Docker, from the repo root: `docker compose up --build`. + +### Frontend + +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +Point it at the API by creating `frontend/.env.local` with +`VITE_API_BASE_URL=http://localhost:8000`. + +--- + +## API at a glance + +Full shapes in [`API_CONTRACT.md`](API_CONTRACT.md). + +| Method & path | Purpose | +| --- | --- | +| `POST /emergency` | Trigger — rank hospitals, alert donors, send confirmation links | +| `GET /emergency/{request_id}/status` | Live status — hospital replies + donor responses | +| `POST /confirm/{token}` | Hospital taps Accept / Not Available | +| `POST /donor/register` | Register a replacement-blood donor | +| `POST /sms/inbound` | Feature-phone SMS path (stretch) | + +--- + +## Deployment + +| Component | Platform | Config | +| --- | --- | --- | +| Backend API | Render (or Fly.io) | [`render.yaml`](render.yaml) — Python runtime, `rootDir: backend` | +| Frontend PWA | Vercel | [`vercel.json`](vercel.json) — builds `frontend/`, SPA rewrites | +| Database | Supabase | Run [`backend/sql/schema.sql`](backend/sql/schema.sql), enable Realtime | + +Production secrets (`SUPABASE_*`, `GOOGLE_MAPS_API_KEY`, `MSG91_AUTH_KEY`, +`VITE_API_BASE_URL`) are set in the respective dashboards — never committed. + +--- + +## License + +[MIT](LICENSE). diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..a990f74 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.env +.venv/ +venv/ +tests/ +*.md diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..7e7fae5 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,35 @@ +# GoldenHour backend configuration. +# Copy to `.env` and fill in to enable each subsystem. With everything blank the +# API runs in demo mode (in-memory store, estimated ETAs, console link delivery). + +# --- Supabase / PostgreSQL + PostGIS --------------------------------------- +# Set both to switch the data layer onto the real database. +SUPABASE_URL= +SUPABASE_ANON_KEY= +# Direct Postgres connection (used by seed_supabase.py / migrations). +DATABASE_URL= + +# --- Google Maps Distance Matrix (real drive-time ETAs) -------------------- +GOOGLE_MAPS_API_KEY= + +# --- SMS gateway (MSG91 / Gupshup) — stretch goal -------------------------- +MSG91_AUTH_KEY= + +# --- Confirmation link delivery -------------------------------------------- +# Public URL of the frontend; confirmation links are FRONTEND_URL/confirm/ +FRONTEND_URL=http://localhost:5173 +# console | telegram | msg91 +DELIVERY_CHANNEL=console +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= + +# --- CORS ------------------------------------------------------------------ +# Comma-separated allowed origins; * for local dev. +CORS_ORIGINS=* + +# --- Algorithm tuning (optional; sensible defaults baked in) ---------------- +RANKING_TOP_N=5 +ETA_MAX_MINUTES=30 +DONOR_RADIUS_METERS=5000 +DONOR_COOLDOWN_DAYS=90 +CITY_AVG_SPEED_KMPH=22 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..fb0ed8c --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +build/ +dist/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Test / tooling caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Environment / secrets — never commit real keys +.env +.env.* +!.env.example + +# Editor / OS +.vscode/ +.idea/ +*.swp +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e32b277 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +# GoldenHour API — single-stage image. One-command setup per the PRD. +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +# Install deps first for better layer caching. +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +# App code. +COPY . . + +EXPOSE 8000 + +# Render/Fly inject $PORT; default to 8000 locally. +CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..cab3a73 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,224 @@ +# GoldenHour — Backend + +FastAPI service that turns one GPS-triggered emergency into two parallel actions: + +1. **Find a hospital** with the right department and a free bed, ranked best-first, and ask each to confirm. +2. **Alert nearby compatible blood donors** who are off their post-donation cooldown. + +It owns everything the user doesn't see: the database, the ranking and matching +algorithms, the confirmation/messaging flow, and live updates to the frontend. + +> **Runs with zero setup.** With no environment variables the API boots in +> **demo mode** — an in-memory store seeded with ~25 Jaipur hospitals and ~180 +> donors, estimated ETAs, and console link delivery. Add Supabase / Google Maps +> / SMS credentials to switch each subsystem to the real service. No code +> changes, no database required for the demo or for CI. + +--- + +## Tech stack + +| Piece | Tool | Why | +| --- | --- | --- | +| Language | Python 3.12 | — | +| API framework | FastAPI + Uvicorn | Async, fast, auto OpenAPI docs | +| Validation | Pydantic v2 | Request/response shapes = the API contract | +| Database (prod) | PostgreSQL + PostGIS via Supabase | Geography radius queries + Realtime | +| Maps / ETA (prod) | Google Maps Distance Matrix | Real drive-time ETA | +| Phone parsing | `phonenumbers` | Normalise donor numbers to E.164 | +| Container | Docker | One-command setup | + +--- + +## Architecture + +A strict three-layer design — HTTP in, services in the middle, data at the bottom: + +``` + HTTP (FastAPI routes) ── main.py + │ validate with schemas.py, call ONE service, return a schema + ▼ + Service layer ── services/ + │ ranking · matching · confirmation · orchestration · ETA · SMS + ▼ + Data layer ── store.py + in-memory now (seed_data.py) · Supabase-ready (sql/schema.sql) +``` + +**Rules that keep it clean:** + +- **Routes are thin.** Every handler in `main.py` validates input with a schema, + calls exactly one service function, and returns a schema. No business logic. +- **Services never touch HTTP.** They take a `store` + plain values and return + plain dicts, so they're trivially unit-testable without a client. +- **All data access goes through `store.py`.** Its method surface mirrors what a + Supabase-backed store would expose (`compatible_donors_nearby`, + `create_emergency`, …), so the demo store and a real DB store are + interchangeable behind `get_store()`. +- **Schemas are the contract.** `schemas.py` mirrors `API_CONTRACT.md` field for + field. Change one, change both, tell the frontend owner. + +### Two interchangeable stores + +`store.py` ships **both** a `InMemoryStore` and a `SupabaseStore` implementing the +identical method surface; `get_store()` picks one from configuration (Supabase +when `SUPABASE_URL`/`SUPABASE_ANON_KEY` are set and reachable, in-memory +otherwise — with automatic fallback if the client can't be created, so boot +never hard-fails). The radius + scoring maths is identical whether it runs in +Python (haversine) or PostGIS (`ST_DWithin`/`ST_Distance`), so the demo runs +end-to-end with no external services, CI needs no database, and the production +path (`sql/schema.sql` + `seed_supabase.py`) is a true drop-in — no service code +changes. + +--- + +## Repository structure + +``` +backend/ +├── main.py # FastAPI app + routes (thin HTTP layer) +├── middleware.py # CORS (origins from settings) +├── config.py # Settings from env; demo-mode fallbacks +├── schemas.py # Pydantic request/response models (= API_CONTRACT.md) +├── blood.py # ABO/Rh compatibility table +├── geo.py # Haversine distance helpers +├── store.py # Data layer: InMemoryStore + SupabaseStore + get_store() +├── seed_data.py # Deterministic ~25 hospitals + ~180 donors +├── seed_supabase.py # Push seed data into a real Supabase DB +├── services/ +│ ├── emergency_service.py # Orchestrates POST /emergency + status +│ ├── hospital_service.py # Hospital ranking algorithm +│ ├── donor_service.py # Blood matching + donor registration +│ ├── confirm_service.py # Accept/decline race resolution +│ ├── geocoder.py # ETA (Google Maps or estimate) + area geocode +│ ├── sms_service.py # Link delivery + inbound SMS parse +│ ├── realtime_service.py # Supabase Realtime publish (no-op in demo) +│ ├── phone_utils.py # E.164 phone normalisation +│ ├── cache.py # Tiny TTL cache +│ └── rate_limiter.py # Fixed-window rate limiter +├── sql/ +│ └── schema.sql # PostGIS tables, indexes, donors_nearby() RPC +├── tests/ # pytest: units + endpoint/contract/flow tests +├── requirements.txt +├── Dockerfile +└── .env.example +``` + +--- + +## API endpoints + +All shapes are defined by [`../API_CONTRACT.md`](../API_CONTRACT.md). Interactive +docs are served at `/docs` (Swagger) and `/redoc` when the app is running. + +| Method & path | Purpose | +| --- | --- | +| `POST /emergency` | Trigger an emergency — rank hospitals, alert donors, send confirmation links. | +| `GET /emergency/{request_id}/status` | Live status — hospital replies + donor responses (poll or Supabase Realtime). | +| `POST /confirm/{token}` | A hospital contact taps Accept (`true`) / Not Available (`false`). | +| `POST /donor/register` | Register a replacement-blood donor. | +| `POST /sms/inbound` | Feature-phone path (stretch): parse an SMS, reply with nearest hospitals. | +| `GET /health` | Liveness + which mode the app is running in. | +| `GET /dev/links` | Demo helper: the confirmation links recently "sent" to hospitals. | + +--- + +## The three algorithms + +**1. Hospital ranking** (`services/hospital_service.py`) — for the needed +department, score each nearby hospital and return the top N: + +``` +P(h) = 0.5 · prox(h) + 0.3 · dept(h) + 0.2 · rel(h) + +prox(h) = 1 − (eta / eta_max), clamped to [0, 1] # closer is better +dept(h) = 1 if the needed department is present else 0 +rel(h) = avg_response_rate # historically reliable +``` + +**2. Blood matching** (`services/donor_service.py` + `blood.py`) — compatible +donor groups (hardcoded ABO table), available, within 5 km, and off the 90-day +cooldown (`last_donated IS NULL OR last_donated < today − 90d`), nearest first. + +**3. Confirmation flow** (`services/confirm_service.py`) — the first hospital to +accept "takes" the patient; any later acceptance gets `already_confirmed: true` +and does not override the winner. The accepted hospital's status flips +`pending → confirmed`, which the frontend sees via polling or Realtime. + +--- + +## Running it + +### Local (demo mode, no setup) + +```bash +cd backend +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 +``` + +Open . Trigger an emergency and watch the +confirmation links print to the console (or hit `GET /dev/links`). + +### Docker + +```bash +# from repo root +docker compose up --build # backend on :8000 + +# or just the backend image +cd backend && docker build -t goldenhour-api . && docker run -p 8000:8000 goldenhour-api +``` + +### With a real Supabase + Maps stack + +1. Create a Supabase project; run [`sql/schema.sql`](sql/schema.sql) in the SQL editor. +2. Enable **Realtime** on the `confirmation_requests` table. +3. Copy `.env.example` → `.env` and fill in `SUPABASE_URL`, `SUPABASE_ANON_KEY`, + `GOOGLE_MAPS_API_KEY` (and `MSG91_AUTH_KEY` for SMS). +4. Seed the database: `python seed_supabase.py`. +5. Start the app — `GET /health` now reports `"mode": "supabase"`. + +--- + +## Configuration + +Everything is environment-driven (see [`.env.example`](.env.example)). Highlights: + +| Variable | Default | Effect | +| --- | --- | --- | +| `SUPABASE_URL`, `SUPABASE_ANON_KEY` | — | Set both → use the real database. | +| `GOOGLE_MAPS_API_KEY` | — | Set → real drive-time ETAs (else estimated). | +| `DELIVERY_CHANNEL` | `console` | `console` \| `telegram` \| `msg91`. | +| `FRONTEND_URL` | `http://localhost:5173` | Base for `/confirm/` links. | +| `CORS_ORIGINS` | `*` | Comma-separated allowed origins. | +| `DONOR_RADIUS_METERS` | `5000` | Donor search radius. | +| `DONOR_COOLDOWN_DAYS` | `90` | Post-donation ineligibility window. | +| `RANKING_TOP_N` | `5` | Hospitals returned per emergency. | + +--- + +## Testing + +```bash +cd backend +pip install -r requirements-dev.txt +pytest # 24 tests: algorithms + endpoint contract + live flow + Supabase path +``` + +The suite runs fully offline (no Supabase, no network) and verifies the exact +`API_CONTRACT.md` response shapes, the ranking/matching algorithms, and the +end-to-end emergency → confirm → status flip. The **`SupabaseStore` production +path is also tested** against a fake supabase client +(`tests/fake_supabase.py`), so column names, query chains, and key mapping are +verified without a live database. CI runs the same suite on Python 3.11 and 3.12. + +--- + +## Deployment + +[`../render.yaml`](../render.yaml) defines the Render web service (Python runtime, +`rootDir: backend`, `uvicorn main:app`). Set the `SUPABASE_*`, `GOOGLE_MAPS_API_KEY`, +and `MSG91_AUTH_KEY` env vars in the Render dashboard. The same Docker image runs +on Fly.io. diff --git a/backend/blood.py b/backend/blood.py new file mode 100644 index 0000000..879ece6 --- /dev/null +++ b/backend/blood.py @@ -0,0 +1,33 @@ +"""ABO/Rh blood-group compatibility. + +For a patient who *needs* a given blood group, which donor groups can safely +donate? This is the hardcoded "ABO table" referenced in the backend PRD. It is +intentionally a plain dictionary — there is no clinical edge case here worth a +database lookup, and keeping it in code makes the donor query trivially fast. +""" +from __future__ import annotations + +from typing import List + +ALL_GROUPS: List[str] = ["O-", "O+", "A-", "A+", "B-", "B+", "AB-", "AB+"] + +# recipient blood group -> list of donor groups that may donate to them +_COMPATIBLE_DONORS = { + "O-": ["O-"], + "O+": ["O-", "O+"], + "A-": ["O-", "A-"], + "A+": ["O-", "O+", "A-", "A+"], + "B-": ["O-", "B-"], + "B+": ["O-", "O+", "B-", "B+"], + "AB-": ["O-", "A-", "B-", "AB-"], + "AB+": ALL_GROUPS, # universal recipient +} + + +def compatible_donor_groups(recipient_group: str) -> List[str]: + """Donor blood groups that can give to ``recipient_group``. + + Unknown groups return an empty list so callers fail safe (alert nobody) + rather than alerting everybody. + """ + return _COMPATIBLE_DONORS.get(recipient_group, []) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..01c1cea --- /dev/null +++ b/backend/config.py @@ -0,0 +1,78 @@ +"""Application configuration. + +All runtime configuration is read from environment variables so the same image +runs locally, in CI, and in production (Render/Fly) without code changes. + +Nothing here is required: with an empty environment the app boots in +**demo mode** — an in-memory store seeded with Jaipur hospitals and donors, a +haversine ETA estimator, and console link delivery. Set the Supabase / Google +Maps / MSG91 variables to switch each subsystem over to the real service. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import List + + +def _env_list(name: str, default: str) -> List[str]: + raw = os.getenv(name, default) + return [item.strip() for item in raw.split(",") if item.strip()] + + +@dataclass(frozen=True) +class Settings: + # --- Core --------------------------------------------------------------- + app_name: str = "GoldenHour API" + version: str = "0.1.0" + + # --- Supabase / PostgreSQL + PostGIS ----------------------------------- + # When both are present the app talks to the real database; otherwise it + # falls back to the in-memory store (demo mode). + supabase_url: str = os.getenv("SUPABASE_URL", "") + supabase_key: str = os.getenv("SUPABASE_ANON_KEY", "") + database_url: str = os.getenv("DATABASE_URL", "") + + # --- Google Maps Distance Matrix (ETA + geocoding) --------------------- + google_maps_api_key: str = os.getenv("GOOGLE_MAPS_API_KEY", "") + + # --- SMS gateway (MSG91 / Gupshup) — stretch goal ---------------------- + msg91_auth_key: str = os.getenv("MSG91_AUTH_KEY", "") + + # --- Algorithm tuning --------------------------------------------------- + ranking_top_n: int = int(os.getenv("RANKING_TOP_N", "5")) + eta_max_minutes: float = float(os.getenv("ETA_MAX_MINUTES", "30")) + donor_radius_meters: float = float(os.getenv("DONOR_RADIUS_METERS", "5000")) + donor_cooldown_days: int = int(os.getenv("DONOR_COOLDOWN_DAYS", "90")) + city_avg_speed_kmph: float = float(os.getenv("CITY_AVG_SPEED_KMPH", "22")) + + # --- Link / message delivery ------------------------------------------- + # Where the hospital confirmation link points. The frontend serves + # /confirm/, so this is the public site URL. + frontend_url: str = os.getenv("FRONTEND_URL", "http://localhost:5173") + # "console" (print the link), "telegram", or "msg91". + delivery_channel: str = os.getenv("DELIVERY_CHANNEL", "console") + telegram_bot_token: str = os.getenv("TELEGRAM_BOT_TOKEN", "") + telegram_chat_id: str = os.getenv("TELEGRAM_CHAT_ID", "") + + # --- CORS --------------------------------------------------------------- + cors_origins: List[str] = field( + default_factory=lambda: _env_list("CORS_ORIGINS", "*") + ) + + @property + def use_supabase(self) -> bool: + """True when a real Supabase backend is configured.""" + return bool(self.supabase_url and self.supabase_key) + + @property + def use_google_maps(self) -> bool: + return bool(self.google_maps_api_key) + + def summary(self) -> str: + mode = "supabase" if self.use_supabase else "in-memory (demo)" + eta = "google-maps" if self.use_google_maps else "haversine-estimate" + return f"store={mode} eta={eta} delivery={self.delivery_channel}" + + +settings = Settings() diff --git a/backend/geo.py b/backend/geo.py new file mode 100644 index 0000000..1d9d9bd --- /dev/null +++ b/backend/geo.py @@ -0,0 +1,28 @@ +"""Geospatial helpers. + +In production the radius queries run inside PostGIS (``ST_DWithin`` / +``ST_Distance``). In demo mode we reproduce the same maths in Python with the +haversine formula so the app behaves identically without a database. +""" +from __future__ import annotations + +import math + +EARTH_RADIUS_M = 6_371_000.0 + + +def haversine_meters(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + """Great-circle distance between two lat/lng points, in metres.""" + phi1, phi2 = math.radians(lat1), math.radians(lat2) + d_phi = math.radians(lat2 - lat1) + d_lambda = math.radians(lng2 - lng1) + + a = ( + math.sin(d_phi / 2) ** 2 + + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2 + ) + return 2 * EARTH_RADIUS_M * math.asin(math.sqrt(a)) + + +def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + return haversine_meters(lat1, lng1, lat2, lng2) / 1000.0 diff --git a/backend/main.py b/backend/main.py index e2b2bc0..2598d50 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,65 +1,133 @@ -from fastapi import FastAPI -from pydantic import BaseModel -from typing import List, Optional +"""GoldenHour API — FastAPI application entrypoint. + +Thin HTTP layer: each route validates input with a schema, calls one service, +and returns a schema. All business logic lives in ``services/``; all data access +lives in ``store.py``. Endpoint shapes match ``API_CONTRACT.md`` exactly. +""" +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException + +from config import settings from middleware import add_cors_middleware +from schemas import ( + DonorRegisterRequest, + DonorRegisterResponse, + EmergencyRequest, + EmergencyResponse, + EmergencyStatusResponse, + HealthResponse, + HospitalConfirmRequest, + HospitalConfirmResponse, + SmsInboundRequest, + SmsInboundResponse, +) +from store import get_store + +from services import confirm_service, donor_service, emergency_service, sms_service + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("goldenhour") -app = FastAPI(title="GoldenHour API") + +@asynccontextmanager +async def lifespan(_app: FastAPI): + get_store() # warm the store (seed in-memory, or connect Supabase) + logger.info("GoldenHour API ready - %s", settings.summary()) + yield + + +app = FastAPI( + title=settings.app_name, + version=settings.version, + description=( + "Emergency hospital routing + replacement-blood donor alerting. " + "One GPS-triggered request finds a hospital with the right department " + "and a free bed, and alerts nearby compatible donors." + ), + lifespan=lifespan, +) add_cors_middleware(app) -class EmergencyRequest(BaseModel): - lat: float - lng: float - emergency_type: str - blood_group: str -class HospitalConfirmRequest(BaseModel): - accepted: bool +@app.get("/", tags=["meta"]) +async def root(): + return {"service": settings.app_name, "version": settings.version, "docs": "/docs"} -class DonorRegisterRequest(BaseModel): - name: str - phone: str - blood_group: str - lat: float - lng: float - last_donated: Optional[str] = None -class SmsInboundRequest(BaseModel): - from_number: str - body: str +@app.get("/health", response_model=HealthResponse, tags=["meta"]) +async def health_check(): + mode = "supabase" if settings.use_supabase else "in-memory" + return HealthResponse(status="ok", version=settings.version, mode=mode) -@app.post("/emergency") + +@app.post("/emergency", response_model=EmergencyResponse, tags=["emergency"]) async def trigger_emergency(request: EmergencyRequest): - return { - "request_id": "abc123", - "hospitals": [], - "donors_alerted": 0 - } + """Trigger an emergency: rank hospitals, alert donors, send confirmations.""" + store = get_store() + return await emergency_service.trigger_emergency( + store, + lat=request.lat, + lng=request.lng, + emergency_type=request.emergency_type, + blood_group=request.blood_group, + ) + -@app.get("/emergency/{request_id}/status") +@app.get( + "/emergency/{request_id}/status", + response_model=EmergencyStatusResponse, + tags=["emergency"], +) async def get_emergency_status(request_id: str): - return { - "request_id": request_id, - "hospitals": [], - "donors_alerted": 0, - "donors_responded": 0 - } - -@app.post("/confirm/{token}") + """Poll/subscribe target: live hospital replies + donor responses.""" + try: + return emergency_service.get_status(get_store(), request_id) + except LookupError: + raise HTTPException(status_code=404, detail="Unknown request_id") + + +@app.post( + "/confirm/{token}", + response_model=HospitalConfirmResponse, + tags=["confirmation"], +) async def confirm_hospital(token: str, request: HospitalConfirmRequest): - return { - "ok": True, - "hospital_name": "Test Hospital", - "already_confirmed": False - } + """A hospital contact taps Accept (true) or Not Available (false).""" + try: + return confirm_service.handle_confirmation( + get_store(), token, request.accepted + ) + except confirm_service.ConfirmationNotFound: + raise HTTPException(status_code=404, detail="Unknown confirmation token") -@app.post("/donor/register") + +@app.post("/donor/register", response_model=DonorRegisterResponse, tags=["donors"]) async def register_donor(request: DonorRegisterRequest): - return {"ok": True, "donor_id": "d123"} + """Register a replacement-blood donor.""" + donor_id = donor_service.register_donor( + get_store(), + name=request.name, + phone=request.phone, + blood_group=request.blood_group, + lat=request.lat, + lng=request.lng, + last_donated=request.last_donated, + ) + return DonorRegisterResponse(ok=True, donor_id=donor_id) + -@app.post("/sms/inbound") +@app.post("/sms/inbound", response_model=SmsInboundResponse, tags=["sms"]) async def handle_sms_inbound(request: SmsInboundRequest): - return {"reply": "Nearest hospitals: ..."} + """Feature-phone path (stretch): parse an inbound SMS and reply.""" + reply = sms_service.handle_inbound( + get_store(), from_number=request.from_number, body=request.body + ) + return SmsInboundResponse(reply=reply) -@app.get("/health") -async def health_check(): - return {"status": "ok", "version": "0.1.0"} + +@app.get("/dev/links", tags=["meta"]) +async def dev_links(): + """Demo helper: the confirmation links recently 'sent' to hospitals.""" + return {"links": sms_service.recent_links} diff --git a/backend/middleware.py b/backend/middleware.py index a8f5188..fddc90b 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -1,11 +1,23 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -def add_cors_middleware(app: FastAPI): +from config import settings + + +def add_cors_middleware(app: FastAPI) -> None: + """Allow the frontend (served from a different origin) to call the API. + + Origins come from the ``CORS_ORIGINS`` env var (comma-separated), defaulting + to ``*`` for local development. Credentials are only enabled when an + explicit origin list is configured, since browsers reject + ``allow_credentials`` together with the ``*`` wildcard. + """ + origins = settings.cors_origins + wildcard = origins == ["*"] app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, + allow_origins=origins, + allow_credentials=not wildcard, allow_methods=["*"], allow_headers=["*"], ) diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..555b5b9 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +# Tests import top-level modules (main, store, services...) directly, so run +# from the backend/ directory. `pythonpath = .` makes that work from anywhere. +pythonpath = . +testpaths = tests +addopts = -q +filterwarnings = + ignore::DeprecationWarning diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..4c0d345 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +# Test/dev-only dependencies (kept out of the runtime image). +-r requirements.txt +pytest==8.3.3 diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..b963aaf --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,101 @@ +"""Pydantic request/response models. + +These mirror ``API_CONTRACT.md`` **exactly** — the field names and shapes here +are the contract the frontend builds against. Do not rename a field without +updating ``API_CONTRACT.md`` and telling the frontend owner first. +""" +from __future__ import annotations + +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +EmergencyType = Literal["trauma", "cardiac", "obstetric", "general"] +BloodGroup = Literal["O+", "O-", "A+", "A-", "B+", "B-", "AB+", "AB-"] +HospitalStatus = Literal["pending", "confirmed", "declined"] + + +# --- POST /emergency ------------------------------------------------------- +class EmergencyRequest(BaseModel): + lat: float + lng: float + emergency_type: EmergencyType + blood_group: BloodGroup + + +class HospitalCard(BaseModel): + hospital_id: str + name: str + eta_minutes: int + department_match: bool + distance_km: float + status: HospitalStatus + phone: str + + +class EmergencyResponse(BaseModel): + request_id: str + hospitals: List[HospitalCard] + donors_alerted: int + + +# --- GET /emergency/{request_id}/status ------------------------------------ +class HospitalStatusCard(BaseModel): + hospital_id: str + name: str + eta_minutes: int + status: HospitalStatus + + +class EmergencyStatusResponse(BaseModel): + request_id: str + hospitals: List[HospitalStatusCard] + donors_alerted: int + donors_responded: int + + +# --- POST /confirm/{token} ------------------------------------------------- +class HospitalConfirmRequest(BaseModel): + accepted: bool + + +class HospitalConfirmResponse(BaseModel): + ok: bool + hospital_name: str + already_confirmed: bool + + +# --- POST /donor/register -------------------------------------------------- +class DonorRegisterRequest(BaseModel): + name: str + phone: str + blood_group: BloodGroup + lat: float + lng: float + last_donated: Optional[str] = None # "YYYY-MM-DD" or null + + +class DonorRegisterResponse(BaseModel): + ok: bool + donor_id: str + + +# --- POST /sms/inbound (stretch) ------------------------------------------- +class SmsInboundRequest(BaseModel): + # The contract sends {"from": ..., "body": ...}; "from" is a reserved + # word in Python, so we expose it as ``from_number`` with an alias. + model_config = ConfigDict(populate_by_name=True) + + from_number: str = Field(alias="from") + body: str + + +class SmsInboundResponse(BaseModel): + reply: str + + +# --- GET /health ----------------------------------------------------------- +class HealthResponse(BaseModel): + status: str + version: str + mode: str diff --git a/backend/seed_data.py b/backend/seed_data.py new file mode 100644 index 0000000..72c721f --- /dev/null +++ b/backend/seed_data.py @@ -0,0 +1,129 @@ +"""Deterministic seed data for demo mode and for the SQL seed script. + +~25 real Jaipur hospitals with approximate coordinates and varied departments, +plus ~180 donors spread across all 8 blood groups and varied distances. The +``last_donated`` dates are deliberately spread so the 90-day cooldown filter +visibly excludes some donors (the eligibility logic actually does something in +the demo). + +Generation is seeded, so every boot and every run of ``seed_supabase.py`` +produces the same dataset — reproducible demos, stable tests. +""" +from __future__ import annotations + +import random +from datetime import date, timedelta +from typing import Dict, List + +from blood import ALL_GROUPS + +# Jaipur city centre — donors and hospitals scatter around this point. +JAIPUR_LAT = 26.9124 +JAIPUR_LNG = 75.7873 + +DEPARTMENTS = ["trauma", "cardiac", "obstetric", "general"] + +# name, lat, lng, departments, beds_available, avg_response_rate +_HOSPITALS = [ + ("SMS Hospital", 26.9036, 75.8147, ["trauma", "cardiac", "general"], 12, 0.92), + ("Fortis Escorts Jaipur", 26.8569, 75.8064, ["cardiac", "general"], 8, 0.88), + ("Manipal Hospital Jaipur", 26.8853, 75.7470, ["trauma", "cardiac", "obstetric", "general"], 15, 0.90), + ("Narayana Multispeciality", 26.8312, 75.8040, ["cardiac", "general"], 6, 0.81), + ("Eternal Hospital", 26.8665, 75.8101, ["cardiac", "trauma", "general"], 9, 0.86), + ("CK Birla Hospital (RBH)", 26.8901, 75.7686, ["trauma", "obstetric", "general"], 11, 0.89), + ("Mahatma Gandhi Hospital", 26.7700, 75.8550, ["trauma", "general"], 7, 0.78), + ("Apex Hospital Malviya Nagar", 26.8541, 75.8136, ["general", "obstetric"], 5, 0.74), + ("Santokba Durlabhji Memorial", 26.9000, 75.8060, ["trauma", "cardiac", "general"], 10, 0.83), + ("Jaipur Golden Hospital", 26.9200, 75.8000, ["general"], 4, 0.70), + ("Bhandari Hospital", 26.8950, 75.7400, ["trauma", "general"], 6, 0.76), + ("Soni Manipal Hospital", 26.8650, 75.7560, ["cardiac", "obstetric", "general"], 8, 0.84), + ("Cocoon Hospital", 26.8780, 75.7510, ["obstetric", "general"], 5, 0.79), + ("EHCC Hospital", 26.8492, 75.8024, ["cardiac", "trauma", "general"], 9, 0.85), + ("Rukmani Birla Hospital", 26.8895, 75.7690, ["obstetric", "general"], 7, 0.82), + ("Tagore Hospital", 26.8350, 75.7900, ["trauma", "general"], 6, 0.75), + ("Monilek Hospital", 26.8880, 75.8060, ["general", "obstetric"], 4, 0.72), + ("Imperial Hospital", 26.9300, 75.7700, ["general"], 3, 0.68), + ("Mital Hospital", 26.9080, 75.7990, ["general", "trauma"], 5, 0.73), + ("Getwell Hospital", 26.8700, 75.8200, ["general"], 4, 0.71), + ("Shalby Hospital Jaipur", 26.8480, 75.7720, ["trauma", "cardiac", "general"], 8, 0.80), + ("Dhanwantari Hospital", 26.9150, 75.8120, ["general", "obstetric"], 5, 0.74), + ("Pratap Memorial Hospital", 26.9250, 75.8200, ["general"], 3, 0.69), + ("Jeevan Rekha Hospital", 26.8600, 75.7980, ["trauma", "cardiac", "general"], 7, 0.81), + ("Marudhar Hospital", 26.9040, 75.8290, ["general", "obstetric"], 4, 0.72), +] + +_FIRST_NAMES = [ + "Asha", "Rohan", "Priya", "Amit", "Neha", "Vikram", "Pooja", "Rahul", + "Sneha", "Arjun", "Kavya", "Manish", "Divya", "Sanjay", "Ritu", "Karan", + "Anjali", "Deepak", "Meena", "Suresh", "Nisha", "Gaurav", "Sunita", "Vivek", +] +_LAST_NAMES = [ + "Verma", "Sharma", "Gupta", "Singh", "Yadav", "Meena", "Agarwal", "Jain", + "Saini", "Choudhary", "Kumawat", "Soni", "Rathore", "Bhatt", "Nair", +] + + +def _scatter(rng: random.Random, max_km: float) -> tuple[float, float]: + """A random point within ``max_km`` of Jaipur centre.""" + # ~111 km per degree latitude; longitude scaled by cos(latitude). + radius_deg = (max_km / 111.0) * (rng.random() ** 0.5) # sqrt for uniform area + angle = rng.uniform(0, 2 * 3.14159265) + import math + + dlat = radius_deg * math.cos(angle) + dlng = radius_deg * math.sin(angle) / math.cos(math.radians(JAIPUR_LAT)) + return round(JAIPUR_LAT + dlat, 5), round(JAIPUR_LNG + dlng, 5) + + +def hospitals() -> List[Dict]: + """The seeded hospital list, one dict per hospital.""" + out: List[Dict] = [] + for i, (name, lat, lng, depts, beds, rel) in enumerate(_HOSPITALS, start=1): + out.append( + { + "id": f"h{i}", + "name": name, + "lat": lat, + "lng": lng, + "departments": depts, + "beds_available": beds, + "avg_response_rate": rel, + "phone": f"+9114{i:07d}", + "contact_phone": f"+9198{i:08d}", + "confirm_token": f"hosp-{i:02d}-token", + } + ) + return out + + +def donors(count: int = 180, seed: int = 42) -> List[Dict]: + """``count`` seeded donors spread across all 8 blood groups.""" + rng = random.Random(seed) + today = date(2026, 6, 18) + out: List[Dict] = [] + for i in range(1, count + 1): + lat, lng = _scatter(rng, max_km=12.0) + group = ALL_GROUPS[i % len(ALL_GROUPS)] + + # Spread donation history: ~25% never donated, the rest 10–200 days ago + # so some fall inside the 90-day cooldown and some don't. + roll = rng.random() + if roll < 0.25: + last_donated = None + else: + days_ago = rng.randint(10, 200) + last_donated = (today - timedelta(days=days_ago)).isoformat() + + out.append( + { + "id": f"d{i}", + "name": f"{rng.choice(_FIRST_NAMES)} {rng.choice(_LAST_NAMES)}", + "phone": f"+9199{i:08d}", + "blood_group": group, + "lat": lat, + "lng": lng, + "last_donated": last_donated, + "available": True, + } + ) + return out diff --git a/backend/seed_supabase.py b/backend/seed_supabase.py new file mode 100644 index 0000000..cf75e2a --- /dev/null +++ b/backend/seed_supabase.py @@ -0,0 +1,74 @@ +"""Seed the Supabase database with the demo hospitals and donors. + +Usage: + python seed_supabase.py # needs SUPABASE_URL + SUPABASE_ANON_KEY + +Run `sql/schema.sql` first to create the tables. This inserts the same ~25 +hospitals and ~180 donors used by demo mode, so the real DB and the in-memory +demo behave identically. + +(Demo mode does NOT need this — the in-memory store seeds itself on boot.) +""" +from __future__ import annotations + +import sys + +import seed_data +from config import settings + + +def main() -> int: + if not settings.use_supabase: + print( + "SUPABASE_URL / SUPABASE_ANON_KEY are not set. Demo mode seeds itself " + "in-memory on boot — nothing to do here. Set them to seed a real DB." + ) + return 0 + + try: + from supabase import create_client + except ImportError: + print("The 'supabase' package is required: pip install supabase") + return 1 + + client = create_client(settings.supabase_url, settings.supabase_key) + + hospitals = [ + { + "id": h["id"], + "name": h["name"], + "lat": h["lat"], + "lng": h["lng"], + "departments": h["departments"], + "beds_available": h["beds_available"], + "avg_response_rate": h["avg_response_rate"], + "phone": h["phone"], + "contact_phone": h["contact_phone"], + } + for h in seed_data.hospitals() + ] + + donors = [ + { + "id": d["id"], + "name": d["name"], + "phone": d["phone"], + "blood_group": d["blood_group"], + "lat": d["lat"], + "lng": d["lng"], + "last_donated": d["last_donated"], + "available": d["available"], + } + for d in seed_data.donors() + ] + + print(f"Upserting {len(hospitals)} hospitals...") + client.table("hospitals").upsert(hospitals).execute() + print(f"Upserting {len(donors)} donors...") + client.table("blood_donors").upsert(donors).execute() + print("Done.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..df3427e --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1,6 @@ +"""Service layer: business logic for GoldenHour. + +Each module owns one concern (hospital ranking, donor matching, confirmation +flow, geocoding/ETA, messaging, realtime, plus small infra helpers). Route +handlers in ``main.py`` call into these; data access goes through ``store.py``. +""" diff --git a/backend/services/cache.py b/backend/services/cache.py index 49cc8ef0e116cef009fe0bd72473a964bbd07f9b..52ba4c903681acaafa0cdf184c71ce37f099f29c 100644 GIT binary patch literal 1715 zcmbVM%Wm5+5WMp%7VgDDWg%_Ri-86R(xR6>F7C-N1X@}-gek5-Qn8JqKtG~i*e~gl zk|jBDF7?gSa&~5Rmb2OH4!klaP-f9sD$qGtt-e4ZiwDev_~EbwPf%k`$|2yfLF3dT zreLu_>E%` zGTtRnkp&4A9>8HirgTuYR+$|iZd>a-P{p-c^HB!h$b%FmH?eBldZrmU!{6V3KzY#2 z(Xp!7=|Fam7R~~tDM2}?l)=8IV@Qn)LnVrmr9z2~rc;f^XJR&+iONz9d0w@?wV3Ci z>V_;6FowMJii{JYKl7@_uHK)Tz`j4fGp7{prPjf+lS?Uy4ZKF{LQE1uC z;699CDS&?^R@__}<@0>*P**8O>MU;-CA4xcArTsHZ4S(cX^g=G}AjzXiHbtIMopKLaK{pT++=-Hv~ zrR?#v3?-4_*^q#;>C^`3JnMrd2}Xz7;bQyl|`3J zp2VZnEpmt0X{cb#Fwd+e9yA$M%oDf`ThQMd8HOGcb`3RwT^bvX9A9G6vx_7~kK8kl zdY+?SmqMAYMiYI_qD#!SH*GSyYozg!^O$$S%K0VX|3STChry$tE01={FG&DiL20Q0 z(j_{-Q0`%{e>8N>W`+pOpN-@7kmKH*WHgX7l9S~{d78BpDp`24oV@c&uGgC`sWFEf zztN`nvXtleT?<}c!O7#CyqvzSY>;=(T+d}SBxB$IR@kq?8ZjTOE{vitJjuMg>h4)`s>AA%1Srf`aWRCa5YvP_2jCs=_wByTi(8 zi@;f7i`oId?XLL0_TbQyc}5>^gi{qR<3Hd&J{+qC0+nik3~Gm7rl6LQpbZPSwFfXN zpzEWQtq`;qflNJ)gst+Rj6+o)V1l1%W@9N)=(3AVJQGT zqjS2(RCuqgMYj-AO0jm-KvAr_&^as$&`nD&a585l&AiAl;m~Tk8Sd|N75WR0?pasp z7mUyZ-C)t546$vuGTLtkkg2K=qB6>R_%s^NBZYhFY(4uzwCmc>&XJoh1@OaAzbWmR z;6Bf8GS)ylmL-yfNEZKS9~P0F5H+q@KwBG}^q=`a4tbn0a|u4Ut9}evFJVPw=5YN1 zBF$F-w{2?**>nphvQp)rquRoX`1r_GMBXY|w3(`=QX$tz#U& z9rbTLcGqlZ#X(&^^{TtTuiB~_F_Xn?UrooDzFMQPe0`ACO zo?Y+v@VjGMu!-V6M|Z>zRrF+MLiR$MTDI%oBtblgkEESkLPT;kJ)g?GeErs?;k=0* x^gMEWTk<9IJT?6+8Roc=S>`}5=WLesB zTtOqXu?@MTm`+wyzKRsCxl&ST>y_N1&(-%k_}Mab1k7YRz1=PsNdox0Tdh}@_wWI} zZa3Gy;oLXl%n#pI+f}Fb=g8pv=5f3Tz31lGFg*mLL4Vx{T=s!czDuqzy6#Za^Z5yUuNCjt~=0M3&pT+g=?(^k?gJ~QP?uL;!5`(Ak zaXz2>n9X*%+QP*n*f6dQ5d|BkX*x+pqft^SSwo(et!Wj@bKrF&l|kHvG!AB+cpuxD z;Wg_$tn4j_2Qs7AFL{CG*H~x@o5oI`R9y;BkFG)lsKY;ZaBH8kkfP*6O3jSH3fM_P zS?Rgh4Q=eO)6Lvf$iq~gC=?u>pf4oZrw8wBq$c77AU v?|_f61UMlOp)>~6+AWfqeT1R7;A}`vgC)W>i{697`}~VRMyiIb+t2?Wo_{z8 literal 6 NcmezWkC%aq0RRg=0u=xN diff --git a/backend/services/emergency_service.py b/backend/services/emergency_service.py index 49cc8ef0e116cef009fe0bd72473a964bbd07f9b..689c56ef48076d220d4ace95bc4b642f260fa9e0 100644 GIT binary patch literal 4143 zcma)9+m72d5PjEI43vk+h@(x?JOnOWAV88LK+r65d0H&AT#2@Yl}MMQve^a!`VsxY zeo4=e5-HocbRQNLIXBLnIUMGB{<4>5D^-0*+SEH~oe{3pivIokA96cMJ1LAKz40H{ zqP~%;DYNYA>iq5d4|FCc^{Xptw6wI*gMn0BDy!a2K3mIdtjP_^2$rVp--{Bz(qiwD%T>SjTEX=2c2_Ozt@ zJ+hIL6!k7{Q0pvWo7rJypR(Uy&NFuQrER{RSts0Jkv${#+CY^9>)KC}ct@`2cGu3V zTiS_FGJ|l}%4SI%_)gzaFVx+X0G5C`yVt6bC{~{5*~aLes%kU1!N{tjwr6ipR~l|m zaGPc0+Ay?DkZ%xxYkN6KPv1-8)xVIiAcwl3-jmdewsu+A@M(w}RyxPH2zyqVPi-x2 z>C0A)pOX=kpx#xXqDS%*Y>rh7z*S9~$4+c-kL4T5X*SioRu$JO%Q9i_R85Tp>`lAf zLMmdVuudDfq)s^eQ`=>NQN;jV1~gV%qlf*19{xt0kX1(bLnYn~%9nUEbY0S2J+;v(sCD)U!@quMSYZAAjb^bG*|7T$?yos@8*1%87_o=Gy zukl2gSG-5(!*$oz3HG%xjio|qw7ru?iH>Zo4FhA-%C52KKaM)ct?9yl@yeI@i@)-z zgjL(*E2=LJ7A}{E!bLL zYFXX%qsDCESU9vh-?<5e5>dU|O|I==OVI?|goNU&e zR7F@?1ewJIy0?6t%(->q=s?j&49##l%2en>-Y^pACt#&bK3iw>Jih9&=d zS}e+){Cu%`{3J;o1cl(n`=DZI!5@L6?PGNuWFKSq9(WZbjtxA5;zvG)H{ zvP0*yx9>9GVfg>c6;AI&?J5u2b;xJAm9EGScJk$z_^=lsz63EY#!_=!{P&&EeO^a` zOuUhgFbI?p(|w%b$RwT&n6FY+@Ht}?BSxaZ9m`!mkq8k9{8=a zMYJ|j>cm?=7GC)|)l(M+xHzvq2i+;xVXWnu|KYW;7&$Q%*Ee`~fy#WmiK+a3EBphd z!-GWj8ZRHt>5K3C{P=|;poJk3-0fc~ct)vaui=|p(!U0ccA7`(U+Gp-uW?=R zx;OXnHF^r~JG{t*C5+nJ9sGgaVkjgr+~3hNvbg9Ks2&&rW*ZXW@u!CVS{iR`oI6Tz z9SmY#!~loUb_?DMk;IIG_{0`(P(IN#hL76l+9-B0)oR=u2MhFX6kgM#L@*=L9(lWA zT;s`PW^Y`qgd1(iPVBi9YbvH^S*i4GF}=*n!PO`xjf#sBsT3XFKt35opsCSsFFqHK XmV|>BV|+^<;)}`0*%>`sEEd^+zAPMH literal 6 NcmezWkC%aq0RRg=0u=xN diff --git a/backend/services/geocoder.py b/backend/services/geocoder.py index 49cc8ef0e116cef009fe0bd72473a964bbd07f9b..93d5df5e55268f3e3d1690ccccef6adbddafc8b2 100644 GIT binary patch literal 3049 zcmb7G-EP}97`@k1d{b}i&6b`2pCJRXv?#iwX|Zw$d z53DuxS{Tb@;l8XEk97Iz9W9h5Mw+vlN-frWYDL2dwzK4`uuHYF#H2?)@GXt5-c6`h zg+ud3jcLP)8ysQQAEJd;4H>JMsFrqG3(2YMZcUghoh`QJnpK<%VYg&j&P)3H&%dZr zsw6Jes#=m&q?vr6r82FstTs4Tl3A(^d~IvCGUBf0NqS6;kSX12bL3qdN&TxLv?bZhW=8Sr-7B7qV6v2{3BPw304#Vr9-D7g?l#&BzK#DqQ6 zARb=2=nTTnADSe2MOTVxgIKw=T8Yy5e7m~*#zec)A(z4mREyDqGw1fST`J`5^M}s} z4{0RH8ieCxfdgiemK)4hY^@gxA(OqeCB#WUpD-rkW% zo*bOaBeG%AFs;@*_U0Yxc>gq_3Npo#H@v&^Y;Qgfmd=iPOPbxI?o2;K$Ieaz=yd!A)$e!d!Tu;hXB1hMIjP9my2`y= z;AFcTRLs!py2d<=asp|xOs&OA3S;&&(EF6E%dkub0)^Az$6ov$VE-477C_gtD>5^jhqe z++P|hWFRw(^c%ur_G#jNW%ZPX77ai^T>*s*FI>R;z=8K6zrZ)Dbc&n0Hy;Y|(j0v9 zPpBIQ98jvw_aMA2*y%~S(}+21MUL&k$)~=ZR=P%{sY`)5pUqs~X5lCfnXFKaUz_1; z7PSJ5#=J7SH%bN)xVQtMHyBdL=QF!>48MX1N;`6HV|KYX%;sYtsqo7~A)EotP2001 z0J4hP5j>o9HMt0-8OY$ymCUot9nYLgqhXuZc*&%NRR_9Bwc42U_GW&2lXhF-v(E-)Qw(T!WzsSIfE;~%pMLML*E!d2y#>P|8W=1tqM@6##3Sft7~Gt$S-s-vZ4d9* z+DWBWD_M@BiJ4basHKaNKNh^T^v3_d*x<=4y5^X2p!a?@%M;4aRUsgXSs-Mbk{L?uJFX-(+++V?i zDPLf!wcTh8VlLclr8#C>Sp07C!b}^dsBzEPT5ULHCFInd$oywlWj`;&y9s70rIB}N h`tC%P3CkNZ8V~70iCs7j*x4QScCrx6`PA>`g5i5WVYH477*Zt)eDd^kN_d7D$R7y4fJ`r7#SImc|wlnN&$SiP7$V?;BF` zM`Ir>((^s@=E-@U|6zTvgX+jB^P^5xbJsIZ47vyW}nD~|5J~OP`YESJLRJ=G2J7=P^75hLBCCw2t znN8NtZj(&aFAVj^r@QGa%Lt#xNjs%mx-NgEAE|ftUG6{O{tNC%ER6ThxI6A}O@^cH zE#1%+O*km}nebh0)%&!dx>MVpQJd^w5Jt^z%J%6h7nhK9W#}> zVSUmWnDmZ4np6;Xp6NQ;rj!UJsMk#eh29#^l~aLfa$S};Q>pU&gFIU814dMpl zTCF&u}8Jg z_Q@12c-1xga>KT^jWi@hiT%@N7Z_1K9dS@07)$>SQ$Oj9rCtZeDv%CYHUI1J zzM6ll?(QGIJk7t}Kg`jie`3$_;M8ub@)?b%BK=+~*JxFXU_1^IyYhhp8YZ&iWsm}^ z>{KVR@mOTvvn*?PO_d-;Z*|yL5sw0u8EF%yQqa1yD$LR-)f-a0DX))s8rxP5z^b;X zIn!bG5%mBZCJm55IDvVAJ$#!2rkV14Qv*cnxS7@1JU@x%f&fkxbjTs4GThDP?0#6t zIEWef;F4mnePv=b^e;VF!>B+WNm^q!bpt3?y|rCZLxrDb)SqN~JsR4lBQ(EL* zFN^d19Nj-Z$MiAau>g|kfEMDeueI|5L?amgW&melo9E~A*zh5|A>1%J@w8#VHQmp@ zfRpSCf=*U;hm_4GdgFI&r~$9tAyH*&zWS)OI*%nsBq1HFG{JYGnEMvpg=x2+qI`NV~C7s9iKvTUb z9TG*SEjkoXNP4IyU#64Uj0uXh3<0bj>K0B;2ZLyBoI`$OBcB&YDOD%NvivZ+L;^>p zVa&4n;Qy0&iRjBj_2Ldx4R4V&CPVBGypIe(2Ot13kWYFIp1$PH-f;0Uyxvly4@OO? z`W|o0L>SQ-z7mU$H+kkGL@M2Y1<_4Bmx%<<^o-haQi!KZDktv%XySIk&9UEUFi<>p z?UMcgae>}PEM73czK5NMZZ7465b_I3eG8^b{?hSMIf?Y46{m2)3trmzcN5!oF8zKO p;IPgK{~xxio>9HXhd4m6lmAJcZ|82u2?WF6qh!CBMVeV={{gU4^(+7Y literal 6 NcmezWkC%aq0RRg=0u=xN diff --git a/backend/services/phone_utils.py b/backend/services/phone_utils.py index 49cc8ef0e116cef009fe0bd72473a964bbd07f9b..d675c34e9d200bb6e83291b27d492339268cc2d6 100644 GIT binary patch literal 1525 zcmZ`(QE$^Q5Pr|EIGG1h(MHYK##Sg#8>kN(69a)XrqoR?twm}_w$rXd{CCcF+NB%K zOOo^5ci-K2XU5pgLP-SKEN7@eD!t^DFx(0ylPJ1SWC1PB;fiqjbpn5qoo5VGo*qL?kw14& z4~&ZJ0yj|LplIq!?c^JVmY%3@<&z!f_@jF5&j-7Utx;=9?s9j76nZOUSaa zv5m$o1F@`?w&W_6^@W=#YHb>Wx82l2&bOXlYS$iKMNxJ!`8@e{3uDlj3 z#7uL&f%O6@)ryOX&nkQmilFsc7=)5nm4O-0fBPnTyjt@)O@PQ@A`8I>IZ7gz2Z@@R z^z+#YTq=;bqG?-jT?`7WQ5FW2^v9Gt*%s^HR1xU{u5bR|QP-ghuHsOO)tjf&R=aLd z!OMl#TpOC!ap*`}bK=oSaU~tX;!LKd0Lf zK5*6OUl;N& k9o-*>Jvsz?p8g;A1<^D3Fy5=3e$$f|iV~&$(C$q91E!(chX4Qo literal 6 NcmezWkC%aq0RRg=0u=xN diff --git a/backend/services/rate_limiter.py b/backend/services/rate_limiter.py index 49cc8ef0e116cef009fe0bd72473a964bbd07f9b..b374030c748998af1830bf585d372822c7e971bf 100644 GIT binary patch literal 1276 zcmZux%Wm5+5WMRv7Cza4tr$J@kk$bL1T6{_ZIR|?7!ob6Y$6n?k`xpJJ@g~`h5eEa zDftydCtEw5o!K2~GMU_yZ-mxVs~47wUKwe7aw0Hk)u_NO&$7>*aMDxQGxZMdh>dKm zGQrc-*dVt0KxO&k`_Cu3;fCFgO?4%i`LnSy^p=A zWw&_d!w0Gp+T$Q7jFp^8ZY&ImK+2Qv>RMF;#XU8h4l1EYa;xp30XOxw(~`ELdd4xF z=~dX%BTMD;Y%-Z-wX+QsMcswYaZ!+JTI*oW!~q3mjnA_2T~G}V_{wU{RjM8jNUjA0 zNL7V7`v2-iXgEaQN7O?s%;^EBEXyh_;P??s`#S9GHX}Tsf+A&9D2l0PUC*fzFNKR| zeek!04bqCNXx<>;!6LHL*{OUS zYY>RSDxnv{@P^6>7|jEa=9(t4hN{h}-0zjTaDU zjFETqaGv6Y538QC!}v74+Z3HM%)NIhl$m6Xzt{>P6t>_~Z8Y`OZG|?`{X%;LieU%K z2UJI>oikh5e}eDM;!yzqSj!`GX2Wt~k`-JF70?^a#o?aI6=u@LR!tS<$TP-kZo?Y! zJlL@-e6Z()Mwbj1GUzyUVk#Q9;9@ivAL*Vmp0KX{$<4^INW+@d83>oAYP%n}W#PO8 zGG~bJGf~X$m8-Dnsarldn_MQsik4e9%g^*bz~hDeb97vmi7dB%zLlsBixZWM_gCUL zXufUvqRjVZ-kTBUEK6-$w<%Vk#}#k3@Q!j2mlY3pRP5Mk&g~|$?IZyWEot3=E38wu PDL&`>wpqN|{@;#&!Rf88 literal 6 NcmezWkC%aq0RRg=0u=xN diff --git a/backend/services/sms_service.py b/backend/services/sms_service.py index 49cc8ef0e116cef009fe0bd72473a964bbd07f9b..909a3f3d910d4abd8c6a014ac19d201b2c622c5f 100644 GIT binary patch literal 3708 zcmbtX+in{<5`FKlC~z=HE0J5tULHmRE#M@ctaoKQust(aXoMEkU6OjzB6~^7j-VB= zAF*G!UowYex1`vahsEl_Qj_dr)u~fe$3loJwpK1HwH%V(xVhe_k_xRBm03$yX+?FV zK3RH2m5Q4SmTqJCtXXNrba!=^CCUAxCd0PT%2xBLu3W`-m?Up9dinCEvzmHKRB~NC zu-W#qmoMq(6)Td8c+F__RT0gRNXWrVvR7HrbSV^V0rpi(QLvZGyk~EvWzZhTsbaDHS{M}#A zudgr0v)PauQz?flgE1gvbu3ewt0Ds1&47Pf{`>F$1oY-?fK0vh`69g_ zTj+*qaS|^w@&^z7Wt+0pI>WA51%^<}L2K!2rT}$KQlHR=Dh&>tjiK1M6xh?O?kf9c=qKvRqI9@hnJOhfvGl0+O=9Q zJ)^rv!|`ReZLr$C_#PbS(-`*qbkle`$U2D!=e5>lckX;V&OhAV{P^)MZieDGSh4IJ zjM})jWZk{Cl3=;56z8ATanrJSs0y|jP8GTYe_MCk=N!PuB=OE;Zv^~86gck2VJ#I) zU6-s@dSf!XwxX9L-_RB4cV@U?<$z#YUi`Hp?gM;Z1HQ=c*YjyE3l#T}mRS-l<$pvV~%=#`o!B& z&8iQpJ_S6wHyiHtV9ki$FLM!!)?az~;rizG;@$b(MLz+Q!1C%J>hiSh$wN^+ z@0Z^rU=&VlL_gk+Ghb33>d=%&v=FjrD%2nX zTbHaO`_S z+65#~@!kwZ=$GDN9E8Dh;%@FoVcu=v&L@{VGimvEVjcUj?Z*qE{O5)@=q>G22juXp zp|^0NAsjGhIp`NqZ@xmFxam8{k?q%*{?RGKi#AW`A%|UHIYwJ!J z_?2_b=g3EY8g+%Hw-A2W4hI9-R9P2Lrp+7ZyNoE z62{0I(P3m|7AmG#g*RisEkVP{W3VmXJ&yx7+UIa8ii}kM9@Ly$CI_Li_h}gpsHq(o@*@RUwx%k z+SnZZjJ?rVzu-^6|Hz%7cMqB|yo6>MBxENX1CR1(@7P`9GfbaucRcD)>c{IMSkJv$ v2uvJSX|&bbw@#C+G2OY$M7*x$^FmxdRrO)Kc-;2cE*c2|??o2%L@)Ubf!ngJ literal 6 NcmezWkC%aq0RRg=0u=xN diff --git a/backend/sql/schema.sql b/backend/sql/schema.sql new file mode 100644 index 0000000..4c9cc41 --- /dev/null +++ b/backend/sql/schema.sql @@ -0,0 +1,113 @@ +-- GoldenHour database schema (PostgreSQL + PostGIS, via Supabase). +-- +-- Run once in the Supabase SQL editor. Stores lat/lng as plain columns (so the +-- seed script can insert them over PostgREST) and derives a PostGIS geography +-- column from them for fast radius queries. Enable Realtime on +-- `confirmation_requests` afterwards so hospital replies push to the frontend. + +create extension if not exists postgis; + +-- --------------------------------------------------------------------------- +-- hospitals +-- --------------------------------------------------------------------------- +create table if not exists hospitals ( + id text primary key, -- e.g. "h1" + name text not null, + lat double precision not null, + lng double precision not null, + location geography(Point, 4326) + generated always as + (ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) stored, + departments text[] not null default '{}', -- {"trauma","cardiac","general"} + beds_available integer not null default 0, + avg_response_rate double precision not null default 0.0, + phone text, + contact_phone text +); + +-- --------------------------------------------------------------------------- +-- blood_donors +-- --------------------------------------------------------------------------- +create table if not exists blood_donors ( + id text primary key, -- e.g. "d42" + name text not null, + phone text not null, + blood_group text not null, -- "O+", "AB-", ... + lat double precision not null, + lng double precision not null, + location geography(Point, 4326) + generated always as + (ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) stored, + last_donated date, -- null = never donated + available boolean not null default true +); + +-- --------------------------------------------------------------------------- +-- emergency_requests +-- --------------------------------------------------------------------------- +create table if not exists emergency_requests ( + id text primary key, -- request_id, e.g. "req0001" + emergency_type text not null, -- trauma|cardiac|obstetric|general + blood_group_needed text not null, + lat double precision not null, + lng double precision not null, + location geography(Point, 4326) + generated always as + (ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) stored, + status text not null default 'pending', -- pending|confirmed|resolved + donors_alerted integer not null default 0, + donors_responded integer not null default 0, + hospitals jsonb not null default '[]', -- ranked hospital-card snapshot + created_at timestamptz not null default now() +); + +-- --------------------------------------------------------------------------- +-- confirmation_requests +-- --------------------------------------------------------------------------- +create table if not exists confirmation_requests ( + id text primary key default gen_random_uuid()::text, + token text unique not null, -- the token in the /confirm URL + emergency_id text not null references emergency_requests(id) on delete cascade, + hospital_id text not null references hospitals(id), + hospital_name text, -- denormalised for the confirm page + sent_at timestamptz not null default now(), + replied_at timestamptz, -- null until the hospital replies + confirmed boolean -- null=pending, true/false on reply +); + +-- --------------------------------------------------------------------------- +-- Spatial indexes — make the radius queries fast. +-- --------------------------------------------------------------------------- +create index if not exists idx_donors_loc on blood_donors using gist (location); +create index if not exists idx_hosp_loc on hospitals using gist (location); +create index if not exists idx_conf_emergency on confirmation_requests (emergency_id); + +-- --------------------------------------------------------------------------- +-- RPC: compatible donors within a radius, off-cooldown, nearest first. +-- Call via supabase.rpc('donors_nearby', {...}). Mirrors donor_service logic. +-- --------------------------------------------------------------------------- +create or replace function donors_nearby( + p_lat double precision, + p_lng double precision, + p_groups text[], + p_radius_m double precision default 5000, + p_cooldown_days integer default 90 +) +returns setof blood_donors +language sql +stable +as $$ + select * + from blood_donors d + where d.blood_group = any(p_groups) + and d.available = true + and (d.last_donated is null + or d.last_donated < current_date - (p_cooldown_days || ' days')::interval) + and ST_DWithin( + d.location, + ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography, + p_radius_m) + order by ST_Distance( + d.location, + ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography); +$$; diff --git a/backend/store.py b/backend/store.py new file mode 100644 index 0000000..9af75fd --- /dev/null +++ b/backend/store.py @@ -0,0 +1,420 @@ +"""Data layer. + +Two interchangeable stores implement the same method surface: + +* ``InMemoryStore`` — seeded with Jaipur hospitals + donors, haversine radius + queries. The default; runs with zero external services (demo mode, CI). +* ``SupabaseStore`` — PostgreSQL + PostGIS via Supabase. Used automatically when + ``SUPABASE_URL`` / ``SUPABASE_ANON_KEY`` are set; falls back to in-memory if the + client can't be created, so the API never hard-fails at boot. + +``get_store()`` picks the right one. Because the four logical tables +(``hospitals``, ``blood_donors``, ``emergency_requests``, +``confirmation_requests``) and every query method are identical across both, +the service layer is completely unaware of which store is live. +""" +from __future__ import annotations + +import itertools +import logging +import uuid +from datetime import date, datetime, timezone +from typing import Dict, List, Optional + +import seed_data +from config import settings +from geo import haversine_km, haversine_meters + +logger = logging.getLogger("goldenhour.store") + + +def _parse_date(value: Optional[str]) -> Optional[date]: + if not value: + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_dt(value) -> datetime: + """Coerce a Supabase timestamptz string (or datetime) to an aware datetime.""" + if isinstance(value, datetime): + return value + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + pass + return _now() + + +# =========================================================================== +# In-memory store (demo / CI) +# =========================================================================== +class InMemoryStore: + backend = "in-memory" + + def __init__(self) -> None: + self.hospitals: List[Dict] = seed_data.hospitals() + self.donors: List[Dict] = seed_data.donors() + self.emergencies: Dict[str, Dict] = {} + self.confirmations: Dict[str, Dict] = {} # keyed by token + self._emergency_seq = itertools.count(1) + self._donor_seq = itertools.count(len(self.donors) + 1) + + # --- Hospitals --------------------------------------------------------- + def hospitals_with_distance(self, lat: float, lng: float) -> List[Dict]: + out = [] + for h in self.hospitals: + enriched = dict(h) + enriched["distance_km"] = round(haversine_km(lat, lng, h["lat"], h["lng"]), 2) + out.append(enriched) + return out + + # --- Donors ------------------------------------------------------------ + def compatible_donors_nearby( + self, + lat: float, + lng: float, + compatible_groups: List[str], + radius_meters: float, + cooldown_days: int, + today: Optional[date] = None, + ) -> List[Dict]: + today = today or date.today() + groups = set(compatible_groups) + result: List[Dict] = [] + for d in self.donors: + if d["blood_group"] not in groups: + continue + if not d.get("available", True): + continue + last = _parse_date(d.get("last_donated")) + if last is not None and (today - last).days < cooldown_days: + continue + dist = haversine_meters(lat, lng, d["lat"], d["lng"]) + if dist > radius_meters: + continue + enriched = dict(d) + enriched["distance_m"] = round(dist, 1) + result.append(enriched) + result.sort(key=lambda d: d["distance_m"]) + return result + + def add_donor(self, name, phone, blood_group, lat, lng, last_donated) -> str: + donor_id = f"d{next(self._donor_seq)}" + self.donors.append( + { + "id": donor_id, + "name": name, + "phone": phone, + "blood_group": blood_group, + "lat": lat, + "lng": lng, + "last_donated": last_donated, + "available": True, + } + ) + return donor_id + + # --- Emergencies ------------------------------------------------------- + def create_emergency( + self, lat, lng, emergency_type, blood_group, hospital_cards, donors_alerted + ) -> Dict: + request_id = f"req{next(self._emergency_seq):04d}" + record = { + "id": request_id, + "lat": lat, + "lng": lng, + "emergency_type": emergency_type, + "blood_group": blood_group, + "status": "pending", + "created_at": _now(), + "hospitals": hospital_cards, + "donors_alerted": donors_alerted, + "donors_responded": 0, + } + self.emergencies[request_id] = record + return record + + def get_emergency(self, request_id: str) -> Optional[Dict]: + return self.emergencies.get(request_id) + + # --- Confirmation requests -------------------------------------------- + def create_confirmation(self, emergency_id, hospital_id, hospital_name, token) -> Dict: + record = { + "token": token, + "emergency_id": emergency_id, + "hospital_id": hospital_id, + "hospital_name": hospital_name, + "sent_at": _now(), + "replied_at": None, + "confirmed": None, + } + self.confirmations[token] = record + return record + + def get_confirmation(self, token: str) -> Optional[Dict]: + return self.confirmations.get(token) + + def confirmations_for_emergency(self, emergency_id: str) -> List[Dict]: + return [c for c in self.confirmations.values() if c["emergency_id"] == emergency_id] + + def emergency_is_taken(self, emergency_id: str) -> bool: + return any( + c["confirmed"] is True for c in self.confirmations_for_emergency(emergency_id) + ) + + def record_reply(self, token: str, accepted: bool) -> None: + """Persist a hospital's Accept/Decline and update the emergency snapshot.""" + conf = self.confirmations.get(token) + if conf is None: + return + conf["confirmed"] = accepted + conf["replied_at"] = _now() + emergency = self.emergencies.get(conf["emergency_id"]) + if emergency is not None: + status = "confirmed" if accepted else "declined" + for card in emergency["hospitals"]: + if card["hospital_id"] == conf["hospital_id"]: + card["status"] = status + if accepted: + emergency["status"] = "confirmed" + + +# =========================================================================== +# Supabase store (production) +# =========================================================================== +class SupabaseStore: + backend = "supabase" + + def __init__(self, client) -> None: + self.client = client + self._hospitals_cache: Optional[List[Dict]] = None + + # --- Hospitals --------------------------------------------------------- + @property + def hospitals(self) -> List[Dict]: + if self._hospitals_cache is None: + res = self.client.table("hospitals").select("*").execute() + self._hospitals_cache = res.data or [] + return self._hospitals_cache + + def hospitals_with_distance(self, lat: float, lng: float) -> List[Dict]: + out = [] + for h in self.hospitals: + enriched = dict(h) + enriched["distance_km"] = round(haversine_km(lat, lng, h["lat"], h["lng"]), 2) + out.append(enriched) + return out + + # --- Donors (PostGIS radius via RPC) ----------------------------------- + def compatible_donors_nearby( + self, + lat: float, + lng: float, + compatible_groups: List[str], + radius_meters: float, + cooldown_days: int, + today: Optional[date] = None, + ) -> List[Dict]: + res = self.client.rpc( + "donors_nearby", + { + "p_lat": lat, + "p_lng": lng, + "p_groups": compatible_groups, + "p_radius_m": radius_meters, + "p_cooldown_days": cooldown_days, + }, + ).execute() + donors = res.data or [] + for d in donors: + d["distance_m"] = round(haversine_meters(lat, lng, d["lat"], d["lng"]), 1) + return donors + + def add_donor(self, name, phone, blood_group, lat, lng, last_donated) -> str: + donor_id = f"d{uuid.uuid4().hex[:8]}" + self.client.table("blood_donors").insert( + { + "id": donor_id, + "name": name, + "phone": phone, + "blood_group": blood_group, + "lat": lat, + "lng": lng, + "last_donated": last_donated, + "available": True, + } + ).execute() + return donor_id + + # --- Emergencies ------------------------------------------------------- + def create_emergency( + self, lat, lng, emergency_type, blood_group, hospital_cards, donors_alerted + ) -> Dict: + request_id = f"req{uuid.uuid4().hex[:8]}" + created_at = _now() + self.client.table("emergency_requests").insert( + { + "id": request_id, + "emergency_type": emergency_type, + "blood_group_needed": blood_group, + "lat": lat, + "lng": lng, + "status": "pending", + "donors_alerted": donors_alerted, + "donors_responded": 0, + "hospitals": hospital_cards, + } + ).execute() + return { + "id": request_id, + "lat": lat, + "lng": lng, + "emergency_type": emergency_type, + "blood_group": blood_group, + "status": "pending", + "created_at": created_at, + "hospitals": hospital_cards, + "donors_alerted": donors_alerted, + "donors_responded": 0, + } + + def get_emergency(self, request_id: str) -> Optional[Dict]: + res = ( + self.client.table("emergency_requests") + .select("*") + .eq("id", request_id) + .limit(1) + .execute() + ) + if not res.data: + return None + row = res.data[0] + return { + "id": row["id"], + "lat": row["lat"], + "lng": row["lng"], + "emergency_type": row["emergency_type"], + "blood_group": row["blood_group_needed"], + "status": row["status"], + "created_at": _parse_dt(row.get("created_at")), + "hospitals": row.get("hospitals") or [], + "donors_alerted": row.get("donors_alerted", 0), + "donors_responded": row.get("donors_responded", 0), + } + + # --- Confirmation requests -------------------------------------------- + def create_confirmation(self, emergency_id, hospital_id, hospital_name, token) -> Dict: + record = { + "token": token, + "emergency_id": emergency_id, + "hospital_id": hospital_id, + "hospital_name": hospital_name, + "confirmed": None, + } + self.client.table("confirmation_requests").insert(record).execute() + record["sent_at"] = _now() + record["replied_at"] = None + return record + + def get_confirmation(self, token: str) -> Optional[Dict]: + res = ( + self.client.table("confirmation_requests") + .select("*") + .eq("token", token) + .limit(1) + .execute() + ) + if not res.data: + return None + row = res.data[0] + return { + "token": row["token"], + "emergency_id": row["emergency_id"], + "hospital_id": row["hospital_id"], + "hospital_name": row.get("hospital_name", ""), + "sent_at": _parse_dt(row.get("sent_at")), + "replied_at": row.get("replied_at"), + "confirmed": row.get("confirmed"), + } + + def confirmations_for_emergency(self, emergency_id: str) -> List[Dict]: + res = ( + self.client.table("confirmation_requests") + .select("*") + .eq("emergency_id", emergency_id) + .execute() + ) + return res.data or [] + + def emergency_is_taken(self, emergency_id: str) -> bool: + res = ( + self.client.table("confirmation_requests") + .select("token") + .eq("emergency_id", emergency_id) + .eq("confirmed", True) + .limit(1) + .execute() + ) + return bool(res.data) + + def record_reply(self, token: str, accepted: bool) -> None: + self.client.table("confirmation_requests").update( + {"confirmed": accepted, "replied_at": _now().isoformat()} + ).eq("token", token).execute() + + conf = self.get_confirmation(token) + if conf is None: + return + emergency = self.get_emergency(conf["emergency_id"]) + if emergency is None: + return + status = "confirmed" if accepted else "declined" + cards = emergency["hospitals"] + for card in cards: + if card["hospital_id"] == conf["hospital_id"]: + card["status"] = status + update = {"hospitals": cards} + if accepted: + update["status"] = "confirmed" + self.client.table("emergency_requests").update(update).eq( + "id", conf["emergency_id"] + ).execute() + + +# =========================================================================== +# Store selection +# =========================================================================== +_store = None + + +def get_store(): + """Process-wide singleton store, chosen from configuration. + + Supabase when configured and reachable; in-memory otherwise. A Supabase + init failure logs and falls back to in-memory rather than breaking boot. + """ + global _store + if _store is not None: + return _store + + if settings.use_supabase: + try: + from supabase import create_client + + client = create_client(settings.supabase_url, settings.supabase_key) + _store = SupabaseStore(client) + logger.info("Data layer: Supabase (PostGIS)") + except Exception: + logger.exception("Supabase init failed — falling back to in-memory store") + _store = InMemoryStore() + else: + _store = InMemoryStore() + return _store diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..7d7a9e0 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,26 @@ +"""Shared pytest fixtures. + +Each test gets a fresh, isolated in-memory store so order never matters. +""" +import pytest +from fastapi.testclient import TestClient + +import store as store_module +from main import app + + +@pytest.fixture(autouse=True) +def fresh_store(): + """Reset the singleton store before every test.""" + store_module._store = None + yield store_module.get_store() + store_module._store = None + + +@pytest.fixture +def client(): + return TestClient(app) + + +# Jaipur centre — a convenient default emergency location. +JAIPUR = {"lat": 26.9124, "lng": 75.7873} diff --git a/backend/tests/fake_supabase.py b/backend/tests/fake_supabase.py new file mode 100644 index 0000000..d42349e --- /dev/null +++ b/backend/tests/fake_supabase.py @@ -0,0 +1,127 @@ +"""A minimal in-process fake of the supabase-py client. + +Implements just the query-builder surface that ``SupabaseStore`` uses +(``table().select().eq().limit().execute()``, ``insert``, ``update``, and +``rpc``) backed by plain lists. Lets us exercise the production store's logic — +column names, query chains, key mapping — without a live database. +""" +from __future__ import annotations + +from datetime import date +from typing import Dict, List + +import seed_data +from geo import haversine_meters + + +class _Result: + def __init__(self, data): + self.data = data + + +class _Query: + def __init__(self, rows: List[Dict], op: str, payload=None): + self._rows = rows # the live backing list for this table + self._op = op + self._payload = payload + self._filters = [] # list of (col, value) + self._limit = None + + # builder methods (all return self) -------------------------------------- + def select(self, *_cols): + self._op = "select" + return self + + def eq(self, col, value): + self._filters.append((col, value)) + return self + + def limit(self, n): + self._limit = n + return self + + def _matches(self, row) -> bool: + return all(row.get(col) == value for col, value in self._filters) + + # terminal --------------------------------------------------------------- + def execute(self) -> _Result: + if self._op == "select": + matched = [dict(r) for r in self._rows if self._matches(r)] + if self._limit is not None: + matched = matched[: self._limit] + return _Result(matched) + + if self._op == "insert": + items = self._payload if isinstance(self._payload, list) else [self._payload] + for item in items: + self._rows.append(dict(item)) + return _Result([dict(i) for i in items]) + + if self._op == "update": + updated = [] + for row in self._rows: + if self._matches(row): + row.update(self._payload) + updated.append(dict(row)) + return _Result(updated) + + return _Result([]) + + +class FakeSupabaseClient: + def __init__(self): + self.tables: Dict[str, List[Dict]] = { + "hospitals": [dict(h) for h in seed_data.hospitals()], + "blood_donors": [dict(d) for d in seed_data.donors()], + "emergency_requests": [], + "confirmation_requests": [], + } + + def table(self, name: str) -> _Query: + return _Query(self.tables.setdefault(name, []), op="select") + + # supabase: client.table(name).insert(...) / .update(...) + # The builder above starts in "select"; insert/update switch the op. + def _wrap_insert_update(self): # pragma: no cover - documentation only + ... + + def rpc(self, name: str, params: Dict) -> _Query: + if name != "donors_nearby": + return _Query([], op="select") + rows = self._donors_nearby(params) + q = _Query(rows, op="select") + return q + + def _donors_nearby(self, p: Dict) -> List[Dict]: + today = date.today() + groups = set(p["p_groups"]) + out = [] + for d in self.tables["blood_donors"]: + if d["blood_group"] not in groups or not d.get("available", True): + continue + last = d.get("last_donated") + if last: + if (today - date.fromisoformat(last)).days < p["p_cooldown_days"]: + continue + if haversine_meters(p["p_lat"], p["p_lng"], d["lat"], d["lng"]) > p["p_radius_m"]: + continue + out.append(dict(d)) + return out + + +# supabase-py exposes insert()/update() on the table builder; add them here so +# `client.table(x).insert(...)` works with our _Query starting state. +def _insert(self, payload): + self._op = "insert" + self._payload = payload + return self + + +def _update(self, payload): + self._op = "update" + self._payload = payload + return self + + +_Query.insert = _insert +_Query.update = _update diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..59f8d84 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,124 @@ +"""Endpoint tests — verify the API_CONTRACT.md shapes and the live flow.""" +from tests.conftest import JAIPUR + +import store as store_module + + +def _trigger(client, **overrides): + payload = {**JAIPUR, "emergency_type": "trauma", "blood_group": "O+", **overrides} + return client.post("/emergency", json=payload) + + +# --- Health / meta --------------------------------------------------------- +def test_health(client): + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert "version" in body and "mode" in body + + +# --- POST /emergency ------------------------------------------------------- +def test_emergency_returns_contract_shape(client): + resp = _trigger(client) + assert resp.status_code == 200 + body = resp.json() + assert set(body) == {"request_id", "hospitals", "donors_alerted"} + assert isinstance(body["hospitals"], list) and body["hospitals"] + assert isinstance(body["donors_alerted"], int) + + card = body["hospitals"][0] + assert set(card) == { + "hospital_id", "name", "eta_minutes", "department_match", + "distance_km", "status", "phone", + } + assert card["status"] == "pending" + + +def test_emergency_hospitals_sorted_best_first(client): + body = _trigger(client).json() + etas = [h["eta_minutes"] for h in body["hospitals"]] + # Not strictly sorted by ETA (score blends factors), but the best card + # should be a sensible, nearby option. + assert all(isinstance(e, int) and e >= 1 for e in etas) + + +def test_emergency_rejects_bad_enum(client): + resp = _trigger(client, emergency_type="zombie") + assert resp.status_code == 422 + + +# --- GET /emergency/{id}/status ------------------------------------------- +def test_status_unknown_request_id_is_404(client): + assert client.get("/emergency/nope/status").status_code == 404 + + +def test_status_shape(client): + request_id = _trigger(client).json()["request_id"] + body = client.get(f"/emergency/{request_id}/status").json() + assert set(body) == { + "request_id", "hospitals", "donors_alerted", "donors_responded", + } + assert body["request_id"] == request_id + card = body["hospitals"][0] + assert set(card) == {"hospital_id", "name", "eta_minutes", "status"} + + +# --- POST /confirm/{token} — the live flow -------------------------------- +def test_confirm_flips_hospital_status_live(client): + request_id = _trigger(client).json()["request_id"] + store = store_module.get_store() + confs = store.confirmations_for_emergency(request_id) + assert len(confs) >= 2 + first, second = confs[0], confs[1] + + # First hospital accepts -> ok, not already confirmed. + r1 = client.post(f"/confirm/{first['token']}", json={"accepted": True}) + assert r1.status_code == 200 + body1 = r1.json() + assert set(body1) == {"ok", "hospital_name", "already_confirmed"} + assert body1["ok"] is True + assert body1["already_confirmed"] is False + + # Status now shows that hospital confirmed. + status = client.get(f"/emergency/{request_id}/status").json() + confirmed = [h for h in status["hospitals"] if h["status"] == "confirmed"] + assert any(h["hospital_id"] == first["hospital_id"] for h in confirmed) + + # A second hospital accepting is told the patient is already routed. + r2 = client.post(f"/confirm/{second['token']}", json={"accepted": True}) + assert r2.json()["already_confirmed"] is True + + +def test_confirm_unknown_token_is_404(client): + resp = client.post("/confirm/does-not-exist", json={"accepted": True}) + assert resp.status_code == 404 + + +# --- POST /donor/register -------------------------------------------------- +def test_register_donor(client): + resp = client.post( + "/donor/register", + json={ + "name": "Asha Verma", + "phone": "+919812345678", + "blood_group": "B+", + "lat": 26.92, + "lng": 75.80, + "last_donated": "2025-01-15", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["donor_id"].startswith("d") + + +# --- POST /sms/inbound ----------------------------------------------------- +def test_sms_inbound_parses_and_replies(client): + resp = client.post( + "/sms/inbound", + json={"from": "+919800000000", "body": "BLOOD O+ Malviya Nagar Jaipur"}, + ) + assert resp.status_code == 200 + assert "Nearest hospitals" in resp.json()["reply"] diff --git a/backend/tests/test_supabase_store.py b/backend/tests/test_supabase_store.py new file mode 100644 index 0000000..083a339 --- /dev/null +++ b/backend/tests/test_supabase_store.py @@ -0,0 +1,75 @@ +"""Verify the production SupabaseStore logic against a fake supabase client. + +Exercises the same emergency -> confirm -> status flow as the in-memory tests, +but through ``SupabaseStore`` query chains, proving column names and key mapping +(e.g. blood_group_needed, hospitals jsonb, hospital_name) are correct. +""" +import asyncio + +from blood import compatible_donor_groups +from services import confirm_service, emergency_service +from store import SupabaseStore +from tests.fake_supabase import FakeSupabaseClient + + +def _store(): + return SupabaseStore(FakeSupabaseClient()) + + +def test_hospitals_with_distance_reads_table(): + store = _store() + rows = store.hospitals_with_distance(26.9124, 75.7873) + assert len(rows) == 25 + assert all("distance_km" in r for r in rows) + + +def test_compatible_donors_nearby_via_rpc(): + store = _store() + donors = store.compatible_donors_nearby( + 26.9124, 75.7873, compatible_donor_groups("O+"), radius_meters=5000, cooldown_days=90 + ) + compatible = set(compatible_donor_groups("O+")) + assert all(d["blood_group"] in compatible for d in donors) + assert all(d["distance_m"] <= 5000 for d in donors) + + +def test_full_flow_emergency_confirm_status(): + store = _store() + + resp = asyncio.run( + emergency_service.trigger_emergency( + store, lat=26.9124, lng=75.7873, emergency_type="trauma", blood_group="O+" + ) + ) + assert resp["hospitals"] and "request_id" in resp + request_id = resp["request_id"] + + # The emergency and its confirmations persisted to the fake DB. + confs = store.confirmations_for_emergency(request_id) + assert len(confs) == len(resp["hospitals"]) + + # First hospital accepts. + first = confs[0] + out = confirm_service.handle_confirmation(store, first["token"], accepted=True) + assert out["already_confirmed"] is False + assert out["hospital_name"] + + # Status reflects the confirmation, read back from the DB. + status = emergency_service.get_status(store, request_id) + confirmed = [h for h in status["hospitals"] if h["status"] == "confirmed"] + assert any(h["hospital_id"] == first["hospital_id"] for h in confirmed) + + # A second hospital accepting is told the patient is already routed. + if len(confs) > 1: + out2 = confirm_service.handle_confirmation(store, confs[1]["token"], accepted=True) + assert out2["already_confirmed"] is True + + +def test_register_donor_persists(): + store = _store() + before = len(store.tables["blood_donors"]) if hasattr(store, "tables") else None + donor_id = store.add_donor("Test User", "+919800000000", "B+", 26.9, 75.8, None) + assert donor_id.startswith("d") + # Confirm it is queryable back. + rows = store.client.tables["blood_donors"] + assert any(r["id"] == donor_id for r in rows) diff --git a/backend/tests/test_units.py b/backend/tests/test_units.py new file mode 100644 index 0000000..7b89088 --- /dev/null +++ b/backend/tests/test_units.py @@ -0,0 +1,75 @@ +"""Unit tests for the algorithms: blood compatibility, geo, ranking, matching.""" +import asyncio +from datetime import date + +from blood import compatible_donor_groups +from geo import haversine_km +from services.donor_service import match_donors +from services.hospital_service import rank_hospitals +from store import get_store + + +# --- Blood compatibility --------------------------------------------------- +def test_o_negative_is_universal_donor_only_for_o_negative_recipient(): + assert compatible_donor_groups("O-") == ["O-"] + + +def test_ab_positive_is_universal_recipient(): + assert set(compatible_donor_groups("AB+")) == { + "O-", "O+", "A-", "A+", "B-", "B+", "AB-", "AB+" + } + + +def test_a_positive_recipient_accepts_o_and_a(): + assert set(compatible_donor_groups("A+")) == {"O-", "O+", "A-", "A+"} + + +def test_unknown_group_fails_safe_to_empty(): + assert compatible_donor_groups("Z+") == [] + + +# --- Geo ------------------------------------------------------------------- +def test_haversine_zero_distance(): + assert haversine_km(26.9, 75.8, 26.9, 75.8) == 0.0 + + +def test_haversine_known_distance_is_reasonable(): + # ~1 degree of latitude is ~111 km. + km = haversine_km(26.0, 75.0, 27.0, 75.0) + assert 110 < km < 112 + + +# --- Hospital ranking ------------------------------------------------------ +def test_ranking_returns_at_most_top_n_sorted_by_score(): + store = get_store() + ranked = asyncio.run(rank_hospitals(store, 26.9124, 75.7873, "trauma", top_n=5)) + assert 1 <= len(ranked) <= 5 + scores = [c["_score"] for c in ranked] + assert scores == sorted(scores, reverse=True) + + +def test_ranking_prefers_department_match(): + store = get_store() + ranked = asyncio.run(rank_hospitals(store, 26.9124, 75.7873, "trauma", top_n=5)) + # At least one of the top hospitals actually has the trauma department. + assert any(c["department_match"] for c in ranked) + + +# --- Donor matching -------------------------------------------------------- +def test_match_donors_respects_compatibility_and_radius(): + store = get_store() + today = date(2026, 6, 18) + donors = match_donors(store, 26.9124, 75.7873, "O+", today=today) + compatible = set(compatible_donor_groups("O+")) + assert all(d["blood_group"] in compatible for d in donors) + assert all(d["distance_m"] <= 5000 for d in donors) + + +def test_match_donors_excludes_recent_donations(): + store = get_store() + today = date(2026, 6, 18) + donors = match_donors(store, 26.9124, 75.7873, "AB+", today=today) + for d in donors: + if d["last_donated"]: + days = (today - date.fromisoformat(d["last_donated"])).days + assert days >= 90 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3eaf7ce --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +# One-command local backend: `docker compose up`. +# The API runs in demo mode by default (in-memory store, no external services). +# To point it at a real Supabase/Maps stack, fill in backend/.env and it is +# picked up automatically. +services: + api: + build: ./backend + ports: + - "8000:8000" + env_file: + - path: ./backend/.env + required: false + environment: + # Sensible demo defaults even without a .env file present. + DELIVERY_CHANNEL: ${DELIVERY_CHANNEL:-console} + CORS_ORIGINS: ${CORS_ORIGINS:-*} + restart: unless-stopped diff --git a/frontend/README.md b/frontend/README.md index a36934d..f580ff1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,16 +1,108 @@ -# React + Vite +# GoldenHour — Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +The mobile-first PWA that everything the user touches lives in: a panicked +family member triggers an emergency, watches hospitals respond live, and a +hospital contact accepts the patient — all on a phone screen. -Currently, two official plugins are available: +Built with **React + Vite + Tailwind CSS** and **React Router**. Every API call +matches [`../API_CONTRACT.md`](../API_CONTRACT.md) exactly. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +--- -## React Compiler +## Tech stack -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +| Piece | Tool | Why | +| --- | --- | --- | +| Framework | React 19 + Vite | Fast dev server, instant HMR | +| Styling | Tailwind CSS | Clean, mobile-friendly utility classes | +| Routing | React Router | Switch between the four screens | +| Live updates | Supabase JS client / polling | Cards flip live when a hospital confirms | -## Expanding the ESLint configuration +--- -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +## The four screens + +Each is a route rendered inside a shared mobile shell (`src/App.jsx`): + +| Route | Component | What it does | +| --- | --- | --- | +| `/` | `PatientIntakeView` | Emergency intake — "Use my location" (Geolocation API), emergency type + blood group dropdowns, big **GET HELP** button → `POST /emergency`, navigates to `/results/:id`. | +| `/results/:id` | `PatientResultsView` | Live results — list of hospital cards (name, ETA badge, department match, status badge `pending`/`confirmed`/`declined`, 1-tap call button) and a blood-donor panel. Cards update live. | +| `/confirm/:token` | `HospitalConfirmPage` | The hospital contact's view — emergency type · blood group · ETA, **Accept Patient** / **Not Available** → `POST /confirm/{token}`. Shows "already routed" when `already_confirmed` is true. | +| `/register` | `DonorRegistration` | One-screen donor sign-up form → `POST /donor/register`. | + +> **Status:** screens are built and navigable; they currently render placeholder +> data per the "fake data first" approach in the PRD. Wiring the real `fetch()` +> calls against the backend is the remaining integration step — because each +> screen is built against the contract shapes, swapping fake data for `fetch()` +> is a localized change. + +### Live updates + +When a hospital taps **Accept** on `/confirm/:token`, the matching card on +`/results/:id` must flip `pending → confirmed` without a refresh. Two options +(agree with the backend owner which to ship): + +- **Polling** — call `GET /emergency/{id}/status` every ~3 s and re-render. +- **Supabase Realtime** — subscribe to the `confirmation_requests` table and + update the matching card when a row changes. + +Start with polling; upgrade to Realtime if there's time. + +--- + +## Repository structure + +``` +frontend/ +├── index.html # App shell + meta/description/OG/PWA tags +├── src/ +│ ├── main.jsx # React entrypoint +│ ├── App.jsx # Mobile shell + React Router routes +│ ├── components/ +│ │ ├── PatientIntakeView.jsx # Screen 1 (/) +│ │ ├── PatientResultsView.jsx # Screen 2 (/results/:id) +│ │ ├── HospitalConfirmPage.jsx # Screen 4 (/confirm/:token) +│ │ └── DonorRegistration.jsx # Screen 3 (/register) +│ ├── index.css # Tailwind entry +│ └── assets/ # Images / icons +├── public/ # favicon, static icons +├── tailwind.config.js +├── vite.config.js +└── package.json +``` + +--- + +## Running it + +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +Other scripts: `npm run build` (production build → `dist/`), `npm run preview` +(serve the build), `npm run lint`. + +### Talking to the backend + +The API base URL is supplied via a Vite env var. Create `frontend/.env.local`: + +``` +VITE_API_BASE_URL=http://localhost:8000 +``` + +In production set it to the deployed backend, e.g. +`https://goldenhour-api.onrender.com`. Read it in code with +`import.meta.env.VITE_API_BASE_URL`. (The backend has CORS open for the +frontend origin, so cross-port calls in dev just work.) + +--- + +## Deployment + +Deployed on Vercel — see [`../vercel.json`](../vercel.json). It builds +`frontend/` and rewrites all routes to `index.html` so React Router's client-side +routes (`/results/:id`, `/confirm/:token`, …) resolve on hard refresh and +deep links. Set `VITE_API_BASE_URL` as a Vercel environment variable. diff --git a/frontend/index.html b/frontend/index.html index f94d687..90dd614 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,37 @@ - - frontend + + + + GoldenHour — Emergency Hospital & Blood Response + + + + + + + + + + + + + + + + + +
diff --git a/frontend/package.json b/frontend/package.json index cff662a..06145cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,8 +1,20 @@ { - "name": "frontend", + "name": "goldenhour-frontend", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", + "description": "GoldenHour PWA — patient emergency intake, live hospital results, donor registration, and the hospital confirmation page. React + Vite + Tailwind.", + "keywords": [ + "goldenhour", + "emergency", + "healthcare", + "blood-donor", + "pwa", + "react", + "vite" + ], + "author": "Team GoldenHour", + "license": "MIT", "scripts": { "dev": "vite", "build": "vite build", From ff23c363b253e198106da7ab0129f507f16c5d68 Mon Sep 17 00:00:00 2001 From: Prajnadeep Sarma Date: Thu, 18 Jun 2026 01:20:26 +0530 Subject: [PATCH 02/32] Add missing env keys to render.yaml Declare SUPABASE_URL (required alongside SUPABASE_ANON_KEY to enable the real database), plus FRONTEND_URL and CORS_ORIGINS, so a production deploy uses Supabase instead of silently falling back to demo mode. Co-Authored-By: Claude Opus 4.8 --- render.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/render.yaml b/render.yaml index d57d336..6794aef 100644 --- a/render.yaml +++ b/render.yaml @@ -7,13 +7,23 @@ services: buildCommand: pip install -r requirements.txt startCommand: uvicorn main:app --host 0.0.0.0 --port $PORT envVars: - - key: DATABASE_URL + # Set both SUPABASE_URL and SUPABASE_ANON_KEY to run against the real + # database; with either missing the API falls back to in-memory demo mode. + - key: SUPABASE_URL sync: false - key: SUPABASE_ANON_KEY sync: false + - key: DATABASE_URL + sync: false - key: GOOGLE_MAPS_API_KEY sync: false - key: MSG91_AUTH_KEY sync: false + # Public URL of the deployed frontend — used for /confirm/ links + # and CORS. Update after the frontend is deployed. + - key: FRONTEND_URL + sync: false + - key: CORS_ORIGINS + sync: false - key: PYTHON_VERSION value: 3.12.0 From 7a7b84e1c2b0137d34d0771f86f3f14108e7e562 Mon Sep 17 00:00:00 2001 From: Prajnadeep Sarma Date: Thu, 18 Jun 2026 12:07:43 +0530 Subject: [PATCH 03/32] Harden DB path: service-role key, SQL seed, /ready, deploy docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production-readiness improvements to the backend/database path: - Prefer SUPABASE_SERVICE_KEY for the backend connection (bypasses RLS), falling back to the anon key; wire into config + store + render.yaml + .env.example - Add sql/seed.sql (generated by build_seed_sql.py) so the DB can be seeded from the Supabase SQL editor as table owner — avoids anon-key RLS friction - Add GET /ready: pings the data layer (503 if unreachable) so a deploy can be verified as truly DB-connected vs silently in demo mode - Document RLS model in schema.sql (default-off + production hardening) - Add DEPLOY.md: full Render + Supabase runbook (demo vs database mode) - Tests now 26 (add /ready + ping coverage); docs updated Co-Authored-By: Claude Opus 4.8 --- DEPLOY.md | 109 ++++++++++++++ backend/.env.example | 5 +- backend/README.md | 29 ++-- backend/build_seed_sql.py | 82 ++++++++++ backend/config.py | 14 +- backend/main.py | 14 ++ backend/sql/schema.sql | 25 ++++ backend/sql/seed.sql | 215 +++++++++++++++++++++++++++ backend/store.py | 18 ++- backend/tests/test_api.py | 8 + backend/tests/test_supabase_store.py | 4 + render.yaml | 3 + 12 files changed, 510 insertions(+), 16 deletions(-) create mode 100644 DEPLOY.md create mode 100644 backend/build_seed_sql.py create mode 100644 backend/sql/seed.sql diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..d13eb4a --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,109 @@ +# Deploying GoldenHour (backend + database) + +Two ways to run in production. Pick one. + +- **Demo mode** — deploy the backend with no Supabase variables. It serves the + seeded in-memory store (25 hospitals, 180 donors). Every endpoint works; + nothing persists. Lowest-risk path for a hackathon demo. +- **Database mode** — back it with Supabase (PostgreSQL + PostGIS) for real + persistence and Supabase Realtime. Do the database steps below first. + +Check which mode is live any time with `GET /health` (`"mode": "supabase"` vs +`"in-memory"`) and confirm the DB is actually reachable with `GET /ready`. + +--- + +## 1. Database — Supabase (skip for demo mode) + +1. Create a project at [supabase.com](https://supabase.com) (remember the DB password). +2. **SQL Editor** → run [`backend/sql/schema.sql`](backend/sql/schema.sql) — creates + the 4 tables, the PostGIS spatial indexes, and the `donors_nearby` RPC. +3. **SQL Editor** → run [`backend/sql/seed.sql`](backend/sql/seed.sql) — loads the + 25 hospitals + 180 donors. (Runs as table owner, so it bypasses RLS — more + reliable than the anon-key Python seeder.) +4. Enable **Realtime** on the `confirmation_requests` table + (Database → Replication, or the table's Realtime toggle). This is what makes + the patient's screen flip live when a hospital accepts. +5. Collect credentials from **Project Settings**: + - **API** → Project URL → `SUPABASE_URL` + - **API** → `service_role` secret → `SUPABASE_SERVICE_KEY` (backend uses this) + - **API** → `anon` public key → `SUPABASE_ANON_KEY` (frontend uses this) + - **Database** → Connection string (URI) → `DATABASE_URL` + +> Alternative to step 3: seed from your machine with +> `cd backend && python seed_supabase.py` (needs `SUPABASE_URL` + +> `SUPABASE_SERVICE_KEY` in `.env`). Prefer the SQL editor if anon RLS blocks inserts. + +--- + +## 2. Backend — Render + +The repo ships a blueprint at [`render.yaml`](render.yaml). + +1. [render.com](https://render.com) → **New → Blueprint** → connect the + `GoldenHour` repo. Render reads `render.yaml` automatically. +2. Set the `sync: false` environment variables: + + | Variable | Demo mode | Database mode | + | --- | --- | --- | + | `SUPABASE_URL` | *(blank)* | from Supabase | + | `SUPABASE_SERVICE_KEY` | *(blank)* | service_role secret | + | `SUPABASE_ANON_KEY` | *(blank)* | anon key (optional for backend) | + | `DATABASE_URL` | *(blank)* | Supabase connection URI | + | `GOOGLE_MAPS_API_KEY` | *(blank → estimated ETAs)* | optional, real ETAs | + | `MSG91_AUTH_KEY` | *(blank)* | optional, SMS | + | `FRONTEND_URL` | your frontend URL | your frontend URL | + | `CORS_ORIGINS` | `*` or frontend URL | frontend URL | + +3. **Create** and wait for the build. The API is live at + `https://goldenhour-api.onrender.com` (or your chosen name). + +The same image runs on Fly.io, or locally: `docker compose up --build`. + +--- + +## 3. Verify + +```bash +# Liveness + which mode is active +curl https://goldenhour-api.onrender.com/health +# {"status":"ok","version":"0.1.0","mode":"supabase"} + +# Readiness — actually pings the database (503 if unreachable) +curl https://goldenhour-api.onrender.com/ready +# {"ready":true,"backend":"supabase"} + +# Smoke-test the main flow +curl -X POST https://goldenhour-api.onrender.com/emergency \ + -H "Content-Type: application/json" \ + -d '{"lat":26.9124,"lng":75.7873,"emergency_type":"trauma","blood_group":"O+"}' +``` + +Interactive API docs are at `…/docs`. + +--- + +## 4. Git flow (you are the Git owner) + +Backend work lands on `backend-dev`; you merge to `main`: + +```bash +git checkout main +git pull origin main +git merge backend-dev +git push origin main +``` + +Your teammate works only on `frontend-dev` and pushes there; you merge it into +`main` the same way. Their app reaches this backend via +`VITE_API_BASE_URL=https://goldenhour-api.onrender.com`. + +--- + +## Security note + +The backend connects with the **service-role** key (`SUPABASE_SERVICE_KEY`), +which bypasses RLS — keep it server-side only, never in the frontend. To harden +the database, enable RLS with read-only `anon` policies (see the commented block +at the end of [`backend/sql/schema.sql`](backend/sql/schema.sql)); backend writes +still succeed via the service-role key. diff --git a/backend/.env.example b/backend/.env.example index 7e7fae5..68c376c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,9 +3,12 @@ # API runs in demo mode (in-memory store, estimated ETAs, console link delivery). # --- Supabase / PostgreSQL + PostGIS --------------------------------------- -# Set both to switch the data layer onto the real database. +# Set SUPABASE_URL + a key to switch the data layer onto the real database. SUPABASE_URL= SUPABASE_ANON_KEY= +# Trusted server-side key — preferred over the anon key for the backend +# (bypasses RLS). Keep secret; never expose to the frontend. +SUPABASE_SERVICE_KEY= # Direct Postgres connection (used by seed_supabase.py / migrations). DATABASE_URL= diff --git a/backend/README.md b/backend/README.md index cab3a73..4f4d50b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -84,7 +84,8 @@ backend/ ├── geo.py # Haversine distance helpers ├── store.py # Data layer: InMemoryStore + SupabaseStore + get_store() ├── seed_data.py # Deterministic ~25 hospitals + ~180 donors -├── seed_supabase.py # Push seed data into a real Supabase DB +├── seed_supabase.py # Push seed data into a real Supabase DB (anon/service key) +├── build_seed_sql.py # Generate sql/seed.sql from seed_data.py ├── services/ │ ├── emergency_service.py # Orchestrates POST /emergency + status │ ├── hospital_service.py # Hospital ranking algorithm @@ -97,7 +98,8 @@ backend/ │ ├── cache.py # Tiny TTL cache │ └── rate_limiter.py # Fixed-window rate limiter ├── sql/ -│ └── schema.sql # PostGIS tables, indexes, donors_nearby() RPC +│ ├── schema.sql # PostGIS tables, indexes, donors_nearby() RPC, RLS notes +│ └── seed.sql # Generated SQL seed (run after schema.sql) ├── tests/ # pytest: units + endpoint/contract/flow tests ├── requirements.txt ├── Dockerfile @@ -118,7 +120,8 @@ docs are served at `/docs` (Swagger) and `/redoc` when the app is running. | `POST /confirm/{token}` | A hospital contact taps Accept (`true`) / Not Available (`false`). | | `POST /donor/register` | Register a replacement-blood donor. | | `POST /sms/inbound` | Feature-phone path (stretch): parse an SMS, reply with nearest hospitals. | -| `GET /health` | Liveness + which mode the app is running in. | +| `GET /health` | Liveness + which mode the app is running in (cheap, no I/O). | +| `GET /ready` | Readiness — pings the data layer; 503 if the DB is unreachable. | | `GET /dev/links` | Demo helper: the confirmation links recently "sent" to hospitals. | --- @@ -174,11 +177,15 @@ cd backend && docker build -t goldenhour-api . && docker run -p 8000:8000 golden ### With a real Supabase + Maps stack 1. Create a Supabase project; run [`sql/schema.sql`](sql/schema.sql) in the SQL editor. -2. Enable **Realtime** on the `confirmation_requests` table. -3. Copy `.env.example` → `.env` and fill in `SUPABASE_URL`, `SUPABASE_ANON_KEY`, - `GOOGLE_MAPS_API_KEY` (and `MSG91_AUTH_KEY` for SMS). -4. Seed the database: `python seed_supabase.py`. -5. Start the app — `GET /health` now reports `"mode": "supabase"`. +2. Run [`sql/seed.sql`](sql/seed.sql) in the SQL editor to load hospitals + donors + (or `python seed_supabase.py` from your machine). +3. Enable **Realtime** on the `confirmation_requests` table. +4. Copy `.env.example` → `.env` and fill in `SUPABASE_URL`, `SUPABASE_SERVICE_KEY` + (preferred for the backend), `GOOGLE_MAPS_API_KEY` (and `MSG91_AUTH_KEY` for SMS). +5. Start the app — `GET /health` reports `"mode": "supabase"`; `GET /ready` + confirms the DB is reachable. + +See [`../DEPLOY.md`](../DEPLOY.md) for the full Render + Supabase runbook. --- @@ -188,7 +195,9 @@ Everything is environment-driven (see [`.env.example`](.env.example)). Highlight | Variable | Default | Effect | | --- | --- | --- | -| `SUPABASE_URL`, `SUPABASE_ANON_KEY` | — | Set both → use the real database. | +| `SUPABASE_URL` + a key | — | Set URL + a key → use the real database. | +| `SUPABASE_SERVICE_KEY` | — | Preferred backend key (bypasses RLS); falls back to anon. | +| `SUPABASE_ANON_KEY` | — | Anon key; used if no service key is set. | | `GOOGLE_MAPS_API_KEY` | — | Set → real drive-time ETAs (else estimated). | | `DELIVERY_CHANNEL` | `console` | `console` \| `telegram` \| `msg91`. | | `FRONTEND_URL` | `http://localhost:5173` | Base for `/confirm/` links. | @@ -204,7 +213,7 @@ Everything is environment-driven (see [`.env.example`](.env.example)). Highlight ```bash cd backend pip install -r requirements-dev.txt -pytest # 24 tests: algorithms + endpoint contract + live flow + Supabase path +pytest # 26 tests: algorithms + endpoint contract + live flow + Supabase path ``` The suite runs fully offline (no Supabase, no network) and verifies the exact diff --git a/backend/build_seed_sql.py b/backend/build_seed_sql.py new file mode 100644 index 0000000..78bc2e4 --- /dev/null +++ b/backend/build_seed_sql.py @@ -0,0 +1,82 @@ +"""Generate sql/seed.sql from seed_data.py. + +Run from the backend/ directory: python build_seed_sql.py + +Produces an idempotent SQL seed (INSERT ... ON CONFLICT DO NOTHING) that can be +pasted into the Supabase SQL editor right after sql/schema.sql. Running it in the +SQL editor executes as the table owner, so it bypasses RLS — more reliable than +seeding over the anon key with seed_supabase.py. +""" +from __future__ import annotations + +import seed_data + + +def _esc(text: str) -> str: + return text.replace("'", "''") + + +def _array_literal(items) -> str: + inner = ", ".join("'%s'" % _esc(i) for i in items) + return f"ARRAY[{inner}]::text[]" + + +def build() -> str: + out = [ + "-- GoldenHour seed data (AUTO-GENERATED from seed_data.py — do not edit by hand).", + "-- Run AFTER sql/schema.sql. Idempotent: re-running is safe.", + "", + "-- Hospitals -----------------------------------------------------------------", + "insert into hospitals (id, name, lat, lng, departments, beds_available, " + "avg_response_rate, phone, contact_phone) values", + ] + rows = [] + for h in seed_data.hospitals(): + rows.append( + " ('{id}', '{name}', {lat}, {lng}, {depts}, {beds}, {rel}, '{phone}', '{contact}')".format( + id=h["id"], + name=_esc(h["name"]), + lat=h["lat"], + lng=h["lng"], + depts=_array_literal(h["departments"]), + beds=h["beds_available"], + rel=h["avg_response_rate"], + phone=h["phone"], + contact=h["contact_phone"], + ) + ) + out.append(",\n".join(rows)) + out.append("on conflict (id) do nothing;") + out.append("") + + out += [ + "-- Donors --------------------------------------------------------------------", + "insert into blood_donors (id, name, phone, blood_group, lat, lng, " + "last_donated, available) values", + ] + rows = [] + for d in seed_data.donors(): + last = "'%s'" % d["last_donated"] if d["last_donated"] else "null" + rows.append( + " ('{id}', '{name}', '{phone}', '{bg}', {lat}, {lng}, {last}, {av})".format( + id=d["id"], + name=_esc(d["name"]), + phone=d["phone"], + bg=d["blood_group"], + lat=d["lat"], + lng=d["lng"], + last=last, + av=str(d["available"]).lower(), + ) + ) + out.append(",\n".join(rows)) + out.append("on conflict (id) do nothing;") + out.append("") + return "\n".join(out) + + +if __name__ == "__main__": + sql = build() + with open("sql/seed.sql", "w", encoding="utf-8", newline="\n") as f: + f.write(sql) + print("Wrote sql/seed.sql") diff --git a/backend/config.py b/backend/config.py index 01c1cea..20fe899 100644 --- a/backend/config.py +++ b/backend/config.py @@ -27,10 +27,13 @@ class Settings: version: str = "0.1.0" # --- Supabase / PostgreSQL + PostGIS ----------------------------------- - # When both are present the app talks to the real database; otherwise it - # falls back to the in-memory store (demo mode). + # When a URL + a key are present the app talks to the real database; + # otherwise it falls back to the in-memory store (demo mode). supabase_url: str = os.getenv("SUPABASE_URL", "") supabase_key: str = os.getenv("SUPABASE_ANON_KEY", "") + # Trusted server-side key. Preferred over the anon key for the backend's + # reads/writes (bypasses RLS); keep it secret — never ship it to a client. + supabase_service_key: str = os.getenv("SUPABASE_SERVICE_KEY", "") database_url: str = os.getenv("DATABASE_URL", "") # --- Google Maps Distance Matrix (ETA + geocoding) --------------------- @@ -60,10 +63,15 @@ class Settings: default_factory=lambda: _env_list("CORS_ORIGINS", "*") ) + @property + def supabase_active_key(self) -> str: + """Key the backend connects with — service-role preferred, anon fallback.""" + return self.supabase_service_key or self.supabase_key + @property def use_supabase(self) -> bool: """True when a real Supabase backend is configured.""" - return bool(self.supabase_url and self.supabase_key) + return bool(self.supabase_url and self.supabase_active_key) @property def use_google_maps(self) -> bool: diff --git a/backend/main.py b/backend/main.py index 2598d50..2101e68 100644 --- a/backend/main.py +++ b/backend/main.py @@ -58,10 +58,24 @@ async def root(): @app.get("/health", response_model=HealthResponse, tags=["meta"]) async def health_check(): + """Liveness — cheap, no I/O (safe for frequent platform health checks).""" mode = "supabase" if settings.use_supabase else "in-memory" return HealthResponse(status="ok", version=settings.version, mode=mode) +@app.get("/ready", tags=["meta"]) +async def readiness_check(): + """Readiness — verifies the data layer actually answers (pings the DB). + + Use this after a deploy to confirm the API is really connected to Supabase + and not silently running in demo mode. Returns 503 if the store is unreachable. + """ + store = get_store() + if not store.ping(): + raise HTTPException(status_code=503, detail="data layer unreachable") + return {"ready": True, "backend": store.backend} + + @app.post("/emergency", response_model=EmergencyResponse, tags=["emergency"]) async def trigger_emergency(request: EmergencyRequest): """Trigger an emergency: rank hospitals, alert donors, send confirmations.""" diff --git a/backend/sql/schema.sql b/backend/sql/schema.sql index 4c9cc41..9c3e0ee 100644 --- a/backend/sql/schema.sql +++ b/backend/sql/schema.sql @@ -111,3 +111,28 @@ as $$ d.location, ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography); $$; + +-- --------------------------------------------------------------------------- +-- Row-Level Security (RLS) +-- --------------------------------------------------------------------------- +-- Tables created above have RLS DISABLED by default, so the anon key can read +-- and write — convenient for the demo/prototype. +-- +-- Recommended production model: +-- * Backend uses the SERVICE-ROLE key (SUPABASE_SERVICE_KEY) — bypasses RLS, +-- so all writes (emergencies, confirmations, donor inserts) go through the +-- trusted server. +-- * Frontend uses the anon key with read-only access to just what it needs to +-- subscribe to via Realtime. +-- +-- To lock down, enable RLS and add read-only anon policies, e.g.: +-- +-- alter table confirmation_requests enable row level security; +-- create policy "anon reads confirmations" +-- on confirmation_requests for select to anon using (true); +-- +-- alter table emergency_requests enable row level security; +-- create policy "anon reads emergencies" +-- on emergency_requests for select to anon using (true); +-- +-- (Writes still succeed because the backend connects with the service-role key.) diff --git a/backend/sql/seed.sql b/backend/sql/seed.sql new file mode 100644 index 0000000..a3106b0 --- /dev/null +++ b/backend/sql/seed.sql @@ -0,0 +1,215 @@ +-- GoldenHour seed data (AUTO-GENERATED from seed_data.py — do not edit by hand). +-- Run AFTER sql/schema.sql. Idempotent: re-running is safe. + +-- Hospitals ----------------------------------------------------------------- +insert into hospitals (id, name, lat, lng, departments, beds_available, avg_response_rate, phone, contact_phone) values + ('h1', 'SMS Hospital', 26.9036, 75.8147, ARRAY['trauma', 'cardiac', 'general']::text[], 12, 0.92, '+91140000001', '+919800000001'), + ('h2', 'Fortis Escorts Jaipur', 26.8569, 75.8064, ARRAY['cardiac', 'general']::text[], 8, 0.88, '+91140000002', '+919800000002'), + ('h3', 'Manipal Hospital Jaipur', 26.8853, 75.747, ARRAY['trauma', 'cardiac', 'obstetric', 'general']::text[], 15, 0.9, '+91140000003', '+919800000003'), + ('h4', 'Narayana Multispeciality', 26.8312, 75.804, ARRAY['cardiac', 'general']::text[], 6, 0.81, '+91140000004', '+919800000004'), + ('h5', 'Eternal Hospital', 26.8665, 75.8101, ARRAY['cardiac', 'trauma', 'general']::text[], 9, 0.86, '+91140000005', '+919800000005'), + ('h6', 'CK Birla Hospital (RBH)', 26.8901, 75.7686, ARRAY['trauma', 'obstetric', 'general']::text[], 11, 0.89, '+91140000006', '+919800000006'), + ('h7', 'Mahatma Gandhi Hospital', 26.77, 75.855, ARRAY['trauma', 'general']::text[], 7, 0.78, '+91140000007', '+919800000007'), + ('h8', 'Apex Hospital Malviya Nagar', 26.8541, 75.8136, ARRAY['general', 'obstetric']::text[], 5, 0.74, '+91140000008', '+919800000008'), + ('h9', 'Santokba Durlabhji Memorial', 26.9, 75.806, ARRAY['trauma', 'cardiac', 'general']::text[], 10, 0.83, '+91140000009', '+919800000009'), + ('h10', 'Jaipur Golden Hospital', 26.92, 75.8, ARRAY['general']::text[], 4, 0.7, '+91140000010', '+919800000010'), + ('h11', 'Bhandari Hospital', 26.895, 75.74, ARRAY['trauma', 'general']::text[], 6, 0.76, '+91140000011', '+919800000011'), + ('h12', 'Soni Manipal Hospital', 26.865, 75.756, ARRAY['cardiac', 'obstetric', 'general']::text[], 8, 0.84, '+91140000012', '+919800000012'), + ('h13', 'Cocoon Hospital', 26.878, 75.751, ARRAY['obstetric', 'general']::text[], 5, 0.79, '+91140000013', '+919800000013'), + ('h14', 'EHCC Hospital', 26.8492, 75.8024, ARRAY['cardiac', 'trauma', 'general']::text[], 9, 0.85, '+91140000014', '+919800000014'), + ('h15', 'Rukmani Birla Hospital', 26.8895, 75.769, ARRAY['obstetric', 'general']::text[], 7, 0.82, '+91140000015', '+919800000015'), + ('h16', 'Tagore Hospital', 26.835, 75.79, ARRAY['trauma', 'general']::text[], 6, 0.75, '+91140000016', '+919800000016'), + ('h17', 'Monilek Hospital', 26.888, 75.806, ARRAY['general', 'obstetric']::text[], 4, 0.72, '+91140000017', '+919800000017'), + ('h18', 'Imperial Hospital', 26.93, 75.77, ARRAY['general']::text[], 3, 0.68, '+91140000018', '+919800000018'), + ('h19', 'Mital Hospital', 26.908, 75.799, ARRAY['general', 'trauma']::text[], 5, 0.73, '+91140000019', '+919800000019'), + ('h20', 'Getwell Hospital', 26.87, 75.82, ARRAY['general']::text[], 4, 0.71, '+91140000020', '+919800000020'), + ('h21', 'Shalby Hospital Jaipur', 26.848, 75.772, ARRAY['trauma', 'cardiac', 'general']::text[], 8, 0.8, '+91140000021', '+919800000021'), + ('h22', 'Dhanwantari Hospital', 26.915, 75.812, ARRAY['general', 'obstetric']::text[], 5, 0.74, '+91140000022', '+919800000022'), + ('h23', 'Pratap Memorial Hospital', 26.925, 75.82, ARRAY['general']::text[], 3, 0.69, '+91140000023', '+919800000023'), + ('h24', 'Jeevan Rekha Hospital', 26.86, 75.798, ARRAY['trauma', 'cardiac', 'general']::text[], 7, 0.81, '+91140000024', '+919800000024'), + ('h25', 'Marudhar Hospital', 26.904, 75.829, ARRAY['general', 'obstetric']::text[], 4, 0.72, '+91140000025', '+919800000025') +on conflict (id) do nothing; + +-- Donors -------------------------------------------------------------------- +insert into blood_donors (id, name, phone, blood_group, lat, lng, last_donated, available) values + ('d1', 'Neha Soni', '+919900000001', 'O+', 26.99778, 75.80247, '2026-04-12', true), + ('d2', 'Sanjay Verma', '+919900000002', 'A-', 26.91037, 75.74855, '2026-01-08', true), + ('d3', 'Deepak Singh', '+919900000003', 'A+', 26.91605, 75.80782, '2026-06-02', true), + ('d4', 'Meena Yadav', '+919900000004', 'B-', 26.88486, 75.68947, '2026-02-14', true), + ('d5', 'Sanjay Meena', '+919900000005', 'B+', 27.00958, 75.79175, '2025-12-12', true), + ('d6', 'Priya Agarwal', '+919900000006', 'AB-', 26.92472, 75.8497, '2026-05-13', true), + ('d7', 'Vivek Jain', '+919900000007', 'AB+', 26.93173, 75.75645, '2026-05-28', true), + ('d8', 'Arjun Bhatt', '+919900000008', 'O-', 26.99044, 75.77237, '2026-01-18', true), + ('d9', 'Sunita Sharma', '+919900000009', 'O+', 26.97686, 75.72394, '2026-04-20', true), + ('d10', 'Rahul Bhatt', '+919900000010', 'A-', 26.9156, 75.813, '2026-05-19', true), + ('d11', 'Vikram Meena', '+919900000011', 'A+', 26.90639, 75.82524, '2026-03-07', true), + ('d12', 'Nisha Sharma', '+919900000012', 'B-', 26.88142, 75.72394, '2025-12-16', true), + ('d13', 'Ritu Agarwal', '+919900000013', 'B+', 26.95252, 75.87054, '2026-04-28', true), + ('d14', 'Gaurav Meena', '+919900000014', 'AB-', 26.96251, 75.75884, '2026-04-13', true), + ('d15', 'Rohan Rathore', '+919900000015', 'AB+', 26.92854, 75.67748, null, true), + ('d16', 'Meena Nair', '+919900000016', 'O-', 26.90565, 75.85497, null, true), + ('d17', 'Ritu Gupta', '+919900000017', 'O+', 26.93371, 75.88721, '2025-12-26', true), + ('d18', 'Vivek Choudhary', '+919900000018', 'A-', 26.91358, 75.84968, '2026-04-02', true), + ('d19', 'Anjali Jain', '+919900000019', 'A+', 26.85116, 75.74754, '2026-05-04', true), + ('d20', 'Nisha Gupta', '+919900000020', 'B-', 26.94358, 75.79796, null, true), + ('d21', 'Divya Choudhary', '+919900000021', 'B+', 26.82746, 75.83799, null, true), + ('d22', 'Gaurav Soni', '+919900000022', 'AB-', 26.8063, 75.76529, '2026-06-06', true), + ('d23', 'Kavya Sharma', '+919900000023', 'AB+', 26.93981, 75.76012, '2025-12-26', true), + ('d24', 'Vivek Nair', '+919900000024', 'O-', 26.94435, 75.84235, null, true), + ('d25', 'Amit Bhatt', '+919900000025', 'O+', 27.00269, 75.76923, '2026-01-30', true), + ('d26', 'Neha Meena', '+919900000026', 'A-', 26.95896, 75.7069, '2026-04-19', true), + ('d27', 'Asha Choudhary', '+919900000027', 'A+', 26.82087, 75.76137, '2026-01-24', true), + ('d28', 'Rahul Verma', '+919900000028', 'B-', 26.97349, 75.79573, '2026-03-22', true), + ('d29', 'Vivek Jain', '+919900000029', 'B+', 26.86402, 75.76286, null, true), + ('d30', 'Neha Kumawat', '+919900000030', 'AB-', 27.00913, 75.7722, '2026-05-07', true), + ('d31', 'Sanjay Singh', '+919900000031', 'AB+', 26.84149, 75.76157, '2026-01-04', true), + ('d32', 'Arjun Agarwal', '+919900000032', 'O-', 26.91585, 75.67051, '2025-12-08', true), + ('d33', 'Ritu Sharma', '+919900000033', 'O+', 26.84894, 75.68951, '2026-01-27', true), + ('d34', 'Deepak Singh', '+919900000034', 'A-', 26.96193, 75.81093, null, true), + ('d35', 'Rahul Sharma', '+919900000035', 'A+', 26.99524, 75.7915, '2026-05-24', true), + ('d36', 'Rahul Yadav', '+919900000036', 'B-', 26.97779, 75.69824, null, true), + ('d37', 'Meena Choudhary', '+919900000037', 'B+', 26.9321, 75.88397, null, true), + ('d38', 'Amit Sharma', '+919900000038', 'AB-', 26.92844, 75.70591, '2026-04-21', true), + ('d39', 'Rohan Kumawat', '+919900000039', 'AB+', 26.85892, 75.86533, '2025-12-04', true), + ('d40', 'Vivek Meena', '+919900000040', 'O-', 26.85935, 75.70942, null, true), + ('d41', 'Deepak Jain', '+919900000041', 'O+', 26.98721, 75.85606, null, true), + ('d42', 'Ritu Rathore', '+919900000042', 'A-', 26.92883, 75.82879, '2026-05-20', true), + ('d43', 'Deepak Bhatt', '+919900000043', 'A+', 26.81704, 75.75231, null, true), + ('d44', 'Vikram Agarwal', '+919900000044', 'B-', 26.92334, 75.79545, '2026-04-09', true), + ('d45', 'Vikram Agarwal', '+919900000045', 'B+', 26.92941, 75.86961, '2026-05-24', true), + ('d46', 'Arjun Agarwal', '+919900000046', 'AB-', 26.90852, 75.79088, '2026-02-12', true), + ('d47', 'Sunita Jain', '+919900000047', 'AB+', 26.90138, 75.68687, '2025-12-21', true), + ('d48', 'Vivek Saini', '+919900000048', 'O-', 26.9001, 75.83296, '2026-01-11', true), + ('d49', 'Karan Saini', '+919900000049', 'O+', 26.90201, 75.81487, null, true), + ('d50', 'Anjali Sharma', '+919900000050', 'A-', 26.81072, 75.76471, null, true), + ('d51', 'Rahul Agarwal', '+919900000051', 'A+', 27.00305, 75.83399, null, true), + ('d52', 'Suresh Verma', '+919900000052', 'B-', 26.94128, 75.7606, null, true), + ('d53', 'Kavya Nair', '+919900000053', 'B+', 26.83804, 75.83367, '2026-01-26', true), + ('d54', 'Divya Gupta', '+919900000054', 'AB-', 26.88572, 75.7331, '2026-04-02', true), + ('d55', 'Asha Jain', '+919900000055', 'AB+', 26.88502, 75.8818, '2026-05-21', true), + ('d56', 'Deepak Singh', '+919900000056', 'O-', 26.83378, 75.75046, null, true), + ('d57', 'Rahul Meena', '+919900000057', 'O+', 26.96417, 75.85106, '2026-05-22', true), + ('d58', 'Suresh Rathore', '+919900000058', 'A-', 26.85899, 75.81181, '2026-03-23', true), + ('d59', 'Gaurav Sharma', '+919900000059', 'A+', 26.99972, 75.79212, '2026-03-24', true), + ('d60', 'Amit Soni', '+919900000060', 'B-', 26.98203, 75.87507, null, true), + ('d61', 'Kavya Singh', '+919900000061', 'B+', 26.90115, 75.87659, '2025-12-07', true), + ('d62', 'Rohan Sharma', '+919900000062', 'AB-', 26.96641, 75.70707, '2026-04-05', true), + ('d63', 'Kavya Rathore', '+919900000063', 'AB+', 26.95357, 75.7025, null, true), + ('d64', 'Ritu Saini', '+919900000064', 'O-', 26.95119, 75.78175, null, true), + ('d65', 'Sunita Nair', '+919900000065', 'O+', 26.82815, 75.74927, null, true), + ('d66', 'Neha Agarwal', '+919900000066', 'A-', 26.95307, 75.79781, '2026-01-18', true), + ('d67', 'Manish Singh', '+919900000067', 'A+', 26.89859, 75.82771, '2026-05-29', true), + ('d68', 'Sanjay Choudhary', '+919900000068', 'B-', 26.86789, 75.7005, '2026-01-16', true), + ('d69', 'Vikram Rathore', '+919900000069', 'B+', 26.99599, 75.7401, null, true), + ('d70', 'Vivek Nair', '+919900000070', 'AB-', 26.98395, 75.71328, null, true), + ('d71', 'Rahul Yadav', '+919900000071', 'AB+', 26.96891, 75.75787, '2025-12-02', true), + ('d72', 'Karan Singh', '+919900000072', 'O-', 26.8994, 75.74118, '2026-05-30', true), + ('d73', 'Rahul Verma', '+919900000073', 'O+', 26.95449, 75.76075, '2026-04-11', true), + ('d74', 'Sneha Meena', '+919900000074', 'A-', 26.84185, 75.84596, '2026-05-22', true), + ('d75', 'Kavya Verma', '+919900000075', 'A+', 26.84245, 75.84453, '2026-01-22', true), + ('d76', 'Sneha Verma', '+919900000076', 'B-', 26.94848, 75.77969, null, true), + ('d77', 'Sanjay Choudhary', '+919900000077', 'B+', 26.87977, 75.80325, '2026-03-20', true), + ('d78', 'Sneha Verma', '+919900000078', 'AB-', 26.99256, 75.86721, '2026-04-21', true), + ('d79', 'Gaurav Soni', '+919900000079', 'AB+', 27.00341, 75.78838, '2026-01-22', true), + ('d80', 'Sanjay Sharma', '+919900000080', 'O-', 26.90387, 75.67013, null, true), + ('d81', 'Amit Soni', '+919900000081', 'O+', 27.00493, 75.73091, '2025-12-21', true), + ('d82', 'Divya Soni', '+919900000082', 'A-', 26.80994, 75.78215, '2026-03-17', true), + ('d83', 'Gaurav Soni', '+919900000083', 'A+', 26.95336, 75.83458, '2026-03-03', true), + ('d84', 'Asha Yadav', '+919900000084', 'B-', 26.83557, 75.71084, '2026-01-19', true), + ('d85', 'Kavya Jain', '+919900000085', 'B+', 26.86002, 75.81499, '2025-12-23', true), + ('d86', 'Vikram Kumawat', '+919900000086', 'AB-', 26.88003, 75.71536, '2025-12-02', true), + ('d87', 'Priya Bhatt', '+919900000087', 'AB+', 26.88107, 75.78388, '2026-03-15', true), + ('d88', 'Neha Verma', '+919900000088', 'O-', 26.92241, 75.90506, '2026-04-19', true), + ('d89', 'Ritu Agarwal', '+919900000089', 'O+', 26.93552, 75.78467, '2026-05-21', true), + ('d90', 'Karan Agarwal', '+919900000090', 'A-', 26.82192, 75.73506, '2026-03-02', true), + ('d91', 'Amit Rathore', '+919900000091', 'A+', 26.88268, 75.73755, null, true), + ('d92', 'Ritu Verma', '+919900000092', 'B-', 26.94405, 75.85793, '2026-01-27', true), + ('d93', 'Neha Rathore', '+919900000093', 'B+', 26.9825, 75.74244, null, true), + ('d94', 'Ritu Choudhary', '+919900000094', 'AB-', 26.84011, 75.77124, '2026-03-19', true), + ('d95', 'Ritu Nair', '+919900000095', 'AB+', 26.98838, 75.71861, '2026-01-19', true), + ('d96', 'Nisha Yadav', '+919900000096', 'O-', 26.9402, 75.75032, '2026-04-06', true), + ('d97', 'Ritu Sharma', '+919900000097', 'O+', 26.81864, 75.77314, '2026-03-30', true), + ('d98', 'Priya Gupta', '+919900000098', 'A-', 26.92128, 75.88923, '2026-01-21', true), + ('d99', 'Pooja Sharma', '+919900000099', 'A+', 26.88125, 75.81887, null, true), + ('d100', 'Pooja Bhatt', '+919900000100', 'B-', 26.87852, 75.85552, '2026-05-24', true), + ('d101', 'Asha Bhatt', '+919900000101', 'B+', 26.97032, 75.74306, '2025-12-12', true), + ('d102', 'Arjun Rathore', '+919900000102', 'AB-', 26.82221, 75.73521, '2026-03-10', true), + ('d103', 'Vivek Soni', '+919900000103', 'AB+', 26.96486, 75.73964, '2026-01-22', true), + ('d104', 'Rahul Yadav', '+919900000104', 'O-', 26.84874, 75.73317, null, true), + ('d105', 'Divya Soni', '+919900000105', 'O+', 26.98259, 75.80182, '2025-12-17', true), + ('d106', 'Suresh Saini', '+919900000106', 'A-', 26.8694, 75.79732, null, true), + ('d107', 'Priya Kumawat', '+919900000107', 'A+', 26.89844, 75.7996, '2026-06-02', true), + ('d108', 'Sneha Agarwal', '+919900000108', 'B-', 26.95982, 75.72838, null, true), + ('d109', 'Sneha Rathore', '+919900000109', 'B+', 26.85303, 75.80678, '2026-03-03', true), + ('d110', 'Asha Soni', '+919900000110', 'AB-', 26.81951, 75.84324, '2026-02-08', true), + ('d111', 'Priya Rathore', '+919900000111', 'AB+', 26.9918, 75.78725, '2025-12-24', true), + ('d112', 'Rahul Singh', '+919900000112', 'O-', 27.01481, 75.81699, null, true), + ('d113', 'Karan Kumawat', '+919900000113', 'O+', 26.84078, 75.71059, null, true), + ('d114', 'Manish Gupta', '+919900000114', 'A-', 26.94701, 75.77408, '2026-04-04', true), + ('d115', 'Arjun Sharma', '+919900000115', 'A+', 26.99438, 75.76598, '2026-04-28', true), + ('d116', 'Divya Soni', '+919900000116', 'B-', 26.98657, 75.74745, '2026-03-04', true), + ('d117', 'Amit Soni', '+919900000117', 'B+', 26.87209, 75.75777, '2026-04-07', true), + ('d118', 'Meena Rathore', '+919900000118', 'AB-', 26.96839, 75.70121, '2026-05-09', true), + ('d119', 'Anjali Kumawat', '+919900000119', 'AB+', 26.89095, 75.78228, '2026-05-22', true), + ('d120', 'Sanjay Meena', '+919900000120', 'O-', 26.94938, 75.72989, '2026-05-12', true), + ('d121', 'Vikram Soni', '+919900000121', 'O+', 26.95331, 75.70222, '2026-02-17', true), + ('d122', 'Karan Jain', '+919900000122', 'A-', 26.86668, 75.7163, '2026-01-22', true), + ('d123', 'Priya Yadav', '+919900000123', 'A+', 26.90391, 75.70785, '2026-04-07', true), + ('d124', 'Gaurav Agarwal', '+919900000124', 'B-', 26.91631, 75.90105, '2026-01-03', true), + ('d125', 'Pooja Meena', '+919900000125', 'B+', 26.84974, 75.78981, '2026-02-04', true), + ('d126', 'Sneha Saini', '+919900000126', 'AB-', 26.86049, 75.87861, '2025-12-11', true), + ('d127', 'Vivek Agarwal', '+919900000127', 'AB+', 26.92272, 75.78338, null, true), + ('d128', 'Sunita Jain', '+919900000128', 'O-', 26.91627, 75.70267, '2025-12-25', true), + ('d129', 'Sunita Singh', '+919900000129', 'O+', 26.98435, 75.79608, '2026-02-25', true), + ('d130', 'Manish Agarwal', '+919900000130', 'A-', 26.86026, 75.75441, '2026-01-24', true), + ('d131', 'Sneha Yadav', '+919900000131', 'A+', 26.80984, 75.74962, '2026-02-12', true), + ('d132', 'Amit Soni', '+919900000132', 'B-', 26.95176, 75.8291, null, true), + ('d133', 'Pooja Soni', '+919900000133', 'B+', 26.91846, 75.69881, null, true), + ('d134', 'Suresh Yadav', '+919900000134', 'AB-', 26.90044, 75.70401, '2026-01-25', true), + ('d135', 'Vikram Yadav', '+919900000135', 'AB+', 26.96551, 75.68307, '2026-03-08', true), + ('d136', 'Deepak Yadav', '+919900000136', 'O-', 26.89984, 75.78423, '2026-05-26', true), + ('d137', 'Amit Bhatt', '+919900000137', 'O+', 26.97572, 75.85947, '2026-02-03', true), + ('d138', 'Vikram Verma', '+919900000138', 'A-', 26.90984, 75.80042, '2026-03-13', true), + ('d139', 'Priya Agarwal', '+919900000139', 'A+', 26.94748, 75.74078, null, true), + ('d140', 'Neha Rathore', '+919900000140', 'B-', 26.84529, 75.74774, '2026-05-01', true), + ('d141', 'Deepak Rathore', '+919900000141', 'B+', 26.88549, 75.8731, '2026-05-09', true), + ('d142', 'Divya Jain', '+919900000142', 'AB-', 26.855, 75.74288, '2026-01-26', true), + ('d143', 'Arjun Choudhary', '+919900000143', 'AB+', 26.88219, 75.89778, '2026-02-19', true), + ('d144', 'Nisha Singh', '+919900000144', 'O-', 26.84663, 75.72657, '2026-04-16', true), + ('d145', 'Deepak Sharma', '+919900000145', 'O+', 26.96093, 75.81775, null, true), + ('d146', 'Arjun Verma', '+919900000146', 'A-', 26.8765, 75.81338, '2026-02-08', true), + ('d147', 'Priya Kumawat', '+919900000147', 'A+', 26.8985, 75.73109, '2026-02-12', true), + ('d148', 'Gaurav Rathore', '+919900000148', 'B-', 26.90763, 75.84563, '2026-01-09', true), + ('d149', 'Neha Nair', '+919900000149', 'B+', 26.81928, 75.84039, '2025-12-25', true), + ('d150', 'Arjun Choudhary', '+919900000150', 'AB-', 26.94731, 75.836, null, true), + ('d151', 'Ritu Soni', '+919900000151', 'AB+', 26.82755, 75.7431, '2026-05-08', true), + ('d152', 'Karan Jain', '+919900000152', 'O-', 26.86362, 75.82574, '2026-01-21', true), + ('d153', 'Suresh Yadav', '+919900000153', 'O+', 26.94211, 75.79582, '2026-03-18', true), + ('d154', 'Meena Verma', '+919900000154', 'A-', 26.9147, 75.80663, '2026-01-12', true), + ('d155', 'Vikram Jain', '+919900000155', 'A+', 26.86169, 75.68025, '2026-05-29', true), + ('d156', 'Sanjay Kumawat', '+919900000156', 'B-', 26.83959, 75.81831, '2026-01-10', true), + ('d157', 'Kavya Meena', '+919900000157', 'B+', 27.00811, 75.76616, '2026-02-24', true), + ('d158', 'Karan Yadav', '+919900000158', 'AB-', 26.96783, 75.70994, '2025-12-13', true), + ('d159', 'Ritu Sharma', '+919900000159', 'AB+', 26.84106, 75.84508, '2026-05-30', true), + ('d160', 'Anjali Bhatt', '+919900000160', 'O-', 26.88546, 75.84821, '2026-02-25', true), + ('d161', 'Rohan Singh', '+919900000161', 'O+', 26.85349, 75.68651, '2026-02-23', true), + ('d162', 'Rohan Singh', '+919900000162', 'A-', 26.85655, 75.7265, '2026-02-15', true), + ('d163', 'Karan Sharma', '+919900000163', 'A+', 26.95038, 75.83324, '2025-12-12', true), + ('d164', 'Vikram Yadav', '+919900000164', 'B-', 26.89983, 75.77228, '2025-12-09', true), + ('d165', 'Amit Jain', '+919900000165', 'B+', 26.83645, 75.75829, null, true), + ('d166', 'Sunita Yadav', '+919900000166', 'AB-', 26.84934, 75.69286, null, true), + ('d167', 'Rahul Jain', '+919900000167', 'AB+', 26.90109, 75.87285, '2026-02-08', true), + ('d168', 'Vivek Nair', '+919900000168', 'O-', 26.85266, 75.84743, '2026-01-29', true), + ('d169', 'Kavya Nair', '+919900000169', 'O+', 26.94856, 75.80632, '2026-02-22', true), + ('d170', 'Vivek Yadav', '+919900000170', 'A-', 26.90207, 75.89428, null, true), + ('d171', 'Ritu Saini', '+919900000171', 'A+', 26.82568, 75.7339, '2026-05-01', true), + ('d172', 'Ritu Nair', '+919900000172', 'B-', 26.87515, 75.86061, '2026-03-04', true), + ('d173', 'Divya Singh', '+919900000173', 'B+', 26.93546, 75.85103, '2026-01-13', true), + ('d174', 'Sunita Nair', '+919900000174', 'AB-', 26.82769, 75.84694, '2026-02-07', true), + ('d175', 'Neha Jain', '+919900000175', 'AB+', 26.83892, 75.85896, '2025-12-24', true), + ('d176', 'Amit Bhatt', '+919900000176', 'O-', 26.9869, 75.87216, '2026-03-16', true), + ('d177', 'Vivek Gupta', '+919900000177', 'O+', 26.99291, 75.85264, '2026-06-05', true), + ('d178', 'Karan Rathore', '+919900000178', 'A-', 26.87341, 75.72316, null, true), + ('d179', 'Priya Bhatt', '+919900000179', 'A+', 26.85619, 75.88865, '2025-12-24', true), + ('d180', 'Nisha Soni', '+919900000180', 'B-', 26.88404, 75.72551, '2026-03-19', true) +on conflict (id) do nothing; diff --git a/backend/store.py b/backend/store.py index 9af75fd..faec246 100644 --- a/backend/store.py +++ b/backend/store.py @@ -186,6 +186,10 @@ def record_reply(self, token: str, accepted: bool) -> None: if accepted: emergency["status"] = "confirmed" + def ping(self) -> bool: + """Readiness check — the in-memory store is always ready.""" + return True + # =========================================================================== # Supabase store (production) @@ -388,6 +392,15 @@ def record_reply(self, token: str, accepted: bool) -> None: "id", conf["emergency_id"] ).execute() + def ping(self) -> bool: + """Readiness check — confirm the database answers a trivial query.""" + try: + self.client.table("hospitals").select("id").limit(1).execute() + return True + except Exception: + logger.exception("Supabase ping failed") + return False + # =========================================================================== # Store selection @@ -409,9 +422,10 @@ def get_store(): try: from supabase import create_client - client = create_client(settings.supabase_url, settings.supabase_key) + client = create_client(settings.supabase_url, settings.supabase_active_key) _store = SupabaseStore(client) - logger.info("Data layer: Supabase (PostGIS)") + key_kind = "service-role" if settings.supabase_service_key else "anon" + logger.info("Data layer: Supabase (PostGIS), %s key", key_kind) except Exception: logger.exception("Supabase init failed — falling back to in-memory store") _store = InMemoryStore() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 59f8d84..01f77f1 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -18,6 +18,14 @@ def test_health(client): assert "version" in body and "mode" in body +def test_ready(client): + resp = client.get("/ready") + assert resp.status_code == 200 + body = resp.json() + assert body["ready"] is True + assert body["backend"] == "in-memory" + + # --- POST /emergency ------------------------------------------------------- def test_emergency_returns_contract_shape(client): resp = _trigger(client) diff --git a/backend/tests/test_supabase_store.py b/backend/tests/test_supabase_store.py index 083a339..76a423c 100644 --- a/backend/tests/test_supabase_store.py +++ b/backend/tests/test_supabase_store.py @@ -16,6 +16,10 @@ def _store(): return SupabaseStore(FakeSupabaseClient()) +def test_ping_ok(): + assert _store().ping() is True + + def test_hospitals_with_distance_reads_table(): store = _store() rows = store.hospitals_with_distance(26.9124, 75.7873) diff --git a/render.yaml b/render.yaml index 6794aef..bf3d97f 100644 --- a/render.yaml +++ b/render.yaml @@ -13,6 +13,9 @@ services: sync: false - key: SUPABASE_ANON_KEY sync: false + # Trusted server-side key (preferred for backend writes; bypasses RLS). + - key: SUPABASE_SERVICE_KEY + sync: false - key: DATABASE_URL sync: false - key: GOOGLE_MAPS_API_KEY From c63155e21c180619694c9d122a9fb61e24994281 Mon Sep 17 00:00:00 2001 From: Prajnadeep Sarma Date: Thu, 18 Jun 2026 23:46:45 +0530 Subject: [PATCH 04/32] Align backend behavior with the pitch deck - Gate hospital rel(h) on >=20 logged confirmations and renormalize the ranking to proximity + department until then, so a fresh deployment ranks on prox+dept only instead of trusting seeded reliability it hasn't earned. - Dispatch blood-needed alerts to the nearest top-K matched donors with an explicit "licensed blood bank, not the emergency ward" directive; expose recently sent alerts at GET /dev/alerts. - Make the donation cooldown sex-aware (90 days men / 120 days women) via an optional `sex` field on donor registration; absent it, defaults to 90 days. - Add rare_group (Rh-negative) to the /emergency response and unconfirmed_fallback to the /status response. - Update API_CONTRACT, backend README, .env.example, and the SQL schema/seed to match; add 4 tests (30 total, all offline). --- API_CONTRACT.md | 17 +- backend/.env.example | 4 + backend/README.md | 26 +- backend/blood.py | 11 + backend/build_seed_sql.py | 6 +- backend/config.py | 10 + backend/main.py | 7 + backend/schemas.py | 9 + backend/seed_data.py | 10 +- backend/services/donor_service.py | 3 + backend/services/emergency_service.py | 20 +- backend/services/hospital_service.py | 23 +- backend/services/sms_service.py | 44 ++++ backend/sql/schema.sql | 17 +- backend/sql/seed.sql | 362 +++++++++++++------------- backend/store.py | 46 +++- backend/tests/fake_supabase.py | 5 +- backend/tests/test_api.py | 20 +- backend/tests/test_units.py | 32 ++- 19 files changed, 459 insertions(+), 213 deletions(-) diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 08d0bdc..7411e20 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -41,11 +41,14 @@ This is the single most important document for the team. Both the backend (You) "phone": "+91xxxxxxxxxx" } ], - "donors_alerted": 5 + "donors_alerted": 5, + "rare_group": false } ``` - `hospitals` is an array, already sorted best-first by the backend. - `donors_alerted` is just a count (integer) for the UI to display. +- `rare_group` (boolean) is `true` when the needed group is Rh-negative (scarce + supply); the UI can warn that compatible donors may be few. Safe to ignore. ## Endpoint 2 — Poll / subscribe to emergency status `GET /emergency/{request_id}/status` @@ -59,9 +62,13 @@ This is the single most important document for the team. Both the backend (You) { "hospital_id": "h2", "name": "Fortis Jaipur", "eta_minutes": 9, "status": "pending" } ], "donors_alerted": 5, - "donors_responded": 2 + "donors_responded": 2, + "unconfirmed_fallback": false } ``` +- `unconfirmed_fallback` (boolean) becomes `true` once ~3 minutes pass with no + hospital confirmed. Cue for the UI to surface the nearest hospitals with a + 1-tap call button (the phones from the `POST /emergency` response). Safe to ignore. ## Endpoint 3 — Hospital taps Accept / Decline `POST /confirm/{token}` @@ -91,10 +98,14 @@ This is hit by the hospital confirmation page when the contact taps a button. "blood_group": "B+", "lat": 26.9200, "lng": 75.8000, - "last_donated": "2025-01-15" + "last_donated": "2025-01-15", + "sex": "female" } ``` `last_donated` is a date string "YYYY-MM-DD", or null if never donated. +`sex` is **optional** — `"male"` or `"female"`. It only sets the donation +cooldown (120 days for women, 90 otherwise). Omit it and the backend defaults to +90 days; existing forms need no change. **Backend returns:** ```json diff --git a/backend/.env.example b/backend/.env.example index 68c376c..78dd0e9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -35,4 +35,8 @@ RANKING_TOP_N=5 ETA_MAX_MINUTES=30 DONOR_RADIUS_METERS=5000 DONOR_COOLDOWN_DAYS=90 +DONOR_COOLDOWN_DAYS_FEMALE=120 +DONOR_ALERT_K=10 +REL_ACTIVATION_THRESHOLD=20 +UNCONFIRMED_FALLBACK_SECONDS=180 CITY_AVG_SPEED_KMPH=22 diff --git a/backend/README.md b/backend/README.md index 4f4d50b..f582e61 100644 --- a/backend/README.md +++ b/backend/README.md @@ -123,6 +123,7 @@ docs are served at `/docs` (Swagger) and `/redoc` when the app is running. | `GET /health` | Liveness + which mode the app is running in (cheap, no I/O). | | `GET /ready` | Readiness — pings the data layer; 503 if the DB is unreachable. | | `GET /dev/links` | Demo helper: the confirmation links recently "sent" to hospitals. | +| `GET /dev/alerts` | Demo helper: the blood-needed alerts recently "sent" to donors. | --- @@ -136,12 +137,23 @@ P(h) = 0.5 · prox(h) + 0.3 · dept(h) + 0.2 · rel(h) prox(h) = 1 − (eta / eta_max), clamped to [0, 1] # closer is better dept(h) = 1 if the needed department is present else 0 -rel(h) = avg_response_rate # historically reliable +rel(h) = live acceptance rate # historically reliable ``` +**Reliability cold start.** `rel(h)` only enters the score once a hospital has +logged ≥ `REL_ACTIVATION_THRESHOLD` (default 20) confirmations — we have no +honest reliability signal before then. Until a hospital crosses that bar the +score degrades to `prox + dept`, renormalised to span [0, 1]. At launch (no +confirmations yet) ranking is pure proximity + department. If nothing confirms +within ~3 minutes the status response flags `unconfirmed_fallback` so the UI can +surface the nearest hospitals with a 1-tap call — never a false "bed available." + **2. Blood matching** (`services/donor_service.py` + `blood.py`) — compatible -donor groups (hardcoded ABO table), available, within 5 km, and off the 90-day -cooldown (`last_donated IS NULL OR last_donated < today − 90d`), nearest first. +donor groups (hardcoded ABO table), available, within 5 km, and off the +sex-aware cooldown (90 days men / 120 days women — `last_donated IS NULL OR +last_donated < today − cooldown`), nearest first. The nearest top-K are then +alerted (`services/sms_service.py`) with directions to the hospital's **licensed +blood bank**, not the emergency ward. Rh-negative requests are flagged `rare_group`. **3. Confirmation flow** (`services/confirm_service.py`) — the first hospital to accept "takes" the patient; any later acceptance gets `already_confirmed: true` @@ -203,7 +215,11 @@ Everything is environment-driven (see [`.env.example`](.env.example)). Highlight | `FRONTEND_URL` | `http://localhost:5173` | Base for `/confirm/` links. | | `CORS_ORIGINS` | `*` | Comma-separated allowed origins. | | `DONOR_RADIUS_METERS` | `5000` | Donor search radius. | -| `DONOR_COOLDOWN_DAYS` | `90` | Post-donation ineligibility window. | +| `DONOR_COOLDOWN_DAYS` | `90` | Post-donation cooldown (men / default). | +| `DONOR_COOLDOWN_DAYS_FEMALE` | `120` | Post-donation cooldown for women. | +| `DONOR_ALERT_K` | `10` | Nearest matched donors actually alerted per emergency. | +| `REL_ACTIVATION_THRESHOLD` | `20` | Confirmations a hospital needs before `rel(h)` counts. | +| `UNCONFIRMED_FALLBACK_SECONDS` | `180` | No-confirmation window before status flags the 1-tap-call fallback. | | `RANKING_TOP_N` | `5` | Hospitals returned per emergency. | --- @@ -213,7 +229,7 @@ Everything is environment-driven (see [`.env.example`](.env.example)). Highlight ```bash cd backend pip install -r requirements-dev.txt -pytest # 26 tests: algorithms + endpoint contract + live flow + Supabase path +pytest # 30 tests: algorithms + endpoint contract + live flow + Supabase path ``` The suite runs fully offline (no Supabase, no network) and verifies the exact diff --git a/backend/blood.py b/backend/blood.py index 879ece6..fa2d6bb 100644 --- a/backend/blood.py +++ b/backend/blood.py @@ -11,6 +11,17 @@ ALL_GROUPS: List[str] = ["O-", "O+", "A-", "A+", "B-", "B+", "AB-", "AB+"] +# Rh-negative groups are scarce in India (~5% of the population). Flag them so +# the UI can warn that compatible donors may be few and rare-group blood banks +# should be contacted in parallel. +RARE_GROUPS = frozenset({"O-", "A-", "B-", "AB-"}) + + +def is_rare_group(recipient_group: str) -> bool: + """True when the needed blood group is Rh-negative (scarce supply).""" + return recipient_group in RARE_GROUPS + + # recipient blood group -> list of donor groups that may donate to them _COMPATIBLE_DONORS = { "O-": ["O-"], diff --git a/backend/build_seed_sql.py b/backend/build_seed_sql.py index 78bc2e4..9a78d2c 100644 --- a/backend/build_seed_sql.py +++ b/backend/build_seed_sql.py @@ -52,13 +52,14 @@ def build() -> str: out += [ "-- Donors --------------------------------------------------------------------", "insert into blood_donors (id, name, phone, blood_group, lat, lng, " - "last_donated, available) values", + "last_donated, sex, available) values", ] rows = [] for d in seed_data.donors(): last = "'%s'" % d["last_donated"] if d["last_donated"] else "null" + sex = "'%s'" % d["sex"] if d.get("sex") else "null" rows.append( - " ('{id}', '{name}', '{phone}', '{bg}', {lat}, {lng}, {last}, {av})".format( + " ('{id}', '{name}', '{phone}', '{bg}', {lat}, {lng}, {last}, {sex}, {av})".format( id=d["id"], name=_esc(d["name"]), phone=d["phone"], @@ -66,6 +67,7 @@ def build() -> str: lat=d["lat"], lng=d["lng"], last=last, + sex=sex, av=str(d["available"]).lower(), ) ) diff --git a/backend/config.py b/backend/config.py index 20fe899..a109458 100644 --- a/backend/config.py +++ b/backend/config.py @@ -46,7 +46,17 @@ class Settings: ranking_top_n: int = int(os.getenv("RANKING_TOP_N", "5")) eta_max_minutes: float = float(os.getenv("ETA_MAX_MINUTES", "30")) donor_radius_meters: float = float(os.getenv("DONOR_RADIUS_METERS", "5000")) + # Post-donation cooldown, differentiated by sex per national blood-donation + # guidelines: 90 days (men) / 120 days (women). donor_cooldown_days: int = int(os.getenv("DONOR_COOLDOWN_DAYS", "90")) + donor_cooldown_days_female: int = int(os.getenv("DONOR_COOLDOWN_DAYS_FEMALE", "120")) + # Nearest matched donors actually alerted per emergency (top-K). + donor_alert_k: int = int(os.getenv("DONOR_ALERT_K", "10")) + # rel(h) enters the hospital score only after a hospital has logged this many + # confirmations; until then ranking degrades to proximity + department. + rel_activation_threshold: int = int(os.getenv("REL_ACTIVATION_THRESHOLD", "20")) + # Seconds with no confirmation before status flags the 1-tap-call fallback. + unconfirmed_fallback_seconds: int = int(os.getenv("UNCONFIRMED_FALLBACK_SECONDS", "180")) city_avg_speed_kmph: float = float(os.getenv("CITY_AVG_SPEED_KMPH", "22")) # --- Link / message delivery ------------------------------------------- diff --git a/backend/main.py b/backend/main.py index 2101e68..d57fd4b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -128,6 +128,7 @@ async def register_donor(request: DonorRegisterRequest): lat=request.lat, lng=request.lng, last_donated=request.last_donated, + sex=request.sex, ) return DonorRegisterResponse(ok=True, donor_id=donor_id) @@ -145,3 +146,9 @@ async def handle_sms_inbound(request: SmsInboundRequest): async def dev_links(): """Demo helper: the confirmation links recently 'sent' to hospitals.""" return {"links": sms_service.recent_links} + + +@app.get("/dev/alerts", tags=["meta"]) +async def dev_alerts(): + """Demo helper: the blood-needed alerts recently 'sent' to donors.""" + return {"alerts": sms_service.recent_alerts} diff --git a/backend/schemas.py b/backend/schemas.py index b963aaf..ea88e2d 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -37,6 +37,9 @@ class EmergencyResponse(BaseModel): request_id: str hospitals: List[HospitalCard] donors_alerted: int + # True when the needed group is Rh-negative (scarce supply) — the UI warns + # that compatible donors may be few and rare-group banks should be contacted. + rare_group: bool = False # --- GET /emergency/{request_id}/status ------------------------------------ @@ -52,6 +55,9 @@ class EmergencyStatusResponse(BaseModel): hospitals: List[HospitalStatusCard] donors_alerted: int donors_responded: int + # True after the no-confirmation window elapses with nothing confirmed — cue + # for the UI to surface the nearest-hospitals 1-tap-call fallback. + unconfirmed_fallback: bool = False # --- POST /confirm/{token} ------------------------------------------------- @@ -73,6 +79,9 @@ class DonorRegisterRequest(BaseModel): lat: float lng: float last_donated: Optional[str] = None # "YYYY-MM-DD" or null + # Optional. Drives the post-donation cooldown: 120 days for "female", + # 90 days otherwise. Omitting it defaults to the 90-day window. + sex: Optional[Literal["male", "female"]] = None class DonorRegisterResponse(BaseModel): diff --git a/backend/seed_data.py b/backend/seed_data.py index 72c721f..84f5a80 100644 --- a/backend/seed_data.py +++ b/backend/seed_data.py @@ -2,9 +2,9 @@ ~25 real Jaipur hospitals with approximate coordinates and varied departments, plus ~180 donors spread across all 8 blood groups and varied distances. The -``last_donated`` dates are deliberately spread so the 90-day cooldown filter -visibly excludes some donors (the eligibility logic actually does something in -the demo). +``last_donated`` dates are deliberately spread so the sex-aware cooldown filter +(90 days men / 120 days women) visibly excludes some donors (the eligibility +logic actually does something in the demo). Generation is seeded, so every boot and every run of ``seed_supabase.py`` produces the same dataset — reproducible demos, stable tests. @@ -104,9 +104,10 @@ def donors(count: int = 180, seed: int = 42) -> List[Dict]: for i in range(1, count + 1): lat, lng = _scatter(rng, max_km=12.0) group = ALL_GROUPS[i % len(ALL_GROUPS)] + sex = "female" if rng.random() < 0.5 else "male" # Spread donation history: ~25% never donated, the rest 10–200 days ago - # so some fall inside the 90-day cooldown and some don't. + # so some fall inside the cooldown (90d men / 120d women) and some don't. roll = rng.random() if roll < 0.25: last_donated = None @@ -123,6 +124,7 @@ def donors(count: int = 180, seed: int = 42) -> List[Dict]: "lat": lat, "lng": lng, "last_donated": last_donated, + "sex": sex, "available": True, } ) diff --git a/backend/services/donor_service.py b/backend/services/donor_service.py index 2244f1c..1099508 100644 --- a/backend/services/donor_service.py +++ b/backend/services/donor_service.py @@ -37,6 +37,7 @@ def match_donors( compatible_groups=compatible, radius_meters=settings.donor_radius_meters, cooldown_days=settings.donor_cooldown_days, + cooldown_days_female=settings.donor_cooldown_days_female, today=today, ) @@ -49,6 +50,7 @@ def register_donor( lat: float, lng: float, last_donated: Optional[str], + sex: Optional[str] = None, ) -> str: """Add a donor to the registry and return the new donor id.""" from services.phone_utils import normalize_phone @@ -61,4 +63,5 @@ def register_donor( lat=lat, lng=lng, last_donated=last_donated, + sex=sex, ) diff --git a/backend/services/emergency_service.py b/backend/services/emergency_service.py index 689c56e..32bc21b 100644 --- a/backend/services/emergency_service.py +++ b/backend/services/emergency_service.py @@ -16,16 +16,19 @@ from datetime import datetime, timezone from typing import Dict +from blood import is_rare_group from config import settings from services.donor_service import match_donors from services.hospital_service import rank_hospitals -from services.sms_service import deliver_confirmation_link +from services.sms_service import alert_donors, deliver_confirmation_link async def trigger_emergency(store, lat, lng, emergency_type, blood_group) -> Dict: """Run the full emergency fan-out and return the API response payload.""" ranked = await rank_hospitals(store, lat, lng, emergency_type) donors = match_donors(store, lat, lng, blood_group) + # Blood branch: actually dispatch alerts to the nearest top-K donors. + donors_alerted = alert_donors(donors, blood_group) # Public hospital cards (no internal scoring fields). cards = [ @@ -47,7 +50,7 @@ async def trigger_emergency(store, lat, lng, emergency_type, blood_group) -> Dic emergency_type=emergency_type, blood_group=blood_group, hospital_cards=cards, - donors_alerted=len(donors), + donors_alerted=donors_alerted, ) # One confirmation request + link per hospital. @@ -69,7 +72,8 @@ async def trigger_emergency(store, lat, lng, emergency_type, blood_group) -> Dic return { "request_id": emergency["id"], "hospitals": cards, - "donors_alerted": len(donors), + "donors_alerted": donors_alerted, + "rare_group": is_rare_group(blood_group), } @@ -85,10 +89,12 @@ def get_status(store, request_id: str) -> Dict: for c in store.confirmations_for_emergency(request_id) } hospital_cards = [] + any_confirmed = False for card in emergency["hospitals"]: conf = confirmations.get(card["hospital_id"]) if conf and conf["confirmed"] is True: status = "confirmed" + any_confirmed = True elif conf and conf["confirmed"] is False: status = "declined" else: @@ -102,11 +108,19 @@ def get_status(store, request_id: str) -> Dict: } ) + # If nothing has confirmed within the window, tell the UI to surface the + # nearest-hospitals 1-tap-call fallback (slide 7: "never show a false bed"). + elapsed = (datetime.now(timezone.utc) - emergency["created_at"]).total_seconds() + unconfirmed_fallback = ( + not any_confirmed and elapsed >= settings.unconfirmed_fallback_seconds + ) + return { "request_id": request_id, "hospitals": hospital_cards, "donors_alerted": emergency["donors_alerted"], "donors_responded": _simulated_responses(emergency), + "unconfirmed_fallback": unconfirmed_fallback, } diff --git a/backend/services/hospital_service.py b/backend/services/hospital_service.py index de4f46c..2466b9c 100644 --- a/backend/services/hospital_service.py +++ b/backend/services/hospital_service.py @@ -7,7 +7,12 @@ prox(h) = 1 - (eta / eta_max), clamped to [0, 1] -> closer is better dept(h) = 1 if the needed department is present, else 0 - rel(h) = avg_response_rate (0..1) -> historically reliable + rel(h) = live acceptance rate (0..1) -> historically reliable + +Cold start: rel(h) only enters the score once a hospital has logged at least +``REL_ACTIVATION_THRESHOLD`` confirmations. We have no honest reliability signal +before then, so the score degrades to proximity + department, renormalised to +span [0, 1]. At launch (no confirmations yet) ranking is pure prox + dept. """ from __future__ import annotations @@ -30,6 +35,19 @@ def _proximity_score(eta: int) -> float: return max(0.0, min(1.0, raw)) +def _score(store, hospital_id: str, prox: float, dept: float) -> float: + """Blend proximity, department, and (only once trustworthy) reliability. + + rel(h) is the hospital's live acceptance rate, included only after it has + logged >= REL_ACTIVATION_THRESHOLD confirmations. Until then the score is + proximity + department, renormalised by 0.8 so it still spans [0, 1]. + """ + replied, accept_rate = store.hospital_reliability(hospital_id) + if replied >= settings.rel_activation_threshold: + return 0.5 * prox + 0.3 * dept + 0.2 * accept_rate + return (0.5 * prox + 0.3 * dept) / 0.8 + + async def rank_hospitals( store, lat: float, @@ -57,8 +75,7 @@ async def rank_hospitals( dept_match = needed_dept in h.get("departments", []) prox = _proximity_score(eta) dept = 1.0 if dept_match else 0.0 - rel = float(h.get("avg_response_rate", 0.0)) - score = 0.5 * prox + 0.3 * dept + 0.2 * rel + score = _score(store, h["id"], prox, dept) scored.append( { diff --git a/backend/services/sms_service.py b/backend/services/sms_service.py index 909a3f3..b7ccaaa 100644 --- a/backend/services/sms_service.py +++ b/backend/services/sms_service.py @@ -28,6 +28,9 @@ # Most-recently delivered confirmation links, newest first — backs /dev/links. recent_links: List[Dict] = [] +# Most-recently dispatched donor alerts, newest first — backs /dev/alerts. +recent_alerts: List[Dict] = [] + # Match blood groups in free text, longest first so "AB+" wins over "B+". _GROUP_RE = re.compile( r"\b(" + "|".join(sorted((re.escape(g) for g in ALL_GROUPS), key=len, reverse=True)) + r")\b", @@ -55,6 +58,47 @@ def deliver_confirmation_link(hospital_name: str, contact_phone: str, token: str return link +def alert_donors(donors: List[Dict], blood_group_needed: str) -> int: + """"Alert" the nearest K matched donors that blood is needed; return the count. + + Demo uses the same low-friction channels as the hospital links + (console/Telegram/log); production swaps in MSG91/Gupshup + DLT. The message + is explicit about the operational reality from the PRD: a donor must report + to a hospital's LICENSED BLOOD BANK, never the emergency ward, or they will + be turned away. This is replacement donation (hours-later), not acute supply. + """ + top = donors[: settings.donor_alert_k] + for d in top: + message = ( + f"GoldenHour: blood urgently needed near you (patient needs " + f"{blood_group_needed}). If eligible, please donate at a hospital's " + f"LICENSED BLOOD BANK - not the emergency ward. Your donation " + f"replaces what the patient's surgery uses." + ) + recent_alerts.insert( + 0, + { + "phone": d["phone"], + "blood_group": d["blood_group"], + "distance_m": d.get("distance_m"), + "message": message, + }, + ) + if settings.delivery_channel == "console": + logger.info("[DONOR ALERT] %s (%s)", d["phone"], d["blood_group"]) + print(f"[DONOR ALERT] {d['phone']} ({d['blood_group']}): {message}") + elif settings.delivery_channel == "telegram": + _send_telegram(message) + else: + logger.info( + "Donor alert for %s queued for %s delivery", + d["phone"], + settings.delivery_channel, + ) + del recent_alerts[100:] # keep the page short + return len(top) + + def _send_telegram(text: str) -> None: if not (settings.telegram_bot_token and settings.telegram_chat_id): logger.warning("Telegram delivery requested but bot token/chat id missing") diff --git a/backend/sql/schema.sql b/backend/sql/schema.sql index 9c3e0ee..b6f91c9 100644 --- a/backend/sql/schema.sql +++ b/backend/sql/schema.sql @@ -39,6 +39,7 @@ create table if not exists blood_donors ( generated always as (ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) stored, last_donated date, -- null = never donated + sex text, -- 'male' | 'female' | null available boolean not null default true ); @@ -87,11 +88,12 @@ create index if not exists idx_conf_emergency on confirmation_requests (emergenc -- Call via supabase.rpc('donors_nearby', {...}). Mirrors donor_service logic. -- --------------------------------------------------------------------------- create or replace function donors_nearby( - p_lat double precision, - p_lng double precision, - p_groups text[], - p_radius_m double precision default 5000, - p_cooldown_days integer default 90 + p_lat double precision, + p_lng double precision, + p_groups text[], + p_radius_m double precision default 5000, + p_cooldown_days integer default 90, + p_cooldown_days_female integer default 120 ) returns setof blood_donors language sql @@ -102,7 +104,10 @@ as $$ where d.blood_group = any(p_groups) and d.available = true and (d.last_donated is null - or d.last_donated < current_date - (p_cooldown_days || ' days')::interval) + or d.last_donated < current_date + - ((case when d.sex = 'female' + then p_cooldown_days_female + else p_cooldown_days end) || ' days')::interval) and ST_DWithin( d.location, ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography, diff --git a/backend/sql/seed.sql b/backend/sql/seed.sql index a3106b0..ed203eb 100644 --- a/backend/sql/seed.sql +++ b/backend/sql/seed.sql @@ -31,185 +31,185 @@ insert into hospitals (id, name, lat, lng, departments, beds_available, avg_resp on conflict (id) do nothing; -- Donors -------------------------------------------------------------------- -insert into blood_donors (id, name, phone, blood_group, lat, lng, last_donated, available) values - ('d1', 'Neha Soni', '+919900000001', 'O+', 26.99778, 75.80247, '2026-04-12', true), - ('d2', 'Sanjay Verma', '+919900000002', 'A-', 26.91037, 75.74855, '2026-01-08', true), - ('d3', 'Deepak Singh', '+919900000003', 'A+', 26.91605, 75.80782, '2026-06-02', true), - ('d4', 'Meena Yadav', '+919900000004', 'B-', 26.88486, 75.68947, '2026-02-14', true), - ('d5', 'Sanjay Meena', '+919900000005', 'B+', 27.00958, 75.79175, '2025-12-12', true), - ('d6', 'Priya Agarwal', '+919900000006', 'AB-', 26.92472, 75.8497, '2026-05-13', true), - ('d7', 'Vivek Jain', '+919900000007', 'AB+', 26.93173, 75.75645, '2026-05-28', true), - ('d8', 'Arjun Bhatt', '+919900000008', 'O-', 26.99044, 75.77237, '2026-01-18', true), - ('d9', 'Sunita Sharma', '+919900000009', 'O+', 26.97686, 75.72394, '2026-04-20', true), - ('d10', 'Rahul Bhatt', '+919900000010', 'A-', 26.9156, 75.813, '2026-05-19', true), - ('d11', 'Vikram Meena', '+919900000011', 'A+', 26.90639, 75.82524, '2026-03-07', true), - ('d12', 'Nisha Sharma', '+919900000012', 'B-', 26.88142, 75.72394, '2025-12-16', true), - ('d13', 'Ritu Agarwal', '+919900000013', 'B+', 26.95252, 75.87054, '2026-04-28', true), - ('d14', 'Gaurav Meena', '+919900000014', 'AB-', 26.96251, 75.75884, '2026-04-13', true), - ('d15', 'Rohan Rathore', '+919900000015', 'AB+', 26.92854, 75.67748, null, true), - ('d16', 'Meena Nair', '+919900000016', 'O-', 26.90565, 75.85497, null, true), - ('d17', 'Ritu Gupta', '+919900000017', 'O+', 26.93371, 75.88721, '2025-12-26', true), - ('d18', 'Vivek Choudhary', '+919900000018', 'A-', 26.91358, 75.84968, '2026-04-02', true), - ('d19', 'Anjali Jain', '+919900000019', 'A+', 26.85116, 75.74754, '2026-05-04', true), - ('d20', 'Nisha Gupta', '+919900000020', 'B-', 26.94358, 75.79796, null, true), - ('d21', 'Divya Choudhary', '+919900000021', 'B+', 26.82746, 75.83799, null, true), - ('d22', 'Gaurav Soni', '+919900000022', 'AB-', 26.8063, 75.76529, '2026-06-06', true), - ('d23', 'Kavya Sharma', '+919900000023', 'AB+', 26.93981, 75.76012, '2025-12-26', true), - ('d24', 'Vivek Nair', '+919900000024', 'O-', 26.94435, 75.84235, null, true), - ('d25', 'Amit Bhatt', '+919900000025', 'O+', 27.00269, 75.76923, '2026-01-30', true), - ('d26', 'Neha Meena', '+919900000026', 'A-', 26.95896, 75.7069, '2026-04-19', true), - ('d27', 'Asha Choudhary', '+919900000027', 'A+', 26.82087, 75.76137, '2026-01-24', true), - ('d28', 'Rahul Verma', '+919900000028', 'B-', 26.97349, 75.79573, '2026-03-22', true), - ('d29', 'Vivek Jain', '+919900000029', 'B+', 26.86402, 75.76286, null, true), - ('d30', 'Neha Kumawat', '+919900000030', 'AB-', 27.00913, 75.7722, '2026-05-07', true), - ('d31', 'Sanjay Singh', '+919900000031', 'AB+', 26.84149, 75.76157, '2026-01-04', true), - ('d32', 'Arjun Agarwal', '+919900000032', 'O-', 26.91585, 75.67051, '2025-12-08', true), - ('d33', 'Ritu Sharma', '+919900000033', 'O+', 26.84894, 75.68951, '2026-01-27', true), - ('d34', 'Deepak Singh', '+919900000034', 'A-', 26.96193, 75.81093, null, true), - ('d35', 'Rahul Sharma', '+919900000035', 'A+', 26.99524, 75.7915, '2026-05-24', true), - ('d36', 'Rahul Yadav', '+919900000036', 'B-', 26.97779, 75.69824, null, true), - ('d37', 'Meena Choudhary', '+919900000037', 'B+', 26.9321, 75.88397, null, true), - ('d38', 'Amit Sharma', '+919900000038', 'AB-', 26.92844, 75.70591, '2026-04-21', true), - ('d39', 'Rohan Kumawat', '+919900000039', 'AB+', 26.85892, 75.86533, '2025-12-04', true), - ('d40', 'Vivek Meena', '+919900000040', 'O-', 26.85935, 75.70942, null, true), - ('d41', 'Deepak Jain', '+919900000041', 'O+', 26.98721, 75.85606, null, true), - ('d42', 'Ritu Rathore', '+919900000042', 'A-', 26.92883, 75.82879, '2026-05-20', true), - ('d43', 'Deepak Bhatt', '+919900000043', 'A+', 26.81704, 75.75231, null, true), - ('d44', 'Vikram Agarwal', '+919900000044', 'B-', 26.92334, 75.79545, '2026-04-09', true), - ('d45', 'Vikram Agarwal', '+919900000045', 'B+', 26.92941, 75.86961, '2026-05-24', true), - ('d46', 'Arjun Agarwal', '+919900000046', 'AB-', 26.90852, 75.79088, '2026-02-12', true), - ('d47', 'Sunita Jain', '+919900000047', 'AB+', 26.90138, 75.68687, '2025-12-21', true), - ('d48', 'Vivek Saini', '+919900000048', 'O-', 26.9001, 75.83296, '2026-01-11', true), - ('d49', 'Karan Saini', '+919900000049', 'O+', 26.90201, 75.81487, null, true), - ('d50', 'Anjali Sharma', '+919900000050', 'A-', 26.81072, 75.76471, null, true), - ('d51', 'Rahul Agarwal', '+919900000051', 'A+', 27.00305, 75.83399, null, true), - ('d52', 'Suresh Verma', '+919900000052', 'B-', 26.94128, 75.7606, null, true), - ('d53', 'Kavya Nair', '+919900000053', 'B+', 26.83804, 75.83367, '2026-01-26', true), - ('d54', 'Divya Gupta', '+919900000054', 'AB-', 26.88572, 75.7331, '2026-04-02', true), - ('d55', 'Asha Jain', '+919900000055', 'AB+', 26.88502, 75.8818, '2026-05-21', true), - ('d56', 'Deepak Singh', '+919900000056', 'O-', 26.83378, 75.75046, null, true), - ('d57', 'Rahul Meena', '+919900000057', 'O+', 26.96417, 75.85106, '2026-05-22', true), - ('d58', 'Suresh Rathore', '+919900000058', 'A-', 26.85899, 75.81181, '2026-03-23', true), - ('d59', 'Gaurav Sharma', '+919900000059', 'A+', 26.99972, 75.79212, '2026-03-24', true), - ('d60', 'Amit Soni', '+919900000060', 'B-', 26.98203, 75.87507, null, true), - ('d61', 'Kavya Singh', '+919900000061', 'B+', 26.90115, 75.87659, '2025-12-07', true), - ('d62', 'Rohan Sharma', '+919900000062', 'AB-', 26.96641, 75.70707, '2026-04-05', true), - ('d63', 'Kavya Rathore', '+919900000063', 'AB+', 26.95357, 75.7025, null, true), - ('d64', 'Ritu Saini', '+919900000064', 'O-', 26.95119, 75.78175, null, true), - ('d65', 'Sunita Nair', '+919900000065', 'O+', 26.82815, 75.74927, null, true), - ('d66', 'Neha Agarwal', '+919900000066', 'A-', 26.95307, 75.79781, '2026-01-18', true), - ('d67', 'Manish Singh', '+919900000067', 'A+', 26.89859, 75.82771, '2026-05-29', true), - ('d68', 'Sanjay Choudhary', '+919900000068', 'B-', 26.86789, 75.7005, '2026-01-16', true), - ('d69', 'Vikram Rathore', '+919900000069', 'B+', 26.99599, 75.7401, null, true), - ('d70', 'Vivek Nair', '+919900000070', 'AB-', 26.98395, 75.71328, null, true), - ('d71', 'Rahul Yadav', '+919900000071', 'AB+', 26.96891, 75.75787, '2025-12-02', true), - ('d72', 'Karan Singh', '+919900000072', 'O-', 26.8994, 75.74118, '2026-05-30', true), - ('d73', 'Rahul Verma', '+919900000073', 'O+', 26.95449, 75.76075, '2026-04-11', true), - ('d74', 'Sneha Meena', '+919900000074', 'A-', 26.84185, 75.84596, '2026-05-22', true), - ('d75', 'Kavya Verma', '+919900000075', 'A+', 26.84245, 75.84453, '2026-01-22', true), - ('d76', 'Sneha Verma', '+919900000076', 'B-', 26.94848, 75.77969, null, true), - ('d77', 'Sanjay Choudhary', '+919900000077', 'B+', 26.87977, 75.80325, '2026-03-20', true), - ('d78', 'Sneha Verma', '+919900000078', 'AB-', 26.99256, 75.86721, '2026-04-21', true), - ('d79', 'Gaurav Soni', '+919900000079', 'AB+', 27.00341, 75.78838, '2026-01-22', true), - ('d80', 'Sanjay Sharma', '+919900000080', 'O-', 26.90387, 75.67013, null, true), - ('d81', 'Amit Soni', '+919900000081', 'O+', 27.00493, 75.73091, '2025-12-21', true), - ('d82', 'Divya Soni', '+919900000082', 'A-', 26.80994, 75.78215, '2026-03-17', true), - ('d83', 'Gaurav Soni', '+919900000083', 'A+', 26.95336, 75.83458, '2026-03-03', true), - ('d84', 'Asha Yadav', '+919900000084', 'B-', 26.83557, 75.71084, '2026-01-19', true), - ('d85', 'Kavya Jain', '+919900000085', 'B+', 26.86002, 75.81499, '2025-12-23', true), - ('d86', 'Vikram Kumawat', '+919900000086', 'AB-', 26.88003, 75.71536, '2025-12-02', true), - ('d87', 'Priya Bhatt', '+919900000087', 'AB+', 26.88107, 75.78388, '2026-03-15', true), - ('d88', 'Neha Verma', '+919900000088', 'O-', 26.92241, 75.90506, '2026-04-19', true), - ('d89', 'Ritu Agarwal', '+919900000089', 'O+', 26.93552, 75.78467, '2026-05-21', true), - ('d90', 'Karan Agarwal', '+919900000090', 'A-', 26.82192, 75.73506, '2026-03-02', true), - ('d91', 'Amit Rathore', '+919900000091', 'A+', 26.88268, 75.73755, null, true), - ('d92', 'Ritu Verma', '+919900000092', 'B-', 26.94405, 75.85793, '2026-01-27', true), - ('d93', 'Neha Rathore', '+919900000093', 'B+', 26.9825, 75.74244, null, true), - ('d94', 'Ritu Choudhary', '+919900000094', 'AB-', 26.84011, 75.77124, '2026-03-19', true), - ('d95', 'Ritu Nair', '+919900000095', 'AB+', 26.98838, 75.71861, '2026-01-19', true), - ('d96', 'Nisha Yadav', '+919900000096', 'O-', 26.9402, 75.75032, '2026-04-06', true), - ('d97', 'Ritu Sharma', '+919900000097', 'O+', 26.81864, 75.77314, '2026-03-30', true), - ('d98', 'Priya Gupta', '+919900000098', 'A-', 26.92128, 75.88923, '2026-01-21', true), - ('d99', 'Pooja Sharma', '+919900000099', 'A+', 26.88125, 75.81887, null, true), - ('d100', 'Pooja Bhatt', '+919900000100', 'B-', 26.87852, 75.85552, '2026-05-24', true), - ('d101', 'Asha Bhatt', '+919900000101', 'B+', 26.97032, 75.74306, '2025-12-12', true), - ('d102', 'Arjun Rathore', '+919900000102', 'AB-', 26.82221, 75.73521, '2026-03-10', true), - ('d103', 'Vivek Soni', '+919900000103', 'AB+', 26.96486, 75.73964, '2026-01-22', true), - ('d104', 'Rahul Yadav', '+919900000104', 'O-', 26.84874, 75.73317, null, true), - ('d105', 'Divya Soni', '+919900000105', 'O+', 26.98259, 75.80182, '2025-12-17', true), - ('d106', 'Suresh Saini', '+919900000106', 'A-', 26.8694, 75.79732, null, true), - ('d107', 'Priya Kumawat', '+919900000107', 'A+', 26.89844, 75.7996, '2026-06-02', true), - ('d108', 'Sneha Agarwal', '+919900000108', 'B-', 26.95982, 75.72838, null, true), - ('d109', 'Sneha Rathore', '+919900000109', 'B+', 26.85303, 75.80678, '2026-03-03', true), - ('d110', 'Asha Soni', '+919900000110', 'AB-', 26.81951, 75.84324, '2026-02-08', true), - ('d111', 'Priya Rathore', '+919900000111', 'AB+', 26.9918, 75.78725, '2025-12-24', true), - ('d112', 'Rahul Singh', '+919900000112', 'O-', 27.01481, 75.81699, null, true), - ('d113', 'Karan Kumawat', '+919900000113', 'O+', 26.84078, 75.71059, null, true), - ('d114', 'Manish Gupta', '+919900000114', 'A-', 26.94701, 75.77408, '2026-04-04', true), - ('d115', 'Arjun Sharma', '+919900000115', 'A+', 26.99438, 75.76598, '2026-04-28', true), - ('d116', 'Divya Soni', '+919900000116', 'B-', 26.98657, 75.74745, '2026-03-04', true), - ('d117', 'Amit Soni', '+919900000117', 'B+', 26.87209, 75.75777, '2026-04-07', true), - ('d118', 'Meena Rathore', '+919900000118', 'AB-', 26.96839, 75.70121, '2026-05-09', true), - ('d119', 'Anjali Kumawat', '+919900000119', 'AB+', 26.89095, 75.78228, '2026-05-22', true), - ('d120', 'Sanjay Meena', '+919900000120', 'O-', 26.94938, 75.72989, '2026-05-12', true), - ('d121', 'Vikram Soni', '+919900000121', 'O+', 26.95331, 75.70222, '2026-02-17', true), - ('d122', 'Karan Jain', '+919900000122', 'A-', 26.86668, 75.7163, '2026-01-22', true), - ('d123', 'Priya Yadav', '+919900000123', 'A+', 26.90391, 75.70785, '2026-04-07', true), - ('d124', 'Gaurav Agarwal', '+919900000124', 'B-', 26.91631, 75.90105, '2026-01-03', true), - ('d125', 'Pooja Meena', '+919900000125', 'B+', 26.84974, 75.78981, '2026-02-04', true), - ('d126', 'Sneha Saini', '+919900000126', 'AB-', 26.86049, 75.87861, '2025-12-11', true), - ('d127', 'Vivek Agarwal', '+919900000127', 'AB+', 26.92272, 75.78338, null, true), - ('d128', 'Sunita Jain', '+919900000128', 'O-', 26.91627, 75.70267, '2025-12-25', true), - ('d129', 'Sunita Singh', '+919900000129', 'O+', 26.98435, 75.79608, '2026-02-25', true), - ('d130', 'Manish Agarwal', '+919900000130', 'A-', 26.86026, 75.75441, '2026-01-24', true), - ('d131', 'Sneha Yadav', '+919900000131', 'A+', 26.80984, 75.74962, '2026-02-12', true), - ('d132', 'Amit Soni', '+919900000132', 'B-', 26.95176, 75.8291, null, true), - ('d133', 'Pooja Soni', '+919900000133', 'B+', 26.91846, 75.69881, null, true), - ('d134', 'Suresh Yadav', '+919900000134', 'AB-', 26.90044, 75.70401, '2026-01-25', true), - ('d135', 'Vikram Yadav', '+919900000135', 'AB+', 26.96551, 75.68307, '2026-03-08', true), - ('d136', 'Deepak Yadav', '+919900000136', 'O-', 26.89984, 75.78423, '2026-05-26', true), - ('d137', 'Amit Bhatt', '+919900000137', 'O+', 26.97572, 75.85947, '2026-02-03', true), - ('d138', 'Vikram Verma', '+919900000138', 'A-', 26.90984, 75.80042, '2026-03-13', true), - ('d139', 'Priya Agarwal', '+919900000139', 'A+', 26.94748, 75.74078, null, true), - ('d140', 'Neha Rathore', '+919900000140', 'B-', 26.84529, 75.74774, '2026-05-01', true), - ('d141', 'Deepak Rathore', '+919900000141', 'B+', 26.88549, 75.8731, '2026-05-09', true), - ('d142', 'Divya Jain', '+919900000142', 'AB-', 26.855, 75.74288, '2026-01-26', true), - ('d143', 'Arjun Choudhary', '+919900000143', 'AB+', 26.88219, 75.89778, '2026-02-19', true), - ('d144', 'Nisha Singh', '+919900000144', 'O-', 26.84663, 75.72657, '2026-04-16', true), - ('d145', 'Deepak Sharma', '+919900000145', 'O+', 26.96093, 75.81775, null, true), - ('d146', 'Arjun Verma', '+919900000146', 'A-', 26.8765, 75.81338, '2026-02-08', true), - ('d147', 'Priya Kumawat', '+919900000147', 'A+', 26.8985, 75.73109, '2026-02-12', true), - ('d148', 'Gaurav Rathore', '+919900000148', 'B-', 26.90763, 75.84563, '2026-01-09', true), - ('d149', 'Neha Nair', '+919900000149', 'B+', 26.81928, 75.84039, '2025-12-25', true), - ('d150', 'Arjun Choudhary', '+919900000150', 'AB-', 26.94731, 75.836, null, true), - ('d151', 'Ritu Soni', '+919900000151', 'AB+', 26.82755, 75.7431, '2026-05-08', true), - ('d152', 'Karan Jain', '+919900000152', 'O-', 26.86362, 75.82574, '2026-01-21', true), - ('d153', 'Suresh Yadav', '+919900000153', 'O+', 26.94211, 75.79582, '2026-03-18', true), - ('d154', 'Meena Verma', '+919900000154', 'A-', 26.9147, 75.80663, '2026-01-12', true), - ('d155', 'Vikram Jain', '+919900000155', 'A+', 26.86169, 75.68025, '2026-05-29', true), - ('d156', 'Sanjay Kumawat', '+919900000156', 'B-', 26.83959, 75.81831, '2026-01-10', true), - ('d157', 'Kavya Meena', '+919900000157', 'B+', 27.00811, 75.76616, '2026-02-24', true), - ('d158', 'Karan Yadav', '+919900000158', 'AB-', 26.96783, 75.70994, '2025-12-13', true), - ('d159', 'Ritu Sharma', '+919900000159', 'AB+', 26.84106, 75.84508, '2026-05-30', true), - ('d160', 'Anjali Bhatt', '+919900000160', 'O-', 26.88546, 75.84821, '2026-02-25', true), - ('d161', 'Rohan Singh', '+919900000161', 'O+', 26.85349, 75.68651, '2026-02-23', true), - ('d162', 'Rohan Singh', '+919900000162', 'A-', 26.85655, 75.7265, '2026-02-15', true), - ('d163', 'Karan Sharma', '+919900000163', 'A+', 26.95038, 75.83324, '2025-12-12', true), - ('d164', 'Vikram Yadav', '+919900000164', 'B-', 26.89983, 75.77228, '2025-12-09', true), - ('d165', 'Amit Jain', '+919900000165', 'B+', 26.83645, 75.75829, null, true), - ('d166', 'Sunita Yadav', '+919900000166', 'AB-', 26.84934, 75.69286, null, true), - ('d167', 'Rahul Jain', '+919900000167', 'AB+', 26.90109, 75.87285, '2026-02-08', true), - ('d168', 'Vivek Nair', '+919900000168', 'O-', 26.85266, 75.84743, '2026-01-29', true), - ('d169', 'Kavya Nair', '+919900000169', 'O+', 26.94856, 75.80632, '2026-02-22', true), - ('d170', 'Vivek Yadav', '+919900000170', 'A-', 26.90207, 75.89428, null, true), - ('d171', 'Ritu Saini', '+919900000171', 'A+', 26.82568, 75.7339, '2026-05-01', true), - ('d172', 'Ritu Nair', '+919900000172', 'B-', 26.87515, 75.86061, '2026-03-04', true), - ('d173', 'Divya Singh', '+919900000173', 'B+', 26.93546, 75.85103, '2026-01-13', true), - ('d174', 'Sunita Nair', '+919900000174', 'AB-', 26.82769, 75.84694, '2026-02-07', true), - ('d175', 'Neha Jain', '+919900000175', 'AB+', 26.83892, 75.85896, '2025-12-24', true), - ('d176', 'Amit Bhatt', '+919900000176', 'O-', 26.9869, 75.87216, '2026-03-16', true), - ('d177', 'Vivek Gupta', '+919900000177', 'O+', 26.99291, 75.85264, '2026-06-05', true), - ('d178', 'Karan Rathore', '+919900000178', 'A-', 26.87341, 75.72316, null, true), - ('d179', 'Priya Bhatt', '+919900000179', 'A+', 26.85619, 75.88865, '2025-12-24', true), - ('d180', 'Nisha Soni', '+919900000180', 'B-', 26.88404, 75.72551, '2026-03-19', true) +insert into blood_donors (id, name, phone, blood_group, lat, lng, last_donated, sex, available) values + ('d1', 'Vivek Sharma', '+919900000001', 'O+', 26.99778, 75.80247, null, 'female', true), + ('d2', 'Priya Singh', '+919900000002', 'A-', 26.98169, 75.72479, '2026-06-01', 'female', true), + ('d3', 'Deepak Agarwal', '+919900000003', 'A+', 26.87061, 75.75233, '2025-12-11', 'male', true), + ('d4', 'Vikram Soni', '+919900000004', 'B-', 26.86942, 75.75702, null, 'male', true), + ('d5', 'Priya Agarwal', '+919900000005', 'B+', 26.90016, 75.86491, '2026-05-13', 'female', true), + ('d6', 'Ritu Saini', '+919900000006', 'AB-', 26.93173, 75.75645, '2025-12-04', 'male', true), + ('d7', 'Suresh Nair', '+919900000007', 'AB+', 26.94613, 75.76721, '2025-12-30', 'female', true), + ('d8', 'Rahul Rathore', '+919900000008', 'O-', 26.82367, 75.73473, null, 'male', true), + ('d9', 'Sneha Jain', '+919900000009', 'O+', 26.9634, 75.81865, null, 'female', true), + ('d10', 'Sneha Soni', '+919900000010', 'A-', 26.85547, 75.85988, null, 'female', true), + ('d11', 'Vivek Singh', '+919900000011', 'A+', 26.84986, 75.69323, null, 'male', true), + ('d12', 'Rahul Kumawat', '+919900000012', 'B-', 26.88065, 75.82097, '2026-01-17', 'male', true), + ('d13', 'Divya Yadav', '+919900000013', 'B+', 26.91947, 75.71872, '2026-03-20', 'female', true), + ('d14', 'Nisha Jain', '+919900000014', 'AB-', 26.93617, 75.77111, '2026-04-15', 'male', true), + ('d15', 'Vivek Saini', '+919900000015', 'AB+', 26.97083, 75.7483, '2026-04-06', 'female', true), + ('d16', 'Rahul Gupta', '+919900000016', 'O-', 26.91091, 75.69831, '2026-03-08', 'female', true), + ('d17', 'Nisha Gupta', '+919900000017', 'O+', 26.97732, 75.83409, null, 'female', true), + ('d18', 'Anjali Yadav', '+919900000018', 'A-', 26.82746, 75.83799, '2026-02-09', 'female', true), + ('d19', 'Deepak Rathore', '+919900000019', 'A+', 26.98071, 75.69562, '2025-12-16', 'female', true), + ('d20', 'Asha Soni', '+919900000020', 'B-', 26.87706, 75.73881, '2026-02-12', 'female', true), + ('d21', 'Amit Bhatt', '+919900000021', 'B+', 26.9039, 75.90036, null, 'male', true), + ('d22', 'Manish Rathore', '+919900000022', 'AB-', 26.95896, 75.7069, null, 'male', true), + ('d23', 'Kavya Jain', '+919900000023', 'AB+', 26.95401, 75.77326, '2026-01-06', 'male', true), + ('d24', 'Rahul Verma', '+919900000024', 'O-', 26.92601, 75.78001, '2026-03-22', 'male', true), + ('d25', 'Deepak Rathore', '+919900000025', 'O+', 26.86402, 75.76286, '2026-05-22', 'female', true), + ('d26', 'Anjali Bhatt', '+919900000026', 'A-', 26.89181, 75.75103, null, 'male', true), + ('d27', 'Pooja Soni', '+919900000027', 'A+', 26.9945, 75.76633, '2025-12-14', 'male', true), + ('d28', 'Ritu Sharma', '+919900000028', 'B-', 26.97273, 75.78524, '2026-01-27', 'male', true), + ('d29', 'Rahul Verma', '+919900000029', 'B+', 26.96193, 75.81093, '2026-01-09', 'female', true), + ('d30', 'Priya Saini', '+919900000030', 'AB-', 26.89283, 75.7636, '2026-03-16', 'female', true), + ('d31', 'Meena Choudhary', '+919900000031', 'AB+', 26.8867, 75.73565, null, 'female', true), + ('d32', 'Amit Kumawat', '+919900000032', 'O-', 26.92844, 75.70591, null, 'male', true), + ('d33', 'Nisha Kumawat', '+919900000033', 'O+', 26.84945, 75.82407, '2025-12-18', 'female', true), + ('d34', 'Pooja Singh', '+919900000034', 'A-', 26.88464, 75.80915, '2026-04-06', 'female', true), + ('d35', 'Ritu Rathore', '+919900000035', 'A+', 26.96279, 75.85577, '2026-05-20', 'female', true), + ('d36', 'Priya Nair', '+919900000036', 'B-', 26.81704, 75.75231, '2026-06-05', 'female', true), + ('d37', 'Rohan Gupta', '+919900000037', 'B+', 26.92042, 75.89216, '2026-02-26', 'female', true), + ('d38', 'Arjun Agarwal', '+919900000038', 'AB-', 26.97867, 75.78042, '2026-02-12', 'female', true), + ('d39', 'Neha Singh', '+919900000039', 'AB+', 26.90138, 75.68687, '2026-02-04', 'male', true), + ('d40', 'Rohan Verma', '+919900000040', 'O-', 26.97015, 75.7744, '2026-03-20', 'male', true), + ('d41', 'Anjali Sharma', '+919900000041', 'O+', 26.82978, 75.78564, null, 'male', true), + ('d42', 'Amit Nair', '+919900000042', 'A-', 27.00305, 75.83399, '2026-02-25', 'female', true), + ('d43', 'Gaurav Choudhary', '+919900000043', 'A+', 26.84063, 75.74376, null, 'female', true), + ('d44', 'Rahul Yadav', '+919900000044', 'B-', 26.87948, 75.87064, '2026-03-20', 'female', true), + ('d45', 'Asha Jain', '+919900000045', 'B+', 26.88027, 75.72008, '2026-05-21', 'female', true), + ('d46', 'Sneha Gupta', '+919900000046', 'AB-', 26.83378, 75.75046, '2026-01-30', 'female', true), + ('d47', 'Ritu Bhatt', '+919900000047', 'AB+', 26.98892, 75.70759, '2026-04-29', 'male', true), + ('d48', 'Asha Kumawat', '+919900000048', 'O-', 26.88658, 75.87184, '2026-01-24', 'male', true), + ('d49', 'Sneha Sharma', '+919900000049', 'O+', 26.88256, 75.89166, '2026-05-05', 'male', true), + ('d50', 'Sunita Meena', '+919900000050', 'A-', 26.90754, 75.67307, '2026-04-16', 'female', true), + ('d51', 'Priya Kumawat', '+919900000051', 'A+', 26.87997, 75.74644, '2026-05-26', 'female', true), + ('d52', 'Sneha Gupta', '+919900000052', 'B-', 26.90066, 75.8651, '2025-12-27', 'female', true), + ('d53', 'Priya Nair', '+919900000053', 'B+', 26.82419, 75.754, null, 'female', true), + ('d54', 'Neha Agarwal', '+919900000054', 'AB-', 26.96565, 75.86848, '2026-01-18', 'female', true), + ('d55', 'Manish Singh', '+919900000055', 'AB+', 26.89859, 75.82771, '2026-05-29', 'male', true), + ('d56', 'Suresh Soni', '+919900000056', 'O-', 26.86789, 75.7005, '2026-02-24', 'female', true), + ('d57', 'Sanjay Verma', '+919900000057', 'O+', 26.95084, 75.76697, '2026-04-24', 'male', true), + ('d58', 'Vivek Rathore', '+919900000058', 'A-', 26.95313, 75.76384, '2025-12-19', 'male', true), + ('d59', 'Karan Singh', '+919900000059', 'A+', 26.94149, 75.83813, '2026-05-30', 'male', true), + ('d60', 'Rahul Verma', '+919900000060', 'B-', 26.95449, 75.76075, '2026-04-11', 'female', true), + ('d61', 'Sneha Meena', '+919900000061', 'B+', 26.84185, 75.84596, null, 'female', true), + ('d62', 'Amit Nair', '+919900000062', 'AB-', 26.84245, 75.84453, '2026-06-01', 'male', true), + ('d63', 'Suresh Agarwal', '+919900000063', 'AB+', 26.95861, 75.8949, '2026-05-12', 'male', true), + ('d64', 'Divya Nair', '+919900000064', 'O-', 26.92682, 75.71788, '2026-05-10', 'female', true), + ('d65', 'Deepak Kumawat', '+919900000065', 'O+', 26.90996, 75.87932, null, 'male', true), + ('d66', 'Gaurav Nair', '+919900000066', 'A-', 26.90776, 75.6846, '2026-05-22', 'male', true), + ('d67', 'Anjali Yadav', '+919900000067', 'A+', 26.88812, 75.85142, '2026-03-24', 'male', true), + ('d68', 'Sanjay Kumawat', '+919900000068', 'B-', 26.87174, 75.87518, '2026-04-20', 'male', true), + ('d69', 'Divya Saini', '+919900000069', 'B+', 26.86617, 75.68178, '2026-03-23', 'male', true), + ('d70', 'Nisha Meena', '+919900000070', 'AB-', 26.87962, 75.89172, '2026-01-04', 'female', true), + ('d71', 'Vikram Kumawat', '+919900000071', 'AB+', 26.8435, 75.81669, '2025-12-02', 'female', true), + ('d72', 'Gaurav Yadav', '+919900000072', 'O-', 26.88107, 75.78388, '2026-04-09', 'male', true), + ('d73', 'Karan Choudhary', '+919900000073', 'O+', 26.9285, 75.84185, null, 'female', true), + ('d74', 'Sunita Soni', '+919900000074', 'A-', 27.0018, 75.83667, '2026-04-20', 'female', true), + ('d75', 'Sanjay Singh', '+919900000075', 'A+', 26.85829, 75.83159, '2026-05-12', 'female', true), + ('d76', 'Rahul Nair', '+919900000076', 'B-', 26.95617, 75.77403, null, 'male', true), + ('d77', 'Suresh Meena', '+919900000077', 'B+', 26.81653, 75.81747, '2026-01-16', 'male', true), + ('d78', 'Sanjay Bhatt', '+919900000078', 'AB-', 26.99419, 75.71279, '2026-01-30', 'male', true), + ('d79', 'Sneha Rathore', '+919900000079', 'AB+', 26.81534, 75.82578, '2026-02-13', 'female', true), + ('d80', 'Rahul Yadav', '+919900000080', 'O-', 26.87753, 75.74142, '2025-12-30', 'male', true), + ('d81', 'Priya Gupta', '+919900000081', 'O+', 26.89612, 75.70899, '2026-01-21', 'female', true), + ('d82', 'Sanjay Agarwal', '+919900000082', 'A-', 26.88125, 75.81887, null, 'female', true), + ('d83', 'Meena Soni', '+919900000083', 'A+', 26.85163, 75.80212, '2026-03-01', 'female', true), + ('d84', 'Arjun Rathore', '+919900000084', 'B-', 26.92346, 75.77575, '2026-03-10', 'male', true), + ('d85', 'Deepak Rathore', '+919900000085', 'B+', 26.96486, 75.73964, '2025-12-02', 'male', true), + ('d86', 'Divya Meena', '+919900000086', 'AB-', 26.92785, 75.87986, '2026-06-01', 'female', true), + ('d87', 'Suresh Saini', '+919900000087', 'AB+', 26.93874, 75.69265, '2026-05-07', 'male', true), + ('d88', 'Nisha Agarwal', '+919900000088', 'O-', 26.89844, 75.7996, null, 'male', true), + ('d89', 'Ritu Meena', '+919900000089', 'O+', 26.87372, 75.79794, '2026-04-15', 'female', true), + ('d90', 'Sneha Bhatt', '+919900000090', 'A-', 26.95809, 75.73897, '2026-02-21', 'female', true), + ('d91', 'Rahul Kumawat', '+919900000091', 'A+', 26.94311, 75.79152, '2026-03-11', 'male', true), + ('d92', 'Rahul Singh', '+919900000092', 'B-', 26.93971, 75.77888, null, 'female', true), + ('d93', 'Meena Singh', '+919900000093', 'B+', 26.84078, 75.71059, '2026-05-10', 'female', true), + ('d94', 'Amit Rathore', '+919900000094', 'AB-', 26.90951, 75.86991, '2025-12-07', 'female', true), + ('d95', 'Arjun Choudhary', '+919900000095', 'AB+', 27.00786, 75.76317, null, 'female', true), + ('d96', 'Meena Soni', '+919900000096', 'O-', 26.99832, 75.76138, '2026-05-20', 'female', true), + ('d97', 'Suresh Rathore', '+919900000097', 'O+', 26.91683, 75.89768, '2025-12-15', 'male', true), + ('d98', 'Gaurav Meena', '+919900000098', 'A-', 26.94982, 75.78303, '2026-02-19', 'male', true), + ('d99', 'Amit Agarwal', '+919900000099', 'A+', 26.89536, 75.76184, '2026-02-03', 'female', true), + ('d100', 'Vikram Soni', '+919900000100', 'B-', 26.84262, 75.69787, '2026-02-17', 'male', true), + ('d101', 'Ritu Agarwal', '+919900000101', 'B+', 26.86668, 75.7163, '2026-02-05', 'male', true), + ('d102', 'Priya Yadav', '+919900000102', 'AB-', 26.83028, 75.72683, null, 'female', true), + ('d103', 'Kavya Verma', '+919900000103', 'AB+', 26.91631, 75.90105, '2026-03-03', 'female', true), + ('d104', 'Kavya Yadav', '+919900000104', 'O-', 26.87789, 75.86324, '2026-04-03', 'female', true), + ('d105', 'Pooja Sharma', '+919900000105', 'O+', 26.88177, 75.67886, null, 'female', true), + ('d106', 'Karan Kumawat', '+919900000106', 'A-', 26.86821, 75.82034, null, 'male', true), + ('d107', 'Sunita Singh', '+919900000107', 'A+', 26.82598, 75.82002, '2026-02-25', 'female', true), + ('d108', 'Vivek Saini', '+919900000108', 'B-', 26.86026, 75.75441, '2026-02-20', 'female', true), + ('d109', 'Vivek Singh', '+919900000109', 'B+', 26.89425, 75.72061, '2026-05-09', 'female', true), + ('d110', 'Pooja Soni', '+919900000110', 'AB-', 26.90968, 75.71926, '2026-04-20', 'male', true), + ('d111', 'Amit Bhatt', '+919900000111', 'AB+', 26.90044, 75.70401, '2026-03-28', 'male', true), + ('d112', 'Deepak Gupta', '+919900000112', 'O-', 26.91912, 75.84018, null, 'female', true), + ('d113', 'Nisha Bhatt', '+919900000113', 'O+', 26.96828, 75.77709, '2026-05-07', 'male', true), + ('d114', 'Ritu Meena', '+919900000114', 'A-', 26.98738, 75.8505, '2026-02-06', 'female', true), + ('d115', 'Divya Jain', '+919900000115', 'A+', 26.95642, 75.80381, '2026-05-23', 'male', true), + ('d116', 'Meena Yadav', '+919900000116', 'B-', 26.89218, 75.76332, null, 'female', true), + ('d117', 'Suresh Singh', '+919900000117', 'B+', 26.91276, 75.82268, '2026-01-07', 'male', true), + ('d118', 'Sanjay Yadav', '+919900000118', 'AB-', 26.84284, 75.86021, '2026-01-09', 'male', true), + ('d119', 'Pooja Kumawat', '+919900000119', 'AB+', 26.98815, 75.82108, null, 'male', true), + ('d120', 'Priya Gupta', '+919900000120', 'O-', 26.8859, 75.7402, null, 'female', true), + ('d121', 'Arjun Soni', '+919900000121', 'O+', 26.90708, 75.78922, '2026-04-10', 'male', true), + ('d122', 'Sneha Rathore', '+919900000122', 'A-', 26.94893, 75.73753, null, 'female', true), + ('d123', 'Amit Saini', '+919900000123', 'A+', 26.83114, 75.72963, null, 'male', true), + ('d124', 'Rohan Gupta', '+919900000124', 'B-', 26.94279, 75.83359, null, 'female', true), + ('d125', 'Amit Jain', '+919900000125', 'B+', 26.83281, 75.72661, '2026-02-16', 'male', true), + ('d126', 'Ritu Sharma', '+919900000126', 'AB-', 26.88453, 75.69166, '2026-02-02', 'male', true), + ('d127', 'Priya Singh', '+919900000127', 'AB+', 26.97662, 75.72728, '2026-06-02', 'male', true), + ('d128', 'Sneha Choudhary', '+919900000128', 'O-', 26.96692, 75.68532, '2025-12-18', 'male', true), + ('d129', 'Vikram Choudhary', '+919900000129', 'O+', 26.91325, 75.763, '2026-03-29', 'female', true), + ('d130', 'Kavya Meena', '+919900000130', 'A-', 26.94044, 75.71369, '2026-02-24', 'male', true), + ('d131', 'Gaurav Agarwal', '+919900000131', 'A+', 26.96783, 75.70994, '2026-03-27', 'female', true), + ('d132', 'Amit Rathore', '+919900000132', 'B-', 26.81968, 75.75344, '2026-03-18', 'female', true), + ('d133', 'Ritu Agarwal', '+919900000133', 'B+', 26.84396, 75.78024, '2026-01-21', 'male', true), + ('d134', 'Rohan Singh', '+919900000134', 'AB-', 26.8874, 75.78404, '2026-02-15', 'male', true), + ('d135', 'Amit Verma', '+919900000135', 'AB+', 26.95038, 75.83324, '2026-02-04', 'female', true), + ('d136', 'Deepak Verma', '+919900000136', 'O-', 26.82975, 75.71172, null, 'female', true), + ('d137', 'Amit Kumawat', '+919900000137', 'O+', 26.97935, 75.8371, '2026-02-10', 'male', true), + ('d138', 'Sanjay Bhatt', '+919900000138', 'A-', 26.81379, 75.78843, '2026-03-31', 'male', true), + ('d139', 'Pooja Nair', '+919900000139', 'A+', 26.83845, 75.80193, null, 'female', true), + ('d140', 'Sanjay Meena', '+919900000140', 'B-', 26.91045, 75.69346, null, 'female', true), + ('d141', 'Meena Choudhary', '+919900000141', 'B+', 26.80803, 75.78161, '2026-03-24', 'male', true), + ('d142', 'Kavya Saini', '+919900000142', 'AB-', 26.80458, 75.79518, '2026-03-12', 'female', true), + ('d143', 'Rahul Choudhary', '+919900000143', 'AB+', 26.84471, 75.86113, '2025-12-12', 'male', true), + ('d144', 'Sunita Nair', '+919900000144', 'O-', 26.95374, 75.72831, '2026-02-07', 'female', true), + ('d145', 'Karan Verma', '+919900000145', 'O+', 26.83892, 75.85896, '2026-05-01', 'male', true), + ('d146', 'Ritu Sharma', '+919900000146', 'A-', 26.94991, 75.778, null, 'female', true), + ('d147', 'Neha Sharma', '+919900000147', 'A+', 26.83685, 75.81081, '2025-12-23', 'male', true), + ('d148', 'Priya Bhatt', '+919900000148', 'B-', 26.98509, 75.77133, '2025-12-24', 'female', true), + ('d149', 'Sunita Nair', '+919900000149', 'B+', 26.88404, 75.72551, '2025-12-30', 'male', true), + ('d150', 'Nisha Kumawat', '+919900000150', 'AB-', 26.97791, 75.71137, null, 'female', true), + ('d151', 'Nisha Soni', '+919900000151', 'AB+', 27.01613, 75.78542, '2026-05-14', 'male', true), + ('d152', 'Kavya Rathore', '+919900000152', 'O-', 26.81767, 75.82655, '2026-05-28', 'male', true), + ('d153', 'Sanjay Choudhary', '+919900000153', 'O+', 26.89627, 75.80961, null, 'female', true), + ('d154', 'Suresh Kumawat', '+919900000154', 'A-', 26.95038, 75.8779, '2026-03-03', 'female', true), + ('d155', 'Ritu Yadav', '+919900000155', 'A+', 26.95769, 75.75632, '2026-04-04', 'female', true), + ('d156', 'Vikram Sharma', '+919900000156', 'B-', 26.98346, 75.72856, '2026-01-20', 'female', true), + ('d157', 'Sunita Yadav', '+919900000157', 'B+', 26.8718, 75.85378, '2026-02-20', 'male', true), + ('d158', 'Rahul Agarwal', '+919900000158', 'AB-', 26.8889, 75.86489, '2026-05-12', 'male', true), + ('d159', 'Divya Yadav', '+919900000159', 'AB+', 26.83951, 75.74577, '2025-12-22', 'male', true), + ('d160', 'Vivek Jain', '+919900000160', 'O-', 26.91884, 75.77912, '2026-01-04', 'male', true), + ('d161', 'Manish Singh', '+919900000161', 'O+', 26.99327, 75.72393, null, 'male', true), + ('d162', 'Gaurav Bhatt', '+919900000162', 'A-', 26.84985, 75.72069, '2025-12-22', 'male', true), + ('d163', 'Ritu Verma', '+919900000163', 'A+', 26.94516, 75.813, null, 'male', true), + ('d164', 'Vikram Singh', '+919900000164', 'B-', 26.9033, 75.69557, '2026-02-22', 'female', true), + ('d165', 'Vikram Yadav', '+919900000165', 'B+', 26.8743, 75.7765, '2026-03-31', 'male', true), + ('d166', 'Amit Jain', '+919900000166', 'AB-', 27.00947, 75.74748, '2026-03-14', 'male', true), + ('d167', 'Gaurav Agarwal', '+919900000167', 'AB+', 26.97963, 75.87933, '2025-12-05', 'male', true), + ('d168', 'Sneha Saini', '+919900000168', 'O-', 26.94816, 75.67501, '2026-06-05', 'female', true), + ('d169', 'Nisha Meena', '+919900000169', 'O+', 26.88663, 75.81863, '2026-03-03', 'male', true), + ('d170', 'Kavya Nair', '+919900000170', 'A-', 26.91601, 75.827, '2026-01-16', 'female', true), + ('d171', 'Arjun Kumawat', '+919900000171', 'A+', 26.86176, 75.71152, '2025-12-11', 'male', true), + ('d172', 'Amit Sharma', '+919900000172', 'B-', 26.95651, 75.84692, '2026-02-02', 'male', true), + ('d173', 'Sunita Saini', '+919900000173', 'B+', 26.86147, 75.77357, '2025-11-30', 'female', true), + ('d174', 'Karan Choudhary', '+919900000174', 'AB-', 26.909, 75.7089, '2026-05-14', 'female', true), + ('d175', 'Ritu Singh', '+919900000175', 'AB+', 26.97828, 75.76407, '2026-02-15', 'female', true), + ('d176', 'Manish Verma', '+919900000176', 'O-', 26.99353, 75.85284, '2025-12-25', 'male', true), + ('d177', 'Priya Kumawat', '+919900000177', 'O+', 26.93759, 75.85839, '2026-02-12', 'female', true), + ('d178', 'Kavya Singh', '+919900000178', 'A-', 26.88054, 75.74439, null, 'male', true), + ('d179', 'Pooja Choudhary', '+919900000179', 'A+', 26.93715, 75.66998, '2026-01-18', 'female', true), + ('d180', 'Suresh Verma', '+919900000180', 'B-', 26.94672, 75.74616, null, 'female', true) on conflict (id) do nothing; diff --git a/backend/store.py b/backend/store.py index faec246..d99c44f 100644 --- a/backend/store.py +++ b/backend/store.py @@ -84,6 +84,7 @@ def compatible_donors_nearby( compatible_groups: List[str], radius_meters: float, cooldown_days: int, + cooldown_days_female: Optional[int] = None, today: Optional[date] = None, ) -> List[Dict]: today = today or date.today() @@ -95,7 +96,10 @@ def compatible_donors_nearby( if not d.get("available", True): continue last = _parse_date(d.get("last_donated")) - if last is not None and (today - last).days < cooldown_days: + cooldown = cooldown_days + if cooldown_days_female is not None and d.get("sex") == "female": + cooldown = cooldown_days_female + if last is not None and (today - last).days < cooldown: continue dist = haversine_meters(lat, lng, d["lat"], d["lng"]) if dist > radius_meters: @@ -106,7 +110,7 @@ def compatible_donors_nearby( result.sort(key=lambda d: d["distance_m"]) return result - def add_donor(self, name, phone, blood_group, lat, lng, last_donated) -> str: + def add_donor(self, name, phone, blood_group, lat, lng, last_donated, sex=None) -> str: donor_id = f"d{next(self._donor_seq)}" self.donors.append( { @@ -117,6 +121,7 @@ def add_donor(self, name, phone, blood_group, lat, lng, last_donated) -> str: "lat": lat, "lng": lng, "last_donated": last_donated, + "sex": sex, "available": True, } ) @@ -170,6 +175,22 @@ def emergency_is_taken(self, emergency_id: str) -> bool: c["confirmed"] is True for c in self.confirmations_for_emergency(emergency_id) ) + def hospital_reliability(self, hospital_id: str) -> tuple[int, float]: + """(#replied confirmations, acceptance rate) for a hospital, all-time. + + Powers the cold-start gate in ranking: reliability is trusted only once + enough confirmations have actually been logged for this hospital. + """ + replied = [ + c + for c in self.confirmations.values() + if c["hospital_id"] == hospital_id and c["confirmed"] is not None + ] + if not replied: + return 0, 0.0 + accepted = sum(1 for c in replied if c["confirmed"] is True) + return len(replied), accepted / len(replied) + def record_reply(self, token: str, accepted: bool) -> None: """Persist a hospital's Accept/Decline and update the emergency snapshot.""" conf = self.confirmations.get(token) @@ -225,6 +246,7 @@ def compatible_donors_nearby( compatible_groups: List[str], radius_meters: float, cooldown_days: int, + cooldown_days_female: Optional[int] = None, today: Optional[date] = None, ) -> List[Dict]: res = self.client.rpc( @@ -235,6 +257,9 @@ def compatible_donors_nearby( "p_groups": compatible_groups, "p_radius_m": radius_meters, "p_cooldown_days": cooldown_days, + "p_cooldown_days_female": ( + cooldown_days_female if cooldown_days_female is not None else cooldown_days + ), }, ).execute() donors = res.data or [] @@ -242,7 +267,7 @@ def compatible_donors_nearby( d["distance_m"] = round(haversine_meters(lat, lng, d["lat"], d["lng"]), 1) return donors - def add_donor(self, name, phone, blood_group, lat, lng, last_donated) -> str: + def add_donor(self, name, phone, blood_group, lat, lng, last_donated, sex=None) -> str: donor_id = f"d{uuid.uuid4().hex[:8]}" self.client.table("blood_donors").insert( { @@ -253,6 +278,7 @@ def add_donor(self, name, phone, blood_group, lat, lng, last_donated) -> str: "lat": lat, "lng": lng, "last_donated": last_donated, + "sex": sex, "available": True, } ).execute() @@ -369,6 +395,20 @@ def emergency_is_taken(self, emergency_id: str) -> bool: ) return bool(res.data) + def hospital_reliability(self, hospital_id: str) -> tuple[int, float]: + """(#replied confirmations, acceptance rate) for a hospital, all-time.""" + res = ( + self.client.table("confirmation_requests") + .select("confirmed") + .eq("hospital_id", hospital_id) + .execute() + ) + replied = [r for r in (res.data or []) if r.get("confirmed") is not None] + if not replied: + return 0, 0.0 + accepted = sum(1 for r in replied if r["confirmed"] is True) + return len(replied), accepted / len(replied) + def record_reply(self, token: str, accepted: bool) -> None: self.client.table("confirmation_requests").update( {"confirmed": accepted, "replied_at": _now().isoformat()} diff --git a/backend/tests/fake_supabase.py b/backend/tests/fake_supabase.py index d42349e..838b3f5 100644 --- a/backend/tests/fake_supabase.py +++ b/backend/tests/fake_supabase.py @@ -95,13 +95,16 @@ def rpc(self, name: str, params: Dict) -> _Query: def _donors_nearby(self, p: Dict) -> List[Dict]: today = date.today() groups = set(p["p_groups"]) + cd_male = p.get("p_cooldown_days", 90) + cd_female = p.get("p_cooldown_days_female", cd_male) out = [] for d in self.tables["blood_donors"]: if d["blood_group"] not in groups or not d.get("available", True): continue last = d.get("last_donated") if last: - if (today - date.fromisoformat(last)).days < p["p_cooldown_days"]: + cooldown = cd_female if d.get("sex") == "female" else cd_male + if (today - date.fromisoformat(last)).days < cooldown: continue if haversine_meters(p["p_lat"], p["p_lng"], d["lat"], d["lng"]) > p["p_radius_m"]: continue diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 01f77f1..4cb944e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -31,9 +31,10 @@ def test_emergency_returns_contract_shape(client): resp = _trigger(client) assert resp.status_code == 200 body = resp.json() - assert set(body) == {"request_id", "hospitals", "donors_alerted"} + assert set(body) == {"request_id", "hospitals", "donors_alerted", "rare_group"} assert isinstance(body["hospitals"], list) and body["hospitals"] assert isinstance(body["donors_alerted"], int) + assert isinstance(body["rare_group"], bool) card = body["hospitals"][0] assert set(card) == { @@ -43,6 +44,20 @@ def test_emergency_returns_contract_shape(client): assert card["status"] == "pending" +def test_emergency_alerts_donors_with_blood_bank_directive(client): + from services import sms_service + + sms_service.recent_alerts.clear() + _trigger(client) + assert sms_service.recent_alerts, "expected the blood branch to dispatch alerts" + assert any("blood bank" in a["message"].lower() for a in sms_service.recent_alerts) + + +def test_rare_group_flag_tracks_rh_negative(client): + assert _trigger(client, blood_group="O-").json()["rare_group"] is True + assert _trigger(client, blood_group="O+").json()["rare_group"] is False + + def test_emergency_hospitals_sorted_best_first(client): body = _trigger(client).json() etas = [h["eta_minutes"] for h in body["hospitals"]] @@ -66,8 +81,11 @@ def test_status_shape(client): body = client.get(f"/emergency/{request_id}/status").json() assert set(body) == { "request_id", "hospitals", "donors_alerted", "donors_responded", + "unconfirmed_fallback", } assert body["request_id"] == request_id + # Freshly created — well inside the no-confirmation window. + assert body["unconfirmed_fallback"] is False card = body["hospitals"][0] assert set(card) == {"hospital_id", "name", "eta_minutes", "status"} diff --git a/backend/tests/test_units.py b/backend/tests/test_units.py index 7b89088..4213c8c 100644 --- a/backend/tests/test_units.py +++ b/backend/tests/test_units.py @@ -1,6 +1,6 @@ """Unit tests for the algorithms: blood compatibility, geo, ranking, matching.""" import asyncio -from datetime import date +from datetime import date, timedelta from blood import compatible_donor_groups from geo import haversine_km @@ -55,6 +55,20 @@ def test_ranking_prefers_department_match(): assert any(c["department_match"] for c in ranked) +def test_hospital_reliability_cold_start_then_accumulates(): + # At launch a hospital has no logged confirmations -> rel is not trusted. + store = get_store() + assert store.hospital_reliability("h1") == (0, 0.0) + + # Two confirmations logged for the same hospital: one accept, one decline. + store.create_confirmation("e1", "h1", "SMS Hospital", "tok-a") + store.create_confirmation("e1", "h1", "SMS Hospital", "tok-b") + store.record_reply("tok-a", True) + store.record_reply("tok-b", False) + count, rate = store.hospital_reliability("h1") + assert count == 2 and rate == 0.5 + + # --- Donor matching -------------------------------------------------------- def test_match_donors_respects_compatibility_and_radius(): store = get_store() @@ -73,3 +87,19 @@ def test_match_donors_excludes_recent_donations(): if d["last_donated"]: days = (today - date.fromisoformat(d["last_donated"])).days assert days >= 90 + + +def test_female_donor_has_longer_cooldown(): + # Same group, same spot, donated 100 days ago: a man is eligible (>= 90d), + # a woman is still inside her 120-day window. + store = get_store() + today = date(2026, 6, 18) + hundred_days_ago = (today - timedelta(days=100)).isoformat() + store.add_donor("Male Donor", "+919800000001", "O-", 26.9124, 75.7873, + hundred_days_ago, sex="male") + store.add_donor("Female Donor", "+919800000002", "O-", 26.9124, 75.7873, + hundred_days_ago, sex="female") + + phones = {d["phone"] for d in match_donors(store, 26.9124, 75.7873, "O-", today=today)} + assert "+919800000001" in phones # man: 100 days >= 90-day cooldown + assert "+919800000002" not in phones # woman: 100 days < 120-day cooldown From 252bd9f252afb04a8f3162c6fcf8cef0297d0224 Mon Sep 17 00:00:00 2001 From: Prajnadeep Sarma Date: Thu, 18 Jun 2026 23:53:02 +0530 Subject: [PATCH 05/32] Validate GPS coordinate ranges on request Reject lat outside -90..90 and lng outside -180..180 with 422 instead of computing distances from a failed or spoofed browser geolocation. Applies to POST /emergency and POST /donor/register; documented in API_CONTRACT. --- API_CONTRACT.md | 2 +- backend/schemas.py | 15 ++++++++++----- backend/tests/test_api.py | 5 +++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 7411e20..ad871fe 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -10,7 +10,7 @@ This is the single most important document for the team. Both the backend (You) - The frontend **never** talks to the database directly. It only ever calls these endpoints. - Blood groups are always strings: `"O+"`, `"O-"`, `"A+"`, `"A-"`, `"B+"`, `"B-"`, `"AB+"`, `"AB-"`. - `status` for a hospital is always one of: `"pending"`, `"confirmed"`, `"declined"`. -- Coordinates: `lat` and `lng` are decimal numbers (e.g. `26.9124`, `75.7873` for Jaipur). +- Coordinates: `lat` and `lng` are decimal numbers (e.g. `26.9124`, `75.7873` for Jaipur). `lat` must be −90..90 and `lng` −180..180 — out-of-range coordinates (e.g. a failed browser geolocation) are rejected with **422**. ## Endpoint 1 — Trigger an emergency `POST /emergency` diff --git a/backend/schemas.py b/backend/schemas.py index ea88e2d..287b49c 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -6,7 +6,7 @@ """ from __future__ import annotations -from typing import List, Literal, Optional +from typing import Annotated, List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field @@ -14,11 +14,16 @@ BloodGroup = Literal["O+", "O-", "A+", "A-", "B+", "B-", "AB+", "AB-"] HospitalStatus = Literal["pending", "confirmed", "declined"] +# GPS coordinates, range-checked so a failed/garbage browser geolocation is +# rejected with 422 rather than producing nonsense distances. +Latitude = Annotated[float, Field(ge=-90.0, le=90.0)] +Longitude = Annotated[float, Field(ge=-180.0, le=180.0)] + # --- POST /emergency ------------------------------------------------------- class EmergencyRequest(BaseModel): - lat: float - lng: float + lat: Latitude + lng: Longitude emergency_type: EmergencyType blood_group: BloodGroup @@ -76,8 +81,8 @@ class DonorRegisterRequest(BaseModel): name: str phone: str blood_group: BloodGroup - lat: float - lng: float + lat: Latitude + lng: Longitude last_donated: Optional[str] = None # "YYYY-MM-DD" or null # Optional. Drives the post-donation cooldown: 120 days for "female", # 90 days otherwise. Omitting it defaults to the 90-day window. diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 4cb944e..444932b 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -71,6 +71,11 @@ def test_emergency_rejects_bad_enum(client): assert resp.status_code == 422 +def test_emergency_rejects_out_of_range_coordinates(client): + assert _trigger(client, lat=999).status_code == 422 + assert _trigger(client, lng=-500).status_code == 422 + + # --- GET /emergency/{id}/status ------------------------------------------- def test_status_unknown_request_id_is_404(client): assert client.get("/emergency/nope/status").status_code == 404 From b0b30dcd038c037ce96b1a593ce40b15770da88d Mon Sep 17 00:00:00 2001 From: Prajnadeep Sarma Date: Fri, 19 Jun 2026 00:07:32 +0530 Subject: [PATCH 06/32] Add README diagrams (SVG) and enrich API metadata - Add docs/ with banner, request-flow, and architecture SVGs; embed them in the root README (banner + badges, diagrams replacing the ASCII art) and the architecture diagram in the backend README. - Enrich FastAPI/OpenAPI: summary, contact, MIT license, per-tag-group descriptions, and a summary on every route so /docs is fully described. - Refresh the root README API table with the current endpoints and flags. --- README.md | 91 +++++++++++++++++++------------------------ backend/README.md | 4 ++ backend/main.py | 79 +++++++++++++++++++++++++++++++------ docs/architecture.svg | 70 +++++++++++++++++++++++++++++++++ docs/banner.svg | 64 ++++++++++++++++++++++++++++++ docs/flow.svg | 75 +++++++++++++++++++++++++++++++++++ 6 files changed, 321 insertions(+), 62 deletions(-) create mode 100644 docs/architecture.svg create mode 100644 docs/banner.svg create mode 100644 docs/flow.svg diff --git a/README.md b/README.md index 9a0924d..a1bf279 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ +

+ GoldenHour — one request, the right hospital and ready blood, inside the hour that decides +

+ +

+ License: MIT + Python 3.12 + FastAPI + Pydantic v2 + Supabase + PostGIS + Tests: 31 passing + PWA: React 19 +

+ # GoldenHour > One tap. Two lifelines. The right hospital and the right blood — before the car even moves. @@ -14,64 +28,35 @@ outside any ambulance system. The "golden hour" after a trauma is when minutes decide outcomes; GoldenHour spends those minutes finding a hospital that can actually take the patient instead of driving to one that can't. -**Tags:** `Healthcare Technology` · `Social Impact` · `Bharat Academix CodeQuest 2026` · `FastAPI` · `React` · `Supabase` · `PostGIS` +**Tags:** `Healthcare Technology` · `Social Impact` · `Emergency Response` · `Bharat Academix CodeQuest 2026` · `FastAPI` · `React` · `Supabase` · `PostGIS` · `PWA` --- ## How it works (end-to-end) -``` - ┌──────────────────────────────────────────────────────────────────────┐ - │ PATIENT'S PHONE (PWA) │ - │ "Use my location" → emergency type + blood group → [ GET HELP ] │ - └───────────────────────────────────┬──────────────────────────────────┘ - │ POST /emergency { lat,lng,type,group } - ▼ - ┌──────────────────────────────────────────────────────────────────────┐ - │ GoldenHour API (FastAPI) │ - │ │ - │ ┌────────────────────────┐ ┌───────────────────────────┐ │ - │ │ Hospital ranking │ │ Blood matching │ │ - │ │ PostGIS radius + score │ │ ABO table + 5km + cooldown│ │ - │ └───────────┬────────────┘ └──────────────┬────────────┘ │ - │ │ top N hospitals │ compatible donors │ - │ ▼ ▼ │ - │ create confirmation_requests (1 link/hospital) count donors_alerted │ - └───────┬───────────────────────────────────┬───────────────────────────┘ - │ confirmation link │ live status - ▼ ▼ - ┌────────────────────────┐ ┌──────────────────────────────────────┐ - │ HOSPITAL CONTACT │ │ Back on the PATIENT'S phone │ - │ /confirm/:token │ │ /results/:id — cards flip live │ - │ [Accept] / [Decline] │──────► │ pending → CONFIRMED (poll/Realtime) │ - └────────────────────────┘ └──────────────────────────────────────┘ -``` +

+ One request triggers two parallel actions: hospital ranking and confirmation, plus donor matching and alerting +

-The first hospital to **Accept** "takes" the patient; later acceptances are told -the patient is already routed. The patient's screen updates live the instant a -hospital confirms. +A single `POST /emergency` fans out into both branches at once. On the **hospital** +side, the backend ranks nearby hospitals and sends each a one-tap confirmation +link; the **first to Accept "takes" the patient**, and later acceptances are told +the patient is already routed. On the **blood** side, it matches compatible donors +within range and alerts the nearest, directing them to the hospital's licensed +blood bank. The patient's screen updates live the instant a hospital confirms. --- ## System architecture -GoldenHour is a two-part system split cleanly by ownership, sharing one contract: +GoldenHour is a two-part system split cleanly by ownership, sharing one contract. +The backend itself is a strict three-layer design with two interchangeable data +stores behind a single `get_store()` — so the demo runs with **zero external +services** and the production path is a true drop-in, no service-code changes: -``` -┌─────────────────────────────┐ ┌──────────────────────────────────────┐ -│ frontend/ (React PWA) │ HTTP │ backend/ (FastAPI) │ -│ 4 screens, mobile-first │ ──────► │ routes → services → data layer │ -│ React Router · Tailwind │ JSON │ │ -└─────────────────────────────┘ │ ┌────────────────────────────────┐ │ - ▲ │ │ store.py │ │ - │ Supabase Realtime / │ │ • in-memory (demo, seeded) │ │ - │ polling for live updates │ │ • Supabase + PostGIS (prod) │ │ - └──────────────────────────┤ └────────────────────────────────┘ │ - │ external (prod only): │ - │ Google Maps ETA · MSG91/Gupshup SMS │ - └──────────────────────────────────────┘ - shared: API_CONTRACT.md (exact request/response shapes) -``` +

+ Three layers — HTTP routes, services, data — with InMemoryStore and SupabaseStore behind get_store() +

- **`API_CONTRACT.md`** is the single source of truth both sides build against. The backend's `schemas.py` and the frontend's `fetch()` calls mirror it exactly. @@ -93,6 +78,7 @@ GoldenHour/ ├── API_CONTRACT.md # SHARED contract — exact API request/response shapes ├── README.md # You are here ├── LICENSE +├── docs/ # README diagrams (banner, flow, architecture SVGs) ├── render.yaml # Backend deploy config (Render) ├── vercel.json # Frontend deploy config (Vercel) ├── docker-compose.yml # One-command local backend (demo mode) @@ -151,8 +137,8 @@ uvicorn main:app --reload --port 8000 ``` Open , trigger an emergency, and watch the hospital -confirmation links print to the console (or hit `GET /dev/links`). Run the tests -with `pytest`. +confirmation links print to the console (`GET /dev/links`) and the donor alerts +(`GET /dev/alerts`). Run the tests with `pytest`. Or, with Docker, from the repo root: `docker compose up --build`. @@ -175,11 +161,13 @@ Full shapes in [`API_CONTRACT.md`](API_CONTRACT.md). | Method & path | Purpose | | --- | --- | -| `POST /emergency` | Trigger — rank hospitals, alert donors, send confirmation links | -| `GET /emergency/{request_id}/status` | Live status — hospital replies + donor responses | +| `POST /emergency` | Trigger — rank hospitals, alert donors, send confirmation links (`rare_group` flag) | +| `GET /emergency/{request_id}/status` | Live status — hospital replies + donor responses (`unconfirmed_fallback` flag) | | `POST /confirm/{token}` | Hospital taps Accept / Not Available | -| `POST /donor/register` | Register a replacement-blood donor | +| `POST /donor/register` | Register a replacement-blood donor (optional `sex` for cooldown) | | `POST /sms/inbound` | Feature-phone SMS path (stretch) | +| `GET /health` · `GET /ready` | Liveness (mode) · readiness (pings the DB) | +| `GET /dev/links` · `GET /dev/alerts` | Demo: links sent to hospitals · alerts sent to donors | --- @@ -193,6 +181,7 @@ Full shapes in [`API_CONTRACT.md`](API_CONTRACT.md). Production secrets (`SUPABASE_*`, `GOOGLE_MAPS_API_KEY`, `MSG91_AUTH_KEY`, `VITE_API_BASE_URL`) are set in the respective dashboards — never committed. +See [`DEPLOY.md`](DEPLOY.md) for the full Supabase + Render runbook. --- diff --git a/backend/README.md b/backend/README.md index f582e61..628d315 100644 --- a/backend/README.md +++ b/backend/README.md @@ -34,6 +34,10 @@ algorithms, the confirmation/messaging flow, and live updates to the frontend. A strict three-layer design — HTTP in, services in the middle, data at the bottom: +

+ Three layers — HTTP routes, services, data — with InMemoryStore and SupabaseStore behind get_store() +

+ ``` HTTP (FastAPI routes) ── main.py │ validate with schemas.py, call ONE service, return a schema diff --git a/backend/main.py b/backend/main.py index d57fd4b..2c8d04c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -38,32 +38,72 @@ async def lifespan(_app: FastAPI): yield +# OpenAPI tag groups — describe each section shown in /docs and /redoc. +TAGS_METADATA = [ + { + "name": "emergency", + "description": "Trigger an emergency and poll its live status. One request " + "fans out into hospital ranking + blood-donor matching.", + }, + { + "name": "confirmation", + "description": "A hospital contact accepts or declines a patient via a " + "one-tap link. First to accept wins; later accepts are told it is taken.", + }, + { + "name": "donors", + "description": "Register replacement-blood donors (sex sets the cooldown).", + }, + { + "name": "sms", + "description": "Feature-phone fallback — parse an inbound SMS, reply with " + "the nearest hospitals.", + }, + { + "name": "meta", + "description": "Health, readiness, and demo helpers (`/dev/links`, " + "`/dev/alerts`).", + }, +] + app = FastAPI( title=settings.app_name, version=settings.version, + summary="Emergency hospital routing + replacement-blood donor alerting.", description=( - "Emergency hospital routing + replacement-blood donor alerting. " - "One GPS-triggered request finds a hospital with the right department " - "and a free bed, and alerts nearby compatible donors." + "One GPS-triggered request finds a hospital with the right department and " + "a free bed **and** alerts nearby compatible blood donors — two parallel " + "actions inside the golden hour.\n\n" + "Runs in **demo mode** with zero external services (seeded in-memory store); " + "set the Supabase / Google Maps / SMS env vars to switch to the real stack." ), + contact={ + "name": "GoldenHour — Bharat Academix CodeQuest 2026", + "url": "https://github.com/SarmaHighOnCode/GoldenHour", + }, + license_info={ + "name": "MIT", + "url": "https://github.com/SarmaHighOnCode/GoldenHour/blob/main/LICENSE", + }, + openapi_tags=TAGS_METADATA, lifespan=lifespan, ) add_cors_middleware(app) -@app.get("/", tags=["meta"]) +@app.get("/", tags=["meta"], summary="Service info") async def root(): return {"service": settings.app_name, "version": settings.version, "docs": "/docs"} -@app.get("/health", response_model=HealthResponse, tags=["meta"]) +@app.get("/health", response_model=HealthResponse, tags=["meta"], summary="Liveness probe") async def health_check(): """Liveness — cheap, no I/O (safe for frequent platform health checks).""" mode = "supabase" if settings.use_supabase else "in-memory" return HealthResponse(status="ok", version=settings.version, mode=mode) -@app.get("/ready", tags=["meta"]) +@app.get("/ready", tags=["meta"], summary="Readiness probe (pings the data layer)") async def readiness_check(): """Readiness — verifies the data layer actually answers (pings the DB). @@ -76,7 +116,12 @@ async def readiness_check(): return {"ready": True, "backend": store.backend} -@app.post("/emergency", response_model=EmergencyResponse, tags=["emergency"]) +@app.post( + "/emergency", + response_model=EmergencyResponse, + tags=["emergency"], + summary="Trigger an emergency", +) async def trigger_emergency(request: EmergencyRequest): """Trigger an emergency: rank hospitals, alert donors, send confirmations.""" store = get_store() @@ -93,6 +138,7 @@ async def trigger_emergency(request: EmergencyRequest): "/emergency/{request_id}/status", response_model=EmergencyStatusResponse, tags=["emergency"], + summary="Live emergency status", ) async def get_emergency_status(request_id: str): """Poll/subscribe target: live hospital replies + donor responses.""" @@ -106,6 +152,7 @@ async def get_emergency_status(request_id: str): "/confirm/{token}", response_model=HospitalConfirmResponse, tags=["confirmation"], + summary="Hospital accept / decline", ) async def confirm_hospital(token: str, request: HospitalConfirmRequest): """A hospital contact taps Accept (true) or Not Available (false).""" @@ -117,7 +164,12 @@ async def confirm_hospital(token: str, request: HospitalConfirmRequest): raise HTTPException(status_code=404, detail="Unknown confirmation token") -@app.post("/donor/register", response_model=DonorRegisterResponse, tags=["donors"]) +@app.post( + "/donor/register", + response_model=DonorRegisterResponse, + tags=["donors"], + summary="Register a blood donor", +) async def register_donor(request: DonorRegisterRequest): """Register a replacement-blood donor.""" donor_id = donor_service.register_donor( @@ -133,7 +185,12 @@ async def register_donor(request: DonorRegisterRequest): return DonorRegisterResponse(ok=True, donor_id=donor_id) -@app.post("/sms/inbound", response_model=SmsInboundResponse, tags=["sms"]) +@app.post( + "/sms/inbound", + response_model=SmsInboundResponse, + tags=["sms"], + summary="Inbound SMS (feature phone)", +) async def handle_sms_inbound(request: SmsInboundRequest): """Feature-phone path (stretch): parse an inbound SMS and reply.""" reply = sms_service.handle_inbound( @@ -142,13 +199,13 @@ async def handle_sms_inbound(request: SmsInboundRequest): return SmsInboundResponse(reply=reply) -@app.get("/dev/links", tags=["meta"]) +@app.get("/dev/links", tags=["meta"], summary="Recent hospital links (demo)") async def dev_links(): """Demo helper: the confirmation links recently 'sent' to hospitals.""" return {"links": sms_service.recent_links} -@app.get("/dev/alerts", tags=["meta"]) +@app.get("/dev/alerts", tags=["meta"], summary="Recent donor alerts (demo)") async def dev_alerts(): """Demo helper: the blood-needed alerts recently 'sent' to donors.""" return {"alerts": sms_service.recent_alerts} diff --git a/docs/architecture.svg b/docs/architecture.svg new file mode 100644 index 0000000..2f7155d --- /dev/null +++ b/docs/architecture.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + Backend architecture — three layers, two interchangeable stores + + + + HTTP routes · main.py + validate (schemas.py) → call exactly one service → return a schema + + + + + + Service layer · services/ + ranking · matching · confirmation · ETA · SMS/alerts · realtime + + + + + + Data layer · store.py + get_store() picks one → + + + + InMemoryStore + demo / CI · seeded 25 hospitals + + 180 donors · haversine + + + SupabaseStore + PostgreSQL + PostGIS · donors_nearby + RPC · Realtime · production + + + + External (prod only) + Google Maps Distance + Matrix — real drive ETA + MSG91 / Gupshup + DLT + — production SMS + Supabase Realtime + — live status push + + Absent these vars, the API + runs fully in demo mode + — zero external services, + CI needs no database. + + + + API_CONTRACT.md — the shared request/response shapes both sides build against + diff --git a/docs/banner.svg b/docs/banner.svg new file mode 100644 index 0000000..3ca9019 --- /dev/null +++ b/docs/banner.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GoldenHour + One request → the right hospital and ready blood, + inside the hour that decides. + + + + + + Healthcare Tech + + + + Social Impact + + + + FastAPI + + + + Supabase + + + + PostGIS + + + diff --git a/docs/flow.svg b/docs/flow.svg new file mode 100644 index 0000000..4406991 --- /dev/null +++ b/docs/flow.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + One request → two parallel actions + + + + + POST /emergency + GPS lat/lng · emergency type · blood group + + + + + + + + + + Rank hospitals + proximity · department · reliability (gated) + + + + Send 1-tap confirm link to each + + + + First to Accept = routed hospital + + + + + + Match donors + ABO/Rh · within 5 km · sex-aware cooldown + + + + Alert nearest top-K donors + + + + → Licensed blood bank + not the emergency ward + + + + + Patient screen updates live — Supabase Realtime / polling + From 9a40e14123a41affe91123134d613eb2354eba85 Mon Sep 17 00:00:00 2001 From: RishabhRana37 Date: Fri, 19 Jun 2026 00:34:28 +0530 Subject: [PATCH 07/32] feat(frontend): Implement premium dark-mode UI/UX for all 4 screens based on PRD --- .gitignore | 53 +++ .vscode/extensions.json | 5 + frontend/index.html | 13 +- frontend/package-lock.json | 414 +++++++++++++++++- frontend/package.json | 1 + frontend/postcss.config.js | 6 - frontend/src/App.jsx | 55 ++- frontend/src/components/DonorRegistration.jsx | 262 +++++++++-- .../src/components/HospitalConfirmPage.jsx | 152 ++++++- frontend/src/components/PatientIntakeView.jsx | 352 +++++++++++++-- .../src/components/PatientResultsView.jsx | 258 +++++++++-- frontend/src/index.css | 10 +- frontend/tailwind.config.js | 11 - frontend/vite.config.js | 6 +- 14 files changed, 1426 insertions(+), 172 deletions(-) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json delete mode 100644 frontend/postcss.config.js delete mode 100644 frontend/tailwind.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfeabd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +serveo.log +surge.log +surge_redeploy.log +tunnel.log + +# Dependency directories +node_modules/ +jspm_packages/ + +# Build outputs +dist/ +dist-ssr/ +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea/ +.DS_Store +*.suo +*.ntvsca* +*.njsproj +*.sln +*.sw? + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.production + +# Python Virtual Environments & Caches +venv/ +.venv/ +env/ +ENV/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ipynb_checkpoints/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c816c40 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "tomoki1207.pdf" + ] +} diff --git a/frontend/index.html b/frontend/index.html index f94d687..a02f49c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,9 +4,18 @@ - frontend + GoldenHour — Every Second Counts + + + + + - +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49a1301..4c50887 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@supabase/supabase-js": "^2.108.2", + "@tailwindcss/vite": "^4.3.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -59,7 +60,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -269,10 +269,33 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -776,6 +799,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", @@ -908,6 +965,290 @@ "node": ">=20.0.0" } }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -946,7 +1287,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -993,7 +1333,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1121,7 +1460,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1242,6 +1580,20 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1271,7 +1623,6 @@ "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -1606,6 +1957,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -1683,6 +2041,16 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2048,6 +2416,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2190,7 +2568,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2218,7 +2595,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -2260,7 +2636,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2270,7 +2645,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2415,6 +2789,20 @@ "dev": true, "license": "MIT" }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2499,7 +2887,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -2624,7 +3011,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index cff662a..00c37f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@supabase/supabase-js": "^2.108.2", + "@tailwindcss/vite": "^4.3.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 348dc6a..34c5d58 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; import PatientIntakeView from './components/PatientIntakeView'; import PatientResultsView from './components/PatientResultsView'; import HospitalConfirmPage from './components/HospitalConfirmPage'; @@ -8,17 +8,50 @@ import DonorRegistration from './components/DonorRegistration'; function App() { return ( -
-
- GoldenHour +
+ + {/* Glowing Top Navigation Header */} +
+
+ +
+ {/* Heartbeat pulse rings */} + + + + + +
+ + GoldenHour + + + + {/* Live Indicator Badge */} +
+ + + + + Emergency Link +
+
-
- - } /> - } /> - } /> - } /> - + + {/* Content Wrapper */} +
+
+ {/* Ambient background glow inside the card */} +
+
+ + + } /> + } /> + } /> + } /> + +
diff --git a/frontend/src/components/DonorRegistration.jsx b/frontend/src/components/DonorRegistration.jsx index b41b340..1252e03 100644 --- a/frontend/src/components/DonorRegistration.jsx +++ b/frontend/src/components/DonorRegistration.jsx @@ -1,66 +1,256 @@ import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +const bloodGroups = ['O+', 'O-', 'A+', 'A-', 'B+', 'B-', 'AB+', 'AB-']; function DonorRegistration() { + const navigate = useNavigate(); + + // Registration States const [registered, setRegistered] = useState(false); + const [donorId, setDonorId] = useState(''); + const [loading, setLoading] = useState(false); + + // Form Field States + const [name, setName] = useState(''); + const [phone, setPhone] = useState(''); + const [bloodGroup, setBloodGroup] = useState('O+'); + const [lastDonated, setLastDonated] = useState(''); + + // Location States + const [gpsLoading, setGpsLoading] = useState(false); + const [gpsCoords, setGpsCoords] = useState({ + lat: 26.9200, + lng: 75.8000, + locked: false, + }); + + const [formErrors, setFormErrors] = useState(''); + + // Fetch GPS Coordinates for registration + const handleGPSFetch = () => { + if (!navigator.geolocation) { + alert('Geolocation is not supported by your browser.'); + return; + } + + setGpsLoading(true); + navigator.geolocation.getCurrentPosition( + (position) => { + setGpsCoords({ + lat: parseFloat(position.coords.latitude.toFixed(6)), + lng: parseFloat(position.coords.longitude.toFixed(6)), + locked: true, + }); + setGpsLoading(false); + }, + (error) => { + console.error('GPS fetch error', error); + setGpsCoords(prev => ({ ...prev, locked: true })); // Fail silently to fallback + setGpsLoading(false); + }, + { timeout: 8000, enableHighAccuracy: true } + ); + }; + + // Submit Handler + const handleSubmit = async (e) => { + e.preventDefault(); + if (!name || !phone) { + setFormErrors('Please fill in your name and phone number.'); + return; + } + + setLoading(true); + setFormErrors(''); + + const payload = { + name, + phone, + blood_group: bloodGroup, + lat: gpsCoords.lat, + lng: gpsCoords.lng, + last_donated: lastDonated || null, + }; + + try { + const res = await fetch('http://localhost:8000/donor/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + const data = await res.json(); + setDonorId(data.donor_id || 'd42'); + setRegistered(true); + } else { + // Fallback for mock demo + generateMockDonor(); + } + } catch (e) { + console.warn('Backend server offline. Simulating mock donor registration.'); + generateMockDonor(); + } finally { + setLoading(false); + } + }; + + const generateMockDonor = () => { + const randomId = `D${Math.floor(100 + Math.random() * 900)}`; + setDonorId(randomId); + setRegistered(true); + }; if (registered) { return ( -
-
✓
-

Registration Complete

-

Thank you for signing up to save a life.

+
+
+ +
+ ✓ +
+
+ +
+

Registration Complete

+

+ Thank you, {name}. You have been registered in the regional donor grid. +

+
+ +
+

Donor ID Badge

+

{donorId}

+

{bloodGroup} Donor Grid

+
+ +

+ You will receive immediate SMS warnings if an emergency request matching your blood group is triggered within 5km. +

+ +
); } return (
-
-

Become a Donor

-

Register to be alerted when replacement blood is needed nearby.

+ {/* Header */} +
+

Register as Donor

+

Join the GoldenHour network to save lives by responding to nearby blood demands.

-
-
- - +
+ {formErrors &&

{formErrors}

} + + {/* Name Input */} +
+ + setName(e.target.value)} + className="w-full bg-slate-950/40 border border-slate-900 focus:border-rose-500/50 rounded-xl p-3 text-sm text-white placeholder-slate-600 focus:outline-none transition-colors" + placeholder="Asha Verma" + />
-
- - + {/* Phone Input */} +
+ + setPhone(e.target.value)} + className="w-full bg-slate-950/40 border border-slate-900 focus:border-rose-500/50 rounded-xl p-3 text-sm text-white placeholder-slate-600 focus:outline-none transition-colors" + placeholder="+91 99999 99999" + />
-
- - + {/* Blood Group Selector */} +
+ +
+ {bloodGroups.map((group) => ( + + ))} +
-
- - + {/* Last Donated */} +
+ + setLastDonated(e.target.value)} + className="w-full bg-slate-950/40 border border-slate-900 focus:border-rose-500/50 rounded-xl p-3 text-sm text-slate-400 focus:outline-none transition-colors" + />
- - -
+
); } diff --git a/frontend/src/components/HospitalConfirmPage.jsx b/frontend/src/components/HospitalConfirmPage.jsx index 87c6c68..e2733a3 100644 --- a/frontend/src/components/HospitalConfirmPage.jsx +++ b/frontend/src/components/HospitalConfirmPage.jsx @@ -5,48 +5,162 @@ function HospitalConfirmPage() { const { token } = useParams(); const [responded, setResponded] = useState(false); const [accepted, setAccepted] = useState(false); + const [alreadyConfirmed, setAlreadyConfirmed] = useState(false); + const [loading, setLoading] = useState(false); + const [hospitalName, setHospitalName] = useState('SMS Hospital'); - const handleResponse = (isAccepted) => { - // API call will go here + // Handle Accept / Decline Responses + const handleResponse = async (isAccepted) => { + setLoading(true); + try { + const payload = { accepted: isAccepted }; + const res = await fetch(`http://localhost:8000/confirm/${token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + const data = await res.json(); + setHospitalName(data.hospital_name || 'SMS Hospital'); + setAlreadyConfirmed(!!data.already_confirmed); + setAccepted(isAccepted); + setResponded(true); + } else { + // Fallback for mock demo + simulateResponse(isAccepted); + } + } catch (e) { + console.warn('Backend server offline. Simulating response locally.'); + simulateResponse(isAccepted); + } finally { + setLoading(false); + } + }; + + const simulateResponse = (isAccepted) => { + // Simulated demo:SMS Hospital accepted + setHospitalName('SMS Hospital'); + setAlreadyConfirmed(false); // Can toggle for testing setAccepted(isAccepted); setResponded(true); }; if (responded) { return ( -
-
✓
-

Thank you

-

The family has been notified.

+
+ {alreadyConfirmed ? ( + <> +
+
+ ! +
+
+
+

Already Claimed

+

+ This patient has already been routed to another trauma center. +

+
+ + ) : accepted ? ( + <> +
+ +
+ ✓ +
+
+
+

Dispatch Locked

+

+ Thank you, {hospitalName}. The ambulance has been directed to your facility, and the family has been notified. +

+
+ + ) : ( + <> +
+
+ ✕ +
+
+
+

Request Declined

+

+ This request will be forwarded to the next nearest trauma center. +

+
+ + )} + +
+

Secure token authorization: {token}

+
); } return ( -
-
-

🚨 Emergency Request

-
-

Type: Trauma

-

Blood: O+

-

ETA: 6 mins

+
+ {/* Alert Header */} +
+
+ + + + +

🚨 EMERGENCY REQUEST

+
+ + {/* Patient specs */} +
+
+

Emergency Type

+

Trauma / Accident

+
+
+

Blood Needed

+

O+

+
+
+

Distance & ETA

+

2.3 km (6 mins ETA)

+
+ {/* Action Buttons */}
- -
+ + {/* Footer Info */} +
+

Dispatcher token verification active

+
); } diff --git a/frontend/src/components/PatientIntakeView.jsx b/frontend/src/components/PatientIntakeView.jsx index a59b1ad..79bf811 100644 --- a/frontend/src/components/PatientIntakeView.jsx +++ b/frontend/src/components/PatientIntakeView.jsx @@ -1,48 +1,338 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +const emergencyTypes = [ + { + id: 'trauma', + name: 'Trauma / Accident', + icon: ( + + + + ), + description: 'Bones, cuts, crash injuries', + color: 'from-red-500/20 to-red-600/5 hover:border-red-500/40 text-red-400', + selectedBorder: 'border-red-500 shadow-[0_0_15px_rgba(239,68,68,0.25)]', + }, + { + id: 'cardiac', + name: 'Cardiac / Heart', + icon: ( + + + + ), + description: 'Chest pain, arrest, stroke', + color: 'from-rose-500/20 to-rose-600/5 hover:border-rose-500/40 text-rose-400', + selectedBorder: 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.25)]', + }, + { + id: 'obstetric', + name: 'Obstetric / Pregnancy', + icon: ( + + + + ), + description: 'Labor, maternity emergencies', + color: 'from-amber-500/20 to-amber-600/5 hover:border-amber-500/40 text-amber-400', + selectedBorder: 'border-amber-500 shadow-[0_0_15px_rgba(245,158,11,0.25)]', + }, + { + id: 'general', + name: 'General Medical', + icon: ( + + + + ), + description: 'Fever, breathing, poisoning', + color: 'from-blue-500/20 to-blue-600/5 hover:border-blue-500/40 text-blue-400', + selectedBorder: 'border-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.25)]', + }, +]; + +const bloodGroups = ['O+', 'O-', 'A+', 'A-', 'B+', 'B-', 'AB+', 'AB-']; + function PatientIntakeView() { const navigate = useNavigate(); + // Location State + const [gpsLoading, setGpsLoading] = useState(false); + const [gpsData, setGpsData] = useState({ + lat: 26.9124, // Default Jaipur + lng: 75.7873, + address: 'Jaipur Highway, NH-8 (Default)', + locked: false, + }); + + // Intake Form Selection State + const [selectedType, setSelectedType] = useState('trauma'); + const [selectedBlood, setSelectedBlood] = useState('O+'); + + // Countdown & Dispatch State + const [showCountdown, setShowCountdown] = useState(false); + const [count, setCount] = useState(5); + const [errorMsg, setErrorMsg] = useState(''); + + // Fetch GPS Coordinates + const handleGPSFetch = () => { + if (!navigator.geolocation) { + setErrorMsg('Geolocation is not supported by your browser.'); + return; + } + + setGpsLoading(true); + setErrorMsg(''); + + navigator.geolocation.getCurrentPosition( + (position) => { + const { latitude, longitude } = position.coords; + setGpsData({ + lat: parseFloat(latitude.toFixed(6)), + lng: parseFloat(longitude.toFixed(6)), + address: `NH-8 Highway, Coord: (${latitude.toFixed(4)}, ${longitude.toFixed(4)})`, + locked: true, + }); + setGpsLoading(false); + }, + (error) => { + console.error('GPS fetch error', error); + setGpsData((prev) => ({ ...prev, locked: true })); // Lock with fallback + setGpsLoading(false); + }, + { timeout: 8000, enableHighAccuracy: true } + ); + }; + + // Start Countdown Flow + const triggerEmergencyFlow = () => { + setShowCountdown(true); + setCount(5); + }; + + // Countdown Loop + useEffect(() => { + let timer; + if (showCountdown && count > 0) { + timer = setTimeout(() => setCount(count - 1), 1000); + } else if (showCountdown && count === 0) { + dispatchEmergency(); + } + return () => clearTimeout(timer); + }, [showCountdown, count]); + + // Cancel Dispatch + const cancelDispatch = () => { + setShowCountdown(false); + setCount(5); + }; + + // Trigger POST API call to Backend + const dispatchEmergency = async () => { + try { + const payload = { + lat: gpsData.lat, + lng: gpsData.lng, + emergency_type: selectedType, + blood_group: selectedBlood, + }; + + const res = await fetch('http://localhost:8000/emergency', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + const data = await res.json(); + navigate(`/results/${data.request_id || 'test123'}`); + } else { + // Fallback to test view on error/no backend + navigate('/results/test123'); + } + } catch (e) { + console.warn('Backend offline, running in mock simulation mode.'); + navigate('/results/test123'); + } + }; + return (
-

Emergency? Get help now.

- - {/* Fake location for now */} - - + {/* Page Header */}
- - +

Emergency dispatch

+

Please provide patient details to alert nearby trauma responders immediately.

-
- - + {/* GPS Location Widget */} +
+
+
+ + + + + Incident Location +
+ {gpsData.locked && ( + + + GPS Locked + + )} +
+ +
+ + +
+

{gpsData.address}

+

Coord: {gpsData.lat.toFixed(4)}, {gpsData.lng.toFixed(4)}

+
+
+ + {errorMsg &&

{errorMsg}

} +
+ + {/* Emergency Type Selector (Grid Card Interface) */} +
+ +
+ {emergencyTypes.map((type) => ( + + ))} +
- + ))} +
+
+ + {/* Dispatch Button */} + + + {/* Become a Donor Link */} +
+ +
+ + {/* Countdown Dispatch Overlay */} + {showCountdown && ( +
+
+
+ {/* Circular progress SVG */} + + + + +
+ + {count} + + Countdown +
+
+ +
+

Emergency Dispatch Pending

+

+ Alerting nearby responders for a {selectedType} request ({selectedBlood}). +

+
+ + +
+
+ )}
); } diff --git a/frontend/src/components/PatientResultsView.jsx b/frontend/src/components/PatientResultsView.jsx index 3a4a4d6..f4cf998 100644 --- a/frontend/src/components/PatientResultsView.jsx +++ b/frontend/src/components/PatientResultsView.jsx @@ -1,50 +1,240 @@ -import React from 'react'; -import { useParams } from 'react-router-dom'; - -const fakeHospitals = [ - { hospital_id: "h1", name: "SMS Hospital", eta_minutes: 6, department_match: true, status: "pending", phone: "+910000000000" }, - { hospital_id: "h2", name: "Fortis Jaipur", eta_minutes: 9, department_match: true, status: "confirmed", phone: "+910000000000" }, - { hospital_id: "h3", name: "Manipal Jaipur", eta_minutes: 12, department_match: false, status: "pending", phone: "+910000000000" }, -]; +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; function PatientResultsView() { const { id } = useParams(); + const navigate = useNavigate(); + + // Active status of scanning radar + const [scanning, setScanning] = useState(true); + + // Live Hospital Data State + const [hospitals, setHospitals] = useState([ + { hospital_id: 'h1', name: 'SMS Hospital', eta_minutes: 6, distance_km: 2.3, department_match: true, status: 'pending', phone: '+919876543210' }, + { hospital_id: 'h2', name: 'Fortis Hospital', eta_minutes: 9, distance_km: 3.8, department_match: true, status: 'pending', phone: '+918765432109' }, + { hospital_id: 'h3', name: 'Manipal Jaipur', eta_minutes: 12, distance_km: 5.1, department_match: false, status: 'pending', phone: '+917654321098' }, + ]); + + // Live Donors Data State + const [donorsAlerted, setDonorsAlerted] = useState(5); + const [donorsResponded, setDonorsResponded] = useState(0); + + // Live API Polling Loop (GET /emergency/{id}/status) + useEffect(() => { + let pollingInterval; + + const fetchStatus = async () => { + try { + const res = await fetch(`http://localhost:8000/emergency/${id}/status`); + if (res.ok) { + const data = await res.json(); + // Update from server payload + if (data.hospitals) setHospitals(data.hospitals); + if (data.donors_alerted !== undefined) setDonorsAlerted(data.donors_alerted); + if (data.donors_responded !== undefined) setDonorsResponded(data.donors_responded); + + // Check if at least one hospital is confirmed + const anyConfirmed = data.hospitals?.some(h => h.status === 'confirmed'); + if (anyConfirmed) { + setScanning(false); + } + } + } catch (e) { + // Safe backend offline warning + console.warn('Backend server offline. Running local simulation for hackathon demo.'); + } + }; + + // Run first pull + fetchStatus(); + + // Poll every 3 seconds as defined in the PRD + pollingInterval = setInterval(fetchStatus, 3000); + + return () => clearInterval(pollingInterval); + }, [id]); + + // Hackathon Local Demo Simulation: If the backend is offline, + // we simulate status transitions over time so judges see live updates. + useEffect(() => { + // SMS Hospital accepts after 6 seconds + const t1 = setTimeout(() => { + setHospitals(prev => + prev.map(h => (h.hospital_id === 'h1' ? { ...h, status: 'confirmed' } : h)) + ); + setDonorsResponded(1); + setScanning(false); + }, 6000); + + // Fortis Jaipur declines after 12 seconds + const t2 = setTimeout(() => { + setHospitals(prev => + prev.map(h => (h.hospital_id === 'h2' ? { ...h, status: 'declined' } : h)) + ); + setDonorsResponded(3); + }, 12000); + + // Manipal Jaipur accepts after 18 seconds (in case sms didn't or for extra response) + const t3 = setTimeout(() => { + setDonorsResponded(4); + }, 18000); + + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; + }, []); return ( -
-

Hospitals Nearby

- -
- {fakeHospitals.map(h => ( -
-
-

{h.name}

- - {h.status} +
+ {/* Header Info */} +
+
+

Trauma Routing

+

Request Ref: {id}

+
+ +
+ + {/* Live Scanner Banner */} +
+ {scanning ? ( + <> +
+ + + + Searching responders
- -
- {h.eta_minutes} min ETA - {h.department_match && Dept match} +

+ Pinging dispatchers at regional medical checkpoints. GPS tracker active. +

+ {/* Scanning radar line */} +
+ + ) : ( + <> +
+ + + + Routing locked
+

+ Responding trauma unit has locked the dispatch request. Family & ambulance notified. +

+ + )} +
- - Call Hospital - -
- ))} + {/* Hospital Cards List */} +
+ {/* Radar beam scan overlay */} + {scanning && ( +
+ )} + + {hospitals.map(h => { + const isConfirmed = h.status === 'confirmed'; + const isDeclined = h.status === 'declined'; + + return ( +
+ {/* Background gradient for confirmed card */} + {isConfirmed && ( +
+ )} + + {/* Card Title & Status Badge */} +
+

{h.name}

+ + + {h.status} + +
+ + {/* ETA, Distance & Match Badges */} +
+ + + + + {h.eta_minutes} min ETA + + + + {h.distance_km} km away + + + {h.department_match && ( + + + + + Trauma Match + + )} +
+ + {/* Tap to Call */} + + + + + {isConfirmed ? 'Establish Call Link' : 'Call Dispatcher'} + +
+ ); + })}
-
+ {/* Fixed Donor Response Notification Bar */} +
-
- 5 donors alerted nearby - 2 responded +
+ + + {donorsAlerted} emergency donors alerted + + {donorsResponded} donors responding to location
-
- {/* Blood drop icon */} - + +
+ + +
diff --git a/frontend/src/index.css b/frontend/src/index.css index 55d2777..aced951 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,9 +1,5 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; -@layer base { - body { - @apply antialiased text-gray-900 bg-gray-50; - } +body { + @apply antialiased bg-slate-900 text-slate-100; } diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js deleted file mode 100644 index dca8ba0..0000000 --- a/frontend/tailwind.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [], -} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8b0f57b..3d15f68 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,7 +1,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + tailwindcss(), + ], }) From fddd9cb359faf193ecb375fb0940e04574eb2e9e Mon Sep 17 00:00:00 2001 From: RishabhRana37 Date: Fri, 19 Jun 2026 07:10:41 +0530 Subject: [PATCH 08/32] chore(design): Scaffold React+Vite+TS, define visual design tokens, and build shared component library --- DESIGN.md | 50 +++ frontend/index.html | 10 +- frontend/package-lock.json | 121 ++++--- frontend/package.json | 4 +- frontend/src/App.css | 191 +--------- frontend/src/App.jsx | 61 ---- frontend/src/App.tsx | 28 ++ frontend/src/components/DonorRegistration.jsx | 258 ------------- frontend/src/components/DonorRegistration.tsx | 10 + .../src/components/HospitalConfirmPage.jsx | 168 --------- .../src/components/HospitalConfirmPage.tsx | 12 + frontend/src/components/PatientIntakeView.jsx | 340 ------------------ frontend/src/components/PatientIntakeView.tsx | 10 + .../src/components/PatientResultsView.jsx | 245 ------------- .../src/components/PatientResultsView.tsx | 12 + frontend/src/components/ui/AppBar.tsx | 39 ++ frontend/src/components/ui/Badge.tsx | 38 ++ frontend/src/components/ui/Button.tsx | 54 +++ frontend/src/components/ui/Card.tsx | 42 +++ frontend/src/components/ui/Input.tsx | 38 ++ frontend/src/components/ui/Select.tsx | 56 +++ frontend/src/index.css | 52 ++- frontend/src/main.jsx | 10 - frontend/src/main.tsx | 10 + frontend/tsconfig.json | 25 ++ frontend/tsconfig.node.json | 10 + frontend/{vite.config.js => vite.config.ts} | 0 27 files changed, 584 insertions(+), 1310 deletions(-) create mode 100644 DESIGN.md delete mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/App.tsx delete mode 100644 frontend/src/components/DonorRegistration.jsx create mode 100644 frontend/src/components/DonorRegistration.tsx delete mode 100644 frontend/src/components/HospitalConfirmPage.jsx create mode 100644 frontend/src/components/HospitalConfirmPage.tsx delete mode 100644 frontend/src/components/PatientIntakeView.jsx create mode 100644 frontend/src/components/PatientIntakeView.tsx delete mode 100644 frontend/src/components/PatientResultsView.jsx create mode 100644 frontend/src/components/PatientResultsView.tsx create mode 100644 frontend/src/components/ui/AppBar.tsx create mode 100644 frontend/src/components/ui/Badge.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/Card.tsx create mode 100644 frontend/src/components/ui/Input.tsx create mode 100644 frontend/src/components/ui/Select.tsx delete mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json rename frontend/{vite.config.js => vite.config.ts} (100%) diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..f7d87a1 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,50 @@ +# GoldenHour Visual Identity & Design System + +GoldenHour's visual language balances **calm urgency**—trustworthy like a medical device, fast like a 911 dispatch console. The interface is optimized for mobile-first, one-handed, glove-friendly usage during critical moments. + +--- + +## 🎨 Color Palette + +| Token | CSS Variable | Hex Code | Purpose | +| :--- | :--- | :--- | :--- | +| **bg** | `--color-bg` | `#FBFAF8` | Warm paper background for readability | +| **surface** | `--color-surface` | `#FFFFFF` | Card backgrounds, separated by soft shadows (no harsh borders) | +| **ink** | `--color-ink` | `#1A1714` | High-contrast readable typography | +| **muted** | `--color-ink-muted` | `#6B6560` | Secondary metadata and labels | +| **emergency** | `--color-emergency` | `#DC2626` | Primary CTAs, urgent warnings | +| **pressed** | `--color-emergency-pressed` | `#B91C1C` | Active/Pressed states for emergency actions | +| **success** | `--color-success` | `#059669` | Confirmed responses, lock status | +| **pending** | `--color-pending` | `#9CA3AF` | Warm gray for loading, pending, inactive states | +| **golden hour** | `--color-accent` | `#F59E0B` | Brand mark and subtle highlighting | + +--- + +## 📐 Shape & Spacing + +* **Border Radius:** + * Cards: `rounded-2xl` (`1rem`) + * Buttons: `rounded-xl` (`0.75rem`) +* **Touch Targets:** + * Primary Action Buttons: Minimum height of `56px` (`h-14`) for easy tap states. +* **Shadows:** + * Soft layered shadows to create depth without borders: + ```css + box-shadow: 0 1px 3px rgba(26, 23, 20, 0.02), 0 8px 24px rgba(26, 23, 20, 0.04); + ``` + +--- + +## ✍️ Typography + +* **Font Family:** Inter throughout (tightly tracked headings). +* **Headings:** Bold, tight-tracked (`tracking-tight`), text sizes `text-3xl` or `text-4xl`. +* **Body:** Generous line heights for readability. + +--- + +## 🎬 Motion + +* **Buttons:** Click/tap feedback scale down (`scale-95`). +* **Transitions:** Spring transitions for status/state changes (`framer-motion`). +* **Card Entrances:** Staggered fade and slide-up transition. diff --git a/frontend/index.html b/frontend/index.html index a02f49c..38ab354 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,18 +5,18 @@ GoldenHour — Every Second Counts - + - + - +
- + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4c50887..23586e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "framer-motion": "^12.40.0", + "lucide-react": "^1.21.0", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -24,7 +26,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "postcss": "^8.5.15", - "react-router-dom": "^7.18.0", + "react-router-dom": "^6.30.4", "tailwindcss": "^4.3.1", "vite": "^8.0.12" } @@ -576,6 +578,16 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -1502,20 +1514,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1906,6 +1904,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2416,6 +2441,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2442,6 +2476,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2653,43 +2702,37 @@ } }, "node_modules/react-router": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", - "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "dev": true, "license": "MIT", "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" + "@remix-run/router": "1.23.3" }, "engines": { - "node": ">=20.0.0" + "node": ">=14.0.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } + "react": ">=16.8" } }, "node_modules/react-router-dom": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", - "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "dev": true, "license": "MIT", "dependencies": { - "react-router": "7.18.0" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { - "node": ">=20.0.0" + "node": ">=14.0.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": ">=16.8", + "react-dom": ">=16.8" } }, "node_modules/rolldown": { @@ -2742,13 +2785,6 @@ "semver": "bin/semver.js" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "dev": true, - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2824,7 +2860,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { diff --git a/frontend/package.json b/frontend/package.json index 00c37f1..e281014 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "framer-motion": "^12.40.0", + "lucide-react": "^1.21.0", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -26,7 +28,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "postcss": "^8.5.15", - "react-router-dom": "^7.18.0", + "react-router-dom": "^6.30.4", "tailwindcss": "^4.3.1", "vite": "^8.0.12" } diff --git a/frontend/src/App.css b/frontend/src/App.css index f90339d..0c8e5b8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,184 +1,19 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); +/* App-specific custom animations or styles */ +@keyframes pulse-ring { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4); } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba(220, 38, 38, 0); } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(220, 38, 38, 0); } } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } +.animate-emergency-pulse { + animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx deleted file mode 100644 index 34c5d58..0000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; -import PatientIntakeView from './components/PatientIntakeView'; -import PatientResultsView from './components/PatientResultsView'; -import HospitalConfirmPage from './components/HospitalConfirmPage'; -import DonorRegistration from './components/DonorRegistration'; - -function App() { - return ( - -
- - {/* Glowing Top Navigation Header */} -
-
- -
- {/* Heartbeat pulse rings */} - - - - - -
- - GoldenHour - - - - {/* Live Indicator Badge */} -
- - - - - Emergency Link -
-
-
- - {/* Content Wrapper */} -
-
- {/* Ambient background glow inside the card */} -
-
- - - } /> - } /> - } /> - } /> - -
-
-
-
- ); -} - -export default App; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..be7162f --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { AppBar } from './components/ui/AppBar'; +import PatientIntakeView from './components/PatientIntakeView'; +import PatientResultsView from './components/PatientResultsView'; +import HospitalConfirmPage from './components/HospitalConfirmPage'; +import DonorRegistration from './components/DonorRegistration'; + +function App() { + return ( + +
+ + +
+ + } /> + } /> + } /> + } /> + +
+
+
+ ); +} + +export default App; diff --git a/frontend/src/components/DonorRegistration.jsx b/frontend/src/components/DonorRegistration.jsx deleted file mode 100644 index 1252e03..0000000 --- a/frontend/src/components/DonorRegistration.jsx +++ /dev/null @@ -1,258 +0,0 @@ -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -const bloodGroups = ['O+', 'O-', 'A+', 'A-', 'B+', 'B-', 'AB+', 'AB-']; - -function DonorRegistration() { - const navigate = useNavigate(); - - // Registration States - const [registered, setRegistered] = useState(false); - const [donorId, setDonorId] = useState(''); - const [loading, setLoading] = useState(false); - - // Form Field States - const [name, setName] = useState(''); - const [phone, setPhone] = useState(''); - const [bloodGroup, setBloodGroup] = useState('O+'); - const [lastDonated, setLastDonated] = useState(''); - - // Location States - const [gpsLoading, setGpsLoading] = useState(false); - const [gpsCoords, setGpsCoords] = useState({ - lat: 26.9200, - lng: 75.8000, - locked: false, - }); - - const [formErrors, setFormErrors] = useState(''); - - // Fetch GPS Coordinates for registration - const handleGPSFetch = () => { - if (!navigator.geolocation) { - alert('Geolocation is not supported by your browser.'); - return; - } - - setGpsLoading(true); - navigator.geolocation.getCurrentPosition( - (position) => { - setGpsCoords({ - lat: parseFloat(position.coords.latitude.toFixed(6)), - lng: parseFloat(position.coords.longitude.toFixed(6)), - locked: true, - }); - setGpsLoading(false); - }, - (error) => { - console.error('GPS fetch error', error); - setGpsCoords(prev => ({ ...prev, locked: true })); // Fail silently to fallback - setGpsLoading(false); - }, - { timeout: 8000, enableHighAccuracy: true } - ); - }; - - // Submit Handler - const handleSubmit = async (e) => { - e.preventDefault(); - if (!name || !phone) { - setFormErrors('Please fill in your name and phone number.'); - return; - } - - setLoading(true); - setFormErrors(''); - - const payload = { - name, - phone, - blood_group: bloodGroup, - lat: gpsCoords.lat, - lng: gpsCoords.lng, - last_donated: lastDonated || null, - }; - - try { - const res = await fetch('http://localhost:8000/donor/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (res.ok) { - const data = await res.json(); - setDonorId(data.donor_id || 'd42'); - setRegistered(true); - } else { - // Fallback for mock demo - generateMockDonor(); - } - } catch (e) { - console.warn('Backend server offline. Simulating mock donor registration.'); - generateMockDonor(); - } finally { - setLoading(false); - } - }; - - const generateMockDonor = () => { - const randomId = `D${Math.floor(100 + Math.random() * 900)}`; - setDonorId(randomId); - setRegistered(true); - }; - - if (registered) { - return ( -
-
- -
- ✓ -
-
- -
-

Registration Complete

-

- Thank you, {name}. You have been registered in the regional donor grid. -

-
- -
-

Donor ID Badge

-

{donorId}

-

{bloodGroup} Donor Grid

-
- -

- You will receive immediate SMS warnings if an emergency request matching your blood group is triggered within 5km. -

- - -
- ); - } - - return ( -
- {/* Header */} -
-

Register as Donor

-

Join the GoldenHour network to save lives by responding to nearby blood demands.

-
- -
- {formErrors &&

{formErrors}

} - - {/* Name Input */} -
- - setName(e.target.value)} - className="w-full bg-slate-950/40 border border-slate-900 focus:border-rose-500/50 rounded-xl p-3 text-sm text-white placeholder-slate-600 focus:outline-none transition-colors" - placeholder="Asha Verma" - /> -
- - {/* Phone Input */} -
- - setPhone(e.target.value)} - className="w-full bg-slate-950/40 border border-slate-900 focus:border-rose-500/50 rounded-xl p-3 text-sm text-white placeholder-slate-600 focus:outline-none transition-colors" - placeholder="+91 99999 99999" - /> -
- - {/* Blood Group Selector */} -
- -
- {bloodGroups.map((group) => ( - - ))} -
-
- - {/* Last Donated */} -
- - setLastDonated(e.target.value)} - className="w-full bg-slate-950/40 border border-slate-900 focus:border-rose-500/50 rounded-xl p-3 text-sm text-slate-400 focus:outline-none transition-colors" - /> -
- - {/* Geolocation Lock Button */} - - - {/* Submit Register Button */} - -
-
- ); -} - -export default DonorRegistration; diff --git a/frontend/src/components/DonorRegistration.tsx b/frontend/src/components/DonorRegistration.tsx new file mode 100644 index 0000000..1911e43 --- /dev/null +++ b/frontend/src/components/DonorRegistration.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function DonorRegistration() { + return ( +
+

Donor Registration Screen

+

Route: "/register"

+
+ ); +} diff --git a/frontend/src/components/HospitalConfirmPage.jsx b/frontend/src/components/HospitalConfirmPage.jsx deleted file mode 100644 index e2733a3..0000000 --- a/frontend/src/components/HospitalConfirmPage.jsx +++ /dev/null @@ -1,168 +0,0 @@ -import React, { useState } from 'react'; -import { useParams } from 'react-router-dom'; - -function HospitalConfirmPage() { - const { token } = useParams(); - const [responded, setResponded] = useState(false); - const [accepted, setAccepted] = useState(false); - const [alreadyConfirmed, setAlreadyConfirmed] = useState(false); - const [loading, setLoading] = useState(false); - const [hospitalName, setHospitalName] = useState('SMS Hospital'); - - // Handle Accept / Decline Responses - const handleResponse = async (isAccepted) => { - setLoading(true); - try { - const payload = { accepted: isAccepted }; - const res = await fetch(`http://localhost:8000/confirm/${token}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (res.ok) { - const data = await res.json(); - setHospitalName(data.hospital_name || 'SMS Hospital'); - setAlreadyConfirmed(!!data.already_confirmed); - setAccepted(isAccepted); - setResponded(true); - } else { - // Fallback for mock demo - simulateResponse(isAccepted); - } - } catch (e) { - console.warn('Backend server offline. Simulating response locally.'); - simulateResponse(isAccepted); - } finally { - setLoading(false); - } - }; - - const simulateResponse = (isAccepted) => { - // Simulated demo:SMS Hospital accepted - setHospitalName('SMS Hospital'); - setAlreadyConfirmed(false); // Can toggle for testing - setAccepted(isAccepted); - setResponded(true); - }; - - if (responded) { - return ( -
- {alreadyConfirmed ? ( - <> -
-
- ! -
-
-
-

Already Claimed

-

- This patient has already been routed to another trauma center. -

-
- - ) : accepted ? ( - <> -
- -
- ✓ -
-
-
-

Dispatch Locked

-

- Thank you, {hospitalName}. The ambulance has been directed to your facility, and the family has been notified. -

-
- - ) : ( - <> -
-
- ✕ -
-
-
-

Request Declined

-

- This request will be forwarded to the next nearest trauma center. -

-
- - )} - -
-

Secure token authorization: {token}

-
-
- ); - } - - return ( -
- {/* Alert Header */} -
-
- - - - -

🚨 EMERGENCY REQUEST

-
- - {/* Patient specs */} -
-
-

Emergency Type

-

Trauma / Accident

-
-
-

Blood Needed

-

O+

-
-
-

Distance & ETA

-

2.3 km (6 mins ETA)

-
-
-
- - {/* Action Buttons */} -
- - - -
- - {/* Footer Info */} -
-

Dispatcher token verification active

-
-
- ); -} - -export default HospitalConfirmPage; diff --git a/frontend/src/components/HospitalConfirmPage.tsx b/frontend/src/components/HospitalConfirmPage.tsx new file mode 100644 index 0000000..e07fb1a --- /dev/null +++ b/frontend/src/components/HospitalConfirmPage.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +export default function HospitalConfirmPage() { + const { token } = useParams<{ token: string }>(); + return ( +
+

Hospital Confirmation Screen

+

Route: "/confirm/{token}"

+
+ ); +} diff --git a/frontend/src/components/PatientIntakeView.jsx b/frontend/src/components/PatientIntakeView.jsx deleted file mode 100644 index 79bf811..0000000 --- a/frontend/src/components/PatientIntakeView.jsx +++ /dev/null @@ -1,340 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; - -const emergencyTypes = [ - { - id: 'trauma', - name: 'Trauma / Accident', - icon: ( - - - - ), - description: 'Bones, cuts, crash injuries', - color: 'from-red-500/20 to-red-600/5 hover:border-red-500/40 text-red-400', - selectedBorder: 'border-red-500 shadow-[0_0_15px_rgba(239,68,68,0.25)]', - }, - { - id: 'cardiac', - name: 'Cardiac / Heart', - icon: ( - - - - ), - description: 'Chest pain, arrest, stroke', - color: 'from-rose-500/20 to-rose-600/5 hover:border-rose-500/40 text-rose-400', - selectedBorder: 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.25)]', - }, - { - id: 'obstetric', - name: 'Obstetric / Pregnancy', - icon: ( - - - - ), - description: 'Labor, maternity emergencies', - color: 'from-amber-500/20 to-amber-600/5 hover:border-amber-500/40 text-amber-400', - selectedBorder: 'border-amber-500 shadow-[0_0_15px_rgba(245,158,11,0.25)]', - }, - { - id: 'general', - name: 'General Medical', - icon: ( - - - - ), - description: 'Fever, breathing, poisoning', - color: 'from-blue-500/20 to-blue-600/5 hover:border-blue-500/40 text-blue-400', - selectedBorder: 'border-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.25)]', - }, -]; - -const bloodGroups = ['O+', 'O-', 'A+', 'A-', 'B+', 'B-', 'AB+', 'AB-']; - -function PatientIntakeView() { - const navigate = useNavigate(); - - // Location State - const [gpsLoading, setGpsLoading] = useState(false); - const [gpsData, setGpsData] = useState({ - lat: 26.9124, // Default Jaipur - lng: 75.7873, - address: 'Jaipur Highway, NH-8 (Default)', - locked: false, - }); - - // Intake Form Selection State - const [selectedType, setSelectedType] = useState('trauma'); - const [selectedBlood, setSelectedBlood] = useState('O+'); - - // Countdown & Dispatch State - const [showCountdown, setShowCountdown] = useState(false); - const [count, setCount] = useState(5); - const [errorMsg, setErrorMsg] = useState(''); - - // Fetch GPS Coordinates - const handleGPSFetch = () => { - if (!navigator.geolocation) { - setErrorMsg('Geolocation is not supported by your browser.'); - return; - } - - setGpsLoading(true); - setErrorMsg(''); - - navigator.geolocation.getCurrentPosition( - (position) => { - const { latitude, longitude } = position.coords; - setGpsData({ - lat: parseFloat(latitude.toFixed(6)), - lng: parseFloat(longitude.toFixed(6)), - address: `NH-8 Highway, Coord: (${latitude.toFixed(4)}, ${longitude.toFixed(4)})`, - locked: true, - }); - setGpsLoading(false); - }, - (error) => { - console.error('GPS fetch error', error); - setGpsData((prev) => ({ ...prev, locked: true })); // Lock with fallback - setGpsLoading(false); - }, - { timeout: 8000, enableHighAccuracy: true } - ); - }; - - // Start Countdown Flow - const triggerEmergencyFlow = () => { - setShowCountdown(true); - setCount(5); - }; - - // Countdown Loop - useEffect(() => { - let timer; - if (showCountdown && count > 0) { - timer = setTimeout(() => setCount(count - 1), 1000); - } else if (showCountdown && count === 0) { - dispatchEmergency(); - } - return () => clearTimeout(timer); - }, [showCountdown, count]); - - // Cancel Dispatch - const cancelDispatch = () => { - setShowCountdown(false); - setCount(5); - }; - - // Trigger POST API call to Backend - const dispatchEmergency = async () => { - try { - const payload = { - lat: gpsData.lat, - lng: gpsData.lng, - emergency_type: selectedType, - blood_group: selectedBlood, - }; - - const res = await fetch('http://localhost:8000/emergency', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (res.ok) { - const data = await res.json(); - navigate(`/results/${data.request_id || 'test123'}`); - } else { - // Fallback to test view on error/no backend - navigate('/results/test123'); - } - } catch (e) { - console.warn('Backend offline, running in mock simulation mode.'); - navigate('/results/test123'); - } - }; - - return ( -
- {/* Page Header */} -
-

Emergency dispatch

-

Please provide patient details to alert nearby trauma responders immediately.

-
- - {/* GPS Location Widget */} -
-
-
- - - - - Incident Location -
- {gpsData.locked && ( - - - GPS Locked - - )} -
- -
- - -
-

{gpsData.address}

-

Coord: {gpsData.lat.toFixed(4)}, {gpsData.lng.toFixed(4)}

-
-
- - {errorMsg &&

{errorMsg}

} -
- - {/* Emergency Type Selector (Grid Card Interface) */} -
- -
- {emergencyTypes.map((type) => ( - - ))} -
-
- - {/* Blood Group Selector (Grid Pills Interface) */} -
- -
- {bloodGroups.map((group) => ( - - ))} -
-
- - {/* Dispatch Button */} - - - {/* Become a Donor Link */} -
- -
- - {/* Countdown Dispatch Overlay */} - {showCountdown && ( -
-
-
- {/* Circular progress SVG */} - - - - -
- - {count} - - Countdown -
-
- -
-

Emergency Dispatch Pending

-

- Alerting nearby responders for a {selectedType} request ({selectedBlood}). -

-
- - -
-
- )} -
- ); -} - -export default PatientIntakeView; diff --git a/frontend/src/components/PatientIntakeView.tsx b/frontend/src/components/PatientIntakeView.tsx new file mode 100644 index 0000000..6d4dd40 --- /dev/null +++ b/frontend/src/components/PatientIntakeView.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function PatientIntakeView() { + return ( +
+

Emergency Intake Screen

+

Route: "/"

+
+ ); +} diff --git a/frontend/src/components/PatientResultsView.jsx b/frontend/src/components/PatientResultsView.jsx deleted file mode 100644 index f4cf998..0000000 --- a/frontend/src/components/PatientResultsView.jsx +++ /dev/null @@ -1,245 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; - -function PatientResultsView() { - const { id } = useParams(); - const navigate = useNavigate(); - - // Active status of scanning radar - const [scanning, setScanning] = useState(true); - - // Live Hospital Data State - const [hospitals, setHospitals] = useState([ - { hospital_id: 'h1', name: 'SMS Hospital', eta_minutes: 6, distance_km: 2.3, department_match: true, status: 'pending', phone: '+919876543210' }, - { hospital_id: 'h2', name: 'Fortis Hospital', eta_minutes: 9, distance_km: 3.8, department_match: true, status: 'pending', phone: '+918765432109' }, - { hospital_id: 'h3', name: 'Manipal Jaipur', eta_minutes: 12, distance_km: 5.1, department_match: false, status: 'pending', phone: '+917654321098' }, - ]); - - // Live Donors Data State - const [donorsAlerted, setDonorsAlerted] = useState(5); - const [donorsResponded, setDonorsResponded] = useState(0); - - // Live API Polling Loop (GET /emergency/{id}/status) - useEffect(() => { - let pollingInterval; - - const fetchStatus = async () => { - try { - const res = await fetch(`http://localhost:8000/emergency/${id}/status`); - if (res.ok) { - const data = await res.json(); - // Update from server payload - if (data.hospitals) setHospitals(data.hospitals); - if (data.donors_alerted !== undefined) setDonorsAlerted(data.donors_alerted); - if (data.donors_responded !== undefined) setDonorsResponded(data.donors_responded); - - // Check if at least one hospital is confirmed - const anyConfirmed = data.hospitals?.some(h => h.status === 'confirmed'); - if (anyConfirmed) { - setScanning(false); - } - } - } catch (e) { - // Safe backend offline warning - console.warn('Backend server offline. Running local simulation for hackathon demo.'); - } - }; - - // Run first pull - fetchStatus(); - - // Poll every 3 seconds as defined in the PRD - pollingInterval = setInterval(fetchStatus, 3000); - - return () => clearInterval(pollingInterval); - }, [id]); - - // Hackathon Local Demo Simulation: If the backend is offline, - // we simulate status transitions over time so judges see live updates. - useEffect(() => { - // SMS Hospital accepts after 6 seconds - const t1 = setTimeout(() => { - setHospitals(prev => - prev.map(h => (h.hospital_id === 'h1' ? { ...h, status: 'confirmed' } : h)) - ); - setDonorsResponded(1); - setScanning(false); - }, 6000); - - // Fortis Jaipur declines after 12 seconds - const t2 = setTimeout(() => { - setHospitals(prev => - prev.map(h => (h.hospital_id === 'h2' ? { ...h, status: 'declined' } : h)) - ); - setDonorsResponded(3); - }, 12000); - - // Manipal Jaipur accepts after 18 seconds (in case sms didn't or for extra response) - const t3 = setTimeout(() => { - setDonorsResponded(4); - }, 18000); - - return () => { - clearTimeout(t1); - clearTimeout(t2); - clearTimeout(t3); - }; - }, []); - - return ( -
- {/* Header Info */} -
-
-

Trauma Routing

-

Request Ref: {id}

-
- -
- - {/* Live Scanner Banner */} -
- {scanning ? ( - <> -
- - - - - Searching responders -
-

- Pinging dispatchers at regional medical checkpoints. GPS tracker active. -

- {/* Scanning radar line */} -
- - ) : ( - <> -
- - - - Routing locked -
-

- Responding trauma unit has locked the dispatch request. Family & ambulance notified. -

- - )} -
- - {/* Hospital Cards List */} -
- {/* Radar beam scan overlay */} - {scanning && ( -
- )} - - {hospitals.map(h => { - const isConfirmed = h.status === 'confirmed'; - const isDeclined = h.status === 'declined'; - - return ( -
- {/* Background gradient for confirmed card */} - {isConfirmed && ( -
- )} - - {/* Card Title & Status Badge */} -
-

{h.name}

- - - {h.status} - -
- - {/* ETA, Distance & Match Badges */} -
- - - - - {h.eta_minutes} min ETA - - - - {h.distance_km} km away - - - {h.department_match && ( - - - - - Trauma Match - - )} -
- - {/* Tap to Call */} - - - - - {isConfirmed ? 'Establish Call Link' : 'Call Dispatcher'} - -
- ); - })} -
- - {/* Fixed Donor Response Notification Bar */} -
-
-
- - - {donorsAlerted} emergency donors alerted - - {donorsResponded} donors responding to location -
- -
- - - -
-
-
-
- ); -} - -export default PatientResultsView; diff --git a/frontend/src/components/PatientResultsView.tsx b/frontend/src/components/PatientResultsView.tsx new file mode 100644 index 0000000..b3d0cba --- /dev/null +++ b/frontend/src/components/PatientResultsView.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +export default function PatientResultsView() { + const { id } = useParams<{ id: string }>(); + return ( +
+

Live Results Screen

+

Route: "/results/{id}"

+
+ ); +} diff --git a/frontend/src/components/ui/AppBar.tsx b/frontend/src/components/ui/AppBar.tsx new file mode 100644 index 0000000..decec66 --- /dev/null +++ b/frontend/src/components/ui/AppBar.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +export const AppBar: React.FC = () => { + return ( +
+
+ +
+ {/* Heartbeat pulse rings for brand visual */} + + + + + + +
+ + + GoldenHour + + + + {/* Live system state badge */} +
+ + + + + Console Active +
+
+
+ ); +}; diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..29eaf80 --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +export interface BadgeProps { + status: 'pending' | 'confirmed' | 'declined'; + className?: string; +} + +export const Badge: React.FC = ({ status, className = '' }) => { + // Styles aligned with calm urgency theme and DESIGN.md + const baseStyle = "inline-flex items-center px-3 py-1 rounded-full text-xs font-black uppercase tracking-wider select-none border"; + + const statusStyles = { + pending: "bg-amber-500/5 text-amber-600 border-amber-500/20", + confirmed: "bg-success/5 text-success border-success/20", + declined: "bg-slate-200/50 text-slate-500 border-slate-300/40", + }; + + const statusLabels = { + pending: 'Pending', + confirmed: 'Confirmed', + declined: 'Declined', + }; + + return ( + + {status === 'pending' && ( + + )} + {status === 'confirmed' && ( + + )} + {status === 'declined' && ( + + )} + {statusLabels[status]} + + ); +}; diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..5588d26 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'emergency' | 'primary' | 'success' | 'ghost'; + isLoading?: boolean; + fullWidth?: boolean; +} + +export const Button: React.FC = ({ + children, + variant = 'primary', + isLoading = false, + fullWidth = false, + className = '', + disabled, + ...props +}) => { + // Base classes optimized for emergency gloves (minimum 56px target) + const baseStyle = "relative flex items-center justify-center h-14 px-6 rounded-xl font-bold tracking-wide transition-colors focus:outline-none focus:ring-4 focus:ring-rose-500/30 disabled:opacity-50 disabled:cursor-not-allowed select-none"; + + const widthStyle = fullWidth ? "w-full" : ""; + + // Custom design token styling matching DESIGN.md + const variantStyles = { + emergency: "bg-emergency text-white hover:bg-emergency-hover focus:ring-red-500/20 active:bg-emergency-pressed shadow-[0_4px_12px_rgba(220,38,38,0.2)]", + primary: "bg-ink text-white hover:bg-ink-muted focus:ring-slate-500/20 active:bg-ink shadow-[0_4px_12px_rgba(26,23,20,0.15)]", + success: "bg-success text-white hover:opacity-90 focus:ring-emerald-500/20 shadow-[0_4px_12px_rgba(5,150,105,0.2)]", + ghost: "bg-transparent text-ink border-2 border-slate-200 hover:bg-slate-100 hover:border-slate-300 focus:ring-slate-500/20" + }; + + return ( + + {isLoading ? ( + + {/* Spinner icon */} + + + + + Processing... + + ) : ( + children + )} + + ); +}; diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..13b8b29 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +export interface CardProps extends React.HTMLAttributes { + animateEntrance?: boolean; + delayIndex?: number; +} + +export const Card: React.FC = ({ + children, + animateEntrance = true, + delayIndex = 0, + className = '', + ...props +}) => { + const cardStyle = `bg-white rounded-2xl p-5 shadow-layered border border-slate-100/30 overflow-hidden relative ${className}`; + + if (!animateEntrance) { + return ( +
+ {children} +
+ ); + } + + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..498177e --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,38 @@ +import React, { useId } from 'react'; + +export interface InputProps extends React.InputHTMLAttributes { + label: string; + error?: string; +} + +export const Input: React.FC = ({ + label, + error, + className = '', + ...props +}) => { + const id = useId(); + + return ( +
+ + + {error && ( +

+ {error} +

+ )} +
+ ); +}; diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx new file mode 100644 index 0000000..f882da8 --- /dev/null +++ b/frontend/src/components/ui/Select.tsx @@ -0,0 +1,56 @@ +import React, { useId } from 'react'; + +export interface SelectProps extends React.SelectHTMLAttributes { + label: string; + options: { value: string; label: string }[]; + error?: string; +} + +export const Select: React.FC = ({ + label, + options, + error, + className = '', + ...props +}) => { + const id = useId(); + + return ( +
+ + +
+ +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index aced951..ea1e57a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,55 @@ @import "tailwindcss"; +@theme { + --font-sans: 'Inter', system-ui, sans-serif; + + --color-bg-warm: #FBFAF8; + --color-surface: #FFFFFF; + --color-ink: #1A1714; + --color-ink-muted: #6B6560; + + --color-emergency: #DC2626; + --color-emergency-pressed: #B91C1C; + --color-success: #059669; + --color-pending: #9CA3AF; + --color-goldenhour: #F59E0B; + + --shadow-layered: 0 1px 3px rgba(26, 23, 20, 0.02), 0 8px 24px rgba(26, 23, 20, 0.04); +} + +:root { + --color-bg: #FBFAF8; + --color-surface: #FFFFFF; + --color-ink: #1A1714; + --color-ink-muted: #6B6560; + + --color-emergency: #DC2626; + --color-emergency-pressed: #B91C1C; + --color-success: #059669; + --color-pending: #9CA3AF; + --color-goldenhour: #F59E0B; +} + body { - @apply antialiased bg-slate-900 text-slate-100; + background-color: var(--color-bg); + color: var(--color-ink); + font-family: 'Inter', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Custom Scrollbar for console feel */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #E5E2DD; + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #D1CCC4; } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx deleted file mode 100644 index b9a1a6d..0000000 --- a/frontend/src/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.jsx' - -createRoot(document.getElementById('root')).render( - - - , -) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..812a203 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2022"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.js b/frontend/vite.config.ts similarity index 100% rename from frontend/vite.config.js rename to frontend/vite.config.ts From 0e86ff6efd14f7d82cceefa973f9eb5a2cc4cef3 Mon Sep 17 00:00:00 2001 From: RishabhRana37 Date: Fri, 19 Jun 2026 07:13:38 +0530 Subject: [PATCH 09/32] feat(frontend): Implement Screen 1 (Emergency Intake View) with Geolocation locks --- frontend/src/components/PatientIntakeView.tsx | 231 +++++++++++++++++- 1 file changed, 226 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/PatientIntakeView.tsx b/frontend/src/components/PatientIntakeView.tsx index 6d4dd40..09f8773 100644 --- a/frontend/src/components/PatientIntakeView.tsx +++ b/frontend/src/components/PatientIntakeView.tsx @@ -1,10 +1,231 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Card } from './ui/Card'; +import { Button } from './ui/Button'; +import { Select } from './ui/Select'; export default function PatientIntakeView() { + const navigate = useNavigate(); + + // Selections + const [emergencyType, setEmergencyType] = useState(''); + const [bloodGroup, setBloodGroup] = useState(''); + + // Geolocation State + const [locating, setLocating] = useState(false); + const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null); + const [locationError, setLocationError] = useState(null); + + // Trigger Geolocation API + const handleAcquireLocation = () => { + if (!navigator.geolocation) { + setLocationError('Your browser does not support location services.'); + return; + } + + setLocating(true); + setLocationError(null); + + navigator.geolocation.getCurrentPosition( + (position) => { + setCoords({ + lat: parseFloat(position.coords.latitude.toFixed(6)), + lng: parseFloat(position.coords.longitude.toFixed(6)) + }); + setLocating(false); + }, + (error) => { + console.error('Error fetching GPS', error); + let errorMsg = 'Failed to acquire location. Please try again.'; + if (error.code === error.PERMISSION_DENIED) { + errorMsg = 'Location permission denied. Please enable location access in settings.'; + } + setLocationError(errorMsg); + setLocating(false); + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + }; + + // Check if all fields are valid for submission + const isFormValid = coords !== null && emergencyType !== '' && bloodGroup !== ''; + + // TODO: Implement real API call on submission + const dispatchEmergency = async () => { + if (!isFormValid || !coords) return; + + /* + TODO: Wire up to real backend POST /emergency endpoint. + + POST Body Shape: + { + "lat": coords.lat, + "lng": coords.lng, + "emergency_type": emergencyType, + "blood_group": bloodGroup + } + + Expected Response: + { + "request_id": string, + "hospitals": Hospital[], + "donors_alerted": number + } + */ + + // For now, simulate transition and navigate to results page + console.log('Emergency Dispatched:', { + type: emergencyType, + blood: bloodGroup, + coords + }); + + // Navigate directly to mock test results page + navigate('/results/test123'); + }; + + // Form options mapping + const typeOptions = [ + { value: '', label: '-- Choose Emergency Type --' }, + { value: 'trauma', label: 'Trauma / Accident' }, + { value: 'cardiac', label: 'Cardiac / Heart' }, + { value: 'obstetric', label: 'Obstetric / Pregnancy' }, + { value: 'general', label: 'General Medical' } + ]; + + const bloodOptions = [ + { value: '', label: '-- Choose Blood Group --' }, + { value: 'O+', label: 'O+' }, + { value: 'O-', label: 'O-' }, + { value: 'A+', label: 'A+' }, + { value: 'A-', label: 'A-' }, + { value: 'B+', label: 'B+' }, + { value: 'B-', label: 'B-' }, + { value: 'AB+', label: 'AB+' }, + { value: 'AB-', label: 'AB-' } + ]; + return ( -
-

Emergency Intake Screen

-

Route: "/"

-
+ + {/* Visual Identity Highlight (Subtle Amber "Golden Hour" Accent Indicator) */} +
+ + {/* Header Headline */} +
+

+ Emergency? Get help now. +

+

+ We'll find the nearest ready hospital and alert matching blood donors in your area. +

+
+ +
+ {/* Geolocation Lock Button */} +
+ + + + + + {coords && ( + +

+ Coordinates Secured +

+

+ Lat: {coords.lat} · Lng: {coords.lng} +

+
+ )} + + {locationError && ( + +

+ {locationError} +

+ +
+ )} +
+
+ + {/* Emergency Type Selection */} + setBloodGroup(e.target.value)} + options={bloodOptions} + /> + + {/* Dispatch Action Button */} + +
+ ); } From e6cd5152dc3d076dea46387b685359b9a7c116f3 Mon Sep 17 00:00:00 2001 From: RishabhRana37 Date: Fri, 19 Jun 2026 07:17:59 +0530 Subject: [PATCH 10/32] feat(frontend): Implement Screen 2 (Live Results View) with dynamic sorting and status animation --- .../src/components/PatientResultsView.tsx | 209 +++++++++++++++++- 1 file changed, 205 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/PatientResultsView.tsx b/frontend/src/components/PatientResultsView.tsx index b3d0cba..133c0a1 100644 --- a/frontend/src/components/PatientResultsView.tsx +++ b/frontend/src/components/PatientResultsView.tsx @@ -1,12 +1,213 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Card } from './ui/Card'; +import { Badge } from './ui/Badge'; +import { Button } from './ui/Button'; + +interface Hospital { + hospital_id: string; + name: string; + eta_minutes: number; + department_match: boolean; + status: 'pending' | 'confirmed' | 'declined'; + phone: string; +} export default function PatientResultsView() { const { id } = useParams<{ id: string }>(); + + // State initialized with the exact fake data shape matching the contract + const [hospitals, setHospitals] = useState([ + { hospital_id: "h1", name: "SMS Hospital", eta_minutes: 6, department_match: true, status: "pending", phone: "+910000000000" }, + { hospital_id: "h2", name: "Fortis Jaipur", eta_minutes: 9, department_match: true, status: "confirmed", phone: "+910000000000" }, + { hospital_id: "h3", name: "Manipal Jaipur", eta_minutes: 12, department_match: false, status: "pending", phone: "+910000000000" }, + ]); + + // Alert metrics + const [donorsAlerted] = useState(5); + const [donorsResponded, setDonorsResponded] = useState(0); + + // TODO: Replace this simulated hook with real API polling and Supabase Realtime subscription + /* + TODO Link: + For real-time updates: + 1. Poll GET /emergency/{id}/status every 3 seconds: + const res = await fetch(`/api/emergency/${id}/status`); + const data = await res.json(); + setHospitals(data.hospitals); + setDonorsResponded(data.donors_responded); + 2. Alternatively, subscribe to Supabase Realtime changes: + supabase + .channel('hospitals-status') + .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'hospitals' }, payload => { ... }) + .subscribe() + */ + + // Hackathon Demo Simulation: transition SMS Hospital (h1) to 'confirmed' + // and Manipal Jaipur (h3) to 'declined' over time to show smooth state transition animations. + useEffect(() => { + // 3 seconds: SMS Hospital gets confirmed + const timer1 = setTimeout(() => { + setHospitals(prev => + prev.map(h => h.hospital_id === 'h1' ? { ...h, status: 'confirmed' } : h) + ); + setDonorsResponded(1); + }, 4000); + + // 6 seconds: Manipal Jaipur gets declined + const timer2 = setTimeout(() => { + setHospitals(prev => + prev.map(h => h.hospital_id === 'h3' ? { ...h, status: 'declined' } : h) + ); + setDonorsResponded(2); + }, 8000); + + return () => { + clearTimeout(timer1); + clearTimeout(timer2); + }; + }, []); + + // Sort: Confirmed float to top, then sorted by ETA (shortest first) + const sortedHospitals = [...hospitals].sort((a, b) => { + if (a.status === 'confirmed' && b.status !== 'confirmed') return -1; + if (a.status !== 'confirmed' && b.status === 'confirmed') return 1; + if (a.status === 'declined' && b.status !== 'declined') return 1; + if (a.status !== 'declined' && b.status === 'declined') return -1; + return a.eta_minutes - b.eta_minutes; + }); + return ( -
-

Live Results Screen

-

Route: "/results/{id}"

+
+ + {/* Top Status Header */} +
+
+ {/* Pulsing indicator */} + + + + + Finding help… +
+ + ID: {id} + +
+ + {/* Hospital Cards (AnimatePresence for layout transition animations) */} +
+ + {sortedHospitals.map((h, index) => { + const isConfirmed = h.status === 'confirmed'; + const isDeclined = h.status === 'declined'; + + return ( + + + {/* Glowing amber branding highlight on confirmed card */} + {isConfirmed && ( + + + {/* Pinned Bottom Donor Panel */} +
+
+
+ + + + + {donorsAlerted} donors alerted nearby + + + {donorsResponded > 0 ? `${donorsResponded} responded` : 'Waiting for responses'} + +
+ + {/* Blood drop placeholder badge */} +
+ + + +
+
+
+
); } From d536dbecd9f9505e406c56903f19e0298324ae35 Mon Sep 17 00:00:00 2001 From: RishabhRana37 Date: Fri, 19 Jun 2026 08:54:51 +0530 Subject: [PATCH 11/32] feat(frontend): Implement Screen 3 (Donor Registration View) with coverage geolocation lock --- frontend/src/components/DonorRegistration.tsx | 321 +++++++++++++++++- 1 file changed, 316 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/DonorRegistration.tsx b/frontend/src/components/DonorRegistration.tsx index 1911e43..73479f3 100644 --- a/frontend/src/components/DonorRegistration.tsx +++ b/frontend/src/components/DonorRegistration.tsx @@ -1,10 +1,321 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Card } from './ui/Card'; +import { Input } from './ui/Input'; +import { Select } from './ui/Select'; +import { Button } from './ui/Button'; export default function DonorRegistration() { + const navigate = useNavigate(); + + // Form Fields State + const [name, setName] = useState(''); + const [phone, setPhone] = useState(''); + const [bloodGroup, setBloodGroup] = useState(''); + const [lastDonated, setLastDonated] = useState(''); + + // Geolocation State + const [locating, setLocating] = useState(false); + const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null); + const [locationError, setLocationError] = useState(null); + + // Form Submission & Success states + const [errors, setErrors] = useState<{ name?: string; phone?: string; blood?: string }>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isRegistered, setIsRegistered] = useState(false); + const [registeredId, setRegisteredId] = useState(''); + + // GPS Locator handler + const handleGPSFetch = () => { + if (!navigator.geolocation) { + setLocationError('Location services not supported.'); + return; + } + + setLocating(true); + setLocationError(null); + + navigator.geolocation.getCurrentPosition( + (position) => { + setCoords({ + lat: parseFloat(position.coords.latitude.toFixed(6)), + lng: parseFloat(position.coords.longitude.toFixed(6)) + }); + setLocating(false); + }, + (error) => { + console.error('GPS registration fetch error', error); + let errorMsg = 'Failed to locate. Try again.'; + if (error.code === error.PERMISSION_DENIED) { + errorMsg = 'Location permission blocked.'; + } + setLocationError(errorMsg); + setLocating(false); + }, + { timeout: 8000, enableHighAccuracy: true } + ); + }; + + // Basic Validation + const validateForm = () => { + const newErrors: typeof errors = {}; + if (!name.trim()) newErrors.name = 'Full name is required'; + + // Check 10-digit phone format roughly + const cleanPhone = phone.replace(/[^0-9+]/g, ''); + if (!phone) { + newErrors.phone = 'Phone number is required'; + } else if (cleanPhone.length < 8) { + newErrors.phone = 'Please enter a valid phone number'; + } + + if (!bloodGroup) newErrors.blood = 'Blood type selection is required'; + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // Submit Handler + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validateForm()) return; + + setIsSubmitting(true); + + // Mock API Payload matches contract + const payload = { + name, + phone, + blood_group: bloodGroup, + lat: coords?.lat ?? null, + lng: coords?.lng ?? null, + last_donated: lastDonated || null, + }; + + /* + TODO: Wire up to real backend POST /donor/register endpoint. + + POST Body: + { + "name": string, + "phone": string, + "blood_group": string, + "lat": number | null, + "lng": number | null, + "last_donated": string | null + } + + Expected Response: + { + "ok": true, + "donor_id": string + } + */ + + try { + const res = await fetch('http://localhost:8000/donor/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + const data = await res.json(); + setRegisteredId(data.donor_id || 'd42'); + setIsRegistered(true); + } else { + // Fallback for hackathon demo + simulateRegistrationSuccess(); + } + } catch (err) { + console.warn('Backend server offline. Simulating local registration success.'); + simulateRegistrationSuccess(); + } finally { + setIsSubmitting(false); + } + }; + + const simulateRegistrationSuccess = () => { + const mockId = `d-${Math.random().toString(36).substring(2, 6).toUpperCase()}`; + setRegisteredId(mockId); + setIsRegistered(true); + }; + + const bloodOptions = [ + { value: '', label: '-- Select Blood Group --' }, + { value: 'O+', label: 'O+' }, + { value: 'O-', label: 'O-' }, + { value: 'A+', label: 'A+' }, + { value: 'A-', label: 'A-' }, + { value: 'B+', label: 'B+' }, + { value: 'B-', label: 'B-' }, + { value: 'AB+', label: 'AB+' }, + { value: 'AB-', label: 'AB-' } + ]; + + // Disable check: Name, Phone, and Blood Group must be populated + const isButtonDisabled = !name.trim() || !phone.trim() || !bloodGroup; + return ( -
-

Donor Registration Screen

-

Route: "/register"

-
+ + {/* Visual Identity Highlight (Subtle Amber "Golden Hour" Accent Indicator) */} +
+ + + {!isRegistered ? ( + + {/* Header / Hero */} +
+

+ Become a donor. Save a life. +

+

+ Register to receive alerts when emergency blood replacements are needed within 5km. +

+
+ +
+ {/* Name Input */} + setName(e.target.value)} + error={errors.name} + /> + + {/* Phone Input */} + setPhone(e.target.value)} + error={errors.phone} + /> + + {/* Blood Group Selector */} + setLastDonated(e.target.value)} + /> + + {/* Submit Registration Button */} + +
+
+ ) : ( + /* Premium Success State Panel */ + + {/* Animated Ring Checkmark */} +
+ +
+ ✓ +
+
+ +
+

You're registered 🎉

+

+ Thank you, {name}. We've logged your profile under Donor ID {registeredId}. +

+

+ We'll alert you immediately when someone nearby needs your blood type ({bloodGroup}). +

+
+ + +
+ )} +
+ ); } From caa717beb9e858714e1fd9670d88604491fe092d Mon Sep 17 00:00:00 2001 From: RishabhRana37 Date: Fri, 19 Jun 2026 09:00:30 +0530 Subject: [PATCH 12/32] feat(frontend): Implement Screen 4 (Hospital Confirmation View) with conditional header and already_routed simulation --- frontend/src/App.tsx | 35 ++- .../src/components/HospitalConfirmPage.tsx | 244 +++++++++++++++++- 2 files changed, 262 insertions(+), 17 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be7162f..b825b6c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,26 +1,35 @@ import React from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; import { AppBar } from './components/ui/AppBar'; import PatientIntakeView from './components/PatientIntakeView'; import PatientResultsView from './components/PatientResultsView'; import HospitalConfirmPage from './components/HospitalConfirmPage'; import DonorRegistration from './components/DonorRegistration'; +function AppContent() { + const location = useLocation(); + const isHospitalConfirm = location.pathname.startsWith('/confirm/'); + + return ( +
+ {!isHospitalConfirm && } + +
+ + } /> + } /> + } /> + } /> + +
+
+ ); +} + function App() { return ( -
- - -
- - } /> - } /> - } /> - } /> - -
-
+
); } diff --git a/frontend/src/components/HospitalConfirmPage.tsx b/frontend/src/components/HospitalConfirmPage.tsx index e07fb1a..130037a 100644 --- a/frontend/src/components/HospitalConfirmPage.tsx +++ b/frontend/src/components/HospitalConfirmPage.tsx @@ -1,12 +1,248 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Card } from './ui/Card'; +import { Button } from './ui/Button'; export default function HospitalConfirmPage() { + // Read token from URL const { token } = useParams<{ token: string }>(); + + // State Management + const [isResponded, setIsResponded] = useState(false); + const [accepted, setAccepted] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [hospitalName, setHospitalName] = useState('SMS Hospital'); + + // Interactive toggle to allow judges to test the "already routed" UI state + const [alreadyConfirmed, setAlreadyConfirmed] = useState(false); + + // Handle Dispatch Decisions + const handleResponse = async (isAccepted: boolean) => { + setIsSubmitting(true); + + const payload = { accepted: isAccepted }; + + /* + TODO: Wire up to real backend POST /confirm/{token} endpoint. + + POST Body: + { + "accepted": boolean + } + + Expected Response: + { + "ok": true, + "hospital_name": string, + "already_confirmed": boolean + } + */ + + try { + const res = await fetch(`http://localhost:8000/confirm/${token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + const data = await res.json(); + setHospitalName(data.hospital_name || 'SMS Hospital'); + setAlreadyConfirmed(!!data.already_confirmed); + setAccepted(isAccepted); + setIsResponded(true); + } else { + // Fallback for hackathon demo + simulateResponse(isAccepted); + } + } catch (e) { + console.warn('Backend server offline. Simulating response locally for demo.'); + simulateResponse(isAccepted); + } finally { + setIsSubmitting(false); + } + }; + + const simulateResponse = (isAccepted: boolean) => { + setHospitalName('SMS Hospital'); + setAccepted(isAccepted); + setIsResponded(true); + }; + return ( -
-

Hospital Confirmation Screen

-

Route: "/confirm/{token}"

+
+ + {/* Compact Header: Brand + Status Label */} +
+
+ + + + + + GoldenHour + +
+ +
+ + + + Emergency Request +
+
+ + + {!isResponded ? ( + + {/* Clean summary card (Fake/Static values matching API schema) */} + +
+
+ + Emergency Type + + + Trauma + +
+ +
+ + Blood Group Needed + + + O− + +
+ +
+ + Patient ETA + + + 6 min away + +
+
+
+ + {/* Action panel conditional rendering based on alreadyConfirmed state */} + {alreadyConfirmed ? ( + +
+ ! +
+

Already Routed

+

+ This patient has already been routed to another hospital. No dispatch action is required. +

+
+ ) : ( +
+ {/* Accept Button */} + + + {/* Decline Button */} + +
+ )} + + {/* Simulated Toggle for Evaluators */} +
+ +
+
+ ) : ( + /* Response State Panel */ + + {accepted ? ( + +
+
+ ✓ +
+
+

Dispatch Secured

+

+ Thank you. The patient has been routed to {hospitalName}. The family has been notified. +

+
+
+
+ ) : ( + +
+
+ ✕ +
+
+

Declined

+

+ Acknowledgment received. The emergency will be routed to the next nearest responder. +

+
+
+
+ )} + + +
+ )} +
+ + {/* Secure Token Footer */} +
+

+ Secure token validation: {token} +

+
); } From 75d98a564e9ff33a3358d5969192bc1991715f19 Mon Sep 17 00:00:00 2001 From: RishabhRana37 Date: Fri, 19 Jun 2026 09:10:36 +0530 Subject: [PATCH 13/32] feat: add location fallbacks and demo bypass options --- frontend/src/components/DonorRegistration.tsx | 48 ++++++++++++++++- frontend/src/components/PatientIntakeView.tsx | 51 ++++++++++++++++--- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/DonorRegistration.tsx b/frontend/src/components/DonorRegistration.tsx index 73479f3..96f24c3 100644 --- a/frontend/src/components/DonorRegistration.tsx +++ b/frontend/src/components/DonorRegistration.tsx @@ -227,7 +227,7 @@ export default function DonorRegistration() { > {coords ? ( - + Coverage locked @@ -243,16 +243,60 @@ export default function DonorRegistration() { )} + {!coords && !locating && ( +
+ +
+ )} + {coords && (

Anchored: {coords.lat.toFixed(4)}, {coords.lng.toFixed(4)}

+
)} {locationError && ( -

{locationError}

+
+

{locationError}

+
+ + | + +
+
)}
diff --git a/frontend/src/components/PatientIntakeView.tsx b/frontend/src/components/PatientIntakeView.tsx index 09f8773..8a65d18 100644 --- a/frontend/src/components/PatientIntakeView.tsx +++ b/frontend/src/components/PatientIntakeView.tsx @@ -154,6 +154,21 @@ export default function PatientIntakeView() { )} + {!coords && !locating && ( +
+ +
+ )} + {coords && ( Lat: {coords.lat} · Lng: {coords.lng}

+
)} @@ -176,18 +198,31 @@ export default function PatientIntakeView() { initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} - className="bg-red-50 border border-red-100/50 rounded-xl p-3 text-center space-y-1.5" + className="bg-red-50 border border-red-100/50 rounded-xl p-3 text-center space-y-2" >

{locationError}

- +
+ + | + +
)}
From 1ff76216a1306f9538647adfbc4d2a3b9c84c4a9 Mon Sep 17 00:00:00 2001 From: RishabhRana37 Date: Fri, 19 Jun 2026 09:14:56 +0530 Subject: [PATCH 14/32] feat: implement live status polling and mock simulation on results screen --- .../src/components/PatientResultsView.tsx | 146 ++++++++++++------ frontend/src/components/ui/Badge.tsx | 11 +- 2 files changed, 111 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/PatientResultsView.tsx b/frontend/src/components/PatientResultsView.tsx index 133c0a1..9580683 100644 --- a/frontend/src/components/PatientResultsView.tsx +++ b/frontend/src/components/PatientResultsView.tsx @@ -1,9 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Card } from './ui/Card'; import { Badge } from './ui/Badge'; -import { Button } from './ui/Button'; interface Hospital { hospital_id: string; @@ -14,13 +13,15 @@ interface Hospital { phone: string; } +const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'; + export default function PatientResultsView() { const { id } = useParams<{ id: string }>(); - // State initialized with the exact fake data shape matching the contract + // State initialized with all hospitals starting as "pending" const [hospitals, setHospitals] = useState([ { hospital_id: "h1", name: "SMS Hospital", eta_minutes: 6, department_match: true, status: "pending", phone: "+910000000000" }, - { hospital_id: "h2", name: "Fortis Jaipur", eta_minutes: 9, department_match: true, status: "confirmed", phone: "+910000000000" }, + { hospital_id: "h2", name: "Fortis Jaipur", eta_minutes: 9, department_match: true, status: "pending", phone: "+910000000000" }, { hospital_id: "h3", name: "Manipal Jaipur", eta_minutes: 12, department_match: false, status: "pending", phone: "+910000000000" }, ]); @@ -28,48 +29,89 @@ export default function PatientResultsView() { const [donorsAlerted] = useState(5); const [donorsResponded, setDonorsResponded] = useState(0); - // TODO: Replace this simulated hook with real API polling and Supabase Realtime subscription - /* - TODO Link: - For real-time updates: - 1. Poll GET /emergency/{id}/status every 3 seconds: - const res = await fetch(`/api/emergency/${id}/status`); - const data = await res.json(); - setHospitals(data.hospitals); - setDonorsResponded(data.donors_responded); - 2. Alternatively, subscribe to Supabase Realtime changes: - supabase - .channel('hospitals-status') - .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'hospitals' }, payload => { ... }) - .subscribe() - */ - - // Hackathon Demo Simulation: transition SMS Hospital (h1) to 'confirmed' - // and Manipal Jaipur (h3) to 'declined' over time to show smooth state transition animations. + // Configurable Mock state (defaults to true for reliable demo offline mode) + const [isMockMode, setIsMockMode] = useState(() => { + return localStorage.getItem('goldenhour_mock_mode') !== 'false'; + }); + + const mountTime = useRef(Date.now()); + + // Save toggle choice in localStorage useEffect(() => { - // 3 seconds: SMS Hospital gets confirmed - const timer1 = setTimeout(() => { - setHospitals(prev => - prev.map(h => h.hospital_id === 'h1' ? { ...h, status: 'confirmed' } : h) - ); - setDonorsResponded(1); - }, 4000); - - // 6 seconds: Manipal Jaipur gets declined - const timer2 = setTimeout(() => { - setHospitals(prev => - prev.map(h => h.hospital_id === 'h3' ? { ...h, status: 'declined' } : h) - ); - setDonorsResponded(2); - }, 8000); + localStorage.setItem('goldenhour_mock_mode', String(isMockMode)); + }, [isMockMode]); + + // Process data returned from status payload + const updateStateFromPayload = (data: any) => { + setDonorsResponded(data.donors_responded ?? 0); + if (Array.isArray(data.hospitals)) { + setHospitals(prev => { + return data.hospitals.map((newH: any) => { + const existing = prev.find(h => h.hospital_id === newH.hospital_id); + return { + hospital_id: newH.hospital_id, + name: newH.name, + eta_minutes: newH.eta_minutes, + status: newH.status, + department_match: existing ? existing.department_match : (newH.department_match ?? false), + phone: existing ? existing.phone : (newH.phone ?? "+910000000000") + }; + }); + }); + } + }; + + // Poll status endpoint or run offline simulation + const pollStatus = async (requestId: string) => { + if (isMockMode) { + const elapsed = Date.now() - mountTime.current; + // After ~4s, Fortis Jaipur flips from pending to confirmed, Manipal Jaipur flips to declined, and responded goes to 2 + const isAfter4s = elapsed >= 4000; + const isAfter8s = elapsed >= 8000; + + const mockPayload = { + request_id: requestId, + hospitals: [ + { hospital_id: "h1", name: "SMS Hospital", eta_minutes: 6, status: "pending" as const }, + { hospital_id: "h2", name: "Fortis Jaipur", eta_minutes: 9, status: isAfter4s ? "confirmed" as const : "pending" as const }, + { hospital_id: "h3", name: "Manipal Jaipur", eta_minutes: 12, status: isAfter8s ? "declined" as const : "pending" as const } + ], + donors_alerted: 5, + donors_responded: isAfter4s ? 2 : 0 + }; + + updateStateFromPayload(mockPayload); + } else { + try { + const res = await fetch(`${BASE_URL}/emergency/${requestId}/status`); + if (res.ok) { + const data = await res.json(); + updateStateFromPayload(data); + } else { + console.warn('Status check returned non-200:', res.statusText); + } + } catch (err) { + console.error('Network error during status polling:', err); + } + } + }; + + // Interval polling subscription + useEffect(() => { + if (!id) return; + + pollStatus(id); + + const intervalId = setInterval(() => { + pollStatus(id); + }, 3000); return () => { - clearTimeout(timer1); - clearTimeout(timer2); + clearInterval(intervalId); }; - }, []); + }, [id, isMockMode]); - // Sort: Confirmed float to top, then sorted by ETA (shortest first) + // Sort: Confirmed float to top, then sorted by ETA (shortest first), declined at the very bottom const sortedHospitals = [...hospitals].sort((a, b) => { if (a.status === 'confirmed' && b.status !== 'confirmed') return -1; if (a.status !== 'confirmed' && b.status === 'confirmed') return 1; @@ -91,9 +133,25 @@ export default function PatientResultsView() { Finding help…
- - ID: {id} - +
+ + + ID: {id} + +
{/* Hospital Cards (AnimatePresence for layout transition animations) */} diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx index 29eaf80..7c26a39 100644 --- a/frontend/src/components/ui/Badge.tsx +++ b/frontend/src/components/ui/Badge.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { motion } from 'framer-motion'; export interface BadgeProps { status: 'pending' | 'confirmed' | 'declined'; @@ -22,7 +23,13 @@ export const Badge: React.FC = ({ status, className = '' }) => { }; return ( - + {status === 'pending' && ( )} @@ -33,6 +40,6 @@ export const Badge: React.FC = ({ status, className = '' }) => { )} {statusLabels[status]} - + ); }; From 638415e4dbcd7195a1447bc5a3eefb239a39638e Mon Sep 17 00:00:00 2001 From: RishabhRana37 Date: Fri, 19 Jun 2026 09:30:46 +0530 Subject: [PATCH 15/32] style: final visual polish, accessibility audit, mobile responsiveness, and route transition animations --- frontend/src/App.tsx | 40 ++- frontend/src/components/DonorRegistration.tsx | 55 ++-- .../src/components/HospitalConfirmPage.tsx | 4 +- frontend/src/components/PatientIntakeView.tsx | 52 ++-- .../src/components/PatientResultsView.tsx | 235 ++++++++++++------ frontend/src/components/ui/Button.tsx | 10 +- frontend/src/components/ui/Input.tsx | 11 +- frontend/src/components/ui/Select.tsx | 11 +- 8 files changed, 286 insertions(+), 132 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b825b6c..a98b6c4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,26 +1,50 @@ import React from 'react'; import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; import { AppBar } from './components/ui/AppBar'; import PatientIntakeView from './components/PatientIntakeView'; import PatientResultsView from './components/PatientResultsView'; import HospitalConfirmPage from './components/HospitalConfirmPage'; import DonorRegistration from './components/DonorRegistration'; +// Shared premium page entry animation wrapper +const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return ( + + {children} + + ); +}; + function AppContent() { const location = useLocation(); const isHospitalConfirm = location.pathname.startsWith('/confirm/'); return ( -
+
+ {/* Memorable Amber Gradient branding line threaded on very top */} +
+ {!isHospitalConfirm && } -
- - } /> - } /> - } /> - } /> - +
+ + + } /> + } /> + } /> + } /> + +
); diff --git a/frontend/src/components/DonorRegistration.tsx b/frontend/src/components/DonorRegistration.tsx index 96f24c3..4b3a7e3 100644 --- a/frontend/src/components/DonorRegistration.tsx +++ b/frontend/src/components/DonorRegistration.tsx @@ -158,9 +158,18 @@ export default function DonorRegistration() { const isButtonDisabled = !name.trim() || !phone.trim() || !bloodGroup; return ( - + {/* Visual Identity Highlight (Subtle Amber "Golden Hour" Accent Indicator) */} -
+
{!isRegistered ? ( @@ -169,11 +178,11 @@ export default function DonorRegistration() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="space-y-6" + className="space-y-5" > {/* Header / Hero */} -
-

+
+

Become a donor. Save a life.

@@ -181,7 +190,7 @@ export default function DonorRegistration() {

-
+ {/* Name Input */} setName(e.target.value)} error={errors.name} + required + aria-required="true" /> {/* Phone Input */} @@ -200,6 +211,8 @@ export default function DonorRegistration() { value={phone} onChange={(e) => setPhone(e.target.value)} error={errors.phone} + required + aria-required="true" /> {/* Blood Group Selector */} @@ -209,6 +222,8 @@ export default function DonorRegistration() { onChange={(e) => setBloodGroup(e.target.value)} options={bloodOptions} error={errors.blood} + required + aria-required="true" /> {/* GPS Coordinates Locker */} @@ -223,18 +238,19 @@ export default function DonorRegistration() { isLoading={locating} variant={coords ? 'success' : 'ghost'} fullWidth - className="transition-all duration-300" + aria-label={coords ? "Coverage location secured" : "Lock rescue coverage radius using GPS location"} + className="transition-all duration-300 h-14 rounded-xl" > {coords ? ( - + Coverage locked ) : ( - + @@ -251,7 +267,8 @@ export default function DonorRegistration() { setCoords({ lat: 26.9124, lng: 75.7873 }); setLocationError(null); }} - className="text-[11px] text-ink-muted hover:text-emerald-600 transition-colors font-medium underline cursor-pointer" + className="text-[11px] text-ink-muted hover:text-emerald-600 transition-colors font-medium underline cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/30 rounded px-1.5 py-0.5" + aria-label="Use mock Jaipur location coordinates for coverage" > Or use demo location (Jaipur) @@ -260,13 +277,14 @@ export default function DonorRegistration() { {coords && (
-

+

Anchored: {coords.lat.toFixed(4)}, {coords.lng.toFixed(4)}

@@ -274,24 +292,26 @@ export default function DonorRegistration() { )} {locationError && ( -
+

{locationError}

- | + | @@ -315,7 +335,8 @@ export default function DonorRegistration() { disabled={isButtonDisabled || isSubmitting} isLoading={isSubmitting} fullWidth - className="mt-6 uppercase text-sm font-extrabold tracking-wider" + aria-label="Register as blood donor" + className="mt-6 uppercase text-sm font-extrabold tracking-wider h-14" > SUBMIT REGISTRATION diff --git a/frontend/src/components/HospitalConfirmPage.tsx b/frontend/src/components/HospitalConfirmPage.tsx index 130037a..a8d8010 100644 --- a/frontend/src/components/HospitalConfirmPage.tsx +++ b/frontend/src/components/HospitalConfirmPage.tsx @@ -182,7 +182,7 @@ export default function HospitalConfirmPage() { @@ -229,7 +229,7 @@ export default function HospitalConfirmPage() { diff --git a/frontend/src/components/PatientIntakeView.tsx b/frontend/src/components/PatientIntakeView.tsx index 8a65d18..db9167b 100644 --- a/frontend/src/components/PatientIntakeView.tsx +++ b/frontend/src/components/PatientIntakeView.tsx @@ -107,13 +107,22 @@ export default function PatientIntakeView() { ]; return ( - + {/* Visual Identity Highlight (Subtle Amber "Golden Hour" Accent Indicator) */} -
+
{/* Header Headline */} -
-

+
+

Emergency? Get help now.

@@ -121,7 +130,7 @@ export default function PatientIntakeView() {

-
+
{/* Geolocation Lock Button */}
{/* Hospital Cards (AnimatePresence for layout transition animations) */} -
- - {sortedHospitals.map((h, index) => { - const isConfirmed = h.status === 'confirmed'; - const isDeclined = h.status === 'declined'; - - return ( - + + {isLoading ? ( + + {[1, 2, 3].map((idx) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} + + ) : hasError ? ( + +
+ ! +
+

Connection Issue

+

+ We are having trouble contacting the dispatch server. Please check your connection. +

+