Skip to content

tkshsbcue/TinkerQuest

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 

Repository files navigation

MandiMind

AI-driven mandi price intelligence and a farmer-first liquidity pool, delivered as an offline-capable Progressive Web App backed by a Rust API with an embedded JavaScript rule engine.


Table of Contents


Overview

The problem

Small and mid-size Indian farmers sell at the mandi (wholesale market) on the day of harvest. Prices are volatile — the onion modal price in Lasalgaon can swing 30–50% over two weeks — and most farmers cannot afford to store and wait. They sell at the bottom of the cycle. Aggregators and middlemen capture the price recovery that follows.

The product

MandiMind is a multilingual mobile-first web app (Hindi, Marathi, English) that gives a farmer three things in one flow:

  1. Price intelligence — a 30-day modal-price forecast per crop and mandi, produced by a sandboxed JavaScript rule engine on the server.
  2. Direct buyer matching — ranked introductions to verified buyers (exporters, wholesalers, restaurants, processors) scored by a transparent algorithm.
  3. The Liquidity Pool — deposit produce into a partner cold storage, receive 70–80% of current market value as cash immediately, and have the pool auto-sell the stock when the price hits a pre-computed trigger. This is the product's defining differentiator.

Design principles

  • Offline-first. Rural connectivity is lossy. Every user-visible action writes locally, then reconciles.
  • Small binary, small bundle. The Rust backend is a single statically-linkable binary. The PWA ships under 400 kB gzipped in the critical path.
  • Auditable formulas. Pricing and advance logic lives in readable JavaScript files, not buried inside handlers.
  • No new auth stack. Supabase handles Google OAuth and token issuance; the backend verifies the resulting ES256 JWTs against Supabase's JWKS endpoint.

The Liquidity Pool (USP)

Why it exists

Traditional farm credit is collateralised against land, not inventory. Commodity futures are inaccessible — contract sizes, broker fees, and margin calls price out anyone under roughly five lakh rupees of exposure. Warehouse receipt financing exists on paper but moves at bank-loan speeds.

The Liquidity Pool closes that gap through one guided flow inside the app. It turns a storable harvest into working capital in hours, not weeks, and removes the "when should I sell" timing risk by committing to an algorithmically-determined trigger price up front.

Advance calculation (pool_advance.js)

The formula is embedded JavaScript, executed in a sandboxed Boa runtime with strict caps (50,000 loop iterations, 100-deep recursion, no I/O). The script is versioned alongside the Rust binary and can be moved to a database-backed rule store in a later iteration without touching handlers.

Inputs:

Field Source
listing.quantity_kg Farmer's crop listing
listing.quality_grade A / B / C — gates base advance percentage
current_market_price Latest modal_price from mandi_prices
predicted_peak current_market_price × 1.15 today; future: output of price_prediction.js
cold_storage_cost_per_kg_per_month Partner facility rate card
requested_advance_pct Farmer's slider, capped by grade

Rule:

base_pct       = { A: 80, B: 75, C: 70 }[grade]
advance_pct    = min(requested_pct, base_pct)
advance_amount = round(qty * market_price * advance_pct / 100)
trigger_price  = round(predicted_peak * 0.95, 2)      # capture 95% of forecast upside
storage_est    = storage_cost * qty * 2               # two months of storage
final_payout   = round(trigger_price * qty - advance_amount - storage_est)

# Safety net: if final_payout < 0, clamp to 0 and solve for break-even trigger

Grade controls LTV — Grade A stock is most liquid and gets the highest advance. The 95% cut on predicted peak is deliberate: the pool sells slightly before the forecast peak rather than chasing a local maximum.

Position state machine

stateDiagram-v2
    [*] --> advance_paid: Farmer deposits listing
    advance_paid --> in_storage: Stock received at cold storage
    in_storage --> auto_sold: market_price >= trigger_price
    in_storage --> settled: Farmer opts to sell manually
    auto_sold --> settled: Final payout released
    settled --> [*]
Loading

What's live vs. designed

Live, end-to-end tested Designed, not yet wired
Advance computation through Boa Background price watcher for auto_sold transitions
Pool position creation and listing status transition to in_storage Settlement integration (UPI / bank rails)
Per-farmer position read API Storage rent ledger billed per day (currently estimated at deposit time)

System Architecture

Component diagram

Request path for a protected endpoint

sequenceDiagram
    participant PWA
    participant API as Axum
    participant Mid as auth_middleware
    participant Cache as JWKS cache
    participant Sup as Supabase JWKS
    participant H as Route handler
    participant DB as SQLite

    PWA->>API: GET /api/listings?farmer_id=... (Bearer ES256 JWT)
    API->>Mid: extract Authorization header
    Mid->>Mid: decode_header -> alg=ES256, kid=...
    alt kid in cache
      Cache-->>Mid: JWK (cached)
    else kid not cached
      Mid->>Sup: GET /auth/v1/.well-known/jwks.json
      Sup-->>Mid: { keys: [...] }
      Mid->>Cache: store
    end
    Mid->>Mid: verify signature + exp with EC P-256 public key
    Mid->>H: inject Claims { sub, email, role, ... } into request extensions
    H->>DB: SELECT ...
    DB-->>H: rows
    H-->>PWA: 200 JSON (snake_case)
    PWA->>PWA: service-layer normalizer -> camelCase domain types
Loading

Offline-first sync

Every mutation writes to IndexedDB first. If the network call fails or the device is offline, the mutation lands in a pending_sync_queue and the UI surfaces a pending count. On reconnect, syncService.processPendingQueue() drains the queue with exponential-style retries (drop after 5 failures to avoid an unbounded queue).

flowchart TD
  A[User action<br/>e.g. Create listing] --> B{Online?}
  B -- Yes --> C[POST /api/listings]
  C -- 200 --> D[Normalise response<br/>Dexie.put]
  C -- Error --> E[Dexie: add to<br/>pending_sync_queue]
  B -- No --> F[Generate local UUID<br/>Dexie.add]
  F --> E
  E --> G[syncStore.incrementPending]
  G --> H[UI badge: N pending sync]
  I[window.onOnline] --> J[syncService.processPendingQueue]
  J --> K{retry <= 5}
  K -- success --> L[delete queue item<br/>decrement pending]
  K -- fail --> M[increment retries<br/>or drop after 5]
Loading

Queue item types:

  • create_listing — POST /api/listings
  • update_listing — PATCH /api/listings/:id
  • update_profile — PATCH /api/farmers/:id (camelCase is normalised to snake_case at send time)

Backend

Tech stack

Concern Choice Why
Language / runtime Rust stable, Tokio Memory safety for financial arithmetic; low-footprint single binary.
Web framework Axum 0.7 Lean type-safe extractors; composes with tower middleware.
Database SQLite via sqlx (WAL mode) Single-file deployability. sqlx is compiled with both sqlite and postgres features, so a Postgres swap needs no query rewrites.
Rule engine Boa 0.21 (ECMAScript 2024) Moves pricing, matching, and advance logic out of Rust and into versionable JS snippets. Sandboxed with strict loop and recursion caps.
Auth JSON Web Tokens verified against Supabase JWKS No in-house user management.
Observability tracing + tracing-subscriber Structured logs.

Why an embedded JS engine

Pricing logic, matching heuristics, and advance formulas change more frequently than core server code. Encoding them as JavaScript snippets with explicit input and output contracts gives three properties the equivalent Rust code would not:

  1. Hot-swappable rules. Today the scripts are embedded with include_str!. Moving them into a database table in a later iteration lets operators tune formulas without a rebuild or restart.
  2. Auditability. Every formula lives in a small reviewable file (pool_advance.js, buyer_matching.js, price_prediction.js) that a non-Rust engineer can read and reason about.
  3. Safe execution. Boa runs with no filesystem, network, or process access, with hard limits of 50,000 loop iterations and 100-deep recursion. A bad rule can fail the evaluation; it cannot crash the server.

Module layout

backend/
├── Cargo.toml
├── migrations/
│   ├── 001_initial.sql       # Full schema
│   └── 002_relax_phone.sql   # OAuth users may have no phone - drop UNIQUE
├── src/
│   ├── main.rs               # Router assembly, CORS, migrations, seed
│   ├── auth.rs               # JWKS ES256 + HS256 + dev fallback verifier
│   ├── db/mod.rs             # Pool init, migration runner with _migrations tracker
│   ├── engine/
│   │   ├── mod.rs            # RuleEngine::evaluate(script, input) - Boa wrapper
│   │   └── scripts/
│   │       ├── mod.rs        # include_str!-embedded scripts
│   │       ├── price_prediction.js
│   │       ├── buyer_matching.js
│   │       └── pool_advance.js
│   ├── error.rs              # AppError to HTTP mapping
│   ├── models.rs             # Serde DTOs and request types
│   ├── routes/
│   │   ├── health.rs         # Public liveness probe
│   │   ├── farmers.rs        # Create/upsert (by id), get, patch
│   │   ├── listings.rs       # CRUD with status and assigned_batch persistence
│   │   ├── mandi.rs          # prices, latest, prediction/:crop (Boa)
│   │   ├── buyers.rs         # offers, match/:listing_id (Boa), accept, decline
│   │   ├── pool.rs           # positions, deposit (Boa) - the USP
│   │   ├── storage.rs        # Cold storage directory
│   │   ├── sync.rs           # Batch offline-queue endpoint
│   │   └── engine.rs         # Public engine showcase endpoints
│   └── seed.rs               # Deterministic seed for farmers, mandis, offers, storages

Migration strategy

Migrations are embedded with include_str! and tracked in a _migrations table (id, applied_at). Each migration runs at most once. Destructive table-rebuild migrations (like 002_relax_phone.sql, which relaxes phone NOT NULL UNIQUE to phone NOT NULL DEFAULT '') run with PRAGMA foreign_keys = OFF for the duration and re-enable afterwards — necessary because SQLite enforces FKs across the crop_listings table during DROP TABLE farmers.


Frontend

Tech stack

Concern Choice
Framework React 18 + TypeScript + Vite 5
Styling Tailwind CSS 3 + Radix primitives
Forms and validation react-hook-form + zod
Server state TanStack Query 5
Client state Zustand 4, persisted to IndexedDB via idb-keyval
Offline storage Dexie 4
Service worker vite-plugin-pwa + Workbox
Charts Recharts
Animation Framer Motion
i18n i18next (Hindi, Marathi, English)
Auth SDK @supabase/supabase-js

Module layout

mandimind-pwa/
├── index.html
├── vite.config.ts
├── tailwind.config.ts
└── src/
    ├── main.tsx
    ├── app/
    │   ├── App.tsx
    │   ├── providers.tsx     # QueryClient, i18n, theme providers
    │   └── router.tsx        # Protected routes gated by farmerStore.isAuthenticated
    ├── pages/                # One component per route
    │   ├── OnboardingPage.tsx       # 4 steps: language - Google OAuth - profile - crops
    │   ├── HomePage.tsx             # Greeting, active listings, price recommendation
    │   ├── ListCropPage.tsx         # 5-step crop listing wizard
    │   ├── MarketIntelligencePage.tsx
    │   ├── SellWindowPage.tsx       # Per-listing forecast curve and sell-window band
    │   ├── BuyerMatchesPage.tsx
    │   ├── LiquidityPoolPage.tsx
    │   ├── ColdStoragePage.tsx      # Distance-sorted via geolocation
    │   ├── ProfilePage.tsx
    │   └── OfflinePage.tsx
    ├── features/             # Feature-scoped components (pool/, buyers/, voice/)
    ├── services/             # One per domain; every HTTP call flows through here
    │   ├── farmerService.ts
    │   ├── listingService.ts
    │   ├── mandiService.ts
    │   ├── buyerService.ts
    │   ├── poolService.ts
    │   ├── storageService.ts
    │   ├── syncService.ts    # Drains the offline queue
    │   └── voiceService.ts   # Web Speech API wrapper (no HTTP)
    ├── stores/
    │   ├── farmerStore.ts    # Persisted; drives route guards
    │   ├── listingStore.ts   # In-memory with optimistic updates
    │   └── syncStore.ts      # online flag, pending count, last-synced timestamp
    ├── lib/
    │   ├── api.ts            # fetch wrapper; attaches localStorage.supabase_token
    │   ├── supabase.ts       # Supabase client + onAuthStateChange to localStorage
    │   ├── db.ts             # Dexie schema
    │   ├── queryClient.ts
    │   ├── i18n.ts
    │   └── utils.ts
    ├── locales/              # hi.json, mr.json, en.json
    ├── types/domain.ts       # Shared TS domain types (camelCase)
    ├── mocks/                # Fixtures used as final offline fallback
    └── sw.ts                 # Workbox runtime strategies

Services layer — the camelCase / snake_case boundary

The backend speaks snake_case; the frontend domain types are camelCase. Every service file carries an explicit normalize* function that maps between them and tolerates either shape, so cached IndexedDB rows and fresh API responses traverse the same path.

Services encode offline fallback in three layers:

backend -> IndexedDB cache -> bundled mock fixtures

A GET /api/prices call returns live data when online, falls back to Dexie-cached rows when the request fails, and finally to src/mocks/mandi-prices.json on a cold-boot offline session. The user never sees an empty screen.

Routing

All routes except /onboarding and /offline are gated on useFarmerStore(s => s.isAuthenticated). The farmer object is persisted to IndexedDB, so a reopened PWA goes straight to / without re-auth — unless Supabase's token has expired, in which case any API call returns 401 and the user re-authenticates via Google.

Why a PWA, not a native app

Target users are on low-end Android devices with under 1 GB RAM. A PWA installs from the browser with no Play Store listing, updates over the air, and works offline through a pre-cached shell plus IndexedDB. The same codebase scales to desktop without a second build pipeline.


Authentication

Supabase migrated its default JWT signing from HS256 to ES256 (asymmetric, EC P-256) in 2024. The private key never leaves Supabase; servers verify tokens against the public JWKS endpoint at https://<project>.supabase.co/auth/v1/.well-known/jwks.json.

auth_middleware in src/auth.rs handles three cases:

  1. Asymmetric path (ES256 / RS256). Decodes the JWT header, reads alg and kid, pulls JWKS (cached process-wide in a OnceLock<RwLock<Option<Jwks>>>), finds the matching key, builds a DecodingKey::from_ec_components(x, y), and verifies signature plus exp.
  2. Legacy HS256. If SUPABASE_JWT_SECRET is a real JWT secret, verifies with it directly.
  3. Dev fallback. If the secret is empty or has the new sb_ API-key shape (a publishable key, not a JWT secret), the middleware falls back to structure-only decode. On missing header plus empty secret it injects a synthetic dev-farmer-001 claim set so the app works offline against the locally-seeded database.

This keeps the dev ergonomics of "clone and run" without compromising production verification.


Data Model

erDiagram
    farmers ||--o{ crop_listings : owns
    farmers ||--o{ pool_positions : has
    crop_listings ||--o| pool_positions : financed_by
    cold_storages ||--o{ pool_positions : hosts
    buyer_offers }o..o{ crop_listings : matched_by_rule_engine
    mandi_prices }o..o{ crop_listings : informs_pricing

    farmers {
        TEXT id PK
        TEXT name
        TEXT phone
        TEXT village
        TEXT district
        TEXT state
        TEXT preferred_language "hi | mr | en"
        TEXT primary_crops "JSON array"
        REAL land_size_acres
    }
    crop_listings {
        TEXT id PK
        TEXT farmer_id FK
        TEXT crop
        REAL quantity_kg
        TEXT expected_harvest_date
        TEXT quality_grade "A | B | C"
        TEXT status "draft | listed | matched | in_storage | sold | cancelled"
        TEXT assigned_batch "monday | wednesday | friday"
    }
    pool_positions {
        TEXT id PK
        TEXT listing_id FK
        TEXT farmer_id FK
        TEXT cold_storage_id FK
        REAL advance_paid
        REAL advance_pct
        REAL current_market_price
        REAL trigger_price
        REAL estimated_final_payout
        TEXT status "advance_paid | in_storage | auto_sold | settled"
    }
    cold_storages {
        TEXT id PK
        TEXT name
        TEXT district
        REAL lat
        REAL lng
        REAL capacity_tonnes
        REAL available_tonnes
        REAL price_per_kg_per_month
    }
    mandi_prices {
        INTEGER id PK
        TEXT mandi_id
        TEXT crop
        REAL min_price
        REAL max_price
        REAL modal_price
        TEXT date
        REAL arrivals_tonnes
    }
    buyer_offers {
        TEXT id PK
        TEXT buyer_name
        TEXT buyer_type "restaurant | exporter | wholesaler | processor"
        TEXT crop
        REAL quantity_needed_kg
        REAL offered_price_per_kg
        INTEGER verified
    }
Loading

API Reference

All request and response bodies are snake_case JSON. Errors return { "error": "..." } with an appropriate HTTP status.

Method Path Auth Purpose
GET /health public Liveness probe
POST /api/farmers Bearer Create or upsert a farmer by id
GET /api/farmers/:id Bearer Fetch by id
GET /api/farmers/by-phone/:phone Bearer Fetch by phone
PATCH /api/farmers/:id Bearer Partial update
POST /api/listings Bearer Create crop listing
GET /api/listings Bearer List; filters: farmer_id, crop, status
GET /api/listings/:id Bearer Fetch by id
PATCH /api/listings/:id Bearer Partial update
GET /api/prices Bearer Mandi prices; filters crop, district
GET /api/prices/latest Bearer Deduplicated latest per mandi+crop
GET /api/prices/prediction/:crop Bearer 30-day forecast via Boa
GET /api/buyers/offers Bearer All offers; filter crop
GET /api/buyers/match/:listing_id Bearer Ranked buyer matches via Boa
POST /api/buyers/offers/:id/accept Bearer Record an acceptance
POST /api/buyers/offers/:id/decline Bearer Record a decline
GET /api/pool/positions Bearer Positions; filter farmer_id
POST /api/pool/deposit Bearer Advance calc via Boa + create pool position
GET /api/storage Bearer Cold-storage directory; filter district
POST /api/sync Bearer Batch offline-queue endpoint
POST /api/engine/eval public (demo) Evaluate arbitrary script against an input
GET /api/engine/scripts public (demo) Describe built-in scripts
POST /api/engine/run/:name public (demo) Run a built-in script
GET /api/engine/demo public (demo) Eight JS snippets showcasing engine capabilities

Getting Started

Prerequisites

  • Rust 1.80 or newer (rustup recommended)
  • Node 20 or newer with npm
  • A Supabase project for Google OAuth. Leaving secrets empty runs the backend in dev-bypass mode; the app still works against the seeded SQLite database.

Environment

backend/.env:

DATABASE_URL=sqlite:mandimind.db
BIND_ADDR=0.0.0.0:3000
SUPABASE_URL=https://<project>.supabase.co
SUPABASE_ANON_KEY=...
SUPABASE_JWT_SECRET=...        # optional real HS256 secret; leave empty for dev bypass
RUST_LOG=info

mandimind-pwa/.env:

VITE_API_URL=http://localhost:3000
VITE_SUPABASE_URL=https://<project>.supabase.co
VITE_SUPABASE_ANON_KEY=...

Run

# Terminal 1 — backend
cd backend
cargo run          # binds :3000, runs migrations and seed on first start

# Terminal 2 — frontend
cd mandimind-pwa
npm install
npm run dev        # Vite on :5173

On first run the backend creates mandimind.db and seeds three farmers, roughly twenty mandi price rows, buyer offers, and cold storages. The seed is idempotent — it runs only when the farmers table is empty.

Build for production

cd backend && cargo build --release
cd mandimind-pwa && npm run build

The PWA output in dist/ is a static bundle, servable behind any CDN. The service worker precaches the application shell for offline use.


Project Structure

TinkerQuest/
├── backend/          # Rust / Axum / Boa / SQLite
└── mandimind-pwa/    # React / Vite / PWA

The two halves are independently buildable and deployable; they communicate only over HTTP.


Roadmap

  • Background price watcher that fires auto_sold transitions on pool positions
  • Replace the market_price × 1.15 heuristic in pool_advance with the output of price_prediction
  • UPI and bank-rail payout on settlement
  • Aadhaar-OTP onboarding for farmers without Google accounts
  • Push notifications for price triggers and batch days
  • Postgres backend for multi-tenant deployment (query-level change is zero; sqlx already compiles both drivers)
  • Migrate embedded Boa scripts to a database-backed rule table for runtime tuning

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors