# Project export: Nirog

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: Nirog : full-fledged tele-health ecosystem connecting doctors, pharmacies, and patients in a single loop. Fronted by ARIA, a 3D AI nurse taking charge to get you better than anyone has in years.
- Devpost: https://devpost.com/software/nirog-7uzo6d
- GitHub: https://github.com/crusheR-058/nirog-care
- Demo: https://drconnect-nirog.vercel.app/
- Video: https://www.youtube.com/embed/VnXessgRQxw?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — Claude Opus 4.8 (1M context) (21 commits), crusheR-058 (17 commits)

## Devpost submission (written by the team)

### Inspiration

: The Intake Bottleneck Imagine a patient in rural India who simply feels unwell. Right now, that patient's reality is a bus ride, lost wages, and a full day of travel just for a ten-minute consultation. Care should begin with a conversation, not a bus ride. We looked at rural India and realized that the healthcare system doesn't just break at the diagnosis but it breaks at the intake. For hundreds of millions of people, a ten-minute consult costs a full day of travel and lost wages, so care is deferred until it becomes an emergency. Existing text-first apps exclude low-literacy users, and when patients finally see a doctor, their story gets lost in translation. We were inspired to build a system that patiently listens to a frightened person's rambling account and translates it into a structured, clinician-ready format.

### What it does

: The Continuous Care Loop Nirog is a comprehensive rural tele-health ecosystem that connects patients, doctors, and pharmacies in one continuous, seamless loop. The Intake: It starts with ARIA, a live, 3D AI nurse who talks to patients in their native language using voice recognition. The Handover: ARIA distills the conversation into a 30-second SBAR (Situation, Background, Assessment, Recommendation) document for the doctor. The Consult: Using an atomic "pool-and-claim" queue, the system connects the patient to an on-call doctor for a video, audio, or chat consultation. The Fulfilment: Prescriptions are geographically routed to a verified partner pharmacy for local delivery, and the entire encounter is auto-filed to the family’s unified ABHA health vault.

### How we built it

: A Serverless, Secure Chassis We managed this product end-to-end with a focus on a thin client backed by a heavy, secure data infrastructure. Frontend: The web platform is built on Next.js 16 and React 19, utilizing the App Router. The mobile reference is built natively on Expo SDK 54, utilizing 14 real Expo modules for audio, blur, and location. AI Backend: We used a stateless AWS Lambda architecture utilizing Amazon Bedrock (gpt-oss-120b) for reasoning and Voxtral for speech-to-text. The Retriever: To diagnose accurately, we built a custom Hybrid RAG search over 13,144 conditions using a 512-d Titan V2 vector index. Database & Security: The entire core runs on Supabase (Auth, Postgres, Storage, Realtime). We implemented rigorous PostgreSQL Row-Level Security (RLS) to ensure patient data is consent-gated and strictly isolated.

### Challenges we ran into

: The Zebra Problem & Edge Resilience The "Zebra Problem" in Vector Search: When we first used pure cosine similarity for our AI, it failed wildly. Because rare diseases share similar descriptive words with common ones, the AI ranked rare OMIM syndromes above a common migraine. We had to engineer a custom scoring algorithm blending BM25 (weighted 0.6) and cosine (weighted 0.4), multiplied by a prevalence prior to ensure base clinical rates were respected. Voice Capture on Android: Getting a live transcript while the user was still speaking was brutal. Android's MediaRecorder creates incomplete containers that can't be decoded mid-sentence. We solved this by moving the microphone into a WebView using getUserMedia to stream continuous PCM audio directly to our backend. WebRTC on Flaky Networks: Building peer-to-peer video for rural networks resulted in "connected but no media" errors. We had to build a custom /api/ice route to inject STUN/TURN configurations and buffer ICE candidates over Supabase Realtime to ensure the media connected properly.

### Accomplishments we're proud of

: True Product Execution We are incredibly proud that this is a shipped, functional architecture, not just a chatbot wrapper. Real-time 3D on Budget Devices: We successfully optimized ARIA's real-time lip-sync to run dynamically inside a WebView on a standard ₹10k Android phone. Zero-Signal Resilience: We bundled a highly compressed (0.57 MB) offline directory of 10,700+ verified Indian facilities. Using a local haversine scan, the app can instantly route patients to the nearest hospital even with zero network bars. Strict Privacy Boundaries: We successfully engineered the pharmacy console so that dispensers only receive the medication lines and a delivery snapshot, they are entirely blocked from accessing the patient's clinical chart or AI intake notes.

### What we learned

: Data Intelligence & Clinical Honesty We learned that raw data analytics and massive LLMs are not enough for healthcare; context is everything. We realized that a model that only reports what it found is dangerous; an AI that explicitly reports what it failed to establish is actually useful to a doctor. We learned to hardcode our emergency escalation (using 40 regex rules) before the LLM even sees the text, because AI should not be making life-or-death triage decisions. Finally, we learned that user experience dictates trust hiding 2 to 8 seconds of server latency behind ARIA murmuring "mm-hm" made the difference between a robotic delay and a humane conversation.

### What's next

: The Path to Production Nirog is currently a highly robust architectural demo, and our next steps are entirely focused on clinical governance and production readiness. Clinical Validation: We will integrate the AI pipeline with strict human-in-the-loop safeguards and undergo formal medical review. Infrastructure Hardening: We plan to deploy managed, self-hosted TURN servers for bulletproof rural video calling and implement Supabase Realtime channel authorization to lock down call rooms. Pharmacy Operations: We will transition the pharmacy onboarding from "auto-verify" to a dedicated admin console for human verification of drug licenses and pharmacist certificates.

## README (from the GitHub repository)

# Nirog — Doctor Web Portal

The **doctor-facing web application** for the Nirog Care Platform — a continuous
telehealth platform for rural India. It pairs with the existing patient mobile
app (Expo) and gives clinicians a responsive workspace to review AI intake, run
consultations, and file care plans.

Built from the platform pitch (`output/pdf/Nirog_Care_Platform_Team_Pitch.pdf`):
identity, consent-driven records, ARIA (AI intake) handover, and a safe path to
real teleconsultation.

## How Codex and GPT-5.6 were used

Codex powered by GPT-5.6 was used as the primary engineering collaborator throughout Nirog’s development—not merely for isolated code completion.

Codex helped us:

- Translate the rural-care product concept into an end-to-end care loop: ARIA intake → doctor review → consultation → care plan → fulfilment → follow-up.
- Build and refine the Expo/React Native patient application and the Next.js doctor portal.
- Design the shared clinical data model, Supabase authentication, consent-scoped access, Row-Level Security policies and immutable audit trail.
- Implement ARIA’s conversational intake, AWS-backed AI services, speech features, patient records, nearby-care discovery and teleconsultation workflows.
- Diagnose Android build, authentication, navigation, WebRTC, asset, animation and deployment issues using live terminal, browser and desktop-app inspection.
- Review and correct privacy and authorization problems discovered during end-to-end testing.
- Run TypeScript checks, inspect runtime behaviour, test key patient/doctor workflows and prepare the APK, documentation and submission materials.
- Iterate on interaction design, accessibility, error states and the “Quiet Glass” clinical interface.

Codex was used agentically: it inspected the real codebase, edited implementation files, ran commands, examined failures, verified fixes and continued iterating until the workflows worked together. GPT-5.6 supplied the reasoning needed to connect product requirements, clinical safety, mobile engineering, backend security and deployment into one coherent system.

All final product decisions were reviewed by the team. Nirog is currently a demonstration using simulated data; real clinical deployment would require specialist medical, legal, DPDP and ABDM review.

## The care loop

```
ARIA intake → Doctor review → Consult → Care plan → Fulfilment → Follow-up
   (voice)      (red flags)   (a/v/chat)  (Rx+notes)  (meds/tests)  (reminders)
```

The north-star metric is **resolved care episodes** — not logins or AI messages.

## Tech stack

| Layer      | Choice |
|------------|--------|
| Framework  | **Next.js 16** (App Router, RSC, Server Actions, Turbopack) |
| Language   | **TypeScript** (strict) |
| Styling    | **Tailwind CSS v4** + a bespoke "Quiet Glass" design system (`src/app/globals.css`) |
| UI         | Hand-built shadcn-style primitives (`src/components/ui`) + `lucide-react` |
| Motion     | **Motion** (Framer Motion) — scroll-scrubbed landing |
| Auth       | **Supabase Auth** (`@supabase/ssr`, cookie sessions) — doctor sign-in, JWT identity |
| Validation | **Zod** |
| Data       | Swappable source: authenticated **Supabase** (RLS-enforced, default) ⇄ in-memory **mock** ⇄ Prisma |
| Security   | **Row-Level Security** on every table + **Storage** bucket, gated by a consent function |

### One shared API contract, three backends

The portal never touches a database directly — it calls `NirogDataSource`
(`src/lib/data/source.ts`). Three implementations satisfy it:

- `supabase-source.ts` — **authenticated** Supabase client; every query runs as
  the signed-in doctor so **RLS enforces access in the database** (**default**).
- `mock-source.ts` — realistic in-memory data, zero setup.
- `prisma-source.ts` — Postgres via Prisma (service role).

Toggle with `NIROG_DATA_SOURCE=supabase | mock | db`.

### Security model (Supabase-native)

- **Auth**: doctors sign in via Supabase Auth; `Doctor.authUserId` links the
  profile to `auth.users`. Middleware refreshes the session and guards `/portal`.
- **RLS**: enabled on every table. A `current_doctor_id()` function maps
  `auth.uid()` → the doctor, and `patient_accessible()` gates patient data by
  **active consent or queue membership**. The anon/public API returns nothing.
  Policies live in `supabase/policies.sql`.
- **Storage**: private `patient-documents` bucket, files at
  `<patientId>/<file>`, with `storage.objects` policies using the same consent
  function. Uploads/downloads use short-lived signed URLs.
- Prisma manages the **schema** (`db push`) and **seeding** (service role, which
  bypasses RLS). The seed also provisions the demo doctor's Supabase Auth user.

## Quick start (mock data — no database)

```bash
pnpm install
# an .env is already provided for the demo; otherwise: cp .env.example .env
pnpm dev                  # http://localhost:3000
```

Open `http://localhost:3000` for the scroll-world landing, then **Enter workspace**.

**Demo doctor:** `ananya.rao@nirog.health` · `nirog-demo`

## Backend: Supabase (live)

This project is connected to a **Supabase** Postgres project (`nirog-care`,
region `ap-south-1` / Mumbai). `.env` holds the pooled `DATABASE_URL` (app,
port 6543) and the session `DIRECT_URL` (migrations, port 5432), and
`NIROG_DATA_SOURCE=db`. Nothing in the UI changed — the same `NirogDataSource`
contract now resolves to `prisma-source.ts` against Supabase.

Useful commands:

```bash
pnpm prisma db push   # sync schema.prisma → Supabase
pnpm db:seed          # (re)load Dr. Rao, patients, ARIA handovers, queue
pnpm db:studio        # browse the live data in Prisma Studio
```

To run fully offline instead, set `NIROG_DATA_SOURCE=mock` (no DB needed), or
point `DATABASE_URL`/`DIRECT_URL` at local Postgres via `pnpm db:up` (docker).
The seed hashes the demo password with bcrypt, so login works identically in
every mode.

## What's inside

```
src/
  app/
    page.tsx                     Scroll-world landing (hero → care world → workspace → trust)
    (auth)/login/                Doctor sign-in (split brand panel + credentials form)
    portal/
      layout.tsx                 Dark icon rail + topbar + mobile tab bar
      page.tsx                   Today — stats, live triage queue, ARIA spotlight, trust log
      patients/                  Consent-scoped patient list + full chart
      consult/[queueId]/         Consultation: mock a/v stage + care-plan capture
      audit/                     Immutable trust log (who / what / why)
      settings/                  Verified identity, security, languages, data source
      actions.ts                 Server actions: accept handover, file encounter (Zod-validated)
  components/
    landing/                     Scroll-scrubbed journey + dioramas built from real UI
    portal/                      Queue, ARIA handover, clinical vocabulary, consult
    ui/                          Quiet Glass primitives (button, card, badge, avatar, input)
    brand/                       Logo / diagnostic mark
  lib/
    domain/                      Shared types + clinical labels (the contract)
    data/                        source.ts + mock-source.ts + prisma-source.ts + seed.ts
  auth.ts, auth.config.ts        NextAuth v5 (split for edge-safe middleware)
prisma/schema.prisma             Identity, care relationships, consent, audit, clinical
```

## Design system — "Quiet Glass"

Apple/iOS-derived clinical palette from the pitch: calm canvas, white panels,
hairline borders, blue primary, **purple for ARIA**, and semantic triage colours
(red / amber / green). Full light + dark themes (dark supports night-shift
clinicians). Clinical figures use tabular numerals.

## The scroll-world landing

The landing is a scroll-scrubbed "fly through the care world" — a continuous
camera dive through five dioramas (Intake → Triage → Consult → Care plan →
Continuity), each built from the real product UI so the world is made of the
same material as the workspace

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 137 recognized source files, 850 KB.
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Go (language) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (120 of 146)

```
.gitignore
.vercelignore
components.json
docker-compose.turn.yml
docker-compose.yml
docs/fulfilment.md
docs/turn-setup.md
docs/webrtc-supabase-handover.md
eslint.config.mjs
next.config.ts
package.json
patient-app-reference/lib/auth.ts
patient-app-reference/lib/enqueue.ts
patient-app-reference/lib/push.ts
patient-app-reference/lib/supabase.ts
patient-app-reference/lib/useIncomingPickup.ts
patient-app-reference/lib/useNativeCall.ts
patient-app-reference/README.md
patient-app-reference/screens/CallScreen.tsx
pnpm-workspace.yaml
postcss.config.mjs
prisma/schema.prisma
prisma/seed-drugs.ts
prisma/seed.ts
README.md
scripts/generate_nirog_pitch_pdf.py
scripts/qa_nirog_pitch_pdf.py
scripts/setup-turn.mjs
src/app/(auth)/actions.ts
src/app/(auth)/login/login-form.tsx
src/app/(auth)/login/page.tsx
src/app/(auth)/signup/page.tsx
src/app/(auth)/signup/signup-form.tsx
src/app/(auth)/verify/page.tsx
src/app/(auth)/verify/verify-form.tsx
src/app/api/drugs/route.ts
src/app/api/ice/route.ts
src/app/auth/callback/route.ts
src/app/call/[room]/call-client.tsx
src/app/call/[room]/page.tsx
src/app/globals.css
src/app/layout.tsx
src/app/onboarding/actions.ts
src/app/onboarding/page.tsx
src/app/page.tsx
src/app/pharmacy/(console)/actions.ts
src/app/pharmacy/(console)/catalog/page.tsx
src/app/pharmacy/(console)/dashboard/page.tsx
src/app/pharmacy/(console)/layout.tsx
src/app/pharmacy/(console)/orders/[id]/page.tsx
src/app/pharmacy/(console)/orders/page.tsx
src/app/pharmacy/actions.ts
src/app/pharmacy/login/login-form.tsx
src/app/pharmacy/login/page.tsx
src/app/pharmacy/onboarding/page.tsx
src/app/pharmacy/page.tsx
src/app/pharmacy/signup-form.tsx
src/app/pharmacy/status/page.tsx
src/app/portal/actions.ts
src/app/portal/audit/page.tsx
src/app/portal/consult/[queueId]/page.tsx
src/app/portal/layout.tsx
src/app/portal/page.tsx
src/app/portal/patients/[id]/page.tsx
src/app/portal/patients/page.tsx
src/app/portal/patients/patients-list.tsx
src/app/portal/settings/page.tsx
src/components/auth/google-button.tsx
src/components/brand/logo.tsx
src/components/landing/bento.tsx
src/components/landing/care-loop.tsx
src/components/landing/closing.tsx
src/components/landing/dioramas.tsx
src/components/landing/features.tsx
src/components/landing/hero-visual.tsx
src/components/landing/hero.tsx
src/components/landing/intro-gate.tsx
src/components/landing/marketing-nav.tsx
src/components/landing/product-window.tsx
src/components/landing/scroll-journey.tsx
src/components/landing/shared.tsx
src/components/landing/word-reveal.tsx
src/components/onboarding/upload-field.tsx
src/components/onboarding/wizard.tsx
src/components/pharmacy/catalog-search.tsx
src/components/pharmacy/console-nav.tsx
src/components/pharmacy/console-ui.tsx
src/components/pharmacy/fulfilment-panel.tsx
src/components/pharmacy/order-board.tsx
src/components/pharmacy/wizard.tsx
src/components/portal/aria-spotlight.tsx
src/components/portal/call-diagnostics.tsx
src/components/portal/clinical.tsx
src/components/portal/consult-form.tsx
src/components/portal/consult-stage.tsx
src/components/portal/documents.tsx
src/components/portal/drug-picker.tsx
src/components/portal/encounter-history.tsx
src/components/portal/handover-card.tsx
src/components/portal/incoming-dock.tsx
src/components/portal/mfa-setup.tsx
src/components/portal/mobile-nav.tsx
src/components/portal/nav.ts
src/components/portal/patient-context.tsx
src/components/portal/queue-list.tsx
src/components/portal/queue-realtime.tsx
src/components/portal/sidebar.tsx
src/components/portal/stat-tile.tsx
src/components/portal/topbar.tsx
src/components/portal/trust-log.tsx
src/components/ui/avatar.tsx
src/components/ui/badge.tsx
src/components/ui/button.tsx
src/components/ui/card.tsx
src/components/ui/input.tsx
src/lib/auth/destination.ts
src/lib/data/constants.ts
src/lib/data/drugs.ts
src/lib/data/mock-source.ts
src/lib/data/pharmacy-source.ts
[26 more files omitted for size]
```

### Dependencies

- package.json: @prisma/client@^6.19.3, @radix-ui/react-slot@^1.3.0, @supabase/ssr@^0.12.3, @supabase/supabase-js@^2.110.6, @tailwindcss/postcss@^4, @types/bcryptjs@^3.0.0, @types/node@^20, @types/react@^19, @types/react-dom@^19, bcryptjs@^3.0.3, class-variance-authority@^0.7.1, clsx@^2.1.1, date-fns@^4.4.0, eslint@^9, eslint-config-next@16.2.10, lucide-react@^1.24.0, motion@^12.42.2, next@16.2.10, playwright@^1.61.1, prisma@^6.19.3, react@19.2.4, react-dom@19.2.4, tailwind-merge@^3.6.0, tailwindcss@^4, tsx@^4.23.1, typescript@^5, zod@^4.4.3

### Recent commits (newest first)

- Add section on Codex and GPT-5.6 contributions
- Proper call UX: fullscreen stage, initial-letter avatars, presence over signalling
- Docs: TURN is configured — relay-only call proven on production
- Fix setup-turn probe: gathering was awaited before it could start
- TURN setup tooling that refuses to save a broken relay
- Separate pharmacy sign-in; re-skin the console to the portal's design system
- Auto-approve partner pharmacies in demo mode
- Close the care loop: prescriptions → partner pharmacies, plus TURN
- Docs: WebRTC/Supabase signalling handover for the phone↔web call
- Fix missing space in pharmacy pitch copy ('Fulfilstep' -> 'Fulfil step')
- Pharmacy partner onboarding + landing CTA
- Care loop: scroll-animation tree replaces the pinned horizontal slider
- Animated entry gate: logo stage + scroll-triggered iris break-through
- Fix: incoming dock now updates live for the already-on-call doctor
- On-demand consult matching: patient app ↔ doctor portal on one Supabase backend
- Wire real WebRTC video calls (was demo): live A/V, true mute, patient link
- Fix mobile right-side gap: clip horizontal overflow from slide-in offsets
- Fix mobile journey jitter and overlapping scene content
- Mobile landing overhaul: uncluttered, fast, fitted (desktop untouched)
- Remove 'Watch the journey' button from hero

## Key source files (fetched from GitHub, selected and truncated for size)

### docs/fulfilment.md

```markdown
# The Fulfil step — prescriptions → partner pharmacies

Closes the last open link in the Nirog care loop. A filed consultation note now
produces a real prescription that reaches a licence-verified pharmacy, which
dispenses and delivers it.

---

## The shape

```
Doctor files the note
  └─ Encounter                                (clinical record — stays private)
       └─ Prescription  + PrescriptionItem[]  (what to dispense, ATC-coded)
            └─ PharmacyOrder                  (pharmacyId = NULL → district pool)
                 ├─ any VERIFIED pharmacy serving that district claims it
                 └─ OrderEvent[]              (append-only fulfilment trail)
```

This is deliberately the **same pool-and-claim pattern as the on-demand doctor
queue**: work is offered to a pool, the first eligible party claims it
atomically, and losing the race is a normal outcome rather than an error.

## Statuses

`routed → accepted → preparing → ready → dispatched → delivered`

Declining returns the order to the pool (`pharmacyId` cleared, back to `routed`)
so another pharmacy in the district can fulfil it — a decline must never be a
dead end for the patient.

Transitions are validated server-side in
[`pharmacy-source.ts`](../src/lib/data/pharmacy-source.ts) against
`ORDER_TRANSITIONS`, so a crafted request cannot jump an order straight to
`delivered`. Marking delivery flips the prescription to `fulfilled` via the
`pharmacy_order_status_sync` trigger — not from the app, because RLS
(correctly) only lets the prescribing doctor update a prescription.

---

## The privacy boundary

**A pharmacy never gains access to `Patient`, `Encounter`, `AriaHandover`,
`QueueEntry` or `Doctor`.** It sees the medication lines it must dispense, plus
a delivery snapshot (name, masked phone, village) denormalised onto the order.

That snapshot is a design decision, not laziness: it means fulfilment needs no
join into the clinical record at all. Widening what a pharmacy can see should
require a new consent scope, not a new join.

Visibility opens in two stages, mirroring the doctor side (triage before accept,
full chart after):

| Stage | The pharmacy can see |
|---|---|
| In the district pool | Medication lines + delivery area — enough to judge stock |
| After claiming | The above, plus contact details for delivery |
| Ever | Nothing clinical — no diagnosis, notes, history or intake |

Enforced in [`supabase/fulfilment.sql`](../supabase/fulfilment.sql) and proven by
the isolation assertions in the E2E run (`tmp/rxflow.mjs`), which sign in as a
real pharmacy and query PostgREST directly — testing the database boundary, not
what the UI chooses to render.

Routing is by **geography, never by patient identity**: the pool is keyed on
district (falling back to state so a district with no partner pharmacy doesn't
strand orders).

---

## The drug catalogue

**5,154 substances** — the complete WHO ATC/DDD index, the global standard for
classifying drug substances, across all 14 anato
[truncated — 2504 more characters]
```

### docs/turn-setup.md

```markdown
# TURN setup — making video calls actually connect

**Status: CONFIGURED and proven (2026-07-22).** `/api/ice` serves the
Metered.ca relay (`provider: "static"`, four transports: udp:80, tcp:80,
udp:443, tls:443). Verified end to end with a relay-only call on production —
both peers `iceTransportPolicy: "relay"`, frames flowed, and the nominated
candidate pair was `type=relay`. Credentials live in `.env` and the Vercel
project env, never in the repo or the client bundle.

---

## Why this is not optional

STUN only tells each peer what its public address looks like. That is enough on
most home Wi-Fi, and it is why the call works when you test it from a laptop.

It is *not* enough behind **carrier-grade NAT (CGNAT)**, which is how Jio, Airtel
and Vi hand out mobile IPv4. Two phones on mobile data usually cannot find a
direct path at all. A TURN server fixes this by relaying the media — it is the
difference between a consultation connecting and silently failing.

Rural patients on mobile data are precisely the users this platform exists for,
so treat TURN as production infrastructure, not a nice-to-have.

---

## How credentials work here

TURN bandwidth is metered and billable, so a username/password shipped in the
client bundle is a standing invitation to use your relay for free. Nirog never
does that.

Instead `/api/ice` mints a **short-lived credential per caller** using coturn's
`use-auth-secret` scheme:

```
username   = <unix-expiry>:<user-id>
credential = base64( HMAC-SHA1( TURN_SECRET, username ) )
```

coturn recomputes the same HMAC to validate, so no per-user state is stored
anywhere, the shared secret never leaves the server, and a leaked credential
expires on its own (4 hours — [`src/lib/webrtc/ice.ts`](../src/lib/webrtc/ice.ts)).

Both clients fetch from the same endpoint, so the web portal and the Expo app
can never disagree about which relay to use.

---

## Option A — Metered.ca free tier (chosen)

No server to run. Free tier is ~50 GB/month with no card, which covers roughly
250 fully-relayed consultations.

1. Sign up at **metered.ca** → **TURN Server** in the dashboard.
2. Copy the **username**, **credential**, and your subdomain's TURN URLs.
3. Run the setup script — it proves the relay works *before* saving anything:

```bash
node scripts/setup-turn.mjs \
  --urls "turn:<subdomain>.relay.metered.ca:80,turn:<subdomain>.relay.metered.ca:443" \
  --username "<username>" \
  --credential "<credential>" \
  --write-env --push-vercel
```

`/api/ice` will then report `provider: "static"`.

> ⚠️ **Do not use the old "Open Relay" free public servers**
> (`openrelay.metered.ca` / `staticauth.openrelay.metered.ca`). That project has
> been retired — probing every endpoint returns `host` candidates only, never a
> `relay`. Anything still recommending it is out of date.

> The env keys have **no `NEXT_PUBLIC_` prefix** deliberately. These must stay
> server-side; anything `NEXT_PUBLIC_` is compiled into the browser bundle and
> is effecti
[truncated — 3042 more characters]
```

### docker-compose.yml

```yaml
services:
  db:
    image: postgres:17-alpine
    container_name: nirog-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: nirog
      POSTGRES_PASSWORD: nirog_dev
      POSTGRES_DB: nirog
    ports:
      - "5433:5432"
    volumes:
      - nirog-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U nirog -d nirog"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  nirog-pgdata:

```

### package.json

```
{
  "name": "nirog-doctor-web",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "prisma generate && next build",
    "start": "next start",
    "postinstall": "prisma generate",
    "lint": "eslint",
    "db:up": "docker compose up -d",
    "db:generate": "prisma generate",
    "db:push": "prisma db push",
    "db:seed": "tsx --env-file=.env prisma/seed.ts",
    "db:seed:drugs": "tsx --env-file=.env prisma/seed-drugs.ts",
    "db:studio": "prisma studio"
  },
  "prisma": {
    "seed": "tsx prisma/seed.ts"
  },
  "dependencies": {
    "@prisma/client": "^6.19.3",
    "@radix-ui/react-slot": "^1.3.0",
    "@supabase/ssr": "^0.12.3",
    "@supabase/supabase-js": "^2.110.6",
    "bcryptjs": "^3.0.3",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "date-fns": "^4.4.0",
    "lucide-react": "^1.24.0",
    "motion": "^12.42.2",
    "next": "16.2.10",
    "react": "19.2.4",
    "react-dom": "19.2.4",
    "tailwind-merge": "^3.6.0",
    "zod": "^4.4.3"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/bcryptjs": "^3.0.0",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.2.10",
    "playwright": "^1.61.1",
    "prisma": "^6.19.3",
    "tailwindcss": "^4",
    "tsx": "^4.23.1",
    "typescript": "^5"
  }
}

```

### src/app/layout.tsx

```typescript
import type { Metadata, Viewport } from "next";
import { Inter, Manrope, JetBrains_Mono } from "next/font/google";
import "./globals.css";

const inter = Inter({
  variable: "--font-inter",
  subsets: ["latin"],
  display: "swap",
});

// Display face matched to the patient app's friendly extra-bold headings.
const manrope = Manrope({
  variable: "--font-display",
  subsets: ["latin"],
  display: "swap",
});

const mono = JetBrains_Mono({
  variable: "--font-mono",
  subsets: ["latin"],
  display: "swap",
});

export const metadata: Metadata = {
  title: {
    default: "Nirog — Continuous care platform",
    template: "%s · Nirog",
  },
  description:
    "Nirog connects rural patients to trusted clinicians through voice-first AI intake, a responsive doctor workspace, and consent-driven health records.",
  applicationName: "Nirog",
};

export const viewport: Viewport = {
  themeColor: "#d9e5f6",
  width: "device-width",
  initialScale: 1,
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  // Light mode only — no theme switching.
  return (
    <html lang="en" data-scroll-behavior="smooth">
      <body
        className={`${inter.variable} ${manrope.variable} ${mono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

```

### src/app/page.tsx

```typescript
import { MarketingNav } from "@/components/landing/marketing-nav";
import { IntroGate } from "@/components/landing/intro-gate";
import { Hero } from "@/components/landing/hero";
import { CareLoop } from "@/components/landing/care-loop";
import { ScrollJourney } from "@/components/landing/scroll-journey";
import { Features } from "@/components/landing/features";
import { Closing } from "@/components/landing/closing";

export default function LandingPage() {
  return (
    <div className="landing-theme relative min-h-dvh">
      {/* Patient-app periwinkle canvas, beneath the journey's scene skies */}
      <div aria-hidden className="fixed inset-0 -z-20 bg-[#d9e5f6]" />
      <IntroGate>
        <MarketingNav />
        <Hero />
        <CareLoop />
        <ScrollJourney />
        <Features />
        <Closing />
      </IntroGate>

      {/* Crawlable copy mirror for SEO / no-JS (the journey renders client-side). */}
      <div className="sr-only">
        <h2>The Nirog care world</h2>
        <p>
          Nirog is a continuous care platform for rural India, pairing a
          voice-first patient app with a responsive doctor workspace.
        </p>
        <h3>Intake</h3>
        <p>ARIA captures symptoms by voice in the patient&rsquo;s language.</p>
        <h3>Triage</h3>
        <p>
          A structured AI handover with red flags reaches the doctor&rsquo;s
          priority queue.
        </p>
        <h3>Consult</h3>
        <p>Audio-first teleconsultation that degrades gracefully and resumes.</p>
        <h3>Care plan</h3>
        <p>
          Notes, prescriptions and follow-up are filed and returned to the
          patient.
        </p>
        <h3>Continuity</h3>
        <p>
          One consent-gated, fully audited longitudinal record — a resolved care
          episode.
        </p>
      </div>
    </div>
  );
}

```

### src/lib/supabase/server.ts

```typescript
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

/**
 * Supabase client for Server Components, Server Actions and Route Handlers.
 * Runs queries as the signed-in doctor (the `authenticated` role) so RLS
 * policies are enforced by the database on every read and write.
 */
export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Called from a Server Component — session refresh happens in
            // middleware, so this can be safely ignored.
          }
        },
      },
    }
  );
}

```

### src/app/portal/layout.tsx

```typescript
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { getDataSource } from "@/lib/data/source";
import { pharmacyRedirectFor } from "@/lib/pharmacy/route";
import { Sidebar } from "@/components/portal/sidebar";
import { MobileNav } from "@/components/portal/mobile-nav";
import { Topbar } from "@/components/portal/topbar";

export default async function PortalLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  // Enforce MFA step-up: an account with a factor must reach aal2 first.
  const supabase = await createClient();
  const { data: aal } =
    await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
  if (aal && aal.nextLevel === "aal2" && aal.currentLevel === "aal1") {
    redirect("/verify");
  }

  // A pharmacy partner shares the same auth pool but has no clinician profile —
  // send them to their own flow instead of failing on getDoctor().
  const pharmacyRoute = await pharmacyRedirectFor();
  if (pharmacyRoute) redirect(pharmacyRoute);

  const ds = await getDataSource();
  const doctor = await ds.getDoctor();

  // New clinicians must finish verification before they can see any patients.
  if (!doctor.onboardingComplete) redirect("/onboarding");

  return (
    <div className="flex min-h-dvh bg-canvas">
      <Sidebar />
      <div className="flex min-w-0 flex-1 flex-col">
        <Topbar doctor={doctor} />
        <main className="flex-1 px-5 pb-24 pt-6 lg:px-8 lg:pb-10">
          {children}
        </main>
      </div>
      <MobileNav />
    </div>
  );
}

```

### src/app/onboarding/page.tsx

```typescript
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { getDataSource } from "@/lib/data/source";
import { logout } from "@/app/(auth)/actions";
import { Logo } from "@/components/brand/logo";
import { OnboardingWizard } from "@/components/onboarding/wizard";

export const metadata: Metadata = { title: "Complete your profile" };
export const dynamic = "force-dynamic";

export default async function OnboardingPage() {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const ds = await getDataSource();
  const doctor = await ds.getDoctor();
  if (doctor.onboardingComplete) redirect("/portal");

  // Pre-fill from whatever we already know (email signup / Google metadata).
  const nameParts = doctor.fullName.replace(/^Dr\.?\s*/i, "").split(/\s+/);

  return (
    <div className="min-h-dvh bg-canvas">
      <header className="flex items-center justify-between border-b border-hairline bg-panel/70 px-5 py-3.5 backdrop-blur">
        <Logo size={26} />
        <form action={logout}>
          <button type="submit" className="text-sm font-medium text-ink-soft hover:text-ink">
            Sign out
          </button>
        </form>
      </header>

      <div className="mx-auto max-w-3xl px-5 pt-8 text-center">
        <p className="inline-flex rounded-full bg-soft-blue px-3.5 py-1.5 text-xs font-semibold text-blue">
          One-time setup
        </p>
        <h1 className="mt-3 font-display text-3xl font-extrabold tracking-tight text-ink sm:text-4xl">
          Complete your clinician profile
        </h1>
        <p className="mx-auto mt-2 max-w-xl text-ink-soft">
          Nirog verifies every doctor before they see a patient. Tell us about
          your practice and upload your credentials — it takes a few minutes.
        </p>
      </div>

      <OnboardingWizard
        doctorId={doctor.id}
        email={doctor.email}
        defaults={{
          firstName: nameParts[0],
          lastName: nameParts.slice(1).join(" "),
          displayName: doctor.fullName,
          specialty: doctor.specialty,
          registrationNo: doctor.registrationNo,
          languages: doctor.languages,
          clinicName: doctor.clinicName,
          country: doctor.country,
        }}
      />
    </div>
  );
}

```

### src/app/portal/page.tsx

```typescript
import type { Metadata } from "next";
import { Users, TriangleAlert, CalendarClock, Timer, CheckCircle2 } from "lucide-react";
import { getDataSource } from "@/lib/data/source";
import { StatTile } from "@/components/portal/stat-tile";
import { QueueList } from "@/components/portal/queue-list";
import { QueueRealtime } from "@/components/portal/queue-realtime";
import { IncomingDock } from "@/components/portal/incoming-dock";
import { AriaSpotlight } from "@/components/portal/aria-spotlight";
import { TrustLog } from "@/components/portal/trust-log";

export const metadata: Metadata = { title: "Today" };
export const dynamic = "force-dynamic";

function greeting(): string {
  const h = new Date().getHours();
  if (h < 12) return "Good morning";
  if (h < 17) return "Good afternoon";
  return "Good evening";
}

export default async function TodayPage() {
  const ds = await getDataSource();
  const [doctor, stats, queue, audit] = await Promise.all([
    ds.getDoctor(),
    ds.getDashboardStats(),
    ds.getQueue(),
    ds.getRecentAudit(5),
  ]);

  // On-demand pool: only fetched (and only visible) while the doctor is on call.
  const pool = doctor.onCall ? await ds.getPoolQueue() : [];

  // Top emergency with an ARIA handover drives the spotlight.
  const spotlight = queue.find(
    (q) => q.state === "waiting" && q.handoverId && q.triage === "emergency"
  );
  const spotlightChart = spotlight
    ? await ds.getPatientChart(spotlight.patientId)
    : null;

  const shortName = doctor.fullName.replace(/^Dr\.?\s*/, "");

  return (
    <div className="mx-auto max-w-6xl">
      <header className="rise">
        <h1 className="font-display text-2xl font-bold tracking-tight text-ink">
          {greeting()}, Dr. {shortName.split(" ").slice(-1)[0]}
        </h1>
        <p className="mt-1 text-sm text-ink-soft">
          {stats.waiting > 0
            ? `${stats.waiting} patient${stats.waiting > 1 ? "s" : ""} waiting${
                stats.emergencies > 0 ? ` · ${stats.emergencies} need urgent review` : ""
              }.`
            : "No one is waiting right now. Scheduled consults are below."}
        </p>
      </header>

      <section className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
        <StatTile label="Waiting" value={stats.waiting} icon={Users} accent="blue" />
        <StatTile
          label="Emergencies"
          value={stats.emergencies}
          icon={TriangleAlert}
          accent="red"
          hint={stats.emergencies > 0 ? "Review first" : "None"}
        />
        <StatTile
          label="Scheduled"
          value={stats.scheduledToday}
          icon={CalendarClock}
          accent="indigo"
        />
        <StatTile
          label="Avg wait"
          value={stats.avgWaitMin}
          unit="min"
          icon={Timer}
          accent="amber"
        />
        <StatTile
          label="Completed"
          value={stats.completedToday}
          icon={CheckCircle2}
          accent="green"
        />
      </section>

      <div className="mt-6 grid gap-6 lg:grid-cols-[1.55fr_1fr]">
        <section>
          <div className="mb-3">
            <IncomingDock initialOnCall={doctor.onCall ?? false} pool={pool} />
          </div>
          <div className="mb-3 flex items-center justify-between gap-2">
            <div className="flex items-center gap-2">
              <h2 className="text-lg font-semibold text-ink">
                Today&rsquo;s queue
              </h2>
              <QueueRealtime />
            </div>
            <span className="hidden text-sm text-ink-faint sm:inline">
              Sorted by triage, then wait time
            </span>
          </div>
          <QueueList items={queue} />
        </section>

        <aside className="flex flex-col gap-6">
          {spotlight && spotlightChart?.handover && (
            <AriaSpotlight
              patient={spotlightChart.patient}
              handover={spotlightChart.handover}
              queueId={spotlight.id}
            />
          )}

          <section className="rounded-2xl border border-hairline bg-panel p-5 shadow-quiet">
            <div className="mb-4 flex items-center justify-between">
              <h2 className="font-semibold text-ink">Trust log</h2>
              <span className="text-xs text-ink-faint">Immutable · who / what / why</span>
            </div>
            <TrustLog events={audit} />
          </section>
        </aside>
      </div>
    </div>
  );
}

```

[125 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]