cat projects/labeasy.md
    Live
    Next.js 15
    Prisma 7
    PostgreSQL
    Gemini
    Razorpay
    Redis
    Docker

    Lab easy

    A multi-vendor healthcare marketplace where patients book lab tests, doctor consultations and health insurance in one app — with AI report summaries, a per-marker health dashboard, and a single signed ledger that pays every vendor.

    Nov 2024 – Jun 2026 · Top 10 finalist, HackCBS 7 (MLH) · 85 commits Solo build — design, backend, frontend, deploy
    5
    Actor types
    Patient · lab · doctor · insurer · admin
    123
    API route handlers
    Under /api/v1
    32
    Prisma models
    37 migrations
    56
    Pages
    4 role dashboards
    4
    Payment flows
    Orders · consults · policies · ads
    3
    Gemini prompts
    Extract · summarise · PDF

    Screens

    patient health dashboard — per-marker trends & score
    patient health dashboard — per-marker trends & score
    report with Gemini summary
    report with Gemini summary
    lab console — wallet ledger
    lab console — wallet ledger
    admin analytics
    admin analytics

    What I built

    Multi-vendor lab marketplace

    120+ tests · 8 order states

    A global test catalogue with per-lab pricing and home-collection fees, lab-owned packages, slot capacity, an eight-state order lifecycle from placed to completed, and offline walk-in bookings that live alongside platform orders in the same tables.

    AI report summaries that fail safe

    Multimodal extraction

    When a lab uploads a PDF or image, Gemini extracts every numeric marker with its reference range, then a second prompt writes a plain-language summary with highlights and specialist suggestions constrained to an eleven-item whitelist. Every AI path degrades to a rule-based fallback so an upload never blocks.

    Health dashboard, sharing & re-test reminders

    Revocable share links

    Per-marker sparklines built from every report in order, a deterministic health score over markers with reference bounds, doctors nearby re-ranked by suggested specialty, revocable share links for reports, and reminder emails that fire when a re-test is due.

    One signed ledger for every vendor

    7 entry types

    Labs, doctors and insurers share a single append-only wallet table. Earnings credit on completion, platform fees, ad spend and commissions debit, and a balance is simply the sum. Vendors can buy sponsored listings wallet-first with Razorpay covering the remainder.

    Payments without trusting the client

    HMAC + idempotent

    Four independent checkout-and-verify flows recompute every price from the database, verify the Razorpay signature with a constant-time HMAC compare, short-circuit on already-terminal records, and flip state inside a transaction. Coupon double-redemption is blocked by a unique index.

    Role consoles & admin

    4 dashboards

    Separate dashboards for labs, doctors and insurers covering bookings, slots or plans, coupons, a phone-linked patient CRM, promotions, wallet and analytics. Admin approves vendors, plan commissions and profile change requests, and runs platform-wide analytics and coupons.

    How it's built

    A single Next.js 15 App Router codebase: every page is a thin re-export of a view component, and all business logic sits in route handlers under /api/v1 plus a lib layer of thirty modules. PostgreSQL through Prisma is the only source of truth; Redis and Gemini are optional subsystems that the app runs fine without.

    Frontend
    • Next.js 15 App Router, 56 pages as one-line re-exports of 48 view components
    • Role-scoped route trees: /labsdashboard, /doctordashboard, /insurancedashboard, /admin
    • Zustand auth store, Tailwind + Radix, Chart.js for analytics, hand-rolled SVG sparklines
    • Sitemap, robots and a catch-all route for SEO
    API & auth
    • 123 route handlers under /api/v1 grouped by domain (admin, labs, insurance, auth, doctor, tests, orders…)
    • Patients and vendors: httpOnly session cookie carrying a typed JWT; each route asserts the principal type
    • Admin: separate short-lived Bearer JWT in sessionStorage so a vendor cookie can never satisfy admin checks
    • Edge middleware verifies the JWT with jose and enforces seven prefix rules before any page renders
    Data (PostgreSQL + Prisma 7)
    • 32 models, 7 enums, 37 migrations; all money stored as integer paise
    • WalletEntry: signed amounts keyed by owner type + id, ref_id as idempotency key, no balance column
    • CouponRedemption.ref_id unique index doubles as the double-redeem guard
    • VendorPatient unique on (vendor, phone) merges walk-in and online patients
    Integrations
    • Razorpay orders + HMAC-SHA256 signature verification with timingSafeEqual
    • Gemini via REST in JSON mode: analyte extraction, summary generation, direct PDF summary
    • Upstash Redis cache-aside: catalogue (5 min), sponsored sets (60 s), PIN geo lookups (30 days)
    • Cloudinary for report files, Resend for OTP and reminder mail, cron endpoints guarded by a secret

    Two-stage Docker build on node:20-slim, standalone Next output, runs as a non-root user.

    cat docs/request-flow.txt
    1. 1.Patient adds tests → checkout recomputes prices from DB → applies vendor or admin coupon → Razorpay order created
    2. 2.Client returns payment id + signature → /orders/verify checks ownership, HMAC, terminal state → $transaction flips PLACED → CONFIRMED
    3. 3.Lab uploads report → MIME + 10 MB check → Cloudinary → Gemini extracts analytes → Report row created
    4. 4.Upload completes the order → lab wallet credited (order:{id} ref_id) → platform-borne discount added back
    5. 5.Patient opens results → summary generated in JSON mode, whitelisted, cached on the report; fallback if AI fails
    6. 6.Whole-profile summary keyed by SHA-256 of the analyte snapshot, regenerated only when markers change
    7. 7.Monthly cron posts platform fee per vendor with fee:{id}:{period} ref_id so re-runs are no-ops

    Hardest problems

    One ledger, three vendor types, no balance column

    Problem

    Labs earn on order completion, doctors on consultations, insurers on policy commissions, and all three spend on platform fees and ads. A balance column per vendor drifts the moment two code paths disagree, and payouts need an audit trail.

    How I solved it

    A single append-only WalletEntry table with signed integer paise, an owner discriminator, and a structured ref_id per event such as order:{id} or fee:{labId}:{period}. Balance is the sum of entries. Adding doctors later kept the original lab ref_id format unchanged so existing idempotency held.

    src/lib/wallet.ts · src/lib/billing.ts

    Who eats a platform coupon in a multi-vendor payout

    Problem

    A vendor coupon reduces what the vendor is paid, but an admin coupon must not. Getting this wrong either shorts the vendor or overpays them, and the maths differs for orders, consults and policies.

    How I solved it

    Coupon lookup prefers vendor-owned codes, and admin codes mark the order as platform-borne with the amount stored in platform_discount. Every payout path then credits gross: total plus platform discount for labs, fee plus discount for doctors, amount plus discount minus commission for insurers. Payable is floored at one rupee.

    src/lib/coupons.ts · api/v1/orders/checkout

    Idempotent, tamper-resistant payments without webhooks

    Problem

    Verification is driven by the client callback, so a replayed or forged callback, a double click, or a modified price in the request body all had to be harmless.

    How I solved it

    Each verify route re-fetches the record, asserts ownership and that the stored provider order id matches, verifies the HMAC with a constant-time compare, returns early if the record is already terminal, flips state in a transaction, and records the coupon redemption behind a unique index that swallows duplicates. Prices are always recomputed server-side.

    src/lib/razorpay.ts · api/v1/*/verify

    Treating the AI as untrusted and optional

    Problem

    Gemini output is free text in a medical context. It can invent specialties, return malformed JSON, or time out, and none of that may block a lab from uploading a report or a patient from reading it.

    How I solved it

    JSON response mode plus a guarded parse, array checks on every field, specialty suggestions filtered against a whitelist, and a rule-based fallback summary on any failure. Only AI-generated results are cached so a fallback is retried next time. The profile-level summary is keyed by a hash of the analyte snapshot so it regenerates only on real change.

    src/lib/ai-summary.ts · api/v1/health/summary

    Merging walk-in and online patients by phone

    Problem

    Labs and doctors still see walk-in patients who never signed up. They needed a CRM that works offline yet reconciles when that patient later books online, without counting manual bookings as platform revenue.

    How I solved it

    A VendorPatient table unique on vendor and phone. Manual orders carry a null user id and a MANUAL source; platform bookings upsert into the same book by phone, so identities merge silently. Every wallet, fee and GMV query filters to PLATFORM source.

    src/lib/vendor-patient.ts

    What I'd do differently

    • $Put the wallet idempotency check inside the insert: a unique index on WalletEntry.ref_id instead of a find-then-insert, so concurrent requests cannot double-credit.
    • $Add a Razorpay webhook as a second verification path so a dropped client callback still confirms the payment.
    • $Enforce slot capacity with a database constraint or a conditional update rather than a read-then-increment.
    • $Proxy report files through a signed, expiring URL so revoking a share link also revokes the file.

    Tech stack

    Next.js 15
    TypeScript
    Prisma 7
    PostgreSQL
    Gemini API
    Razorpay
    Upstash Redis
    Cloudinary
    Resend
    Zod
    Zustand
    Tailwind CSS
    Docker
    $ cd ../portfolio