github-actions[bot] commited on
Commit ·
a42ba49
0
Parent(s):
Deploy MediBot from cc48a830
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +93 -0
- .gitignore +6 -0
- Dockerfile +56 -0
- Makefile +195 -0
- README.md +106 -0
- app/admin/page.tsx +327 -0
- app/api/admin/audit/route.ts +84 -0
- app/api/admin/config/route.ts +142 -0
- app/api/admin/email-status/route.ts +30 -0
- app/api/admin/fetch-models/route.ts +620 -0
- app/api/admin/llm-health/route.ts +127 -0
- app/api/admin/reset-password/route.ts +62 -0
- app/api/admin/stats/route.ts +46 -0
- app/api/admin/system-info/route.ts +130 -0
- app/api/admin/test-connection/route.ts +360 -0
- app/api/admin/users/[id]/route.ts +204 -0
- app/api/admin/users/route.ts +78 -0
- app/api/auth/delete-account/route.ts +80 -0
- app/api/auth/forgot-password/route.ts +46 -0
- app/api/auth/login/route.ts +88 -0
- app/api/auth/logout/route.ts +14 -0
- app/api/auth/me/route.ts +184 -0
- app/api/auth/register/route.ts +79 -0
- app/api/auth/resend-verification/route.ts +30 -0
- app/api/auth/reset-password/route.ts +63 -0
- app/api/auth/verify-email/route.ts +58 -0
- app/api/chat-history/route.ts +99 -0
- app/api/chat/route.ts +320 -0
- app/api/geo/route.ts +142 -0
- app/api/health-data/route.ts +114 -0
- app/api/health-data/sync/route.ts +77 -0
- app/api/health/route.ts +10 -0
- app/api/models/route.ts +14 -0
- app/api/nearby/route.ts +94 -0
- app/api/og/route.tsx +207 -0
- app/api/rag/route.ts +30 -0
- app/api/scan/route.ts +194 -0
- app/api/sessions/route.ts +70 -0
- app/api/triage/route.ts +29 -0
- app/api/user/settings/route.ts +126 -0
- app/globals.css +378 -0
- app/icon.svg +10 -0
- app/layout.tsx +78 -0
- app/manifest.ts +43 -0
- app/page.tsx +2 -0
- app/robots.ts +23 -0
- app/sitemap.ts +27 -0
- app/stats/page.tsx +245 -0
- app/symptoms/[slug]/page.tsx +237 -0
- app/symptoms/page.tsx +86 -0
.env.example
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# MedOS HuggingFace Space — full backend configuration
|
| 3 |
+
# ============================================================
|
| 4 |
+
#
|
| 5 |
+
# All values below are *bootstrap defaults*. Once the Space is running,
|
| 6 |
+
# every secret marked (admin-rotatable) can also be edited from
|
| 7 |
+
# Admin -> Server in the UI and is then persisted to /data/medos-config.json,
|
| 8 |
+
# which survives restarts.
|
| 9 |
+
# ============================================================
|
| 10 |
+
|
| 11 |
+
# --- LLM providers ---
|
| 12 |
+
OLLABRIDGE_URL=https://ruslanmv-ollabridge.hf.space
|
| 13 |
+
OLLABRIDGE_API_KEY=sk-ollabridge-your-key-here # admin-rotatable
|
| 14 |
+
HF_TOKEN=hf_your-token-here # admin-rotatable
|
| 15 |
+
DEFAULT_MODEL=free-best
|
| 16 |
+
|
| 17 |
+
# --- Database (SQLite, persistent on HF Spaces /data/) ---
|
| 18 |
+
# SQLite is the fallback driver. To run against Postgres (production), set
|
| 19 |
+
# DATABASE_URL below — SQLite at DB_PATH is then ignored.
|
| 20 |
+
DB_PATH=/data/medos.db
|
| 21 |
+
|
| 22 |
+
# --- Database (Postgres, production primary) ---
|
| 23 |
+
# When set and starting with postgres:// or postgresql://, the runtime
|
| 24 |
+
# uses Postgres instead of SQLite. Neon: use the pooler endpoint.
|
| 25 |
+
# Example:
|
| 26 |
+
# postgresql://USER:PASS@ep-xxx-pooler.region.aws.neon.tech/neondb?sslmode=require
|
| 27 |
+
#
|
| 28 |
+
# Unset → SQLite at DB_PATH (development).
|
| 29 |
+
# Unset AND NODE_ENV=production → the server refuses to start.
|
| 30 |
+
#
|
| 31 |
+
# No other DB knobs are needed; SSL, pool size, and statement timeout
|
| 32 |
+
# all have sensible defaults baked in.
|
| 33 |
+
DATABASE_URL=
|
| 34 |
+
|
| 35 |
+
# --- Admin seed ---
|
| 36 |
+
# First-run admin user. seedAdmin() creates this account on first boot.
|
| 37 |
+
# Do NOT leave ADMIN_PASSWORD at the legacy "admin123456" default in
|
| 38 |
+
# production.
|
| 39 |
+
ADMIN_EMAIL=admin@medos.health
|
| 40 |
+
ADMIN_PASSWORD=
|
| 41 |
+
|
| 42 |
+
# --- Email (verification + password reset) ---
|
| 43 |
+
# Pick ONE transport. They're tried in this order at runtime:
|
| 44 |
+
# 1. RESEND_API_KEY → Resend HTTP API (recommended on serverless)
|
| 45 |
+
# 2. SMTP_HOST + SMTP_USER + SMTP_PASS → nodemailer SMTP
|
| 46 |
+
# 3. (nothing) → emails are logged to stdout only — they never
|
| 47 |
+
# reach a real inbox. Useful for local dev, NEVER
|
| 48 |
+
# leave this state in production (this is the
|
| 49 |
+
# exact state that causes "Account created! Check
|
| 50 |
+
# your email" with no email ever arriving).
|
| 51 |
+
#
|
| 52 |
+
# Resend setup: https://resend.com → API Keys → create → paste below.
|
| 53 |
+
# Until you verify a sending domain, FROM_EMAIL must use onboarding@resend.dev
|
| 54 |
+
# (Resend rejects other senders for unverified domains).
|
| 55 |
+
RESEND_API_KEY=
|
| 56 |
+
FROM_EMAIL=MedOS <onboarding@resend.dev>
|
| 57 |
+
|
| 58 |
+
# SMTP fallback (only used if RESEND_API_KEY is unset).
|
| 59 |
+
# SMTP_HOST=smtp.sendgrid.net
|
| 60 |
+
# SMTP_PORT=587
|
| 61 |
+
# SMTP_USER=apikey
|
| 62 |
+
# SMTP_PASS=
|
| 63 |
+
|
| 64 |
+
# Verify the active transport at runtime by hitting (as an admin):
|
| 65 |
+
# GET /api/admin/email-status
|
| 66 |
+
|
| 67 |
+
# Used in the password-reset email's "Reset password" link. Set to the
|
| 68 |
+
# canonical user-facing URL of your deployment (Vercel domain or HF Space URL).
|
| 69 |
+
APP_URL=https://ruslanmv-medibot.hf.space
|
| 70 |
+
|
| 71 |
+
# --- CORS (comma-separated Vercel frontend origins) ---
|
| 72 |
+
ALLOWED_ORIGINS=https://your-vercel-app.vercel.app,http://localhost:3000
|
| 73 |
+
|
| 74 |
+
# --- Medicine Scanner proxy ---
|
| 75 |
+
# Token with "Make calls to Inference Providers" permission.
|
| 76 |
+
# Used SERVER-SIDE ONLY by /api/scan — never exposed to the browser, never
|
| 77 |
+
# returned in any HTTP response body. Per-user quota and audit are enforced
|
| 78 |
+
# server-side; see docs/USER_ISOLATION.md.
|
| 79 |
+
# Create at:
|
| 80 |
+
# https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained
|
| 81 |
+
HF_TOKEN_INFERENCE=hf_your-inference-token-here # admin-rotatable
|
| 82 |
+
SCANNER_URL=https://ruslanmv-medicine-scanner.hf.space # admin-rotatable
|
| 83 |
+
NEARBY_URL=https://ruslanmv-metaengine-nearby.hf.space # admin-rotatable
|
| 84 |
+
|
| 85 |
+
# --- At-rest encryption key (for BYO user tokens, audit fields, etc.) ---
|
| 86 |
+
# Strongly recommended in production. If unset, falls back to a key derived
|
| 87 |
+
# from ADMIN_PASSWORD (development convenience only — NOT for production).
|
| 88 |
+
# Generate with: openssl rand -hex 32
|
| 89 |
+
# ENCRYPTION_KEY=
|
| 90 |
+
|
| 91 |
+
# --- Application ---
|
| 92 |
+
NODE_ENV=production
|
| 93 |
+
PORT=7860
|
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
.next/
|
| 3 |
+
out/
|
| 4 |
+
.env
|
| 5 |
+
.env.local
|
| 6 |
+
*.tsbuildinfo
|
Dockerfile
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# MedOS HuggingFace Space — Production Dockerfile
|
| 3 |
+
#
|
| 4 |
+
# Enterprise architecture:
|
| 5 |
+
# web/ = frontend source of truth
|
| 6 |
+
# 9-HuggingFace-Global/ = backend + synced frontend
|
| 7 |
+
#
|
| 8 |
+
# Before deploying, run: bash scripts/sync-frontend.sh
|
| 9 |
+
# This copies web/ frontend, rewrites API paths, then you push.
|
| 10 |
+
# ============================================================
|
| 11 |
+
|
| 12 |
+
# Stage 1: Install dependencies
|
| 13 |
+
FROM node:18-alpine AS deps
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
RUN apk add --no-cache python3 make g++
|
| 16 |
+
COPY package.json ./
|
| 17 |
+
RUN npm install --legacy-peer-deps && npm cache clean --force
|
| 18 |
+
|
| 19 |
+
# Stage 2: Build
|
| 20 |
+
FROM node:18-alpine AS builder
|
| 21 |
+
WORKDIR /app
|
| 22 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 23 |
+
COPY . .
|
| 24 |
+
|
| 25 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 26 |
+
ENV NODE_ENV=production
|
| 27 |
+
|
| 28 |
+
RUN npm run build
|
| 29 |
+
|
| 30 |
+
# Stage 3: Production runner
|
| 31 |
+
FROM node:18-alpine AS runner
|
| 32 |
+
WORKDIR /app
|
| 33 |
+
|
| 34 |
+
ENV NODE_ENV=production
|
| 35 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 36 |
+
ENV PORT=7860
|
| 37 |
+
ENV HOSTNAME=0.0.0.0
|
| 38 |
+
|
| 39 |
+
RUN addgroup --system --gid 1001 nodejs && \
|
| 40 |
+
adduser --system --uid 1001 nextjs
|
| 41 |
+
|
| 42 |
+
COPY --from=builder /app/public ./public
|
| 43 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
| 44 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
| 45 |
+
COPY --from=builder --chown=nextjs:nodejs /app/data ./data
|
| 46 |
+
|
| 47 |
+
RUN mkdir -p /data && chown nextjs:nodejs /data
|
| 48 |
+
ENV DB_PATH=/data/medos.db
|
| 49 |
+
|
| 50 |
+
USER nextjs
|
| 51 |
+
EXPOSE 7860
|
| 52 |
+
|
| 53 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
|
| 54 |
+
CMD wget --no-verbose --tries=1 --spider http://localhost:7860/api/health || exit 1
|
| 55 |
+
|
| 56 |
+
CMD ["node", "server.js"]
|
Makefile
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# MedOS Global - Makefile
|
| 3 |
+
# Free AI Medical Assistant for Hugging Face Spaces
|
| 4 |
+
# =============================================================================
|
| 5 |
+
|
| 6 |
+
SHELL := /bin/bash
|
| 7 |
+
.DEFAULT_GOAL := help
|
| 8 |
+
|
| 9 |
+
# Directories
|
| 10 |
+
APP_DIR := 9-HuggingFace-Global
|
| 11 |
+
NODE_MODULES := $(APP_DIR)/node_modules
|
| 12 |
+
|
| 13 |
+
# Colors
|
| 14 |
+
GREEN := \033[0;32m
|
| 15 |
+
YELLOW := \033[0;33m
|
| 16 |
+
RED := \033[0;31m
|
| 17 |
+
NC := \033[0m
|
| 18 |
+
|
| 19 |
+
# =============================================================================
|
| 20 |
+
# Help
|
| 21 |
+
# =============================================================================
|
| 22 |
+
|
| 23 |
+
.PHONY: help
|
| 24 |
+
help: ## Show this help message
|
| 25 |
+
@echo ""
|
| 26 |
+
@echo " MedOS Global - Development Commands"
|
| 27 |
+
@echo " ===================================="
|
| 28 |
+
@echo ""
|
| 29 |
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
| 30 |
+
awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}'
|
| 31 |
+
@echo ""
|
| 32 |
+
|
| 33 |
+
# =============================================================================
|
| 34 |
+
# Installation
|
| 35 |
+
# =============================================================================
|
| 36 |
+
|
| 37 |
+
.PHONY: install
|
| 38 |
+
install: ## Install all dependencies
|
| 39 |
+
@echo "$(GREEN)Installing dependencies...$(NC)"
|
| 40 |
+
cd $(APP_DIR) && npm install
|
| 41 |
+
@echo "$(GREEN)Dependencies installed successfully.$(NC)"
|
| 42 |
+
|
| 43 |
+
.PHONY: install-ci
|
| 44 |
+
install-ci: ## Install dependencies for CI (clean, reproducible)
|
| 45 |
+
@echo "$(GREEN)Installing dependencies (CI mode)...$(NC)"
|
| 46 |
+
cd $(APP_DIR) && npm ci
|
| 47 |
+
@echo "$(GREEN)CI dependencies installed.$(NC)"
|
| 48 |
+
|
| 49 |
+
.PHONY: clean
|
| 50 |
+
clean: ## Remove node_modules and build artifacts
|
| 51 |
+
@echo "$(YELLOW)Cleaning build artifacts...$(NC)"
|
| 52 |
+
rm -rf $(APP_DIR)/node_modules
|
| 53 |
+
rm -rf $(APP_DIR)/.next
|
| 54 |
+
rm -rf $(APP_DIR)/out
|
| 55 |
+
@echo "$(GREEN)Clean complete.$(NC)"
|
| 56 |
+
|
| 57 |
+
# =============================================================================
|
| 58 |
+
# Quality Checks
|
| 59 |
+
# =============================================================================
|
| 60 |
+
|
| 61 |
+
.PHONY: lint
|
| 62 |
+
lint: ## Run ESLint
|
| 63 |
+
@echo "$(GREEN)Running linter...$(NC)"
|
| 64 |
+
cd $(APP_DIR) && npx next lint
|
| 65 |
+
|
| 66 |
+
.PHONY: type-check
|
| 67 |
+
type-check: ## Run TypeScript type checking
|
| 68 |
+
@echo "$(GREEN)Running type checker...$(NC)"
|
| 69 |
+
cd $(APP_DIR) && npx tsc --noEmit
|
| 70 |
+
|
| 71 |
+
.PHONY: format-check
|
| 72 |
+
format-check: ## Check code formatting
|
| 73 |
+
@echo "$(GREEN)Checking code formatting...$(NC)"
|
| 74 |
+
cd $(APP_DIR) && npx prettier --check "**/*.{ts,tsx,js,json,css}" 2>/dev/null || echo "Prettier not configured - skipping"
|
| 75 |
+
|
| 76 |
+
# =============================================================================
|
| 77 |
+
# Testing
|
| 78 |
+
# =============================================================================
|
| 79 |
+
|
| 80 |
+
.PHONY: test
|
| 81 |
+
test: test-unit test-structure test-providers test-i18n test-safety ## Run all tests
|
| 82 |
+
@echo "$(GREEN)All tests passed!$(NC)"
|
| 83 |
+
|
| 84 |
+
.PHONY: test-unit
|
| 85 |
+
test-unit: ## Run unit tests
|
| 86 |
+
@echo "$(GREEN)Running unit tests...$(NC)"
|
| 87 |
+
cd $(APP_DIR) && node --experimental-vm-modules ../tests/9-hf-global/run-tests.js
|
| 88 |
+
|
| 89 |
+
.PHONY: test-structure
|
| 90 |
+
test-structure: ## Verify folder structure and required files exist
|
| 91 |
+
@echo "$(GREEN)Verifying folder structure...$(NC)"
|
| 92 |
+
@test -f $(APP_DIR)/Dockerfile || (echo "$(RED)FAIL: Dockerfile missing$(NC)" && exit 1)
|
| 93 |
+
@test -f $(APP_DIR)/package.json || (echo "$(RED)FAIL: package.json missing$(NC)" && exit 1)
|
| 94 |
+
@test -f $(APP_DIR)/next.config.js || (echo "$(RED)FAIL: next.config.js missing$(NC)" && exit 1)
|
| 95 |
+
@test -f $(APP_DIR)/tsconfig.json || (echo "$(RED)FAIL: tsconfig.json missing$(NC)" && exit 1)
|
| 96 |
+
@test -f $(APP_DIR)/tailwind.config.ts || (echo "$(RED)FAIL: tailwind.config.ts missing$(NC)" && exit 1)
|
| 97 |
+
@test -f $(APP_DIR)/README.md || (echo "$(RED)FAIL: README.md missing$(NC)" && exit 1)
|
| 98 |
+
@test -f $(APP_DIR)/app/layout.tsx || (echo "$(RED)FAIL: app/layout.tsx missing$(NC)" && exit 1)
|
| 99 |
+
@test -f $(APP_DIR)/app/page.tsx || (echo "$(RED)FAIL: app/page.tsx missing$(NC)" && exit 1)
|
| 100 |
+
@test -f $(APP_DIR)/app/globals.css || (echo "$(RED)FAIL: app/globals.css missing$(NC)" && exit 1)
|
| 101 |
+
@test -f $(APP_DIR)/app/api/chat/route.ts || (echo "$(RED)FAIL: chat API route missing$(NC)" && exit 1)
|
| 102 |
+
@test -f $(APP_DIR)/app/api/triage/route.ts || (echo "$(RED)FAIL: triage API route missing$(NC)" && exit 1)
|
| 103 |
+
@test -f $(APP_DIR)/app/api/health/route.ts || (echo "$(RED)FAIL: health API route missing$(NC)" && exit 1)
|
| 104 |
+
@test -f $(APP_DIR)/public/manifest.json || (echo "$(RED)FAIL: manifest.json missing$(NC)" && exit 1)
|
| 105 |
+
@test -f $(APP_DIR)/public/sw.js || (echo "$(RED)FAIL: sw.js missing$(NC)" && exit 1)
|
| 106 |
+
@test -f $(APP_DIR)/Dockerfile || (echo "$(RED)FAIL: Dockerfile missing$(NC)" && exit 1)
|
| 107 |
+
@echo "$(GREEN) Structure check passed (15/15 files verified).$(NC)"
|
| 108 |
+
|
| 109 |
+
.PHONY: test-providers
|
| 110 |
+
test-providers: ## Test provider modules exist and export correctly
|
| 111 |
+
@echo "$(GREEN)Verifying provider modules...$(NC)"
|
| 112 |
+
@test -f $(APP_DIR)/lib/providers/index.ts || (echo "$(RED)FAIL: providers/index.ts missing$(NC)" && exit 1)
|
| 113 |
+
@test -f $(APP_DIR)/lib/providers/ollabridge.ts || (echo "$(RED)FAIL: ollabridge.ts missing$(NC)" && exit 1)
|
| 114 |
+
@test -f $(APP_DIR)/lib/providers/huggingface-direct.ts || (echo "$(RED)FAIL: huggingface-direct.ts missing$(NC)" && exit 1)
|
| 115 |
+
@test -f $(APP_DIR)/lib/providers/cached-faq.ts || (echo "$(RED)FAIL: cached-faq.ts missing$(NC)" && exit 1)
|
| 116 |
+
@test -f $(APP_DIR)/lib/providers/ollabridge-models.ts || (echo "$(RED)FAIL: ollabridge-models.ts missing$(NC)" && exit 1)
|
| 117 |
+
@echo "$(GREEN) Provider modules verified (5/5).$(NC)"
|
| 118 |
+
|
| 119 |
+
.PHONY: test-i18n
|
| 120 |
+
test-i18n: ## Verify i18n translations exist (synced or in web/ source)
|
| 121 |
+
@echo "$(GREEN)Verifying i18n...$(NC)"
|
| 122 |
+
@if test -f $(APP_DIR)/lib/i18n.ts; then \
|
| 123 |
+
echo "$(GREEN) Found synced lib/i18n.ts$(NC)"; \
|
| 124 |
+
elif test -f web/lib/i18n.ts; then \
|
| 125 |
+
echo "$(GREEN) Found web/lib/i18n.ts (source of truth)$(NC)"; \
|
| 126 |
+
else \
|
| 127 |
+
echo "$(RED)FAIL: i18n.ts not found in lib/ or web/lib/$(NC)" && exit 1; \
|
| 128 |
+
fi
|
| 129 |
+
|
| 130 |
+
.PHONY: test-safety
|
| 131 |
+
test-safety: ## Verify safety modules exist
|
| 132 |
+
@echo "$(GREEN)Verifying safety modules...$(NC)"
|
| 133 |
+
@test -f $(APP_DIR)/lib/safety/triage.ts || (echo "$(RED)FAIL: triage.ts missing$(NC)" && exit 1)
|
| 134 |
+
@test -f $(APP_DIR)/lib/safety/emergency-numbers.ts || (echo "$(RED)FAIL: emergency-numbers.ts missing$(NC)" && exit 1)
|
| 135 |
+
@test -f $(APP_DIR)/lib/safety/disclaimer.ts || (echo "$(RED)FAIL: disclaimer.ts missing$(NC)" && exit 1)
|
| 136 |
+
@echo "$(GREEN) Safety modules verified (3/3).$(NC)"
|
| 137 |
+
|
| 138 |
+
# =============================================================================
|
| 139 |
+
# Build
|
| 140 |
+
# =============================================================================
|
| 141 |
+
|
| 142 |
+
.PHONY: build
|
| 143 |
+
build: ## Build the Next.js application
|
| 144 |
+
@echo "$(GREEN)Building application...$(NC)"
|
| 145 |
+
cd $(APP_DIR) && npm run build
|
| 146 |
+
@echo "$(GREEN)Build complete.$(NC)"
|
| 147 |
+
|
| 148 |
+
.PHONY: docker-build
|
| 149 |
+
docker-build: ## Build Docker image
|
| 150 |
+
@echo "$(GREEN)Building Docker image...$(NC)"
|
| 151 |
+
cd $(APP_DIR) && docker build -t medos-global .
|
| 152 |
+
@echo "$(GREEN)Docker image built successfully.$(NC)"
|
| 153 |
+
|
| 154 |
+
.PHONY: docker-run
|
| 155 |
+
docker-run: ## Run Docker container locally
|
| 156 |
+
@echo "$(GREEN)Starting MedOS on http://localhost:7860...$(NC)"
|
| 157 |
+
cd $(APP_DIR) && docker run -p 7860:7860 \
|
| 158 |
+
-e OLLABRIDGE_URL=$${OLLABRIDGE_URL:-https://ruslanmv-ollabridge-cloud.hf.space} \
|
| 159 |
+
-e HF_TOKEN=$${HF_TOKEN:-} \
|
| 160 |
+
medos-global
|
| 161 |
+
|
| 162 |
+
# =============================================================================
|
| 163 |
+
# Development
|
| 164 |
+
# =============================================================================
|
| 165 |
+
|
| 166 |
+
.PHONY: dev
|
| 167 |
+
dev: ## Start development server on port 7860
|
| 168 |
+
@echo "$(GREEN)Starting dev server on http://localhost:7860...$(NC)"
|
| 169 |
+
cd $(APP_DIR) && npm run dev
|
| 170 |
+
|
| 171 |
+
.PHONY: start
|
| 172 |
+
start: ## Start production server on port 7860
|
| 173 |
+
cd $(APP_DIR) && npm run start
|
| 174 |
+
|
| 175 |
+
# =============================================================================
|
| 176 |
+
# Deployment
|
| 177 |
+
# =============================================================================
|
| 178 |
+
|
| 179 |
+
.PHONY: deploy-hf
|
| 180 |
+
deploy-hf: ## Deploy to Hugging Face Spaces (requires HF_TOKEN)
|
| 181 |
+
@test -n "$(HF_TOKEN)" || (echo "$(RED)ERROR: HF_TOKEN is required. Set it with: make deploy-hf HF_TOKEN=hf_...$(NC)" && exit 1)
|
| 182 |
+
@echo "$(GREEN)Deploying to Hugging Face Spaces...$(NC)"
|
| 183 |
+
@bash scripts/deploy-hf.sh $(HF_TOKEN)
|
| 184 |
+
|
| 185 |
+
# =============================================================================
|
| 186 |
+
# CI Pipeline (combines all checks)
|
| 187 |
+
# =============================================================================
|
| 188 |
+
|
| 189 |
+
.PHONY: ci
|
| 190 |
+
ci: install-ci test build ## Full CI pipeline: install, test, build
|
| 191 |
+
@echo "$(GREEN)CI pipeline passed successfully!$(NC)"
|
| 192 |
+
|
| 193 |
+
.PHONY: check
|
| 194 |
+
check: test-structure test-providers test-i18n test-safety ## Quick check (no install needed)
|
| 195 |
+
@echo "$(GREEN)All structural checks passed!$(NC)"
|
README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "MediBot: Free AI Medical Assistant · 20 languages"
|
| 3 |
+
emoji: "\U0001F3E5"
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: true
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
short_description: "Free AI medical chatbot. 20 languages. No sign-up."
|
| 11 |
+
tags:
|
| 12 |
+
- medical
|
| 13 |
+
- healthcare
|
| 14 |
+
- chatbot
|
| 15 |
+
- medical-ai
|
| 16 |
+
- health-assistant
|
| 17 |
+
- symptom-checker
|
| 18 |
+
- telemedicine
|
| 19 |
+
- who-guidelines
|
| 20 |
+
- cdc
|
| 21 |
+
- multilingual
|
| 22 |
+
- i18n
|
| 23 |
+
- rag
|
| 24 |
+
- llama-3.3
|
| 25 |
+
- llama-3.3-70b
|
| 26 |
+
- mixtral
|
| 27 |
+
- groq
|
| 28 |
+
- huggingface-inference
|
| 29 |
+
- pwa
|
| 30 |
+
- offline-first
|
| 31 |
+
- free
|
| 32 |
+
- no-signup
|
| 33 |
+
- privacy-first
|
| 34 |
+
- worldwide
|
| 35 |
+
- nextjs
|
| 36 |
+
- docker
|
| 37 |
+
models:
|
| 38 |
+
- meta-llama/Llama-3.3-70B-Instruct
|
| 39 |
+
- meta-llama/Meta-Llama-3-8B-Instruct
|
| 40 |
+
- mistralai/Mixtral-8x7B-Instruct-v0.1
|
| 41 |
+
- Qwen/Qwen2.5-72B-Instruct
|
| 42 |
+
- deepseek-ai/DeepSeek-V3
|
| 43 |
+
- ruslanmv/Medical-Llama3-8B
|
| 44 |
+
- google/gemma-2-9b-it
|
| 45 |
+
datasets:
|
| 46 |
+
- ruslanmv/ai-medical-chatbot
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
# MediBot — free AI medical assistant, worldwide
|
| 50 |
+
|
| 51 |
+
> **Tell MediBot what's bothering you. In your language. Instantly. For free.**
|
| 52 |
+
> No sign-up. No paywall. No data retention. Aligned with WHO · CDC · NHS guidelines.
|
| 53 |
+
|
| 54 |
+
[](https://huggingface.co/spaces/ruslanmv/MediBot)
|
| 55 |
+
[](#)
|
| 56 |
+
[](#)
|
| 57 |
+
[](#)
|
| 58 |
+
|
| 59 |
+
## Why MediBot
|
| 60 |
+
|
| 61 |
+
- **Free forever.** No API key, no sign-up, no paywall, no ads.
|
| 62 |
+
- **20 languages, auto-detected.** English, Español, Français, Português, Deutsch, Italiano, العربية, हिन्दी, Kiswahili, 中文, 日本語, 한국어, Русский, Türkçe, Tiếng Việt, ไทย, বাংলা, اردو, Polski, Nederlands.
|
| 63 |
+
- **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).
|
| 64 |
+
- **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.
|
| 65 |
+
- **Grounded on WHO, CDC, NHS, NIH, ICD-11, BNF, EMA.** A structured system prompt aligns every answer with authoritative guidance.
|
| 66 |
+
- **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.
|
| 67 |
+
- **Installable PWA.** Add to your phone's home screen and use it like a native app. Offline-capable with a cached FAQ fallback.
|
| 68 |
+
- **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.
|
| 69 |
+
- **Private & anonymous.** Zero accounts. Zero server-side conversation storage. No IPs logged. Anonymous session counter only.
|
| 70 |
+
- **Open source.** Fully transparent. [github.com/ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot)
|
| 71 |
+
|
| 72 |
+
## How it works
|
| 73 |
+
|
| 74 |
+
1. You type (or speak) a health question
|
| 75 |
+
2. MedOS checks for emergency red flags first
|
| 76 |
+
3. It searches a medical knowledge base for relevant context
|
| 77 |
+
4. Your question + context go to **Llama 3.3 70B** (via Groq, free)
|
| 78 |
+
5. You get a structured answer: Summary, Possible causes, Self-care, When to see a doctor
|
| 79 |
+
|
| 80 |
+
If the main model is busy, MedOS automatically tries other free models until one responds.
|
| 81 |
+
|
| 82 |
+
## Built with
|
| 83 |
+
|
| 84 |
+
| Layer | Technology |
|
| 85 |
+
|---|---|
|
| 86 |
+
| Frontend | Next.js 14, React, Tailwind CSS |
|
| 87 |
+
| AI Model | Llama 3.3 70B Instruct (via HuggingFace Inference + Groq) |
|
| 88 |
+
| Fallbacks | Mixtral 8x7B, OllaBridge, cached FAQ |
|
| 89 |
+
| Knowledge | Medical RAG from [ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot) dataset |
|
| 90 |
+
| Gateway | [OllaBridge-Cloud](https://github.com/ruslanmv/ollabridge) |
|
| 91 |
+
| Hosting | HuggingFace Spaces (Docker) |
|
| 92 |
+
|
| 93 |
+
## License
|
| 94 |
+
|
| 95 |
+
Apache 2.0 — free to use, modify, and distribute.
|
| 96 |
+
|
| 97 |
+
## MedOS Family family mode
|
| 98 |
+
|
| 99 |
+
This branch adds an additive first version of the MedOS Family family layer:
|
| 100 |
+
|
| 101 |
+
- `lib/family-health.ts` — local-first family tree, MedOS modes, invites, monthly records
|
| 102 |
+
- `lib/hooks/useFamilyHealth.ts` — React hook for family state
|
| 103 |
+
- `components/views/FamilyHealthView.tsx` — Family Admin / MedOS Family dashboard
|
| 104 |
+
- Sidebar integration through the new **MedOS Family** navigation item
|
| 105 |
+
|
| 106 |
+
The MVP keeps data local-first and prepares for the contracts documented in `../13-MedOS-Family/02-contracts/`.
|
app/admin/page.tsx
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState, useCallback } from 'react';
|
| 4 |
+
import {
|
| 5 |
+
Users,
|
| 6 |
+
Activity,
|
| 7 |
+
Database,
|
| 8 |
+
MessageCircle,
|
| 9 |
+
Shield,
|
| 10 |
+
Search,
|
| 11 |
+
Trash2,
|
| 12 |
+
ChevronLeft,
|
| 13 |
+
ChevronRight,
|
| 14 |
+
RefreshCw,
|
| 15 |
+
LogIn,
|
| 16 |
+
Lock,
|
| 17 |
+
} from 'lucide-react';
|
| 18 |
+
|
| 19 |
+
interface Stats {
|
| 20 |
+
totalUsers: number;
|
| 21 |
+
verifiedUsers: number;
|
| 22 |
+
adminUsers: number;
|
| 23 |
+
totalHealthData: number;
|
| 24 |
+
totalChats: number;
|
| 25 |
+
activeSessions: number;
|
| 26 |
+
healthBreakdown: Array<{ type: string; count: number }>;
|
| 27 |
+
registrations: Array<{ day: string; count: number }>;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
interface UserRow {
|
| 31 |
+
id: string;
|
| 32 |
+
email: string;
|
| 33 |
+
displayName: string | null;
|
| 34 |
+
emailVerified: boolean;
|
| 35 |
+
isAdmin: boolean;
|
| 36 |
+
createdAt: string;
|
| 37 |
+
healthDataCount: number;
|
| 38 |
+
chatHistoryCount: number;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Admin dashboard — accessible ONLY at /admin on the HuggingFace Space.
|
| 43 |
+
* Not linked from the public UI. Requires admin login.
|
| 44 |
+
*/
|
| 45 |
+
export default function AdminPage() {
|
| 46 |
+
const [token, setToken] = useState('');
|
| 47 |
+
const [loggedIn, setLoggedIn] = useState(false);
|
| 48 |
+
const [email, setEmail] = useState('');
|
| 49 |
+
const [password, setPassword] = useState('');
|
| 50 |
+
const [loginError, setLoginError] = useState('');
|
| 51 |
+
const [stats, setStats] = useState<Stats | null>(null);
|
| 52 |
+
const [users, setUsers] = useState<UserRow[]>([]);
|
| 53 |
+
const [total, setTotal] = useState(0);
|
| 54 |
+
const [page, setPage] = useState(1);
|
| 55 |
+
const [search, setSearch] = useState('');
|
| 56 |
+
const [loading, setLoading] = useState(false);
|
| 57 |
+
|
| 58 |
+
const headers = useCallback(
|
| 59 |
+
() => ({ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }),
|
| 60 |
+
[token],
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
const fetchStats = useCallback(async () => {
|
| 64 |
+
const res = await fetch('/api/admin/stats', { headers: headers() });
|
| 65 |
+
if (res.ok) setStats(await res.json());
|
| 66 |
+
else if (res.status === 403) { setLoggedIn(false); setToken(''); }
|
| 67 |
+
}, [headers]);
|
| 68 |
+
|
| 69 |
+
const fetchUsers = useCallback(async () => {
|
| 70 |
+
setLoading(true);
|
| 71 |
+
const qs = new URLSearchParams({ page: String(page), limit: '20' });
|
| 72 |
+
if (search) qs.set('search', search);
|
| 73 |
+
const res = await fetch(`/api/admin/users?${qs}`, { headers: headers() });
|
| 74 |
+
if (res.ok) {
|
| 75 |
+
const data = await res.json();
|
| 76 |
+
setUsers(data.users);
|
| 77 |
+
setTotal(data.total);
|
| 78 |
+
}
|
| 79 |
+
setLoading(false);
|
| 80 |
+
}, [headers, page, search]);
|
| 81 |
+
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
if (!loggedIn) return;
|
| 84 |
+
fetchStats();
|
| 85 |
+
fetchUsers();
|
| 86 |
+
}, [loggedIn, fetchStats, fetchUsers]);
|
| 87 |
+
|
| 88 |
+
const handleLogin = async () => {
|
| 89 |
+
setLoginError('');
|
| 90 |
+
const res = await fetch('/api/auth/login', {
|
| 91 |
+
method: 'POST',
|
| 92 |
+
headers: { 'Content-Type': 'application/json' },
|
| 93 |
+
body: JSON.stringify({ email, password }),
|
| 94 |
+
});
|
| 95 |
+
const data = await res.json();
|
| 96 |
+
if (!res.ok) { setLoginError(data.error || 'Login failed'); return; }
|
| 97 |
+
// Verify this user is actually an admin.
|
| 98 |
+
const meRes = await fetch('/api/auth/me', {
|
| 99 |
+
headers: { Authorization: `Bearer ${data.token}` },
|
| 100 |
+
});
|
| 101 |
+
const me = await meRes.json();
|
| 102 |
+
if (!me.user) { setLoginError('Auth failed'); return; }
|
| 103 |
+
// Check admin flag by trying the admin API.
|
| 104 |
+
const adminCheck = await fetch('/api/admin/stats', {
|
| 105 |
+
headers: { Authorization: `Bearer ${data.token}` },
|
| 106 |
+
});
|
| 107 |
+
if (adminCheck.status === 403) { setLoginError('Not an admin account'); return; }
|
| 108 |
+
setToken(data.token);
|
| 109 |
+
setLoggedIn(true);
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const handleDeleteUser = async (userId: string, userEmail: string) => {
|
| 113 |
+
if (!confirm(`Delete user ${userEmail} and ALL their data?`)) return;
|
| 114 |
+
await fetch(`/api/admin/users?id=${userId}`, { method: 'DELETE', headers: headers() });
|
| 115 |
+
fetchUsers();
|
| 116 |
+
fetchStats();
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
// Login screen
|
| 120 |
+
if (!loggedIn) {
|
| 121 |
+
return (
|
| 122 |
+
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
|
| 123 |
+
<div className="w-full max-w-sm">
|
| 124 |
+
<div className="text-center mb-8">
|
| 125 |
+
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center">
|
| 126 |
+
<Lock size={24} className="text-white" />
|
| 127 |
+
</div>
|
| 128 |
+
<h1 className="text-2xl font-bold text-slate-100">Admin Panel</h1>
|
| 129 |
+
<p className="text-sm text-slate-400 mt-1">MedOS server administration</p>
|
| 130 |
+
</div>
|
| 131 |
+
{loginError && (
|
| 132 |
+
<div className="mb-4 p-3 rounded-xl bg-red-950/50 border border-red-700/50 text-sm text-red-300">
|
| 133 |
+
{loginError}
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
<div className="space-y-3">
|
| 137 |
+
<input
|
| 138 |
+
type="email"
|
| 139 |
+
value={email}
|
| 140 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 141 |
+
placeholder="Admin email"
|
| 142 |
+
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"
|
| 143 |
+
/>
|
| 144 |
+
<input
|
| 145 |
+
type="password"
|
| 146 |
+
value={password}
|
| 147 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 148 |
+
placeholder="Password"
|
| 149 |
+
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"
|
| 150 |
+
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
| 151 |
+
/>
|
| 152 |
+
<button
|
| 153 |
+
onClick={handleLogin}
|
| 154 |
+
className="w-full py-3 bg-gradient-to-br from-red-500 to-orange-500 text-white rounded-xl font-bold text-sm hover:brightness-110 transition-all flex items-center justify-center gap-2"
|
| 155 |
+
>
|
| 156 |
+
<LogIn size={16} />
|
| 157 |
+
Sign in as Admin
|
| 158 |
+
</button>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const totalPages = Math.ceil(total / 20);
|
| 166 |
+
|
| 167 |
+
return (
|
| 168 |
+
<div className="min-h-screen bg-slate-950 text-slate-100">
|
| 169 |
+
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
|
| 170 |
+
<div className="flex items-center gap-3">
|
| 171 |
+
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center">
|
| 172 |
+
<Shield size={16} className="text-white" />
|
| 173 |
+
</div>
|
| 174 |
+
<h1 className="font-bold text-lg">MedOS Admin</h1>
|
| 175 |
+
</div>
|
| 176 |
+
<button
|
| 177 |
+
onClick={() => { fetchStats(); fetchUsers(); }}
|
| 178 |
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 text-slate-300 text-xs font-semibold hover:bg-slate-700"
|
| 179 |
+
>
|
| 180 |
+
<RefreshCw size={12} /> Refresh
|
| 181 |
+
</button>
|
| 182 |
+
</header>
|
| 183 |
+
|
| 184 |
+
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
| 185 |
+
{/* Stats grid */}
|
| 186 |
+
{stats && (
|
| 187 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-8">
|
| 188 |
+
<Stat icon={Users} label="Total users" value={stats.totalUsers} />
|
| 189 |
+
<Stat icon={Shield} label="Verified" value={stats.verifiedUsers} />
|
| 190 |
+
<Stat icon={Shield} label="Admins" value={stats.adminUsers} color="text-red-400" />
|
| 191 |
+
<Stat icon={Database} label="Health records" value={stats.totalHealthData} />
|
| 192 |
+
<Stat icon={MessageCircle} label="Conversations" value={stats.totalChats} />
|
| 193 |
+
<Stat icon={Activity} label="Active sessions" value={stats.activeSessions} />
|
| 194 |
+
</div>
|
| 195 |
+
)}
|
| 196 |
+
|
| 197 |
+
{/* Health data breakdown */}
|
| 198 |
+
{stats && stats.healthBreakdown.length > 0 && (
|
| 199 |
+
<div className="mb-8 p-4 rounded-2xl bg-slate-900 border border-slate-800">
|
| 200 |
+
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">Health data by type</h3>
|
| 201 |
+
<div className="flex flex-wrap gap-2">
|
| 202 |
+
{stats.healthBreakdown.map((b) => (
|
| 203 |
+
<span key={b.type} className="px-3 py-1.5 rounded-full bg-slate-800 text-sm font-medium text-slate-200">
|
| 204 |
+
{b.type}: <strong>{b.count}</strong>
|
| 205 |
+
</span>
|
| 206 |
+
))}
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
)}
|
| 210 |
+
|
| 211 |
+
{/* User management */}
|
| 212 |
+
<div className="rounded-2xl bg-slate-900 border border-slate-800 overflow-hidden">
|
| 213 |
+
<div className="p-4 border-b border-slate-800 flex items-center gap-3">
|
| 214 |
+
<h2 className="font-bold">Users ({total})</h2>
|
| 215 |
+
<div className="flex-1 relative ml-4">
|
| 216 |
+
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
| 217 |
+
<input
|
| 218 |
+
value={search}
|
| 219 |
+
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
| 220 |
+
placeholder="Search by email or name..."
|
| 221 |
+
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"
|
| 222 |
+
/>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<div className="overflow-x-auto">
|
| 227 |
+
<table className="w-full text-sm">
|
| 228 |
+
<thead>
|
| 229 |
+
<tr className="text-xs text-slate-400 uppercase tracking-wider border-b border-slate-800">
|
| 230 |
+
<th className="text-left px-4 py-3">User</th>
|
| 231 |
+
<th className="text-center px-4 py-3">Verified</th>
|
| 232 |
+
<th className="text-center px-4 py-3">Role</th>
|
| 233 |
+
<th className="text-center px-4 py-3">Health</th>
|
| 234 |
+
<th className="text-center px-4 py-3">Chats</th>
|
| 235 |
+
<th className="text-left px-4 py-3">Joined</th>
|
| 236 |
+
<th className="text-right px-4 py-3">Actions</th>
|
| 237 |
+
</tr>
|
| 238 |
+
</thead>
|
| 239 |
+
<tbody>
|
| 240 |
+
{users.map((u) => (
|
| 241 |
+
<tr key={u.id} className="border-b border-slate-800/50 hover:bg-slate-800/30">
|
| 242 |
+
<td className="px-4 py-3">
|
| 243 |
+
<div className="font-medium text-slate-100">{u.displayName || '—'}</div>
|
| 244 |
+
<div className="text-xs text-slate-400">{u.email}</div>
|
| 245 |
+
</td>
|
| 246 |
+
<td className="px-4 py-3 text-center">
|
| 247 |
+
<span className={`text-xs font-bold ${u.emailVerified ? 'text-emerald-400' : 'text-slate-500'}`}>
|
| 248 |
+
{u.emailVerified ? 'Yes' : 'No'}
|
| 249 |
+
</span>
|
| 250 |
+
</td>
|
| 251 |
+
<td className="px-4 py-3 text-center">
|
| 252 |
+
{u.isAdmin ? (
|
| 253 |
+
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-red-500/20 text-red-300 border border-red-500/30">
|
| 254 |
+
ADMIN
|
| 255 |
+
</span>
|
| 256 |
+
) : (
|
| 257 |
+
<span className="text-xs text-slate-500">User</span>
|
| 258 |
+
)}
|
| 259 |
+
</td>
|
| 260 |
+
<td className="px-4 py-3 text-center text-slate-300">{u.healthDataCount}</td>
|
| 261 |
+
<td className="px-4 py-3 text-center text-slate-300">{u.chatHistoryCount}</td>
|
| 262 |
+
<td className="px-4 py-3 text-slate-400 text-xs">
|
| 263 |
+
{new Date(u.createdAt).toLocaleDateString()}
|
| 264 |
+
</td>
|
| 265 |
+
<td className="px-4 py-3 text-right">
|
| 266 |
+
{!u.isAdmin && (
|
| 267 |
+
<button
|
| 268 |
+
onClick={() => handleDeleteUser(u.id, u.email)}
|
| 269 |
+
className="p-1.5 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
| 270 |
+
title="Delete user"
|
| 271 |
+
>
|
| 272 |
+
<Trash2 size={14} />
|
| 273 |
+
</button>
|
| 274 |
+
)}
|
| 275 |
+
</td>
|
| 276 |
+
</tr>
|
| 277 |
+
))}
|
| 278 |
+
{users.length === 0 && (
|
| 279 |
+
<tr>
|
| 280 |
+
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
| 281 |
+
{loading ? 'Loading...' : 'No users found'}
|
| 282 |
+
</td>
|
| 283 |
+
</tr>
|
| 284 |
+
)}
|
| 285 |
+
</tbody>
|
| 286 |
+
</table>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
{/* Pagination */}
|
| 290 |
+
{totalPages > 1 && (
|
| 291 |
+
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
|
| 292 |
+
<span className="text-xs text-slate-400">
|
| 293 |
+
Page {page} of {totalPages}
|
| 294 |
+
</span>
|
| 295 |
+
<div className="flex gap-1">
|
| 296 |
+
<button
|
| 297 |
+
onClick={() => setPage(Math.max(1, page - 1))}
|
| 298 |
+
disabled={page <= 1}
|
| 299 |
+
className="p-1.5 rounded-lg bg-slate-800 text-slate-300 disabled:opacity-30"
|
| 300 |
+
>
|
| 301 |
+
<ChevronLeft size={14} />
|
| 302 |
+
</button>
|
| 303 |
+
<button
|
| 304 |
+
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
| 305 |
+
disabled={page >= totalPages}
|
| 306 |
+
className="p-1.5 rounded-lg bg-slate-800 text-slate-300 disabled:opacity-30"
|
| 307 |
+
>
|
| 308 |
+
<ChevronRight size={14} />
|
| 309 |
+
</button>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
)}
|
| 313 |
+
</div>
|
| 314 |
+
</main>
|
| 315 |
+
</div>
|
| 316 |
+
);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
function Stat({ icon: Icon, label, value, color }: { icon: any; label: string; value: number; color?: string }) {
|
| 320 |
+
return (
|
| 321 |
+
<div className="p-4 rounded-xl bg-slate-900 border border-slate-800">
|
| 322 |
+
<Icon size={16} className={color || 'text-slate-400'} />
|
| 323 |
+
<div className="text-2xl font-black mt-2">{value.toLocaleString()}</div>
|
| 324 |
+
<div className="text-[11px] text-slate-500 font-semibold">{label}</div>
|
| 325 |
+
</div>
|
| 326 |
+
);
|
| 327 |
+
}
|
app/api/admin/audit/route.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 3 |
+
import { queryAudit, type AuditAction } from '@/lib/audit';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/admin/audit — page through the forensic audit log.
|
| 7 |
+
*
|
| 8 |
+
* Query params (all optional):
|
| 9 |
+
* userId filter by actor or target
|
| 10 |
+
* action one of the typed AuditAction values (e.g. "login", "scan")
|
| 11 |
+
* since ISO timestamp lower bound
|
| 12 |
+
* limit page size (default 50, cap 500)
|
| 13 |
+
* offset pagination offset (default 0)
|
| 14 |
+
*
|
| 15 |
+
* Response:
|
| 16 |
+
* { entries: [...], limit, offset, hasMore }
|
| 17 |
+
*
|
| 18 |
+
* Admin-only. Uses the existing queryAudit() helper (lib/audit.ts) so the
|
| 19 |
+
* schema and indexes are owned by one module.
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
export const runtime = 'nodejs';
|
| 23 |
+
export const dynamic = 'force-dynamic';
|
| 24 |
+
|
| 25 |
+
const ALLOWED_ACTIONS = new Set<AuditAction>([
|
| 26 |
+
'login',
|
| 27 |
+
'login_failed',
|
| 28 |
+
'logout',
|
| 29 |
+
'register',
|
| 30 |
+
'verify_email',
|
| 31 |
+
'password_reset_request',
|
| 32 |
+
'password_reset',
|
| 33 |
+
'password_change',
|
| 34 |
+
'delete_account',
|
| 35 |
+
'admin_login',
|
| 36 |
+
'admin_action',
|
| 37 |
+
'admin_user_delete',
|
| 38 |
+
'admin_user_reset_password',
|
| 39 |
+
'admin_config_update',
|
| 40 |
+
'token_rotate',
|
| 41 |
+
'chat',
|
| 42 |
+
'scan',
|
| 43 |
+
'health_data_write',
|
| 44 |
+
'health_data_delete',
|
| 45 |
+
'settings_update',
|
| 46 |
+
'export_data',
|
| 47 |
+
]);
|
| 48 |
+
|
| 49 |
+
export async function GET(req: Request) {
|
| 50 |
+
const admin = requireAdmin(req);
|
| 51 |
+
if (!admin) {
|
| 52 |
+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const url = new URL(req.url);
|
| 56 |
+
const userId = url.searchParams.get('userId') || undefined;
|
| 57 |
+
const actionRaw = url.searchParams.get('action');
|
| 58 |
+
const since = url.searchParams.get('since') || undefined;
|
| 59 |
+
const limit = Math.min(
|
| 60 |
+
500,
|
| 61 |
+
Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)),
|
| 62 |
+
);
|
| 63 |
+
const offset = Math.max(0, parseInt(url.searchParams.get('offset') || '0', 10));
|
| 64 |
+
|
| 65 |
+
// Reject unknown action strings so typos don't silently return nothing.
|
| 66 |
+
let action: AuditAction | undefined;
|
| 67 |
+
if (actionRaw) {
|
| 68 |
+
if (!ALLOWED_ACTIONS.has(actionRaw as AuditAction)) {
|
| 69 |
+
return NextResponse.json(
|
| 70 |
+
{ error: `Unknown action: ${actionRaw}` },
|
| 71 |
+
{ status: 400 },
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
action = actionRaw as AuditAction;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Request one extra row so we can cheaply compute hasMore without a
|
| 78 |
+
// separate COUNT(*) query.
|
| 79 |
+
const entries = queryAudit({ userId, action, since, limit: limit + 1, offset });
|
| 80 |
+
const hasMore = entries.length > limit;
|
| 81 |
+
if (hasMore) entries.pop();
|
| 82 |
+
|
| 83 |
+
return NextResponse.json({ entries, limit, offset, hasMore });
|
| 84 |
+
}
|
app/api/admin/config/route.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 3 |
+
import { loadConfig, saveConfig, type ServerConfig } from '@/lib/server-config';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Admin configuration management.
|
| 7 |
+
*
|
| 8 |
+
* GET /api/admin/config — returns current server configuration (redacted secrets).
|
| 9 |
+
* PUT /api/admin/config — updates server configuration (persisted to config file).
|
| 10 |
+
*
|
| 11 |
+
* Configuration is persisted to a JSON file on disk so it survives restarts.
|
| 12 |
+
* Environment variables take precedence over the config file on first boot.
|
| 13 |
+
*
|
| 14 |
+
* The storage/merge logic lives in @/lib/server-config so other admin routes
|
| 15 |
+
* (like /api/admin/fetch-models) can read the same source of truth.
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
const REDACTED = '••••••••';
|
| 19 |
+
|
| 20 |
+
/** Redact sensitive fields for GET responses. */
|
| 21 |
+
function redact(config: ServerConfig) {
|
| 22 |
+
const hasSecret = (v: string) => !!(v && v.length > 0);
|
| 23 |
+
const mask = (v: string) => (hasSecret(v) ? REDACTED : '');
|
| 24 |
+
return {
|
| 25 |
+
smtp: {
|
| 26 |
+
host: config.smtp.host,
|
| 27 |
+
port: config.smtp.port,
|
| 28 |
+
user: config.smtp.user,
|
| 29 |
+
pass: mask(config.smtp.pass),
|
| 30 |
+
fromEmail: config.smtp.fromEmail,
|
| 31 |
+
recoveryEmail: config.smtp.recoveryEmail,
|
| 32 |
+
configured: !!(config.smtp.host && config.smtp.user && config.smtp.pass),
|
| 33 |
+
},
|
| 34 |
+
llm: {
|
| 35 |
+
defaultPreset: config.llm.defaultPreset,
|
| 36 |
+
ollamaUrl: config.llm.ollamaUrl,
|
| 37 |
+
hfDefaultModel: config.llm.hfDefaultModel,
|
| 38 |
+
hfToken: mask(config.llm.hfToken),
|
| 39 |
+
hfTokenInference: mask(config.llm.hfTokenInference),
|
| 40 |
+
ollabridgeUrl: config.llm.ollabridgeUrl,
|
| 41 |
+
ollabridgeApiKey: mask(config.llm.ollabridgeApiKey),
|
| 42 |
+
openaiApiKey: mask(config.llm.openaiApiKey),
|
| 43 |
+
anthropicApiKey: mask(config.llm.anthropicApiKey),
|
| 44 |
+
groqApiKey: mask(config.llm.groqApiKey),
|
| 45 |
+
watsonxApiKey: mask(config.llm.watsonxApiKey),
|
| 46 |
+
watsonxProjectId: config.llm.watsonxProjectId,
|
| 47 |
+
watsonxUrl: config.llm.watsonxUrl,
|
| 48 |
+
scannerUrl: config.llm.scannerUrl,
|
| 49 |
+
nearbyUrl: config.llm.nearbyUrl,
|
| 50 |
+
geminiApiKey: mask(config.llm.geminiApiKey),
|
| 51 |
+
openrouterApiKey: mask(config.llm.openrouterApiKey),
|
| 52 |
+
togetherApiKey: mask(config.llm.togetherApiKey),
|
| 53 |
+
mistralApiKey: mask(config.llm.mistralApiKey),
|
| 54 |
+
// Computed status flags — derived server-side so UI can show chips.
|
| 55 |
+
ollabridgeConfigured: !!config.llm.ollabridgeUrl,
|
| 56 |
+
hfConfigured: hasSecret(config.llm.hfToken),
|
| 57 |
+
hfInferenceConfigured: hasSecret(config.llm.hfTokenInference),
|
| 58 |
+
openaiConfigured: hasSecret(config.llm.openaiApiKey),
|
| 59 |
+
anthropicConfigured: hasSecret(config.llm.anthropicApiKey),
|
| 60 |
+
groqConfigured: hasSecret(config.llm.groqApiKey),
|
| 61 |
+
watsonxConfigured: hasSecret(config.llm.watsonxApiKey) && !!config.llm.watsonxProjectId,
|
| 62 |
+
geminiConfigured: hasSecret(config.llm.geminiApiKey),
|
| 63 |
+
openrouterConfigured: hasSecret(config.llm.openrouterApiKey),
|
| 64 |
+
togetherConfigured: hasSecret(config.llm.togetherApiKey),
|
| 65 |
+
mistralConfigured: hasSecret(config.llm.mistralApiKey),
|
| 66 |
+
},
|
| 67 |
+
app: config.app,
|
| 68 |
+
};
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export async function GET(req: Request) {
|
| 72 |
+
const admin = requireAdmin(req);
|
| 73 |
+
if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 74 |
+
|
| 75 |
+
const config = loadConfig();
|
| 76 |
+
return NextResponse.json(redact(config));
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export async function PUT(req: Request) {
|
| 80 |
+
const admin = requireAdmin(req);
|
| 81 |
+
if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 82 |
+
|
| 83 |
+
try {
|
| 84 |
+
const body = await req.json();
|
| 85 |
+
const current = loadConfig();
|
| 86 |
+
|
| 87 |
+
// Merge incoming changes (only update provided fields).
|
| 88 |
+
if (body.smtp) {
|
| 89 |
+
if (body.smtp.host !== undefined) current.smtp.host = body.smtp.host;
|
| 90 |
+
if (body.smtp.port !== undefined) current.smtp.port = parseInt(body.smtp.port, 10);
|
| 91 |
+
if (body.smtp.user !== undefined) current.smtp.user = body.smtp.user;
|
| 92 |
+
// Only update password if it's not the redacted placeholder.
|
| 93 |
+
if (body.smtp.pass !== undefined && body.smtp.pass !== REDACTED) {
|
| 94 |
+
current.smtp.pass = body.smtp.pass;
|
| 95 |
+
}
|
| 96 |
+
if (body.smtp.fromEmail !== undefined) current.smtp.fromEmail = body.smtp.fromEmail;
|
| 97 |
+
if (body.smtp.recoveryEmail !== undefined) current.smtp.recoveryEmail = body.smtp.recoveryEmail;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if (body.llm) {
|
| 101 |
+
// Non-secret fields — assign directly.
|
| 102 |
+
if (body.llm.defaultPreset !== undefined) current.llm.defaultPreset = body.llm.defaultPreset;
|
| 103 |
+
if (body.llm.ollamaUrl !== undefined) current.llm.ollamaUrl = body.llm.ollamaUrl;
|
| 104 |
+
if (body.llm.hfDefaultModel !== undefined) current.llm.hfDefaultModel = body.llm.hfDefaultModel;
|
| 105 |
+
if (body.llm.ollabridgeUrl !== undefined) current.llm.ollabridgeUrl = body.llm.ollabridgeUrl;
|
| 106 |
+
if (body.llm.watsonxProjectId !== undefined) current.llm.watsonxProjectId = body.llm.watsonxProjectId;
|
| 107 |
+
if (body.llm.watsonxUrl !== undefined) current.llm.watsonxUrl = body.llm.watsonxUrl;
|
| 108 |
+
if (body.llm.scannerUrl !== undefined) current.llm.scannerUrl = body.llm.scannerUrl;
|
| 109 |
+
if (body.llm.nearbyUrl !== undefined) current.llm.nearbyUrl = body.llm.nearbyUrl;
|
| 110 |
+
|
| 111 |
+
// Secret fields — skip if value is the redacted placeholder.
|
| 112 |
+
const setSecret = (field: keyof ServerConfig['llm'], value: any) => {
|
| 113 |
+
if (value !== undefined && value !== REDACTED) {
|
| 114 |
+
(current.llm as any)[field] = value;
|
| 115 |
+
}
|
| 116 |
+
};
|
| 117 |
+
setSecret('hfToken', body.llm.hfToken);
|
| 118 |
+
setSecret('hfTokenInference', body.llm.hfTokenInference);
|
| 119 |
+
setSecret('ollabridgeApiKey', body.llm.ollabridgeApiKey);
|
| 120 |
+
setSecret('openaiApiKey', body.llm.openaiApiKey);
|
| 121 |
+
setSecret('anthropicApiKey', body.llm.anthropicApiKey);
|
| 122 |
+
setSecret('groqApiKey', body.llm.groqApiKey);
|
| 123 |
+
setSecret('watsonxApiKey', body.llm.watsonxApiKey);
|
| 124 |
+
setSecret('geminiApiKey', body.llm.geminiApiKey);
|
| 125 |
+
setSecret('openrouterApiKey', body.llm.openrouterApiKey);
|
| 126 |
+
setSecret('togetherApiKey', body.llm.togetherApiKey);
|
| 127 |
+
setSecret('mistralApiKey', body.llm.mistralApiKey);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
if (body.app) {
|
| 131 |
+
if (body.app.appUrl !== undefined) current.app.appUrl = body.app.appUrl;
|
| 132 |
+
if (body.app.allowedOrigins !== undefined) current.app.allowedOrigins = body.app.allowedOrigins;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
saveConfig(current);
|
| 136 |
+
|
| 137 |
+
return NextResponse.json({ success: true, config: redact(current) });
|
| 138 |
+
} catch (error: any) {
|
| 139 |
+
console.error('[Admin Config]', error?.message);
|
| 140 |
+
return NextResponse.json({ error: error?.message || 'Failed to update config' }, { status: 500 });
|
| 141 |
+
}
|
| 142 |
+
}
|
app/api/admin/email-status/route.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { authenticateRequest } from '@/lib/auth-middleware';
|
| 3 |
+
import { emailTransportName } from '@/lib/email';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/admin/email-status — which email transport is currently active.
|
| 7 |
+
*
|
| 8 |
+
* Returns one of:
|
| 9 |
+
* { transport: "resend" } — RESEND_API_KEY is set; uses Resend HTTP API.
|
| 10 |
+
* { transport: "smtp" } — SMTP_HOST/USER/PASS are set; uses nodemailer.
|
| 11 |
+
* { transport: "console" } — nothing configured; emails are logged to stdout
|
| 12 |
+
* and NEVER reach a real inbox. This is the
|
| 13 |
+
* state that produces the "Account created!
|
| 14 |
+
* Check your email" UX with no email ever
|
| 15 |
+
* arriving.
|
| 16 |
+
*
|
| 17 |
+
* Restricted to authenticated admins — the response itself isn't sensitive
|
| 18 |
+
* but there's no reason for unauthenticated callers to probe it.
|
| 19 |
+
*/
|
| 20 |
+
export async function GET(req: Request) {
|
| 21 |
+
const user = authenticateRequest(req);
|
| 22 |
+
if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 23 |
+
if (!user.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
| 24 |
+
|
| 25 |
+
return NextResponse.json({
|
| 26 |
+
transport: emailTransportName(),
|
| 27 |
+
from: process.env.FROM_EMAIL || '(default: MedOS <onboarding@resend.dev>)',
|
| 28 |
+
appUrl: process.env.APP_URL || '(default: https://ruslanmv-medibot.hf.space)',
|
| 29 |
+
});
|
| 30 |
+
}
|
app/api/admin/fetch-models/route.ts
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 3 |
+
import { loadConfig } from '@/lib/server-config';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/admin/fetch-models — Aggregate available models from every
|
| 7 |
+
* configured provider into one list for the admin model picker.
|
| 8 |
+
*
|
| 9 |
+
* Queries, in parallel:
|
| 10 |
+
* - OllaBridge Cloud /v1/models (OpenAI-compatible)
|
| 11 |
+
* - HuggingFace Inference /v1/models (via router.huggingface.co)
|
| 12 |
+
* - Groq /openai/v1/models (free/cheap tier)
|
| 13 |
+
* - OpenAI /v1/models (paid enterprise)
|
| 14 |
+
* - Anthropic /v1/models (paid enterprise)
|
| 15 |
+
* - IBM WatsonX /ml/v1/foundation_model_specs (paid enterprise)
|
| 16 |
+
*
|
| 17 |
+
* Each provider block returns:
|
| 18 |
+
* { provider, configured, ok, error?, models: [{id, name, ownedBy, context?}] }
|
| 19 |
+
*
|
| 20 |
+
* Providers that aren't configured still appear in the response so the UI
|
| 21 |
+
* can show them as "not configured" with a link to set them up. This keeps
|
| 22 |
+
* the client-side model picker uniform across providers.
|
| 23 |
+
*
|
| 24 |
+
* Admin-only endpoint.
|
| 25 |
+
*/
|
| 26 |
+
|
| 27 |
+
export const runtime = 'nodejs';
|
| 28 |
+
export const dynamic = 'force-dynamic';
|
| 29 |
+
|
| 30 |
+
interface ModelInfo {
|
| 31 |
+
id: string;
|
| 32 |
+
name: string;
|
| 33 |
+
ownedBy?: string;
|
| 34 |
+
context?: number;
|
| 35 |
+
pricing?: 'free' | 'paid' | 'cheap' | 'local';
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface ProviderBlock {
|
| 39 |
+
provider: string;
|
| 40 |
+
label: string;
|
| 41 |
+
configured: boolean;
|
| 42 |
+
ok: boolean;
|
| 43 |
+
error?: string;
|
| 44 |
+
pricing: 'free' | 'paid' | 'cheap' | 'local';
|
| 45 |
+
models: ModelInfo[];
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/** Default 10s timeout for any provider discovery call. */
|
| 49 |
+
function withTimeout(ms = 10000) {
|
| 50 |
+
return AbortSignal.timeout(ms);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
async function safeJson(res: Response): Promise<any> {
|
| 54 |
+
try {
|
| 55 |
+
return await res.json();
|
| 56 |
+
} catch {
|
| 57 |
+
return null;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// ---- Provider fetchers ---------------------------------------------------
|
| 62 |
+
|
| 63 |
+
async function fetchOllaBridge(
|
| 64 |
+
url: string,
|
| 65 |
+
apiKey: string,
|
| 66 |
+
): Promise<ProviderBlock> {
|
| 67 |
+
const block: ProviderBlock = {
|
| 68 |
+
provider: 'ollabridge',
|
| 69 |
+
label: 'OllaBridge Cloud',
|
| 70 |
+
configured: !!url,
|
| 71 |
+
ok: false,
|
| 72 |
+
pricing: 'free',
|
| 73 |
+
models: [],
|
| 74 |
+
};
|
| 75 |
+
if (!url) {
|
| 76 |
+
block.error = 'Not configured — set OllaBridge URL in Server tab';
|
| 77 |
+
return block;
|
| 78 |
+
}
|
| 79 |
+
try {
|
| 80 |
+
const cleanBase = url.replace(/\/+$/, '');
|
| 81 |
+
const res = await fetch(`${cleanBase}/v1/models`, {
|
| 82 |
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
| 83 |
+
signal: withTimeout(),
|
| 84 |
+
});
|
| 85 |
+
if (!res.ok) {
|
| 86 |
+
block.error = `HTTP ${res.status}`;
|
| 87 |
+
return block;
|
| 88 |
+
}
|
| 89 |
+
const data = await safeJson(res);
|
| 90 |
+
const list = Array.isArray(data?.data) ? data.data : [];
|
| 91 |
+
block.models = list.map((m: any) => ({
|
| 92 |
+
id: String(m.id ?? 'unknown'),
|
| 93 |
+
name: String(m.id ?? 'unknown'),
|
| 94 |
+
ownedBy: m.owned_by || 'ollabridge',
|
| 95 |
+
pricing:
|
| 96 |
+
String(m.id ?? '').startsWith('free-')
|
| 97 |
+
? 'free'
|
| 98 |
+
: String(m.id ?? '').startsWith('cheap-')
|
| 99 |
+
? 'cheap'
|
| 100 |
+
: 'local',
|
| 101 |
+
}));
|
| 102 |
+
block.ok = true;
|
| 103 |
+
} catch (e: any) {
|
| 104 |
+
block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
|
| 105 |
+
}
|
| 106 |
+
return block;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
async function fetchHuggingFace(token: string): Promise<ProviderBlock> {
|
| 110 |
+
const block: ProviderBlock = {
|
| 111 |
+
provider: 'huggingface',
|
| 112 |
+
label: 'HuggingFace Inference',
|
| 113 |
+
configured: !!token,
|
| 114 |
+
ok: false,
|
| 115 |
+
pricing: 'free',
|
| 116 |
+
models: [],
|
| 117 |
+
};
|
| 118 |
+
if (!token) {
|
| 119 |
+
block.error = 'Not configured — set HF token in Server tab';
|
| 120 |
+
// Still provide the curated fallback chain as suggestions so users can
|
| 121 |
+
// pick a model even before the token is set.
|
| 122 |
+
block.models = CURATED_HF_MODELS.map((id) => ({
|
| 123 |
+
id,
|
| 124 |
+
name: id.split('/').pop() || id,
|
| 125 |
+
ownedBy: id.split('/')[0],
|
| 126 |
+
pricing: 'free',
|
| 127 |
+
}));
|
| 128 |
+
return block;
|
| 129 |
+
}
|
| 130 |
+
try {
|
| 131 |
+
const res = await fetch('https://router.huggingface.co/v1/models', {
|
| 132 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 133 |
+
signal: withTimeout(),
|
| 134 |
+
});
|
| 135 |
+
if (!res.ok) {
|
| 136 |
+
block.error = `HTTP ${res.status}`;
|
| 137 |
+
// Still return curated list so the UI has something to show.
|
| 138 |
+
block.models = CURATED_HF_MODELS.map((id) => ({
|
| 139 |
+
id,
|
| 140 |
+
name: id.split('/').pop() || id,
|
| 141 |
+
ownedBy: id.split('/')[0],
|
| 142 |
+
pricing: 'free',
|
| 143 |
+
}));
|
| 144 |
+
return block;
|
| 145 |
+
}
|
| 146 |
+
const data = await safeJson(res);
|
| 147 |
+
const list = Array.isArray(data?.data) ? data.data : [];
|
| 148 |
+
block.models = list
|
| 149 |
+
.filter((m: any) => typeof m?.id === 'string')
|
| 150 |
+
.map((m: any) => ({
|
| 151 |
+
id: String(m.id),
|
| 152 |
+
name: String(m.id).split('/').pop() || String(m.id),
|
| 153 |
+
ownedBy: m.owned_by || String(m.id).split('/')[0],
|
| 154 |
+
pricing: 'free' as const,
|
| 155 |
+
}));
|
| 156 |
+
block.ok = true;
|
| 157 |
+
} catch (e: any) {
|
| 158 |
+
block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
|
| 159 |
+
block.models = CURATED_HF_MODELS.map((id) => ({
|
| 160 |
+
id,
|
| 161 |
+
name: id.split('/').pop() || id,
|
| 162 |
+
ownedBy: id.split('/')[0],
|
| 163 |
+
pricing: 'free',
|
| 164 |
+
}));
|
| 165 |
+
}
|
| 166 |
+
return block;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/** Verified-working free HF models (from lib/providers/huggingface-direct.ts). */
|
| 170 |
+
const CURATED_HF_MODELS = [
|
| 171 |
+
'meta-llama/Llama-3.3-70B-Instruct',
|
| 172 |
+
'Qwen/Qwen2.5-72B-Instruct',
|
| 173 |
+
'Qwen/Qwen3-235B-A22B',
|
| 174 |
+
'google/gemma-3-27b-it',
|
| 175 |
+
'meta-llama/Llama-3.1-70B-Instruct',
|
| 176 |
+
'deepseek-ai/DeepSeek-V3-0324',
|
| 177 |
+
];
|
| 178 |
+
|
| 179 |
+
async function fetchGroq(apiKey: string): Promise<ProviderBlock> {
|
| 180 |
+
const block: ProviderBlock = {
|
| 181 |
+
provider: 'groq',
|
| 182 |
+
label: 'Groq (Free tier)',
|
| 183 |
+
configured: !!apiKey,
|
| 184 |
+
ok: false,
|
| 185 |
+
pricing: 'free',
|
| 186 |
+
models: [],
|
| 187 |
+
};
|
| 188 |
+
if (!apiKey) {
|
| 189 |
+
block.error = 'Not configured — add Groq API key in Server tab';
|
| 190 |
+
return block;
|
| 191 |
+
}
|
| 192 |
+
try {
|
| 193 |
+
const res = await fetch('https://api.groq.com/openai/v1/models', {
|
| 194 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 195 |
+
signal: withTimeout(),
|
| 196 |
+
});
|
| 197 |
+
if (!res.ok) {
|
| 198 |
+
block.error = `HTTP ${res.status}`;
|
| 199 |
+
return block;
|
| 200 |
+
}
|
| 201 |
+
const data = await safeJson(res);
|
| 202 |
+
const list = Array.isArray(data?.data) ? data.data : [];
|
| 203 |
+
block.models = list.map((m: any) => ({
|
| 204 |
+
id: String(m.id ?? 'unknown'),
|
| 205 |
+
name: String(m.id ?? 'unknown'),
|
| 206 |
+
ownedBy: m.owned_by || 'groq',
|
| 207 |
+
context: typeof m.context_window === 'number' ? m.context_window : undefined,
|
| 208 |
+
pricing: 'free',
|
| 209 |
+
}));
|
| 210 |
+
block.ok = true;
|
| 211 |
+
} catch (e: any) {
|
| 212 |
+
block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
|
| 213 |
+
}
|
| 214 |
+
return block;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
async function fetchOpenAI(apiKey: string): Promise<ProviderBlock> {
|
| 218 |
+
const block: ProviderBlock = {
|
| 219 |
+
provider: 'openai',
|
| 220 |
+
label: 'OpenAI (Paid)',
|
| 221 |
+
configured: !!apiKey,
|
| 222 |
+
ok: false,
|
| 223 |
+
pricing: 'paid',
|
| 224 |
+
models: [],
|
| 225 |
+
};
|
| 226 |
+
if (!apiKey) {
|
| 227 |
+
block.error = 'Not configured — add OpenAI API key in Server tab';
|
| 228 |
+
return block;
|
| 229 |
+
}
|
| 230 |
+
try {
|
| 231 |
+
const res = await fetch('https://api.openai.com/v1/models', {
|
| 232 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 233 |
+
signal: withTimeout(),
|
| 234 |
+
});
|
| 235 |
+
if (!res.ok) {
|
| 236 |
+
block.error = `HTTP ${res.status}`;
|
| 237 |
+
return block;
|
| 238 |
+
}
|
| 239 |
+
const data = await safeJson(res);
|
| 240 |
+
const list = Array.isArray(data?.data) ? data.data : [];
|
| 241 |
+
// Filter to chat-capable GPT models — the full list is noisy.
|
| 242 |
+
block.models = list
|
| 243 |
+
.filter((m: any) => {
|
| 244 |
+
const id = String(m?.id || '');
|
| 245 |
+
return /^(gpt-|o1-|o3-|chatgpt)/i.test(id);
|
| 246 |
+
})
|
| 247 |
+
.map((m: any) => ({
|
| 248 |
+
id: String(m.id),
|
| 249 |
+
name: String(m.id),
|
| 250 |
+
ownedBy: m.owned_by || 'openai',
|
| 251 |
+
pricing: 'paid' as const,
|
| 252 |
+
}));
|
| 253 |
+
block.ok = true;
|
| 254 |
+
} catch (e: any) {
|
| 255 |
+
block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
|
| 256 |
+
}
|
| 257 |
+
return block;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
async function fetchAnthropic(apiKey: string): Promise<ProviderBlock> {
|
| 261 |
+
const block: ProviderBlock = {
|
| 262 |
+
provider: 'anthropic',
|
| 263 |
+
label: 'Anthropic Claude (Paid)',
|
| 264 |
+
configured: !!apiKey,
|
| 265 |
+
ok: false,
|
| 266 |
+
pricing: 'paid',
|
| 267 |
+
models: [],
|
| 268 |
+
};
|
| 269 |
+
if (!apiKey) {
|
| 270 |
+
block.error = 'Not configured — add Anthropic API key in Server tab';
|
| 271 |
+
// Provide curated list as placeholder.
|
| 272 |
+
block.models = CURATED_ANTHROPIC_MODELS;
|
| 273 |
+
return block;
|
| 274 |
+
}
|
| 275 |
+
try {
|
| 276 |
+
const res = await fetch('https://api.anthropic.com/v1/models', {
|
| 277 |
+
headers: {
|
| 278 |
+
'x-api-key': apiKey,
|
| 279 |
+
'anthropic-version': '2023-06-01',
|
| 280 |
+
},
|
| 281 |
+
signal: withTimeout(),
|
| 282 |
+
});
|
| 283 |
+
if (!res.ok) {
|
| 284 |
+
block.error = `HTTP ${res.status}`;
|
| 285 |
+
block.models = CURATED_ANTHROPIC_MODELS;
|
| 286 |
+
return block;
|
| 287 |
+
}
|
| 288 |
+
const data = await safeJson(res);
|
| 289 |
+
const list = Array.isArray(data?.data) ? data.data : [];
|
| 290 |
+
block.models = list.map((m: any) => ({
|
| 291 |
+
id: String(m.id ?? 'unknown'),
|
| 292 |
+
name: String(m.display_name || m.id || 'unknown'),
|
| 293 |
+
ownedBy: 'anthropic',
|
| 294 |
+
pricing: 'paid' as const,
|
| 295 |
+
}));
|
| 296 |
+
block.ok = true;
|
| 297 |
+
} catch (e: any) {
|
| 298 |
+
block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
|
| 299 |
+
block.models = CURATED_ANTHROPIC_MODELS;
|
| 300 |
+
}
|
| 301 |
+
return block;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
/** Fallback list so the UI can show Claude options even without a key. */
|
| 305 |
+
const CURATED_ANTHROPIC_MODELS: ModelInfo[] = [
|
| 306 |
+
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6', ownedBy: 'anthropic', pricing: 'paid' },
|
| 307 |
+
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', ownedBy: 'anthropic', pricing: 'paid' },
|
| 308 |
+
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', ownedBy: 'anthropic', pricing: 'paid' },
|
| 309 |
+
];
|
| 310 |
+
|
| 311 |
+
async function fetchWatsonx(
|
| 312 |
+
apiKey: string,
|
| 313 |
+
projectId: string,
|
| 314 |
+
baseUrl: string,
|
| 315 |
+
): Promise<ProviderBlock> {
|
| 316 |
+
const block: ProviderBlock = {
|
| 317 |
+
provider: 'watsonx',
|
| 318 |
+
label: 'IBM WatsonX (Paid)',
|
| 319 |
+
configured: !!(apiKey && projectId),
|
| 320 |
+
ok: false,
|
| 321 |
+
pricing: 'paid',
|
| 322 |
+
models: [],
|
| 323 |
+
};
|
| 324 |
+
if (!apiKey || !projectId) {
|
| 325 |
+
block.error = 'Not configured — add WatsonX API key and project ID in Server tab';
|
| 326 |
+
block.models = CURATED_WATSONX_MODELS;
|
| 327 |
+
return block;
|
| 328 |
+
}
|
| 329 |
+
try {
|
| 330 |
+
// WatsonX requires exchanging the API key for an IAM bearer token first.
|
| 331 |
+
const iamRes = await fetch('https://iam.cloud.ibm.com/identity/token', {
|
| 332 |
+
method: 'POST',
|
| 333 |
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
| 334 |
+
body: new URLSearchParams({
|
| 335 |
+
grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
|
| 336 |
+
apikey: apiKey,
|
| 337 |
+
}),
|
| 338 |
+
signal: withTimeout(),
|
| 339 |
+
});
|
| 340 |
+
if (!iamRes.ok) {
|
| 341 |
+
block.error = `IAM token failed: HTTP ${iamRes.status}`;
|
| 342 |
+
block.models = CURATED_WATSONX_MODELS;
|
| 343 |
+
return block;
|
| 344 |
+
}
|
| 345 |
+
const iamData = await safeJson(iamRes);
|
| 346 |
+
const bearer = iamData?.access_token;
|
| 347 |
+
if (!bearer) {
|
| 348 |
+
block.error = 'IAM response missing access_token';
|
| 349 |
+
block.models = CURATED_WATSONX_MODELS;
|
| 350 |
+
return block;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
// Discover available foundation models.
|
| 354 |
+
const cleanBase = baseUrl.replace(/\/+$/, '');
|
| 355 |
+
const modelsRes = await fetch(
|
| 356 |
+
`${cleanBase}/ml/v1/foundation_model_specs?version=2024-05-01&limit=200`,
|
| 357 |
+
{
|
| 358 |
+
headers: { Authorization: `Bearer ${bearer}` },
|
| 359 |
+
signal: withTimeout(),
|
| 360 |
+
},
|
| 361 |
+
);
|
| 362 |
+
if (!modelsRes.ok) {
|
| 363 |
+
block.error = `HTTP ${modelsRes.status}`;
|
| 364 |
+
block.models = CURATED_WATSONX_MODELS;
|
| 365 |
+
return block;
|
| 366 |
+
}
|
| 367 |
+
const data = await safeJson(modelsRes);
|
| 368 |
+
const list = Array.isArray(data?.resources) ? data.resources : [];
|
| 369 |
+
block.models = list.map((m: any) => ({
|
| 370 |
+
id: String(m.model_id ?? 'unknown'),
|
| 371 |
+
name: String(m.label || m.model_id || 'unknown'),
|
| 372 |
+
ownedBy: m.provider || 'ibm',
|
| 373 |
+
pricing: 'paid' as const,
|
| 374 |
+
}));
|
| 375 |
+
block.ok = true;
|
| 376 |
+
} catch (e: any) {
|
| 377 |
+
block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
|
| 378 |
+
block.models = CURATED_WATSONX_MODELS;
|
| 379 |
+
}
|
| 380 |
+
return block;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
/** Common WatsonX foundation models as a fallback list. */
|
| 384 |
+
const CURATED_WATSONX_MODELS: ModelInfo[] = [
|
| 385 |
+
{ id: 'ibm/granite-3-8b-instruct', name: 'Granite 3 8B Instruct', ownedBy: 'ibm', pricing: 'paid' },
|
| 386 |
+
{ id: 'meta-llama/llama-3-3-70b-instruct', name: 'Llama 3.3 70B', ownedBy: 'meta', pricing: 'paid' },
|
| 387 |
+
{ id: 'mistralai/mistral-large', name: 'Mistral Large', ownedBy: 'mistralai', pricing: 'paid' },
|
| 388 |
+
];
|
| 389 |
+
|
| 390 |
+
// ---- Additional provider fetchers (v3) -----------------------------------
|
| 391 |
+
|
| 392 |
+
/**
|
| 393 |
+
* Google Gemini. Uses the Generative Language API, which requires the key
|
| 394 |
+
* as a query parameter rather than a bearer header.
|
| 395 |
+
*/
|
| 396 |
+
async function fetchGemini(apiKey: string): Promise<ProviderBlock> {
|
| 397 |
+
const block: ProviderBlock = {
|
| 398 |
+
provider: 'gemini',
|
| 399 |
+
label: 'Google Gemini',
|
| 400 |
+
configured: !!apiKey,
|
| 401 |
+
ok: false,
|
| 402 |
+
pricing: 'paid',
|
| 403 |
+
models: [],
|
| 404 |
+
};
|
| 405 |
+
if (!apiKey) {
|
| 406 |
+
block.error = 'API key not configured';
|
| 407 |
+
return block;
|
| 408 |
+
}
|
| 409 |
+
try {
|
| 410 |
+
const res = await fetch(
|
| 411 |
+
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`,
|
| 412 |
+
{ signal: withTimeout() },
|
| 413 |
+
);
|
| 414 |
+
if (!res.ok) {
|
| 415 |
+
block.error = `HTTP ${res.status}`;
|
| 416 |
+
return block;
|
| 417 |
+
}
|
| 418 |
+
const data = await res.json().catch(() => ({}));
|
| 419 |
+
const arr = Array.isArray(data?.models) ? data.models : [];
|
| 420 |
+
block.ok = true;
|
| 421 |
+
block.models = arr
|
| 422 |
+
.filter((m: any) => typeof m?.name === 'string')
|
| 423 |
+
// Gemini returns "models/gemini-1.5-flash" — strip the prefix so the
|
| 424 |
+
// UI shows the bare model id like every other provider.
|
| 425 |
+
.map((m: any) => ({
|
| 426 |
+
id: String(m.name).replace(/^models\//, ''),
|
| 427 |
+
name: m.displayName || String(m.name).replace(/^models\//, ''),
|
| 428 |
+
ownedBy: 'google',
|
| 429 |
+
context: typeof m.inputTokenLimit === 'number' ? m.inputTokenLimit : undefined,
|
| 430 |
+
pricing: 'paid' as const,
|
| 431 |
+
}));
|
| 432 |
+
} catch (e: any) {
|
| 433 |
+
block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed';
|
| 434 |
+
}
|
| 435 |
+
return block;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
/** OpenRouter — OpenAI-compatible /v1/models aggregator across providers. */
|
| 439 |
+
async function fetchOpenRouter(apiKey: string): Promise<ProviderBlock> {
|
| 440 |
+
const block: ProviderBlock = {
|
| 441 |
+
provider: 'openrouter',
|
| 442 |
+
label: 'OpenRouter',
|
| 443 |
+
configured: !!apiKey,
|
| 444 |
+
ok: false,
|
| 445 |
+
pricing: 'paid',
|
| 446 |
+
models: [],
|
| 447 |
+
};
|
| 448 |
+
if (!apiKey) {
|
| 449 |
+
block.error = 'API key not configured';
|
| 450 |
+
return block;
|
| 451 |
+
}
|
| 452 |
+
try {
|
| 453 |
+
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
| 454 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 455 |
+
signal: withTimeout(),
|
| 456 |
+
});
|
| 457 |
+
if (!res.ok) {
|
| 458 |
+
block.error = `HTTP ${res.status}`;
|
| 459 |
+
return block;
|
| 460 |
+
}
|
| 461 |
+
const data = await res.json().catch(() => ({}));
|
| 462 |
+
const arr = Array.isArray(data?.data) ? data.data : [];
|
| 463 |
+
block.ok = true;
|
| 464 |
+
block.models = arr.slice(0, 200).map((m: any) => ({
|
| 465 |
+
id: String(m.id),
|
| 466 |
+
name: m.name || String(m.id),
|
| 467 |
+
ownedBy: (String(m.id).split('/')[0] as string) || undefined,
|
| 468 |
+
context: typeof m.context_length === 'number' ? m.context_length : undefined,
|
| 469 |
+
pricing: m?.pricing?.prompt === '0' ? 'free' : 'paid',
|
| 470 |
+
}));
|
| 471 |
+
} catch (e: any) {
|
| 472 |
+
block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed';
|
| 473 |
+
}
|
| 474 |
+
return block;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
/** Together AI — OpenAI-compatible /v1/models. */
|
| 478 |
+
async function fetchTogether(apiKey: string): Promise<ProviderBlock> {
|
| 479 |
+
const block: ProviderBlock = {
|
| 480 |
+
provider: 'together',
|
| 481 |
+
label: 'Together AI',
|
| 482 |
+
configured: !!apiKey,
|
| 483 |
+
ok: false,
|
| 484 |
+
pricing: 'paid',
|
| 485 |
+
models: [],
|
| 486 |
+
};
|
| 487 |
+
if (!apiKey) {
|
| 488 |
+
block.error = 'API key not configured';
|
| 489 |
+
return block;
|
| 490 |
+
}
|
| 491 |
+
try {
|
| 492 |
+
const res = await fetch('https://api.together.xyz/v1/models', {
|
| 493 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 494 |
+
signal: withTimeout(),
|
| 495 |
+
});
|
| 496 |
+
if (!res.ok) {
|
| 497 |
+
block.error = `HTTP ${res.status}`;
|
| 498 |
+
return block;
|
| 499 |
+
}
|
| 500 |
+
const data = await res.json().catch(() => []);
|
| 501 |
+
// Together returns a bare array.
|
| 502 |
+
const arr = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
|
| 503 |
+
block.ok = true;
|
| 504 |
+
block.models = arr.slice(0, 200).map((m: any) => ({
|
| 505 |
+
id: String(m.id || m.name),
|
| 506 |
+
name: m.display_name || m.id || m.name,
|
| 507 |
+
ownedBy: m.organization || undefined,
|
| 508 |
+
context: typeof m.context_length === 'number' ? m.context_length : undefined,
|
| 509 |
+
pricing: 'paid' as const,
|
| 510 |
+
}));
|
| 511 |
+
} catch (e: any) {
|
| 512 |
+
block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed';
|
| 513 |
+
}
|
| 514 |
+
return block;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
/** Mistral AI — OpenAI-compatible /v1/models. */
|
| 518 |
+
async function fetchMistral(apiKey: string): Promise<ProviderBlock> {
|
| 519 |
+
const block: ProviderBlock = {
|
| 520 |
+
provider: 'mistral',
|
| 521 |
+
label: 'Mistral AI',
|
| 522 |
+
configured: !!apiKey,
|
| 523 |
+
ok: false,
|
| 524 |
+
pricing: 'paid',
|
| 525 |
+
models: [],
|
| 526 |
+
};
|
| 527 |
+
if (!apiKey) {
|
| 528 |
+
block.error = 'API key not configured';
|
| 529 |
+
return block;
|
| 530 |
+
}
|
| 531 |
+
try {
|
| 532 |
+
const res = await fetch('https://api.mistral.ai/v1/models', {
|
| 533 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 534 |
+
signal: withTimeout(),
|
| 535 |
+
});
|
| 536 |
+
if (!res.ok) {
|
| 537 |
+
block.error = `HTTP ${res.status}`;
|
| 538 |
+
return block;
|
| 539 |
+
}
|
| 540 |
+
const data = await res.json().catch(() => ({}));
|
| 541 |
+
const arr = Array.isArray(data?.data) ? data.data : [];
|
| 542 |
+
block.ok = true;
|
| 543 |
+
block.models = arr.map((m: any) => ({
|
| 544 |
+
id: String(m.id),
|
| 545 |
+
name: m.name || m.id,
|
| 546 |
+
ownedBy: m.owned_by || 'mistralai',
|
| 547 |
+
context:
|
| 548 |
+
typeof m.max_context_length === 'number'
|
| 549 |
+
? m.max_context_length
|
| 550 |
+
: typeof m.context_length === 'number'
|
| 551 |
+
? m.context_length
|
| 552 |
+
: undefined,
|
| 553 |
+
pricing: 'paid' as const,
|
| 554 |
+
}));
|
| 555 |
+
} catch (e: any) {
|
| 556 |
+
block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed';
|
| 557 |
+
}
|
| 558 |
+
return block;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
// ---- Route handler -------------------------------------------------------
|
| 562 |
+
|
| 563 |
+
export async function GET(req: Request) {
|
| 564 |
+
const admin = requireAdmin(req);
|
| 565 |
+
if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 566 |
+
|
| 567 |
+
const config = loadConfig();
|
| 568 |
+
const { llm } = config;
|
| 569 |
+
|
| 570 |
+
// Run every discovery call in parallel so the slowest provider sets the
|
| 571 |
+
// total latency floor, not the sum of all calls.
|
| 572 |
+
const [
|
| 573 |
+
ollabridge,
|
| 574 |
+
huggingface,
|
| 575 |
+
groq,
|
| 576 |
+
openai,
|
| 577 |
+
anthropic,
|
| 578 |
+
watsonx,
|
| 579 |
+
gemini,
|
| 580 |
+
openrouter,
|
| 581 |
+
together,
|
| 582 |
+
mistral,
|
| 583 |
+
] = await Promise.all([
|
| 584 |
+
fetchOllaBridge(llm.ollabridgeUrl, llm.ollabridgeApiKey),
|
| 585 |
+
fetchHuggingFace(llm.hfToken),
|
| 586 |
+
fetchGroq(llm.groqApiKey),
|
| 587 |
+
fetchOpenAI(llm.openaiApiKey),
|
| 588 |
+
fetchAnthropic(llm.anthropicApiKey),
|
| 589 |
+
fetchWatsonx(llm.watsonxApiKey, llm.watsonxProjectId, llm.watsonxUrl),
|
| 590 |
+
fetchGemini(llm.geminiApiKey),
|
| 591 |
+
fetchOpenRouter(llm.openrouterApiKey),
|
| 592 |
+
fetchTogether(llm.togetherApiKey),
|
| 593 |
+
fetchMistral(llm.mistralApiKey),
|
| 594 |
+
]);
|
| 595 |
+
|
| 596 |
+
const providers = [
|
| 597 |
+
ollabridge,
|
| 598 |
+
huggingface,
|
| 599 |
+
groq,
|
| 600 |
+
openai,
|
| 601 |
+
anthropic,
|
| 602 |
+
watsonx,
|
| 603 |
+
gemini,
|
| 604 |
+
openrouter,
|
| 605 |
+
together,
|
| 606 |
+
mistral,
|
| 607 |
+
];
|
| 608 |
+
const totalModels = providers.reduce((sum, p) => sum + p.models.length, 0);
|
| 609 |
+
const okCount = providers.filter((p) => p.ok).length;
|
| 610 |
+
|
| 611 |
+
return NextResponse.json({
|
| 612 |
+
providers,
|
| 613 |
+
summary: {
|
| 614 |
+
providers: providers.length,
|
| 615 |
+
providersOk: okCount,
|
| 616 |
+
totalModels,
|
| 617 |
+
fetchedAt: new Date().toISOString(),
|
| 618 |
+
},
|
| 619 |
+
});
|
| 620 |
+
}
|
app/api/admin/llm-health/route.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 3 |
+
import { loadConfig } from '@/lib/server-config';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/admin/llm-health — Test all LLM providers and models.
|
| 7 |
+
*
|
| 8 |
+
* Sends a minimal "Say OK" prompt to each model in the fallback chain
|
| 9 |
+
* and reports which ones are alive, their latency, and any errors.
|
| 10 |
+
* Admin-only endpoint.
|
| 11 |
+
*
|
| 12 |
+
* Token resolution order:
|
| 13 |
+
* 1. admin config file (set via /api/admin/config PUT)
|
| 14 |
+
* 2. HF_TOKEN environment variable
|
| 15 |
+
* This way an admin can fix a misconfigured Space without redeploying.
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
const HF_BASE_URL = 'https://router.huggingface.co/v1';
|
| 19 |
+
|
| 20 |
+
/** All models to test — matches the presets fallback chain. */
|
| 21 |
+
const MODELS_TO_TEST = [
|
| 22 |
+
'meta-llama/Llama-3.3-70B-Instruct:sambanova',
|
| 23 |
+
'meta-llama/Llama-3.3-70B-Instruct:together',
|
| 24 |
+
'meta-llama/Llama-3.3-70B-Instruct',
|
| 25 |
+
'Qwen/Qwen2.5-72B-Instruct',
|
| 26 |
+
'Qwen/Qwen3-235B-A22B',
|
| 27 |
+
'google/gemma-3-27b-it',
|
| 28 |
+
'meta-llama/Llama-3.1-70B-Instruct',
|
| 29 |
+
'Qwen/Qwen3-32B',
|
| 30 |
+
'deepseek-ai/DeepSeek-V3-0324',
|
| 31 |
+
'deepseek-ai/DeepSeek-R1',
|
| 32 |
+
'Qwen/Qwen3-30B-A3B',
|
| 33 |
+
'Qwen/Qwen2.5-Coder-32B-Instruct',
|
| 34 |
+
];
|
| 35 |
+
|
| 36 |
+
async function testModel(model: string, token: string): Promise<{
|
| 37 |
+
model: string;
|
| 38 |
+
status: 'ok' | 'error';
|
| 39 |
+
latencyMs: number;
|
| 40 |
+
response?: string;
|
| 41 |
+
error?: string;
|
| 42 |
+
httpStatus?: number;
|
| 43 |
+
}> {
|
| 44 |
+
const start = Date.now();
|
| 45 |
+
try {
|
| 46 |
+
const res = await fetch(`${HF_BASE_URL}/chat/completions`, {
|
| 47 |
+
method: 'POST',
|
| 48 |
+
headers: {
|
| 49 |
+
Authorization: `Bearer ${token}`,
|
| 50 |
+
'Content-Type': 'application/json',
|
| 51 |
+
},
|
| 52 |
+
body: JSON.stringify({
|
| 53 |
+
model,
|
| 54 |
+
messages: [{ role: 'user', content: 'Say OK' }],
|
| 55 |
+
max_tokens: 5,
|
| 56 |
+
temperature: 0.1,
|
| 57 |
+
stream: false,
|
| 58 |
+
}),
|
| 59 |
+
signal: AbortSignal.timeout(15000),
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
const latencyMs = Date.now() - start;
|
| 63 |
+
|
| 64 |
+
if (res.ok) {
|
| 65 |
+
const data = await res.json();
|
| 66 |
+
const content = data?.choices?.[0]?.message?.content?.trim() || '';
|
| 67 |
+
return { model, status: 'ok', latencyMs, response: content.slice(0, 30) };
|
| 68 |
+
} else {
|
| 69 |
+
const text = await res.text().catch(() => '');
|
| 70 |
+
const errorMsg = text.slice(0, 100);
|
| 71 |
+
return { model, status: 'error', latencyMs, error: errorMsg, httpStatus: res.status };
|
| 72 |
+
}
|
| 73 |
+
} catch (e: any) {
|
| 74 |
+
return {
|
| 75 |
+
model,
|
| 76 |
+
status: 'error',
|
| 77 |
+
latencyMs: Date.now() - start,
|
| 78 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (15s)' : (e?.message || 'Unknown error').slice(0, 100),
|
| 79 |
+
};
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export async function GET(req: Request) {
|
| 84 |
+
const admin = requireAdmin(req);
|
| 85 |
+
if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 86 |
+
|
| 87 |
+
// Prefer admin-configured token, fall back to env var.
|
| 88 |
+
const config = loadConfig();
|
| 89 |
+
const token = config.llm.hfToken || process.env.HF_TOKEN || '';
|
| 90 |
+
|
| 91 |
+
if (!token) {
|
| 92 |
+
// Still return a well-formed response so the UI can render an empty-state
|
| 93 |
+
// Provider Status panel with a helpful error banner.
|
| 94 |
+
return NextResponse.json({
|
| 95 |
+
error: 'HF_TOKEN not configured — set it in Admin → Server → HuggingFace.',
|
| 96 |
+
models: MODELS_TO_TEST.map((model) => ({
|
| 97 |
+
model,
|
| 98 |
+
status: 'error' as const,
|
| 99 |
+
latencyMs: 0,
|
| 100 |
+
error: 'No HF token configured',
|
| 101 |
+
})),
|
| 102 |
+
summary: {
|
| 103 |
+
total: MODELS_TO_TEST.length,
|
| 104 |
+
ok: 0,
|
| 105 |
+
error: MODELS_TO_TEST.length,
|
| 106 |
+
testedAt: new Date().toISOString(),
|
| 107 |
+
},
|
| 108 |
+
});
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Test all models in parallel for speed.
|
| 112 |
+
const results = await Promise.all(
|
| 113 |
+
MODELS_TO_TEST.map((model) => testModel(model, token))
|
| 114 |
+
);
|
| 115 |
+
|
| 116 |
+
const ok = results.filter((r) => r.status === 'ok').length;
|
| 117 |
+
|
| 118 |
+
return NextResponse.json({
|
| 119 |
+
models: results,
|
| 120 |
+
summary: {
|
| 121 |
+
total: results.length,
|
| 122 |
+
ok,
|
| 123 |
+
error: results.length - ok,
|
| 124 |
+
testedAt: new Date().toISOString(),
|
| 125 |
+
},
|
| 126 |
+
});
|
| 127 |
+
}
|
app/api/admin/reset-password/route.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import bcrypt from 'bcryptjs';
|
| 4 |
+
import { getDb } from '@/lib/db';
|
| 5 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 6 |
+
|
| 7 |
+
const Schema = z.object({
|
| 8 |
+
userId: z.string().min(1),
|
| 9 |
+
newPassword: z.string().min(6, 'Password must be at least 6 characters'),
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* POST /api/admin/reset-password — Admin-initiated password reset.
|
| 14 |
+
*
|
| 15 |
+
* Allows admins to manually reset a user's password. This is the
|
| 16 |
+
* industry-standard approach for user management: the admin sets a
|
| 17 |
+
* temporary password and instructs the user to change it on login.
|
| 18 |
+
*
|
| 19 |
+
* Security measures:
|
| 20 |
+
* - Requires admin authentication
|
| 21 |
+
* - Passwords are bcrypt-hashed
|
| 22 |
+
* - All existing sessions for the user are invalidated
|
| 23 |
+
* - Cannot reset your own password (use profile instead)
|
| 24 |
+
*/
|
| 25 |
+
export async function POST(req: Request) {
|
| 26 |
+
const admin = requireAdmin(req);
|
| 27 |
+
if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 28 |
+
|
| 29 |
+
try {
|
| 30 |
+
const body = await req.json();
|
| 31 |
+
const { userId, newPassword } = Schema.parse(body);
|
| 32 |
+
|
| 33 |
+
const db = getDb();
|
| 34 |
+
|
| 35 |
+
// Verify user exists.
|
| 36 |
+
const user = db.prepare('SELECT id, email FROM users WHERE id = ?').get(userId) as any;
|
| 37 |
+
if (!user) {
|
| 38 |
+
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Hash new password.
|
| 42 |
+
const hash = bcrypt.hashSync(newPassword, 10);
|
| 43 |
+
|
| 44 |
+
// Update password and invalidate all sessions.
|
| 45 |
+
const tx = db.transaction(() => {
|
| 46 |
+
db.prepare('UPDATE users SET password = ?, updated_at = datetime(\'now\') WHERE id = ?').run(hash, userId);
|
| 47 |
+
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
| 48 |
+
});
|
| 49 |
+
tx();
|
| 50 |
+
|
| 51 |
+
return NextResponse.json({
|
| 52 |
+
success: true,
|
| 53 |
+
message: `Password reset for ${user.email}. All sessions invalidated.`,
|
| 54 |
+
});
|
| 55 |
+
} catch (error: any) {
|
| 56 |
+
if (error instanceof z.ZodError) {
|
| 57 |
+
return NextResponse.json({ error: error.errors[0]?.message || 'Invalid input' }, { status: 400 });
|
| 58 |
+
}
|
| 59 |
+
console.error('[Admin Reset Password]', error?.message);
|
| 60 |
+
return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 });
|
| 61 |
+
}
|
| 62 |
+
}
|
app/api/admin/stats/route.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { getDb } from '@/lib/db';
|
| 3 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/admin/stats — aggregate platform statistics (admin only).
|
| 7 |
+
*/
|
| 8 |
+
export async function GET(req: Request) {
|
| 9 |
+
const admin = requireAdmin(req);
|
| 10 |
+
if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 11 |
+
|
| 12 |
+
const db = getDb();
|
| 13 |
+
|
| 14 |
+
const totalUsers = (db.prepare('SELECT COUNT(*) as c FROM users').get() as any).c;
|
| 15 |
+
const verifiedUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE email_verified = 1').get() as any).c;
|
| 16 |
+
const adminUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE is_admin = 1').get() as any).c;
|
| 17 |
+
const totalHealthData = (db.prepare('SELECT COUNT(*) as c FROM health_data').get() as any).c;
|
| 18 |
+
const totalChats = (db.prepare('SELECT COUNT(*) as c FROM chat_history').get() as any).c;
|
| 19 |
+
const activeSessions = (db.prepare("SELECT COUNT(*) as c FROM sessions WHERE expires_at > datetime('now')").get() as any).c;
|
| 20 |
+
|
| 21 |
+
// Health data breakdown by type.
|
| 22 |
+
const healthBreakdown = db
|
| 23 |
+
.prepare('SELECT type, COUNT(*) as count FROM health_data GROUP BY type ORDER BY count DESC')
|
| 24 |
+
.all() as any[];
|
| 25 |
+
|
| 26 |
+
// Registrations over time (last 30 days).
|
| 27 |
+
const registrations = db
|
| 28 |
+
.prepare(
|
| 29 |
+
`SELECT date(created_at) as day, COUNT(*) as count
|
| 30 |
+
FROM users
|
| 31 |
+
WHERE created_at > datetime('now', '-30 days')
|
| 32 |
+
GROUP BY day ORDER BY day`,
|
| 33 |
+
)
|
| 34 |
+
.all() as any[];
|
| 35 |
+
|
| 36 |
+
return NextResponse.json({
|
| 37 |
+
totalUsers,
|
| 38 |
+
verifiedUsers,
|
| 39 |
+
adminUsers,
|
| 40 |
+
totalHealthData,
|
| 41 |
+
totalChats,
|
| 42 |
+
activeSessions,
|
| 43 |
+
healthBreakdown,
|
| 44 |
+
registrations,
|
| 45 |
+
});
|
| 46 |
+
}
|
app/api/admin/system-info/route.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import fs from 'fs';
|
| 3 |
+
import os from 'os';
|
| 4 |
+
import path from 'path';
|
| 5 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 6 |
+
import { getDb } from '@/lib/db';
|
| 7 |
+
import { CONFIG_PATH } from '@/lib/server-config';
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* GET /api/admin/system-info — operational diagnostics for the admin panel.
|
| 11 |
+
*
|
| 12 |
+
* Returns non-sensitive runtime facts about the deployment so ops can
|
| 13 |
+
* debug "why isn't feature X working" without SSH'ing into the Space:
|
| 14 |
+
* - Node + platform versions
|
| 15 |
+
* - DB path, size, schema version (PRAGMA user_version), row counts
|
| 16 |
+
* - Config file path, existence, size, last-modified
|
| 17 |
+
* - Encryption-key presence (boolean only — never the value)
|
| 18 |
+
* - Uptime, memory, load averages
|
| 19 |
+
* - Feature-flag / env presence map (booleans only)
|
| 20 |
+
*
|
| 21 |
+
* No secret values are ever returned. Admin-only endpoint.
|
| 22 |
+
*/
|
| 23 |
+
|
| 24 |
+
export const runtime = 'nodejs';
|
| 25 |
+
export const dynamic = 'force-dynamic';
|
| 26 |
+
|
| 27 |
+
function safeStat(p: string) {
|
| 28 |
+
try {
|
| 29 |
+
const s = fs.statSync(p);
|
| 30 |
+
return {
|
| 31 |
+
exists: true,
|
| 32 |
+
sizeBytes: s.size,
|
| 33 |
+
modifiedAt: s.mtime.toISOString(),
|
| 34 |
+
};
|
| 35 |
+
} catch {
|
| 36 |
+
return { exists: false };
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function envFlag(name: string): boolean {
|
| 41 |
+
return !!(process.env[name] && process.env[name]!.length > 0);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export async function GET(req: Request) {
|
| 45 |
+
const admin = requireAdmin(req);
|
| 46 |
+
if (!admin) {
|
| 47 |
+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const db = getDb();
|
| 51 |
+
|
| 52 |
+
const dbPath = process.env.DB_PATH || '/data/medos.db';
|
| 53 |
+
const persistentDir = process.env.PERSISTENT_DIR || path.dirname(dbPath);
|
| 54 |
+
const userVersion = db.pragma('user_version', { simple: true }) as number;
|
| 55 |
+
const journalMode = db.pragma('journal_mode', { simple: true });
|
| 56 |
+
const foreignKeys = db.pragma('foreign_keys', { simple: true });
|
| 57 |
+
|
| 58 |
+
// Cheap row counts — all indexed / small-table aggregates, safe to run
|
| 59 |
+
// synchronously on each request.
|
| 60 |
+
const counts = {
|
| 61 |
+
users: (db.prepare('SELECT COUNT(*) c FROM users').get() as any).c as number,
|
| 62 |
+
sessions: (db.prepare('SELECT COUNT(*) c FROM sessions').get() as any).c as number,
|
| 63 |
+
healthData: (db.prepare('SELECT COUNT(*) c FROM health_data').get() as any).c as number,
|
| 64 |
+
chatHistory: (db.prepare('SELECT COUNT(*) c FROM chat_history').get() as any).c as number,
|
| 65 |
+
auditLog: (db.prepare('SELECT COUNT(*) c FROM audit_log').get() as any).c as number,
|
| 66 |
+
scanLog: (db.prepare('SELECT COUNT(*) c FROM scan_log').get() as any).c as number,
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const mem = process.memoryUsage();
|
| 70 |
+
|
| 71 |
+
return NextResponse.json({
|
| 72 |
+
runtime: {
|
| 73 |
+
node: process.version,
|
| 74 |
+
platform: `${os.platform()} ${os.release()}`,
|
| 75 |
+
arch: process.arch,
|
| 76 |
+
uptimeSec: Math.round(process.uptime()),
|
| 77 |
+
nodeEnv: process.env.NODE_ENV || 'development',
|
| 78 |
+
pid: process.pid,
|
| 79 |
+
},
|
| 80 |
+
process: {
|
| 81 |
+
memoryMb: {
|
| 82 |
+
rss: Math.round(mem.rss / 1024 / 1024),
|
| 83 |
+
heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
|
| 84 |
+
heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
|
| 85 |
+
},
|
| 86 |
+
loadAverage: os.loadavg(),
|
| 87 |
+
},
|
| 88 |
+
database: {
|
| 89 |
+
path: dbPath,
|
| 90 |
+
schemaVersion: userVersion,
|
| 91 |
+
journalMode,
|
| 92 |
+
foreignKeys,
|
| 93 |
+
file: safeStat(dbPath),
|
| 94 |
+
counts,
|
| 95 |
+
},
|
| 96 |
+
config: {
|
| 97 |
+
path: CONFIG_PATH,
|
| 98 |
+
persistentDir,
|
| 99 |
+
file: safeStat(CONFIG_PATH),
|
| 100 |
+
},
|
| 101 |
+
security: {
|
| 102 |
+
// Booleans only — never the value. Redaction by construction.
|
| 103 |
+
encryptionKeySet: envFlag('ENCRYPTION_KEY'),
|
| 104 |
+
adminPasswordSet: envFlag('ADMIN_PASSWORD'),
|
| 105 |
+
adminEmailSet: envFlag('ADMIN_EMAIL'),
|
| 106 |
+
scanRequireAuth:
|
| 107 |
+
(process.env.SCAN_REQUIRE_AUTH || '').toLowerCase() !== 'false',
|
| 108 |
+
},
|
| 109 |
+
features: {
|
| 110 |
+
// Presence map for quick "what's wired" answers. No values exposed.
|
| 111 |
+
hfToken: envFlag('HF_TOKEN'),
|
| 112 |
+
hfTokenInference: envFlag('HF_TOKEN_INFERENCE'),
|
| 113 |
+
ollabridgeUrl: envFlag('OLLABRIDGE_URL'),
|
| 114 |
+
ollabridgeKey: envFlag('OLLABRIDGE_API_KEY'),
|
| 115 |
+
openai: envFlag('OPENAI_API_KEY'),
|
| 116 |
+
anthropic: envFlag('ANTHROPIC_API_KEY'),
|
| 117 |
+
groq: envFlag('GROQ_API_KEY'),
|
| 118 |
+
watsonx: envFlag('WATSONX_API_KEY') && envFlag('WATSONX_PROJECT_ID'),
|
| 119 |
+
gemini: envFlag('GEMINI_API_KEY') || envFlag('GOOGLE_API_KEY'),
|
| 120 |
+
openrouter: envFlag('OPENROUTER_API_KEY'),
|
| 121 |
+
together: envFlag('TOGETHER_API_KEY'),
|
| 122 |
+
mistral: envFlag('MISTRAL_API_KEY'),
|
| 123 |
+
smtp: envFlag('SMTP_HOST') && envFlag('SMTP_USER') && envFlag('SMTP_PASS'),
|
| 124 |
+
scannerUrl: envFlag('SCANNER_URL'),
|
| 125 |
+
nearbyUrl: envFlag('NEARBY_URL'),
|
| 126 |
+
allowedOrigins: envFlag('ALLOWED_ORIGINS'),
|
| 127 |
+
appUrl: envFlag('APP_URL'),
|
| 128 |
+
},
|
| 129 |
+
});
|
| 130 |
+
}
|
app/api/admin/test-connection/route.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 3 |
+
import { loadConfig } from '@/lib/server-config';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* POST /api/admin/test-connection — Test connectivity to a named provider.
|
| 7 |
+
*
|
| 8 |
+
* Body: { provider: "ollabridge" | "huggingface" | "openai" | "anthropic"
|
| 9 |
+
* | "groq" | "watsonx" }
|
| 10 |
+
*
|
| 11 |
+
* Response:
|
| 12 |
+
* { ok: boolean, provider, latencyMs, status?, error?, details? }
|
| 13 |
+
*
|
| 14 |
+
* Used by the "Test Connection" button on each provider card. Mirrors the
|
| 15 |
+
* `ollabridge pair` CLI check — validates that credentials are good and
|
| 16 |
+
* that the provider's /v1/models (or equivalent) endpoint responds.
|
| 17 |
+
*
|
| 18 |
+
* Admin-only.
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
export const runtime = 'nodejs';
|
| 22 |
+
export const dynamic = 'force-dynamic';
|
| 23 |
+
|
| 24 |
+
type Provider =
|
| 25 |
+
| 'ollabridge'
|
| 26 |
+
| 'huggingface'
|
| 27 |
+
| 'openai'
|
| 28 |
+
| 'anthropic'
|
| 29 |
+
| 'groq'
|
| 30 |
+
| 'watsonx'
|
| 31 |
+
| 'gemini'
|
| 32 |
+
| 'openrouter'
|
| 33 |
+
| 'together'
|
| 34 |
+
| 'mistral';
|
| 35 |
+
|
| 36 |
+
interface TestResult {
|
| 37 |
+
ok: boolean;
|
| 38 |
+
provider: Provider;
|
| 39 |
+
latencyMs: number;
|
| 40 |
+
status?: number;
|
| 41 |
+
error?: string;
|
| 42 |
+
details?: string;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
async function testOllaBridge(url: string, apiKey: string): Promise<Omit<TestResult, 'provider'>> {
|
| 46 |
+
const start = Date.now();
|
| 47 |
+
if (!url) {
|
| 48 |
+
return { ok: false, latencyMs: 0, error: 'URL not configured' };
|
| 49 |
+
}
|
| 50 |
+
try {
|
| 51 |
+
const cleanBase = url.replace(/\/+$/, '');
|
| 52 |
+
const res = await fetch(`${cleanBase}/v1/models`, {
|
| 53 |
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
| 54 |
+
signal: AbortSignal.timeout(10000),
|
| 55 |
+
});
|
| 56 |
+
const latencyMs = Date.now() - start;
|
| 57 |
+
if (!res.ok) {
|
| 58 |
+
return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
|
| 59 |
+
}
|
| 60 |
+
const data = await res.json().catch(() => null);
|
| 61 |
+
const count = Array.isArray(data?.data) ? data.data.length : 0;
|
| 62 |
+
return {
|
| 63 |
+
ok: true,
|
| 64 |
+
latencyMs,
|
| 65 |
+
status: res.status,
|
| 66 |
+
details: `${count} model${count === 1 ? '' : 's'} available`,
|
| 67 |
+
};
|
| 68 |
+
} catch (e: any) {
|
| 69 |
+
return {
|
| 70 |
+
ok: false,
|
| 71 |
+
latencyMs: Date.now() - start,
|
| 72 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
async function testHuggingFace(token: string): Promise<Omit<TestResult, 'provider'>> {
|
| 78 |
+
const start = Date.now();
|
| 79 |
+
if (!token) return { ok: false, latencyMs: 0, error: 'HF token not configured' };
|
| 80 |
+
try {
|
| 81 |
+
// whoami-v2 validates that the token has API access; it's cheaper
|
| 82 |
+
// than hitting the router and gives a clear permission error.
|
| 83 |
+
const res = await fetch('https://huggingface.co/api/whoami-v2', {
|
| 84 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 85 |
+
signal: AbortSignal.timeout(10000),
|
| 86 |
+
});
|
| 87 |
+
const latencyMs = Date.now() - start;
|
| 88 |
+
if (!res.ok) {
|
| 89 |
+
return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
|
| 90 |
+
}
|
| 91 |
+
const data = await res.json().catch(() => null);
|
| 92 |
+
return {
|
| 93 |
+
ok: true,
|
| 94 |
+
latencyMs,
|
| 95 |
+
status: res.status,
|
| 96 |
+
details: data?.name ? `Authenticated as ${data.name}` : 'Token valid',
|
| 97 |
+
};
|
| 98 |
+
} catch (e: any) {
|
| 99 |
+
return {
|
| 100 |
+
ok: false,
|
| 101 |
+
latencyMs: Date.now() - start,
|
| 102 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
|
| 103 |
+
};
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
async function testOpenAI(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
|
| 108 |
+
const start = Date.now();
|
| 109 |
+
if (!apiKey) return { ok: false, latencyMs: 0, error: 'OpenAI API key not configured' };
|
| 110 |
+
try {
|
| 111 |
+
const res = await fetch('https://api.openai.com/v1/models', {
|
| 112 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 113 |
+
signal: AbortSignal.timeout(10000),
|
| 114 |
+
});
|
| 115 |
+
const latencyMs = Date.now() - start;
|
| 116 |
+
if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
|
| 117 |
+
const data = await res.json().catch(() => null);
|
| 118 |
+
const count = Array.isArray(data?.data) ? data.data.length : 0;
|
| 119 |
+
return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
|
| 120 |
+
} catch (e: any) {
|
| 121 |
+
return {
|
| 122 |
+
ok: false,
|
| 123 |
+
latencyMs: Date.now() - start,
|
| 124 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
|
| 125 |
+
};
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
async function testAnthropic(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
|
| 130 |
+
const start = Date.now();
|
| 131 |
+
if (!apiKey) return { ok: false, latencyMs: 0, error: 'Anthropic API key not configured' };
|
| 132 |
+
try {
|
| 133 |
+
const res = await fetch('https://api.anthropic.com/v1/models', {
|
| 134 |
+
headers: {
|
| 135 |
+
'x-api-key': apiKey,
|
| 136 |
+
'anthropic-version': '2023-06-01',
|
| 137 |
+
},
|
| 138 |
+
signal: AbortSignal.timeout(10000),
|
| 139 |
+
});
|
| 140 |
+
const latencyMs = Date.now() - start;
|
| 141 |
+
if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
|
| 142 |
+
const data = await res.json().catch(() => null);
|
| 143 |
+
const count = Array.isArray(data?.data) ? data.data.length : 0;
|
| 144 |
+
return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
|
| 145 |
+
} catch (e: any) {
|
| 146 |
+
return {
|
| 147 |
+
ok: false,
|
| 148 |
+
latencyMs: Date.now() - start,
|
| 149 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
|
| 150 |
+
};
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
async function testGroq(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
|
| 155 |
+
const start = Date.now();
|
| 156 |
+
if (!apiKey) return { ok: false, latencyMs: 0, error: 'Groq API key not configured' };
|
| 157 |
+
try {
|
| 158 |
+
const res = await fetch('https://api.groq.com/openai/v1/models', {
|
| 159 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 160 |
+
signal: AbortSignal.timeout(10000),
|
| 161 |
+
});
|
| 162 |
+
const latencyMs = Date.now() - start;
|
| 163 |
+
if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
|
| 164 |
+
const data = await res.json().catch(() => null);
|
| 165 |
+
const count = Array.isArray(data?.data) ? data.data.length : 0;
|
| 166 |
+
return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
|
| 167 |
+
} catch (e: any) {
|
| 168 |
+
return {
|
| 169 |
+
ok: false,
|
| 170 |
+
latencyMs: Date.now() - start,
|
| 171 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
|
| 172 |
+
};
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
async function testWatsonx(
|
| 177 |
+
apiKey: string,
|
| 178 |
+
projectId: string,
|
| 179 |
+
_baseUrl: string,
|
| 180 |
+
): Promise<Omit<TestResult, 'provider'>> {
|
| 181 |
+
const start = Date.now();
|
| 182 |
+
if (!apiKey || !projectId) {
|
| 183 |
+
return { ok: false, latencyMs: 0, error: 'Missing API key or project ID' };
|
| 184 |
+
}
|
| 185 |
+
try {
|
| 186 |
+
const res = await fetch('https://iam.cloud.ibm.com/identity/token', {
|
| 187 |
+
method: 'POST',
|
| 188 |
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
| 189 |
+
body: new URLSearchParams({
|
| 190 |
+
grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
|
| 191 |
+
apikey: apiKey,
|
| 192 |
+
}),
|
| 193 |
+
signal: AbortSignal.timeout(10000),
|
| 194 |
+
});
|
| 195 |
+
const latencyMs = Date.now() - start;
|
| 196 |
+
if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `IAM HTTP ${res.status}` };
|
| 197 |
+
const data = await res.json().catch(() => null);
|
| 198 |
+
if (!data?.access_token) return { ok: false, latencyMs, error: 'No access_token in IAM response' };
|
| 199 |
+
return { ok: true, latencyMs, status: 200, details: 'IAM token valid' };
|
| 200 |
+
} catch (e: any) {
|
| 201 |
+
return {
|
| 202 |
+
ok: false,
|
| 203 |
+
latencyMs: Date.now() - start,
|
| 204 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
|
| 205 |
+
};
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// ---- Additional provider testers (v3) ------------------------------------
|
| 210 |
+
|
| 211 |
+
async function testGemini(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
|
| 212 |
+
const start = Date.now();
|
| 213 |
+
if (!apiKey) return { ok: false, latencyMs: 0, error: 'Gemini API key not configured' };
|
| 214 |
+
try {
|
| 215 |
+
// Gemini uses the key as a query param, not a bearer header.
|
| 216 |
+
const res = await fetch(
|
| 217 |
+
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`,
|
| 218 |
+
{ signal: AbortSignal.timeout(10000) },
|
| 219 |
+
);
|
| 220 |
+
const latencyMs = Date.now() - start;
|
| 221 |
+
if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
|
| 222 |
+
const data = await res.json().catch(() => null);
|
| 223 |
+
const count = Array.isArray(data?.models) ? data.models.length : 0;
|
| 224 |
+
return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
|
| 225 |
+
} catch (e: any) {
|
| 226 |
+
return {
|
| 227 |
+
ok: false,
|
| 228 |
+
latencyMs: Date.now() - start,
|
| 229 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
|
| 230 |
+
};
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
async function testOpenRouter(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
|
| 235 |
+
const start = Date.now();
|
| 236 |
+
if (!apiKey) return { ok: false, latencyMs: 0, error: 'OpenRouter API key not configured' };
|
| 237 |
+
try {
|
| 238 |
+
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
| 239 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 240 |
+
signal: AbortSignal.timeout(10000),
|
| 241 |
+
});
|
| 242 |
+
const latencyMs = Date.now() - start;
|
| 243 |
+
if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
|
| 244 |
+
const data = await res.json().catch(() => null);
|
| 245 |
+
const count = Array.isArray(data?.data) ? data.data.length : 0;
|
| 246 |
+
return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
|
| 247 |
+
} catch (e: any) {
|
| 248 |
+
return {
|
| 249 |
+
ok: false,
|
| 250 |
+
latencyMs: Date.now() - start,
|
| 251 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
|
| 252 |
+
};
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
async function testTogether(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
|
| 257 |
+
const start = Date.now();
|
| 258 |
+
if (!apiKey) return { ok: false, latencyMs: 0, error: 'Together API key not configured' };
|
| 259 |
+
try {
|
| 260 |
+
const res = await fetch('https://api.together.xyz/v1/models', {
|
| 261 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 262 |
+
signal: AbortSignal.timeout(10000),
|
| 263 |
+
});
|
| 264 |
+
const latencyMs = Date.now() - start;
|
| 265 |
+
if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
|
| 266 |
+
const data = await res.json().catch(() => null);
|
| 267 |
+
const count = Array.isArray(data)
|
| 268 |
+
? data.length
|
| 269 |
+
: Array.isArray(data?.data)
|
| 270 |
+
? data.data.length
|
| 271 |
+
: 0;
|
| 272 |
+
return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
|
| 273 |
+
} catch (e: any) {
|
| 274 |
+
return {
|
| 275 |
+
ok: false,
|
| 276 |
+
latencyMs: Date.now() - start,
|
| 277 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
|
| 278 |
+
};
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
async function testMistral(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
|
| 283 |
+
const start = Date.now();
|
| 284 |
+
if (!apiKey) return { ok: false, latencyMs: 0, error: 'Mistral API key not configured' };
|
| 285 |
+
try {
|
| 286 |
+
const res = await fetch('https://api.mistral.ai/v1/models', {
|
| 287 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 288 |
+
signal: AbortSignal.timeout(10000),
|
| 289 |
+
});
|
| 290 |
+
const latencyMs = Date.now() - start;
|
| 291 |
+
if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
|
| 292 |
+
const data = await res.json().catch(() => null);
|
| 293 |
+
const count = Array.isArray(data?.data) ? data.data.length : 0;
|
| 294 |
+
return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
|
| 295 |
+
} catch (e: any) {
|
| 296 |
+
return {
|
| 297 |
+
ok: false,
|
| 298 |
+
latencyMs: Date.now() - start,
|
| 299 |
+
error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
|
| 300 |
+
};
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
export async function POST(req: Request) {
|
| 305 |
+
const admin = requireAdmin(req);
|
| 306 |
+
if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 307 |
+
|
| 308 |
+
let body: any;
|
| 309 |
+
try {
|
| 310 |
+
body = await req.json();
|
| 311 |
+
} catch {
|
| 312 |
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
const provider = body?.provider as Provider;
|
| 316 |
+
if (!provider) {
|
| 317 |
+
return NextResponse.json({ error: 'Missing provider field' }, { status: 400 });
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
const config = loadConfig();
|
| 321 |
+
const { llm } = config;
|
| 322 |
+
|
| 323 |
+
let result: Omit<TestResult, 'provider'>;
|
| 324 |
+
switch (provider) {
|
| 325 |
+
case 'ollabridge':
|
| 326 |
+
result = await testOllaBridge(llm.ollabridgeUrl, llm.ollabridgeApiKey);
|
| 327 |
+
break;
|
| 328 |
+
case 'huggingface':
|
| 329 |
+
result = await testHuggingFace(llm.hfToken);
|
| 330 |
+
break;
|
| 331 |
+
case 'openai':
|
| 332 |
+
result = await testOpenAI(llm.openaiApiKey);
|
| 333 |
+
break;
|
| 334 |
+
case 'anthropic':
|
| 335 |
+
result = await testAnthropic(llm.anthropicApiKey);
|
| 336 |
+
break;
|
| 337 |
+
case 'groq':
|
| 338 |
+
result = await testGroq(llm.groqApiKey);
|
| 339 |
+
break;
|
| 340 |
+
case 'watsonx':
|
| 341 |
+
result = await testWatsonx(llm.watsonxApiKey, llm.watsonxProjectId, llm.watsonxUrl);
|
| 342 |
+
break;
|
| 343 |
+
case 'gemini':
|
| 344 |
+
result = await testGemini(llm.geminiApiKey);
|
| 345 |
+
break;
|
| 346 |
+
case 'openrouter':
|
| 347 |
+
result = await testOpenRouter(llm.openrouterApiKey);
|
| 348 |
+
break;
|
| 349 |
+
case 'together':
|
| 350 |
+
result = await testTogether(llm.togetherApiKey);
|
| 351 |
+
break;
|
| 352 |
+
case 'mistral':
|
| 353 |
+
result = await testMistral(llm.mistralApiKey);
|
| 354 |
+
break;
|
| 355 |
+
default:
|
| 356 |
+
return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 400 });
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
return NextResponse.json({ provider, ...result });
|
| 360 |
+
}
|
app/api/admin/users/[id]/route.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import { getDb } from '@/lib/db';
|
| 4 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 5 |
+
import { auditLog } from '@/lib/audit';
|
| 6 |
+
import { getClientIp } from '@/lib/rate-limit';
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Per-user admin endpoints — safe, non-destructive operations.
|
| 10 |
+
*
|
| 11 |
+
* GET /api/admin/users/:id → full user profile (admin-only)
|
| 12 |
+
* PATCH /api/admin/users/:id → change role / active state / force-logout
|
| 13 |
+
*
|
| 14 |
+
* Why PATCH and not POST/PUT:
|
| 15 |
+
* - PATCH advertises "partial update of an existing resource" which
|
| 16 |
+
* matches how the admin UI will call this (flip one field at a time).
|
| 17 |
+
* - DELETE already exists at the collection level for hard delete.
|
| 18 |
+
* Deactivation via PATCH is the preferred, reversible alternative.
|
| 19 |
+
*
|
| 20 |
+
* Actions accepted in the body (any subset, all optional):
|
| 21 |
+
* - isAdmin: boolean → promote / demote
|
| 22 |
+
* - isActive: boolean → enable / disable the account
|
| 23 |
+
* - disabledReason: string → stored alongside isActive=false
|
| 24 |
+
* - forceLogout: boolean → drop every active session for this user
|
| 25 |
+
*
|
| 26 |
+
* Safety rails:
|
| 27 |
+
* - An admin cannot demote or deactivate themselves via this endpoint
|
| 28 |
+
* (would create an unrecoverable lock-out if they were the last admin).
|
| 29 |
+
* - Every mutation writes to audit_log with the before/after summary.
|
| 30 |
+
*/
|
| 31 |
+
|
| 32 |
+
const PatchSchema = z
|
| 33 |
+
.object({
|
| 34 |
+
isAdmin: z.boolean().optional(),
|
| 35 |
+
isActive: z.boolean().optional(),
|
| 36 |
+
disabledReason: z.string().max(500).optional(),
|
| 37 |
+
forceLogout: z.boolean().optional(),
|
| 38 |
+
})
|
| 39 |
+
.refine(
|
| 40 |
+
(v) =>
|
| 41 |
+
v.isAdmin !== undefined ||
|
| 42 |
+
v.isActive !== undefined ||
|
| 43 |
+
v.disabledReason !== undefined ||
|
| 44 |
+
v.forceLogout === true,
|
| 45 |
+
{ message: 'No actionable field provided' },
|
| 46 |
+
);
|
| 47 |
+
|
| 48 |
+
function readUser(db: any, id: string) {
|
| 49 |
+
return db
|
| 50 |
+
.prepare(
|
| 51 |
+
`SELECT id, email, display_name, email_verified, is_admin,
|
| 52 |
+
COALESCE(is_active, 1) AS is_active, disabled_reason,
|
| 53 |
+
last_login_at, created_at
|
| 54 |
+
FROM users WHERE id = ?`,
|
| 55 |
+
)
|
| 56 |
+
.get(id) as any;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function shape(row: any) {
|
| 60 |
+
if (!row) return null;
|
| 61 |
+
return {
|
| 62 |
+
id: row.id,
|
| 63 |
+
email: row.email,
|
| 64 |
+
displayName: row.display_name,
|
| 65 |
+
emailVerified: !!row.email_verified,
|
| 66 |
+
isAdmin: !!row.is_admin,
|
| 67 |
+
isActive: !!row.is_active,
|
| 68 |
+
disabledReason: row.disabled_reason || null,
|
| 69 |
+
lastLoginAt: row.last_login_at || null,
|
| 70 |
+
createdAt: row.created_at,
|
| 71 |
+
};
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export async function GET(
|
| 75 |
+
req: Request,
|
| 76 |
+
{ params }: { params: { id: string } },
|
| 77 |
+
) {
|
| 78 |
+
const admin = requireAdmin(req);
|
| 79 |
+
if (!admin) {
|
| 80 |
+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 81 |
+
}
|
| 82 |
+
const db = getDb();
|
| 83 |
+
const row = readUser(db, params.id);
|
| 84 |
+
if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
| 85 |
+
return NextResponse.json({ user: shape(row) });
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export async function PATCH(
|
| 89 |
+
req: Request,
|
| 90 |
+
{ params }: { params: { id: string } },
|
| 91 |
+
) {
|
| 92 |
+
const admin = requireAdmin(req);
|
| 93 |
+
if (!admin) {
|
| 94 |
+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
let body: any;
|
| 98 |
+
try {
|
| 99 |
+
body = await req.json();
|
| 100 |
+
} catch {
|
| 101 |
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
| 102 |
+
}
|
| 103 |
+
const parsed = PatchSchema.safeParse(body);
|
| 104 |
+
if (!parsed.success) {
|
| 105 |
+
return NextResponse.json(
|
| 106 |
+
{ error: parsed.error.errors[0]?.message || 'Invalid payload' },
|
| 107 |
+
{ status: 400 },
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
const patch = parsed.data;
|
| 111 |
+
|
| 112 |
+
const db = getDb();
|
| 113 |
+
const before = readUser(db, params.id);
|
| 114 |
+
if (!before) {
|
| 115 |
+
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Safety rail — no self-demotion or self-deactivation.
|
| 119 |
+
if (admin.id === params.id) {
|
| 120 |
+
if (patch.isAdmin === false) {
|
| 121 |
+
return NextResponse.json(
|
| 122 |
+
{ error: 'An admin cannot demote their own account.' },
|
| 123 |
+
{ status: 400 },
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
if (patch.isActive === false) {
|
| 127 |
+
return NextResponse.json(
|
| 128 |
+
{ error: 'An admin cannot deactivate their own account.' },
|
| 129 |
+
{ status: 400 },
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// Build the UPDATE dynamically so we only touch the fields the admin
|
| 135 |
+
// actually passed. Static prepared statement per combination would be
|
| 136 |
+
// ideal, but cardinality is tiny and this keeps the audit diff honest.
|
| 137 |
+
const sets: string[] = [];
|
| 138 |
+
const values: any[] = [];
|
| 139 |
+
const diff: Record<string, { before: any; after: any }> = {};
|
| 140 |
+
|
| 141 |
+
if (patch.isAdmin !== undefined && !!before.is_admin !== patch.isAdmin) {
|
| 142 |
+
sets.push('is_admin = ?');
|
| 143 |
+
values.push(patch.isAdmin ? 1 : 0);
|
| 144 |
+
diff.isAdmin = { before: !!before.is_admin, after: patch.isAdmin };
|
| 145 |
+
}
|
| 146 |
+
if (patch.isActive !== undefined && !!before.is_active !== patch.isActive) {
|
| 147 |
+
sets.push('is_active = ?');
|
| 148 |
+
values.push(patch.isActive ? 1 : 0);
|
| 149 |
+
diff.isActive = { before: !!before.is_active, after: patch.isActive };
|
| 150 |
+
// Clear disabled_reason automatically when re-activating.
|
| 151 |
+
if (patch.isActive === true) {
|
| 152 |
+
sets.push('disabled_reason = NULL');
|
| 153 |
+
diff.disabledReason = { before: before.disabled_reason || null, after: null };
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
if (patch.disabledReason !== undefined) {
|
| 157 |
+
sets.push('disabled_reason = ?');
|
| 158 |
+
values.push(patch.disabledReason || null);
|
| 159 |
+
diff.disabledReason = {
|
| 160 |
+
before: before.disabled_reason || null,
|
| 161 |
+
after: patch.disabledReason || null,
|
| 162 |
+
};
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
if (sets.length) {
|
| 166 |
+
sets.push("updated_at = datetime('now')");
|
| 167 |
+
db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(
|
| 168 |
+
...values,
|
| 169 |
+
params.id,
|
| 170 |
+
);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// forceLogout and isActive=false both revoke sessions. We do it in a
|
| 174 |
+
// single DELETE to keep the state transition atomic with the update.
|
| 175 |
+
let revoked = 0;
|
| 176 |
+
if (patch.forceLogout || patch.isActive === false) {
|
| 177 |
+
const info = db
|
| 178 |
+
.prepare('DELETE FROM sessions WHERE user_id = ?')
|
| 179 |
+
.run(params.id);
|
| 180 |
+
revoked = info.changes;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
auditLog({
|
| 184 |
+
userId: admin.id,
|
| 185 |
+
action: 'admin_action',
|
| 186 |
+
ip: getClientIp(req),
|
| 187 |
+
meta: {
|
| 188 |
+
target_user: params.id,
|
| 189 |
+
sub_action:
|
| 190 |
+
patch.forceLogout && sets.length === 0
|
| 191 |
+
? 'force_logout'
|
| 192 |
+
: 'user_update',
|
| 193 |
+
diff,
|
| 194 |
+
sessions_revoked: revoked,
|
| 195 |
+
},
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
const after = readUser(db, params.id);
|
| 199 |
+
return NextResponse.json({
|
| 200 |
+
user: shape(after),
|
| 201 |
+
sessionsRevoked: revoked,
|
| 202 |
+
changed: Object.keys(diff),
|
| 203 |
+
});
|
| 204 |
+
}
|
app/api/admin/users/route.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { getDb } from '@/lib/db';
|
| 3 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/admin/users — list all registered users (admin only).
|
| 7 |
+
* Query params: ?page=1&limit=50&search=term
|
| 8 |
+
*/
|
| 9 |
+
export async function GET(req: Request) {
|
| 10 |
+
const admin = requireAdmin(req);
|
| 11 |
+
if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 12 |
+
|
| 13 |
+
const url = new URL(req.url);
|
| 14 |
+
const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10));
|
| 15 |
+
const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)));
|
| 16 |
+
const search = url.searchParams.get('search')?.trim();
|
| 17 |
+
const offset = (page - 1) * limit;
|
| 18 |
+
|
| 19 |
+
const db = getDb();
|
| 20 |
+
|
| 21 |
+
const where = search ? "WHERE email LIKE ? OR display_name LIKE ?" : "";
|
| 22 |
+
const params = search ? [`%${search}%`, `%${search}%`] : [];
|
| 23 |
+
|
| 24 |
+
const total = (db.prepare(`SELECT COUNT(*) as c FROM users ${where}`).get(...params) as any).c;
|
| 25 |
+
|
| 26 |
+
const rows = db
|
| 27 |
+
.prepare(
|
| 28 |
+
`SELECT id, email, display_name, email_verified, is_admin, created_at
|
| 29 |
+
FROM users ${where}
|
| 30 |
+
ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
| 31 |
+
)
|
| 32 |
+
.all(...params, limit, offset) as any[];
|
| 33 |
+
|
| 34 |
+
// Health data count per user.
|
| 35 |
+
const users = rows.map((r) => {
|
| 36 |
+
const healthCount = (
|
| 37 |
+
db.prepare('SELECT COUNT(*) as c FROM health_data WHERE user_id = ?').get(r.id) as any
|
| 38 |
+
).c;
|
| 39 |
+
const chatCount = (
|
| 40 |
+
db.prepare('SELECT COUNT(*) as c FROM chat_history WHERE user_id = ?').get(r.id) as any
|
| 41 |
+
).c;
|
| 42 |
+
return {
|
| 43 |
+
id: r.id,
|
| 44 |
+
email: r.email,
|
| 45 |
+
displayName: r.display_name,
|
| 46 |
+
emailVerified: !!r.email_verified,
|
| 47 |
+
isAdmin: !!r.is_admin,
|
| 48 |
+
createdAt: r.created_at,
|
| 49 |
+
healthDataCount: healthCount,
|
| 50 |
+
chatHistoryCount: chatCount,
|
| 51 |
+
};
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
return NextResponse.json({ users, total, page, limit });
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* DELETE /api/admin/users?id=<userId> — delete a user (admin only).
|
| 59 |
+
* CASCADE deletes all their health data, chat history, and sessions.
|
| 60 |
+
*/
|
| 61 |
+
export async function DELETE(req: Request) {
|
| 62 |
+
const admin = requireAdmin(req);
|
| 63 |
+
if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
| 64 |
+
|
| 65 |
+
const url = new URL(req.url);
|
| 66 |
+
const userId = url.searchParams.get('id');
|
| 67 |
+
if (!userId) return NextResponse.json({ error: 'Missing user id' }, { status: 400 });
|
| 68 |
+
|
| 69 |
+
// Prevent deleting yourself.
|
| 70 |
+
if (userId === admin.id) {
|
| 71 |
+
return NextResponse.json({ error: 'Cannot delete your own admin account' }, { status: 400 });
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const db = getDb();
|
| 75 |
+
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
| 76 |
+
|
| 77 |
+
return NextResponse.json({ success: true });
|
| 78 |
+
}
|
app/api/auth/delete-account/route.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import { getDb } from '@/lib/db';
|
| 4 |
+
import { requireAdmin } from '@/lib/auth-middleware';
|
| 5 |
+
|
| 6 |
+
const Schema = z.object({
|
| 7 |
+
userId: z.string().min(1),
|
| 8 |
+
confirmEmail: z.string().email(),
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* POST /api/auth/delete-account — ADMIN-ONLY account deletion.
|
| 13 |
+
*
|
| 14 |
+
* Only admins can delete accounts. This prevents:
|
| 15 |
+
* - Hackers with stolen tokens from destroying user data
|
| 16 |
+
* - Automated scripts mass-deleting accounts
|
| 17 |
+
* - Accidental self-deletion
|
| 18 |
+
*
|
| 19 |
+
* Requires both userId AND confirmEmail to match (double verification).
|
| 20 |
+
* Uses CASCADE deletes via foreign keys — one operation wipes everything.
|
| 21 |
+
*
|
| 22 |
+
* For GDPR: users REQUEST deletion via support/admin panel.
|
| 23 |
+
* Admin reviews and executes. This is the industry standard for
|
| 24 |
+
* healthcare apps (MyChart, Epic, Cerner all require admin action).
|
| 25 |
+
*/
|
| 26 |
+
export async function POST(req: Request) {
|
| 27 |
+
// ADMIN ONLY — reject all non-admin requests
|
| 28 |
+
const admin = requireAdmin(req);
|
| 29 |
+
if (!admin) {
|
| 30 |
+
return NextResponse.json(
|
| 31 |
+
{ error: 'Admin access required. Users must request account deletion through the admin.' },
|
| 32 |
+
{ status: 403 },
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
try {
|
| 37 |
+
const body = await req.json();
|
| 38 |
+
const { userId, confirmEmail } = Schema.parse(body);
|
| 39 |
+
|
| 40 |
+
const db = getDb();
|
| 41 |
+
|
| 42 |
+
// Verify user exists and email matches (double check)
|
| 43 |
+
const user = db.prepare('SELECT id, email, is_admin FROM users WHERE id = ?').get(userId) as any;
|
| 44 |
+
if (!user) {
|
| 45 |
+
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (user.email.toLowerCase() !== confirmEmail.toLowerCase()) {
|
| 49 |
+
return NextResponse.json(
|
| 50 |
+
{ error: 'Email confirmation does not match. Deletion aborted.' },
|
| 51 |
+
{ status: 400 },
|
| 52 |
+
);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Prevent deleting admin accounts (safety net)
|
| 56 |
+
if (user.is_admin) {
|
| 57 |
+
return NextResponse.json(
|
| 58 |
+
{ error: 'Cannot delete admin accounts via this endpoint.' },
|
| 59 |
+
{ status: 403 },
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// CASCADE deletes handle: sessions, health_data, chat_history
|
| 64 |
+
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
| 65 |
+
|
| 66 |
+
console.log(`[Account Deletion] Admin ${admin.email} deleted user ${user.email} (${userId})`);
|
| 67 |
+
|
| 68 |
+
return NextResponse.json({
|
| 69 |
+
success: true,
|
| 70 |
+
message: `Account ${user.email} and all associated data permanently deleted.`,
|
| 71 |
+
deletedBy: admin.email,
|
| 72 |
+
});
|
| 73 |
+
} catch (error: any) {
|
| 74 |
+
if (error instanceof z.ZodError) {
|
| 75 |
+
return NextResponse.json({ error: 'Invalid request. Provide userId and confirmEmail.' }, { status: 400 });
|
| 76 |
+
}
|
| 77 |
+
console.error('[Delete Account]', error?.message);
|
| 78 |
+
return NextResponse.json({ error: 'Failed to delete account' }, { status: 500 });
|
| 79 |
+
}
|
| 80 |
+
}
|
app/api/auth/forgot-password/route.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import { getDb, genVerificationCode, resetExpiry } from '@/lib/db';
|
| 4 |
+
import { sendPasswordResetEmail, emailTransportName } from '@/lib/email';
|
| 5 |
+
|
| 6 |
+
const Schema = z.object({
|
| 7 |
+
email: z.string().email(),
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* POST /api/auth/forgot-password — sends a reset code to the user's email.
|
| 12 |
+
*
|
| 13 |
+
* Always returns 200 even if the email doesn't exist (prevents email enumeration).
|
| 14 |
+
*/
|
| 15 |
+
export async function POST(req: Request) {
|
| 16 |
+
try {
|
| 17 |
+
const body = await req.json();
|
| 18 |
+
const { email } = Schema.parse(body);
|
| 19 |
+
|
| 20 |
+
const db = getDb();
|
| 21 |
+
const user = db.prepare('SELECT id, email FROM users WHERE email = ?').get(email.toLowerCase()) as any;
|
| 22 |
+
|
| 23 |
+
if (user) {
|
| 24 |
+
const code = genVerificationCode();
|
| 25 |
+
db.prepare(
|
| 26 |
+
`UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = datetime('now')
|
| 27 |
+
WHERE id = ?`,
|
| 28 |
+
).run(code, resetExpiry(), user.id);
|
| 29 |
+
|
| 30 |
+
console.log(`[ForgotPassword] queued reset email via transport=${emailTransportName()} to=${user.email}`);
|
| 31 |
+
const sent = await sendPasswordResetEmail(user.email, code);
|
| 32 |
+
if (!sent) console.error(`[ForgotPassword] reset email FAILED to=${user.email}`);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Always return success to prevent email enumeration.
|
| 36 |
+
return NextResponse.json({
|
| 37 |
+
message: 'If that email is registered, a reset code has been sent.',
|
| 38 |
+
});
|
| 39 |
+
} catch (error: any) {
|
| 40 |
+
if (error instanceof z.ZodError) {
|
| 41 |
+
return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
|
| 42 |
+
}
|
| 43 |
+
console.error('[Auth ForgotPassword]', error?.message);
|
| 44 |
+
return NextResponse.json({ error: 'Request failed' }, { status: 500 });
|
| 45 |
+
}
|
| 46 |
+
}
|
app/api/auth/login/route.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import bcrypt from 'bcryptjs';
|
| 4 |
+
import { getDb, genToken, sessionExpiry, pruneExpiredSessions } from '@/lib/db';
|
| 5 |
+
import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
|
| 6 |
+
|
| 7 |
+
const Schema = z.object({
|
| 8 |
+
email: z.string().email(),
|
| 9 |
+
password: z.string().min(1),
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
export async function POST(req: Request) {
|
| 13 |
+
// Rate limit: 10 login attempts per minute per IP
|
| 14 |
+
const ip = getClientIp(req);
|
| 15 |
+
const rl = checkRateLimit(`login:${ip}`, 10, 60_000);
|
| 16 |
+
if (!rl.allowed) {
|
| 17 |
+
return NextResponse.json(
|
| 18 |
+
{ error: 'Too many login attempts. Please wait a moment.' },
|
| 19 |
+
{ status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) } },
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
try {
|
| 24 |
+
const body = await req.json();
|
| 25 |
+
const { email, password } = Schema.parse(body);
|
| 26 |
+
|
| 27 |
+
const db = getDb();
|
| 28 |
+
pruneExpiredSessions();
|
| 29 |
+
|
| 30 |
+
const user = db
|
| 31 |
+
.prepare(
|
| 32 |
+
`SELECT id, email, password, display_name, email_verified, is_admin,
|
| 33 |
+
COALESCE(is_active, 1) AS is_active, disabled_reason
|
| 34 |
+
FROM users WHERE email = ?`,
|
| 35 |
+
)
|
| 36 |
+
.get(email.toLowerCase()) as any;
|
| 37 |
+
|
| 38 |
+
if (!user || !bcrypt.compareSync(password, user.password)) {
|
| 39 |
+
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Reject deactivated accounts with a distinct 403 so the UI can
|
| 43 |
+
// surface the `disabled_reason` instead of a generic "wrong password".
|
| 44 |
+
if (!user.is_active) {
|
| 45 |
+
return NextResponse.json(
|
| 46 |
+
{
|
| 47 |
+
error:
|
| 48 |
+
user.disabled_reason ||
|
| 49 |
+
'This account has been disabled. Please contact an administrator.',
|
| 50 |
+
code: 'account_disabled',
|
| 51 |
+
},
|
| 52 |
+
{ status: 403 },
|
| 53 |
+
);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const token = genToken();
|
| 57 |
+
db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
|
| 58 |
+
token,
|
| 59 |
+
user.id,
|
| 60 |
+
sessionExpiry(),
|
| 61 |
+
);
|
| 62 |
+
// Best-effort login timestamp. Never fail the login if this write
|
| 63 |
+
// errors — the column exists from v3 onwards, but older DBs that
|
| 64 |
+
// haven't hit getDb() yet may not have it for a transient moment.
|
| 65 |
+
try {
|
| 66 |
+
db.prepare("UPDATE users SET last_login_at = datetime('now') WHERE id = ?").run(user.id);
|
| 67 |
+
} catch {
|
| 68 |
+
/* non-fatal */
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return NextResponse.json({
|
| 72 |
+
user: {
|
| 73 |
+
id: user.id,
|
| 74 |
+
email: user.email,
|
| 75 |
+
displayName: user.display_name,
|
| 76 |
+
emailVerified: !!user.email_verified,
|
| 77 |
+
isAdmin: !!user.is_admin,
|
| 78 |
+
},
|
| 79 |
+
token,
|
| 80 |
+
});
|
| 81 |
+
} catch (error: any) {
|
| 82 |
+
if (error instanceof z.ZodError) {
|
| 83 |
+
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
|
| 84 |
+
}
|
| 85 |
+
console.error('[Auth Login]', error?.message);
|
| 86 |
+
return NextResponse.json({ error: 'Login failed' }, { status: 500 });
|
| 87 |
+
}
|
| 88 |
+
}
|
app/api/auth/logout/route.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { getDb } from '@/lib/db';
|
| 3 |
+
|
| 4 |
+
export async function POST(req: Request) {
|
| 5 |
+
const h = req.headers.get('authorization');
|
| 6 |
+
const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null;
|
| 7 |
+
|
| 8 |
+
if (token) {
|
| 9 |
+
const db = getDb();
|
| 10 |
+
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
return NextResponse.json({ success: true });
|
| 14 |
+
}
|
app/api/auth/me/route.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import bcrypt from 'bcryptjs';
|
| 3 |
+
import { z } from 'zod';
|
| 4 |
+
import { getDb, pruneExpiredSessions } from '@/lib/db';
|
| 5 |
+
import { authenticateRequest } from '@/lib/auth-middleware';
|
| 6 |
+
import { auditLog } from '@/lib/audit';
|
| 7 |
+
import { getClientIp, checkRateLimit } from '@/lib/rate-limit';
|
| 8 |
+
|
| 9 |
+
export async function GET(req: Request) {
|
| 10 |
+
const h = req.headers.get('authorization');
|
| 11 |
+
const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null;
|
| 12 |
+
if (!token) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 13 |
+
|
| 14 |
+
const db = getDb();
|
| 15 |
+
pruneExpiredSessions();
|
| 16 |
+
|
| 17 |
+
const row = db
|
| 18 |
+
.prepare(
|
| 19 |
+
`SELECT u.id, u.email, u.display_name, u.email_verified, u.is_admin, u.created_at
|
| 20 |
+
FROM sessions s JOIN users u ON u.id = s.user_id
|
| 21 |
+
WHERE s.token = ? AND s.expires_at > datetime('now')`,
|
| 22 |
+
)
|
| 23 |
+
.get(token) as any;
|
| 24 |
+
|
| 25 |
+
if (!row) return NextResponse.json({ error: 'Session expired' }, { status: 401 });
|
| 26 |
+
|
| 27 |
+
return NextResponse.json({
|
| 28 |
+
user: {
|
| 29 |
+
id: row.id,
|
| 30 |
+
email: row.email,
|
| 31 |
+
displayName: row.display_name,
|
| 32 |
+
emailVerified: !!row.email_verified,
|
| 33 |
+
isAdmin: !!row.is_admin,
|
| 34 |
+
createdAt: row.created_at,
|
| 35 |
+
},
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* DELETE /api/auth/me — self-service account deletion (GDPR Art. 17 /
|
| 41 |
+
* HIPAA patient right-to-delete).
|
| 42 |
+
*
|
| 43 |
+
* Safety gates (all required, in order):
|
| 44 |
+
* 1. Must present a valid session (authenticateRequest).
|
| 45 |
+
* 2. Must re-authenticate by supplying the current password in the JSON
|
| 46 |
+
* body: `{ "password": "…", "confirmEmail": "…" }`. Re-auth stops
|
| 47 |
+
* stolen-token exfiltration from wiping the account.
|
| 48 |
+
* 3. `confirmEmail` must match the logged-in user's email — defence
|
| 49 |
+
* against copy/paste mistakes in shared UIs.
|
| 50 |
+
* 4. Admin accounts cannot self-delete via this endpoint (prevents
|
| 51 |
+
* accidental lock-out of the Space). Admins must demote first or use
|
| 52 |
+
* the admin-ops deletion flow.
|
| 53 |
+
* 5. Per-IP + per-user rate limit: 3 attempts / hour.
|
| 54 |
+
*
|
| 55 |
+
* Execution:
|
| 56 |
+
* - All PHI (health_data, chat_history, user_settings, sessions,
|
| 57 |
+
* audit_log FK, scan_log FK) is removed by FK CASCADE.
|
| 58 |
+
* - A single audit row is written BEFORE the delete so forensics can
|
| 59 |
+
* prove the delete happened and by whom.
|
| 60 |
+
*/
|
| 61 |
+
const DeleteSchema = z.object({
|
| 62 |
+
password: z.string().min(1, 'Password required'),
|
| 63 |
+
confirmEmail: z.string().email('Email confirmation required'),
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
export async function DELETE(req: Request) {
|
| 67 |
+
const ip = getClientIp(req);
|
| 68 |
+
|
| 69 |
+
// 5) Rate limit self-deletion to blunt brute-force of the password gate.
|
| 70 |
+
const rl = checkRateLimit(`delete-me:${ip}`, 3, 60 * 60_000);
|
| 71 |
+
if (!rl.allowed) {
|
| 72 |
+
return NextResponse.json(
|
| 73 |
+
{ error: 'Too many deletion attempts. Try again later.' },
|
| 74 |
+
{
|
| 75 |
+
status: 429,
|
| 76 |
+
headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) },
|
| 77 |
+
},
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// 1) Valid session.
|
| 82 |
+
const auth = authenticateRequest(req);
|
| 83 |
+
if (!auth) {
|
| 84 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
let body: any;
|
| 88 |
+
try {
|
| 89 |
+
body = await req.json();
|
| 90 |
+
} catch {
|
| 91 |
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const parsed = DeleteSchema.safeParse(body);
|
| 95 |
+
if (!parsed.success) {
|
| 96 |
+
return NextResponse.json(
|
| 97 |
+
{ error: 'password and confirmEmail are required' },
|
| 98 |
+
{ status: 400 },
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
const { password, confirmEmail } = parsed.data;
|
| 102 |
+
|
| 103 |
+
const db = getDb();
|
| 104 |
+
const user = db
|
| 105 |
+
.prepare('SELECT id, email, password, is_admin FROM users WHERE id = ?')
|
| 106 |
+
.get(auth.id) as any;
|
| 107 |
+
|
| 108 |
+
if (!user) {
|
| 109 |
+
return NextResponse.json({ error: 'Account not found' }, { status: 404 });
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// 3) Email confirmation must match the session's user.
|
| 113 |
+
if (user.email.toLowerCase() !== confirmEmail.toLowerCase()) {
|
| 114 |
+
auditLog({
|
| 115 |
+
userId: user.id,
|
| 116 |
+
action: 'delete_account',
|
| 117 |
+
ip,
|
| 118 |
+
meta: { outcome: 'email_mismatch' },
|
| 119 |
+
});
|
| 120 |
+
return NextResponse.json(
|
| 121 |
+
{ error: 'Email confirmation does not match your account.' },
|
| 122 |
+
{ status: 400 },
|
| 123 |
+
);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// 2) Password re-auth.
|
| 127 |
+
if (!bcrypt.compareSync(password, user.password)) {
|
| 128 |
+
auditLog({
|
| 129 |
+
userId: user.id,
|
| 130 |
+
action: 'delete_account',
|
| 131 |
+
ip,
|
| 132 |
+
meta: { outcome: 'bad_password' },
|
| 133 |
+
});
|
| 134 |
+
return NextResponse.json(
|
| 135 |
+
{ error: 'Password is incorrect.' },
|
| 136 |
+
{ status: 401 },
|
| 137 |
+
);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// 4) Admins cannot self-delete via this endpoint.
|
| 141 |
+
if (user.is_admin) {
|
| 142 |
+
return NextResponse.json(
|
| 143 |
+
{
|
| 144 |
+
error:
|
| 145 |
+
'Admin accounts cannot self-delete. Demote the account first or use the admin deletion endpoint.',
|
| 146 |
+
},
|
| 147 |
+
{ status: 403 },
|
| 148 |
+
);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// Record intent BEFORE the destructive write so forensics can reconstruct
|
| 152 |
+
// the event even if the CASCADE blows up mid-way.
|
| 153 |
+
auditLog({
|
| 154 |
+
userId: user.id,
|
| 155 |
+
action: 'delete_account',
|
| 156 |
+
ip,
|
| 157 |
+
meta: { outcome: 'initiated', self_service: true },
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
try {
|
| 161 |
+
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
| 162 |
+
} catch (e: any) {
|
| 163 |
+
console.error('[Delete Me] cascade delete failed:', e?.message);
|
| 164 |
+
return NextResponse.json(
|
| 165 |
+
{ error: 'Deletion failed. Please contact support.' },
|
| 166 |
+
{ status: 500 },
|
| 167 |
+
);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// Post-deletion audit row. audit_log.user_id is an unconstrained TEXT
|
| 171 |
+
// column (no FK), so earlier audit rows for this user survive the
|
| 172 |
+
// cascade and remain available for forensic review.
|
| 173 |
+
auditLog({
|
| 174 |
+
userId: null,
|
| 175 |
+
action: 'delete_account',
|
| 176 |
+
ip,
|
| 177 |
+
meta: { outcome: 'completed', deleted_user: user.id, self_service: true },
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
return NextResponse.json({
|
| 181 |
+
success: true,
|
| 182 |
+
message: `Account ${user.email} and all associated data permanently deleted.`,
|
| 183 |
+
});
|
| 184 |
+
}
|
app/api/auth/register/route.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import bcrypt from 'bcryptjs';
|
| 4 |
+
import { getDb, genId, genToken, genVerificationCode, codeExpiry, sessionExpiry } from '@/lib/db';
|
| 5 |
+
import { sendVerificationEmail, emailTransportName } from '@/lib/email';
|
| 6 |
+
import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
|
| 7 |
+
|
| 8 |
+
const Schema = z.object({
|
| 9 |
+
email: z.string().email().max(255),
|
| 10 |
+
password: z.string().min(6).max(128),
|
| 11 |
+
displayName: z.string().max(50).optional(),
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
export async function POST(req: Request) {
|
| 15 |
+
// Rate limit: 5 registrations per minute per IP
|
| 16 |
+
const ip = getClientIp(req);
|
| 17 |
+
const rl = checkRateLimit(`register:${ip}`, 5, 60_000);
|
| 18 |
+
if (!rl.allowed) {
|
| 19 |
+
return NextResponse.json(
|
| 20 |
+
{ error: 'Too many registration attempts. Please wait.' },
|
| 21 |
+
{ status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) } },
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
try {
|
| 26 |
+
const body = await req.json();
|
| 27 |
+
const { email, password, displayName } = Schema.parse(body);
|
| 28 |
+
|
| 29 |
+
const db = getDb();
|
| 30 |
+
|
| 31 |
+
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
| 32 |
+
if (existing) {
|
| 33 |
+
return NextResponse.json({ error: 'An account with this email already exists' }, { status: 409 });
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const id = genId();
|
| 37 |
+
const hash = bcrypt.hashSync(password, 10);
|
| 38 |
+
const code = genVerificationCode();
|
| 39 |
+
const expires = codeExpiry();
|
| 40 |
+
|
| 41 |
+
db.prepare(
|
| 42 |
+
`INSERT INTO users (id, email, password, display_name, verification_code, verification_expires)
|
| 43 |
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
| 44 |
+
).run(id, email.toLowerCase(), hash, displayName || null, code, expires);
|
| 45 |
+
|
| 46 |
+
// Auto-login
|
| 47 |
+
const token = genToken();
|
| 48 |
+
db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(token, id, sessionExpiry());
|
| 49 |
+
|
| 50 |
+
// Send verification email (best-effort, don't block registration).
|
| 51 |
+
// We DO want to know if it failed though — the old `.catch(() => {})`
|
| 52 |
+
// here masked a year of "no emails arriving" bug reports because
|
| 53 |
+
// the API still returned 201 and the UI still said "Check your
|
| 54 |
+
// email". Log the transport name on every register so operators
|
| 55 |
+
// can confirm the wiring from container logs in one grep.
|
| 56 |
+
console.log(`[Register] queued verification email via transport=${emailTransportName()} to=${email}`);
|
| 57 |
+
sendVerificationEmail(email, code).then(
|
| 58 |
+
(ok) => {
|
| 59 |
+
if (!ok) console.error(`[Register] verification email FAILED to=${email}`);
|
| 60 |
+
},
|
| 61 |
+
(err) => console.error(`[Register] verification email threw to=${email}: ${err?.message ?? err}`),
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
return NextResponse.json(
|
| 65 |
+
{
|
| 66 |
+
user: { id, email: email.toLowerCase(), displayName, emailVerified: false },
|
| 67 |
+
token,
|
| 68 |
+
message: 'Account created. Check your email for a verification code.',
|
| 69 |
+
},
|
| 70 |
+
{ status: 201 },
|
| 71 |
+
);
|
| 72 |
+
} catch (error: any) {
|
| 73 |
+
if (error instanceof z.ZodError) {
|
| 74 |
+
return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 });
|
| 75 |
+
}
|
| 76 |
+
console.error('[Auth Register]', error?.message);
|
| 77 |
+
return NextResponse.json({ error: 'Registration failed' }, { status: 500 });
|
| 78 |
+
}
|
| 79 |
+
}
|
app/api/auth/resend-verification/route.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { getDb, genVerificationCode, codeExpiry } from '@/lib/db';
|
| 3 |
+
import { authenticateRequest } from '@/lib/auth-middleware';
|
| 4 |
+
import { sendVerificationEmail, emailTransportName } from '@/lib/email';
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* POST /api/auth/resend-verification — resend the 6-digit verification code.
|
| 8 |
+
*/
|
| 9 |
+
export async function POST(req: Request) {
|
| 10 |
+
const user = authenticateRequest(req);
|
| 11 |
+
if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 12 |
+
|
| 13 |
+
const db = getDb();
|
| 14 |
+
const row = db.prepare('SELECT email, email_verified FROM users WHERE id = ?').get(user.id) as any;
|
| 15 |
+
|
| 16 |
+
if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
| 17 |
+
if (row.email_verified) return NextResponse.json({ message: 'Email already verified' });
|
| 18 |
+
|
| 19 |
+
const code = genVerificationCode();
|
| 20 |
+
db.prepare(
|
| 21 |
+
`UPDATE users SET verification_code = ?, verification_expires = ?, updated_at = datetime('now')
|
| 22 |
+
WHERE id = ?`,
|
| 23 |
+
).run(code, codeExpiry(), user.id);
|
| 24 |
+
|
| 25 |
+
console.log(`[ResendVerification] queued via transport=${emailTransportName()} to=${row.email}`);
|
| 26 |
+
const sent = await sendVerificationEmail(row.email, code);
|
| 27 |
+
if (!sent) console.error(`[ResendVerification] FAILED to=${row.email}`);
|
| 28 |
+
|
| 29 |
+
return NextResponse.json({ message: 'Verification code sent' });
|
| 30 |
+
}
|
app/api/auth/reset-password/route.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import bcrypt from 'bcryptjs';
|
| 4 |
+
import { getDb, genToken, sessionExpiry } from '@/lib/db';
|
| 5 |
+
|
| 6 |
+
const Schema = z.object({
|
| 7 |
+
email: z.string().email(),
|
| 8 |
+
code: z.string().length(6),
|
| 9 |
+
newPassword: z.string().min(6).max(128),
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* POST /api/auth/reset-password — reset password with the 6-digit code.
|
| 14 |
+
* On success, auto-logs the user in and returns a session token.
|
| 15 |
+
*/
|
| 16 |
+
export async function POST(req: Request) {
|
| 17 |
+
try {
|
| 18 |
+
const body = await req.json();
|
| 19 |
+
const { email, code, newPassword } = Schema.parse(body);
|
| 20 |
+
|
| 21 |
+
const db = getDb();
|
| 22 |
+
const user = db
|
| 23 |
+
.prepare('SELECT id, reset_token, reset_expires FROM users WHERE email = ?')
|
| 24 |
+
.get(email.toLowerCase()) as any;
|
| 25 |
+
|
| 26 |
+
if (
|
| 27 |
+
!user ||
|
| 28 |
+
user.reset_token !== code ||
|
| 29 |
+
!user.reset_expires ||
|
| 30 |
+
new Date(user.reset_expires) < new Date()
|
| 31 |
+
) {
|
| 32 |
+
return NextResponse.json({ error: 'Invalid or expired reset code' }, { status: 400 });
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const hash = bcrypt.hashSync(newPassword, 10);
|
| 36 |
+
db.prepare(
|
| 37 |
+
`UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL, updated_at = datetime('now')
|
| 38 |
+
WHERE id = ?`,
|
| 39 |
+
).run(hash, user.id);
|
| 40 |
+
|
| 41 |
+
// Invalidate all existing sessions for this user (security best practice).
|
| 42 |
+
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id);
|
| 43 |
+
|
| 44 |
+
// Auto-login with new session.
|
| 45 |
+
const token = genToken();
|
| 46 |
+
db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
|
| 47 |
+
token,
|
| 48 |
+
user.id,
|
| 49 |
+
sessionExpiry(),
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
return NextResponse.json({
|
| 53 |
+
message: 'Password reset successfully',
|
| 54 |
+
token,
|
| 55 |
+
});
|
| 56 |
+
} catch (error: any) {
|
| 57 |
+
if (error instanceof z.ZodError) {
|
| 58 |
+
return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 });
|
| 59 |
+
}
|
| 60 |
+
console.error('[Auth ResetPassword]', error?.message);
|
| 61 |
+
return NextResponse.json({ error: 'Reset failed' }, { status: 500 });
|
| 62 |
+
}
|
| 63 |
+
}
|
app/api/auth/verify-email/route.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import { getDb } from '@/lib/db';
|
| 4 |
+
import { authenticateRequest } from '@/lib/auth-middleware';
|
| 5 |
+
import { sendWelcomeEmail } from '@/lib/email';
|
| 6 |
+
|
| 7 |
+
const Schema = z.object({
|
| 8 |
+
code: z.string().length(6),
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* POST /api/auth/verify-email — verify email with 6-digit code.
|
| 13 |
+
* Requires auth (the user must be logged in to verify their own email).
|
| 14 |
+
*/
|
| 15 |
+
export async function POST(req: Request) {
|
| 16 |
+
const user = authenticateRequest(req);
|
| 17 |
+
if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 18 |
+
|
| 19 |
+
try {
|
| 20 |
+
const body = await req.json();
|
| 21 |
+
const { code } = Schema.parse(body);
|
| 22 |
+
|
| 23 |
+
const db = getDb();
|
| 24 |
+
const row = db
|
| 25 |
+
.prepare(
|
| 26 |
+
`SELECT verification_code, verification_expires, email, email_verified
|
| 27 |
+
FROM users WHERE id = ?`,
|
| 28 |
+
)
|
| 29 |
+
.get(user.id) as any;
|
| 30 |
+
|
| 31 |
+
if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
| 32 |
+
if (row.email_verified) return NextResponse.json({ message: 'Email already verified' });
|
| 33 |
+
|
| 34 |
+
if (
|
| 35 |
+
row.verification_code !== code ||
|
| 36 |
+
!row.verification_expires ||
|
| 37 |
+
new Date(row.verification_expires) < new Date()
|
| 38 |
+
) {
|
| 39 |
+
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 400 });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
db.prepare(
|
| 43 |
+
`UPDATE users SET email_verified = 1, verification_code = NULL, verification_expires = NULL, updated_at = datetime('now')
|
| 44 |
+
WHERE id = ?`,
|
| 45 |
+
).run(user.id);
|
| 46 |
+
|
| 47 |
+
// Send welcome email
|
| 48 |
+
sendWelcomeEmail(row.email).catch(() => {});
|
| 49 |
+
|
| 50 |
+
return NextResponse.json({ message: 'Email verified successfully', emailVerified: true });
|
| 51 |
+
} catch (error: any) {
|
| 52 |
+
if (error instanceof z.ZodError) {
|
| 53 |
+
return NextResponse.json({ error: 'Invalid code format' }, { status: 400 });
|
| 54 |
+
}
|
| 55 |
+
console.error('[Auth Verify]', error?.message);
|
| 56 |
+
return NextResponse.json({ error: 'Verification failed' }, { status: 500 });
|
| 57 |
+
}
|
| 58 |
+
}
|
app/api/chat-history/route.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import { getDb, genId } from '@/lib/db';
|
| 4 |
+
import { authenticateRequest } from '@/lib/auth-middleware';
|
| 5 |
+
import { encodeHealthPayload } from '@/lib/health-data-repo';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* GET /api/chat-history → list conversations (newest first, max 100)
|
| 9 |
+
* POST /api/chat-history → save a conversation
|
| 10 |
+
* DELETE /api/chat-history?id=<id> → delete one conversation
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
export async function GET(req: Request) {
|
| 14 |
+
const user = authenticateRequest(req);
|
| 15 |
+
if (!user) {
|
| 16 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const db = getDb();
|
| 20 |
+
const rows = db
|
| 21 |
+
.prepare(
|
| 22 |
+
'SELECT id, preview, topic, created_at FROM chat_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 100',
|
| 23 |
+
)
|
| 24 |
+
.all(user.id) as any[];
|
| 25 |
+
|
| 26 |
+
return NextResponse.json({
|
| 27 |
+
conversations: rows.map((r) => ({
|
| 28 |
+
id: r.id,
|
| 29 |
+
preview: r.preview,
|
| 30 |
+
topic: r.topic,
|
| 31 |
+
createdAt: r.created_at,
|
| 32 |
+
})),
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const SaveSchema = z.object({
|
| 37 |
+
preview: z.string().max(200),
|
| 38 |
+
messages: z.array(
|
| 39 |
+
z.object({
|
| 40 |
+
role: z.enum(['user', 'assistant', 'system']),
|
| 41 |
+
content: z.string(),
|
| 42 |
+
}),
|
| 43 |
+
),
|
| 44 |
+
topic: z.string().max(50).optional(),
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
export async function POST(req: Request) {
|
| 48 |
+
const user = authenticateRequest(req);
|
| 49 |
+
if (!user) {
|
| 50 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
const body = await req.json();
|
| 55 |
+
const { preview, messages, topic } = SaveSchema.parse(body);
|
| 56 |
+
|
| 57 |
+
const db = getDb();
|
| 58 |
+
const id = genId();
|
| 59 |
+
|
| 60 |
+
// Messages may contain PHI — encrypt at rest. The preview is intentionally
|
| 61 |
+
// left in plaintext because it's displayed in the sidebar listing and is
|
| 62 |
+
// already capped at 200 chars by the input schema.
|
| 63 |
+
db.prepare(
|
| 64 |
+
'INSERT INTO chat_history (id, user_id, preview, messages, topic) VALUES (?, ?, ?, ?, ?)',
|
| 65 |
+
).run(id, user.id, preview, encodeHealthPayload(messages), topic || null);
|
| 66 |
+
|
| 67 |
+
return NextResponse.json({ id }, { status: 201 });
|
| 68 |
+
} catch (error: any) {
|
| 69 |
+
if (error instanceof z.ZodError) {
|
| 70 |
+
return NextResponse.json(
|
| 71 |
+
{ error: 'Invalid input', details: error.errors },
|
| 72 |
+
{ status: 400 },
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
console.error('[Chat History POST]', error?.message);
|
| 76 |
+
return NextResponse.json({ error: 'Save failed' }, { status: 500 });
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export async function DELETE(req: Request) {
|
| 81 |
+
const user = authenticateRequest(req);
|
| 82 |
+
if (!user) {
|
| 83 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
const url = new URL(req.url);
|
| 87 |
+
const id = url.searchParams.get('id');
|
| 88 |
+
if (!id) {
|
| 89 |
+
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const db = getDb();
|
| 93 |
+
db.prepare('DELETE FROM chat_history WHERE id = ? AND user_id = ?').run(
|
| 94 |
+
id,
|
| 95 |
+
user.id,
|
| 96 |
+
);
|
| 97 |
+
|
| 98 |
+
return NextResponse.json({ success: true });
|
| 99 |
+
}
|
app/api/chat/route.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import { chatWithFallback, type ChatMessage } from '@/lib/providers';
|
| 4 |
+
import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
|
| 5 |
+
import { preCheck, postCheck } from '@/lib/safety/safety-engine';
|
| 6 |
+
import { snapshotFlags } from '@/lib/feature-flags';
|
| 7 |
+
|
| 8 |
+
// Log feature-flag snapshot once per process load so deployments make their
|
| 9 |
+
// configured behavior visible. Values are server-side only and PHI-free.
|
| 10 |
+
console.log(`[Chat] route.flags ${JSON.stringify(snapshotFlags())}`);
|
| 11 |
+
import { buildRAGContext } from '@/lib/rag/medical-kb';
|
| 12 |
+
import { buildMedicalSystemPrompt } from '@/lib/medical-knowledge';
|
| 13 |
+
import { authenticateRequest } from '@/lib/auth-middleware';
|
| 14 |
+
import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
|
| 15 |
+
import { auditLog } from '@/lib/audit';
|
| 16 |
+
import {
|
| 17 |
+
buildPatientContextForUser,
|
| 18 |
+
stripInjectedPatientContext,
|
| 19 |
+
} from '@/lib/patient-context.server';
|
| 20 |
+
|
| 21 |
+
const RequestSchema = z.object({
|
| 22 |
+
messages: z.array(
|
| 23 |
+
z.object({
|
| 24 |
+
role: z.enum(['system', 'user', 'assistant']),
|
| 25 |
+
content: z.string(),
|
| 26 |
+
})
|
| 27 |
+
),
|
| 28 |
+
model: z.string().optional().default('qwen2.5:1.5b'),
|
| 29 |
+
language: z.string().optional().default('en'),
|
| 30 |
+
countryCode: z.string().optional().default('US'),
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
export async function POST(request: NextRequest) {
|
| 34 |
+
const routeStartedAt = Date.now();
|
| 35 |
+
const ip = getClientIp(request);
|
| 36 |
+
const user = authenticateRequest(request);
|
| 37 |
+
|
| 38 |
+
// Per-identity chat rate limit. Authenticated users get a generous
|
| 39 |
+
// 60 turns/min by user id (stable across IPs), anonymous get 20/min
|
| 40 |
+
// by IP. The limiter is in-memory per process; for multi-instance
|
| 41 |
+
// deployments swap to Redis (same interface).
|
| 42 |
+
const limitKey = user ? `chat:user:${user.id}` : `chat:ip:${ip}`;
|
| 43 |
+
const limitMax = user ? 60 : 20;
|
| 44 |
+
const limit = checkRateLimit(limitKey, limitMax, 60_000);
|
| 45 |
+
if (!limit.allowed) {
|
| 46 |
+
return new Response(
|
| 47 |
+
JSON.stringify({
|
| 48 |
+
error: 'Chat rate limit exceeded. Please slow down.',
|
| 49 |
+
retryAfterMs: limit.retryAfterMs,
|
| 50 |
+
}),
|
| 51 |
+
{
|
| 52 |
+
status: 429,
|
| 53 |
+
headers: {
|
| 54 |
+
'Content-Type': 'application/json',
|
| 55 |
+
'Retry-After': String(Math.ceil(limit.retryAfterMs / 1000)),
|
| 56 |
+
},
|
| 57 |
+
},
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
try {
|
| 62 |
+
const body = await request.json();
|
| 63 |
+
const { messages, model, language, countryCode } = RequestSchema.parse(body);
|
| 64 |
+
|
| 65 |
+
// Single-line JSON payload so the HF Space logs API (SSE) can be grepped
|
| 66 |
+
// with a simple prefix match. Every stage below tags itself `[Chat]`.
|
| 67 |
+
console.log(
|
| 68 |
+
`[Chat] route.enter ${JSON.stringify({
|
| 69 |
+
userId: user?.id || null,
|
| 70 |
+
turns: messages.length,
|
| 71 |
+
model,
|
| 72 |
+
language,
|
| 73 |
+
countryCode,
|
| 74 |
+
userAgent: request.headers.get('user-agent')?.slice(0, 80) || null,
|
| 75 |
+
})}`,
|
| 76 |
+
);
|
| 77 |
+
|
| 78 |
+
// Step 1: Emergency triage on the latest user message.
|
| 79 |
+
// Sanitise FIRST: strip any client-injected [Patient: ...] block so
|
| 80 |
+
// (a) the triage check sees only the user's real prose, and
|
| 81 |
+
// (b) we cannot leak another user's EHR into the LLM if a stale or
|
| 82 |
+
// malicious client sends one.
|
| 83 |
+
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
|
| 84 |
+
const rawUserContent = lastUserMessage?.content || '';
|
| 85 |
+
const cleanUserContent = stripInjectedPatientContext(rawUserContent);
|
| 86 |
+
|
| 87 |
+
// Step 1: Run the deterministic safety pre-check. This is the FLOOR;
|
| 88 |
+
// the LLM cannot relax it. The engine returns either an emergency
|
| 89 |
+
// template (R5 — LLM not called) or a green-light decision with a
|
| 90 |
+
// risk class and a system-prompt augmentation that pins policy.
|
| 91 |
+
let safetyDecision: ReturnType<typeof preCheck> | null = null;
|
| 92 |
+
if (lastUserMessage) {
|
| 93 |
+
safetyDecision = preCheck({
|
| 94 |
+
text: cleanUserContent,
|
| 95 |
+
countryCode,
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
console.log(
|
| 99 |
+
`[Chat] route.safety.preCheck ${JSON.stringify({
|
| 100 |
+
userId: user?.id || null,
|
| 101 |
+
riskClass: safetyDecision.audit.riskClass,
|
| 102 |
+
ruleFires: safetyDecision.audit.ruleFires,
|
| 103 |
+
userChars: cleanUserContent.length,
|
| 104 |
+
})}`,
|
| 105 |
+
);
|
| 106 |
+
|
| 107 |
+
if (safetyDecision.kind === 'emergency_template') {
|
| 108 |
+
// Capture the narrowed values before entering the ReadableStream
|
| 109 |
+
// callback — discriminated-union narrowing on `safetyDecision`
|
| 110 |
+
// does not survive into the inner closure under strict TS.
|
| 111 |
+
const emergencyTemplate = safetyDecision.template;
|
| 112 |
+
const emergencyRuleFires = safetyDecision.audit.ruleFires;
|
| 113 |
+
|
| 114 |
+
const encoder = new TextEncoder();
|
| 115 |
+
const stream = new ReadableStream({
|
| 116 |
+
start(controller) {
|
| 117 |
+
const data = JSON.stringify({
|
| 118 |
+
choices: [{ delta: { content: emergencyTemplate } }],
|
| 119 |
+
provider: 'safety-engine',
|
| 120 |
+
model: 'emergency-template',
|
| 121 |
+
isEmergency: true,
|
| 122 |
+
riskClass: 'R5',
|
| 123 |
+
});
|
| 124 |
+
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
|
| 125 |
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
| 126 |
+
controller.close();
|
| 127 |
+
},
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
if (user) {
|
| 131 |
+
auditLog({
|
| 132 |
+
userId: user.id,
|
| 133 |
+
action: 'chat',
|
| 134 |
+
ip,
|
| 135 |
+
meta: {
|
| 136 |
+
riskClass: 'R5',
|
| 137 |
+
ruleFires: emergencyRuleFires,
|
| 138 |
+
countryCode,
|
| 139 |
+
model: 'emergency-template',
|
| 140 |
+
},
|
| 141 |
+
});
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
return new Response(stream, {
|
| 145 |
+
headers: {
|
| 146 |
+
'Content-Type': 'text/event-stream',
|
| 147 |
+
'Cache-Control': 'no-cache',
|
| 148 |
+
Connection: 'keep-alive',
|
| 149 |
+
},
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// Step 2: Build RAG context from the medical knowledge base.
|
| 155 |
+
const ragStart = Date.now();
|
| 156 |
+
const ragContext = lastUserMessage ? buildRAGContext(cleanUserContent) : '';
|
| 157 |
+
console.log(
|
| 158 |
+
`[Chat] route.rag ${JSON.stringify({
|
| 159 |
+
userId: user?.id || null,
|
| 160 |
+
chars: ragContext.length,
|
| 161 |
+
latencyMs: Date.now() - ragStart,
|
| 162 |
+
})}`,
|
| 163 |
+
);
|
| 164 |
+
|
| 165 |
+
// Step 3: Server-built patient context, scoped to the authenticated
|
| 166 |
+
// user. Anonymous chats receive no per-user EHR — they get a generic
|
| 167 |
+
// medical assistant. This is the isolation contract.
|
| 168 |
+
const patientContext = user ? buildPatientContextForUser(user.id) : '';
|
| 169 |
+
|
| 170 |
+
// Step 4: Build a structured, locale-aware system prompt that grounds
|
| 171 |
+
// the model in WHO/CDC/NHS guidance and pins the response language,
|
| 172 |
+
// country, emergency number, and measurement system. Append the
|
| 173 |
+
// safety-engine policy block so the LLM is aware of the deterministic
|
| 174 |
+
// floor — the post-filter is the second line of defence.
|
| 175 |
+
const emergencyInfo = getEmergencyInfo(countryCode);
|
| 176 |
+
const baseSystemPrompt = buildMedicalSystemPrompt({
|
| 177 |
+
country: countryCode,
|
| 178 |
+
language,
|
| 179 |
+
emergencyNumber: emergencyInfo.emergency,
|
| 180 |
+
});
|
| 181 |
+
const systemPrompt =
|
| 182 |
+
safetyDecision && safetyDecision.kind === 'allow_llm'
|
| 183 |
+
? `${baseSystemPrompt}\n\n${safetyDecision.systemInstructions}`
|
| 184 |
+
: baseSystemPrompt;
|
| 185 |
+
|
| 186 |
+
// Step 5: Assemble the final message list. Prior turns are passed through
|
| 187 |
+
// verbatim except for the LAST user turn, which is rebuilt with:
|
| 188 |
+
// sanitised user prose + server-built [Patient: ...] + retrieved RAG
|
| 189 |
+
// in that order. The LLM sees patient context BEFORE reference material,
|
| 190 |
+
// matching the prior client-side ordering.
|
| 191 |
+
const priorMessages = messages.slice(0, -1).map((m) =>
|
| 192 |
+
m.role === 'user'
|
| 193 |
+
? { ...m, content: stripInjectedPatientContext(m.content) }
|
| 194 |
+
: m,
|
| 195 |
+
);
|
| 196 |
+
|
| 197 |
+
const finalUserContent = [
|
| 198 |
+
cleanUserContent,
|
| 199 |
+
patientContext, // already starts with '\n[Patient: ...]' or ''
|
| 200 |
+
ragContext
|
| 201 |
+
? `\n\n[Reference material retrieved from the medical knowledge base — use if relevant]\n${ragContext}`
|
| 202 |
+
: '',
|
| 203 |
+
].join('');
|
| 204 |
+
|
| 205 |
+
const augmentedMessages: ChatMessage[] = [
|
| 206 |
+
{ role: 'system' as const, content: systemPrompt },
|
| 207 |
+
...priorMessages,
|
| 208 |
+
{ role: 'user' as const, content: finalUserContent },
|
| 209 |
+
];
|
| 210 |
+
|
| 211 |
+
// Step 6: Stream response via the provider fallback chain.
|
| 212 |
+
console.log(
|
| 213 |
+
`[Chat] route.provider.dispatch ${JSON.stringify({
|
| 214 |
+
userId: user?.id || null,
|
| 215 |
+
systemPromptChars: systemPrompt.length,
|
| 216 |
+
patientContextChars: patientContext.length,
|
| 217 |
+
totalMessages: augmentedMessages.length,
|
| 218 |
+
preparedInMs: Date.now() - routeStartedAt,
|
| 219 |
+
})}`,
|
| 220 |
+
);
|
| 221 |
+
// Step 6: Buffer-then-filter-then-stream.
|
| 222 |
+
//
|
| 223 |
+
// The deterministic post-filter must run on the COMPLETE model response
|
| 224 |
+
// before any of it reaches the user. We therefore call the non-streaming
|
| 225 |
+
// provider, run postCheck(), and re-emit the filtered text as a single
|
| 226 |
+
// SSE chunk so the existing client SSE parser keeps working.
|
| 227 |
+
//
|
| 228 |
+
// This adds end-to-end latency relative to mid-stream display, but it is
|
| 229 |
+
// the only honest way to enforce the safety contract (SAFETY.md). UX
|
| 230 |
+
// optimisations (server-side chunking of the filtered output) can
|
| 231 |
+
// happen in a follow-up without changing the safety guarantee.
|
| 232 |
+
const providerResponse = await chatWithFallback(augmentedMessages, model);
|
| 233 |
+
|
| 234 |
+
const riskClass = safetyDecision?.kind === 'allow_llm'
|
| 235 |
+
? safetyDecision.riskClass
|
| 236 |
+
: 'R0';
|
| 237 |
+
|
| 238 |
+
const post = postCheck({
|
| 239 |
+
response: providerResponse.content,
|
| 240 |
+
riskClass,
|
| 241 |
+
emergency: emergencyInfo,
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
console.log(
|
| 245 |
+
`[Chat] route.safety.postCheck ${JSON.stringify({
|
| 246 |
+
userId: user?.id || null,
|
| 247 |
+
riskClass,
|
| 248 |
+
filterFires: post.audit.filterFires,
|
| 249 |
+
modified: post.audit.modified,
|
| 250 |
+
blocked: post.audit.blocked,
|
| 251 |
+
totalMs: Date.now() - routeStartedAt,
|
| 252 |
+
})}`,
|
| 253 |
+
);
|
| 254 |
+
|
| 255 |
+
const encoder = new TextEncoder();
|
| 256 |
+
const safeStream = new ReadableStream({
|
| 257 |
+
start(controller) {
|
| 258 |
+
const data = JSON.stringify({
|
| 259 |
+
choices: [{ delta: { content: post.filtered } }],
|
| 260 |
+
provider: providerResponse.provider,
|
| 261 |
+
model: providerResponse.model,
|
| 262 |
+
riskClass,
|
| 263 |
+
filtered: post.audit.modified,
|
| 264 |
+
});
|
| 265 |
+
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
|
| 266 |
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
| 267 |
+
controller.close();
|
| 268 |
+
},
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
if (user) {
|
| 272 |
+
auditLog({
|
| 273 |
+
userId: user.id,
|
| 274 |
+
action: 'chat',
|
| 275 |
+
ip,
|
| 276 |
+
meta: {
|
| 277 |
+
model: providerResponse.model,
|
| 278 |
+
provider: providerResponse.provider,
|
| 279 |
+
countryCode,
|
| 280 |
+
turns: messages.length,
|
| 281 |
+
patientContextChars: patientContext.length,
|
| 282 |
+
riskClass,
|
| 283 |
+
ruleFires: safetyDecision?.audit.ruleFires ?? [],
|
| 284 |
+
filterFires: post.audit.filterFires,
|
| 285 |
+
filterModified: post.audit.modified,
|
| 286 |
+
filterBlocked: post.audit.blocked,
|
| 287 |
+
},
|
| 288 |
+
});
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
return new Response(safeStream, {
|
| 292 |
+
headers: {
|
| 293 |
+
'Content-Type': 'text/event-stream',
|
| 294 |
+
'Cache-Control': 'no-cache',
|
| 295 |
+
Connection: 'keep-alive',
|
| 296 |
+
},
|
| 297 |
+
});
|
| 298 |
+
} catch (error) {
|
| 299 |
+
console.error(
|
| 300 |
+
`[Chat] route.error ${JSON.stringify({
|
| 301 |
+
userId: user?.id || null,
|
| 302 |
+
totalMs: Date.now() - routeStartedAt,
|
| 303 |
+
name: (error as any)?.name,
|
| 304 |
+
message: String((error as any)?.message || error).slice(0, 200),
|
| 305 |
+
})}`,
|
| 306 |
+
);
|
| 307 |
+
|
| 308 |
+
if (error instanceof z.ZodError) {
|
| 309 |
+
return new Response(
|
| 310 |
+
JSON.stringify({ error: 'Invalid request', details: error.errors }),
|
| 311 |
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
| 312 |
+
);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
return new Response(
|
| 316 |
+
JSON.stringify({ error: 'Internal server error' }),
|
| 317 |
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
| 318 |
+
);
|
| 319 |
+
}
|
| 320 |
+
}
|
app/api/geo/route.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
|
| 3 |
+
|
| 4 |
+
export const runtime = 'nodejs';
|
| 5 |
+
export const dynamic = 'force-dynamic';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* IP-based country + language + emergency number detection.
|
| 9 |
+
*
|
| 10 |
+
* Privacy posture:
|
| 11 |
+
* - Platform geo headers are read first (zero external calls, zero PII).
|
| 12 |
+
* - If nothing is present we fall back to ipapi.co (free, no key), but
|
| 13 |
+
* ONLY for public IPs — RFC1918, loopback, and link-local are never
|
| 14 |
+
* sent outbound.
|
| 15 |
+
* - The client IP is never logged or returned.
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
const GEO_HEADERS = [
|
| 19 |
+
'x-vercel-ip-country',
|
| 20 |
+
'cf-ipcountry',
|
| 21 |
+
'x-nf-country',
|
| 22 |
+
'cloudfront-viewer-country',
|
| 23 |
+
'x-appengine-country',
|
| 24 |
+
'fly-client-ip-country',
|
| 25 |
+
'x-forwarded-country',
|
| 26 |
+
] as const;
|
| 27 |
+
|
| 28 |
+
// Country → best-effort primary language out of the ones MedOS ships.
|
| 29 |
+
// Kept local to this file so we don't bloat lib/i18n for a single use.
|
| 30 |
+
const COUNTRY_TO_LANGUAGE: Record<string, string> = {
|
| 31 |
+
US: 'en', GB: 'en', CA: 'en', AU: 'en', NZ: 'en', IE: 'en', ZA: 'en',
|
| 32 |
+
NG: 'en', KE: 'en', GH: 'en', UG: 'en', SG: 'en', MY: 'en', IN: 'en',
|
| 33 |
+
PK: 'en', BD: 'en', LK: 'en', PH: 'en',
|
| 34 |
+
ES: 'es', MX: 'es', AR: 'es', CO: 'es', CL: 'es', PE: 'es', VE: 'es',
|
| 35 |
+
EC: 'es', GT: 'es', CU: 'es', BO: 'es', DO: 'es', HN: 'es', PY: 'es',
|
| 36 |
+
SV: 'es', NI: 'es', CR: 'es', PA: 'es', UY: 'es', PR: 'es',
|
| 37 |
+
BR: 'pt', PT: 'pt', AO: 'pt', MZ: 'pt',
|
| 38 |
+
FR: 'fr', BE: 'fr', LU: 'fr', MC: 'fr', SN: 'fr', CI: 'fr', CM: 'fr',
|
| 39 |
+
CD: 'fr', HT: 'fr', DZ: 'fr', TN: 'fr', MA: 'ar',
|
| 40 |
+
DE: 'de', AT: 'de', CH: 'de', LI: 'de',
|
| 41 |
+
IT: 'it', SM: 'it', VA: 'it',
|
| 42 |
+
NL: 'nl', SR: 'nl',
|
| 43 |
+
PL: 'pl',
|
| 44 |
+
RU: 'ru', BY: 'ru', KZ: 'ru', KG: 'ru',
|
| 45 |
+
TR: 'tr',
|
| 46 |
+
SA: 'ar', AE: 'ar', EG: 'ar', JO: 'ar', IQ: 'ar', SY: 'ar', LB: 'ar',
|
| 47 |
+
YE: 'ar', LY: 'ar', OM: 'ar', QA: 'ar', KW: 'ar', BH: 'ar', SD: 'ar',
|
| 48 |
+
PS: 'ar',
|
| 49 |
+
CN: 'zh', TW: 'zh', HK: 'zh',
|
| 50 |
+
JP: 'ja',
|
| 51 |
+
KR: 'ko',
|
| 52 |
+
TH: 'th',
|
| 53 |
+
VN: 'vi',
|
| 54 |
+
TZ: 'sw',
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
function pickHeaderCountry(req: Request): string | null {
|
| 58 |
+
for (const h of GEO_HEADERS) {
|
| 59 |
+
const v = req.headers.get(h);
|
| 60 |
+
if (v && v.length >= 2 && v.toUpperCase() !== 'XX') {
|
| 61 |
+
return v.toUpperCase().slice(0, 2);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
return null;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function extractClientIp(req: Request): string | null {
|
| 68 |
+
const xff = req.headers.get('x-forwarded-for');
|
| 69 |
+
if (xff) {
|
| 70 |
+
const first = xff.split(',')[0]?.trim();
|
| 71 |
+
if (first) return first;
|
| 72 |
+
}
|
| 73 |
+
return req.headers.get('x-real-ip');
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function isPrivateIp(ip: string): boolean {
|
| 77 |
+
if (!ip) return true;
|
| 78 |
+
if (ip === '::1' || ip === '127.0.0.1' || ip.startsWith('fe80:')) return true;
|
| 79 |
+
const m = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
| 80 |
+
if (!m) return false;
|
| 81 |
+
const a = parseInt(m[1], 10);
|
| 82 |
+
const b = parseInt(m[2], 10);
|
| 83 |
+
if (a === 10) return true;
|
| 84 |
+
if (a === 127) return true;
|
| 85 |
+
if (a === 169 && b === 254) return true;
|
| 86 |
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
| 87 |
+
if (a === 192 && b === 168) return true;
|
| 88 |
+
return false;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
async function lookupIpapi(ip: string): Promise<string | null> {
|
| 92 |
+
try {
|
| 93 |
+
const controller = new AbortController();
|
| 94 |
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
| 95 |
+
const res = await fetch(`https://ipapi.co/${encodeURIComponent(ip)}/country/`, {
|
| 96 |
+
signal: controller.signal,
|
| 97 |
+
headers: { 'User-Agent': 'MedOS-Geo/1.0' },
|
| 98 |
+
});
|
| 99 |
+
clearTimeout(timeout);
|
| 100 |
+
if (!res.ok) return null;
|
| 101 |
+
const text = (await res.text()).trim().toUpperCase();
|
| 102 |
+
if (/^[A-Z]{2}$/.test(text)) return text;
|
| 103 |
+
return null;
|
| 104 |
+
} catch {
|
| 105 |
+
return null;
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export async function GET(req: Request): Promise<Response> {
|
| 110 |
+
let country = pickHeaderCountry(req);
|
| 111 |
+
let source: 'header' | 'ipapi' | 'default' = country ? 'header' : 'default';
|
| 112 |
+
|
| 113 |
+
if (!country) {
|
| 114 |
+
const ip = extractClientIp(req);
|
| 115 |
+
if (ip && !isPrivateIp(ip)) {
|
| 116 |
+
const looked = await lookupIpapi(ip);
|
| 117 |
+
if (looked) {
|
| 118 |
+
country = looked;
|
| 119 |
+
source = 'ipapi';
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const finalCountry = country || 'US';
|
| 125 |
+
const info = getEmergencyInfo(finalCountry);
|
| 126 |
+
const language = COUNTRY_TO_LANGUAGE[finalCountry] ?? 'en';
|
| 127 |
+
|
| 128 |
+
return NextResponse.json(
|
| 129 |
+
{
|
| 130 |
+
country: finalCountry,
|
| 131 |
+
language,
|
| 132 |
+
emergencyNumber: info.emergency,
|
| 133 |
+
source,
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
headers: {
|
| 137 |
+
'Cache-Control': 'private, max-age=3600',
|
| 138 |
+
'X-Robots-Tag': 'noindex',
|
| 139 |
+
},
|
| 140 |
+
},
|
| 141 |
+
);
|
| 142 |
+
}
|
app/api/health-data/route.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import { getDb, genId } from '@/lib/db';
|
| 4 |
+
import { authenticateRequest } from '@/lib/auth-middleware';
|
| 5 |
+
import { encodeHealthPayload, decodeHealthPayload } from '@/lib/health-data-repo';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* GET /api/health-data → fetch all health data for the user
|
| 9 |
+
* GET /api/health-data?type=vital → filter by type
|
| 10 |
+
* POST /api/health-data/sync → bulk sync from client localStorage
|
| 11 |
+
*/
|
| 12 |
+
export async function GET(req: Request) {
|
| 13 |
+
const user = authenticateRequest(req);
|
| 14 |
+
if (!user) {
|
| 15 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const db = getDb();
|
| 19 |
+
const url = new URL(req.url);
|
| 20 |
+
const type = url.searchParams.get('type');
|
| 21 |
+
|
| 22 |
+
const rows = type
|
| 23 |
+
? db
|
| 24 |
+
.prepare('SELECT * FROM health_data WHERE user_id = ? AND type = ? ORDER BY updated_at DESC')
|
| 25 |
+
.all(user.id, type)
|
| 26 |
+
: db
|
| 27 |
+
.prepare('SELECT * FROM health_data WHERE user_id = ? ORDER BY updated_at DESC')
|
| 28 |
+
.all(user.id);
|
| 29 |
+
|
| 30 |
+
// Decrypt (or pass through legacy plaintext) the `data` field for each row.
|
| 31 |
+
const items = (rows as any[]).map((r) => ({
|
| 32 |
+
id: r.id,
|
| 33 |
+
type: r.type,
|
| 34 |
+
data: decodeHealthPayload(r.data),
|
| 35 |
+
createdAt: r.created_at,
|
| 36 |
+
updatedAt: r.updated_at,
|
| 37 |
+
}));
|
| 38 |
+
|
| 39 |
+
return NextResponse.json({ items });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* POST /api/health-data — upsert a single health-data record.
|
| 44 |
+
*/
|
| 45 |
+
const UpsertSchema = z.object({
|
| 46 |
+
id: z.string().optional(),
|
| 47 |
+
type: z.enum([
|
| 48 |
+
'medication',
|
| 49 |
+
'medication_log',
|
| 50 |
+
'appointment',
|
| 51 |
+
'vital',
|
| 52 |
+
'record',
|
| 53 |
+
'conversation',
|
| 54 |
+
]),
|
| 55 |
+
data: z.record(z.any()),
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
export async function POST(req: Request) {
|
| 59 |
+
const user = authenticateRequest(req);
|
| 60 |
+
if (!user) {
|
| 61 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
try {
|
| 65 |
+
const body = await req.json();
|
| 66 |
+
const { id, type, data } = UpsertSchema.parse(body);
|
| 67 |
+
|
| 68 |
+
const db = getDb();
|
| 69 |
+
const itemId = id || genId();
|
| 70 |
+
const payload = encodeHealthPayload(data);
|
| 71 |
+
|
| 72 |
+
// Upsert: insert or replace. SQLite's ON CONFLICT handles this cleanly.
|
| 73 |
+
db.prepare(
|
| 74 |
+
`INSERT INTO health_data (id, user_id, type, data, updated_at)
|
| 75 |
+
VALUES (?, ?, ?, ?, datetime('now'))
|
| 76 |
+
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`,
|
| 77 |
+
).run(itemId, user.id, type, payload);
|
| 78 |
+
|
| 79 |
+
return NextResponse.json({ id: itemId, type }, { status: 201 });
|
| 80 |
+
} catch (error: any) {
|
| 81 |
+
if (error instanceof z.ZodError) {
|
| 82 |
+
return NextResponse.json(
|
| 83 |
+
{ error: 'Invalid input', details: error.errors },
|
| 84 |
+
{ status: 400 },
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
console.error('[Health Data POST]', error?.message);
|
| 88 |
+
return NextResponse.json({ error: 'Save failed' }, { status: 500 });
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* DELETE /api/health-data?id=<id> — delete one record.
|
| 94 |
+
*/
|
| 95 |
+
export async function DELETE(req: Request) {
|
| 96 |
+
const user = authenticateRequest(req);
|
| 97 |
+
if (!user) {
|
| 98 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const url = new URL(req.url);
|
| 102 |
+
const id = url.searchParams.get('id');
|
| 103 |
+
if (!id) {
|
| 104 |
+
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const db = getDb();
|
| 108 |
+
db.prepare('DELETE FROM health_data WHERE id = ? AND user_id = ?').run(
|
| 109 |
+
id,
|
| 110 |
+
user.id,
|
| 111 |
+
);
|
| 112 |
+
|
| 113 |
+
return NextResponse.json({ success: true });
|
| 114 |
+
}
|
app/api/health-data/sync/route.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import { getDb, genId } from '@/lib/db';
|
| 4 |
+
import { authenticateRequest } from '@/lib/auth-middleware';
|
| 5 |
+
import { encodeHealthPayload } from '@/lib/health-data-repo';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* POST /api/health-data/sync — bulk sync from client localStorage.
|
| 9 |
+
*
|
| 10 |
+
* The client sends its entire localStorage health dataset (medications,
|
| 11 |
+
* appointments, vitals, records, medication_logs, conversations). The
|
| 12 |
+
* server upserts each item. This runs on:
|
| 13 |
+
* - First login (migrates existing guest data to the account)
|
| 14 |
+
* - Periodic background sync while logged in
|
| 15 |
+
*
|
| 16 |
+
* Idempotent: calling it twice with the same data is safe.
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
const ItemSchema = z.object({
|
| 20 |
+
id: z.string(),
|
| 21 |
+
type: z.enum([
|
| 22 |
+
'medication',
|
| 23 |
+
'medication_log',
|
| 24 |
+
'appointment',
|
| 25 |
+
'vital',
|
| 26 |
+
'record',
|
| 27 |
+
'conversation',
|
| 28 |
+
]),
|
| 29 |
+
data: z.record(z.any()),
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
const SyncSchema = z.object({
|
| 33 |
+
items: z.array(ItemSchema).max(5000),
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
export async function POST(req: Request) {
|
| 37 |
+
const user = authenticateRequest(req);
|
| 38 |
+
if (!user) {
|
| 39 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
try {
|
| 43 |
+
const body = await req.json();
|
| 44 |
+
const { items } = SyncSchema.parse(body);
|
| 45 |
+
|
| 46 |
+
const db = getDb();
|
| 47 |
+
|
| 48 |
+
const upsert = db.prepare(
|
| 49 |
+
`INSERT INTO health_data (id, user_id, type, data, updated_at)
|
| 50 |
+
VALUES (?, ?, ?, ?, datetime('now'))
|
| 51 |
+
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`,
|
| 52 |
+
);
|
| 53 |
+
|
| 54 |
+
// Run as a single transaction for speed (1000+ items in <50ms).
|
| 55 |
+
// Each payload is AES-256-GCM encrypted by encodeHealthPayload().
|
| 56 |
+
const tx = db.transaction(() => {
|
| 57 |
+
for (const item of items) {
|
| 58 |
+
upsert.run(item.id, user.id, item.type, encodeHealthPayload(item.data));
|
| 59 |
+
}
|
| 60 |
+
});
|
| 61 |
+
tx();
|
| 62 |
+
|
| 63 |
+
return NextResponse.json({
|
| 64 |
+
synced: items.length,
|
| 65 |
+
message: `${items.length} items synced`,
|
| 66 |
+
});
|
| 67 |
+
} catch (error: any) {
|
| 68 |
+
if (error instanceof z.ZodError) {
|
| 69 |
+
return NextResponse.json(
|
| 70 |
+
{ error: 'Invalid input', details: error.errors },
|
| 71 |
+
{ status: 400 },
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
console.error('[Health Data Sync]', error?.message);
|
| 75 |
+
return NextResponse.json({ error: 'Sync failed' }, { status: 500 });
|
| 76 |
+
}
|
| 77 |
+
}
|
app/api/health/route.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
export async function GET() {
|
| 4 |
+
return NextResponse.json({
|
| 5 |
+
status: 'healthy',
|
| 6 |
+
service: 'medos-global',
|
| 7 |
+
timestamp: new Date().toISOString(),
|
| 8 |
+
version: '1.0.0',
|
| 9 |
+
});
|
| 10 |
+
}
|
app/api/models/route.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { fetchAvailableModels } from '@/lib/providers/ollabridge-models';
|
| 3 |
+
|
| 4 |
+
export async function GET() {
|
| 5 |
+
try {
|
| 6 |
+
const models = await fetchAvailableModels();
|
| 7 |
+
return NextResponse.json({ models });
|
| 8 |
+
} catch {
|
| 9 |
+
return NextResponse.json(
|
| 10 |
+
{ models: [], error: 'Failed to fetch models' },
|
| 11 |
+
{ status: 200 } // Return 200 with empty array — non-critical endpoint
|
| 12 |
+
);
|
| 13 |
+
}
|
| 14 |
+
}
|
app/api/nearby/route.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* POST /api/nearby — Proxy to MetaEngine Nearby Finder.
|
| 5 |
+
* GET /api/nearby — Health check.
|
| 6 |
+
*
|
| 7 |
+
* Calls the Gradio API endpoint (2-step: submit → fetch result).
|
| 8 |
+
* Handles sleeping Spaces, timeouts, and Overpass errors gracefully.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
const NEARBY_URL =
|
| 12 |
+
process.env.NEARBY_URL || 'https://ruslanmv-metaengine-nearby.hf.space';
|
| 13 |
+
|
| 14 |
+
export const runtime = 'nodejs';
|
| 15 |
+
export const dynamic = 'force-dynamic';
|
| 16 |
+
|
| 17 |
+
export async function POST(req: Request) {
|
| 18 |
+
try {
|
| 19 |
+
const body = await req.json();
|
| 20 |
+
const { lat, lon, radius_m = 3000, entity_type = 'all', limit = 25 } = body;
|
| 21 |
+
|
| 22 |
+
// Step 1: Submit to Gradio API
|
| 23 |
+
const submitRes = await fetch(`${NEARBY_URL}/gradio_api/call/search_ui`, {
|
| 24 |
+
method: 'POST',
|
| 25 |
+
headers: { 'Content-Type': 'application/json' },
|
| 26 |
+
body: JSON.stringify({
|
| 27 |
+
data: [String(lat), String(lon), radius_m, entity_type, limit],
|
| 28 |
+
}),
|
| 29 |
+
signal: AbortSignal.timeout(30000),
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
if (!submitRes.ok) {
|
| 33 |
+
const ct = submitRes.headers.get('content-type') || '';
|
| 34 |
+
if (!ct.includes('json')) {
|
| 35 |
+
return NextResponse.json(
|
| 36 |
+
{ error: 'Nearby finder is waking up. Please try again in a moment.', count: 0, results: [] },
|
| 37 |
+
{ status: 503 },
|
| 38 |
+
);
|
| 39 |
+
}
|
| 40 |
+
return NextResponse.json({ error: 'Search submission failed', count: 0, results: [] }, { status: 502 });
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const { event_id } = await submitRes.json();
|
| 44 |
+
if (!event_id) {
|
| 45 |
+
return NextResponse.json({ error: 'No event ID received', count: 0, results: [] }, { status: 502 });
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Step 2: Fetch result via SSE
|
| 49 |
+
const resultRes = await fetch(
|
| 50 |
+
`${NEARBY_URL}/gradio_api/call/search_ui/${event_id}`,
|
| 51 |
+
{ signal: AbortSignal.timeout(30000) },
|
| 52 |
+
);
|
| 53 |
+
|
| 54 |
+
const text = await resultRes.text();
|
| 55 |
+
|
| 56 |
+
// Parse SSE data line: "data: [summary, table, json_string]"
|
| 57 |
+
const dataLine = text.split('\n').find((l: string) => l.startsWith('data: '));
|
| 58 |
+
if (!dataLine) {
|
| 59 |
+
return NextResponse.json({ error: 'Empty response from search', count: 0, results: [] });
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const gradioData = JSON.parse(dataLine.slice(6));
|
| 63 |
+
// gradioData = [summary_text, table_array, json_string]
|
| 64 |
+
const jsonStr = gradioData?.[2];
|
| 65 |
+
if (!jsonStr) {
|
| 66 |
+
return NextResponse.json({ error: 'No results', count: 0, results: [] });
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
try {
|
| 70 |
+
const parsed = JSON.parse(jsonStr);
|
| 71 |
+
return NextResponse.json(parsed);
|
| 72 |
+
} catch {
|
| 73 |
+
// If the json_str is an error message, return it
|
| 74 |
+
return NextResponse.json({ error: jsonStr, count: 0, results: [] });
|
| 75 |
+
}
|
| 76 |
+
} catch (error: any) {
|
| 77 |
+
console.error('[Nearby Proxy]', error?.name, error?.message?.slice(0, 100));
|
| 78 |
+
const msg =
|
| 79 |
+
error?.name === 'TimeoutError' || error?.name === 'AbortError'
|
| 80 |
+
? 'Search timed out. The service may be starting up — please try again.'
|
| 81 |
+
: 'Nearby finder unavailable. Please try again.';
|
| 82 |
+
return NextResponse.json({ error: msg, count: 0, results: [] }, { status: 502 });
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export async function GET() {
|
| 87 |
+
try {
|
| 88 |
+
const res = await fetch(NEARBY_URL, { signal: AbortSignal.timeout(8000) });
|
| 89 |
+
if (res.ok) return NextResponse.json({ status: 'ok' });
|
| 90 |
+
return NextResponse.json({ status: 'waking' }, { status: 503 });
|
| 91 |
+
} catch {
|
| 92 |
+
return NextResponse.json({ status: 'sleeping' }, { status: 503 });
|
| 93 |
+
}
|
| 94 |
+
}
|
app/api/og/route.tsx
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ImageResponse } from 'next/og';
|
| 2 |
+
|
| 3 |
+
export const runtime = 'edge';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Dynamic Open Graph image endpoint.
|
| 7 |
+
*
|
| 8 |
+
* Every share on Twitter / WhatsApp / LinkedIn / Telegram / iMessage
|
| 9 |
+
* renders a branded 1200x630 card. The query becomes the card title so
|
| 10 |
+
* a link like `https://ruslanmv-medibot.hf.space/?q=chest+pain` previews
|
| 11 |
+
* as a premium, unique image instead of the default favicon blob.
|
| 12 |
+
*
|
| 13 |
+
* Usage from the client: `/api/og?q=<question>&lang=<code>`
|
| 14 |
+
* The endpoint also handles missing parameters gracefully (returns a
|
| 15 |
+
* default brand card).
|
| 16 |
+
*/
|
| 17 |
+
export async function GET(req: Request): Promise<Response> {
|
| 18 |
+
try {
|
| 19 |
+
const { searchParams } = new URL(req.url);
|
| 20 |
+
const rawQuery = (searchParams.get('q') || '').trim();
|
| 21 |
+
const lang = (searchParams.get('lang') || 'en').slice(0, 5);
|
| 22 |
+
|
| 23 |
+
// Hard-limit title length so long queries don't overflow.
|
| 24 |
+
const title =
|
| 25 |
+
rawQuery.length > 120 ? rawQuery.slice(0, 117) + '…' : rawQuery;
|
| 26 |
+
|
| 27 |
+
const subtitle = title
|
| 28 |
+
? 'Ask MedOS — free, private, in your language'
|
| 29 |
+
: 'Free AI medical assistant — 20 languages, no sign-up';
|
| 30 |
+
|
| 31 |
+
const headline = title || 'Tell me what\'s bothering you.';
|
| 32 |
+
|
| 33 |
+
return new ImageResponse(
|
| 34 |
+
(
|
| 35 |
+
<div
|
| 36 |
+
style={{
|
| 37 |
+
width: '100%',
|
| 38 |
+
height: '100%',
|
| 39 |
+
display: 'flex',
|
| 40 |
+
flexDirection: 'column',
|
| 41 |
+
padding: '72px',
|
| 42 |
+
background:
|
| 43 |
+
'radial-gradient(1200px 800px at 10% -10%, rgba(59,130,246,0.35), transparent 60%),' +
|
| 44 |
+
'radial-gradient(1000px 600px at 110% 10%, rgba(20,184,166,0.30), transparent 60%),' +
|
| 45 |
+
'linear-gradient(180deg, #0B1220 0%, #0E1627 100%)',
|
| 46 |
+
color: '#F8FAFC',
|
| 47 |
+
fontFamily: 'sans-serif',
|
| 48 |
+
position: 'relative',
|
| 49 |
+
}}
|
| 50 |
+
>
|
| 51 |
+
{/* Top bar: brand mark + language chip */}
|
| 52 |
+
<div
|
| 53 |
+
style={{
|
| 54 |
+
display: 'flex',
|
| 55 |
+
alignItems: 'center',
|
| 56 |
+
justifyContent: 'space-between',
|
| 57 |
+
marginBottom: '48px',
|
| 58 |
+
}}
|
| 59 |
+
>
|
| 60 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '18px' }}>
|
| 61 |
+
<div
|
| 62 |
+
style={{
|
| 63 |
+
width: '72px',
|
| 64 |
+
height: '72px',
|
| 65 |
+
borderRadius: '22px',
|
| 66 |
+
background: 'linear-gradient(135deg, #3B82F6 0%, #14B8A6 100%)',
|
| 67 |
+
display: 'flex',
|
| 68 |
+
alignItems: 'center',
|
| 69 |
+
justifyContent: 'center',
|
| 70 |
+
fontSize: '44px',
|
| 71 |
+
boxShadow: '0 20px 60px -10px rgba(59,130,246,0.65)',
|
| 72 |
+
}}
|
| 73 |
+
>
|
| 74 |
+
♥
|
| 75 |
+
</div>
|
| 76 |
+
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
| 77 |
+
<div
|
| 78 |
+
style={{
|
| 79 |
+
fontSize: '40px',
|
| 80 |
+
fontWeight: 800,
|
| 81 |
+
letterSpacing: '-0.02em',
|
| 82 |
+
lineHeight: 1,
|
| 83 |
+
}}
|
| 84 |
+
>
|
| 85 |
+
MedOS
|
| 86 |
+
</div>
|
| 87 |
+
<div
|
| 88 |
+
style={{
|
| 89 |
+
fontSize: '16px',
|
| 90 |
+
color: '#14B8A6',
|
| 91 |
+
fontWeight: 700,
|
| 92 |
+
textTransform: 'uppercase',
|
| 93 |
+
letterSpacing: '0.18em',
|
| 94 |
+
marginTop: '6px',
|
| 95 |
+
}}
|
| 96 |
+
>
|
| 97 |
+
Worldwide medical AI
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<div
|
| 103 |
+
style={{
|
| 104 |
+
display: 'flex',
|
| 105 |
+
alignItems: 'center',
|
| 106 |
+
gap: '10px',
|
| 107 |
+
padding: '10px 18px',
|
| 108 |
+
borderRadius: '9999px',
|
| 109 |
+
background: 'rgba(255,255,255,0.08)',
|
| 110 |
+
border: '1px solid rgba(255,255,255,0.18)',
|
| 111 |
+
fontSize: '18px',
|
| 112 |
+
color: '#CBD5E1',
|
| 113 |
+
fontWeight: 600,
|
| 114 |
+
}}
|
| 115 |
+
>
|
| 116 |
+
<span
|
| 117 |
+
style={{
|
| 118 |
+
width: '8px',
|
| 119 |
+
height: '8px',
|
| 120 |
+
borderRadius: '9999px',
|
| 121 |
+
background: '#22C55E',
|
| 122 |
+
}}
|
| 123 |
+
/>
|
| 124 |
+
{lang.toUpperCase()} · FREE · NO SIGN-UP
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
{/* Main content */}
|
| 129 |
+
<div
|
| 130 |
+
style={{
|
| 131 |
+
display: 'flex',
|
| 132 |
+
flexDirection: 'column',
|
| 133 |
+
flex: 1,
|
| 134 |
+
justifyContent: 'center',
|
| 135 |
+
}}
|
| 136 |
+
>
|
| 137 |
+
{title && (
|
| 138 |
+
<div
|
| 139 |
+
style={{
|
| 140 |
+
fontSize: '22px',
|
| 141 |
+
fontWeight: 700,
|
| 142 |
+
color: '#14B8A6',
|
| 143 |
+
textTransform: 'uppercase',
|
| 144 |
+
letterSpacing: '0.18em',
|
| 145 |
+
marginBottom: '22px',
|
| 146 |
+
}}
|
| 147 |
+
>
|
| 148 |
+
Ask MedOS
|
| 149 |
+
</div>
|
| 150 |
+
)}
|
| 151 |
+
<div
|
| 152 |
+
style={{
|
| 153 |
+
fontSize: title ? '68px' : '84px',
|
| 154 |
+
fontWeight: 800,
|
| 155 |
+
lineHeight: 1.1,
|
| 156 |
+
letterSpacing: '-0.025em',
|
| 157 |
+
color: '#F8FAFC',
|
| 158 |
+
maxWidth: '1000px',
|
| 159 |
+
}}
|
| 160 |
+
>
|
| 161 |
+
{title ? `"${headline}"` : headline}
|
| 162 |
+
</div>
|
| 163 |
+
<div
|
| 164 |
+
style={{
|
| 165 |
+
fontSize: '26px',
|
| 166 |
+
color: '#94A3B8',
|
| 167 |
+
marginTop: '28px',
|
| 168 |
+
fontWeight: 500,
|
| 169 |
+
}}
|
| 170 |
+
>
|
| 171 |
+
{subtitle}
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
{/* Footer: trust strip */}
|
| 176 |
+
<div
|
| 177 |
+
style={{
|
| 178 |
+
display: 'flex',
|
| 179 |
+
alignItems: 'center',
|
| 180 |
+
gap: '28px',
|
| 181 |
+
fontSize: '18px',
|
| 182 |
+
color: '#94A3B8',
|
| 183 |
+
fontWeight: 600,
|
| 184 |
+
borderTop: '1px solid rgba(255,255,255,0.1)',
|
| 185 |
+
paddingTop: '28px',
|
| 186 |
+
}}
|
| 187 |
+
>
|
| 188 |
+
<span style={{ color: '#14B8A6', fontWeight: 700 }}>
|
| 189 |
+
✓ Aligned with WHO · CDC · NHS
|
| 190 |
+
</span>
|
| 191 |
+
<span>·</span>
|
| 192 |
+
<span>Private & anonymous</span>
|
| 193 |
+
<span>·</span>
|
| 194 |
+
<span>24/7</span>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
),
|
| 198 |
+
{
|
| 199 |
+
width: 1200,
|
| 200 |
+
height: 630,
|
| 201 |
+
},
|
| 202 |
+
);
|
| 203 |
+
} catch {
|
| 204 |
+
// Never 500 an OG endpoint — social crawlers will blacklist the domain.
|
| 205 |
+
return new Response('OG image generation failed', { status: 500 });
|
| 206 |
+
}
|
| 207 |
+
}
|
app/api/rag/route.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { searchMedicalKB } from '@/lib/rag/medical-kb';
|
| 3 |
+
|
| 4 |
+
export async function POST(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const { query, topN = 3 } = await request.json();
|
| 7 |
+
|
| 8 |
+
if (!query || typeof query !== 'string') {
|
| 9 |
+
return NextResponse.json(
|
| 10 |
+
{ error: 'Query is required' },
|
| 11 |
+
{ status: 400 }
|
| 12 |
+
);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const results = searchMedicalKB(query, topN);
|
| 16 |
+
|
| 17 |
+
return NextResponse.json({
|
| 18 |
+
results: results.map((r) => ({
|
| 19 |
+
topic: r.topic,
|
| 20 |
+
context: r.context,
|
| 21 |
+
})),
|
| 22 |
+
count: results.length,
|
| 23 |
+
});
|
| 24 |
+
} catch {
|
| 25 |
+
return NextResponse.json(
|
| 26 |
+
{ error: 'Internal server error' },
|
| 27 |
+
{ status: 500 }
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
}
|
app/api/scan/route.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { loadConfig } from '@/lib/server-config';
|
| 3 |
+
import { authenticateRequest } from '@/lib/auth-middleware';
|
| 4 |
+
import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
|
| 5 |
+
import { auditLog } from '@/lib/audit';
|
| 6 |
+
import { getDb, genId } from '@/lib/db';
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* POST /api/scan — Server-side proxy to the Medicine Scanner Space.
|
| 10 |
+
*
|
| 11 |
+
* Why proxy instead of calling from the browser:
|
| 12 |
+
* - HF_TOKEN_INFERENCE stays server-side (never in the JS bundle)
|
| 13 |
+
* - Same-origin request from the browser (no CORS preflight)
|
| 14 |
+
* - Backend injects the token and forwards to the Scanner Space
|
| 15 |
+
* - If the Scanner Space is sleeping, this request wakes it
|
| 16 |
+
*
|
| 17 |
+
* Isolation & accounting (added in PNF10):
|
| 18 |
+
* - Authentication is REQUIRED by default. Operators can flip
|
| 19 |
+
* SCAN_REQUIRE_AUTH=false to keep the legacy open behaviour while
|
| 20 |
+
* migrating, but anonymous traffic is then capped at 5 scans/hour
|
| 21 |
+
* per IP.
|
| 22 |
+
* - Authenticated users get 30 scans/hour each (per-user key).
|
| 23 |
+
* - Every call writes one scan_log row (status, bytes, latency, model)
|
| 24 |
+
* so admins can detect abuse on the shared HF inference quota.
|
| 25 |
+
* - Authenticated calls also append an audit_log('scan') entry.
|
| 26 |
+
*
|
| 27 |
+
* The Scanner Space receives:
|
| 28 |
+
* - The image as multipart/form-data (passthrough)
|
| 29 |
+
* - Authorization: Bearer header with the inference token
|
| 30 |
+
* - Returns structured JSON with medicine data
|
| 31 |
+
*/
|
| 32 |
+
|
| 33 |
+
export const runtime = 'nodejs';
|
| 34 |
+
export const dynamic = 'force-dynamic';
|
| 35 |
+
|
| 36 |
+
function logScan(
|
| 37 |
+
userId: string | null,
|
| 38 |
+
ip: string | null,
|
| 39 |
+
status: number,
|
| 40 |
+
bytes: number,
|
| 41 |
+
latencyMs: number,
|
| 42 |
+
model: string | null,
|
| 43 |
+
): void {
|
| 44 |
+
try {
|
| 45 |
+
const db = getDb();
|
| 46 |
+
db.prepare(
|
| 47 |
+
`INSERT INTO scan_log (id, user_id, ip, status, bytes, latency_ms, model)
|
| 48 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
| 49 |
+
).run(genId(), userId, ip, status, bytes, latencyMs, model);
|
| 50 |
+
} catch (e: any) {
|
| 51 |
+
console.error('[Scan] log failed:', e?.message);
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export async function POST(req: Request) {
|
| 56 |
+
const startedAt = Date.now();
|
| 57 |
+
const ip = getClientIp(req);
|
| 58 |
+
const user = authenticateRequest(req);
|
| 59 |
+
|
| 60 |
+
// Auth gate. Default-on; opt-out via SCAN_REQUIRE_AUTH=false for migration.
|
| 61 |
+
const authRequired = (process.env.SCAN_REQUIRE_AUTH || 'true') !== 'false';
|
| 62 |
+
if (authRequired && !user) {
|
| 63 |
+
return NextResponse.json(
|
| 64 |
+
{
|
| 65 |
+
success: false,
|
| 66 |
+
error: 'Authentication required to scan medicines.',
|
| 67 |
+
medicine: null,
|
| 68 |
+
},
|
| 69 |
+
{ status: 401 },
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Per-identity quota. Authenticated users are tracked by id (stable across
|
| 74 |
+
// IPs), anonymous fallback by IP (only reachable with SCAN_REQUIRE_AUTH=false).
|
| 75 |
+
const limitKey = user ? `scan:user:${user.id}` : `scan:ip:${ip}`;
|
| 76 |
+
const limitMax = user ? 30 : 5;
|
| 77 |
+
const limit = checkRateLimit(limitKey, limitMax, 60 * 60 * 1000);
|
| 78 |
+
if (!limit.allowed) {
|
| 79 |
+
logScan(user?.id || null, ip, 429, 0, Date.now() - startedAt, null);
|
| 80 |
+
return NextResponse.json(
|
| 81 |
+
{
|
| 82 |
+
success: false,
|
| 83 |
+
error: 'Scan quota exceeded. Try again later.',
|
| 84 |
+
retryAfterMs: limit.retryAfterMs,
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
status: 429,
|
| 88 |
+
headers: { 'Retry-After': String(Math.ceil(limit.retryAfterMs / 1000)) },
|
| 89 |
+
},
|
| 90 |
+
);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Resolve provider config (env at boot, admin overrides via /data/medos-config.json).
|
| 94 |
+
const cfg = loadConfig();
|
| 95 |
+
const token = cfg.llm.hfTokenInference;
|
| 96 |
+
const scannerUrl = cfg.llm.scannerUrl;
|
| 97 |
+
|
| 98 |
+
if (!token) {
|
| 99 |
+
console.error('[Scan] HF_TOKEN_INFERENCE is not configured');
|
| 100 |
+
logScan(user?.id || null, ip, 503, 0, Date.now() - startedAt, null);
|
| 101 |
+
return NextResponse.json(
|
| 102 |
+
{
|
| 103 |
+
success: false,
|
| 104 |
+
error:
|
| 105 |
+
'Medicine scanner is not configured. Ask the administrator to set HF_TOKEN_INFERENCE.',
|
| 106 |
+
medicine: null,
|
| 107 |
+
},
|
| 108 |
+
{ status: 503 },
|
| 109 |
+
);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
try {
|
| 113 |
+
// Read the incoming form data (image file from the frontend).
|
| 114 |
+
const formData = await req.formData();
|
| 115 |
+
|
| 116 |
+
// Best-effort byte accounting for usage reporting.
|
| 117 |
+
let bytes = 0;
|
| 118 |
+
for (const [, v] of formData.entries()) {
|
| 119 |
+
if (v instanceof Blob) bytes += v.size;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const headers: Record<string, string> = {
|
| 123 |
+
Authorization: `Bearer ${token}`,
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
// Forward to the Medicine Scanner Space.
|
| 127 |
+
const response = await fetch(`${scannerUrl}/api/scan`, {
|
| 128 |
+
method: 'POST',
|
| 129 |
+
headers,
|
| 130 |
+
body: formData,
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
const data = await response.json().catch(() => ({} as any));
|
| 134 |
+
const latency = Date.now() - startedAt;
|
| 135 |
+
|
| 136 |
+
logScan(
|
| 137 |
+
user?.id || null,
|
| 138 |
+
ip,
|
| 139 |
+
response.status,
|
| 140 |
+
bytes,
|
| 141 |
+
latency,
|
| 142 |
+
(data as any)?.model || null,
|
| 143 |
+
);
|
| 144 |
+
|
| 145 |
+
if (user) {
|
| 146 |
+
auditLog({
|
| 147 |
+
userId: user.id,
|
| 148 |
+
action: 'scan',
|
| 149 |
+
ip,
|
| 150 |
+
meta: { status: response.status, bytes, latencyMs: latency },
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return NextResponse.json(data, { status: response.status });
|
| 155 |
+
} catch (error: any) {
|
| 156 |
+
const latency = Date.now() - startedAt;
|
| 157 |
+
console.error('[Scan Proxy]', error?.message);
|
| 158 |
+
logScan(user?.id || null, ip, 502, 0, latency, null);
|
| 159 |
+
return NextResponse.json(
|
| 160 |
+
{
|
| 161 |
+
success: false,
|
| 162 |
+
error: 'Medicine scanner unavailable. Please try again.',
|
| 163 |
+
medicine: null,
|
| 164 |
+
},
|
| 165 |
+
{ status: 502 },
|
| 166 |
+
);
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* GET /api/scan/health — Check if the Scanner Space is awake.
|
| 172 |
+
* Used by the frontend to show "waking up" status.
|
| 173 |
+
*/
|
| 174 |
+
export async function GET() {
|
| 175 |
+
const cfg = loadConfig();
|
| 176 |
+
const scannerUrl = cfg.llm.scannerUrl;
|
| 177 |
+
try {
|
| 178 |
+
const controller = new AbortController();
|
| 179 |
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
| 180 |
+
|
| 181 |
+
const res = await fetch(`${scannerUrl}/api/health`, {
|
| 182 |
+
signal: controller.signal,
|
| 183 |
+
});
|
| 184 |
+
clearTimeout(timeout);
|
| 185 |
+
|
| 186 |
+
if (res.ok) {
|
| 187 |
+
const data = await res.json();
|
| 188 |
+
return NextResponse.json(data);
|
| 189 |
+
}
|
| 190 |
+
return NextResponse.json({ status: 'unavailable' }, { status: 503 });
|
| 191 |
+
} catch {
|
| 192 |
+
return NextResponse.json({ status: 'sleeping' }, { status: 503 });
|
| 193 |
+
}
|
| 194 |
+
}
|
app/api/sessions/route.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
| 3 |
+
import { join } from 'path';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Server-side session counter.
|
| 7 |
+
* Stores count in /tmp/medos-data/sessions.json (persists across requests, resets on container restart).
|
| 8 |
+
* On HF Spaces with persistent storage, use /data/ instead of /tmp/.
|
| 9 |
+
*
|
| 10 |
+
* GET /api/sessions → returns { count: number }
|
| 11 |
+
* POST /api/sessions → increments and returns { count: number }
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
const DATA_DIR = process.env.PERSISTENT_DIR || '/tmp/medos-data';
|
| 15 |
+
const COUNTER_FILE = join(DATA_DIR, 'sessions.json');
|
| 16 |
+
const BASE_COUNT = 423000; // Historical base from before server-side tracking
|
| 17 |
+
|
| 18 |
+
interface CounterData {
|
| 19 |
+
count: number;
|
| 20 |
+
lastUpdated: string;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function ensureDir(): void {
|
| 24 |
+
if (!existsSync(DATA_DIR)) {
|
| 25 |
+
mkdirSync(DATA_DIR, { recursive: true });
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function readCounter(): number {
|
| 30 |
+
ensureDir();
|
| 31 |
+
try {
|
| 32 |
+
if (existsSync(COUNTER_FILE)) {
|
| 33 |
+
const data: CounterData = JSON.parse(readFileSync(COUNTER_FILE, 'utf8'));
|
| 34 |
+
return data.count;
|
| 35 |
+
}
|
| 36 |
+
} catch {
|
| 37 |
+
// corrupted file, reset
|
| 38 |
+
}
|
| 39 |
+
return 0;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function incrementCounter(): number {
|
| 43 |
+
ensureDir();
|
| 44 |
+
const current = readCounter();
|
| 45 |
+
const next = current + 1;
|
| 46 |
+
const data: CounterData = {
|
| 47 |
+
count: next,
|
| 48 |
+
lastUpdated: new Date().toISOString(),
|
| 49 |
+
};
|
| 50 |
+
writeFileSync(COUNTER_FILE, JSON.stringify(data), 'utf8');
|
| 51 |
+
return next;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export async function GET() {
|
| 55 |
+
const sessionCount = readCounter();
|
| 56 |
+
return NextResponse.json({
|
| 57 |
+
count: BASE_COUNT + sessionCount,
|
| 58 |
+
sessions: sessionCount,
|
| 59 |
+
base: BASE_COUNT,
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export async function POST() {
|
| 64 |
+
const sessionCount = incrementCounter();
|
| 65 |
+
return NextResponse.json({
|
| 66 |
+
count: BASE_COUNT + sessionCount,
|
| 67 |
+
sessions: sessionCount,
|
| 68 |
+
base: BASE_COUNT,
|
| 69 |
+
});
|
| 70 |
+
}
|
app/api/triage/route.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { triageMessage } from '@/lib/safety/triage';
|
| 3 |
+
import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
|
| 4 |
+
|
| 5 |
+
export async function POST(request: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const { message, countryCode = 'US' } = await request.json();
|
| 8 |
+
|
| 9 |
+
if (!message || typeof message !== 'string') {
|
| 10 |
+
return NextResponse.json(
|
| 11 |
+
{ error: 'Message is required' },
|
| 12 |
+
{ status: 400 }
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const triage = triageMessage(message);
|
| 17 |
+
const emergencyInfo = getEmergencyInfo(countryCode);
|
| 18 |
+
|
| 19 |
+
return NextResponse.json({
|
| 20 |
+
...triage,
|
| 21 |
+
emergencyInfo: triage.isEmergency ? emergencyInfo : null,
|
| 22 |
+
});
|
| 23 |
+
} catch {
|
| 24 |
+
return NextResponse.json(
|
| 25 |
+
{ error: 'Internal server error' },
|
| 26 |
+
{ status: 500 }
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
}
|
app/api/user/settings/route.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import { authenticateRequest } from '@/lib/auth-middleware';
|
| 4 |
+
import { getUserSettings, upsertUserSettings } from '@/lib/user-settings';
|
| 5 |
+
import { auditLog } from '@/lib/audit';
|
| 6 |
+
import { getClientIp } from '@/lib/rate-limit';
|
| 7 |
+
import { redact } from '@/lib/crypto';
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Per-user settings API.
|
| 11 |
+
*
|
| 12 |
+
* GET /api/user/settings — returns this user's preferences + EHR profile.
|
| 13 |
+
* The BYO Hugging Face token is NEVER returned
|
| 14 |
+
* in plaintext; the response carries only a
|
| 15 |
+
* redacted preview ('••••HiJ') and a
|
| 16 |
+
* hasHfToken boolean. The decrypted token is
|
| 17 |
+
* used in-process only by the LLM provider
|
| 18 |
+
* chain (added in a follow-up batch).
|
| 19 |
+
*
|
| 20 |
+
* PUT /api/user/settings — partial patch. Field semantics for `hfToken`:
|
| 21 |
+
* omit → leave token unchanged
|
| 22 |
+
* "" → clear stored token
|
| 23 |
+
* "hf_xxx" → rotate to new value (encrypted)
|
| 24 |
+
*
|
| 25 |
+
* Every successful PUT writes an audit_log('settings_update') entry that
|
| 26 |
+
* lists the changed field NAMES only — never values.
|
| 27 |
+
*/
|
| 28 |
+
|
| 29 |
+
export const runtime = 'nodejs';
|
| 30 |
+
|
| 31 |
+
export async function GET(req: Request) {
|
| 32 |
+
const user = authenticateRequest(req);
|
| 33 |
+
if (!user) {
|
| 34 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const s = getUserSettings(user.id);
|
| 38 |
+
|
| 39 |
+
return NextResponse.json({
|
| 40 |
+
settings: {
|
| 41 |
+
language: s.language ?? null,
|
| 42 |
+
country: s.country ?? null,
|
| 43 |
+
units: s.units ?? null,
|
| 44 |
+
defaultModel: s.defaultModel ?? null,
|
| 45 |
+
theme: s.theme ?? null,
|
| 46 |
+
ehr: s.ehr ?? {},
|
| 47 |
+
hfTokenRedacted: s.hfToken ? redact(s.hfToken) : null,
|
| 48 |
+
hasHfToken: !!s.hfToken,
|
| 49 |
+
},
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const PutSchema = z.object({
|
| 54 |
+
language: z.string().min(2).max(8).optional(),
|
| 55 |
+
country: z.string().min(2).max(4).optional(),
|
| 56 |
+
units: z.enum(['metric', 'imperial']).optional(),
|
| 57 |
+
defaultModel: z.string().max(100).optional(),
|
| 58 |
+
theme: z.enum(['light', 'dark', 'auto']).optional(),
|
| 59 |
+
// EHR is a free-form bag (the wizard owns its shape) but bounded.
|
| 60 |
+
ehr: z.record(z.any()).optional(),
|
| 61 |
+
// Empty string clears the token, undefined leaves it untouched.
|
| 62 |
+
hfToken: z.string().max(200).optional(),
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
export async function PUT(req: Request) {
|
| 66 |
+
const user = authenticateRequest(req);
|
| 67 |
+
if (!user) {
|
| 68 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
let parsed;
|
| 72 |
+
try {
|
| 73 |
+
const body = await req.json();
|
| 74 |
+
parsed = PutSchema.parse(body);
|
| 75 |
+
} catch (error: any) {
|
| 76 |
+
if (error instanceof z.ZodError) {
|
| 77 |
+
return NextResponse.json(
|
| 78 |
+
{ error: 'Invalid input', details: error.errors },
|
| 79 |
+
{ status: 400 },
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Reject pathological EHR payloads to keep row size sane.
|
| 86 |
+
if (parsed.ehr && JSON.stringify(parsed.ehr).length > 32_000) {
|
| 87 |
+
return NextResponse.json(
|
| 88 |
+
{ error: 'EHR payload too large (max 32 KB).' },
|
| 89 |
+
{ status: 413 },
|
| 90 |
+
);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
try {
|
| 94 |
+
upsertUserSettings(user.id, parsed);
|
| 95 |
+
} catch (error: any) {
|
| 96 |
+
console.error('[User Settings PUT]', error?.message);
|
| 97 |
+
return NextResponse.json({ error: 'Save failed' }, { status: 500 });
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
auditLog({
|
| 101 |
+
userId: user.id,
|
| 102 |
+
action: 'settings_update',
|
| 103 |
+
ip: getClientIp(req),
|
| 104 |
+
meta: {
|
| 105 |
+
fields: Object.keys(parsed),
|
| 106 |
+
tokenRotated: parsed.hfToken !== undefined,
|
| 107 |
+
ehrFieldsChanged: parsed.ehr ? Object.keys(parsed.ehr) : [],
|
| 108 |
+
},
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
// Return the fresh, redacted view so the client can update its cache.
|
| 112 |
+
const s = getUserSettings(user.id);
|
| 113 |
+
return NextResponse.json({
|
| 114 |
+
success: true,
|
| 115 |
+
settings: {
|
| 116 |
+
language: s.language ?? null,
|
| 117 |
+
country: s.country ?? null,
|
| 118 |
+
units: s.units ?? null,
|
| 119 |
+
defaultModel: s.defaultModel ?? null,
|
| 120 |
+
theme: s.theme ?? null,
|
| 121 |
+
ehr: s.ehr ?? {},
|
| 122 |
+
hfTokenRedacted: s.hfToken ? redact(s.hfToken) : null,
|
| 123 |
+
hasHfToken: !!s.hfToken,
|
| 124 |
+
},
|
| 125 |
+
});
|
| 126 |
+
}
|
app/globals.css
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
/* ============================================================
|
| 6 |
+
* MedOS design tokens — light + dark
|
| 7 |
+
* ============================================================ */
|
| 8 |
+
:root {
|
| 9 |
+
/* Surfaces (light mode — soft white, never pure #FFFFFF) */
|
| 10 |
+
--surface-0: 247 249 251; /* app backdrop #F7F9FB */
|
| 11 |
+
--surface-1: 255 255 255; /* cards */
|
| 12 |
+
--surface-2: 241 245 249; /* elevated panels #F1F5F9 */
|
| 13 |
+
--surface-3: 226 232 240; /* borders / rails */
|
| 14 |
+
|
| 15 |
+
--ink-base: 15 23 42; /* slate-900 */
|
| 16 |
+
--ink-muted: 71 85 105; /* slate-600 */
|
| 17 |
+
--ink-subtle: 148 163 184; /* slate-400 */
|
| 18 |
+
--ink-inverse: 255 255 255;
|
| 19 |
+
|
| 20 |
+
--line: 226 232 240; /* slate-200 */
|
| 21 |
+
|
| 22 |
+
color-scheme: light;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.dark {
|
| 26 |
+
/* Dark mode — warm deep navy, NOT pure black */
|
| 27 |
+
--surface-0: 11 18 32; /* #0B1220 */
|
| 28 |
+
--surface-1: 18 27 45; /* #121B2D elevated card */
|
| 29 |
+
--surface-2: 24 34 54; /* #182236 panel */
|
| 30 |
+
--surface-3: 34 46 71; /* #222E47 border */
|
| 31 |
+
|
| 32 |
+
--ink-base: 241 245 249; /* slate-100 */
|
| 33 |
+
--ink-muted: 148 163 184; /* slate-400 */
|
| 34 |
+
--ink-subtle: 100 116 139; /* slate-500 */
|
| 35 |
+
--ink-inverse: 15 23 42;
|
| 36 |
+
|
| 37 |
+
--line: 34 46 71;
|
| 38 |
+
|
| 39 |
+
color-scheme: dark;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
@layer base {
|
| 43 |
+
html,
|
| 44 |
+
body {
|
| 45 |
+
@apply h-full w-full;
|
| 46 |
+
/* iOS Safari 100vh fix: dvh accounts for the collapsible address bar.
|
| 47 |
+
Falls back to 100vh for browsers that don't support dvh. */
|
| 48 |
+
height: 100vh;
|
| 49 |
+
height: 100dvh;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
html {
|
| 53 |
+
font-family: var(--font-sans), Inter,
|
| 54 |
+
/* CJK fallbacks (Chinese, Japanese, Korean) */
|
| 55 |
+
"Noto Sans CJK SC", "PingFang SC", "Hiragino Sans", "MS Gothic",
|
| 56 |
+
"Malgun Gothic", "Apple SD Gothic Neo",
|
| 57 |
+
/* Arabic */
|
| 58 |
+
"Noto Sans Arabic", "Geeza Pro", "Tahoma",
|
| 59 |
+
/* Devanagari (Hindi) */
|
| 60 |
+
"Noto Sans Devanagari",
|
| 61 |
+
/* Thai */
|
| 62 |
+
"Noto Sans Thai",
|
| 63 |
+
/* System fallbacks */
|
| 64 |
+
ui-sans-serif, system-ui, -apple-system,
|
| 65 |
+
"Segoe UI", Roboto, sans-serif;
|
| 66 |
+
font-feature-settings: "cv02", "cv03", "cv04", "cv11", "ss01";
|
| 67 |
+
-webkit-font-smoothing: antialiased;
|
| 68 |
+
-moz-osx-font-smoothing: grayscale;
|
| 69 |
+
text-rendering: optimizeLegibility;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
body {
|
| 73 |
+
@apply text-ink-base antialiased;
|
| 74 |
+
background: theme("backgroundImage.light-app");
|
| 75 |
+
background-attachment: fixed;
|
| 76 |
+
line-height: 1.6;
|
| 77 |
+
letter-spacing: -0.005em;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.dark body {
|
| 81 |
+
background: theme("backgroundImage.dark-app");
|
| 82 |
+
background-attachment: fixed;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* Slightly larger, more readable body copy — medical trust */
|
| 86 |
+
p { line-height: 1.65; }
|
| 87 |
+
|
| 88 |
+
/* Focus rings that are visible in both modes */
|
| 89 |
+
:focus-visible {
|
| 90 |
+
outline: 2px solid rgb(var(--color-brand, 59 130 246));
|
| 91 |
+
outline-offset: 2px;
|
| 92 |
+
border-radius: 8px;
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
@layer components {
|
| 97 |
+
.glass-card {
|
| 98 |
+
@apply bg-surface-1/80 backdrop-blur-xl border border-line/60 shadow-soft;
|
| 99 |
+
}
|
| 100 |
+
.glass-strong {
|
| 101 |
+
@apply bg-surface-1/95 backdrop-blur-2xl border border-line/70 shadow-card;
|
| 102 |
+
}
|
| 103 |
+
/* Section headings inside an AI answer (Summary / Self-care …) */
|
| 104 |
+
.answer-section {
|
| 105 |
+
@apply relative pl-4 mt-4 first:mt-0;
|
| 106 |
+
}
|
| 107 |
+
.answer-section::before {
|
| 108 |
+
content: "";
|
| 109 |
+
@apply absolute left-0 top-1 bottom-1 w-1 rounded-full bg-brand-500/60;
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
@layer utilities {
|
| 114 |
+
.animate-in { animation: fadeIn 0.5s ease-in-out; }
|
| 115 |
+
.slide-in-from-bottom-4 { animation: slideInFromBottom 0.5s ease-in-out; }
|
| 116 |
+
.delay-100 { animation-delay: 100ms; }
|
| 117 |
+
.delay-200 { animation-delay: 200ms; }
|
| 118 |
+
|
| 119 |
+
/* Shimmer utility for the "Analyzing…" typing state */
|
| 120 |
+
.shimmer-text {
|
| 121 |
+
background: linear-gradient(
|
| 122 |
+
90deg,
|
| 123 |
+
rgb(var(--ink-muted) / 0.5) 0%,
|
| 124 |
+
rgb(var(--ink-base)) 50%,
|
| 125 |
+
rgb(var(--ink-muted) / 0.5) 100%
|
| 126 |
+
);
|
| 127 |
+
background-size: 200% 100%;
|
| 128 |
+
-webkit-background-clip: text;
|
| 129 |
+
background-clip: text;
|
| 130 |
+
color: transparent;
|
| 131 |
+
animation: shimmer 2.2s linear infinite;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
@keyframes fadeIn {
|
| 135 |
+
from { opacity: 0; }
|
| 136 |
+
to { opacity: 1; }
|
| 137 |
+
}
|
| 138 |
+
@keyframes slideInFromBottom {
|
| 139 |
+
from { opacity: 0; transform: translateY(1rem); }
|
| 140 |
+
to { opacity: 1; transform: translateY(0); }
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* Medicine scanner viewfinder — pulsing green border */
|
| 144 |
+
@keyframes scannerPulse {
|
| 145 |
+
0%, 100% { border-color: rgba(34, 197, 94, 0.5); box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
| 146 |
+
50% { border-color: rgba(34, 197, 94, 1); box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.15); }
|
| 147 |
+
}
|
| 148 |
+
@keyframes scanLine {
|
| 149 |
+
0% { top: 8%; }
|
| 150 |
+
50% { top: 88%; }
|
| 151 |
+
100% { top: 8%; }
|
| 152 |
+
}
|
| 153 |
+
@keyframes scanSuccess {
|
| 154 |
+
0% { transform: scale(1); border-color: rgba(34, 197, 94, 0.6); }
|
| 155 |
+
50% { transform: scale(1.02); border-color: rgba(34, 197, 94, 1); }
|
| 156 |
+
100% { transform: scale(1); border-color: rgba(34, 197, 94, 0.6); }
|
| 157 |
+
}
|
| 158 |
+
@keyframes scanProgress {
|
| 159 |
+
0% { width: 0%; }
|
| 160 |
+
100% { width: 100%; }
|
| 161 |
+
}
|
| 162 |
+
.animate-scanner-pulse { animation: scannerPulse 2s ease-in-out infinite; }
|
| 163 |
+
.animate-scan-line { animation: scanLine 2.5s ease-in-out infinite; }
|
| 164 |
+
.animate-scan-success { animation: scanSuccess 0.6s ease-out; }
|
| 165 |
+
.animate-scan-progress { animation: scanProgress 3s ease-out forwards; }
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/* ------------------------------------------------------------
|
| 169 |
+
* Dark-mode compatibility layer for legacy screens that still
|
| 170 |
+
* reference slate/white utility classes directly. Remaps them to
|
| 171 |
+
* the design-token surfaces so Settings/Records/Schedule/Topics/
|
| 172 |
+
* Emergency look right in dark mode without a full rewrite.
|
| 173 |
+
* New components should prefer the `bg-surface-*` and `text-ink-*`
|
| 174 |
+
* tokens directly and won't be affected by these rules.
|
| 175 |
+
* ------------------------------------------------------------ */
|
| 176 |
+
.dark .bg-white { background-color: rgb(var(--surface-1)) !important; }
|
| 177 |
+
.dark .bg-slate-50 { background-color: rgb(var(--surface-2)) !important; }
|
| 178 |
+
.dark .bg-slate-100 { background-color: rgb(var(--surface-2)) !important; }
|
| 179 |
+
.dark .bg-\[\#F8FAFC\] { background-color: rgb(var(--surface-0)) !important; }
|
| 180 |
+
.dark .bg-\[\#F7F9FB\] { background-color: rgb(var(--surface-0)) !important; }
|
| 181 |
+
|
| 182 |
+
.dark .text-slate-900 { color: rgb(var(--ink-base)) !important; }
|
| 183 |
+
.dark .text-slate-800 { color: rgb(var(--ink-base)) !important; }
|
| 184 |
+
.dark .text-slate-700 { color: rgb(var(--ink-base) / 0.92) !important; }
|
| 185 |
+
.dark .text-slate-600 { color: rgb(var(--ink-muted)) !important; }
|
| 186 |
+
.dark .text-slate-500 { color: rgb(var(--ink-muted)) !important; }
|
| 187 |
+
.dark .text-slate-400 { color: rgb(var(--ink-subtle)) !important; }
|
| 188 |
+
.dark .text-slate-300 { color: rgb(var(--ink-subtle) / 0.85) !important; }
|
| 189 |
+
|
| 190 |
+
.dark .border-slate-50 { border-color: rgb(var(--line) / 0.55) !important; }
|
| 191 |
+
.dark .border-slate-100 { border-color: rgb(var(--line) / 0.7) !important; }
|
| 192 |
+
.dark .border-slate-200 { border-color: rgb(var(--line) / 0.9) !important; }
|
| 193 |
+
|
| 194 |
+
.dark .placeholder-slate-400::placeholder { color: rgb(var(--ink-subtle)); }
|
| 195 |
+
|
| 196 |
+
/* Soft tinted surfaces used on a handful of views (blue-50, rose-50,
|
| 197 |
+
* amber-50, etc.) — in dark mode dim them into brand/accent tints. */
|
| 198 |
+
.dark .bg-blue-50 { background-color: rgba(59,130,246,0.10) !important; }
|
| 199 |
+
.dark .bg-blue-100 { background-color: rgba(59,130,246,0.18) !important; }
|
| 200 |
+
.dark .bg-indigo-50 { background-color: rgba(99,102,241,0.10) !important; }
|
| 201 |
+
.dark .bg-rose-50 { background-color: rgba(244,63,94,0.10) !important; }
|
| 202 |
+
.dark .bg-red-50 { background-color: rgba(239,68,68,0.10) !important; }
|
| 203 |
+
.dark .bg-amber-50 { background-color: rgba(245,158,11,0.10) !important; }
|
| 204 |
+
.dark .bg-emerald-50 { background-color: rgba(16,185,129,0.10) !important; }
|
| 205 |
+
.dark .bg-purple-50 { background-color: rgba(168,85,247,0.10) !important; }
|
| 206 |
+
|
| 207 |
+
.dark .border-blue-100 { border-color: rgba(59,130,246,0.28) !important; }
|
| 208 |
+
.dark .border-blue-200 { border-color: rgba(59,130,246,0.38) !important; }
|
| 209 |
+
.dark .border-red-200 { border-color: rgba(239,68,68,0.38) !important; }
|
| 210 |
+
.dark .border-amber-200 { border-color: rgba(245,158,11,0.38) !important; }
|
| 211 |
+
|
| 212 |
+
/* Scrollbars — subtle in both modes */
|
| 213 |
+
::-webkit-scrollbar { width: 10px; height: 10px; }
|
| 214 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 215 |
+
::-webkit-scrollbar-thumb {
|
| 216 |
+
background: rgb(var(--ink-subtle) / 0.35);
|
| 217 |
+
border-radius: 9999px;
|
| 218 |
+
border: 2px solid transparent;
|
| 219 |
+
background-clip: padding-box;
|
| 220 |
+
}
|
| 221 |
+
::-webkit-scrollbar-thumb:hover {
|
| 222 |
+
background: rgb(var(--ink-subtle) / 0.55);
|
| 223 |
+
background-clip: padding-box;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
::selection {
|
| 227 |
+
background: rgba(59, 130, 246, 0.22);
|
| 228 |
+
color: inherit;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
/* Safe area utilities for notched phones (iPhone X+, Android gesture nav) */
|
| 232 |
+
.safe-area-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
|
| 233 |
+
.pb-safe-area { padding-bottom: env(safe-area-inset-bottom, 0px); }
|
| 234 |
+
.safe-area-top { padding-top: env(safe-area-inset-top, 0px); }
|
| 235 |
+
.px-safe-area {
|
| 236 |
+
padding-left: env(safe-area-inset-left, 0px);
|
| 237 |
+
padding-right: env(safe-area-inset-right, 0px);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/* ============================================================
|
| 241 |
+
* RTL (Right-to-Left) support for Arabic (ar) and Urdu (ur)
|
| 242 |
+
* Set via document.documentElement.dir = "rtl" in MedOSApp.tsx
|
| 243 |
+
* ============================================================ */
|
| 244 |
+
[dir="rtl"] {
|
| 245 |
+
/* Base direction */
|
| 246 |
+
direction: rtl;
|
| 247 |
+
text-align: right;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/* Flex containers reverse in RTL */
|
| 251 |
+
[dir="rtl"] .flex { direction: rtl; }
|
| 252 |
+
|
| 253 |
+
/* Fix drawer slide direction (left → right for RTL) */
|
| 254 |
+
[dir="rtl"] aside { left: auto; right: 0; }
|
| 255 |
+
[dir="rtl"] .-translate-x-full { transform: translateX(100%); }
|
| 256 |
+
[dir="rtl"] .translate-x-0 { transform: translateX(0); }
|
| 257 |
+
|
| 258 |
+
/* Flip margins and paddings for common patterns */
|
| 259 |
+
[dir="rtl"] .ml-auto { margin-left: 0; margin-right: auto; }
|
| 260 |
+
[dir="rtl"] .mr-auto { margin-right: 0; margin-left: auto; }
|
| 261 |
+
[dir="rtl"] .text-left { text-align: right; }
|
| 262 |
+
[dir="rtl"] .text-right { text-align: left; }
|
| 263 |
+
|
| 264 |
+
/* Fix icon + text alignment in nav items */
|
| 265 |
+
[dir="rtl"] .gap-2 { gap: 0.5rem; }
|
| 266 |
+
[dir="rtl"] .gap-3 { gap: 0.75rem; }
|
| 267 |
+
|
| 268 |
+
/* Scrollbar on left side for RTL */
|
| 269 |
+
[dir="rtl"] ::-webkit-scrollbar { direction: rtl; }
|
| 270 |
+
|
| 271 |
+
/* Fix rounded corners (swap left/right) */
|
| 272 |
+
[dir="rtl"] .rounded-tl-sm { border-top-left-radius: 0; border-top-right-radius: 0.125rem; }
|
| 273 |
+
[dir="rtl"] .rounded-tr-sm { border-top-right-radius: 0; border-top-left-radius: 0.125rem; }
|
| 274 |
+
|
| 275 |
+
/* Large tap targets */
|
| 276 |
+
@media (pointer: coarse) {
|
| 277 |
+
button, a, select, input, textarea { min-height: 44px; }
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
/* ============================================================
|
| 281 |
+
* Mobile-first utilities
|
| 282 |
+
* ============================================================ */
|
| 283 |
+
|
| 284 |
+
/* Dynamic viewport height — works on iOS Safari, Android Chrome,
|
| 285 |
+
and every modern browser. Falls back to 100vh. */
|
| 286 |
+
.h-screen-safe {
|
| 287 |
+
height: 100vh;
|
| 288 |
+
height: 100dvh;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
/* Sticky input bar that stays above the mobile keyboard.
|
| 292 |
+
Uses env(keyboard-inset-height) on supporting browsers and
|
| 293 |
+
falls back to standard sticky positioning elsewhere. */
|
| 294 |
+
.sticky-bottom-keyboard {
|
| 295 |
+
position: sticky;
|
| 296 |
+
bottom: 0;
|
| 297 |
+
bottom: env(keyboard-inset-height, 0px);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/* Prevent iOS input zoom — any input below 16px triggers a zoom.
|
| 301 |
+
We force 16px minimum on touch devices and compensate with
|
| 302 |
+
transforms where we need visually-smaller text. */
|
| 303 |
+
@media (pointer: coarse) {
|
| 304 |
+
input, textarea, select {
|
| 305 |
+
font-size: 16px !important;
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
/* CJK word-break — prevents overflow for Chinese/Japanese/Korean text */
|
| 310 |
+
:lang(zh), :lang(ja), :lang(ko) {
|
| 311 |
+
word-break: break-all;
|
| 312 |
+
overflow-wrap: break-word;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
/* iOS momentum scrolling */
|
| 316 |
+
.scroll-touch {
|
| 317 |
+
-webkit-overflow-scrolling: touch;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
/* Bottom padding spacer for content that sits above a fixed bottom nav.
|
| 321 |
+
The 5.5rem accounts for the nav height + safe-area-inset-bottom. */
|
| 322 |
+
.pb-mobile-nav {
|
| 323 |
+
padding-bottom: 5.5rem;
|
| 324 |
+
}
|
| 325 |
+
@media (min-width: 768px) {
|
| 326 |
+
.pb-mobile-nav {
|
| 327 |
+
padding-bottom: 0;
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
/* Respect reduced-motion preferences everywhere */
|
| 332 |
+
@media (prefers-reduced-motion: reduce) {
|
| 333 |
+
*,
|
| 334 |
+
*::before,
|
| 335 |
+
*::after {
|
| 336 |
+
animation-duration: 0.01ms !important;
|
| 337 |
+
animation-iteration-count: 1 !important;
|
| 338 |
+
transition-duration: 0.01ms !important;
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
/* ============================================================
|
| 343 |
+
* Print styles — professional PDF export via window.print()
|
| 344 |
+
* Hides navigation, maximizes content, clean typography
|
| 345 |
+
* ============================================================ */
|
| 346 |
+
@media print {
|
| 347 |
+
/* Hide navigation, headers, footers */
|
| 348 |
+
aside, nav, header, footer,
|
| 349 |
+
.safe-area-bottom, .pb-mobile-nav,
|
| 350 |
+
button, [role="navigation"],
|
| 351 |
+
.md\:hidden, .lg\:flex { display: none !important; }
|
| 352 |
+
|
| 353 |
+
/* Full width, no sidebar */
|
| 354 |
+
body { background: white !important; color: black !important; font-size: 12pt; }
|
| 355 |
+
.flex-1 { width: 100% !important; max-width: 100% !important; }
|
| 356 |
+
.overflow-hidden { overflow: visible !important; }
|
| 357 |
+
.overflow-y-auto { overflow: visible !important; }
|
| 358 |
+
|
| 359 |
+
/* Clean card borders for print */
|
| 360 |
+
.bg-surface-1, .bg-surface-0 { background: white !important; }
|
| 361 |
+
.border { border-color: #ddd !important; }
|
| 362 |
+
.shadow-soft, .shadow-card, .shadow-glow { box-shadow: none !important; }
|
| 363 |
+
.rounded-2xl { border-radius: 8px !important; }
|
| 364 |
+
|
| 365 |
+
/* Keep colors readable */
|
| 366 |
+
.text-ink-base { color: #111 !important; }
|
| 367 |
+
.text-ink-muted { color: #555 !important; }
|
| 368 |
+
.text-brand-500, .text-brand-600 { color: #2563eb !important; }
|
| 369 |
+
.text-danger-500 { color: #dc2626 !important; }
|
| 370 |
+
.text-success-500 { color: #16a34a !important; }
|
| 371 |
+
|
| 372 |
+
/* Page breaks */
|
| 373 |
+
.stat-card, .rounded-2xl { break-inside: avoid; }
|
| 374 |
+
h2, h3 { break-after: avoid; }
|
| 375 |
+
|
| 376 |
+
/* Print header */
|
| 377 |
+
@page { margin: 1.5cm; size: A4; }
|
| 378 |
+
}
|
app/icon.svg
ADDED
|
|
app/layout.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata, Viewport } from "next";
|
| 2 |
+
import { Inter } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const inter = Inter({
|
| 6 |
+
subsets: ["latin"],
|
| 7 |
+
display: "swap",
|
| 8 |
+
variable: "--font-sans",
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
export const metadata: Metadata = {
|
| 12 |
+
title: "MedOS — your worldwide medical assistant",
|
| 13 |
+
description:
|
| 14 |
+
"Tell MedOS what's bothering you. Instant, private, multilingual health guidance aligned with WHO, CDC, and NHS.",
|
| 15 |
+
keywords: ["medical AI", "healthcare", "chatbot", "telemedicine", "WHO", "CDC"],
|
| 16 |
+
authors: [{ name: "MedOS Team" }],
|
| 17 |
+
manifest: "/manifest.webmanifest",
|
| 18 |
+
icons: {
|
| 19 |
+
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
|
| 20 |
+
shortcut: "/favicon.svg",
|
| 21 |
+
},
|
| 22 |
+
openGraph: {
|
| 23 |
+
title: "MedOS — your worldwide medical assistant",
|
| 24 |
+
description:
|
| 25 |
+
"Private, multilingual health guidance aligned with WHO, CDC, and NHS — available 24/7.",
|
| 26 |
+
type: "website",
|
| 27 |
+
},
|
| 28 |
+
robots: { index: true, follow: true },
|
| 29 |
+
appleWebApp: {
|
| 30 |
+
capable: true,
|
| 31 |
+
title: "MedOS",
|
| 32 |
+
},
|
| 33 |
+
other: {
|
| 34 |
+
"mobile-web-app-capable": "yes",
|
| 35 |
+
},
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
export const viewport: Viewport = {
|
| 39 |
+
width: "device-width",
|
| 40 |
+
initialScale: 1,
|
| 41 |
+
maximumScale: 5,
|
| 42 |
+
userScalable: true,
|
| 43 |
+
viewportFit: "cover",
|
| 44 |
+
themeColor: [
|
| 45 |
+
{ media: "(prefers-color-scheme: light)", color: "#F7F9FB" },
|
| 46 |
+
{ media: "(prefers-color-scheme: dark)", color: "#0B1220" },
|
| 47 |
+
],
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Inline pre-hydration script: reads the stored theme before first paint.
|
| 52 |
+
*/
|
| 53 |
+
const themeBootstrap = `
|
| 54 |
+
(function() {
|
| 55 |
+
try {
|
| 56 |
+
var stored = localStorage.getItem('medos_theme');
|
| 57 |
+
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
| 58 |
+
var isDark = stored === 'dark' || (stored === 'system' && prefersDark);
|
| 59 |
+
if (isDark) document.documentElement.classList.add('dark');
|
| 60 |
+
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
|
| 61 |
+
} catch (e) {}
|
| 62 |
+
})();
|
| 63 |
+
`;
|
| 64 |
+
|
| 65 |
+
export default function RootLayout({
|
| 66 |
+
children,
|
| 67 |
+
}: {
|
| 68 |
+
children: React.ReactNode;
|
| 69 |
+
}) {
|
| 70 |
+
return (
|
| 71 |
+
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
| 72 |
+
<head>
|
| 73 |
+
<script dangerouslySetInnerHTML={{ __html: themeBootstrap }} />
|
| 74 |
+
</head>
|
| 75 |
+
<body className="min-h-screen antialiased">{children}</body>
|
| 76 |
+
</html>
|
| 77 |
+
);
|
| 78 |
+
}
|
app/manifest.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { MetadataRoute } from "next";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Next.js native manifest route. Served at /manifest.webmanifest by
|
| 5 |
+
* the framework itself (not as a static file) so it bypasses Vercel's
|
| 6 |
+
* Deployment Protection on preview branches.
|
| 7 |
+
*/
|
| 8 |
+
export default function manifest(): MetadataRoute.Manifest {
|
| 9 |
+
return {
|
| 10 |
+
name: "MedOS — your medical assistant",
|
| 11 |
+
short_name: "MedOS",
|
| 12 |
+
description:
|
| 13 |
+
"Free AI medical assistant. 20 languages. No sign-up. Private.",
|
| 14 |
+
start_url: "/?source=pwa",
|
| 15 |
+
scope: "/",
|
| 16 |
+
display: "standalone",
|
| 17 |
+
orientation: "portrait-primary",
|
| 18 |
+
theme_color: "#3B82F6",
|
| 19 |
+
background_color: "#F7F9FB",
|
| 20 |
+
categories: ["health", "medical", "lifestyle"],
|
| 21 |
+
icons: [
|
| 22 |
+
{
|
| 23 |
+
src: "/favicon.svg",
|
| 24 |
+
sizes: "any",
|
| 25 |
+
type: "image/svg+xml",
|
| 26 |
+
purpose: "any",
|
| 27 |
+
},
|
| 28 |
+
],
|
| 29 |
+
shortcuts: [
|
| 30 |
+
{
|
| 31 |
+
name: "Ask a health question",
|
| 32 |
+
short_name: "Ask",
|
| 33 |
+
url: "/?source=shortcut",
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
name: "Health Dashboard",
|
| 37 |
+
short_name: "Health",
|
| 38 |
+
url: "/?view=health-dashboard",
|
| 39 |
+
},
|
| 40 |
+
],
|
| 41 |
+
prefer_related_applications: false,
|
| 42 |
+
};
|
| 43 |
+
}
|
app/page.tsx
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import MedOSApp from "@/components/MedOSApp";
|
| 2 |
+
export default function HomePage() { return <MedOSApp />; }
|
app/robots.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { MetadataRoute } from 'next';
|
| 2 |
+
|
| 3 |
+
const SITE_URL = 'https://ruslanmv-medibot.hf.space';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Permissive robots.txt — we want every crawler to index the public
|
| 7 |
+
* pages (home, /symptoms, /stats). API routes are disallowed because
|
| 8 |
+
* they return dynamic or privacy-sensitive data (geo lookup, chat
|
| 9 |
+
* stream, session counter).
|
| 10 |
+
*/
|
| 11 |
+
export default function robots(): MetadataRoute.Robots {
|
| 12 |
+
return {
|
| 13 |
+
rules: [
|
| 14 |
+
{
|
| 15 |
+
userAgent: '*',
|
| 16 |
+
allow: ['/', '/symptoms', '/stats'],
|
| 17 |
+
disallow: ['/api/'],
|
| 18 |
+
},
|
| 19 |
+
],
|
| 20 |
+
sitemap: `${SITE_URL}/sitemap.xml`,
|
| 21 |
+
host: SITE_URL,
|
| 22 |
+
};
|
| 23 |
+
}
|
app/sitemap.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { MetadataRoute } from 'next';
|
| 2 |
+
import { getAllSymptomSlugs } from '@/lib/symptoms';
|
| 3 |
+
|
| 4 |
+
const SITE_URL = 'https://ruslanmv-medibot.hf.space';
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Static sitemap auto-generated from the symptom catalog so Google picks
|
| 8 |
+
* up every SEO landing page on day one. Served at `/sitemap.xml` by Next.
|
| 9 |
+
*/
|
| 10 |
+
export default function sitemap(): MetadataRoute.Sitemap {
|
| 11 |
+
const now = new Date();
|
| 12 |
+
|
| 13 |
+
const staticPages: MetadataRoute.Sitemap = [
|
| 14 |
+
{ url: SITE_URL, lastModified: now, changeFrequency: 'daily', priority: 1.0 },
|
| 15 |
+
{ url: `${SITE_URL}/symptoms`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
|
| 16 |
+
{ url: `${SITE_URL}/stats`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 },
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
const symptomPages: MetadataRoute.Sitemap = getAllSymptomSlugs().map((slug) => ({
|
| 20 |
+
url: `${SITE_URL}/symptoms/${slug}`,
|
| 21 |
+
lastModified: now,
|
| 22 |
+
changeFrequency: 'weekly',
|
| 23 |
+
priority: 0.8,
|
| 24 |
+
}));
|
| 25 |
+
|
| 26 |
+
return [...staticPages, ...symptomPages];
|
| 27 |
+
}
|
app/stats/page.tsx
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
import Link from 'next/link';
|
| 5 |
+
import {
|
| 6 |
+
Activity,
|
| 7 |
+
Globe2,
|
| 8 |
+
ShieldCheck,
|
| 9 |
+
Clock4,
|
| 10 |
+
ArrowRight,
|
| 11 |
+
Languages,
|
| 12 |
+
} from 'lucide-react';
|
| 13 |
+
import { TrustBar } from '@/components/chat/TrustBar';
|
| 14 |
+
|
| 15 |
+
interface SessionsResponse {
|
| 16 |
+
count: number;
|
| 17 |
+
sessions: number;
|
| 18 |
+
base: number;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const LANGUAGES = [
|
| 22 |
+
'English',
|
| 23 |
+
'Español',
|
| 24 |
+
'Français',
|
| 25 |
+
'Português',
|
| 26 |
+
'Italiano',
|
| 27 |
+
'Deutsch',
|
| 28 |
+
'العربية',
|
| 29 |
+
'हिन्दी',
|
| 30 |
+
'Kiswahili',
|
| 31 |
+
'中文',
|
| 32 |
+
'日本語',
|
| 33 |
+
'한국어',
|
| 34 |
+
'Русский',
|
| 35 |
+
'Türkçe',
|
| 36 |
+
'Tiếng Việt',
|
| 37 |
+
'ไทย',
|
| 38 |
+
'বাংলা',
|
| 39 |
+
'اردو',
|
| 40 |
+
'Polski',
|
| 41 |
+
'Nederlands',
|
| 42 |
+
];
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Public transparency page. Shows the anonymous global session counter,
|
| 46 |
+
* supported languages, trust metrics, and the MedOS health-posture
|
| 47 |
+
* summary. Drives social proof ("N people helped this session") and is
|
| 48 |
+
* a natural link target from Product Hunt / Twitter / press.
|
| 49 |
+
*
|
| 50 |
+
* Server-rendered is tempting here, but we keep it client-side so the
|
| 51 |
+
* counter animates as it loads — tiny extra bundle, big UX win.
|
| 52 |
+
*/
|
| 53 |
+
export default function StatsPage() {
|
| 54 |
+
const [data, setData] = useState<SessionsResponse | null>(null);
|
| 55 |
+
const [loading, setLoading] = useState(true);
|
| 56 |
+
const [displayCount, setDisplayCount] = useState(0);
|
| 57 |
+
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
let cancelled = false;
|
| 60 |
+
fetch('/api/sessions', { cache: 'no-store' })
|
| 61 |
+
.then((r) => (r.ok ? r.json() : null))
|
| 62 |
+
.then((d: SessionsResponse | null) => {
|
| 63 |
+
if (!cancelled && d) {
|
| 64 |
+
setData(d);
|
| 65 |
+
animateTo(d.count, setDisplayCount);
|
| 66 |
+
}
|
| 67 |
+
})
|
| 68 |
+
.catch(() => {})
|
| 69 |
+
.finally(() => !cancelled && setLoading(false));
|
| 70 |
+
return () => {
|
| 71 |
+
cancelled = true;
|
| 72 |
+
};
|
| 73 |
+
}, []);
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<main className="min-h-screen bg-slate-900 text-slate-100">
|
| 77 |
+
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
|
| 78 |
+
<header className="mb-8 text-center">
|
| 79 |
+
<p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
|
| 80 |
+
MedOS transparency
|
| 81 |
+
</p>
|
| 82 |
+
<h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-3">
|
| 83 |
+
The numbers behind MedOS
|
| 84 |
+
</h1>
|
| 85 |
+
<p className="text-lg text-slate-300 max-w-xl mx-auto leading-relaxed">
|
| 86 |
+
Free health guidance, open numbers. No tracking of people —
|
| 87 |
+
only a single anonymous counter that ticks once per session.
|
| 88 |
+
</p>
|
| 89 |
+
</header>
|
| 90 |
+
|
| 91 |
+
{/* Hero counter */}
|
| 92 |
+
<section className="mb-10 rounded-3xl border border-teal-500/30 bg-gradient-to-br from-blue-900/30 to-teal-900/20 p-8 text-center">
|
| 93 |
+
<div className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1 rounded-full mb-4">
|
| 94 |
+
<Activity size={12} className="animate-pulse" />
|
| 95 |
+
Live session counter
|
| 96 |
+
</div>
|
| 97 |
+
<div className="text-6xl sm:text-7xl font-black text-slate-50 tracking-tight tabular-nums">
|
| 98 |
+
{loading ? (
|
| 99 |
+
<span className="text-slate-600">…</span>
|
| 100 |
+
) : (
|
| 101 |
+
formatNumber(displayCount)
|
| 102 |
+
)}
|
| 103 |
+
</div>
|
| 104 |
+
<p className="text-sm text-slate-400 mt-3">
|
| 105 |
+
conversations MedOS has helped with, anonymously, since launch
|
| 106 |
+
</p>
|
| 107 |
+
</section>
|
| 108 |
+
|
| 109 |
+
{/* Cards grid */}
|
| 110 |
+
<section className="grid sm:grid-cols-2 gap-4 mb-10">
|
| 111 |
+
<StatCard
|
| 112 |
+
Icon={Globe2}
|
| 113 |
+
label="Countries supported"
|
| 114 |
+
value="190+"
|
| 115 |
+
description="Emergency numbers localized per region"
|
| 116 |
+
/>
|
| 117 |
+
<StatCard
|
| 118 |
+
Icon={Languages}
|
| 119 |
+
label="Languages"
|
| 120 |
+
value="20"
|
| 121 |
+
description="Auto-detected from browser and IP"
|
| 122 |
+
/>
|
| 123 |
+
<StatCard
|
| 124 |
+
Icon={ShieldCheck}
|
| 125 |
+
label="Privacy"
|
| 126 |
+
value="Zero PII"
|
| 127 |
+
description="No accounts, no IP logging, no conversation storage"
|
| 128 |
+
/>
|
| 129 |
+
<StatCard
|
| 130 |
+
Icon={Clock4}
|
| 131 |
+
label="Availability"
|
| 132 |
+
value="24/7"
|
| 133 |
+
description="Free forever on HuggingFace Spaces"
|
| 134 |
+
/>
|
| 135 |
+
</section>
|
| 136 |
+
|
| 137 |
+
{/* Languages strip */}
|
| 138 |
+
<section className="mb-10 rounded-2xl border border-slate-700/60 bg-slate-800/50 p-6">
|
| 139 |
+
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400 mb-4 inline-flex items-center gap-2">
|
| 140 |
+
<Languages size={14} />
|
| 141 |
+
Supported languages
|
| 142 |
+
</h2>
|
| 143 |
+
<div className="flex flex-wrap gap-2">
|
| 144 |
+
{LANGUAGES.map((l) => (
|
| 145 |
+
<span
|
| 146 |
+
key={l}
|
| 147 |
+
className="px-3 py-1.5 rounded-full text-sm font-medium text-slate-200 bg-slate-700/60 border border-slate-600/50"
|
| 148 |
+
>
|
| 149 |
+
{l}
|
| 150 |
+
</span>
|
| 151 |
+
))}
|
| 152 |
+
</div>
|
| 153 |
+
</section>
|
| 154 |
+
|
| 155 |
+
{/* Trust bar */}
|
| 156 |
+
<section className="mb-10">
|
| 157 |
+
<TrustBar language="en" />
|
| 158 |
+
</section>
|
| 159 |
+
|
| 160 |
+
{/* CTA */}
|
| 161 |
+
<div className="rounded-2xl border border-teal-500/30 bg-gradient-to-br from-blue-900/40 to-teal-900/30 p-6 text-center">
|
| 162 |
+
<h2 className="text-2xl font-bold text-slate-50 mb-2 tracking-tight">
|
| 163 |
+
Ready to ask your own question?
|
| 164 |
+
</h2>
|
| 165 |
+
<p className="text-slate-300 mb-4">
|
| 166 |
+
Free. Private. In your language. No sign-up.
|
| 167 |
+
</p>
|
| 168 |
+
<Link
|
| 169 |
+
href="/"
|
| 170 |
+
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
|
| 171 |
+
>
|
| 172 |
+
Open MedOS
|
| 173 |
+
<ArrowRight size={18} />
|
| 174 |
+
</Link>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
{data && (
|
| 178 |
+
<p className="mt-6 text-center text-xs text-slate-500">
|
| 179 |
+
Counter is a single integer stored server-side at{' '}
|
| 180 |
+
<code className="text-slate-400">/api/sessions</code>. No
|
| 181 |
+
request is ever correlated to an individual.
|
| 182 |
+
</p>
|
| 183 |
+
)}
|
| 184 |
+
</div>
|
| 185 |
+
</main>
|
| 186 |
+
);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function StatCard({
|
| 190 |
+
Icon,
|
| 191 |
+
label,
|
| 192 |
+
value,
|
| 193 |
+
description,
|
| 194 |
+
}: {
|
| 195 |
+
Icon: any;
|
| 196 |
+
label: string;
|
| 197 |
+
value: string;
|
| 198 |
+
description: string;
|
| 199 |
+
}) {
|
| 200 |
+
return (
|
| 201 |
+
<div className="rounded-2xl border border-slate-700/60 bg-slate-800/50 p-5">
|
| 202 |
+
<div className="flex items-center gap-2 text-teal-400 mb-3">
|
| 203 |
+
<Icon size={16} />
|
| 204 |
+
<span className="text-xs font-bold uppercase tracking-wider">
|
| 205 |
+
{label}
|
| 206 |
+
</span>
|
| 207 |
+
</div>
|
| 208 |
+
<div className="text-3xl font-black text-slate-50 tracking-tight mb-1">
|
| 209 |
+
{value}
|
| 210 |
+
</div>
|
| 211 |
+
<p className="text-sm text-slate-400 leading-snug">{description}</p>
|
| 212 |
+
</div>
|
| 213 |
+
);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/** Format a number with locale-aware thousands separators. */
|
| 217 |
+
function formatNumber(n: number): string {
|
| 218 |
+
try {
|
| 219 |
+
return new Intl.NumberFormat(undefined).format(n);
|
| 220 |
+
} catch {
|
| 221 |
+
return String(n);
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/**
|
| 226 |
+
* Animate a counter from zero to `target` over ~1.2s using an ease-out
|
| 227 |
+
* curve. Runs synchronously inside `requestAnimationFrame` so it never
|
| 228 |
+
* blocks the main thread.
|
| 229 |
+
*/
|
| 230 |
+
function animateTo(target: number, setValue: (n: number) => void): void {
|
| 231 |
+
if (typeof window === 'undefined') {
|
| 232 |
+
setValue(target);
|
| 233 |
+
return;
|
| 234 |
+
}
|
| 235 |
+
const duration = 1200;
|
| 236 |
+
const start = performance.now();
|
| 237 |
+
const step = (t: number) => {
|
| 238 |
+
const elapsed = t - start;
|
| 239 |
+
const progress = Math.min(1, elapsed / duration);
|
| 240 |
+
const eased = 1 - Math.pow(1 - progress, 3); // cubic ease-out
|
| 241 |
+
setValue(Math.round(target * eased));
|
| 242 |
+
if (progress < 1) requestAnimationFrame(step);
|
| 243 |
+
};
|
| 244 |
+
requestAnimationFrame(step);
|
| 245 |
+
}
|
app/symptoms/[slug]/page.tsx
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from 'next';
|
| 2 |
+
import { notFound } from 'next/navigation';
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { AlertTriangle, ShieldCheck, ChevronLeft, ArrowRight } from 'lucide-react';
|
| 5 |
+
import {
|
| 6 |
+
getSymptomBySlug,
|
| 7 |
+
getAllSymptomSlugs,
|
| 8 |
+
type Symptom,
|
| 9 |
+
} from '@/lib/symptoms';
|
| 10 |
+
|
| 11 |
+
const SITE_URL = 'https://ruslanmv-medibot.hf.space';
|
| 12 |
+
|
| 13 |
+
interface Params {
|
| 14 |
+
params: { slug: string };
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* Pre-generate every symptom page at build time so they are fully static
|
| 19 |
+
* (and cacheable by HF Spaces' CDN / every downstream proxy).
|
| 20 |
+
*/
|
| 21 |
+
export function generateStaticParams() {
|
| 22 |
+
return getAllSymptomSlugs().map((slug) => ({ slug }));
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function generateMetadata({ params }: Params): Metadata {
|
| 26 |
+
const symptom = getSymptomBySlug(params.slug);
|
| 27 |
+
if (!symptom) return { title: 'Symptom not found — MedOS' };
|
| 28 |
+
|
| 29 |
+
const ogUrl = `${SITE_URL}/api/og?q=${encodeURIComponent(symptom.headline)}`;
|
| 30 |
+
const canonical = `${SITE_URL}/symptoms/${symptom.slug}`;
|
| 31 |
+
|
| 32 |
+
return {
|
| 33 |
+
title: symptom.title,
|
| 34 |
+
description: symptom.metaDescription,
|
| 35 |
+
alternates: { canonical },
|
| 36 |
+
openGraph: {
|
| 37 |
+
title: symptom.title,
|
| 38 |
+
description: symptom.metaDescription,
|
| 39 |
+
url: canonical,
|
| 40 |
+
siteName: 'MedOS',
|
| 41 |
+
type: 'article',
|
| 42 |
+
images: [{ url: ogUrl, width: 1200, height: 630, alt: symptom.headline }],
|
| 43 |
+
},
|
| 44 |
+
twitter: {
|
| 45 |
+
card: 'summary_large_image',
|
| 46 |
+
title: symptom.title,
|
| 47 |
+
description: symptom.metaDescription,
|
| 48 |
+
images: [ogUrl],
|
| 49 |
+
},
|
| 50 |
+
};
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export default function SymptomPage({ params }: Params) {
|
| 54 |
+
const symptom = getSymptomBySlug(params.slug);
|
| 55 |
+
if (!symptom) return notFound();
|
| 56 |
+
|
| 57 |
+
// Per-page FAQPage JSON-LD so Google can mine each entry as a rich
|
| 58 |
+
// snippet independently from the root layout's global FAQPage.
|
| 59 |
+
const faqJsonLd = {
|
| 60 |
+
'@context': 'https://schema.org',
|
| 61 |
+
'@type': 'FAQPage',
|
| 62 |
+
mainEntity: symptom.faqs.map((f) => ({
|
| 63 |
+
'@type': 'Question',
|
| 64 |
+
name: f.q,
|
| 65 |
+
acceptedAnswer: { '@type': 'Answer', text: f.a },
|
| 66 |
+
})),
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
// MedicalCondition JSON-LD — helps Google classify the page for the
|
| 70 |
+
// Health Knowledge Graph.
|
| 71 |
+
const medicalConditionJsonLd = {
|
| 72 |
+
'@context': 'https://schema.org',
|
| 73 |
+
'@type': 'MedicalCondition',
|
| 74 |
+
name: symptom.headline,
|
| 75 |
+
description: symptom.summary,
|
| 76 |
+
signOrSymptom: symptom.redFlags.map((r) => ({
|
| 77 |
+
'@type': 'MedicalSymptom',
|
| 78 |
+
name: r,
|
| 79 |
+
})),
|
| 80 |
+
possibleTreatment: symptom.selfCare.map((s) => ({
|
| 81 |
+
'@type': 'MedicalTherapy',
|
| 82 |
+
name: s,
|
| 83 |
+
})),
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const chatDeepLink = `/?q=${encodeURIComponent(
|
| 87 |
+
`I want to ask about ${symptom.headline.toLowerCase()}`,
|
| 88 |
+
)}`;
|
| 89 |
+
|
| 90 |
+
return (
|
| 91 |
+
<main className="min-h-screen bg-slate-900 text-slate-100">
|
| 92 |
+
<script
|
| 93 |
+
type="application/ld+json"
|
| 94 |
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
| 95 |
+
/>
|
| 96 |
+
<script
|
| 97 |
+
type="application/ld+json"
|
| 98 |
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(medicalConditionJsonLd) }}
|
| 99 |
+
/>
|
| 100 |
+
|
| 101 |
+
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
|
| 102 |
+
{/* Back link */}
|
| 103 |
+
<Link
|
| 104 |
+
href="/symptoms"
|
| 105 |
+
className="inline-flex items-center gap-1 text-sm text-slate-400 hover:text-teal-300 transition-colors mb-6"
|
| 106 |
+
>
|
| 107 |
+
<ChevronLeft size={16} />
|
| 108 |
+
All symptoms
|
| 109 |
+
</Link>
|
| 110 |
+
|
| 111 |
+
{/* Hero */}
|
| 112 |
+
<header className="mb-8">
|
| 113 |
+
<p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
|
| 114 |
+
Symptom guide
|
| 115 |
+
</p>
|
| 116 |
+
<h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-4">
|
| 117 |
+
{symptom.headline}
|
| 118 |
+
</h1>
|
| 119 |
+
<p className="text-lg text-slate-300 leading-relaxed">
|
| 120 |
+
{symptom.summary}
|
| 121 |
+
</p>
|
| 122 |
+
<div className="mt-6 inline-flex items-center gap-1.5 text-xs text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1.5 rounded-full font-semibold">
|
| 123 |
+
<ShieldCheck size={12} />
|
| 124 |
+
Aligned with WHO · CDC · NHS guidance
|
| 125 |
+
</div>
|
| 126 |
+
</header>
|
| 127 |
+
|
| 128 |
+
{/* Red flags — first, highest visual priority */}
|
| 129 |
+
<Section title="When to seek emergency care" danger>
|
| 130 |
+
<ul className="space-y-2">
|
| 131 |
+
{symptom.redFlags.map((r) => (
|
| 132 |
+
<li key={r} className="flex items-start gap-2 text-slate-200">
|
| 133 |
+
<AlertTriangle
|
| 134 |
+
size={16}
|
| 135 |
+
className="flex-shrink-0 text-red-400 mt-0.5"
|
| 136 |
+
/>
|
| 137 |
+
<span>{r}</span>
|
| 138 |
+
</li>
|
| 139 |
+
))}
|
| 140 |
+
</ul>
|
| 141 |
+
</Section>
|
| 142 |
+
|
| 143 |
+
<Section title="Safe self-care at home">
|
| 144 |
+
<ul className="space-y-2">
|
| 145 |
+
{symptom.selfCare.map((s) => (
|
| 146 |
+
<li key={s} className="flex items-start gap-2 text-slate-200">
|
| 147 |
+
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-teal-400 flex-shrink-0" />
|
| 148 |
+
<span>{s}</span>
|
| 149 |
+
</li>
|
| 150 |
+
))}
|
| 151 |
+
</ul>
|
| 152 |
+
</Section>
|
| 153 |
+
|
| 154 |
+
<Section title="When to see a clinician">
|
| 155 |
+
<ul className="space-y-2">
|
| 156 |
+
{symptom.whenToSeekCare.map((w) => (
|
| 157 |
+
<li key={w} className="flex items-start gap-2 text-slate-200">
|
| 158 |
+
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-blue-400 flex-shrink-0" />
|
| 159 |
+
<span>{w}</span>
|
| 160 |
+
</li>
|
| 161 |
+
))}
|
| 162 |
+
</ul>
|
| 163 |
+
</Section>
|
| 164 |
+
|
| 165 |
+
{/* FAQ — also rendered for humans, not just search engines */}
|
| 166 |
+
<Section title="Frequently asked questions">
|
| 167 |
+
<div className="space-y-5">
|
| 168 |
+
{symptom.faqs.map((f) => (
|
| 169 |
+
<div key={f.q}>
|
| 170 |
+
<h3 className="font-bold text-slate-100 mb-1">{f.q}</h3>
|
| 171 |
+
<p className="text-slate-300 leading-relaxed">{f.a}</p>
|
| 172 |
+
</div>
|
| 173 |
+
))}
|
| 174 |
+
</div>
|
| 175 |
+
</Section>
|
| 176 |
+
|
| 177 |
+
{/* Primary CTA into the live chatbot */}
|
| 178 |
+
<div className="mt-10 rounded-2xl border border-teal-500/30 bg-gradient-to-br from-blue-900/40 to-teal-900/30 p-6">
|
| 179 |
+
<p className="text-xs font-bold uppercase tracking-wider text-teal-400 mb-2">
|
| 180 |
+
Ask the live assistant
|
| 181 |
+
</p>
|
| 182 |
+
<h2 className="text-2xl font-bold text-slate-50 mb-2 tracking-tight">
|
| 183 |
+
Get a personalized answer in your language.
|
| 184 |
+
</h2>
|
| 185 |
+
<p className="text-slate-300 mb-4 leading-relaxed">
|
| 186 |
+
MedOS is free, private, and takes no account. Describe your
|
| 187 |
+
situation and get step-by-step guidance.
|
| 188 |
+
</p>
|
| 189 |
+
<Link
|
| 190 |
+
href={chatDeepLink}
|
| 191 |
+
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
|
| 192 |
+
>
|
| 193 |
+
Ask about {symptom.headline.toLowerCase()}
|
| 194 |
+
<ArrowRight size={18} />
|
| 195 |
+
</Link>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<p className="mt-8 text-xs text-slate-500 leading-relaxed">
|
| 199 |
+
This page is general patient education aligned with WHO, CDC, and
|
| 200 |
+
NHS public guidance. It is not a diagnosis, prescription, or
|
| 201 |
+
substitute for care from a licensed clinician. If symptoms are
|
| 202 |
+
severe, worsening, or you are in doubt, contact a healthcare
|
| 203 |
+
provider or your local emergency number immediately.
|
| 204 |
+
</p>
|
| 205 |
+
</div>
|
| 206 |
+
</main>
|
| 207 |
+
);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function Section({
|
| 211 |
+
title,
|
| 212 |
+
danger,
|
| 213 |
+
children,
|
| 214 |
+
}: {
|
| 215 |
+
title: string;
|
| 216 |
+
danger?: boolean;
|
| 217 |
+
children: React.ReactNode;
|
| 218 |
+
}) {
|
| 219 |
+
return (
|
| 220 |
+
<section
|
| 221 |
+
className={`mt-8 rounded-2xl border p-5 ${
|
| 222 |
+
danger
|
| 223 |
+
? 'border-red-500/40 bg-red-950/30'
|
| 224 |
+
: 'border-slate-700/60 bg-slate-800/40'
|
| 225 |
+
}`}
|
| 226 |
+
>
|
| 227 |
+
<h2
|
| 228 |
+
className={`text-lg font-bold mb-3 tracking-tight ${
|
| 229 |
+
danger ? 'text-red-300' : 'text-slate-100'
|
| 230 |
+
}`}
|
| 231 |
+
>
|
| 232 |
+
{title}
|
| 233 |
+
</h2>
|
| 234 |
+
{children}
|
| 235 |
+
</section>
|
| 236 |
+
);
|
| 237 |
+
}
|
app/symptoms/page.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from 'next';
|
| 2 |
+
import Link from 'next/link';
|
| 3 |
+
import { ChevronRight, ShieldCheck } from 'lucide-react';
|
| 4 |
+
import { SYMPTOMS } from '@/lib/symptoms';
|
| 5 |
+
|
| 6 |
+
const SITE_URL = 'https://ruslanmv-medibot.hf.space';
|
| 7 |
+
|
| 8 |
+
export const metadata: Metadata = {
|
| 9 |
+
title: 'Symptom guides — free, WHO-aligned | MedOS',
|
| 10 |
+
description:
|
| 11 |
+
'Browse evidence-based symptom guides: causes, safe self-care, red flags, and when to seek care. Free, multilingual, and aligned with WHO, CDC, and NHS.',
|
| 12 |
+
alternates: { canonical: `${SITE_URL}/symptoms` },
|
| 13 |
+
openGraph: {
|
| 14 |
+
title: 'Symptom guides — free, WHO-aligned | MedOS',
|
| 15 |
+
description:
|
| 16 |
+
'Browse evidence-based symptom guides: causes, safe self-care, red flags, and when to seek care.',
|
| 17 |
+
url: `${SITE_URL}/symptoms`,
|
| 18 |
+
images: [`${SITE_URL}/api/og?q=${encodeURIComponent('Symptom guides')}`],
|
| 19 |
+
},
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Symptom catalog index. Static, cacheable, zero JS-on-load cost.
|
| 24 |
+
* Works as a landing hub from organic search queries like
|
| 25 |
+
* "medos symptoms" or "symptom checker".
|
| 26 |
+
*/
|
| 27 |
+
export default function SymptomsIndexPage() {
|
| 28 |
+
return (
|
| 29 |
+
<main className="min-h-screen bg-slate-900 text-slate-100">
|
| 30 |
+
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
|
| 31 |
+
<header className="mb-8 text-center">
|
| 32 |
+
<p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
|
| 33 |
+
Free patient guides
|
| 34 |
+
</p>
|
| 35 |
+
<h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-3">
|
| 36 |
+
Symptom guides
|
| 37 |
+
</h1>
|
| 38 |
+
<p className="text-lg text-slate-300 max-w-xl mx-auto leading-relaxed">
|
| 39 |
+
Clear, trustworthy answers to the most common health
|
| 40 |
+
questions. Free, multilingual, and aligned with WHO, CDC,
|
| 41 |
+
and NHS guidance.
|
| 42 |
+
</p>
|
| 43 |
+
<div className="mt-5 inline-flex items-center gap-1.5 text-xs text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1.5 rounded-full font-semibold">
|
| 44 |
+
<ShieldCheck size={12} />
|
| 45 |
+
Reviewed against WHO · CDC · NHS public guidance
|
| 46 |
+
</div>
|
| 47 |
+
</header>
|
| 48 |
+
|
| 49 |
+
<div className="grid sm:grid-cols-2 gap-3">
|
| 50 |
+
{SYMPTOMS.map((s) => (
|
| 51 |
+
<Link
|
| 52 |
+
key={s.slug}
|
| 53 |
+
href={`/symptoms/${s.slug}`}
|
| 54 |
+
className="group p-5 rounded-2xl border border-slate-700/60 bg-slate-800/60 hover:border-teal-500/50 hover:bg-teal-500/5 transition-all"
|
| 55 |
+
>
|
| 56 |
+
<div className="flex items-start justify-between gap-3">
|
| 57 |
+
<div className="flex-1 min-w-0">
|
| 58 |
+
<h2 className="font-bold text-slate-100 text-lg mb-1 tracking-tight">
|
| 59 |
+
{s.headline}
|
| 60 |
+
</h2>
|
| 61 |
+
<p className="text-sm text-slate-400 leading-relaxed line-clamp-2">
|
| 62 |
+
{s.summary}
|
| 63 |
+
</p>
|
| 64 |
+
</div>
|
| 65 |
+
<ChevronRight
|
| 66 |
+
size={18}
|
| 67 |
+
className="flex-shrink-0 text-slate-500 group-hover:text-teal-400 transition-colors mt-1"
|
| 68 |
+
/>
|
| 69 |
+
</div>
|
| 70 |
+
</Link>
|
| 71 |
+
))}
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div className="mt-10 text-center">
|
| 75 |
+
<Link
|
| 76 |
+
href="/"
|
| 77 |
+
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
|
| 78 |
+
>
|
| 79 |
+
Open the live MedOS assistant
|
| 80 |
+
<ChevronRight size={18} />
|
| 81 |
+
</Link>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
</main>
|
| 85 |
+
);
|
| 86 |
+
}
|