diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..08c144d --- /dev/null +++ b/.env.local.example @@ -0,0 +1,118 @@ +# Environment Variables for Monadic DNA Explorer +# Copy this file to .env.local and fill in your values + +# ============================================================================= +# NILLION API - Required for AI features (nilAI provider) +# ============================================================================= +# Get your API key from: https://nillion.com +# NOTE: This is only needed for the nilAI provider. Ollama and HuggingFace +# providers are configured in the UI (Menu Bar > AI Settings). +NILLION_API_KEY=your_api_key_here + +# ============================================================================= +# DATABASE - PostgreSQL (optional, for vector similarity search) +# ============================================================================= +# If not provided, app will fall back to in-memory storage +# POSTGRES_URL=postgresql://user:password@localhost:5432/gwasifier + +# ============================================================================= +# AUTHENTICATION - Dynamic.xyz (for wallet connection) +# ============================================================================= +NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your_dynamic_environment_id_here + +# ============================================================================= +# BLOCKCHAIN PAYMENTS - Alchemy indexer API +# ============================================================================= +# Get your API key from: https://www.alchemy.com/ +ALCHEMY_API_KEY=your_alchemy_api_key + +# EVM payment wallet address (same address used across all EVM chains) +# This is where users send their stablecoin payments on Ethereum, Base, Arbitrum, Optimism, Polygon +# NEXT_PUBLIC_ prefix is required because it's displayed in the browser UI +# Format: 0x... (42 characters, EVM address) +# Note: For future non-EVM chains (Solana, Monad, etc.), add separate variables like: +# NEXT_PUBLIC_SOLANA_PAYMENT_WALLET_ADDRESS, NEXT_PUBLIC_MONAD_PAYMENT_WALLET_ADDRESS +NEXT_PUBLIC_EVM_PAYMENT_WALLET_ADDRESS=0x0000000000000000000000000000000000000000 + +# Stablecoin contract addresses (standard addresses, don't change unless deploying new token) + +# USDC contracts +USDC_CONTRACT_ETHEREUM=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 +USDC_CONTRACT_BASE=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 +USDC_CONTRACT_ARBITRUM=0xaf88d065e77c8cC2239327C5EDb3A432268e5831 +USDC_CONTRACT_OPTIMISM=0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85 +USDC_CONTRACT_POLYGON=0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 + +NEXT_PUBLIC_USDC_CONTRACT_ETHEREUM=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 +NEXT_PUBLIC_USDC_CONTRACT_BASE=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 +NEXT_PUBLIC_USDC_CONTRACT_ARBITRUM=0xaf88d065e77c8cC2239327C5EDb3A432268e5831 +NEXT_PUBLIC_USDC_CONTRACT_OPTIMISM=0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85 +NEXT_PUBLIC_USDC_CONTRACT_POLYGON=0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 + +# USDT contracts +USDT_CONTRACT_ETHEREUM=0xdAC17F958D2ee523a2206206994597C13D831ec7 +USDT_CONTRACT_BASE=0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2 +USDT_CONTRACT_ARBITRUM=0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9 +USDT_CONTRACT_OPTIMISM=0x94b008aA00579c1307B0EF2c499aD98a8ce58e58 +USDT_CONTRACT_POLYGON=0xc2132D05D31c914a87C6611C10748AEb04B58e8F + +NEXT_PUBLIC_USDT_CONTRACT_ETHEREUM=0xdAC17F958D2ee523a2206206994597C13D831ec7 +NEXT_PUBLIC_USDT_CONTRACT_BASE=0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2 +NEXT_PUBLIC_USDT_CONTRACT_ARBITRUM=0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9 +NEXT_PUBLIC_USDT_CONTRACT_OPTIMISM=0x94b008aA00579c1307B0EF2c499aD98a8ce58e58 +NEXT_PUBLIC_USDT_CONTRACT_POLYGON=0xc2132D05D31c914a87C6611C10748AEb04B58e8F + +# DAI contracts +DAI_CONTRACT_ETHEREUM=0x6B175474E89094C44Da98b954EedeAC495271d0F +DAI_CONTRACT_BASE=0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb +DAI_CONTRACT_ARBITRUM=0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1 +DAI_CONTRACT_OPTIMISM=0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1 +DAI_CONTRACT_POLYGON=0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063 + +NEXT_PUBLIC_DAI_CONTRACT_ETHEREUM=0x6B175474E89094C44Da98b954EedeAC495271d0F +NEXT_PUBLIC_DAI_CONTRACT_BASE=0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb +NEXT_PUBLIC_DAI_CONTRACT_ARBITRUM=0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1 +NEXT_PUBLIC_DAI_CONTRACT_OPTIMISM=0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1 +NEXT_PUBLIC_DAI_CONTRACT_POLYGON=0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063 + +# ============================================================================= +# STRIPE PAYMENTS - Credit/Debit Card Payments +# ============================================================================= +# Get your API keys from: https://dashboard.stripe.com/apikeys +# Use test keys for development (sk_test_..., pk_test_...) +# Use live keys for production (sk_live_..., pk_live_...) + +# Server-side secret key (NEVER expose in client code) +STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key + +# Client-side publishable key (safe to expose in browser) +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key + +# Stripe price ID for monthly subscription ($4.99/month recurring) +# Get this from: https://dashboard.stripe.com/products (click your product → copy Price ID) +# Format: price_... (NOT prod_...) +STRIPE_PRICE_ID=price_your_price_id_here + +# NOTE: Subscriptions are verified via direct Stripe API queries +# No webhook configuration or database storage required + +# ============================================================================= +# STABLECOIN PRICING +# ============================================================================= +# All stablecoins (USDC, USDT, DAI) are assumed to be pegged 1:1 with USD +# No historical price lookups needed + +# ============================================================================= +# SUBSCRIPTION SETTINGS +# ============================================================================= +# Subscription price (used for calculating days purchased) +MONTHLY_SUBSCRIPTION_PRICE=4.99 + +# Subscription cache duration in hours (default: 1 hour) +# How long to cache subscription status in localStorage before re-checking blockchain +NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS=1 + +# ============================================================================= +# ANALYTICS (optional) +# ============================================================================= +# NEXT_PUBLIC_PLAUSIBLE_DOMAIN=your_domain.com diff --git a/.gitignore b/.gitignore index e208c04..c7bf4f3 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ yarn-error.log* /.idea/ /localdata/gwas_catalog.sqlite /localdata/gwas_catalog_v1.0.2-associations_e115_r2025-09-15.tsv +/localdata/gwas_catalog_v1.0.2-associations_e115_r2025-09-15.tsv.gz +/venv/ +/embeddings_backup.npz diff --git a/README.md b/README.md index c3a2ec6..394104c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,30 @@ # Monadic DNA Explorer -Match your DNA data against an open ended catalogue of DNA traits with private AI-powered analysis +Match your DNA data against an open ended catalogue of DNA traits with private LLM-powered analysis **🔗 Repository:** [github.com/Monadic-DNA/Explorer](https://github.com/Monadic-DNA/Explorer) +## Table of Contents + +- [Features](#features) +- [Development](#development) + - [Quick Start](#running-the-monadic-dna-explorer) + - [Environment Variables](#environment-variables) +- [Production Deployment](#production-deployment) +- [Semantic Search Setup](#semantic-search-setup) +- [Premium Features & Payments](#premium-features--payments) +- [License](#license) + ## Features -- Interactive exploration of GWAS Catalog studies with quality-aware filtering -- Upload and analyze your personal genetic data (23andMe, AncestryDNA, Monadic DNA) -- Private AI analysis powered by Nillion's nilAI - your data is processed in a Trusted Execution Environment -- Save and export your results +- **Semantic Search**: LLM-powered semantic search understands the meaning of your queries (e.g., "memory loss" finds "cognitive decline" studies) +- **Interactive exploration** of GWAS Catalog studies with quality-aware filtering +- **Upload and analyze** your personal genetic data (23andMe, AncestryDNA, Monadic DNA) +- **Private LLM analysis** powered by Nillion's nilAI, your data is processed in a Trusted Execution Environment +- **Premium Features**: LLM-powered genetic analysis chat, Run All analysis, comprehensive reports +- **Dual payment system**: Credit/debit cards (Stripe) or stablecoins (USDC/USDT/DAI on Ethereum, Base, Arbitrum, Optimism, Polygon) +- **Save and export** your results +- **Privacy-focused**: All processing happens on your infrastructure (no third-party APIs for search) ## Development @@ -32,6 +47,47 @@ npm run dev The development server defaults to http://localhost:3000. You can override the database location by exporting `GWAS_DB_PATH` before starting the server. +### Dev Mode Auto-Loading (Development Only) + +When running `npm run dev` on localhost, the app automatically enables dev mode to speed up your development workflow: + +**What Auto-Loads:** +- **Genotype file** - After uploading once, auto-loads on next session +- **Results file** - After loading/exporting once, auto-loads on next session +- **Personalization password** - Auto-unlocks encrypted personal data + +**How It Works:** + +1. **Chrome/Edge (Full Auto-Load)**: + - Uses File System Access API to store persistent file handles in IndexedDB + - Files load automatically with zero interaction + - Password stored in IndexedDB to auto-unlock personalization + +2. **Brave/Firefox (Fallback Mode)**: + - Brave disables File System Access API by default for privacy + - File pickers appear automatically on app load + - Just select your files - still faster than manual navigation + - Password auto-unlock works in all browsers + +**First-Time Setup:** +```bash +npm run dev +# 1. Upload your genotype file (saves handle/marker) +# 2. Load or export results (saves handle/marker) +# 3. Set up personalization (saves password) +# Next load: Everything restores automatically! +``` + +**Security Note:** +- Dev mode ONLY activates when `NODE_ENV==='development'` AND `hostname==='localhost'` +- Password stored in plain text in IndexedDB (local only, never sent to server) +- Clear dev data: `indexedDB.deleteDatabase('gwasifier_dev_mode')` in browser console + +**Enable Full Auto-Load in Brave:** +1. Open `brave://settings/` +2. **Privacy and security** → **Site and Shields Settings** → **File System Access** +3. Add exception for `http://localhost:3000` + ## Production Deployment ### Using PostgreSQL in Production @@ -56,27 +112,336 @@ npm start ### Environment Variables +**GWAS Database (Required):** - `POSTGRES_DB`: PostgreSQL connection string (if set, takes precedence over SQLite) - `GWAS_DB_PATH`: Path to SQLite database file (only used if `POSTGRES_DB` is not set) -- `NILLION_API_KEY`: (Optional) API key for Nillion's nilAI to enable private AI analysis of results + +**LLM Features:** +- **LLM Provider Selection**: Configure in the UI (Menu Bar > LLM Settings button) + - **Nillion nilAI** (Default): Privacy-preserving LLM in Trusted Execution Environment + - Requires `NILLION_API_KEY` environment variable + - **Ollama** (Local): Run LLM models on your own machine + - Requires Ollama installation with gpt-oss-20b model + - Configure address and port in UI settings + - **HuggingFace** (Cloud): Cloud-based LLM via HuggingFace Router + - Configure API key directly in UI settings (stored in browser localStorage) +- **Privacy**: All providers send data directly from browser to LLM service - never through our servers + +**Authentication (Required for Premium):** +- `NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID`: Dynamic.xyz environment ID for logging in + +**Blockchain Payments (Required for Premium):** +- `ALCHEMY_API_KEY`: Alchemy API key for blockchain indexer queries +- `NEXT_PUBLIC_EVM_PAYMENT_WALLET_ADDRESS`: EVM wallet address where users send ETH/USDC payments +- `NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS`: Cache duration in hours (default: 1) + +See `.env.local.example` for complete configuration details. ### Database Schema -The application expects a table named `gwas_catalog` with the standard GWAS Catalog schema. The PostgreSQL version should mirror the SQLite schema structure. +Complete database schemas are provided in the `sql/` directory: -### Performance Optimization +- **PostgreSQL**: `sql/postgres_schema.sql` - Production schema with pgvector support +- **SQLite**: `sql/sqlite_schema.sql` - Development schema -For optimal performance with large GWAS datasets, apply the provided database indexes: +Both schemas include: +- `gwas_catalog` table with auto-incrementing `id` primary key +- `study_embeddings` table with foreign key to `gwas_catalog.id` +- `embedding_cache` table for query caching +- All necessary indexes including HNSW for PostgreSQL + +To initialize a fresh database: ```bash +# For PostgreSQL +psql $POSTGRES_DB < sql/postgres_schema.sql + # For SQLite -sqlite3 /path/to/gwas_catalog.sqlite < sql/sqlite_indexes.sql +sqlite3 /path/to/gwas_catalog.sqlite < sql/sqlite_schema.sql +``` -# For PostgreSQL -psql $POSTGRES_DB < sql/postgresql_indexes.sql +**Architecture benefits:** +- Simple integer foreign key JOINs (faster than string operations) +- Foreign key constraints ensure data integrity +- No redundant lookup tables needed +- Reduced storage and improved query performance + +## Semantic Search Setup + +The application includes LLM-powered semantic search that understands the meaning of queries, not just keywords. + +### Prerequisites + +1. **PostgreSQL with pgvector** (production) or **SQLite** (development) +2. **Python 3.8+** with GPU support (for initial embedding generation) + +The complete database schema (including semantic search support) is in `sql/postgres_schema.sql` or `sql/sqlite_schema.sql`. See [Database Schema](#database-schema) section above for setup instructions. + +**Note for PostgreSQL:** The `pgvector` extension is automatically enabled by the schema. Most managed PostgreSQL services (DigitalOcean, AWS RDS, etc.) allow extension creation by database owners. + +### Step 1: Generate Study Embeddings + +Use your local GPU to generate embeddings for all studies: + +```bash +# Install Python dependencies +pip install -r scripts/requirements.txt + +# For PostgreSQL (production) - save local backup +POSTGRES_DB="postgresql://..." python scripts/generate-embeddings.py --save-local embeddings_backup.npz + +# For SQLite (development) +python scripts/generate-embeddings.py + +# Load from local backup to new database (no GPU needed) +POSTGRES_DB="postgresql://..." python scripts/generate-embeddings.py --load-local embeddings_backup.npz + +# Optional: Limit for testing +python scripts/generate-embeddings.py --limit 1000 + +# Adjust batch size based on GPU VRAM +python scripts/generate-embeddings.py --batch-size 256 # Default: 512 +``` + +**Time estimate**: 20-60 minutes for 1M studies on a modern GPU (RTX 3080/4090) + +**Local backup benefits**: +- Reuse embeddings for multiple databases +- No need to regenerate on database migration +- Transfer embeddings between environments +- Backup file size: ~500 MB compressed (for 1M studies, 512 dims) + +The script uses **nomic-embed-text-v1.5** with: +- **512 dimensions** (33% storage savings, 0.5% quality loss vs 768) +- **Matryoshka representation learning** (efficient truncation) +- **Task-specific prefixes** (`search_document:` for studies) + +### Step 2: Deploy Application + +The application automatically generates query embeddings on-the-fly using Transformers.js. + +**For DigitalOcean App Platform (Node.js Buildpack):** + +1. **Configure deployment** using the provided `.do/app.yaml`: + - Node.js buildpack (auto-detected) + - Health check with 60s timeout (allows model download) + - Professional-XS instance (1 vCPU, 1 GB RAM) + +2. **Set environment variables** in DO dashboard: + - `POSTGRES_DB`: Your PostgreSQL connection string + +3. **Deploy** - push to GitHub or deploy via DO CLI + +4. **First deployment**: Health check downloads model (~30-50s) before routing traffic + +5. **Subsequent deployments**: Model re-downloads on each deploy (health check handles it) + +**Cold start behavior:** +- First request after deploy: 10-20s (health check pre-warms model) +- Subsequent requests: <100ms (model already loaded) +- Model downloads: ~137 MB per deployment (cached in `/tmp/.transformers-cache`) + +**Alternative: Docker Deployment (Optional)** + +For faster cold starts and no repeated downloads, use the provided `Dockerfile`: + +```yaml +# .do/app.yaml +services: + - name: web + dockerfile_path: Dockerfile # Switch to Docker +``` + +Benefits: +- Cold start: 10-20s (model pre-baked in image) +- Model downloaded once during build (not per deploy) +- Larger image size: +137 MB + +**For local development:** + +```bash +npm install +npm run dev +``` + +The model downloads automatically on first search (~137 MB, cached in `.transformers-cache/`). + +### Step 3: Test Semantic Search + +Try these queries to see semantic search in action: + +- **"memory loss"** → finds studies about "cognitive decline", "dementia", "Alzheimer's" +- **"heart attack"** → finds "myocardial infarction", "coronary artery disease" +- **"diabetes risk"** → finds "type 2 diabetes", "insulin resistance", "hyperglycemia" + +**API Usage:** + +```bash +# Semantic search (default) +curl "http://localhost:3000/api/studies?search=alzheimer%20risk" + +# Keyword search (fallback) +curl "http://localhost:3000/api/studies?search=alzheimer%20risk&semantic=false" +``` + +### Architecture + +**Two-tier caching for fast queries:** + +1. **Memory cache** (100 hot queries): <1ms +2. **PostgreSQL cache** (10K warm queries): 2-5ms +3. **Generation** (cache miss): 50-100ms + +**Query flow:** +``` +User query → Check memory cache → Check DB cache → Generate embedding → + pgvector similarity search → Filter + rank → Return results +``` + +**Storage requirements:** +- Study embeddings: ~2 KB per study (512 dims × 4 bytes) +- 1M studies: ~2 GB embeddings + ~4 GB HNSW index = 6 GB total +- Query cache: ~2 KB per cached query (~20 MB for 10K queries) +- Compared to old architecture: Saves ~105 MB by eliminating redundant lookup table + +### Privacy & Security + +- ✅ **No third-party APIs**: All embedding generation happens on your infrastructure +- ✅ **Self-hosted models**: Uses open-source nomic-embed-text-v1.5 +- ✅ **Query privacy**: Search queries never leave the servers +- ✅ **Cache encryption**: Database cache uses standard PostgreSQL security +- ✅ **Ephemeral processing**: Query embeddings computed transiently (not logged) + +### Monitoring & Maintenance + +**Check embedding service status:** +```bash +curl http://localhost:3000/api/health +``` + +**Monitor cache performance:** +```sql +-- PostgreSQL +SELECT + COUNT(*) as total_queries, + AVG(access_count) as avg_accesses, + MAX(access_count) as most_popular_count +FROM embedding_cache; + +-- Top 20 most popular queries +SELECT query, access_count, accessed_at +FROM embedding_cache +ORDER BY access_count DESC +LIMIT 20; +``` + +**Clean up old cache entries** (run periodically): +```bash +# Via API (requires auth) +curl -X POST http://localhost:3000/api/admin/cache-cleanup \ + -H "Authorization: Bearer $ADMIN_SECRET" + +# Or manually in PostgreSQL +DELETE FROM embedding_cache +WHERE accessed_at < NOW() - INTERVAL '90 days' + OR id IN ( + SELECT id FROM embedding_cache + ORDER BY accessed_at ASC + LIMIT (SELECT COUNT(*) - 10000 FROM embedding_cache) + ); +``` + +### Troubleshooting + +**Slow first search after deployment:** +- Model is loading (~5-10s). Health check at `/api/health` warms it up automatically. + +**"Vector dimension mismatch" errors:** +- Ensure you used `--dimensions 512` when generating embeddings +- Check migration created `vector(512)` column (not `vector(768)`) + +**Embeddings not found:** +- Verify schema applied: `\d study_embeddings` should show table exists +- Check embeddings generated: `SELECT COUNT(*) FROM study_embeddings;` + +**Poor search quality:** +- Semantic search only works with PostgreSQL + pgvector (SQLite falls back to keyword search) +- Ensure HNSW index created: `\d+ study_embeddings` should show `idx_study_embeddings_embedding` + +## Premium Features & Payments + +GWASifier offers premium features including LLM-powered genetic analysis chat, Run All analysis, and comprehensive reports. + +### Dual Payment System + +The app supports **two payment methods** for premium subscriptions: + +#### 1. Credit/Debit Card Payments (Stripe) - Recurring Subscription +- **Fixed $4.99/month** - Standard recurring subscription +- **Auto-renewal** - Automatically renews monthly +- **Instant activation** - Subscription activates immediately +- **Managed via Stripe** - Cancel anytime through customer portal +- **Setup**: See `STRIPE_INTEGRATION.md` for detailed instructions + +#### 2. Stablecoin Payments (Blockchain) - Flexible Prepaid +- **Flexible amounts** - Pay any amount ($1+ USD equivalent) +- **One-time payment** - No auto-renewal, top up when needed +- **Supported chains**: Ethereum, Base (recommended), Arbitrum, Optimism +- **Accepted tokens**: ETH and USDC +- **Examples**: $4.99 = 30 days, $10 = 60 days, $50 = 300 days +- **Setup**: See `STABLECOIN_PAYMENTS.md` for detailed instructions + +**Key Difference:** +- **Stripe**: Fixed recurring subscription ($4.99/month, auto-renews) +- **Stablecoin**: Flexible prepaid (choose your amount, no auto-renewal) + +**Payment Stacking:** +Users can combine both! Subscribe with card for recurring billing, then add extra months with stablecoin payments as needed. + +**See `PAYMENT_METHODS.md` for detailed comparison.** + +### How It Works + +**Stripe Card Payments (Recurring):** +1. User logs in via Dynamic.xyz (for identity) +2. User selects "Pay with Card" (fixed $4.99/month) +3. Redirected to Stripe Checkout for secure payment +4. Subscription created, payments recorded in PostgreSQL +5. Auto-renews monthly, cancel anytime via Stripe portal + +**Blockchain Stablecoin Payments:** +1. User logs in via Dynamic.xyz +2. User sends ETH or USDC to payment wallet from connected wallet +3. App queries Alchemy indexer to find all payments from user's wallet +4. App uses Alchemy Prices API to get historical prices at transaction time +5. App calculates subscription: `days = (amountUSD / 4.99) * 30` + +**Combined Subscription:** +- Both payment sources are checked and combined +- Total days = blockchain days + Stripe days +- Subscription status cached in localStorage for 1 hour + +### Setup + +See detailed setup guides: +- **Stripe Payments**: `STRIPE_INTEGRATION.md` +- **Stablecoin Payments**: `STABLECOIN_PAYMENTS.md` + +**Quick Start:** +```bash +# Set required environment variables in .env.local +ALCHEMY_API_KEY=your_alchemy_api_key +NEXT_PUBLIC_EVM_PAYMENT_WALLET_ADDRESS=0xYourWalletAddress +NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your_dynamic_environment_id ``` -These indexes can improve query performance by 10-100x for search operations. See `sql/README.md` for detailed instructions. +### Benefits + +- ✅ Zero infrastructure costs (no database) +- ✅ No personal data storage (GDPR-friendly) +- ✅ Transparent (all payments verifiable on-chain) +- ✅ Flexible (users can top up anytime) +- ✅ Free tier API usage sufficient for 5,000+ daily active users ## License diff --git a/STABLECOIN_PAYMENTS.md b/STABLECOIN_PAYMENTS.md new file mode 100644 index 0000000..4c608d6 --- /dev/null +++ b/STABLECOIN_PAYMENTS.md @@ -0,0 +1,255 @@ +# Stablecoin Payment System (EVM Chains) + +## Overview + +GWASifier Premium now uses a **database-free, stablecoin-based payment system**. Users pay with stablecoins (USDC, USDT, or DAI) from their connected wallet, and subscription status is verified on-chain using Alchemy's indexer API. + +## How It Works + +### User Flow +1. User connects wallet via Dynamic.xyz +2. User navigates to Premium tab +3. User selects stablecoin (USDC, USDT, or DAI) and sends payment to payment wallet +4. After blockchain confirmation (~1-2 minutes), subscription activates automatically +5. Subscription status is cached in localStorage for 1 hour + +### Technical Architecture + +``` +┌─────────────┐ +│ User │ Connects wallet (Dynamic.xyz) +│ Wallet │ Sends stablecoin (USDC/USDT/DAI) to payment wallet +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ Blockchain │ Ethereum, Base, Arbitrum, Optimism, Polygon +│ Transaction │ Stablecoin transfer +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ Alchemy │ Indexer API queries transaction history +│ Indexer │ getAssetTransfers(from: userWallet, to: paymentWallet) +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│Subscription │ Calculate total days purchased +│ Calculator │ days = (amountUSD / $4.99) * 30 +│ │ (stablecoins assumed 1:1 with USD) +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ localStorage│ Cache subscription status for 1 hour +│ Cache │ Reduce API calls by 80%+ +└─────────────┘ +``` + +## Setup Instructions + +### 1. Environment Variables + +Copy `.env.local.example` to `.env.local` and configure: + +```bash +# Required: Alchemy API key (free tier: 300M compute units/month) +ALCHEMY_API_KEY=your_alchemy_api_key + +# Required: Payment wallet address (same across all chains) +# NEXT_PUBLIC_ prefix needed because it's displayed in the browser UI +NEXT_PUBLIC_# NEXT_PUBLIC_ prefix needed because it's displayed in the browser UI + +# Optional: Alchemy Prices API Pro API key (free tier usually sufficient) +# COINGECKO_API_KEY=your_alchemy-prices_pro_api_key + +# Optional: Cache duration in hours (default: 1) +NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS=1 + +# Required: Dynamic.xyz for wallet connection +NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your_dynamic_environment_id +``` + +### 2. Supported Chains + +The system supports 5 EVM chains: +- **Ethereum** +- **Base** +- **Arbitrum** +- **Optimism** +- **Polygon** + +**USDC**, **USDT**, and **DAI** are accepted on all chains. + +### 3. Pricing Model + +``` +Payment Amount (USD) = Subscription Days +------------------------------------------- +$4.99 = 30 days (1 month) +$9.98 = 60 days (2 months) +$2.50 = 15 days (~2 weeks) +$10.00 = 60 days (2 months) +$1.00 = 6 days (minimum) +``` + +Formula: `days = (amountUSD / 4.99) * 30` + +Payments are cumulative - users can top up anytime. + +## API Endpoints + +### Check Subscription Status + +**POST** `/api/check-subscription` + +Request: +```json +{ + "walletAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb" +} +``` + +Response: +```json +{ + "success": true, + "subscription": { + "isActive": true, + "expiresAt": "2025-02-15T10:30:00.000Z", + "daysRemaining": 45, + "totalDaysPurchased": 60, + "totalPaid": 9.98, + "paymentCount": 2 + } +} +``` + +## Caching Strategy + +### localStorage Cache +- **Key**: `subscription_{walletAddress}` +- **Duration**: 1 hour (configurable via `NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS`) +- **Cache hit rate**: ~80-90% (users don't refresh constantly) + +### In-Memory Price Cache +- **Alchemy Prices API historical prices** are cached in memory for 24 hours +- Prices don't change after the fact, so cache can be long-lived +- Reduces Alchemy Prices API API calls by ~95% + +### Performance +- **Cache hit**: <1ms (localStorage read) +- **Cache miss**: 2-5 seconds (Alchemy + Alchemy Prices API API calls) +- **Average**: <100ms (with 80% cache hit rate) + +## API Rate Limits & Costs + +### Free Tier (Sufficient for Initial Launch) + +| Service | Free Tier | Usage Pattern | Sufficient For | +|---------|-----------|---------------|----------------| +| **Alchemy** | 300M compute units/month | ~2-5 CU per subscription check | ~5,000 daily active users | +| **Alchemy Prices API** | 10-30 calls/minute | ~0.5 calls per new payment | ~20k payments/month | + +### Upgrade Path + +**Alchemy Growth ($49/mo)** +- 1.5B compute units/month +- Handles ~25,000 daily active users + +**Alchemy Prices API Pro ($129/mo)** +- 500 calls/minute +- Only needed if processing >100k payments/month + +## Benefits Over Database Approach + +1. **No infrastructure costs** - No PostgreSQL hosting fees +2. **No maintenance** - No database backups, migrations, or scaling +3. **Zero personal data** - No GDPR/privacy concerns +4. **Transparent** - All subscription data verifiable on-chain +5. **Stateless** - Easy to scale horizontally +6. **Simpler codebase** - ~1,000 lines of code removed +7. **No price volatility** - Stablecoins maintain 1:1 USD peg + +## Security Considerations + +### What's Secure +- ✅ Payment wallet is view-only (users can verify balance) +- ✅ Subscription calculation is deterministic (same for all nodes) +- ✅ No personal data stored (just wallet addresses) +- ✅ localStorage cache can't be exploited (backend verifies on API calls) + +### What to Monitor +- 🔍 Watch payment wallet balance and withdraw regularly +- 🔍 Monitor Alchemy API usage to avoid rate limits +- 🔍 Set up alerts for unusual payment patterns + +## Troubleshooting + +### Subscription not activating after payment + +1. **Check transaction confirmed**: View on block explorer (Etherscan, Basescan, etc.) +2. **Verify payment amount**: Minimum $1 USD +3. **Verify sender wallet**: Must match connected wallet in app +4. **Clear localStorage cache**: Force refresh with `localStorage.removeItem('subscription_...')` +5. **Check Alchemy API key**: Ensure valid and not rate-limited + +### "Failed to check subscription" error + +1. **Check API keys**: Alchemy API key must be valid +2. **Check wallet address format**: Must be valid EVM address (0x...) +3. **Check network connectivity**: API calls may be timing out +4. **Check browser console**: Look for specific error messages + +### Cache not updating after payment + +- **Solution**: Call `refreshSubscription()` from AuthProvider +- **Alternative**: Wait 1 hour for automatic cache expiration +- **Manual**: Clear localStorage: `localStorage.clear()` + +## Testing + +### Testnet Testing (Recommended) + +1. Get testnet stablecoins from faucets (Sepolia, Base Sepolia, etc.) +2. Configure testnet payment wallet in `.env.local` +3. Send test transaction +4. Verify subscription activates + +### Mainnet Testing (Small Amount) + +1. Send $1 worth of USDC, USDT, or DAI from connected wallet +2. Wait ~2 minutes for blockchain confirmation +3. Refresh page to check subscription status +4. Verify 6 days added to subscription + +## Migration from Old System + +If you were using the old Paddle + Database system: + +1. **No data migration needed** - Old subscriptions won't transfer +2. **Remove database**: Drop payment tables (`users`, `subscriptions`, `payments`, `webhook_events`) +3. **Remove environment variables**: Remove all `PADDLE_*` and `POSTGRES_URL` (payment-related) +4. **Deploy new code**: The system is now 100% blockchain-based + +## Future Enhancements + +Potential improvements: +- [ ] Add email notifications when subscription expires +- [ ] Add QR code for mobile wallet payments +- [ ] Support more chains (Polygon, Avalanche, etc.) +- [ ] Add token price charts in UI +- [ ] Add refund mechanism (for accidental overpayments) + +## Support + +For payment issues: +- Check blockchain explorer for transaction status +- Verify payment wallet address is correct +- Contact support with transaction hash + +For technical issues: +- Check browser console for errors +- Verify API keys are configured +- Review server logs for detailed error messages diff --git a/STRIPE_INTEGRATION.md b/STRIPE_INTEGRATION.md new file mode 100644 index 0000000..b7158cc --- /dev/null +++ b/STRIPE_INTEGRATION.md @@ -0,0 +1,335 @@ +# Stripe Integration Documentation + +## Overview + +Stripe integration uses **direct API queries** to check subscription status, similar to how blockchain payments are verified via Alchemy API. This approach eliminates the need for webhooks, database storage, and complex synchronization logic. + +## Architecture + +``` +User subscribes → Stripe manages subscription → App queries Stripe API → Verify active subscription +``` + +**Key Benefits:** +- ✅ No webhooks to configure or debug +- ✅ No database tables for payment storage +- ✅ Always up-to-date (Stripe is source of truth) +- ✅ Consistent with blockchain payment verification +- ✅ Simple and reliable + +## Environment Variables + +Add these to `.env.local`: + +```bash +# Stripe API Keys +STRIPE_SECRET_KEY=sk_test_your_secret_key_here +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here + +# Stripe Product Configuration +STRIPE_PRICE_ID=price_your_recurring_price_id_here + +# Note: STRIPE_WEBHOOK_SECRET is NOT needed for direct query approach +``` + +## How It Works + +### 1. Subscription Creation Flow + +When a user subscribes: + +1. User logs in with wallet (Dynamic.xyz) +2. Frontend calls `/api/stripe/create-subscription` with `walletAddress` +3. Backend creates Stripe customer with `walletAddress` in metadata +4. Backend creates Stripe subscription with `walletAddress` in metadata +5. Frontend shows Stripe Payment Element for user to complete payment +6. Stripe handles recurring billing automatically + +**File:** `app/api/stripe/create-subscription/route.ts` + +### 2. Subscription Verification Flow + +When checking if user has access: + +1. User logs in with wallet +2. Frontend calls `/api/check-subscription` with `walletAddress` +3. Backend queries Stripe API: Search for customers with matching `walletAddress` in metadata +4. Backend checks if customer has active subscription +5. Returns subscription status (active/inactive, expiration date, days remaining) + +**Files:** +- `lib/subscription-manager.ts` - `checkStripeSubscription()` +- `app/api/check-subscription/route.ts` - API endpoint + +### 3. Combined Subscription Check + +The app supports both Stripe (card) and blockchain (crypto) payments: + +```typescript +// Queries both Stripe API and Alchemy API in parallel +const subscription = await checkCombinedSubscription(walletAddress); + +// Returns whichever subscription expires latest +// User has access if EITHER subscription is active +``` + +**File:** `lib/subscription-manager.ts` - `checkCombinedSubscription()` + +## API Endpoints + +### Create Subscription + +**POST** `/api/stripe/create-subscription` + +```bash +curl -X POST http://localhost:3000/api/stripe/create-subscription \ + -H "Content-Type: application/json" \ + -d '{"walletAddress": "0x1234..."}' +``` + +**Response:** +```json +{ + "success": true, + "subscriptionId": "sub_...", + "clientSecret": "pi_..._secret_..." +} +``` + +### Check Subscription + +**POST** `/api/check-subscription` + +```bash +curl -X POST http://localhost:3000/api/check-subscription \ + -H "Content-Type: application/json" \ + -d '{"walletAddress": "0x1234..."}' +``` + +**Response:** +```json +{ + "success": true, + "subscription": { + "isActive": true, + "expiresAt": "2025-12-15T00:00:00.000Z", + "daysRemaining": 30, + "totalDaysPurchased": 30, + "totalPaid": 4.99, + "paymentCount": 1 + } +} +``` + +## Code Structure + +### Frontend Components + +**Payment Modal** - `app/components/PaymentModal.tsx` +- Shows subscription options (crypto vs card) +- Handles Stripe subscription creation +- Displays Stripe Payment Element + +**Stripe Form** - `app/components/StripeSubscriptionForm.tsx` +- Loads Stripe.js +- Renders Payment Element +- Handles payment confirmation + +**Auth Provider** - `app/components/AuthProvider.tsx` +- Checks subscription status on login +- Caches subscription status for 1 hour +- Provides `hasActiveSubscription` to components + +### Backend Logic + +**Subscription Manager** - `lib/subscription-manager.ts` +- `checkStripeSubscription()` - Queries Stripe API directly +- `checkCombinedSubscription()` - Merges Stripe + blockchain payments + +**API Routes:** +- `app/api/stripe/create-subscription/route.ts` - Creates subscription +- `app/api/check-subscription/route.ts` - Verifies subscription status + +## Testing + +### Local Development + +1. Use Stripe test mode keys: + ```bash + STRIPE_SECRET_KEY=sk_test_... + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... + ``` + +2. Test card numbers: + - Success: `4242 4242 4242 4242` + - Decline: `4000 0000 0000 0002` + - Requires authentication: `4000 0025 0000 3155` + +3. Any future expiry date and CVC will work + +### Testing Subscription Flow + +1. Start dev server: `npm run dev` +2. Log in with wallet +3. Click "Subscribe to Premium" +4. Choose "Pay with Card" +5. Enter test card: `4242 4242 4242 4242` +6. Complete payment +7. Verify subscription shows as active + +### Testing API Directly + +```bash +# Check subscription status +curl -X POST http://localhost:3000/api/check-subscription \ + -H "Content-Type: application/json" \ + -d '{"walletAddress": "YOUR_WALLET_ADDRESS"}' +``` + +## Stripe Dashboard + +### Create Price + +1. Go to https://dashboard.stripe.com/test/products +2. Click "Add product" +3. Name: "Premium Subscription" +4. Price: $4.99 +5. Billing period: Monthly +6. Click "Save product" +7. Copy the Price ID (starts with `price_...`) +8. Add to `.env.local` as `STRIPE_PRICE_ID` + +### View Subscriptions + +1. Go to https://dashboard.stripe.com/test/subscriptions +2. Click a subscription to view details +3. Check "Metadata" tab for `walletAddress` + +### View Customers + +1. Go to https://dashboard.stripe.com/test/customers +2. Click a customer to view details +3. Check "Metadata" tab for `walletAddress` + +## Rate Limits + +Stripe API rate limits: +- **Test mode:** 100 requests/second +- **Live mode:** 100 requests/second + +Subscription checks are cached for 1 hour in localStorage, so rate limits are rarely reached. + +## Production Deployment + +### Pre-deployment Checklist + +- [ ] Switch to **live** Stripe keys (not test keys) +- [ ] Update `STRIPE_SECRET_KEY` in production environment +- [ ] Update `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` in production environment +- [ ] Update `STRIPE_PRICE_ID` to use live price (not test price) +- [ ] Test subscription creation in production +- [ ] Test subscription verification in production +- [ ] Verify recurring billing works correctly + +### Environment Variables + +Production `.env.local`: +```bash +# Use LIVE keys +STRIPE_SECRET_KEY=sk_live_... +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... +STRIPE_PRICE_ID=price_... # Must be from live mode +``` + +## Security + +### Wallet Address Association + +- Wallet addresses are stored in Stripe customer and subscription metadata +- Addresses are normalized to lowercase for consistency +- No blockchain transactions required for card payments +- User proves wallet ownership via Dynamic.xyz authentication + +### API Keys + +- `STRIPE_SECRET_KEY` must NEVER be exposed to client +- Only used in server-side API routes +- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` is safe to expose (by design) + +### Subscription Verification + +- Subscription status queried directly from Stripe +- Cannot be spoofed or manipulated client-side +- User must prove wallet ownership to access subscription benefits + +## Troubleshooting + +### "No active subscription found" + +**Possible causes:** +1. Subscription not created yet +2. Subscription cancelled or expired +3. Payment failed +4. Wrong wallet address + +**Debug:** +```bash +# Check Stripe for subscriptions with this wallet address +# Dashboard → Customers → Search metadata for walletAddress +``` + +### "Failed to check Stripe subscription" + +**Possible causes:** +1. `STRIPE_SECRET_KEY` not configured +2. Stripe API error +3. Network timeout + +**Debug:** +Check server console logs for detailed error message + +### Multiple subscriptions for same wallet + +This is supported! The system uses the subscription with the **latest expiration date**. + +### Subscription not recognized immediately after payment + +Try: +1. Clear localStorage cache: `localStorage.removeItem('subscription_YOUR_ADDRESS')` +2. Refresh the page +3. Wait a few seconds for Stripe to update + +## Comparison with Blockchain Payments + +| Feature | Stripe (Card) | Blockchain (Crypto) | +|---------|--------------|---------------------| +| **Verification** | Direct Stripe API | Alchemy API indexer | +| **Storage** | Stripe manages | No storage needed | +| **Latency** | ~200-500ms | ~500-1000ms | +| **Rate limits** | 100 req/s | 660 compute units/s | +| **Caching** | 1 hour | 1 hour | +| **Recurring** | Yes (automatic) | No (manual top-ups) | +| **Refunds** | Via Stripe Dashboard | Cannot refund blockchain tx | + +## Migration from Webhook Approach + +If you previously used webhooks + database: + +1. Remove old data: + ```sql + DROP TABLE IF EXISTS stripe_payments; + ``` + +2. Environment variables to remove: + - `STRIPE_WEBHOOK_SECRET` (no longer needed) + - `POSTGRES_DB` (no longer required for Stripe payments) + +3. Existing subscriptions will continue to work automatically via direct API queries + +## Support + +For Stripe integration issues: +1. Check Stripe Dashboard logs +2. Check server console for error messages +3. Verify environment variables are set correctly +4. Review Stripe API documentation: https://stripe.com/docs/api diff --git a/app/api/analyze-study/route.ts b/app/api/analyze-study/route.ts index 120c1de..e8c239a 100644 --- a/app/api/analyze-study/route.ts +++ b/app/api/analyze-study/route.ts @@ -35,7 +35,8 @@ export async function POST(request: NextRequest) { strongest_snp_risk_allele, or_or_beta, ci_text, - study_accession + study_accession, + disease_trait FROM gwas_catalog WHERE ${idCondition} AND snps IS NOT NULL AND snps != '' @@ -49,6 +50,7 @@ export async function POST(request: NextRequest) { or_or_beta: string | null; ci_text: string | null; study_accession: string | null; + disease_trait: string | null; }>(query, [studyId]); if (!study) { @@ -59,11 +61,15 @@ export async function POST(request: NextRequest) { } // Determine effect type from ci_text - // Beta coefficients have "unit" in CI (e.g., "[0.0068-0.0139] unit increase") - // Odds ratios are just numbers (e.g., "[1.08-1.15]") - const isBeta = study.ci_text?.toLowerCase().includes('unit') ?? false; + // Beta coefficients have "increase" or "decrease" in CI text + // e.g., "[NR] unit increase", "[0.0068-0.0139] unit increase", "[112.27-112.33] increase" + // Odds ratios are just numbers: e.g., "[1.08-1.15]" + const ciTextLower = study.ci_text?.toLowerCase() ?? ''; + const isBeta = ciTextLower.includes('increase') || ciTextLower.includes('decrease'); const effectType = isBeta ? 'beta' : 'OR'; + // Debug logging + // Return only study metadata - client will perform the analysis return NextResponse.json({ success: true, diff --git a/app/api/check-subscription/route.ts b/app/api/check-subscription/route.ts new file mode 100644 index 0000000..030862c --- /dev/null +++ b/app/api/check-subscription/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { checkCombinedSubscription } from '@/lib/subscription-manager'; + +export async function POST(request: NextRequest) { + try { + const { walletAddress } = await request.json(); + + if (!walletAddress) { + return NextResponse.json( + { error: 'Wallet address required' }, + { status: 400 } + ); + } + + // Validate wallet address format (basic check) + if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) { + return NextResponse.json( + { error: 'Invalid wallet address format' }, + { status: 400 } + ); + } + + // Query both blockchain (Alchemy) and Stripe payments + // Combined subscription includes payments from both sources + console.log('[Subscription Check] Checking combined subscription (blockchain + Stripe)'); + const subscription = await checkCombinedSubscription(walletAddress); + + return NextResponse.json({ + success: true, + subscription: { + isActive: subscription.isActive, + expiresAt: subscription.expiresAt?.toISOString() || null, + daysRemaining: subscription.daysRemaining, + totalDaysPurchased: subscription.totalDaysPurchased, + totalPaid: subscription.totalPaid, + paymentCount: subscription.payments.length, + }, + }); + } catch (error) { + console.error('Subscription check error:', error); + return NextResponse.json( + { + error: 'Failed to check subscription', + details: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/app/api/embeddings/route.ts b/app/api/embeddings/route.ts new file mode 100644 index 0000000..d80079f --- /dev/null +++ b/app/api/embeddings/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { embeddingService } from "@/lib/embedding-service"; +import { validateOrigin } from "@/lib/origin-validator"; + +/** + * POST /api/embeddings + * Generate embeddings for text strings server-side + * + * Body: { texts: string[] } + * Returns: { embeddings: number[][] } + */ +export async function POST(request: NextRequest) { + // Validate origin + const originError = validateOrigin(request); + if (originError) return originError; + + try { + const body = await request.json(); + const { texts } = body; + + if (!texts || !Array.isArray(texts)) { + return NextResponse.json( + { error: "Invalid request: texts array required" }, + { status: 400 } + ); + } + + if (texts.length === 0) { + return NextResponse.json({ embeddings: [] }); + } + + if (texts.length > 1000) { + return NextResponse.json( + { error: "Too many texts: maximum 1000 per request" }, + { status: 400 } + ); + } + + console.log(`[Embeddings API] Generating embeddings for ${texts.length} texts...`); + const startTime = Date.now(); + + // Generate embeddings in parallel + const embeddings = await Promise.all( + texts.map(async (text) => { + try { + return await embeddingService.embed(text); + } catch (error) { + console.error(`[Embeddings API] Failed to generate embedding for: "${text}"`, error); + return null; + } + }) + ); + + const elapsed = Date.now() - startTime; + const successCount = embeddings.filter(e => e !== null).length; + console.log(`[Embeddings API] Generated ${successCount}/${texts.length} embeddings in ${elapsed}ms`); + + return NextResponse.json({ embeddings }); + } catch (error) { + console.error("[Embeddings API] Error:", error); + return NextResponse.json( + { error: "Failed to generate embeddings" }, + { status: 500 } + ); + } +} diff --git a/app/api/fetch-embeddings/route.ts b/app/api/fetch-embeddings/route.ts new file mode 100644 index 0000000..34259f8 --- /dev/null +++ b/app/api/fetch-embeddings/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import { validateOrigin } from "@/lib/origin-validator"; + +/** + * POST /api/fetch-embeddings + * Fetch pre-computed embeddings from PostgreSQL by composite keys + * + * Body: { keys: Array<{ study_accession: string, snps: string, strongest_snp_risk_allele: string }> } + * Returns: { embeddings: Array<{ key: string, embedding: number[] | null }> } + */ +export async function POST(request: NextRequest) { + // Validate origin + const originError = validateOrigin(request); + if (originError) return originError; + + try { + const body = await request.json(); + const { keys } = body; + + if (!keys || !Array.isArray(keys)) { + return NextResponse.json( + { error: "Invalid request: keys array required" }, + { status: 400 } + ); + } + + if (keys.length === 0) { + return NextResponse.json({ embeddings: [] }); + } + + if (keys.length > 1000) { + return NextResponse.json( + { error: "Too many keys: maximum 1000 per request" }, + { status: 400 } + ); + } + + console.log(`[Fetch Embeddings API] Fetching ${keys.length} embeddings from PostgreSQL...`); + const startTime = Date.now(); + + // Build WHERE clause for batch lookup + const conditions = keys.map((_, i) => + `(study_accession = $${i * 3 + 1} AND snps = $${i * 3 + 2} AND strongest_snp_risk_allele = $${i * 3 + 3})` + ).join(' OR '); + + const params = keys.flatMap((k: any) => [k.study_accession, k.snps, k.strongest_snp_risk_allele]); + + const query = ` + SELECT study_accession, snps, strongest_snp_risk_allele, embedding + FROM study_embeddings + WHERE ${conditions} + `; + + const db = getDb(); + if (db.type !== 'postgres' || !db.postgres) { + return NextResponse.json( + { error: "Embeddings are only supported with PostgreSQL" }, + { status: 503 } + ); + } + + const result = await db.postgres.query(query, params); + + // Create lookup map + const embeddingMap = new Map(); + for (const row of result.rows) { + const key = `${row.study_accession}|${row.snps}|${row.strongest_snp_risk_allele}`; + embeddingMap.set(key, row.embedding); + } + + // Return embeddings in same order as keys + const embeddings = keys.map((k: any) => { + const key = `${k.study_accession}|${k.snps}|${k.strongest_snp_risk_allele}`; + return { + key, + embedding: embeddingMap.get(key) || null + }; + }); + + const elapsed = Date.now() - startTime; + const foundCount = embeddings.filter(e => e.embedding !== null).length; + console.log(`[Fetch Embeddings API] Found ${foundCount}/${keys.length} embeddings in ${elapsed}ms`); + + return NextResponse.json({ embeddings }); + } catch (error) { + console.error("[Fetch Embeddings API] Error:", error); + return NextResponse.json( + { error: "Failed to fetch embeddings" }, + { status: 500 } + ); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..ded21b4 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,44 @@ +/** + * Health check endpoint + * + * - Checks if app is running + * - Warms up embedding model on startup (prevents cold start delays) + * - Used by DO App Platform for readiness checks + */ + +import { NextResponse } from 'next/server'; +import { embeddingService } from '@/lib/embedding-service'; + +export async function GET() { + try { + // Initialize embedding model (no-op if already loaded) + await embeddingService.initialize(); + + const info = embeddingService.getInfo(); + + return NextResponse.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + embedding: { + modelLoaded: info.ready, + modelName: info.modelName, + dimensions: info.dimensions, + quantized: info.quantized, + }, + }); + } catch (error) { + console.error('[Health] Model initialization error:', error); + + return NextResponse.json( + { + status: 'warming', + timestamp: new Date().toISOString(), + embedding: { + modelLoaded: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + }, + { status: 503 } + ); + } +} diff --git a/app/api/nilai-delegation/route.ts b/app/api/nilai-delegation/route.ts index 1b70004..69a4830 100644 --- a/app/api/nilai-delegation/route.ts +++ b/app/api/nilai-delegation/route.ts @@ -99,7 +99,7 @@ export async function POST(request: NextRequest) { const server = new DelegationTokenServer(apiKey, { nilauthInstance: NilAuthInstance.PRODUCTION, expirationTime: 600, // 10 minutes validity - tokenMaxUses: 1 // Single use for privacy + tokenMaxUses: 1 // Single use for privacy (fresh token fetched per message) }); // Generate delegation token diff --git a/app/api/similar-studies/route.ts b/app/api/similar-studies/route.ts new file mode 100644 index 0000000..3ab57e5 --- /dev/null +++ b/app/api/similar-studies/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import { validateOrigin } from "@/lib/origin-validator"; +import { embeddingService } from "@/lib/embedding-service"; + +/** + * POST /api/similar-studies + * Find studies most similar to a query using vector similarity search + * + * Body: { query: string, limit: number } + * Returns: { studies: Array<{ study_accession, snps, strongest_snp_risk_allele, similarity }> } + */ +export async function POST(request: NextRequest) { + // Validate origin + const originError = validateOrigin(request); + if (originError) return originError; + + try { + const body = await request.json(); + const { query, limit = 500 } = body; + + if (!query || typeof query !== 'string') { + return NextResponse.json( + { error: "Invalid request: query string required" }, + { status: 400 } + ); + } + + if (limit > 50000) { + return NextResponse.json( + { error: "Limit cannot exceed 50000" }, + { status: 400 } + ); + } + + console.log(`[Similar Studies API] Finding ${limit} studies similar to: "${query}"`); + const startTime = Date.now(); + + // Generate embedding for query + const queryEmbedding = await embeddingService.embed(query); + + // Get database connection + const dbConn = getDb(); + + if (dbConn.type !== 'postgres' || !dbConn.postgres) { + return NextResponse.json( + { error: "Vector similarity search requires PostgreSQL" }, + { status: 503 } + ); + } + + // Use PostgreSQL's vector similarity search with HNSW index + // Strategy: Aggregate at study level to avoid missing SNPs due to gene context in embeddings + // For each study, we take the MAX similarity across all its SNPs + const sqlQuery = ` + WITH ranked_studies AS ( + SELECT + study_accession, + MAX(1 - (embedding <=> $1::vector)) as max_similarity + FROM study_embeddings + GROUP BY study_accession + ORDER BY max_similarity DESC + LIMIT $2 + ) + SELECT DISTINCT + rs.study_accession, + rs.max_similarity as similarity + FROM ranked_studies rs + ORDER BY rs.max_similarity DESC + `; + + const result = await dbConn.postgres.query(sqlQuery, [JSON.stringify(queryEmbedding), limit]); + + const elapsed = Date.now() - startTime; + console.log(`[Similar Studies API] Found ${result.rows.length} similar studies in ${elapsed}ms`); + + return NextResponse.json({ + studies: result.rows, + query_embedding_dims: queryEmbedding.length + }); + } catch (error) { + console.error("[Similar Studies API] Error:", error); + return NextResponse.json( + { error: "Failed to find similar studies" }, + { status: 500 } + ); + } +} diff --git a/app/api/stripe/cancel-subscription/route.ts b/app/api/stripe/cancel-subscription/route.ts new file mode 100644 index 0000000..5b0c8cb --- /dev/null +++ b/app/api/stripe/cancel-subscription/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', { + apiVersion: '2025-02-24.acacia', +}); + +export async function POST(request: NextRequest) { + try { + // Get wallet address from the authenticated user (would typically come from session/auth) + // For now, we'll require it in the request body + const body = await request.json().catch(() => ({})); + const { walletAddress } = body; + + if (!walletAddress) { + return NextResponse.json( + { error: 'Wallet address required' }, + { status: 400 } + ); + } + + const normalizedAddress = walletAddress.toLowerCase(); + + // Find all customers with this wallet address + const customers = await stripe.customers.search({ + query: `metadata['walletAddress']:'${normalizedAddress}'`, + limit: 100, + }); + + if (customers.data.length === 0) { + return NextResponse.json( + { error: 'No subscription found for this wallet address' }, + { status: 404 } + ); + } + + // Find all active subscriptions for these customers + const cancelledSubscriptions: string[] = []; + + for (const customer of customers.data) { + const subscriptions = await stripe.subscriptions.list({ + customer: customer.id, + status: 'active', + limit: 100, + }); + + // Cancel all active subscriptions (cancel at period end) + for (const subscription of subscriptions.data) { + const updated = await stripe.subscriptions.update(subscription.id, { + cancel_at_period_end: true, + }); + cancelledSubscriptions.push(subscription.id); + console.log(`[Stripe] Cancelled subscription: ${subscription.id} for wallet ${walletAddress}`, { + cancel_at_period_end: updated.cancel_at_period_end, + current_period_end: new Date(updated.current_period_end * 1000).toISOString(), + status: updated.status, + }); + } + } + + if (cancelledSubscriptions.length === 0) { + return NextResponse.json( + { error: 'No active subscriptions found to cancel' }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + message: `${cancelledSubscriptions.length} subscription(s) will be cancelled at the end of the billing period`, + cancelledSubscriptions, + }); + } catch (error: any) { + console.error('[Stripe] Subscription cancellation error:', error); + + return NextResponse.json( + { + error: 'Failed to cancel subscription', + details: error.message || 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/app/api/stripe/create-checkout/route.ts b/app/api/stripe/create-checkout/route.ts new file mode 100644 index 0000000..331b9f2 --- /dev/null +++ b/app/api/stripe/create-checkout/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', { + apiVersion: '2025-02-24.acacia', +}); + +const DAYS_PER_MONTH = 30; + +export async function POST(request: NextRequest) { + try { + const { walletAddress } = await request.json(); + + // Validate inputs + if (!walletAddress) { + return NextResponse.json( + { error: 'Wallet address required' }, + { status: 400 } + ); + } + + // Validate wallet address format + if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) { + return NextResponse.json( + { error: 'Invalid wallet address format' }, + { status: 400 } + ); + } + + // Validate Stripe configuration + if (!process.env.STRIPE_SECRET_KEY) { + return NextResponse.json( + { error: 'Stripe is not configured' }, + { status: 500 } + ); + } + + if (!process.env.STRIPE_PRICE_ID) { + return NextResponse.json( + { error: 'Stripe price is not configured. Set STRIPE_PRICE_ID environment variable.' }, + { status: 500 } + ); + } + + // Get the base URL for redirect + const origin = request.headers.get('origin') || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + + // Create Stripe Checkout Session for subscription + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + mode: 'subscription', // Recurring subscription + line_items: [ + { + price: process.env.STRIPE_PRICE_ID, // Reference existing price from Stripe Dashboard + quantity: 1, + }, + ], + success_url: `${origin}/payment/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/payment/cancel`, + metadata: { + walletAddress: walletAddress.toLowerCase(), + }, + subscription_data: { + metadata: { + walletAddress: walletAddress.toLowerCase(), + }, + }, + customer_email: undefined, // Optional: can be pre-filled if user provides email + }); + + console.log(`[Stripe] Created subscription checkout session: ${session.id} for wallet ${walletAddress}`); + + return NextResponse.json({ + success: true, + sessionId: session.id, + checkoutUrl: session.url, + }); + } catch (error: any) { + console.error('Stripe checkout creation error:', error); + + return NextResponse.json( + { + error: 'Failed to create checkout session', + details: error.message || 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/app/api/stripe/create-subscription/route.ts b/app/api/stripe/create-subscription/route.ts new file mode 100644 index 0000000..7b5340f --- /dev/null +++ b/app/api/stripe/create-subscription/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', { + apiVersion: '2025-02-24.acacia', +}); + +export async function POST(request: NextRequest) { + try { + const { walletAddress } = await request.json(); + + // Validate inputs + if (!walletAddress) { + return NextResponse.json( + { error: 'Wallet address required' }, + { status: 400 } + ); + } + + // Validate wallet address format + if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) { + return NextResponse.json( + { error: 'Invalid wallet address format' }, + { status: 400 } + ); + } + + // Validate Stripe configuration + if (!process.env.STRIPE_SECRET_KEY) { + return NextResponse.json( + { error: 'Stripe is not configured' }, + { status: 500 } + ); + } + + if (!process.env.STRIPE_PRICE_ID) { + return NextResponse.json( + { error: 'Stripe price is not configured. Set STRIPE_PRICE_ID environment variable.' }, + { status: 500 } + ); + } + + // Create or retrieve customer + // Note: Stripe API doesn't support filtering customers by metadata in list() + // We need to search by email or create a new customer each time + // For this use case, we'll create a new customer per subscription + const customer = await stripe.customers.create({ + metadata: { + walletAddress: walletAddress.toLowerCase(), + }, + }); + + // Create subscription + const subscription = await stripe.subscriptions.create({ + customer: customer.id, + items: [ + { + price: process.env.STRIPE_PRICE_ID, + }, + ], + payment_behavior: 'default_incomplete', + payment_settings: { + save_default_payment_method: 'on_subscription', + }, + expand: ['latest_invoice.payment_intent'], + metadata: { + walletAddress: walletAddress.toLowerCase(), + }, + }); + + const invoice = subscription.latest_invoice as Stripe.Invoice; + const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent; + + console.log(`[Stripe] Created subscription: ${subscription.id} for wallet ${walletAddress}`); + + return NextResponse.json({ + success: true, + subscriptionId: subscription.id, + clientSecret: paymentIntent.client_secret, + }); + } catch (error: any) { + console.error('Stripe subscription creation error:', error); + + return NextResponse.json( + { + error: 'Failed to create subscription', + details: error.message || 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/app/api/studies/route.ts b/app/api/studies/route.ts index 6ef9833..772ace8 100644 --- a/app/api/studies/route.ts +++ b/app/api/studies/route.ts @@ -11,6 +11,7 @@ import { parseSampleSize, QualityFlag, } from "@/lib/parsing"; +import { embeddingService } from "@/lib/embedding-service"; type ConfidenceBand = "high" | "medium" | "low"; @@ -35,6 +36,7 @@ type RawStudy = { risk_allele_frequency: string | null; strongest_snp_risk_allele: string | null; snps: string | null; + similarity?: number; // Semantic search similarity score (0-1) }; type Study = RawStudy & { @@ -235,7 +237,16 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const search = searchParams.get("search")?.trim(); const trait = searchParams.get("trait")?.trim(); - const limit = Math.max(10, Math.min(Number(searchParams.get("limit")) || 75, 200)); + const searchMode = searchParams.get("searchMode") ?? "similarity"; // "similarity" or "exact" + const useSemanticSearch = searchMode === "similarity"; // Use semantic search only for similarity mode + + // Special parameter for "Run All" - fetches all studies with SNPs + const fetchAll = searchParams.get("fetchAll") === "true"; + // Allow larger batches for pagination (up to 100000 for Run All with fetchAll) + const requestedLimit = Number(searchParams.get("limit")) || 75; + const limit = fetchAll ? Math.max(10, Math.min(requestedLimit, 100000)) : Math.max(10, Math.min(requestedLimit, 50000)); + const offset = Math.max(0, Number(searchParams.get("offset")) || 0); + const sort = searchParams.get("sort") ?? "relevance"; const direction = searchParams.get("direction") === "asc" ? "asc" : "desc"; const minSampleSize = parseInteger(searchParams.get("minSampleSize")); @@ -251,66 +262,346 @@ export async function GET(request: NextRequest) { const filters: string[] = []; const params: unknown[] = []; + let orderByClause = ""; + let useSemanticQuery = false; + let queryEmbedding: number[] = []; + + // Semantic search: Generate embedding for query + if (search && useSemanticSearch) { + try { + console.log(`[Semantic Search] Embedding query: "${search}"`); + queryEmbedding = await embeddingService.embed(search); + useSemanticQuery = true; + console.log(`[Semantic Search] Embedding generated (${queryEmbedding.length} dims)`); + console.log(`[Semantic Search] First 10 values:`, queryEmbedding.slice(0, 10)); + } catch (error) { + console.error(`[Semantic Search] Failed to generate embedding:`, error); + // Fall back to keyword search + useSemanticQuery = false; + } + } - if (search) { + // Build search filters + if (search && !useSemanticQuery) { + // Keyword search (fallback or when semantic is disabled) const wildcard = `%${search}%`; filters.push( - "(study LIKE ? OR disease_trait LIKE ? OR mapped_trait LIKE ? OR first_author LIKE ? OR mapped_gene LIKE ? OR study_accession LIKE ?)", + "(gc.study LIKE ? OR gc.disease_trait LIKE ? OR gc.mapped_trait LIKE ? OR gc.first_author LIKE ? OR gc.mapped_gene LIKE ? OR gc.study_accession LIKE ? OR gc.snps LIKE ?)", ); - params.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard); + params.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard, wildcard); + } else if (useSemanticQuery) { + // Semantic search: Use separate study_embeddings table + const dbType = getDbType(); + + if (dbType === 'postgres') { + // PostgreSQL: Semantic search handled in FROM clause subquery + // The subquery already filters and orders by similarity + // Just order by the distance column from the subquery + orderByClause = `ORDER BY se.distance`; + } else { + // SQLite: No native vector support, fall back to keyword search + console.warn(`[Semantic Search] SQLite doesn't support vector similarity, falling back to keyword search`); + const wildcard = `%${search}%`; + filters.push( + "(gc.study LIKE ? OR gc.disease_trait LIKE ? OR gc.mapped_trait LIKE ? OR gc.first_author LIKE ? OR gc.mapped_gene LIKE ? OR gc.study_accession LIKE ? OR gc.snps LIKE ?)", + ); + params.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard, wildcard); + useSemanticQuery = false; + } } if (trait) { - filters.push("(mapped_trait = ? OR disease_trait = ?)"); + filters.push("(gc.mapped_trait = ? OR gc.disease_trait = ?)"); params.push(trait, trait); } - const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : ""; + // For fetchAll, always require SNPs and risk alleles (since we're doing SNP matching) + if (fetchAll) { + filters.push("(gc.snps IS NOT NULL AND gc.snps != '')"); + filters.push("(gc.strongest_snp_risk_allele IS NOT NULL AND gc.strongest_snp_risk_allele != '')"); + } + + // Add backend filters to SQL WHERE clause + const maxPValue = maxPValueRaw ? parsePValue(maxPValueRaw) : null; + const minLogP = minLogPRaw ? Number(minLogPRaw) : null; - // Use appropriate ID selection based on database type + // Get database type first to use appropriate syntax const dbType = getDbType(); + + if (minSampleSize !== null) { + // Filter for minimum sample size + // Check both initial_sample_size and replication_sample_size + // Note: Some values contain text like "1,000 cases, 1,034 controls" + // Extract only the first number to avoid overflow from concatenating multiple numbers + if (dbType === 'postgres') { + // Extract first number with commas, then remove commas + filters.push("((NULLIF(regexp_replace((regexp_match(gc.initial_sample_size, '[0-9,]+'))[1], ',', '', 'g'), '')::numeric >= ?::numeric) OR (NULLIF(regexp_replace((regexp_match(gc.replication_sample_size, '[0-9,]+'))[1], ',', '', 'g'), '')::numeric >= ?::numeric))"); + } else { + filters.push("((CAST(gc.initial_sample_size AS INTEGER) >= ?) OR (CAST(gc.replication_sample_size AS INTEGER) >= ?))"); + } + params.push(minSampleSize, minSampleSize); + } + + if (maxPValue !== null) { + // Filter for maximum p-value + // Use pvalue_mlog to avoid numeric overflow with extreme p-values like 1E-18716 + // Convert: maxPValue = 5e-8 => minLogP = -log10(5e-8) ≈ 7.3 + const minLogPFromMaxP = -Math.log10(maxPValue); + if (dbType === 'postgres') { + filters.push("(gc.pvalue_mlog IS NULL OR gc.pvalue_mlog::numeric >= ?::numeric)"); + } else { + filters.push("(gc.pvalue_mlog IS NULL OR CAST(gc.pvalue_mlog AS REAL) >= ?)"); + } + params.push(minLogPFromMaxP); + } + + if (minLogP !== null) { + // Filter for minimum -log10(p-value) + if (dbType === 'postgres') { + filters.push("gc.pvalue_mlog::numeric >= ?::numeric"); + } else { + filters.push("CAST(gc.pvalue_mlog AS REAL) >= ?"); + } + params.push(minLogP); + } + + if (excludeMissingGenotype) { + // Filter out studies with missing or invalid genotype data + filters.push("(gc.strongest_snp_risk_allele IS NOT NULL AND gc.strongest_snp_risk_allele != '' AND gc.strongest_snp_risk_allele != '?' AND gc.strongest_snp_risk_allele != 'NR' AND gc.strongest_snp_risk_allele NOT LIKE '%?%')"); + } + + const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : ""; // NOTE: hashtext() is a 32-bit non-cryptographic hash with potential collision risk. // For production with high study volumes, consider migrating to a stable UUID column // computed during data ingestion to eliminate collision probability. // Current risk is low given GWAS catalog size (~hundreds of thousands of studies) and // the composite key includes multiple discriminating fields (accession, SNPs, p-value, OR). const idSelection = dbType === 'postgres' - ? 'hashtext(COALESCE(study_accession, \'\') || COALESCE(snps, \'\') || COALESCE(strongest_snp_risk_allele, \'\') || COALESCE(p_value, \'\') || COALESCE(or_or_beta::text, \'\')) AS id' - : 'rowid AS id'; + ? 'hashtext(COALESCE(gc.study_accession, \'\') || COALESCE(gc.snps, \'\') || COALESCE(gc.strongest_snp_risk_allele, \'\') || COALESCE(gc.p_value, \'\') || COALESCE(gc.or_or_beta::text, \'\')) AS id' + : 'gc.rowid AS id'; + + // Calculate rawLimit first (needed for HNSW candidate limit calculation) + // Most filters now run in SQL, so we only need a small buffer for excludeLowQuality and confidenceBand + // These filters are applied post-query in JavaScript + const needsPostFilterBuffer = excludeLowQuality || confidenceBandFilter !== null; + const isRunAllQuery = excludeLowQuality === false && excludeMissingGenotype === false && !search && !trait; + // Use 2x buffer for post-filtering to ensure enough results after JS-side filtering + // Respect user's limit choice (removed arbitrary 200 cap) + const rawLimit = fetchAll ? limit : (needsPostFilterBuffer ? limit * 2 : limit); + + // Build FROM clause - for semantic search, query embeddings table first, then join + // This allows the HNSW index to be used efficiently + let fromClause = 'FROM gwas_catalog gc'; + + if (useSemanticQuery && dbType === 'postgres') { + // Two-stage query for efficient HNSW index usage: + // 1. First: Use HNSW index to get top candidate embeddings (fast!) + // 2. Then: JOIN with gwas_catalog using composite key (study_accession, snps, strongest_snp_risk_allele) + // This prevents full table scans by letting the HNSW index do the heavy lifting + + const vectorLiteral = `'${JSON.stringify(queryEmbedding)}'::vector`; + // Dynamic candidate limit based on user's requested limit and filter strictness + // Use larger multiplier when filters are active to ensure enough results after filtering + const hasStrictFilters = minSampleSize !== null || maxPValue !== null || minLogP !== null || excludeMissingGenotype; + const candidateMultiplier = hasStrictFilters ? 10 : 5; // 10x with filters, 5x without + const hnswCandidateLimit = Math.max(1000, Math.min(rawLimit * candidateMultiplier, 10000)); + + fromClause = `FROM ( + SELECT study_accession, snps, strongest_snp_risk_allele, embedding + FROM study_embeddings + ORDER BY embedding <=> ${vectorLiteral} + LIMIT ${hnswCandidateLimit} + ) se + INNER JOIN gwas_catalog gc ON ( + se.study_accession = gc.study_accession + AND se.snps = gc.snps + AND se.strongest_snp_risk_allele = gc.strongest_snp_risk_allele + )`; + + // Update order by to use embedding distance (ASC = lowest distance first = highest similarity first = descending similarity) + orderByClause = `ORDER BY se.embedding <=> ${vectorLiteral}`; + } + + // Add similarity score for semantic search + const similarityColumn = useSemanticQuery && dbType === 'postgres' + ? `,\n (1 - (se.embedding <=> ${`'${JSON.stringify(queryEmbedding)}'::vector`})) as similarity` + : ''; const baseQuery = `SELECT ${idSelection}, - study_accession, - study, - disease_trait, - mapped_trait, - mapped_trait_uri, - mapped_gene, - first_author, - date, - journal, - pubmedid, - link, - initial_sample_size, - replication_sample_size, - p_value, - pvalue_mlog, - or_or_beta, - risk_allele_frequency, - strongest_snp_risk_allele, - snps - FROM gwas_catalog + gc.study_accession, + gc.study, + gc.disease_trait, + gc.mapped_trait, + gc.mapped_trait_uri, + gc.mapped_gene, + gc.first_author, + gc.date, + gc.journal, + gc.pubmedid, + gc.link, + gc.initial_sample_size, + gc.replication_sample_size, + gc.p_value, + gc.pvalue_mlog, + gc.or_or_beta, + gc.risk_allele_frequency, + gc.strongest_snp_risk_allele, + gc.snps${similarityColumn} + ${fromClause} ${whereClause} - LIMIT ?`; - const rawLimit = Math.min(limit * 4, 800); + ${orderByClause}`; + + // Add LIMIT/OFFSET directly to query for PostgreSQL (safe since rawLimit and offset are validated integers) + // This avoids parameter type inference issues with pg driver + const finalQuery = dbType === 'postgres' + ? `${baseQuery}\n LIMIT ${rawLimit} OFFSET ${offset}` + : `${baseQuery}\n LIMIT ? OFFSET ?`; + + let rawRows: RawStudy[]; try { - const rawRows = await executeQuery(baseQuery, [...params, rawLimit]); + // Log query timing for semantic search debugging + const queryStart = Date.now(); + console.log(`[Query] Starting database query...`); + if (useSemanticQuery) { + console.log(`[Query] Semantic search active, params count: ${params.length}`); + console.log(`[Query] HNSW ef_search is set to 1000 at connection level for better recall`); + } - const maxPValue = maxPValueRaw ? parsePValue(maxPValueRaw) : null; - const minLogP = minLogPRaw ? Number(minLogPRaw) : null; + // DEBUG: Print the actual SQL query + console.log(`[Query] SQL:\n${finalQuery}`); + console.log(`[Query] First 3 params:`, params.slice(0, 3).map(p => typeof p === 'string' && p.length > 100 ? `${p.substring(0, 100)}...` : p)); + + // For PostgreSQL, LIMIT/OFFSET are in the query string; for SQLite, they're in params + rawRows = dbType === 'postgres' + ? await executeQuery(finalQuery, params) + : await executeQuery(finalQuery, [...params, rawLimit, offset]); + + const queryElapsed = Date.now() - queryStart; + console.log(`[Query] Database query completed in ${queryElapsed}ms`); + console.log(`[Query] Raw rows returned: ${rawRows.length}`); + if (queryElapsed > 5000) { + console.warn(`[Query] SLOW QUERY DETECTED: ${queryElapsed}ms - consider database upgrade`); + } + } catch (error: any) { + // If semantic search fails (e.g., study_embeddings table doesn't exist), fall back to keyword search + if (useSemanticQuery && (error?.message?.includes('study_embeddings') || error?.message?.includes('relation') || error?.message?.includes('does not exist'))) { + console.warn(`[Semantic Search] Table not found or query failed, falling back to keyword search:`, error.message); + + // Rebuild query without semantic search, using same filters as main query + const fallbackFilters: string[] = []; + const fallbackParams: any[] = []; + + // Re-add search as keyword search + if (search) { + const wildcard = `%${search}%`; + fallbackFilters.push( + "(gc.study LIKE ? OR gc.disease_trait LIKE ? OR gc.mapped_trait LIKE ? OR gc.first_author LIKE ? OR gc.mapped_gene LIKE ? OR gc.study_accession LIKE ? OR gc.snps LIKE ?)", + ); + fallbackParams.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard, wildcard); + } + + if (trait) { + fallbackFilters.push("(gc.mapped_trait = ? OR gc.disease_trait = ?)"); + fallbackParams.push(trait, trait); + } + + if (fetchAll) { + fallbackFilters.push("(gc.snps IS NOT NULL AND gc.snps != '')"); + fallbackFilters.push("(gc.strongest_snp_risk_allele IS NOT NULL AND gc.strongest_snp_risk_allele != '')"); + } + + // Add the same backend filters as the main query + if (minSampleSize !== null) { + if (dbType === 'postgres') { + // Extract first number with commas, then remove commas + fallbackFilters.push("((NULLIF(regexp_replace((regexp_match(gc.initial_sample_size, '[0-9,]+'))[1], ',', '', 'g'), '')::numeric >= ?::numeric) OR (NULLIF(regexp_replace((regexp_match(gc.replication_sample_size, '[0-9,]+'))[1], ',', '', 'g'), '')::numeric >= ?::numeric))"); + } else { + fallbackFilters.push("((CAST(gc.initial_sample_size AS INTEGER) >= ?) OR (CAST(gc.replication_sample_size AS INTEGER) >= ?))"); + } + fallbackParams.push(minSampleSize, minSampleSize); + } + + if (maxPValue !== null) { + // Use pvalue_mlog to avoid numeric overflow with extreme p-values + const minLogPFromMaxP = -Math.log10(maxPValue); + if (dbType === 'postgres') { + fallbackFilters.push("(gc.pvalue_mlog IS NULL OR gc.pvalue_mlog::numeric >= ?::numeric)"); + } else { + fallbackFilters.push("(gc.pvalue_mlog IS NULL OR CAST(gc.pvalue_mlog AS REAL) >= ?)"); + } + fallbackParams.push(minLogPFromMaxP); + } + + if (minLogP !== null) { + if (dbType === 'postgres') { + fallbackFilters.push("gc.pvalue_mlog::numeric >= ?::numeric"); + } else { + fallbackFilters.push("CAST(gc.pvalue_mlog AS REAL) >= ?"); + } + fallbackParams.push(minLogP); + } + + if (excludeMissingGenotype) { + fallbackFilters.push("(gc.strongest_snp_risk_allele IS NOT NULL AND gc.strongest_snp_risk_allele != '' AND gc.strongest_snp_risk_allele != '?' AND gc.strongest_snp_risk_allele != 'NR' AND gc.strongest_snp_risk_allele NOT LIKE '%?%')"); + } + + const fallbackWhereClause = fallbackFilters.length ? `WHERE ${fallbackFilters.join(" AND ")}` : ""; + + // Build order by based on sort parameter + let fallbackOrderBy = ""; + if (sort === "power") { + fallbackOrderBy = `ORDER BY CAST(gc.initial_sample_size AS INTEGER) ${direction}`; + } else if (sort === "recent") { + fallbackOrderBy = `ORDER BY gc.date ${direction}`; + } else if (sort === "alphabetical") { + fallbackOrderBy = `ORDER BY gc.study ${direction}`; + } else { + // Default: relevance (sort by -log10(p)) + fallbackOrderBy = `ORDER BY CAST(gc.pvalue_mlog AS REAL) ${direction}`; + } + + const fallbackQuery = `SELECT ${idSelection}, + gc.study_accession, + gc.study, + gc.disease_trait, + gc.mapped_trait, + gc.mapped_trait_uri, + gc.mapped_gene, + gc.first_author, + gc.date, + gc.journal, + gc.pubmedid, + gc.link, + gc.initial_sample_size, + gc.replication_sample_size, + gc.p_value, + gc.pvalue_mlog, + gc.or_or_beta, + gc.risk_allele_frequency, + gc.strongest_snp_risk_allele, + gc.snps + FROM gwas_catalog gc + ${fallbackWhereClause} + ${fallbackOrderBy}`; + + const fallbackFinalQuery = dbType === 'postgres' + ? `${fallbackQuery}\n LIMIT ${rawLimit} OFFSET ${offset}` + : `${fallbackQuery}\n LIMIT ? OFFSET ?`; + + rawRows = dbType === 'postgres' + ? await executeQuery(fallbackFinalQuery, fallbackParams) + : await executeQuery(fallbackFinalQuery, [...fallbackParams, rawLimit, offset]); + } else { + // Re-throw if it's a different error + throw error; + } + } + + try { const studies: Study[] = rawRows - .map((row) => { + .map((row, index) => { const sampleSize = parseSampleSize(row.initial_sample_size) ?? parseSampleSize(row.replication_sample_size); const pValueNumeric = parsePValue(row.p_value); const logPValue = parseLogPValue(row.pvalue_mlog) ?? (pValueNumeric ? -Math.log10(pValueNumeric) : null); @@ -333,80 +624,90 @@ export async function GET(request: NextRequest) { } satisfies Study; }) .filter((row) => { - if (minSampleSize && row.sampleSize !== null && row.sampleSize < minSampleSize) { - return false; - } - if (minSampleSize && row.sampleSize === null) { - return false; - } - if (maxPValue !== null && row.pValueNumeric !== null && row.pValueNumeric > maxPValue) { - return false; - } - if (maxPValue !== null && row.pValueNumeric === null) { - return false; - } - if (minLogP !== null && row.logPValue !== null && row.logPValue < minLogP) { - return false; - } - if (minLogP !== null && row.logPValue === null) { - return false; - } + // Note: minSampleSize, maxPValue, minLogP, and excludeMissingGenotype are now handled in SQL + // Only keep filters that require computed fields (excludeLowQuality, confidenceBand) if (excludeLowQuality && row.isLowQuality) { return false; } - if (excludeMissingGenotype) { - if (!row.strongest_snp_risk_allele || - row.strongest_snp_risk_allele.trim().length === 0 || - row.strongest_snp_risk_allele.trim() === '?' || - row.strongest_snp_risk_allele.trim() === 'NR' || - row.strongest_snp_risk_allele.includes('?')) { - return false; - } - } if (confidenceBandFilter && row.confidenceBand !== confidenceBandFilter) { return false; } return true; }); - const countResult = await executeQuerySingle<{ count: number }>(`SELECT COUNT(*) as count FROM gwas_catalog ${whereClause}`, params); - const sourceCount = countResult?.count ?? 0; + // Get source count (may fail if numeric overflow in WHERE clause, that's ok) + let sourceCount = 0; + try { + const countQuery = useSemanticQuery && dbType === 'postgres' + ? `SELECT COUNT(*) as count ${fromClause} ${whereClause}` + : `SELECT COUNT(*) as count FROM gwas_catalog gc ${whereClause}`; + const countResult = await executeQuerySingle<{ count: number }>(countQuery, params); + sourceCount = countResult?.count ?? 0; + } catch (error: any) { + console.warn('[Query] Count query failed:', error?.message || 'Unknown error'); + console.warn('[Query] Using result length as count instead'); + sourceCount = rawRows.length; + } const sortedStudies = [...studies]; const directionFactor = direction === "asc" ? 1 : -1; - switch (sort) { - case "power": - sortedStudies.sort((a, b) => directionFactor * ((a.sampleSize ?? 0) - (b.sampleSize ?? 0))); - break; - case "recent": - sortedStudies.sort((a, b) => { - const aDate = a.publicationDate; - const bDate = b.publicationDate; - if (aDate === null && bDate === null) { - return 0; - } - if (aDate === null) { - return 1; - } - if (bDate === null) { - return -1; - } - return directionFactor * (aDate - bDate); - }); - break; - case "alphabetical": - sortedStudies.sort( - (a, b) => (a.study ?? "").localeCompare(b.study ?? "") * directionFactor, - ); - break; - default: - sortedStudies.sort((a, b) => directionFactor * ((a.logPValue ?? -Infinity) - (b.logPValue ?? -Infinity))); - break; + // Skip client-side sorting if semantic search already ordered by relevance + if (!useSemanticQuery) { + switch (sort) { + case "power": + sortedStudies.sort((a, b) => directionFactor * ((a.sampleSize ?? 0) - (b.sampleSize ?? 0))); + break; + case "recent": + sortedStudies.sort((a, b) => { + const aDate = a.publicationDate; + const bDate = b.publicationDate; + if (aDate === null && bDate === null) { + return 0; + } + if (aDate === null) { + return 1; + } + if (bDate === null) { + return -1; + } + return directionFactor * (aDate - bDate); + }); + break; + case "alphabetical": + sortedStudies.sort( + (a, b) => (a.study ?? "").localeCompare(b.study ?? "") * directionFactor, + ); + break; + default: + sortedStudies.sort((a, b) => directionFactor * ((a.logPValue ?? -Infinity) - (b.logPValue ?? -Infinity))); + break; + } } const finalResults = sortedStudies.slice(0, limit); + // For Run All queries, return minimal payload to avoid JSON serialization limits + if (isRunAllQuery) { + const minimalResults = finalResults.map(s => ({ + id: s.id, + study_accession: s.study_accession, + disease_trait: s.disease_trait, + study: s.study, + snps: s.snps, + strongest_snp_risk_allele: s.strongest_snp_risk_allele, + or_or_beta: s.or_or_beta, + })); + + return NextResponse.json({ + data: minimalResults, + total: studies.length, + limit, + truncated: studies.length > finalResults.length, + sourceCount, + }); + } + return NextResponse.json({ data: finalResults, total: studies.length, diff --git a/app/components/AuthProvider.tsx b/app/components/AuthProvider.tsx new file mode 100644 index 0000000..48c0f28 --- /dev/null +++ b/app/components/AuthProvider.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { DynamicContextProvider, DynamicWidget, useDynamicContext } from '@dynamic-labs/sdk-react-core'; +import { EthereumWalletConnectors } from '@dynamic-labs/ethereum'; +import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react'; + +interface SubscriptionData { + isActive: boolean; + expiresAt: string | null; + daysRemaining: number; + totalDaysPurchased: number; + totalPaid: number; + paymentCount: number; +} + +interface AuthContextType { + isAuthenticated: boolean; + user: any | null; + hasActiveSubscription: boolean; + checkingSubscription: boolean; + subscriptionData: SubscriptionData | null; + refreshSubscription: () => Promise; +} + +const AuthContext = createContext({ + isAuthenticated: false, + user: null, + hasActiveSubscription: false, + checkingSubscription: true, + subscriptionData: null, + refreshSubscription: async () => {}, +}); + +export const useAuth = () => useContext(AuthContext); + +// Inner component to sync Dynamic context with Auth context +function AuthStateSync({ + onAuthStateChange, + onCheckSubscription, +}: { + onAuthStateChange: (isAuth: boolean, user: any) => void; + onCheckSubscription: (walletAddress: string) => Promise; +}) { + const { user: dynamicUser } = useDynamicContext(); + + useEffect(() => { + console.log('[AuthStateSync] Dynamic state:', { + hasUser: !!dynamicUser, + userAddress: dynamicUser?.verifiedCredentials?.[0]?.address + }); + + // If we have a user with a wallet, treat them as authenticated + const isAuth = !!dynamicUser; + + // Sync Dynamic's auth state with our context + onAuthStateChange(isAuth, dynamicUser); + + // If we have a user with wallet address, check subscription + if (dynamicUser) { + const walletAddress = dynamicUser?.verifiedCredentials?.[0]?.address; + if (walletAddress) { + console.log('[AuthStateSync] Checking subscription for wallet:', walletAddress); + onCheckSubscription(walletAddress); + } else { + console.warn('[AuthStateSync] User exists but no wallet address found'); + } + } + }, [dynamicUser, onAuthStateChange, onCheckSubscription]); + + return null; +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [user, setUser] = useState(null); + const [hasActiveSubscription, setHasActiveSubscription] = useState(false); + const [subscriptionData, setSubscriptionData] = useState(null); + const [checkingSubscription, setCheckingSubscription] = useState(true); + + // If environment ID is not set, render without Dynamic (useful for CI/CD builds) + const environmentId = process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID; + const isDynamicEnabled = !!environmentId; + + const checkSubscription = useCallback(async (walletAddress: string) => { + try { + console.log('[AuthProvider] Checking subscription for:', walletAddress); + setCheckingSubscription(true); + + // Query API directly - no caching + const response = await fetch('/api/check-subscription', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ walletAddress }), + }); + + if (!response.ok) { + throw new Error(`API error: ${response.statusText}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'Unknown error'); + } + + const subData = result.subscription; + setHasActiveSubscription(subData.isActive); + setSubscriptionData(subData); + + console.log('[AuthProvider] Subscription check complete:', { + isActive: subData.isActive, + daysRemaining: subData.daysRemaining, + }); + + } catch (error) { + console.error('[AuthProvider] Failed to check subscription:', error); + setHasActiveSubscription(false); + setSubscriptionData(null); + } finally { + setCheckingSubscription(false); + } + }, []); + + const refreshSubscription = async () => { + const walletAddress = user?.verifiedCredentials?.[0]?.address; + if (walletAddress) { + await checkSubscription(walletAddress); + } + }; + + // On mount, if no user is authenticated, stop checking subscription + useEffect(() => { + if (!isAuthenticated && !user) { + setCheckingSubscription(false); + } + }, [isAuthenticated, user]); + + const handleAuthStateChange = useCallback((isAuth: boolean, dynamicUser: any) => { + console.log('[AuthProvider] Auth state changed:', { isAuth, hasUser: !!dynamicUser, userAddress: dynamicUser?.verifiedCredentials?.[0]?.address }); + setIsAuthenticated(isAuth); + setUser(dynamicUser); + + if (!isAuth) { + // User logged out + setHasActiveSubscription(false); + setSubscriptionData(null); + setCheckingSubscription(false); + } + }, []); + + // If Dynamic is not enabled (no environment ID), render children with default auth context + if (!isDynamicEnabled) { + return ( + {}, + }} + > + {children} + + ); + } + + return ( + { + setIsAuthenticated(false); + setUser(null); + setHasActiveSubscription(false); + setSubscriptionData(null); + setCheckingSubscription(false); + }, + }, + }} + > + + + {children} + + + ); +} + +export function AuthButton() { + const environmentId = process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID; + + // Don't render widget if Dynamic is not enabled + if (!environmentId) { + return null; + } + + return ; +} diff --git a/app/components/CustomizationContext.tsx b/app/components/CustomizationContext.tsx new file mode 100644 index 0000000..f5d6c01 --- /dev/null +++ b/app/components/CustomizationContext.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, ReactNode } from "react"; +import { encryptData, decryptData, EncryptedData } from "@/lib/encryption-utils"; +import { isDevModeEnabled, getPersonalizationPassword, savePersonalizationPassword } from "@/lib/dev-mode"; + +export interface UserCustomization { + ethnicities: string[]; + countriesOfOrigin: string[]; + genderAtBirth: string; + age: number | null; + personalConditions: string[]; + familyConditions: string[]; + smokingHistory?: 'still-smoking' | 'past-smoker' | 'never-smoked' | ''; + alcoholUse?: 'none' | 'rare' | 'mild' | 'moderate' | 'heavy' | ''; + medications?: string[]; + diet?: 'regular' | 'vegetarian' | 'vegan' | 'pescatarian' | 'keto' | 'paleo' | 'carnivore' | 'mediterranean' | 'low-carb' | 'gluten-free' | ''; +} + +type CustomizationStatus = 'not-set' | 'locked' | 'unlocked'; + +type CustomizationContextType = { + customization: UserCustomization | null; + status: CustomizationStatus; + saveCustomization: (data: UserCustomization, password: string) => Promise; + unlockCustomization: (password: string) => Promise; + lockCustomization: () => void; + clearCustomization: () => void; +}; + +const CustomizationContext = createContext(null); + +const STORAGE_KEY = 'user_customization_encrypted'; + +const defaultCustomization: UserCustomization = { + ethnicities: [], + countriesOfOrigin: [], + genderAtBirth: '', + age: null, + personalConditions: [], + familyConditions: [], + smokingHistory: '', + alcoholUse: '', + medications: [], + diet: '', +}; + +export function CustomizationProvider({ children }: { children: ReactNode }) { + const [customization, setCustomization] = useState(null); + const [status, setStatus] = useState('not-set'); + const [devModeAutoUnlockAttempted, setDevModeAutoUnlockAttempted] = useState(false); + + useEffect(() => { + // Check if encrypted data exists in localStorage + if (typeof window !== 'undefined') { + const encrypted = localStorage.getItem(STORAGE_KEY); + if (encrypted) { + setStatus('locked'); + } else { + setStatus('not-set'); + } + } + }, []); + + // Dev mode: Auto-unlock personalization on mount + useEffect(() => { + if (devModeAutoUnlockAttempted || status !== 'locked') return; + + const autoUnlock = async () => { + if (!isDevModeEnabled()) { + setDevModeAutoUnlockAttempted(true); + return; + } + + console.log('[Dev Mode] 🚀 Attempting to auto-unlock personalization...'); + + try { + const savedPassword = await getPersonalizationPassword(); + if (savedPassword) { + const success = await unlockCustomization(savedPassword); + if (success) { + console.log('[Dev Mode] ✓ Personalization auto-unlocked successfully'); + } else { + console.log('[Dev Mode] Failed to unlock - password may have changed'); + } + } else { + console.log('[Dev Mode] No saved password found. Unlock once to enable auto-unlock.'); + } + } catch (error) { + console.error('[Dev Mode] Failed to auto-unlock personalization:', error); + } finally { + setDevModeAutoUnlockAttempted(true); + } + }; + + autoUnlock(); + }, [status, devModeAutoUnlockAttempted]); + + const saveCustomization = async (data: UserCustomization, password: string) => { + if (!password || password.length < 6) { + throw new Error('Password must be at least 6 characters'); + } + + const encrypted = await encryptData(JSON.stringify(data), password); + localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted)); + + setCustomization(data); + setStatus('unlocked'); + + // Dev mode: Save password for auto-unlock + if (isDevModeEnabled()) { + await savePersonalizationPassword(password); + } + }; + + const unlockCustomization = async (password: string): Promise => { + const encryptedStr = localStorage.getItem(STORAGE_KEY); + if (!encryptedStr) { + return false; + } + + try { + const encrypted: EncryptedData = JSON.parse(encryptedStr); + const decrypted = await decryptData(encrypted, password); + const data: UserCustomization = JSON.parse(decrypted); + + setCustomization(data); + setStatus('unlocked'); + + // Dev mode: Save password for auto-unlock + if (isDevModeEnabled()) { + await savePersonalizationPassword(password); + } + + return true; + } catch (error) { + return false; + } + }; + + const lockCustomization = () => { + setCustomization(null); + setStatus('locked'); + }; + + const clearCustomization = () => { + localStorage.removeItem(STORAGE_KEY); + setCustomization(null); + setStatus('not-set'); + }; + + return ( + + {children} + + ); +} + +export function useCustomization() { + const context = useContext(CustomizationContext); + if (!context) { + throw new Error('useCustomization must be used within CustomizationProvider'); + } + return context; +} diff --git a/app/components/CustomizationModal.tsx b/app/components/CustomizationModal.tsx new file mode 100644 index 0000000..a219824 --- /dev/null +++ b/app/components/CustomizationModal.tsx @@ -0,0 +1,456 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { useCustomization, UserCustomization } from "./CustomizationContext"; + +type CustomizationModalProps = { + isOpen: boolean; + onClose: () => void; +}; + +export default function CustomizationModal({ isOpen, onClose }: CustomizationModalProps) { + const { customization, status, saveCustomization, unlockCustomization, lockCustomization, clearCustomization } = useCustomization(); + + const [isUnlockMode, setIsUnlockMode] = useState(false); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + // Form fields + const [ethnicities, setEthnicities] = useState(''); + const [countriesOfOrigin, setCountriesOfOrigin] = useState(''); + const [genderAtBirth, setGenderAtBirth] = useState(''); + const [customGender, setCustomGender] = useState(''); + const [age, setAge] = useState(''); + const [personalConditions, setPersonalConditions] = useState(''); + const [familyConditions, setFamilyConditions] = useState(''); + const [smokingHistory, setSmokingHistory] = useState(''); + const [alcoholUse, setAlcoholUse] = useState(''); + const [medications, setMedications] = useState(''); + const [diet, setDiet] = useState(''); + + useEffect(() => { + if (isOpen) { + setError(null); + setPassword(''); + setConfirmPassword(''); + + if (status === 'locked') { + setIsUnlockMode(true); + } else if (status === 'unlocked' && customization) { + setIsUnlockMode(false); + // Populate form with existing data + setEthnicities(customization.ethnicities.join(', ')); + setCountriesOfOrigin(customization.countriesOfOrigin.join(', ')); + + // Handle gender - check if it's a preset or custom value + if (customization.genderAtBirth === 'male' || customization.genderAtBirth === 'female') { + setGenderAtBirth(customization.genderAtBirth); + setCustomGender(''); + } else { + setGenderAtBirth('other'); + setCustomGender(customization.genderAtBirth); + } + + setAge(customization.age?.toString() || ''); + // Handle backward compatibility with old data structure + setPersonalConditions((customization.personalConditions || []).join(', ')); + setFamilyConditions((customization.familyConditions || []).join(', ')); + setSmokingHistory(customization.smokingHistory || ''); + setAlcoholUse(customization.alcoholUse || ''); + setMedications((customization.medications || []).join(', ')); + setDiet(customization.diet || ''); + } else { + setIsUnlockMode(false); + // Reset form for new customization + setEthnicities(''); + setCountriesOfOrigin(''); + setGenderAtBirth(''); + setCustomGender(''); + setAge(''); + setPersonalConditions(''); + setFamilyConditions(''); + setSmokingHistory(''); + setAlcoholUse(''); + setMedications(''); + setDiet(''); + } + } + }, [isOpen, status, customization]); + + const handleUnlock = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsSaving(true); + + const success = await unlockCustomization(password); + + setIsSaving(false); + + if (success) { + setIsUnlockMode(false); + setPassword(''); + onClose(); // Close modal immediately after successful unlock + } else { + setError('Incorrect password'); + } + }; + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + // Validate password for new customization + if (status === 'not-set' || status === 'unlocked') { + if (!password) { + setError('Password is required'); + return; + } + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + } + + setIsSaving(true); + + try { + const finalGender = genderAtBirth === 'other' ? customGender : genderAtBirth; + + const data: UserCustomization = { + ethnicities: ethnicities.split(',').map(s => s.trim()).filter(Boolean), + countriesOfOrigin: countriesOfOrigin.split(',').map(s => s.trim()).filter(Boolean), + genderAtBirth: finalGender, + age: age ? parseInt(age) : null, + personalConditions: personalConditions.split(',').map(s => s.trim()).filter(Boolean), + familyConditions: familyConditions.split(',').map(s => s.trim()).filter(Boolean), + smokingHistory: smokingHistory as any, + alcoholUse: alcoholUse as any, + medications: medications.split(',').map(s => s.trim()).filter(Boolean), + diet: diet as any, + }; + + await saveCustomization(data, password); + // Don't close - let user decide with buttons + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save customization'); + } finally { + setIsSaving(false); + } + }; + + const handleSaveAndClose = async () => { + // Trigger form submission first + const formEvent = new Event('submit', { bubbles: true, cancelable: true }); + const form = document.querySelector('form'); + if (form) { + form.dispatchEvent(formEvent); + // Wait a bit for save to complete, then close if no errors + setTimeout(() => { + if (!error) { + setPassword(''); + setConfirmPassword(''); + onClose(); + } + }, 100); + } + }; + + const handleLock = () => { + lockCustomization(); + onClose(); + }; + + const handleClear = () => { + if (confirm('Are you sure you want to delete all customization data? This cannot be undone.')) { + clearCustomization(); + onClose(); + } + }; + + if (!isOpen) return null; + + const modalContent = ( +
+
e.stopPropagation()} + > +
+

⚙️ Personalize LLM Analysis

+ +
+

+ Provide personal information to help the LLM give more relevant interpretations. + Your data is encrypted with your password and stored only in your browser. +

+
+ + {isUnlockMode ? ( +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + autoFocus + /> +
+ + {error &&
❌ {error}
} + +
+ + +
+
+ ) : ( +
+
+ + setEthnicities(e.target.value)} + placeholder="e.g., European, East Asian" + /> +
+ +
+ + setCountriesOfOrigin(e.target.value)} + placeholder="e.g., India, China" + /> +
+ +
+ + +
+ + {genderAtBirth === 'other' && ( +
+ + setCustomGender(e.target.value)} + placeholder="Enter your gender" + /> +
+ )} + +
+ + setAge(e.target.value)} + placeholder="e.g., 30" + min="0" + max="120" + /> +
+ +
+ +