diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..2064d4a000c366126e9e2ae089cd5d8c9b6ea2d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# ============================================================ +# MedOS HuggingFace Space — full backend configuration +# ============================================================ + +# --- LLM providers --- +OLLABRIDGE_URL=https://ruslanmv-ollabridge.hf.space +OLLABRIDGE_API_KEY=sk-ollabridge-your-key-here +HF_TOKEN=hf_your-token-here +DEFAULT_MODEL=free-best + +# --- Database (SQLite, persistent on HF Spaces /data/) --- +DB_PATH=/data/medos.db + +# --- CORS (comma-separated Vercel frontend origins) --- +ALLOWED_ORIGINS=https://your-vercel-app.vercel.app,http://localhost:3000 + +# --- Medicine Scanner proxy --- +# Token with "Make calls to Inference Providers" permission. +# Used server-side only — never exposed to browser. +# Create at: https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained +HF_TOKEN_INFERENCE=hf_your-inference-token-here +SCANNER_URL=https://ruslanmv-medicine-scanner.hf.space + +# --- Application --- +NODE_ENV=production +PORT=7860 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4d68e8f99e51bf93fea5ff4975444364fcd9400e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +out/ +.env +.env.local +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..8dc4a95413d88a71f1ec34dd2ad569b542dab31b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# ============================================================ +# MedOS HuggingFace Space — Production Dockerfile +# +# Enterprise architecture: +# web/ = frontend source of truth +# 9-HuggingFace-Global/ = backend + synced frontend +# +# Before deploying, run: bash scripts/sync-frontend.sh +# This copies web/ frontend, rewrites API paths, then you push. +# ============================================================ + +# Stage 1: Install dependencies +FROM node:18-alpine AS deps +WORKDIR /app +RUN apk add --no-cache python3 make g++ +COPY package.json ./ +RUN npm install --legacy-peer-deps && npm cache clean --force + +# Stage 2: Build +FROM node:18-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +RUN npm run build + +# Stage 3: Production runner +FROM node:18-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=7860 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/data ./data + +RUN mkdir -p /data && chown nextjs:nodejs /data +ENV DB_PATH=/data/medos.db + +USER nextjs +EXPOSE 7860 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:7860/api/health || exit 1 + +CMD ["node", "server.js"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..f5ad723e455d362aff898a033c4f454c7fc97c3e --- /dev/null +++ b/Makefile @@ -0,0 +1,195 @@ +# ============================================================================= +# MedOS Global - Makefile +# Free AI Medical Assistant for Hugging Face Spaces +# ============================================================================= + +SHELL := /bin/bash +.DEFAULT_GOAL := help + +# Directories +APP_DIR := 9-HuggingFace-Global +NODE_MODULES := $(APP_DIR)/node_modules + +# Colors +GREEN := \033[0;32m +YELLOW := \033[0;33m +RED := \033[0;31m +NC := \033[0m + +# ============================================================================= +# Help +# ============================================================================= + +.PHONY: help +help: ## Show this help message + @echo "" + @echo " MedOS Global - Development Commands" + @echo " ====================================" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}' + @echo "" + +# ============================================================================= +# Installation +# ============================================================================= + +.PHONY: install +install: ## Install all dependencies + @echo "$(GREEN)Installing dependencies...$(NC)" + cd $(APP_DIR) && npm install + @echo "$(GREEN)Dependencies installed successfully.$(NC)" + +.PHONY: install-ci +install-ci: ## Install dependencies for CI (clean, reproducible) + @echo "$(GREEN)Installing dependencies (CI mode)...$(NC)" + cd $(APP_DIR) && npm ci + @echo "$(GREEN)CI dependencies installed.$(NC)" + +.PHONY: clean +clean: ## Remove node_modules and build artifacts + @echo "$(YELLOW)Cleaning build artifacts...$(NC)" + rm -rf $(APP_DIR)/node_modules + rm -rf $(APP_DIR)/.next + rm -rf $(APP_DIR)/out + @echo "$(GREEN)Clean complete.$(NC)" + +# ============================================================================= +# Quality Checks +# ============================================================================= + +.PHONY: lint +lint: ## Run ESLint + @echo "$(GREEN)Running linter...$(NC)" + cd $(APP_DIR) && npx next lint + +.PHONY: type-check +type-check: ## Run TypeScript type checking + @echo "$(GREEN)Running type checker...$(NC)" + cd $(APP_DIR) && npx tsc --noEmit + +.PHONY: format-check +format-check: ## Check code formatting + @echo "$(GREEN)Checking code formatting...$(NC)" + cd $(APP_DIR) && npx prettier --check "**/*.{ts,tsx,js,json,css}" 2>/dev/null || echo "Prettier not configured - skipping" + +# ============================================================================= +# Testing +# ============================================================================= + +.PHONY: test +test: test-unit test-structure test-providers test-i18n test-safety ## Run all tests + @echo "$(GREEN)All tests passed!$(NC)" + +.PHONY: test-unit +test-unit: ## Run unit tests + @echo "$(GREEN)Running unit tests...$(NC)" + cd $(APP_DIR) && node --experimental-vm-modules ../tests/9-hf-global/run-tests.js + +.PHONY: test-structure +test-structure: ## Verify folder structure and required files exist + @echo "$(GREEN)Verifying folder structure...$(NC)" + @test -f $(APP_DIR)/Dockerfile || (echo "$(RED)FAIL: Dockerfile missing$(NC)" && exit 1) + @test -f $(APP_DIR)/package.json || (echo "$(RED)FAIL: package.json missing$(NC)" && exit 1) + @test -f $(APP_DIR)/next.config.js || (echo "$(RED)FAIL: next.config.js missing$(NC)" && exit 1) + @test -f $(APP_DIR)/tsconfig.json || (echo "$(RED)FAIL: tsconfig.json missing$(NC)" && exit 1) + @test -f $(APP_DIR)/tailwind.config.ts || (echo "$(RED)FAIL: tailwind.config.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/README.md || (echo "$(RED)FAIL: README.md missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/layout.tsx || (echo "$(RED)FAIL: app/layout.tsx missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/page.tsx || (echo "$(RED)FAIL: app/page.tsx missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/globals.css || (echo "$(RED)FAIL: app/globals.css missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/api/chat/route.ts || (echo "$(RED)FAIL: chat API route missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/api/triage/route.ts || (echo "$(RED)FAIL: triage API route missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/api/health/route.ts || (echo "$(RED)FAIL: health API route missing$(NC)" && exit 1) + @test -f $(APP_DIR)/public/manifest.json || (echo "$(RED)FAIL: manifest.json missing$(NC)" && exit 1) + @test -f $(APP_DIR)/public/sw.js || (echo "$(RED)FAIL: sw.js missing$(NC)" && exit 1) + @test -f $(APP_DIR)/Dockerfile || (echo "$(RED)FAIL: Dockerfile missing$(NC)" && exit 1) + @echo "$(GREEN) Structure check passed (15/15 files verified).$(NC)" + +.PHONY: test-providers +test-providers: ## Test provider modules exist and export correctly + @echo "$(GREEN)Verifying provider modules...$(NC)" + @test -f $(APP_DIR)/lib/providers/index.ts || (echo "$(RED)FAIL: providers/index.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/providers/ollabridge.ts || (echo "$(RED)FAIL: ollabridge.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/providers/huggingface-direct.ts || (echo "$(RED)FAIL: huggingface-direct.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/providers/cached-faq.ts || (echo "$(RED)FAIL: cached-faq.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/providers/ollabridge-models.ts || (echo "$(RED)FAIL: ollabridge-models.ts missing$(NC)" && exit 1) + @echo "$(GREEN) Provider modules verified (5/5).$(NC)" + +.PHONY: test-i18n +test-i18n: ## Verify i18n translations exist (synced or in web/ source) + @echo "$(GREEN)Verifying i18n...$(NC)" + @if test -f $(APP_DIR)/lib/i18n.ts; then \ + echo "$(GREEN) Found synced lib/i18n.ts$(NC)"; \ + elif test -f web/lib/i18n.ts; then \ + echo "$(GREEN) Found web/lib/i18n.ts (source of truth)$(NC)"; \ + else \ + echo "$(RED)FAIL: i18n.ts not found in lib/ or web/lib/$(NC)" && exit 1; \ + fi + +.PHONY: test-safety +test-safety: ## Verify safety modules exist + @echo "$(GREEN)Verifying safety modules...$(NC)" + @test -f $(APP_DIR)/lib/safety/triage.ts || (echo "$(RED)FAIL: triage.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/safety/emergency-numbers.ts || (echo "$(RED)FAIL: emergency-numbers.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/safety/disclaimer.ts || (echo "$(RED)FAIL: disclaimer.ts missing$(NC)" && exit 1) + @echo "$(GREEN) Safety modules verified (3/3).$(NC)" + +# ============================================================================= +# Build +# ============================================================================= + +.PHONY: build +build: ## Build the Next.js application + @echo "$(GREEN)Building application...$(NC)" + cd $(APP_DIR) && npm run build + @echo "$(GREEN)Build complete.$(NC)" + +.PHONY: docker-build +docker-build: ## Build Docker image + @echo "$(GREEN)Building Docker image...$(NC)" + cd $(APP_DIR) && docker build -t medos-global . + @echo "$(GREEN)Docker image built successfully.$(NC)" + +.PHONY: docker-run +docker-run: ## Run Docker container locally + @echo "$(GREEN)Starting MedOS on http://localhost:7860...$(NC)" + cd $(APP_DIR) && docker run -p 7860:7860 \ + -e OLLABRIDGE_URL=$${OLLABRIDGE_URL:-https://ruslanmv-ollabridge-cloud.hf.space} \ + -e HF_TOKEN=$${HF_TOKEN:-} \ + medos-global + +# ============================================================================= +# Development +# ============================================================================= + +.PHONY: dev +dev: ## Start development server on port 7860 + @echo "$(GREEN)Starting dev server on http://localhost:7860...$(NC)" + cd $(APP_DIR) && npm run dev + +.PHONY: start +start: ## Start production server on port 7860 + cd $(APP_DIR) && npm run start + +# ============================================================================= +# Deployment +# ============================================================================= + +.PHONY: deploy-hf +deploy-hf: ## Deploy to Hugging Face Spaces (requires HF_TOKEN) + @test -n "$(HF_TOKEN)" || (echo "$(RED)ERROR: HF_TOKEN is required. Set it with: make deploy-hf HF_TOKEN=hf_...$(NC)" && exit 1) + @echo "$(GREEN)Deploying to Hugging Face Spaces...$(NC)" + @bash scripts/deploy-hf.sh $(HF_TOKEN) + +# ============================================================================= +# CI Pipeline (combines all checks) +# ============================================================================= + +.PHONY: ci +ci: install-ci test build ## Full CI pipeline: install, test, build + @echo "$(GREEN)CI pipeline passed successfully!$(NC)" + +.PHONY: check +check: test-structure test-providers test-i18n test-safety ## Quick check (no install needed) + @echo "$(GREEN)All structural checks passed!$(NC)" diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c6bc9c57eb2bf7392afbedacde36c0b70adea663 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +--- +title: "MediBot: Free AI Medical Assistant · 20 languages" +emoji: "\U0001F3E5" +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 7860 +pinned: true +license: apache-2.0 +short_description: "Free AI medical chatbot. 20 languages. No sign-up." +tags: + - medical + - healthcare + - chatbot + - medical-ai + - health-assistant + - symptom-checker + - telemedicine + - who-guidelines + - cdc + - multilingual + - i18n + - rag + - llama-3.3 + - llama-3.3-70b + - mixtral + - groq + - huggingface-inference + - pwa + - offline-first + - free + - no-signup + - privacy-first + - worldwide + - nextjs + - docker +models: + - meta-llama/Llama-3.3-70B-Instruct + - meta-llama/Meta-Llama-3-8B-Instruct + - mistralai/Mixtral-8x7B-Instruct-v0.1 + - Qwen/Qwen2.5-72B-Instruct + - deepseek-ai/DeepSeek-V3 + - ruslanmv/Medical-Llama3-8B + - google/gemma-2-9b-it +datasets: + - ruslanmv/ai-medical-chatbot +--- + +# MediBot — free AI medical assistant, worldwide + +> **Tell MediBot what's bothering you. In your language. Instantly. For free.** +> No sign-up. No paywall. No data retention. Aligned with WHO · CDC · NHS guidelines. + +[![Try MediBot](https://img.shields.io/badge/Try_MediBot-Free_on_HuggingFace-blue?style=for-the-badge&logo=huggingface)](https://huggingface.co/spaces/ruslanmv/MediBot) +[![Languages](https://img.shields.io/badge/languages-20-14B8A6?style=for-the-badge)](#) +[![Free](https://img.shields.io/badge/price-free_forever-22C55E?style=for-the-badge)](#) +[![No sign-up](https://img.shields.io/badge/account-not_required-3B82F6?style=for-the-badge)](#) + +## Why MediBot + +- **Free forever.** No API key, no sign-up, no paywall, no ads. +- **20 languages, auto-detected.** English, Español, Français, Português, Deutsch, Italiano, العربية, हिन्दी, Kiswahili, 中文, 日本語, 한국어, Русский, Türkçe, Tiếng Việt, ไทย, বাংলা, اردو, Polski, Nederlands. +- **Worldwide.** IP-based country detection picks your local emergency number (190+ countries) and adapts the answer to your region (°C/°F, metric/imperial, local guidance). +- **Best free LLM on HuggingFace.** Powered by **Llama 3.3 70B via HF Inference Providers (Groq)** — fastest high-quality free tier available — with an automatic fallback chain across Cerebras, SambaNova, Together, and Mixtral. +- **Grounded on WHO, CDC, NHS, NIH, ICD-11, BNF, EMA.** A structured system prompt aligns every answer with authoritative guidance. +- **Red-flag triage.** Built-in symptom patterns detect cardiac, neurological, respiratory, obstetric, pediatric, and mental-health emergencies in every supported language — and immediately escalate to the local emergency number. +- **Installable PWA.** Add to your phone's home screen and use it like a native app. Offline-capable with a cached FAQ fallback. +- **Shareable.** Every AI answer gets a Share button that generates a clean deep link with a branded OG card preview — perfect for WhatsApp, Twitter, and Telegram. +- **Private & anonymous.** Zero accounts. Zero server-side conversation storage. No IPs logged. Anonymous session counter only. +- **Open source.** Fully transparent. [github.com/ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot) + +## How it works + +1. You type (or speak) a health question +2. MedOS checks for emergency red flags first +3. It searches a medical knowledge base for relevant context +4. Your question + context go to **Llama 3.3 70B** (via Groq, free) +5. You get a structured answer: Summary, Possible causes, Self-care, When to see a doctor + +If the main model is busy, MedOS automatically tries other free models until one responds. + +## Built with + +| Layer | Technology | +|---|---| +| Frontend | Next.js 14, React, Tailwind CSS | +| AI Model | Llama 3.3 70B Instruct (via HuggingFace Inference + Groq) | +| Fallbacks | Mixtral 8x7B, OllaBridge, cached FAQ | +| Knowledge | Medical RAG from [ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot) dataset | +| Gateway | [OllaBridge-Cloud](https://github.com/ruslanmv/ollabridge) | +| Hosting | HuggingFace Spaces (Docker) | + +## License + +Apache 2.0 — free to use, modify, and distribute. diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d565eab6868e71289d41ee8e788cab0d2c7e3aa --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,327 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { + Users, + Activity, + Database, + MessageCircle, + Shield, + Search, + Trash2, + ChevronLeft, + ChevronRight, + RefreshCw, + LogIn, + Lock, +} from 'lucide-react'; + +interface Stats { + totalUsers: number; + verifiedUsers: number; + adminUsers: number; + totalHealthData: number; + totalChats: number; + activeSessions: number; + healthBreakdown: Array<{ type: string; count: number }>; + registrations: Array<{ day: string; count: number }>; +} + +interface UserRow { + id: string; + email: string; + displayName: string | null; + emailVerified: boolean; + isAdmin: boolean; + createdAt: string; + healthDataCount: number; + chatHistoryCount: number; +} + +/** + * Admin dashboard — accessible ONLY at /admin on the HuggingFace Space. + * Not linked from the public UI. Requires admin login. + */ +export default function AdminPage() { + const [token, setToken] = useState(''); + const [loggedIn, setLoggedIn] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loginError, setLoginError] = useState(''); + const [stats, setStats] = useState(null); + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(false); + + const headers = useCallback( + () => ({ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }), + [token], + ); + + const fetchStats = useCallback(async () => { + const res = await fetch('/api/admin/stats', { headers: headers() }); + if (res.ok) setStats(await res.json()); + else if (res.status === 403) { setLoggedIn(false); setToken(''); } + }, [headers]); + + const fetchUsers = useCallback(async () => { + setLoading(true); + const qs = new URLSearchParams({ page: String(page), limit: '20' }); + if (search) qs.set('search', search); + const res = await fetch(`/api/admin/users?${qs}`, { headers: headers() }); + if (res.ok) { + const data = await res.json(); + setUsers(data.users); + setTotal(data.total); + } + setLoading(false); + }, [headers, page, search]); + + useEffect(() => { + if (!loggedIn) return; + fetchStats(); + fetchUsers(); + }, [loggedIn, fetchStats, fetchUsers]); + + const handleLogin = async () => { + setLoginError(''); + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json(); + if (!res.ok) { setLoginError(data.error || 'Login failed'); return; } + // Verify this user is actually an admin. + const meRes = await fetch('/api/auth/me', { + headers: { Authorization: `Bearer ${data.token}` }, + }); + const me = await meRes.json(); + if (!me.user) { setLoginError('Auth failed'); return; } + // Check admin flag by trying the admin API. + const adminCheck = await fetch('/api/admin/stats', { + headers: { Authorization: `Bearer ${data.token}` }, + }); + if (adminCheck.status === 403) { setLoginError('Not an admin account'); return; } + setToken(data.token); + setLoggedIn(true); + }; + + const handleDeleteUser = async (userId: string, userEmail: string) => { + if (!confirm(`Delete user ${userEmail} and ALL their data?`)) return; + await fetch(`/api/admin/users?id=${userId}`, { method: 'DELETE', headers: headers() }); + fetchUsers(); + fetchStats(); + }; + + // Login screen + if (!loggedIn) { + return ( +
+
+
+
+ +
+

Admin Panel

+

MedOS server administration

+
+ {loginError && ( +
+ {loginError} +
+ )} +
+ setEmail(e.target.value)} + placeholder="Admin email" + className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/50" + /> + setPassword(e.target.value)} + placeholder="Password" + className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/50" + onKeyDown={(e) => e.key === 'Enter' && handleLogin()} + /> + +
+
+
+ ); + } + + const totalPages = Math.ceil(total / 20); + + return ( +
+
+
+
+ +
+

MedOS Admin

+
+ +
+ +
+ {/* Stats grid */} + {stats && ( +
+ + + + + + +
+ )} + + {/* Health data breakdown */} + {stats && stats.healthBreakdown.length > 0 && ( +
+

Health data by type

+
+ {stats.healthBreakdown.map((b) => ( + + {b.type}: {b.count} + + ))} +
+
+ )} + + {/* User management */} +
+
+

Users ({total})

+
+ + { setSearch(e.target.value); setPage(1); }} + placeholder="Search by email or name..." + className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-9 pr-4 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-red-500/50" + /> +
+
+ +
+ + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + + ))} + {users.length === 0 && ( + + + + )} + +
UserVerifiedRoleHealthChatsJoinedActions
+
{u.displayName || '—'}
+
{u.email}
+
+ + {u.emailVerified ? 'Yes' : 'No'} + + + {u.isAdmin ? ( + + ADMIN + + ) : ( + User + )} + {u.healthDataCount}{u.chatHistoryCount} + {new Date(u.createdAt).toLocaleDateString()} + + {!u.isAdmin && ( + + )} +
+ {loading ? 'Loading...' : 'No users found'} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} +
+
+
+ ); +} + +function Stat({ icon: Icon, label, value, color }: { icon: any; label: string; value: number; color?: string }) { + return ( +
+ +
{value.toLocaleString()}
+
{label}
+
+ ); +} diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..76bea8467f81fdda60ac164be536fd165dd1d835 --- /dev/null +++ b/app/api/admin/config/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { loadConfig, saveConfig, type ServerConfig } from '@/lib/server-config'; + +/** + * Admin configuration management. + * + * GET /api/admin/config — returns current server configuration (redacted secrets). + * PUT /api/admin/config — updates server configuration (persisted to config file). + * + * Configuration is persisted to a JSON file on disk so it survives restarts. + * Environment variables take precedence over the config file on first boot. + * + * The storage/merge logic lives in @/lib/server-config so other admin routes + * (like /api/admin/fetch-models) can read the same source of truth. + */ + +/** Redact sensitive fields for GET responses. */ +function redact(config: ServerConfig) { + const hasSecret = (v: string) => !!(v && v.length > 0); + const mask = (v: string) => (hasSecret(v) ? '••••••••' : ''); + return { + smtp: { + host: config.smtp.host, + port: config.smtp.port, + user: config.smtp.user, + pass: mask(config.smtp.pass), + fromEmail: config.smtp.fromEmail, + recoveryEmail: config.smtp.recoveryEmail, + configured: !!(config.smtp.host && config.smtp.user && config.smtp.pass), + }, + llm: { + defaultPreset: config.llm.defaultPreset, + ollamaUrl: config.llm.ollamaUrl, + hfDefaultModel: config.llm.hfDefaultModel, + hfToken: mask(config.llm.hfToken), + ollabridgeUrl: config.llm.ollabridgeUrl, + ollabridgeApiKey: mask(config.llm.ollabridgeApiKey), + openaiApiKey: mask(config.llm.openaiApiKey), + anthropicApiKey: mask(config.llm.anthropicApiKey), + groqApiKey: mask(config.llm.groqApiKey), + watsonxApiKey: mask(config.llm.watsonxApiKey), + watsonxProjectId: config.llm.watsonxProjectId, + watsonxUrl: config.llm.watsonxUrl, + // Computed status flags — derived server-side so UI can show chips. + ollabridgeConfigured: !!config.llm.ollabridgeUrl, + hfConfigured: hasSecret(config.llm.hfToken), + openaiConfigured: hasSecret(config.llm.openaiApiKey), + anthropicConfigured: hasSecret(config.llm.anthropicApiKey), + groqConfigured: hasSecret(config.llm.groqApiKey), + watsonxConfigured: hasSecret(config.llm.watsonxApiKey) && !!config.llm.watsonxProjectId, + }, + app: config.app, + }; +} + +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + const config = loadConfig(); + return NextResponse.json(redact(config)); +} + +export async function PUT(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + try { + const body = await req.json(); + const current = loadConfig(); + + // Merge incoming changes (only update provided fields). + if (body.smtp) { + if (body.smtp.host !== undefined) current.smtp.host = body.smtp.host; + if (body.smtp.port !== undefined) current.smtp.port = parseInt(body.smtp.port, 10); + if (body.smtp.user !== undefined) current.smtp.user = body.smtp.user; + // Only update password if it's not the redacted placeholder. + if (body.smtp.pass !== undefined && body.smtp.pass !== '••••••••') { + current.smtp.pass = body.smtp.pass; + } + if (body.smtp.fromEmail !== undefined) current.smtp.fromEmail = body.smtp.fromEmail; + if (body.smtp.recoveryEmail !== undefined) current.smtp.recoveryEmail = body.smtp.recoveryEmail; + } + + if (body.llm) { + // Non-secret fields — assign directly. + if (body.llm.defaultPreset !== undefined) current.llm.defaultPreset = body.llm.defaultPreset; + if (body.llm.ollamaUrl !== undefined) current.llm.ollamaUrl = body.llm.ollamaUrl; + if (body.llm.hfDefaultModel !== undefined) current.llm.hfDefaultModel = body.llm.hfDefaultModel; + if (body.llm.ollabridgeUrl !== undefined) current.llm.ollabridgeUrl = body.llm.ollabridgeUrl; + if (body.llm.watsonxProjectId !== undefined) current.llm.watsonxProjectId = body.llm.watsonxProjectId; + if (body.llm.watsonxUrl !== undefined) current.llm.watsonxUrl = body.llm.watsonxUrl; + + // Secret fields — skip if value is the redacted placeholder. + const setSecret = (field: keyof ServerConfig['llm'], value: any) => { + if (value !== undefined && value !== '••••••••') { + (current.llm as any)[field] = value; + } + }; + setSecret('hfToken', body.llm.hfToken); + setSecret('ollabridgeApiKey', body.llm.ollabridgeApiKey); + setSecret('openaiApiKey', body.llm.openaiApiKey); + setSecret('anthropicApiKey', body.llm.anthropicApiKey); + setSecret('groqApiKey', body.llm.groqApiKey); + setSecret('watsonxApiKey', body.llm.watsonxApiKey); + } + + if (body.app) { + if (body.app.appUrl !== undefined) current.app.appUrl = body.app.appUrl; + if (body.app.allowedOrigins !== undefined) current.app.allowedOrigins = body.app.allowedOrigins; + } + + saveConfig(current); + + return NextResponse.json({ success: true, config: redact(current) }); + } catch (error: any) { + console.error('[Admin Config]', error?.message); + return NextResponse.json({ error: error?.message || 'Failed to update config' }, { status: 500 }); + } +} diff --git a/app/api/admin/fetch-models/route.ts b/app/api/admin/fetch-models/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d6e3b9ef0d651f889349bc5689765ed34c07564 --- /dev/null +++ b/app/api/admin/fetch-models/route.ts @@ -0,0 +1,423 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { loadConfig } from '@/lib/server-config'; + +/** + * GET /api/admin/fetch-models — Aggregate available models from every + * configured provider into one list for the admin model picker. + * + * Queries, in parallel: + * - OllaBridge Cloud /v1/models (OpenAI-compatible) + * - HuggingFace Inference /v1/models (via router.huggingface.co) + * - Groq /openai/v1/models (free/cheap tier) + * - OpenAI /v1/models (paid enterprise) + * - Anthropic /v1/models (paid enterprise) + * - IBM WatsonX /ml/v1/foundation_model_specs (paid enterprise) + * + * Each provider block returns: + * { provider, configured, ok, error?, models: [{id, name, ownedBy, context?}] } + * + * Providers that aren't configured still appear in the response so the UI + * can show them as "not configured" with a link to set them up. This keeps + * the client-side model picker uniform across providers. + * + * Admin-only endpoint. + */ + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface ModelInfo { + id: string; + name: string; + ownedBy?: string; + context?: number; + pricing?: 'free' | 'paid' | 'cheap' | 'local'; +} + +interface ProviderBlock { + provider: string; + label: string; + configured: boolean; + ok: boolean; + error?: string; + pricing: 'free' | 'paid' | 'cheap' | 'local'; + models: ModelInfo[]; +} + +/** Default 10s timeout for any provider discovery call. */ +function withTimeout(ms = 10000) { + return AbortSignal.timeout(ms); +} + +async function safeJson(res: Response): Promise { + try { + return await res.json(); + } catch { + return null; + } +} + +// ---- Provider fetchers --------------------------------------------------- + +async function fetchOllaBridge( + url: string, + apiKey: string, +): Promise { + const block: ProviderBlock = { + provider: 'ollabridge', + label: 'OllaBridge Cloud', + configured: !!url, + ok: false, + pricing: 'free', + models: [], + }; + if (!url) { + block.error = 'Not configured — set OllaBridge URL in Server tab'; + return block; + } + try { + const cleanBase = url.replace(/\/+$/, ''); + const res = await fetch(`${cleanBase}/v1/models`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + return block; + } + const data = await safeJson(res); + const list = Array.isArray(data?.data) ? data.data : []; + block.models = list.map((m: any) => ({ + id: String(m.id ?? 'unknown'), + name: String(m.id ?? 'unknown'), + ownedBy: m.owned_by || 'ollabridge', + pricing: + String(m.id ?? '').startsWith('free-') + ? 'free' + : String(m.id ?? '').startsWith('cheap-') + ? 'cheap' + : 'local', + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + } + return block; +} + +async function fetchHuggingFace(token: string): Promise { + const block: ProviderBlock = { + provider: 'huggingface', + label: 'HuggingFace Inference', + configured: !!token, + ok: false, + pricing: 'free', + models: [], + }; + if (!token) { + block.error = 'Not configured — set HF token in Server tab'; + // Still provide the curated fallback chain as suggestions so users can + // pick a model even before the token is set. + block.models = CURATED_HF_MODELS.map((id) => ({ + id, + name: id.split('/').pop() || id, + ownedBy: id.split('/')[0], + pricing: 'free', + })); + return block; + } + try { + const res = await fetch('https://router.huggingface.co/v1/models', { + headers: { Authorization: `Bearer ${token}` }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + // Still return curated list so the UI has something to show. + block.models = CURATED_HF_MODELS.map((id) => ({ + id, + name: id.split('/').pop() || id, + ownedBy: id.split('/')[0], + pricing: 'free', + })); + return block; + } + const data = await safeJson(res); + const list = Array.isArray(data?.data) ? data.data : []; + block.models = list + .filter((m: any) => typeof m?.id === 'string') + .map((m: any) => ({ + id: String(m.id), + name: String(m.id).split('/').pop() || String(m.id), + ownedBy: m.owned_by || String(m.id).split('/')[0], + pricing: 'free' as const, + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + block.models = CURATED_HF_MODELS.map((id) => ({ + id, + name: id.split('/').pop() || id, + ownedBy: id.split('/')[0], + pricing: 'free', + })); + } + return block; +} + +/** Verified-working free HF models (from lib/providers/huggingface-direct.ts). */ +const CURATED_HF_MODELS = [ + 'meta-llama/Llama-3.3-70B-Instruct', + 'Qwen/Qwen2.5-72B-Instruct', + 'Qwen/Qwen3-235B-A22B', + 'google/gemma-3-27b-it', + 'meta-llama/Llama-3.1-70B-Instruct', + 'deepseek-ai/DeepSeek-V3-0324', +]; + +async function fetchGroq(apiKey: string): Promise { + const block: ProviderBlock = { + provider: 'groq', + label: 'Groq (Free tier)', + configured: !!apiKey, + ok: false, + pricing: 'free', + models: [], + }; + if (!apiKey) { + block.error = 'Not configured — add Groq API key in Server tab'; + return block; + } + try { + const res = await fetch('https://api.groq.com/openai/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + return block; + } + const data = await safeJson(res); + const list = Array.isArray(data?.data) ? data.data : []; + block.models = list.map((m: any) => ({ + id: String(m.id ?? 'unknown'), + name: String(m.id ?? 'unknown'), + ownedBy: m.owned_by || 'groq', + context: typeof m.context_window === 'number' ? m.context_window : undefined, + pricing: 'free', + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + } + return block; +} + +async function fetchOpenAI(apiKey: string): Promise { + const block: ProviderBlock = { + provider: 'openai', + label: 'OpenAI (Paid)', + configured: !!apiKey, + ok: false, + pricing: 'paid', + models: [], + }; + if (!apiKey) { + block.error = 'Not configured — add OpenAI API key in Server tab'; + return block; + } + try { + const res = await fetch('https://api.openai.com/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + return block; + } + const data = await safeJson(res); + const list = Array.isArray(data?.data) ? data.data : []; + // Filter to chat-capable GPT models — the full list is noisy. + block.models = list + .filter((m: any) => { + const id = String(m?.id || ''); + return /^(gpt-|o1-|o3-|chatgpt)/i.test(id); + }) + .map((m: any) => ({ + id: String(m.id), + name: String(m.id), + ownedBy: m.owned_by || 'openai', + pricing: 'paid' as const, + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + } + return block; +} + +async function fetchAnthropic(apiKey: string): Promise { + const block: ProviderBlock = { + provider: 'anthropic', + label: 'Anthropic Claude (Paid)', + configured: !!apiKey, + ok: false, + pricing: 'paid', + models: [], + }; + if (!apiKey) { + block.error = 'Not configured — add Anthropic API key in Server tab'; + // Provide curated list as placeholder. + block.models = CURATED_ANTHROPIC_MODELS; + return block; + } + try { + const res = await fetch('https://api.anthropic.com/v1/models', { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + block.models = CURATED_ANTHROPIC_MODELS; + return block; + } + const data = await safeJson(res); + const list = Array.isArray(data?.data) ? data.data : []; + block.models = list.map((m: any) => ({ + id: String(m.id ?? 'unknown'), + name: String(m.display_name || m.id || 'unknown'), + ownedBy: 'anthropic', + pricing: 'paid' as const, + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + block.models = CURATED_ANTHROPIC_MODELS; + } + return block; +} + +/** Fallback list so the UI can show Claude options even without a key. */ +const CURATED_ANTHROPIC_MODELS: ModelInfo[] = [ + { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', ownedBy: 'anthropic', pricing: 'paid' }, + { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', ownedBy: 'anthropic', pricing: 'paid' }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', ownedBy: 'anthropic', pricing: 'paid' }, +]; + +async function fetchWatsonx( + apiKey: string, + projectId: string, + baseUrl: string, +): Promise { + const block: ProviderBlock = { + provider: 'watsonx', + label: 'IBM WatsonX (Paid)', + configured: !!(apiKey && projectId), + ok: false, + pricing: 'paid', + models: [], + }; + if (!apiKey || !projectId) { + block.error = 'Not configured — add WatsonX API key and project ID in Server tab'; + block.models = CURATED_WATSONX_MODELS; + return block; + } + try { + // WatsonX requires exchanging the API key for an IAM bearer token first. + const iamRes = await fetch('https://iam.cloud.ibm.com/identity/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ibm:params:oauth:grant-type:apikey', + apikey: apiKey, + }), + signal: withTimeout(), + }); + if (!iamRes.ok) { + block.error = `IAM token failed: HTTP ${iamRes.status}`; + block.models = CURATED_WATSONX_MODELS; + return block; + } + const iamData = await safeJson(iamRes); + const bearer = iamData?.access_token; + if (!bearer) { + block.error = 'IAM response missing access_token'; + block.models = CURATED_WATSONX_MODELS; + return block; + } + + // Discover available foundation models. + const cleanBase = baseUrl.replace(/\/+$/, ''); + const modelsRes = await fetch( + `${cleanBase}/ml/v1/foundation_model_specs?version=2024-05-01&limit=200`, + { + headers: { Authorization: `Bearer ${bearer}` }, + signal: withTimeout(), + }, + ); + if (!modelsRes.ok) { + block.error = `HTTP ${modelsRes.status}`; + block.models = CURATED_WATSONX_MODELS; + return block; + } + const data = await safeJson(modelsRes); + const list = Array.isArray(data?.resources) ? data.resources : []; + block.models = list.map((m: any) => ({ + id: String(m.model_id ?? 'unknown'), + name: String(m.label || m.model_id || 'unknown'), + ownedBy: m.provider || 'ibm', + pricing: 'paid' as const, + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + block.models = CURATED_WATSONX_MODELS; + } + return block; +} + +/** Common WatsonX foundation models as a fallback list. */ +const CURATED_WATSONX_MODELS: ModelInfo[] = [ + { id: 'ibm/granite-3-8b-instruct', name: 'Granite 3 8B Instruct', ownedBy: 'ibm', pricing: 'paid' }, + { id: 'meta-llama/llama-3-3-70b-instruct', name: 'Llama 3.3 70B', ownedBy: 'meta', pricing: 'paid' }, + { id: 'mistralai/mistral-large', name: 'Mistral Large', ownedBy: 'mistralai', pricing: 'paid' }, +]; + +// ---- Route handler ------------------------------------------------------- + +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + const config = loadConfig(); + const { llm } = config; + + // Run every discovery call in parallel so the slowest provider sets the + // total latency floor, not the sum of all calls. + const [ollabridge, huggingface, groq, openai, anthropic, watsonx] = await Promise.all([ + fetchOllaBridge(llm.ollabridgeUrl, llm.ollabridgeApiKey), + fetchHuggingFace(llm.hfToken), + fetchGroq(llm.groqApiKey), + fetchOpenAI(llm.openaiApiKey), + fetchAnthropic(llm.anthropicApiKey), + fetchWatsonx(llm.watsonxApiKey, llm.watsonxProjectId, llm.watsonxUrl), + ]); + + const providers = [ollabridge, huggingface, groq, openai, anthropic, watsonx]; + const totalModels = providers.reduce((sum, p) => sum + p.models.length, 0); + const okCount = providers.filter((p) => p.ok).length; + + return NextResponse.json({ + providers, + summary: { + providers: providers.length, + providersOk: okCount, + totalModels, + fetchedAt: new Date().toISOString(), + }, + }); +} diff --git a/app/api/admin/llm-health/route.ts b/app/api/admin/llm-health/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..86cb04c9befae34a319b823b260b2a43e198031b --- /dev/null +++ b/app/api/admin/llm-health/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { loadConfig } from '@/lib/server-config'; + +/** + * GET /api/admin/llm-health — Test all LLM providers and models. + * + * Sends a minimal "Say OK" prompt to each model in the fallback chain + * and reports which ones are alive, their latency, and any errors. + * Admin-only endpoint. + * + * Token resolution order: + * 1. admin config file (set via /api/admin/config PUT) + * 2. HF_TOKEN environment variable + * This way an admin can fix a misconfigured Space without redeploying. + */ + +const HF_BASE_URL = 'https://router.huggingface.co/v1'; + +/** All models to test — matches the presets fallback chain. */ +const MODELS_TO_TEST = [ + 'meta-llama/Llama-3.3-70B-Instruct:sambanova', + 'meta-llama/Llama-3.3-70B-Instruct:together', + 'meta-llama/Llama-3.3-70B-Instruct', + 'Qwen/Qwen2.5-72B-Instruct', + 'Qwen/Qwen3-235B-A22B', + 'google/gemma-3-27b-it', + 'meta-llama/Llama-3.1-70B-Instruct', + 'Qwen/Qwen3-32B', + 'deepseek-ai/DeepSeek-V3-0324', + 'deepseek-ai/DeepSeek-R1', + 'Qwen/Qwen3-30B-A3B', + 'Qwen/Qwen2.5-Coder-32B-Instruct', +]; + +async function testModel(model: string, token: string): Promise<{ + model: string; + status: 'ok' | 'error'; + latencyMs: number; + response?: string; + error?: string; + httpStatus?: number; +}> { + const start = Date.now(); + try { + const res = await fetch(`${HF_BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: 'Say OK' }], + max_tokens: 5, + temperature: 0.1, + stream: false, + }), + signal: AbortSignal.timeout(15000), + }); + + const latencyMs = Date.now() - start; + + if (res.ok) { + const data = await res.json(); + const content = data?.choices?.[0]?.message?.content?.trim() || ''; + return { model, status: 'ok', latencyMs, response: content.slice(0, 30) }; + } else { + const text = await res.text().catch(() => ''); + const errorMsg = text.slice(0, 100); + return { model, status: 'error', latencyMs, error: errorMsg, httpStatus: res.status }; + } + } catch (e: any) { + return { + model, + status: 'error', + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (15s)' : (e?.message || 'Unknown error').slice(0, 100), + }; + } +} + +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + // Prefer admin-configured token, fall back to env var. + const config = loadConfig(); + const token = config.llm.hfToken || process.env.HF_TOKEN || ''; + + if (!token) { + // Still return a well-formed response so the UI can render an empty-state + // Provider Status panel with a helpful error banner. + return NextResponse.json({ + error: 'HF_TOKEN not configured — set it in Admin → Server → HuggingFace.', + models: MODELS_TO_TEST.map((model) => ({ + model, + status: 'error' as const, + latencyMs: 0, + error: 'No HF token configured', + })), + summary: { + total: MODELS_TO_TEST.length, + ok: 0, + error: MODELS_TO_TEST.length, + testedAt: new Date().toISOString(), + }, + }); + } + + // Test all models in parallel for speed. + const results = await Promise.all( + MODELS_TO_TEST.map((model) => testModel(model, token)) + ); + + const ok = results.filter((r) => r.status === 'ok').length; + + return NextResponse.json({ + models: results, + summary: { + total: results.length, + ok, + error: results.length - ok, + testedAt: new Date().toISOString(), + }, + }); +} diff --git a/app/api/admin/reset-password/route.ts b/app/api/admin/reset-password/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbe9457ccc8cf71e6b8d11da46bd5dc8fd50dbf1 --- /dev/null +++ b/app/api/admin/reset-password/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { getDb } from '@/lib/db'; +import { requireAdmin } from '@/lib/auth-middleware'; + +const Schema = z.object({ + userId: z.string().min(1), + newPassword: z.string().min(6, 'Password must be at least 6 characters'), +}); + +/** + * POST /api/admin/reset-password — Admin-initiated password reset. + * + * Allows admins to manually reset a user's password. This is the + * industry-standard approach for user management: the admin sets a + * temporary password and instructs the user to change it on login. + * + * Security measures: + * - Requires admin authentication + * - Passwords are bcrypt-hashed + * - All existing sessions for the user are invalidated + * - Cannot reset your own password (use profile instead) + */ +export async function POST(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + try { + const body = await req.json(); + const { userId, newPassword } = Schema.parse(body); + + const db = getDb(); + + // Verify user exists. + const user = db.prepare('SELECT id, email FROM users WHERE id = ?').get(userId) as any; + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Hash new password. + const hash = bcrypt.hashSync(newPassword, 10); + + // Update password and invalidate all sessions. + const tx = db.transaction(() => { + db.prepare('UPDATE users SET password = ?, updated_at = datetime(\'now\') WHERE id = ?').run(hash, userId); + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId); + }); + tx(); + + return NextResponse.json({ + success: true, + message: `Password reset for ${user.email}. All sessions invalidated.`, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0]?.message || 'Invalid input' }, { status: 400 }); + } + console.error('[Admin Reset Password]', error?.message); + return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 }); + } +} diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..91fc25bdc4270db5d9cb35c1c5a88c3858cb0cac --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; +import { requireAdmin } from '@/lib/auth-middleware'; + +/** + * GET /api/admin/stats — aggregate platform statistics (admin only). + */ +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + const db = getDb(); + + const totalUsers = (db.prepare('SELECT COUNT(*) as c FROM users').get() as any).c; + const verifiedUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE email_verified = 1').get() as any).c; + const adminUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE is_admin = 1').get() as any).c; + const totalHealthData = (db.prepare('SELECT COUNT(*) as c FROM health_data').get() as any).c; + const totalChats = (db.prepare('SELECT COUNT(*) as c FROM chat_history').get() as any).c; + const activeSessions = (db.prepare("SELECT COUNT(*) as c FROM sessions WHERE expires_at > datetime('now')").get() as any).c; + + // Health data breakdown by type. + const healthBreakdown = db + .prepare('SELECT type, COUNT(*) as count FROM health_data GROUP BY type ORDER BY count DESC') + .all() as any[]; + + // Registrations over time (last 30 days). + const registrations = db + .prepare( + `SELECT date(created_at) as day, COUNT(*) as count + FROM users + WHERE created_at > datetime('now', '-30 days') + GROUP BY day ORDER BY day`, + ) + .all() as any[]; + + return NextResponse.json({ + totalUsers, + verifiedUsers, + adminUsers, + totalHealthData, + totalChats, + activeSessions, + healthBreakdown, + registrations, + }); +} diff --git a/app/api/admin/test-connection/route.ts b/app/api/admin/test-connection/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6bf8bf55203b34bccce20579c7c979048598d59 --- /dev/null +++ b/app/api/admin/test-connection/route.ts @@ -0,0 +1,243 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { loadConfig } from '@/lib/server-config'; + +/** + * POST /api/admin/test-connection — Test connectivity to a named provider. + * + * Body: { provider: "ollabridge" | "huggingface" | "openai" | "anthropic" + * | "groq" | "watsonx" } + * + * Response: + * { ok: boolean, provider, latencyMs, status?, error?, details? } + * + * Used by the "Test Connection" button on each provider card. Mirrors the + * `ollabridge pair` CLI check — validates that credentials are good and + * that the provider's /v1/models (or equivalent) endpoint responds. + * + * Admin-only. + */ + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type Provider = 'ollabridge' | 'huggingface' | 'openai' | 'anthropic' | 'groq' | 'watsonx'; + +interface TestResult { + ok: boolean; + provider: Provider; + latencyMs: number; + status?: number; + error?: string; + details?: string; +} + +async function testOllaBridge(url: string, apiKey: string): Promise> { + const start = Date.now(); + if (!url) { + return { ok: false, latencyMs: 0, error: 'URL not configured' }; + } + try { + const cleanBase = url.replace(/\/+$/, ''); + const res = await fetch(`${cleanBase}/v1/models`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) { + return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + } + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.data) ? data.data.length : 0; + return { + ok: true, + latencyMs, + status: res.status, + details: `${count} model${count === 1 ? '' : 's'} available`, + }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testHuggingFace(token: string): Promise> { + const start = Date.now(); + if (!token) return { ok: false, latencyMs: 0, error: 'HF token not configured' }; + try { + // whoami-v2 validates that the token has API access; it's cheaper + // than hitting the router and gives a clear permission error. + const res = await fetch('https://huggingface.co/api/whoami-v2', { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) { + return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + } + const data = await res.json().catch(() => null); + return { + ok: true, + latencyMs, + status: res.status, + details: data?.name ? `Authenticated as ${data.name}` : 'Token valid', + }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testOpenAI(apiKey: string): Promise> { + const start = Date.now(); + if (!apiKey) return { ok: false, latencyMs: 0, error: 'OpenAI API key not configured' }; + try { + const res = await fetch('https://api.openai.com/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.data) ? data.data.length : 0; + return { ok: true, latencyMs, status: res.status, details: `${count} models visible` }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testAnthropic(apiKey: string): Promise> { + const start = Date.now(); + if (!apiKey) return { ok: false, latencyMs: 0, error: 'Anthropic API key not configured' }; + try { + const res = await fetch('https://api.anthropic.com/v1/models', { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.data) ? data.data.length : 0; + return { ok: true, latencyMs, status: res.status, details: `${count} models visible` }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testGroq(apiKey: string): Promise> { + const start = Date.now(); + if (!apiKey) return { ok: false, latencyMs: 0, error: 'Groq API key not configured' }; + try { + const res = await fetch('https://api.groq.com/openai/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.data) ? data.data.length : 0; + return { ok: true, latencyMs, status: res.status, details: `${count} models visible` }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testWatsonx( + apiKey: string, + projectId: string, + _baseUrl: string, +): Promise> { + const start = Date.now(); + if (!apiKey || !projectId) { + return { ok: false, latencyMs: 0, error: 'Missing API key or project ID' }; + } + try { + const res = await fetch('https://iam.cloud.ibm.com/identity/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ibm:params:oauth:grant-type:apikey', + apikey: apiKey, + }), + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `IAM HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + if (!data?.access_token) return { ok: false, latencyMs, error: 'No access_token in IAM response' }; + return { ok: true, latencyMs, status: 200, details: 'IAM token valid' }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +export async function POST(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + let body: any; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const provider = body?.provider as Provider; + if (!provider) { + return NextResponse.json({ error: 'Missing provider field' }, { status: 400 }); + } + + const config = loadConfig(); + const { llm } = config; + + let result: Omit; + switch (provider) { + case 'ollabridge': + result = await testOllaBridge(llm.ollabridgeUrl, llm.ollabridgeApiKey); + break; + case 'huggingface': + result = await testHuggingFace(llm.hfToken); + break; + case 'openai': + result = await testOpenAI(llm.openaiApiKey); + break; + case 'anthropic': + result = await testAnthropic(llm.anthropicApiKey); + break; + case 'groq': + result = await testGroq(llm.groqApiKey); + break; + case 'watsonx': + result = await testWatsonx(llm.watsonxApiKey, llm.watsonxProjectId, llm.watsonxUrl); + break; + default: + return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 400 }); + } + + return NextResponse.json({ provider, ...result }); +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..7793031eecbdd5e3da18d99d49b08b086518d85f --- /dev/null +++ b/app/api/admin/users/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; +import { requireAdmin } from '@/lib/auth-middleware'; + +/** + * GET /api/admin/users — list all registered users (admin only). + * Query params: ?page=1&limit=50&search=term + */ +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + const url = new URL(req.url); + const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10)); + const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10))); + const search = url.searchParams.get('search')?.trim(); + const offset = (page - 1) * limit; + + const db = getDb(); + + const where = search ? "WHERE email LIKE ? OR display_name LIKE ?" : ""; + const params = search ? [`%${search}%`, `%${search}%`] : []; + + const total = (db.prepare(`SELECT COUNT(*) as c FROM users ${where}`).get(...params) as any).c; + + const rows = db + .prepare( + `SELECT id, email, display_name, email_verified, is_admin, created_at + FROM users ${where} + ORDER BY created_at DESC LIMIT ? OFFSET ?`, + ) + .all(...params, limit, offset) as any[]; + + // Health data count per user. + const users = rows.map((r) => { + const healthCount = ( + db.prepare('SELECT COUNT(*) as c FROM health_data WHERE user_id = ?').get(r.id) as any + ).c; + const chatCount = ( + db.prepare('SELECT COUNT(*) as c FROM chat_history WHERE user_id = ?').get(r.id) as any + ).c; + return { + id: r.id, + email: r.email, + displayName: r.display_name, + emailVerified: !!r.email_verified, + isAdmin: !!r.is_admin, + createdAt: r.created_at, + healthDataCount: healthCount, + chatHistoryCount: chatCount, + }; + }); + + return NextResponse.json({ users, total, page, limit }); +} + +/** + * DELETE /api/admin/users?id= — delete a user (admin only). + * CASCADE deletes all their health data, chat history, and sessions. + */ +export async function DELETE(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + const url = new URL(req.url); + const userId = url.searchParams.get('id'); + if (!userId) return NextResponse.json({ error: 'Missing user id' }, { status: 400 }); + + // Prevent deleting yourself. + if (userId === admin.id) { + return NextResponse.json({ error: 'Cannot delete your own admin account' }, { status: 400 }); + } + + const db = getDb(); + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/auth/delete-account/route.ts b/app/api/auth/delete-account/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..64857ad49e5aadeff58d9122ea2b4bf3ea2aae6e --- /dev/null +++ b/app/api/auth/delete-account/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb } from '@/lib/db'; +import { requireAdmin } from '@/lib/auth-middleware'; + +const Schema = z.object({ + userId: z.string().min(1), + confirmEmail: z.string().email(), +}); + +/** + * POST /api/auth/delete-account — ADMIN-ONLY account deletion. + * + * Only admins can delete accounts. This prevents: + * - Hackers with stolen tokens from destroying user data + * - Automated scripts mass-deleting accounts + * - Accidental self-deletion + * + * Requires both userId AND confirmEmail to match (double verification). + * Uses CASCADE deletes via foreign keys — one operation wipes everything. + * + * For GDPR: users REQUEST deletion via support/admin panel. + * Admin reviews and executes. This is the industry standard for + * healthcare apps (MyChart, Epic, Cerner all require admin action). + */ +export async function POST(req: Request) { + // ADMIN ONLY — reject all non-admin requests + const admin = requireAdmin(req); + if (!admin) { + return NextResponse.json( + { error: 'Admin access required. Users must request account deletion through the admin.' }, + { status: 403 }, + ); + } + + try { + const body = await req.json(); + const { userId, confirmEmail } = Schema.parse(body); + + const db = getDb(); + + // Verify user exists and email matches (double check) + const user = db.prepare('SELECT id, email, is_admin FROM users WHERE id = ?').get(userId) as any; + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if (user.email.toLowerCase() !== confirmEmail.toLowerCase()) { + return NextResponse.json( + { error: 'Email confirmation does not match. Deletion aborted.' }, + { status: 400 }, + ); + } + + // Prevent deleting admin accounts (safety net) + if (user.is_admin) { + return NextResponse.json( + { error: 'Cannot delete admin accounts via this endpoint.' }, + { status: 403 }, + ); + } + + // CASCADE deletes handle: sessions, health_data, chat_history + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + + console.log(`[Account Deletion] Admin ${admin.email} deleted user ${user.email} (${userId})`); + + return NextResponse.json({ + success: true, + message: `Account ${user.email} and all associated data permanently deleted.`, + deletedBy: admin.email, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request. Provide userId and confirmEmail.' }, { status: 400 }); + } + console.error('[Delete Account]', error?.message); + return NextResponse.json({ error: 'Failed to delete account' }, { status: 500 }); + } +} diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a35fbf5ea4decd8c05522554e915072451d57769 --- /dev/null +++ b/app/api/auth/forgot-password/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb, genVerificationCode, resetExpiry } from '@/lib/db'; +import { sendPasswordResetEmail } from '@/lib/email'; + +const Schema = z.object({ + email: z.string().email(), +}); + +/** + * POST /api/auth/forgot-password — sends a reset code to the user's email. + * + * Always returns 200 even if the email doesn't exist (prevents email enumeration). + */ +export async function POST(req: Request) { + try { + const body = await req.json(); + const { email } = Schema.parse(body); + + const db = getDb(); + const user = db.prepare('SELECT id, email FROM users WHERE email = ?').get(email.toLowerCase()) as any; + + if (user) { + const code = genVerificationCode(); + db.prepare( + `UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = datetime('now') + WHERE id = ?`, + ).run(code, resetExpiry(), user.id); + + await sendPasswordResetEmail(user.email, code); + } + + // Always return success to prevent email enumeration. + return NextResponse.json({ + message: 'If that email is registered, a reset code has been sent.', + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid email' }, { status: 400 }); + } + console.error('[Auth ForgotPassword]', error?.message); + return NextResponse.json({ error: 'Request failed' }, { status: 500 }); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..8764f8b936ad0abeda2e159567d19638a357214b --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { getDb, genToken, sessionExpiry, pruneExpiredSessions } from '@/lib/db'; +import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; + +const Schema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export async function POST(req: Request) { + // Rate limit: 10 login attempts per minute per IP + const ip = getClientIp(req); + const rl = checkRateLimit(`login:${ip}`, 10, 60_000); + if (!rl.allowed) { + return NextResponse.json( + { error: 'Too many login attempts. Please wait a moment.' }, + { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) } }, + ); + } + + try { + const body = await req.json(); + const { email, password } = Schema.parse(body); + + const db = getDb(); + pruneExpiredSessions(); + + const user = db + .prepare('SELECT id, email, password, display_name, email_verified, is_admin FROM users WHERE email = ?') + .get(email.toLowerCase()) as any; + + if (!user || !bcrypt.compareSync(password, user.password)) { + return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 }); + } + + const token = genToken(); + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run( + token, + user.id, + sessionExpiry(), + ); + + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + displayName: user.display_name, + emailVerified: !!user.email_verified, + isAdmin: !!user.is_admin, + }, + token, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid input' }, { status: 400 }); + } + console.error('[Auth Login]', error?.message); + return NextResponse.json({ error: 'Login failed' }, { status: 500 }); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..6aef5208bb4e2df890e40f58f67466227cde112c --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; + +export async function POST(req: Request) { + const h = req.headers.get('authorization'); + const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null; + + if (token) { + const db = getDb(); + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); + } + + return NextResponse.json({ success: true }); +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cc0b7a4616cd13c8b8c5f794b0a235b0ec0e4fc --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { getDb, pruneExpiredSessions } from '@/lib/db'; + +export async function GET(req: Request) { + const h = req.headers.get('authorization'); + const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null; + if (!token) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const db = getDb(); + pruneExpiredSessions(); + + const row = db + .prepare( + `SELECT u.id, u.email, u.display_name, u.email_verified, u.is_admin, u.created_at + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token = ? AND s.expires_at > datetime('now')`, + ) + .get(token) as any; + + if (!row) return NextResponse.json({ error: 'Session expired' }, { status: 401 }); + + return NextResponse.json({ + user: { + id: row.id, + email: row.email, + displayName: row.display_name, + emailVerified: !!row.email_verified, + isAdmin: !!row.is_admin, + createdAt: row.created_at, + }, + }); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ec4e1fb785c383cc0581e1ff260a53c53a9111f --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { getDb, genId, genToken, genVerificationCode, codeExpiry, sessionExpiry } from '@/lib/db'; +import { sendVerificationEmail } from '@/lib/email'; +import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; + +const Schema = z.object({ + email: z.string().email().max(255), + password: z.string().min(6).max(128), + displayName: z.string().max(50).optional(), +}); + +export async function POST(req: Request) { + // Rate limit: 5 registrations per minute per IP + const ip = getClientIp(req); + const rl = checkRateLimit(`register:${ip}`, 5, 60_000); + if (!rl.allowed) { + return NextResponse.json( + { error: 'Too many registration attempts. Please wait.' }, + { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) } }, + ); + } + + try { + const body = await req.json(); + const { email, password, displayName } = Schema.parse(body); + + const db = getDb(); + + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); + if (existing) { + return NextResponse.json({ error: 'An account with this email already exists' }, { status: 409 }); + } + + const id = genId(); + const hash = bcrypt.hashSync(password, 10); + const code = genVerificationCode(); + const expires = codeExpiry(); + + db.prepare( + `INSERT INTO users (id, email, password, display_name, verification_code, verification_expires) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, email.toLowerCase(), hash, displayName || null, code, expires); + + // Auto-login + const token = genToken(); + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(token, id, sessionExpiry()); + + // Send verification email (best-effort, don't block registration) + sendVerificationEmail(email, code).catch(() => {}); + + return NextResponse.json( + { + user: { id, email: email.toLowerCase(), displayName, emailVerified: false }, + token, + message: 'Account created. Check your email for a verification code.', + }, + { status: 201 }, + ); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 }); + } + console.error('[Auth Register]', error?.message); + return NextResponse.json({ error: 'Registration failed' }, { status: 500 }); + } +} diff --git a/app/api/auth/resend-verification/route.ts b/app/api/auth/resend-verification/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..017e2cf7f9807aa7a94e9bf48d332bda9d4756be --- /dev/null +++ b/app/api/auth/resend-verification/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import { getDb, genVerificationCode, codeExpiry } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { sendVerificationEmail } from '@/lib/email'; + +/** + * POST /api/auth/resend-verification — resend the 6-digit verification code. + */ +export async function POST(req: Request) { + const user = authenticateRequest(req); + if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const db = getDb(); + const row = db.prepare('SELECT email, email_verified FROM users WHERE id = ?').get(user.id) as any; + + if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + if (row.email_verified) return NextResponse.json({ message: 'Email already verified' }); + + const code = genVerificationCode(); + db.prepare( + `UPDATE users SET verification_code = ?, verification_expires = ?, updated_at = datetime('now') + WHERE id = ?`, + ).run(code, codeExpiry(), user.id); + + await sendVerificationEmail(row.email, code); + + return NextResponse.json({ message: 'Verification code sent' }); +} diff --git a/app/api/auth/reset-password/route.ts b/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..61ac4178ef9fdf1f185eefb191cf4ef05e57dd56 --- /dev/null +++ b/app/api/auth/reset-password/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { getDb, genToken, sessionExpiry } from '@/lib/db'; + +const Schema = z.object({ + email: z.string().email(), + code: z.string().length(6), + newPassword: z.string().min(6).max(128), +}); + +/** + * POST /api/auth/reset-password — reset password with the 6-digit code. + * On success, auto-logs the user in and returns a session token. + */ +export async function POST(req: Request) { + try { + const body = await req.json(); + const { email, code, newPassword } = Schema.parse(body); + + const db = getDb(); + const user = db + .prepare('SELECT id, reset_token, reset_expires FROM users WHERE email = ?') + .get(email.toLowerCase()) as any; + + if ( + !user || + user.reset_token !== code || + !user.reset_expires || + new Date(user.reset_expires) < new Date() + ) { + return NextResponse.json({ error: 'Invalid or expired reset code' }, { status: 400 }); + } + + const hash = bcrypt.hashSync(newPassword, 10); + db.prepare( + `UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL, updated_at = datetime('now') + WHERE id = ?`, + ).run(hash, user.id); + + // Invalidate all existing sessions for this user (security best practice). + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id); + + // Auto-login with new session. + const token = genToken(); + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run( + token, + user.id, + sessionExpiry(), + ); + + return NextResponse.json({ + message: 'Password reset successfully', + token, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 }); + } + console.error('[Auth ResetPassword]', error?.message); + return NextResponse.json({ error: 'Reset failed' }, { status: 500 }); + } +} diff --git a/app/api/auth/verify-email/route.ts b/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..42cd45b2cfa147145b6add629c387470b189eeef --- /dev/null +++ b/app/api/auth/verify-email/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { sendWelcomeEmail } from '@/lib/email'; + +const Schema = z.object({ + code: z.string().length(6), +}); + +/** + * POST /api/auth/verify-email — verify email with 6-digit code. + * Requires auth (the user must be logged in to verify their own email). + */ +export async function POST(req: Request) { + const user = authenticateRequest(req); + if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + try { + const body = await req.json(); + const { code } = Schema.parse(body); + + const db = getDb(); + const row = db + .prepare( + `SELECT verification_code, verification_expires, email, email_verified + FROM users WHERE id = ?`, + ) + .get(user.id) as any; + + if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + if (row.email_verified) return NextResponse.json({ message: 'Email already verified' }); + + if ( + row.verification_code !== code || + !row.verification_expires || + new Date(row.verification_expires) < new Date() + ) { + return NextResponse.json({ error: 'Invalid or expired code' }, { status: 400 }); + } + + db.prepare( + `UPDATE users SET email_verified = 1, verification_code = NULL, verification_expires = NULL, updated_at = datetime('now') + WHERE id = ?`, + ).run(user.id); + + // Send welcome email + sendWelcomeEmail(row.email).catch(() => {}); + + return NextResponse.json({ message: 'Email verified successfully', emailVerified: true }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid code format' }, { status: 400 }); + } + console.error('[Auth Verify]', error?.message); + return NextResponse.json({ error: 'Verification failed' }, { status: 500 }); + } +} diff --git a/app/api/chat-history/route.ts b/app/api/chat-history/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..dce5eab45f5dbb5608439f9ddbffe38ab5193f3c --- /dev/null +++ b/app/api/chat-history/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb, genId } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; + +/** + * GET /api/chat-history → list conversations (newest first, max 100) + * POST /api/chat-history → save a conversation + * DELETE /api/chat-history?id= → delete one conversation + */ + +export async function GET(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const db = getDb(); + const rows = db + .prepare( + 'SELECT id, preview, topic, created_at FROM chat_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 100', + ) + .all(user.id) as any[]; + + return NextResponse.json({ + conversations: rows.map((r) => ({ + id: r.id, + preview: r.preview, + topic: r.topic, + createdAt: r.created_at, + })), + }); +} + +const SaveSchema = z.object({ + preview: z.string().max(200), + messages: z.array( + z.object({ + role: z.enum(['user', 'assistant', 'system']), + content: z.string(), + }), + ), + topic: z.string().max(50).optional(), +}); + +export async function POST(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const body = await req.json(); + const { preview, messages, topic } = SaveSchema.parse(body); + + const db = getDb(); + const id = genId(); + + db.prepare( + 'INSERT INTO chat_history (id, user_id, preview, messages, topic) VALUES (?, ?, ?, ?, ?)', + ).run(id, user.id, preview, JSON.stringify(messages), topic || null); + + return NextResponse.json({ id }, { status: 201 }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.errors }, + { status: 400 }, + ); + } + console.error('[Chat History POST]', error?.message); + return NextResponse.json({ error: 'Save failed' }, { status: 500 }); + } +} + +export async function DELETE(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const url = new URL(req.url); + const id = url.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + } + + const db = getDb(); + db.prepare('DELETE FROM chat_history WHERE id = ? AND user_id = ?').run( + id, + user.id, + ); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..008b8744091a87b7ecc691ed91622073e3ccc864 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,173 @@ +import { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { streamWithFallback, type ChatMessage } from '@/lib/providers'; +import { triageMessage } from '@/lib/safety/triage'; +import { getEmergencyInfo } from '@/lib/safety/emergency-numbers'; +import { buildRAGContext } from '@/lib/rag/medical-kb'; +import { buildMedicalSystemPrompt } from '@/lib/medical-knowledge'; + +const RequestSchema = z.object({ + messages: z.array( + z.object({ + role: z.enum(['system', 'user', 'assistant']), + content: z.string(), + }) + ), + model: z.string().optional().default('qwen2.5:1.5b'), + language: z.string().optional().default('en'), + countryCode: z.string().optional().default('US'), +}); + +export async function POST(request: NextRequest) { + const routeStartedAt = Date.now(); + try { + const body = await request.json(); + const { messages, model, language, countryCode } = RequestSchema.parse(body); + + // Single-line JSON payload so the HF Space logs API (SSE) can be grepped + // with a simple prefix match. Every stage below tags itself `[Chat]`. + console.log( + `[Chat] route.enter ${JSON.stringify({ + turns: messages.length, + model, + language, + countryCode, + userAgent: request.headers.get('user-agent')?.slice(0, 80) || null, + })}`, + ); + + // Step 1: Emergency triage on the latest user message + const lastUserMessage = messages.filter((m) => m.role === 'user').pop(); + if (lastUserMessage) { + const triage = triageMessage(lastUserMessage.content); + console.log( + `[Chat] route.triage ${JSON.stringify({ + isEmergency: triage.isEmergency, + userChars: lastUserMessage.content.length, + })}`, + ); + + if (triage.isEmergency) { + const emergencyInfo = getEmergencyInfo(countryCode); + const emergencyResponse = [ + `**EMERGENCY DETECTED**\n\n`, + `${triage.guidance}\n\n`, + `**Call emergency services NOW:**\n`, + `- Emergency: **${emergencyInfo.emergency}** (${emergencyInfo.country})\n`, + `- Ambulance: **${emergencyInfo.ambulance}**\n`, + emergencyInfo.crisisHotline + ? `- Crisis Hotline: **${emergencyInfo.crisisHotline}**\n` + : '', + `\nDo not delay. Every minute matters.`, + ].join(''); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const data = JSON.stringify({ + choices: [{ delta: { content: emergencyResponse } }], + provider: 'triage', + model: 'emergency-detection', + isEmergency: true, + }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); + } + } + + // Step 2: Build RAG context from the medical knowledge base. + const ragStart = Date.now(); + const ragContext = lastUserMessage + ? buildRAGContext(lastUserMessage.content) + : ''; + console.log( + `[Chat] route.rag ${JSON.stringify({ + chars: ragContext.length, + latencyMs: Date.now() - ragStart, + })}`, + ); + + // Step 3: Build a structured, locale-aware system prompt that grounds + // the model in WHO/CDC/NHS guidance and pins the response language, + // country, emergency number, and measurement system. This replaces + // the old inline "respond in X language" instruction. + const emergencyInfo = getEmergencyInfo(countryCode); + const systemPrompt = buildMedicalSystemPrompt({ + country: countryCode, + language, + emergencyNumber: emergencyInfo.emergency, + }); + + // Step 4: Assemble the final message list: system prompt first, then + // the conversation history, with the last user turn augmented by the + // retrieved RAG context (kept inline so the model treats it as recent + // reference material rather than a background instruction). + const priorMessages = messages.slice(0, -1); + const finalUserContent = [ + lastUserMessage?.content || '', + ragContext + ? `\n\n[Reference material retrieved from the medical knowledge base — use if relevant]\n${ragContext}` + : '', + ].join(''); + + const augmentedMessages: ChatMessage[] = [ + { role: 'system' as const, content: systemPrompt }, + ...priorMessages, + { role: 'user' as const, content: finalUserContent }, + ]; + + // Step 4: Stream response via the provider fallback chain. + console.log( + `[Chat] route.provider.dispatch ${JSON.stringify({ + systemPromptChars: systemPrompt.length, + totalMessages: augmentedMessages.length, + preparedInMs: Date.now() - routeStartedAt, + })}`, + ); + const stream = await streamWithFallback(augmentedMessages, model); + console.log( + `[Chat] route.stream.opened ${JSON.stringify({ + totalMs: Date.now() - routeStartedAt, + })}`, + ); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); + } catch (error) { + console.error( + `[Chat] route.error ${JSON.stringify({ + totalMs: Date.now() - routeStartedAt, + name: (error as any)?.name, + message: String((error as any)?.message || error).slice(0, 200), + })}`, + ); + + if (error instanceof z.ZodError) { + return new Response( + JSON.stringify({ error: 'Invalid request', details: error.errors }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +} diff --git a/app/api/geo/route.ts b/app/api/geo/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..e32a05e8715e96a1f7246b314b5bed8e17ebd978 --- /dev/null +++ b/app/api/geo/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from 'next/server'; +import { getEmergencyInfo } from '@/lib/safety/emergency-numbers'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * IP-based country + language + emergency number detection. + * + * Privacy posture: + * - Platform geo headers are read first (zero external calls, zero PII). + * - If nothing is present we fall back to ipapi.co (free, no key), but + * ONLY for public IPs — RFC1918, loopback, and link-local are never + * sent outbound. + * - The client IP is never logged or returned. + */ + +const GEO_HEADERS = [ + 'x-vercel-ip-country', + 'cf-ipcountry', + 'x-nf-country', + 'cloudfront-viewer-country', + 'x-appengine-country', + 'fly-client-ip-country', + 'x-forwarded-country', +] as const; + +// Country → best-effort primary language out of the ones MedOS ships. +// Kept local to this file so we don't bloat lib/i18n for a single use. +const COUNTRY_TO_LANGUAGE: Record = { + US: 'en', GB: 'en', CA: 'en', AU: 'en', NZ: 'en', IE: 'en', ZA: 'en', + NG: 'en', KE: 'en', GH: 'en', UG: 'en', SG: 'en', MY: 'en', IN: 'en', + PK: 'en', BD: 'en', LK: 'en', PH: 'en', + ES: 'es', MX: 'es', AR: 'es', CO: 'es', CL: 'es', PE: 'es', VE: 'es', + EC: 'es', GT: 'es', CU: 'es', BO: 'es', DO: 'es', HN: 'es', PY: 'es', + SV: 'es', NI: 'es', CR: 'es', PA: 'es', UY: 'es', PR: 'es', + BR: 'pt', PT: 'pt', AO: 'pt', MZ: 'pt', + FR: 'fr', BE: 'fr', LU: 'fr', MC: 'fr', SN: 'fr', CI: 'fr', CM: 'fr', + CD: 'fr', HT: 'fr', DZ: 'fr', TN: 'fr', MA: 'ar', + DE: 'de', AT: 'de', CH: 'de', LI: 'de', + IT: 'it', SM: 'it', VA: 'it', + NL: 'nl', SR: 'nl', + PL: 'pl', + RU: 'ru', BY: 'ru', KZ: 'ru', KG: 'ru', + TR: 'tr', + SA: 'ar', AE: 'ar', EG: 'ar', JO: 'ar', IQ: 'ar', SY: 'ar', LB: 'ar', + YE: 'ar', LY: 'ar', OM: 'ar', QA: 'ar', KW: 'ar', BH: 'ar', SD: 'ar', + PS: 'ar', + CN: 'zh', TW: 'zh', HK: 'zh', + JP: 'ja', + KR: 'ko', + TH: 'th', + VN: 'vi', + TZ: 'sw', +}; + +function pickHeaderCountry(req: Request): string | null { + for (const h of GEO_HEADERS) { + const v = req.headers.get(h); + if (v && v.length >= 2 && v.toUpperCase() !== 'XX') { + return v.toUpperCase().slice(0, 2); + } + } + return null; +} + +function extractClientIp(req: Request): string | null { + const xff = req.headers.get('x-forwarded-for'); + if (xff) { + const first = xff.split(',')[0]?.trim(); + if (first) return first; + } + return req.headers.get('x-real-ip'); +} + +function isPrivateIp(ip: string): boolean { + if (!ip) return true; + if (ip === '::1' || ip === '127.0.0.1' || ip.startsWith('fe80:')) return true; + const m = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (!m) return false; + const a = parseInt(m[1], 10); + const b = parseInt(m[2], 10); + if (a === 10) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + return false; +} + +async function lookupIpapi(ip: string): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1500); + const res = await fetch(`https://ipapi.co/${encodeURIComponent(ip)}/country/`, { + signal: controller.signal, + headers: { 'User-Agent': 'MedOS-Geo/1.0' }, + }); + clearTimeout(timeout); + if (!res.ok) return null; + const text = (await res.text()).trim().toUpperCase(); + if (/^[A-Z]{2}$/.test(text)) return text; + return null; + } catch { + return null; + } +} + +export async function GET(req: Request): Promise { + let country = pickHeaderCountry(req); + let source: 'header' | 'ipapi' | 'default' = country ? 'header' : 'default'; + + if (!country) { + const ip = extractClientIp(req); + if (ip && !isPrivateIp(ip)) { + const looked = await lookupIpapi(ip); + if (looked) { + country = looked; + source = 'ipapi'; + } + } + } + + const finalCountry = country || 'US'; + const info = getEmergencyInfo(finalCountry); + const language = COUNTRY_TO_LANGUAGE[finalCountry] ?? 'en'; + + return NextResponse.json( + { + country: finalCountry, + language, + emergencyNumber: info.emergency, + source, + }, + { + headers: { + 'Cache-Control': 'private, max-age=3600', + 'X-Robots-Tag': 'noindex', + }, + }, + ); +} diff --git a/app/api/health-data/route.ts b/app/api/health-data/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c26bba00d9013d39d6f944100c4f8029ae1687a --- /dev/null +++ b/app/api/health-data/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb, genId } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; + +/** + * GET /api/health-data → fetch all health data for the user + * GET /api/health-data?type=vital → filter by type + * POST /api/health-data/sync → bulk sync from client localStorage + */ +export async function GET(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const db = getDb(); + const url = new URL(req.url); + const type = url.searchParams.get('type'); + + const rows = type + ? db + .prepare('SELECT * FROM health_data WHERE user_id = ? AND type = ? ORDER BY updated_at DESC') + .all(user.id, type) + : db + .prepare('SELECT * FROM health_data WHERE user_id = ? ORDER BY updated_at DESC') + .all(user.id); + + // Parse the JSON `data` field back into objects. + const items = (rows as any[]).map((r) => ({ + id: r.id, + type: r.type, + data: JSON.parse(r.data), + createdAt: r.created_at, + updatedAt: r.updated_at, + })); + + return NextResponse.json({ items }); +} + +/** + * POST /api/health-data — upsert a single health-data record. + */ +const UpsertSchema = z.object({ + id: z.string().optional(), + type: z.enum([ + 'medication', + 'medication_log', + 'appointment', + 'vital', + 'record', + 'conversation', + ]), + data: z.record(z.any()), +}); + +export async function POST(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const body = await req.json(); + const { id, type, data } = UpsertSchema.parse(body); + + const db = getDb(); + const itemId = id || genId(); + const json = JSON.stringify(data); + + // Upsert: insert or replace. SQLite's ON CONFLICT handles this cleanly. + db.prepare( + `INSERT INTO health_data (id, user_id, type, data, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`, + ).run(itemId, user.id, type, json); + + return NextResponse.json({ id: itemId, type }, { status: 201 }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.errors }, + { status: 400 }, + ); + } + console.error('[Health Data POST]', error?.message); + return NextResponse.json({ error: 'Save failed' }, { status: 500 }); + } +} + +/** + * DELETE /api/health-data?id= — delete one record. + */ +export async function DELETE(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const url = new URL(req.url); + const id = url.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + } + + const db = getDb(); + db.prepare('DELETE FROM health_data WHERE id = ? AND user_id = ?').run( + id, + user.id, + ); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/health-data/sync/route.ts b/app/api/health-data/sync/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..74bf2b9d033ed9bf8978f25a07a09895f8b02b83 --- /dev/null +++ b/app/api/health-data/sync/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb, genId } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; + +/** + * POST /api/health-data/sync — bulk sync from client localStorage. + * + * The client sends its entire localStorage health dataset (medications, + * appointments, vitals, records, medication_logs, conversations). The + * server upserts each item. This runs on: + * - First login (migrates existing guest data to the account) + * - Periodic background sync while logged in + * + * Idempotent: calling it twice with the same data is safe. + */ + +const ItemSchema = z.object({ + id: z.string(), + type: z.enum([ + 'medication', + 'medication_log', + 'appointment', + 'vital', + 'record', + 'conversation', + ]), + data: z.record(z.any()), +}); + +const SyncSchema = z.object({ + items: z.array(ItemSchema).max(5000), +}); + +export async function POST(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const body = await req.json(); + const { items } = SyncSchema.parse(body); + + const db = getDb(); + + const upsert = db.prepare( + `INSERT INTO health_data (id, user_id, type, data, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`, + ); + + // Run as a single transaction for speed (1000+ items in <50ms). + const tx = db.transaction(() => { + for (const item of items) { + upsert.run(item.id, user.id, item.type, JSON.stringify(item.data)); + } + }); + tx(); + + return NextResponse.json({ + synced: items.length, + message: `${items.length} items synced`, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.errors }, + { status: 400 }, + ); + } + console.error('[Health Data Sync]', error?.message); + return NextResponse.json({ error: 'Sync failed' }, { status: 500 }); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..72cb1d23dab16204a55ddf882fb9d0754caafb83 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ + status: 'healthy', + service: 'medos-global', + timestamp: new Date().toISOString(), + version: '1.0.0', + }); +} diff --git a/app/api/models/route.ts b/app/api/models/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..50beeb32319c53bf2ac5749ead76accf6c363538 --- /dev/null +++ b/app/api/models/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { fetchAvailableModels } from '@/lib/providers/ollabridge-models'; + +export async function GET() { + try { + const models = await fetchAvailableModels(); + return NextResponse.json({ models }); + } catch { + return NextResponse.json( + { models: [], error: 'Failed to fetch models' }, + { status: 200 } // Return 200 with empty array — non-critical endpoint + ); + } +} diff --git a/app/api/nearby/route.ts b/app/api/nearby/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7f8571994d98cca954a749476aca2149c18abf8 --- /dev/null +++ b/app/api/nearby/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from 'next/server'; + +/** + * POST /api/nearby — Proxy to MetaEngine Nearby Finder. + * GET /api/nearby — Health check. + * + * Calls the Gradio API endpoint (2-step: submit → fetch result). + * Handles sleeping Spaces, timeouts, and Overpass errors gracefully. + */ + +const NEARBY_URL = + process.env.NEARBY_URL || 'https://ruslanmv-metaengine-nearby.hf.space'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request) { + try { + const body = await req.json(); + const { lat, lon, radius_m = 3000, entity_type = 'all', limit = 25 } = body; + + // Step 1: Submit to Gradio API + const submitRes = await fetch(`${NEARBY_URL}/gradio_api/call/search_ui`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: [String(lat), String(lon), radius_m, entity_type, limit], + }), + signal: AbortSignal.timeout(30000), + }); + + if (!submitRes.ok) { + const ct = submitRes.headers.get('content-type') || ''; + if (!ct.includes('json')) { + return NextResponse.json( + { error: 'Nearby finder is waking up. Please try again in a moment.', count: 0, results: [] }, + { status: 503 }, + ); + } + return NextResponse.json({ error: 'Search submission failed', count: 0, results: [] }, { status: 502 }); + } + + const { event_id } = await submitRes.json(); + if (!event_id) { + return NextResponse.json({ error: 'No event ID received', count: 0, results: [] }, { status: 502 }); + } + + // Step 2: Fetch result via SSE + const resultRes = await fetch( + `${NEARBY_URL}/gradio_api/call/search_ui/${event_id}`, + { signal: AbortSignal.timeout(30000) }, + ); + + const text = await resultRes.text(); + + // Parse SSE data line: "data: [summary, table, json_string]" + const dataLine = text.split('\n').find((l: string) => l.startsWith('data: ')); + if (!dataLine) { + return NextResponse.json({ error: 'Empty response from search', count: 0, results: [] }); + } + + const gradioData = JSON.parse(dataLine.slice(6)); + // gradioData = [summary_text, table_array, json_string] + const jsonStr = gradioData?.[2]; + if (!jsonStr) { + return NextResponse.json({ error: 'No results', count: 0, results: [] }); + } + + try { + const parsed = JSON.parse(jsonStr); + return NextResponse.json(parsed); + } catch { + // If the json_str is an error message, return it + return NextResponse.json({ error: jsonStr, count: 0, results: [] }); + } + } catch (error: any) { + console.error('[Nearby Proxy]', error?.name, error?.message?.slice(0, 100)); + const msg = + error?.name === 'TimeoutError' || error?.name === 'AbortError' + ? 'Search timed out. The service may be starting up — please try again.' + : 'Nearby finder unavailable. Please try again.'; + return NextResponse.json({ error: msg, count: 0, results: [] }, { status: 502 }); + } +} + +export async function GET() { + try { + const res = await fetch(NEARBY_URL, { signal: AbortSignal.timeout(8000) }); + if (res.ok) return NextResponse.json({ status: 'ok' }); + return NextResponse.json({ status: 'waking' }, { status: 503 }); + } catch { + return NextResponse.json({ status: 'sleeping' }, { status: 503 }); + } +} diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx new file mode 100644 index 0000000000000000000000000000000000000000..25337551143cda6008c18f805697b8b84012a3c3 --- /dev/null +++ b/app/api/og/route.tsx @@ -0,0 +1,207 @@ +import { ImageResponse } from 'next/og'; + +export const runtime = 'edge'; + +/** + * Dynamic Open Graph image endpoint. + * + * Every share on Twitter / WhatsApp / LinkedIn / Telegram / iMessage + * renders a branded 1200x630 card. The query becomes the card title so + * a link like `https://ruslanmv-medibot.hf.space/?q=chest+pain` previews + * as a premium, unique image instead of the default favicon blob. + * + * Usage from the client: `/api/og?q=&lang=` + * The endpoint also handles missing parameters gracefully (returns a + * default brand card). + */ +export async function GET(req: Request): Promise { + try { + const { searchParams } = new URL(req.url); + const rawQuery = (searchParams.get('q') || '').trim(); + const lang = (searchParams.get('lang') || 'en').slice(0, 5); + + // Hard-limit title length so long queries don't overflow. + const title = + rawQuery.length > 120 ? rawQuery.slice(0, 117) + '…' : rawQuery; + + const subtitle = title + ? 'Ask MedOS — free, private, in your language' + : 'Free AI medical assistant — 20 languages, no sign-up'; + + const headline = title || 'Tell me what\'s bothering you.'; + + return new ImageResponse( + ( +
+ {/* Top bar: brand mark + language chip */} +
+
+
+ ♥ +
+
+
+ MedOS +
+
+ Worldwide medical AI +
+
+
+ +
+ + {lang.toUpperCase()} · FREE · NO SIGN-UP +
+
+ + {/* Main content */} +
+ {title && ( +
+ Ask MedOS +
+ )} +
+ {title ? `"${headline}"` : headline} +
+
+ {subtitle} +
+
+ + {/* Footer: trust strip */} +
+ + ✓ Aligned with WHO · CDC · NHS + + · + Private & anonymous + · + 24/7 +
+
+ ), + { + width: 1200, + height: 630, + }, + ); + } catch { + // Never 500 an OG endpoint — social crawlers will blacklist the domain. + return new Response('OG image generation failed', { status: 500 }); + } +} diff --git a/app/api/rag/route.ts b/app/api/rag/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2d675273b86e6b43ebcdd8db6f418289ece99a9 --- /dev/null +++ b/app/api/rag/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { searchMedicalKB } from '@/lib/rag/medical-kb'; + +export async function POST(request: NextRequest) { + try { + const { query, topN = 3 } = await request.json(); + + if (!query || typeof query !== 'string') { + return NextResponse.json( + { error: 'Query is required' }, + { status: 400 } + ); + } + + const results = searchMedicalKB(query, topN); + + return NextResponse.json({ + results: results.map((r) => ({ + topic: r.topic, + context: r.context, + })), + count: results.length, + }); + } catch { + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/scan/route.ts b/app/api/scan/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..42c000df6d95b5259c25c51d7d406aa2e3f3467d --- /dev/null +++ b/app/api/scan/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server'; + +/** + * POST /api/scan — Server-side proxy to the Medicine Scanner Space. + * + * Why proxy instead of calling from the browser: + * - HF_TOKEN_INFERENCE stays server-side (never in the JS bundle) + * - Same-origin request from the browser (no CORS preflight) + * - Backend injects the token and forwards to the Scanner Space + * - If the Scanner Space is sleeping, this request wakes it + * + * The Scanner Space receives: + * - The image as multipart/form-data (passthrough) + * - Authorization: Bearer header with the inference token + * - Returns structured JSON with medicine data + */ + +const SCANNER_URL = + process.env.SCANNER_URL || 'https://ruslanmv-medicine-scanner.hf.space'; +const INFERENCE_TOKEN = process.env.HF_TOKEN_INFERENCE || ''; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request) { + try { + // Read the incoming form data (image file from the frontend) + const formData = await req.formData(); + + // Build outbound headers — inject the inference token server-side + const headers: Record = {}; + if (INFERENCE_TOKEN) { + headers['Authorization'] = `Bearer ${INFERENCE_TOKEN}`; + } + + // Forward to the Medicine Scanner Space + const response = await fetch(`${SCANNER_URL}/api/scan`, { + method: 'POST', + headers, + body: formData, + }); + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (error: any) { + console.error('[Scan Proxy]', error?.message); + return NextResponse.json( + { + success: false, + error: 'Medicine scanner unavailable. Please try again.', + medicine: null, + }, + { status: 502 }, + ); + } +} + +/** + * GET /api/scan/health — Check if the Scanner Space is awake. + * Used by the frontend to show "waking up" status. + */ +export async function GET() { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(`${SCANNER_URL}/api/health`, { + signal: controller.signal, + }); + clearTimeout(timeout); + + if (res.ok) { + const data = await res.json(); + return NextResponse.json(data); + } + return NextResponse.json({ status: 'unavailable' }, { status: 503 }); + } catch { + return NextResponse.json({ status: 'sleeping' }, { status: 503 }); + } +} diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..33c0158647314083869979235622a8678f8410a0 --- /dev/null +++ b/app/api/sessions/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +/** + * Server-side session counter. + * Stores count in /tmp/medos-data/sessions.json (persists across requests, resets on container restart). + * On HF Spaces with persistent storage, use /data/ instead of /tmp/. + * + * GET /api/sessions → returns { count: number } + * POST /api/sessions → increments and returns { count: number } + */ + +const DATA_DIR = process.env.PERSISTENT_DIR || '/tmp/medos-data'; +const COUNTER_FILE = join(DATA_DIR, 'sessions.json'); +const BASE_COUNT = 423000; // Historical base from before server-side tracking + +interface CounterData { + count: number; + lastUpdated: string; +} + +function ensureDir(): void { + if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); + } +} + +function readCounter(): number { + ensureDir(); + try { + if (existsSync(COUNTER_FILE)) { + const data: CounterData = JSON.parse(readFileSync(COUNTER_FILE, 'utf8')); + return data.count; + } + } catch { + // corrupted file, reset + } + return 0; +} + +function incrementCounter(): number { + ensureDir(); + const current = readCounter(); + const next = current + 1; + const data: CounterData = { + count: next, + lastUpdated: new Date().toISOString(), + }; + writeFileSync(COUNTER_FILE, JSON.stringify(data), 'utf8'); + return next; +} + +export async function GET() { + const sessionCount = readCounter(); + return NextResponse.json({ + count: BASE_COUNT + sessionCount, + sessions: sessionCount, + base: BASE_COUNT, + }); +} + +export async function POST() { + const sessionCount = incrementCounter(); + return NextResponse.json({ + count: BASE_COUNT + sessionCount, + sessions: sessionCount, + base: BASE_COUNT, + }); +} diff --git a/app/api/triage/route.ts b/app/api/triage/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..68b1c18de133939e8f99ebfd222ea46597a23afa --- /dev/null +++ b/app/api/triage/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { triageMessage } from '@/lib/safety/triage'; +import { getEmergencyInfo } from '@/lib/safety/emergency-numbers'; + +export async function POST(request: NextRequest) { + try { + const { message, countryCode = 'US' } = await request.json(); + + if (!message || typeof message !== 'string') { + return NextResponse.json( + { error: 'Message is required' }, + { status: 400 } + ); + } + + const triage = triageMessage(message); + const emergencyInfo = getEmergencyInfo(countryCode); + + return NextResponse.json({ + ...triage, + emergencyInfo: triage.isEmergency ? emergencyInfo : null, + }); + } catch { + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..0dea31359bbab2177f1579406d75ffc131b33a80 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,378 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ============================================================ + * MedOS design tokens — light + dark + * ============================================================ */ +:root { + /* Surfaces (light mode — soft white, never pure #FFFFFF) */ + --surface-0: 247 249 251; /* app backdrop #F7F9FB */ + --surface-1: 255 255 255; /* cards */ + --surface-2: 241 245 249; /* elevated panels #F1F5F9 */ + --surface-3: 226 232 240; /* borders / rails */ + + --ink-base: 15 23 42; /* slate-900 */ + --ink-muted: 71 85 105; /* slate-600 */ + --ink-subtle: 148 163 184; /* slate-400 */ + --ink-inverse: 255 255 255; + + --line: 226 232 240; /* slate-200 */ + + color-scheme: light; +} + +.dark { + /* Dark mode — warm deep navy, NOT pure black */ + --surface-0: 11 18 32; /* #0B1220 */ + --surface-1: 18 27 45; /* #121B2D elevated card */ + --surface-2: 24 34 54; /* #182236 panel */ + --surface-3: 34 46 71; /* #222E47 border */ + + --ink-base: 241 245 249; /* slate-100 */ + --ink-muted: 148 163 184; /* slate-400 */ + --ink-subtle: 100 116 139; /* slate-500 */ + --ink-inverse: 15 23 42; + + --line: 34 46 71; + + color-scheme: dark; +} + +@layer base { + html, + body { + @apply h-full w-full; + /* iOS Safari 100vh fix: dvh accounts for the collapsible address bar. + Falls back to 100vh for browsers that don't support dvh. */ + height: 100vh; + height: 100dvh; + } + + html { + font-family: var(--font-sans), Inter, + /* CJK fallbacks (Chinese, Japanese, Korean) */ + "Noto Sans CJK SC", "PingFang SC", "Hiragino Sans", "MS Gothic", + "Malgun Gothic", "Apple SD Gothic Neo", + /* Arabic */ + "Noto Sans Arabic", "Geeza Pro", "Tahoma", + /* Devanagari (Hindi) */ + "Noto Sans Devanagari", + /* Thai */ + "Noto Sans Thai", + /* System fallbacks */ + ui-sans-serif, system-ui, -apple-system, + "Segoe UI", Roboto, sans-serif; + font-feature-settings: "cv02", "cv03", "cv04", "cv11", "ss01"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + + body { + @apply text-ink-base antialiased; + background: theme("backgroundImage.light-app"); + background-attachment: fixed; + line-height: 1.6; + letter-spacing: -0.005em; + } + + .dark body { + background: theme("backgroundImage.dark-app"); + background-attachment: fixed; + } + + /* Slightly larger, more readable body copy — medical trust */ + p { line-height: 1.65; } + + /* Focus rings that are visible in both modes */ + :focus-visible { + outline: 2px solid rgb(var(--color-brand, 59 130 246)); + outline-offset: 2px; + border-radius: 8px; + } +} + +@layer components { + .glass-card { + @apply bg-surface-1/80 backdrop-blur-xl border border-line/60 shadow-soft; + } + .glass-strong { + @apply bg-surface-1/95 backdrop-blur-2xl border border-line/70 shadow-card; + } + /* Section headings inside an AI answer (Summary / Self-care …) */ + .answer-section { + @apply relative pl-4 mt-4 first:mt-0; + } + .answer-section::before { + content: ""; + @apply absolute left-0 top-1 bottom-1 w-1 rounded-full bg-brand-500/60; + } +} + +@layer utilities { + .animate-in { animation: fadeIn 0.5s ease-in-out; } + .slide-in-from-bottom-4 { animation: slideInFromBottom 0.5s ease-in-out; } + .delay-100 { animation-delay: 100ms; } + .delay-200 { animation-delay: 200ms; } + + /* Shimmer utility for the "Analyzing…" typing state */ + .shimmer-text { + background: linear-gradient( + 90deg, + rgb(var(--ink-muted) / 0.5) 0%, + rgb(var(--ink-base)) 50%, + rgb(var(--ink-muted) / 0.5) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: shimmer 2.2s linear infinite; + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes slideInFromBottom { + from { opacity: 0; transform: translateY(1rem); } + to { opacity: 1; transform: translateY(0); } + } + + /* Medicine scanner viewfinder — pulsing green border */ + @keyframes scannerPulse { + 0%, 100% { border-color: rgba(34, 197, 94, 0.5); box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } + 50% { border-color: rgba(34, 197, 94, 1); box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.15); } + } + @keyframes scanLine { + 0% { top: 8%; } + 50% { top: 88%; } + 100% { top: 8%; } + } + @keyframes scanSuccess { + 0% { transform: scale(1); border-color: rgba(34, 197, 94, 0.6); } + 50% { transform: scale(1.02); border-color: rgba(34, 197, 94, 1); } + 100% { transform: scale(1); border-color: rgba(34, 197, 94, 0.6); } + } + @keyframes scanProgress { + 0% { width: 0%; } + 100% { width: 100%; } + } + .animate-scanner-pulse { animation: scannerPulse 2s ease-in-out infinite; } + .animate-scan-line { animation: scanLine 2.5s ease-in-out infinite; } + .animate-scan-success { animation: scanSuccess 0.6s ease-out; } + .animate-scan-progress { animation: scanProgress 3s ease-out forwards; } +} + +/* ------------------------------------------------------------ + * Dark-mode compatibility layer for legacy screens that still + * reference slate/white utility classes directly. Remaps them to + * the design-token surfaces so Settings/Records/Schedule/Topics/ + * Emergency look right in dark mode without a full rewrite. + * New components should prefer the `bg-surface-*` and `text-ink-*` + * tokens directly and won't be affected by these rules. + * ------------------------------------------------------------ */ +.dark .bg-white { background-color: rgb(var(--surface-1)) !important; } +.dark .bg-slate-50 { background-color: rgb(var(--surface-2)) !important; } +.dark .bg-slate-100 { background-color: rgb(var(--surface-2)) !important; } +.dark .bg-\[\#F8FAFC\] { background-color: rgb(var(--surface-0)) !important; } +.dark .bg-\[\#F7F9FB\] { background-color: rgb(var(--surface-0)) !important; } + +.dark .text-slate-900 { color: rgb(var(--ink-base)) !important; } +.dark .text-slate-800 { color: rgb(var(--ink-base)) !important; } +.dark .text-slate-700 { color: rgb(var(--ink-base) / 0.92) !important; } +.dark .text-slate-600 { color: rgb(var(--ink-muted)) !important; } +.dark .text-slate-500 { color: rgb(var(--ink-muted)) !important; } +.dark .text-slate-400 { color: rgb(var(--ink-subtle)) !important; } +.dark .text-slate-300 { color: rgb(var(--ink-subtle) / 0.85) !important; } + +.dark .border-slate-50 { border-color: rgb(var(--line) / 0.55) !important; } +.dark .border-slate-100 { border-color: rgb(var(--line) / 0.7) !important; } +.dark .border-slate-200 { border-color: rgb(var(--line) / 0.9) !important; } + +.dark .placeholder-slate-400::placeholder { color: rgb(var(--ink-subtle)); } + +/* Soft tinted surfaces used on a handful of views (blue-50, rose-50, + * amber-50, etc.) — in dark mode dim them into brand/accent tints. */ +.dark .bg-blue-50 { background-color: rgba(59,130,246,0.10) !important; } +.dark .bg-blue-100 { background-color: rgba(59,130,246,0.18) !important; } +.dark .bg-indigo-50 { background-color: rgba(99,102,241,0.10) !important; } +.dark .bg-rose-50 { background-color: rgba(244,63,94,0.10) !important; } +.dark .bg-red-50 { background-color: rgba(239,68,68,0.10) !important; } +.dark .bg-amber-50 { background-color: rgba(245,158,11,0.10) !important; } +.dark .bg-emerald-50 { background-color: rgba(16,185,129,0.10) !important; } +.dark .bg-purple-50 { background-color: rgba(168,85,247,0.10) !important; } + +.dark .border-blue-100 { border-color: rgba(59,130,246,0.28) !important; } +.dark .border-blue-200 { border-color: rgba(59,130,246,0.38) !important; } +.dark .border-red-200 { border-color: rgba(239,68,68,0.38) !important; } +.dark .border-amber-200 { border-color: rgba(245,158,11,0.38) !important; } + +/* Scrollbars — subtle in both modes */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: rgb(var(--ink-subtle) / 0.35); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: rgb(var(--ink-subtle) / 0.55); + background-clip: padding-box; +} + +::selection { + background: rgba(59, 130, 246, 0.22); + color: inherit; +} + +/* Safe area utilities for notched phones (iPhone X+, Android gesture nav) */ +.safe-area-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); } +.pb-safe-area { padding-bottom: env(safe-area-inset-bottom, 0px); } +.safe-area-top { padding-top: env(safe-area-inset-top, 0px); } +.px-safe-area { + padding-left: env(safe-area-inset-left, 0px); + padding-right: env(safe-area-inset-right, 0px); +} + +/* ============================================================ + * RTL (Right-to-Left) support for Arabic (ar) and Urdu (ur) + * Set via document.documentElement.dir = "rtl" in MedOSApp.tsx + * ============================================================ */ +[dir="rtl"] { + /* Base direction */ + direction: rtl; + text-align: right; +} + +/* Flex containers reverse in RTL */ +[dir="rtl"] .flex { direction: rtl; } + +/* Fix drawer slide direction (left → right for RTL) */ +[dir="rtl"] aside { left: auto; right: 0; } +[dir="rtl"] .-translate-x-full { transform: translateX(100%); } +[dir="rtl"] .translate-x-0 { transform: translateX(0); } + +/* Flip margins and paddings for common patterns */ +[dir="rtl"] .ml-auto { margin-left: 0; margin-right: auto; } +[dir="rtl"] .mr-auto { margin-right: 0; margin-left: auto; } +[dir="rtl"] .text-left { text-align: right; } +[dir="rtl"] .text-right { text-align: left; } + +/* Fix icon + text alignment in nav items */ +[dir="rtl"] .gap-2 { gap: 0.5rem; } +[dir="rtl"] .gap-3 { gap: 0.75rem; } + +/* Scrollbar on left side for RTL */ +[dir="rtl"] ::-webkit-scrollbar { direction: rtl; } + +/* Fix rounded corners (swap left/right) */ +[dir="rtl"] .rounded-tl-sm { border-top-left-radius: 0; border-top-right-radius: 0.125rem; } +[dir="rtl"] .rounded-tr-sm { border-top-right-radius: 0; border-top-left-radius: 0.125rem; } + +/* Large tap targets */ +@media (pointer: coarse) { + button, a, select, input, textarea { min-height: 44px; } +} + +/* ============================================================ + * Mobile-first utilities + * ============================================================ */ + +/* Dynamic viewport height — works on iOS Safari, Android Chrome, + and every modern browser. Falls back to 100vh. */ +.h-screen-safe { + height: 100vh; + height: 100dvh; +} + +/* Sticky input bar that stays above the mobile keyboard. + Uses env(keyboard-inset-height) on supporting browsers and + falls back to standard sticky positioning elsewhere. */ +.sticky-bottom-keyboard { + position: sticky; + bottom: 0; + bottom: env(keyboard-inset-height, 0px); +} + +/* Prevent iOS input zoom — any input below 16px triggers a zoom. + We force 16px minimum on touch devices and compensate with + transforms where we need visually-smaller text. */ +@media (pointer: coarse) { + input, textarea, select { + font-size: 16px !important; + } +} + +/* CJK word-break — prevents overflow for Chinese/Japanese/Korean text */ +:lang(zh), :lang(ja), :lang(ko) { + word-break: break-all; + overflow-wrap: break-word; +} + +/* iOS momentum scrolling */ +.scroll-touch { + -webkit-overflow-scrolling: touch; +} + +/* Bottom padding spacer for content that sits above a fixed bottom nav. + The 5.5rem accounts for the nav height + safe-area-inset-bottom. */ +.pb-mobile-nav { + padding-bottom: 5.5rem; +} +@media (min-width: 768px) { + .pb-mobile-nav { + padding-bottom: 0; + } +} + +/* Respect reduced-motion preferences everywhere */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ============================================================ + * Print styles — professional PDF export via window.print() + * Hides navigation, maximizes content, clean typography + * ============================================================ */ +@media print { + /* Hide navigation, headers, footers */ + aside, nav, header, footer, + .safe-area-bottom, .pb-mobile-nav, + button, [role="navigation"], + .md\:hidden, .lg\:flex { display: none !important; } + + /* Full width, no sidebar */ + body { background: white !important; color: black !important; font-size: 12pt; } + .flex-1 { width: 100% !important; max-width: 100% !important; } + .overflow-hidden { overflow: visible !important; } + .overflow-y-auto { overflow: visible !important; } + + /* Clean card borders for print */ + .bg-surface-1, .bg-surface-0 { background: white !important; } + .border { border-color: #ddd !important; } + .shadow-soft, .shadow-card, .shadow-glow { box-shadow: none !important; } + .rounded-2xl { border-radius: 8px !important; } + + /* Keep colors readable */ + .text-ink-base { color: #111 !important; } + .text-ink-muted { color: #555 !important; } + .text-brand-500, .text-brand-600 { color: #2563eb !important; } + .text-danger-500 { color: #dc2626 !important; } + .text-success-500 { color: #16a34a !important; } + + /* Page breaks */ + .stat-card, .rounded-2xl { break-inside: avoid; } + h2, h3 { break-after: avoid; } + + /* Print header */ + @page { margin: 1.5cm; size: A4; } +} diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..65b2d8f213cc15a39403b630de541c795801c372 --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a04ec40653f36e043b5d95441945d0be6c38d17 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,78 @@ +import type { Metadata, Viewport } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-sans", +}); + +export const metadata: Metadata = { + title: "MedOS — your worldwide medical assistant", + description: + "Tell MedOS what's bothering you. Instant, private, multilingual health guidance aligned with WHO, CDC, and NHS.", + keywords: ["medical AI", "healthcare", "chatbot", "telemedicine", "WHO", "CDC"], + authors: [{ name: "MedOS Team" }], + manifest: "/manifest.webmanifest", + icons: { + icon: [{ url: "/favicon.svg", type: "image/svg+xml" }], + shortcut: "/favicon.svg", + }, + openGraph: { + title: "MedOS — your worldwide medical assistant", + description: + "Private, multilingual health guidance aligned with WHO, CDC, and NHS — available 24/7.", + type: "website", + }, + robots: { index: true, follow: true }, + appleWebApp: { + capable: true, + title: "MedOS", + }, + other: { + "mobile-web-app-capable": "yes", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 5, + userScalable: true, + viewportFit: "cover", + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#F7F9FB" }, + { media: "(prefers-color-scheme: dark)", color: "#0B1220" }, + ], +}; + +/** + * Inline pre-hydration script: reads the stored theme before first paint. + */ +const themeBootstrap = ` +(function() { + try { + var stored = localStorage.getItem('medos_theme'); + var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + var isDark = stored === 'dark' || (stored === 'system' && prefersDark); + if (isDark) document.documentElement.classList.add('dark'); + document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'; + } catch (e) {} +})(); +`; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +