can you code to make ai app for doctors online consultation ?
ls -la /home/daytona/workspace/
mkdir -p /home/daytona/workspace/medconsult-ai/{src/{app/{api/{auth,consultations,messages,appointments,ai},dashboard,consultation,symptom-checker,appointments,profile,admin},components/{ui,chat,forms,layout},lib,hooks,types},prisma,public}
~/medconsult-ai/package.json
{
"name": "medconsult-ai",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@ai-sdk/openai": "^0.0.36",
"@auth/prisma-adapter": "^2.4.1",
"@prisma/client": "^5.14.0",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-badge": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"ai": "^3.2.22",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.390.0",
"next": "14.2.4",
"next-auth": "^5.0.0-beta.19",
"openai": "^4.52.1",
"pusher": "^5.2.0",
"pusher-js": "^8.4.0-rc2",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.52.1",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8",
"@hookform/resolvers": "^3.6.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.4",
"postcss": "^8",
"prisma": "^5.14.0",
"tailwindcss": "^3.4.1",
"tsx": "^4.15.7",
"typescript": "^5"
}
}
~/medconsult-ai/prisma/schema.prisma
// This is your Prisma schema file.
// Learn more at: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
PATIENT
DOCTOR
ADMIN
}
enum ConsultationStatus {
PENDING
ACTIVE
COMPLETED
CANCELLED
}
enum AppointmentStatus {
SCHEDULED
CONFIRMED
COMPLETED
CANCELLED
NO_SHOW
}
enum Severity {
LOW
MODERATE
HIGH
EMERGENCY
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
password String?
image String?
role Role @default(PATIENT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
accounts Account[]
sessions Session[]
patientProfile PatientProfile?
doctorProfile DoctorProfile?
sentMessages Message[] @relation("SentMessages")
consultationsAsPatient Consultation[] @relation("PatientConsultations")
consultationsAsDoctor Consultation[] @relation("DoctorConsultations")
appointmentsAsPatient Appointment[] @relation("PatientAppointments")
appointmentsAsDoctor Appointment[] @relation("DoctorAppointments")
symptomChecks SymptomCheck[]
notifications Notification[]
}
model Account {
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
}
model Session {
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerificationToken {
identifier String
token String
expires DateTime
@@id([identifier, token])
}
model PatientProfile {
id String @id @default(cuid())
userId String @unique
dateOfBirth DateTime?
gender String?
bloodType String?
allergies String[]
medications String[]
conditions String[]
phone String?
address String?
emergencyContact String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model DoctorProfile {
id String @id @default(cuid())
userId String @unique
specialty String
licenseNumber String @unique
hospital String?
bio String?
experience Int? // years
education String[]
languages String[]
consultationFee Float @default(0)
rating Float @default(0)
reviewCount Int @default(0)
isAvailable Boolean @default(true)
availableFrom String? // "09:00"
availableTo String? // "17:00"
workingDays String[] // ["Monday", "Tuesday", ...]
phone String?
verified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Consultation {
id String @id @default(cuid())
patientId String
doctorId String
status ConsultationStatus @default(PENDING)
chief String // chief complaint
notes String?
diagnosis String?
prescription String?
aiSummary String?
startedAt DateTime?
endedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
patient User @relation("PatientConsultations", fields: [patientId], references: [id])
doctor User @relation("DoctorConsultations", fields: [doctorId], references: [id])
messages Message[]
appointment Appointment?
}
model Message {
id String @id @default(cuid())
consultationId String
senderId String
content String
isAI Boolean @default(false)
createdAt DateTime @default(now())
consultation Consultation @relation(fields: [consultationId], references: [id], onDelete: Cascade)
sender User @relation("SentMessages", fields: [senderId], references: [id])
}
model Appointment {
id String @id @default(cuid())
patientId String
doctorId String
consultationId String? @unique
scheduledAt DateTime
duration Int @default(30) // minutes
status AppointmentStatus @default(SCHEDULED)
reason String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
patient User @relation("PatientAppointments", fields: [patientId], references: [id])
doctor User @relation("DoctorAppointments", fields: [doctorId], references: [id])
consultation Consultation? @relation(fields: [consultationId], references: [id])
}
model SymptomCheck {
id String @id @default(cuid())
userId String
symptoms String[]
duration String?
severity Severity
aiAnalysis String
triage String
recommended String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Notification {
id String @id @default(cuid())
userId String
title String
body String
read Boolean @default(false)
link String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
~/medconsult-ai/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: {
allowedOrigins: ["localhost:3000"],
},
},
images: {
remotePatterns: [
{ protocol: "https", hostname: "avatars.githubusercontent.com" },
{ protocol: "https", hostname: "lh3.googleusercontent.com" },
],
},
};
export default nextConfig;
~/medconsult-ai/.env.example
# MedConsult AI — Environment Variables # Copy this file to .env.local and fill in values. # ── Database ───────────────────────────────────────────────────────────────── DATABASE_URL="postgresql://postgres:password@localhost:5432/medconsult" # ── NextAuth ────────────────────────────────────────────────────────────────── NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="your-super-secret-nextauth-secret-change-in-production" # ── OAuth Providers (optional) ──────────────────────────────────────────────── GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" # ── OpenAI ──────────────────────────────────────────────────────────────────── OPENAI_API_KEY="sk-..." # ── Pusher (real-time chat) ─────────────────────────────────────────────────── PUSHER_APP_ID="" PUSHER_KEY="" PUSHER_SECRET="" PUSHER_CLUSTER="us2" NEXT_PUBLIC_PUSHER_KEY="" NEXT_PUBLIC_PUSHER_CLUSTER="us2"
~/medconsult-ai/tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
medical: {
50: "#eff6ff",
100: "#dbeafe",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
pulse: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: ".5" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
},
},
},
plugins: [require("tailwindcss-animate")],
};
~/medconsult-ai/tsconfig.json
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
~/medconsult-ai/src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
~/medconsult-ai/src/lib/auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
pages: {
signIn: "/login",
error: "/login",
},
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) return null;
const passwordMatch = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!passwordMatch) return null;
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = (user as any).role;
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
});
~/medconsult-ai/src/lib/openai.ts
import OpenAI from "openai";
export const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// ── System prompts ────────────────────────────────────────────────────────────
export const SYMPTOM_CHECKER_SYSTEM = `You are MedAI, a clinical triage assistant.
Analyze the patient's symptoms and provide:
1. A brief differential diagnosis (top 3 conditions, most likely first)
2. Triage level: LOW / MODERATE / HIGH / EMERGENCY
3. Recommended next steps (see a doctor, go to ER, home care, etc.)
4. Warning signs to watch for
Important disclaimers:
- You are NOT replacing a licensed physician.
- Always recommend professional medical evaluation for serious concerns.
- Output must be structured JSON as defined in the function schema.`;
export const CHAT_ASSISTANT_SYSTEM = `You are MedAI, a medical assistant embedded in a doctor-patient consultation platform.
Your role:
- Help patients understand their symptoms and prepare for their consultation
- Answer general health questions clearly and accurately
- Remind patients this is not a substitute for professional medical advice
- If a patient describes emergency symptoms (chest pain, difficulty breathing, stroke signs), immediately advise them to call emergency services
Keep responses concise, empathetic, and medically accurate.`;
export const CONSULTATION_SUMMARY_SYSTEM = `You are a medical scribe AI.
Read the consultation chat transcript and generate a structured clinical note with:
- Chief Complaint
- History of Present Illness (HPI)
- Key Symptoms Reported
- Provisional Diagnosis (if discussed)
- Plan / Recommendations discussed
- Follow-up instructions
Format as structured markdown. Be clinical but clear.`;
// ── Helper: symptom analysis ───────────────────────────────────────────────────
export async function analyzeSymptoms(symptoms: string[], duration: string, severity: string) {
const prompt = `Patient symptoms: ${symptoms.join(", ")}
Duration: ${duration}
Self-reported severity: ${severity}
Provide a JSON response with:
{
"analysis": "2-3 sentence clinical overview",
"topConditions": ["condition1", "condition2", "condition3"],
"triageLevel": "LOW|MODERATE|HIGH|EMERGENCY",
"recommended": "what the patient should do",
"warningSigns": ["sign1", "sign2"]
}`;
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYMPTOM_CHECKER_SYSTEM },
{ role: "user", content: prompt },
],
response_format: { type: "json_object" },
max_tokens: 600,
});
return JSON.parse(response.choices[0].message.content || "{}");
}
// ── Helper: generate consultation summary ────────────────────────────────────
export async function generateConsultationSummary(transcript: string) {
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: CONSULTATION_SUMMARY_SYSTEM },
{
role: "user",
content: `Generate a clinical summary from this consultation transcript:\n\n${transcript}`,
},
],
max_tokens: 800,
});
return response.choices[0].message.content || "";
}
~/medconsult-ai/src/lib/pusher.ts
import Pusher from "pusher";
import PusherClient from "pusher-js";
// Server-side Pusher instance
export const pusherServer = new Pusher({
appId: process.env.PUSHER_APP_ID!,
key: process.env.PUSHER_KEY!,
secret: process.env.PUSHER_SECRET!,
cluster: process.env.PUSHER_CLUSTER!,
useTLS: true,
});
// Client-side Pusher instance (singleton)
let pusherClientInstance: PusherClient | null = null;
export function getPusherClient(): PusherClient {
if (typeof window === "undefined") {
throw new Error("getPusherClient can only be called on the client side");
}
if (!pusherClientInstance) {
pusherClientInstance = new PusherClient(
process.env.NEXT_PUBLIC_PUSHER_KEY!,
{
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
}
);
}
return pusherClientInstance;
}
export const CHANNELS = {
consultation: (id: string) => `consultation-${id}`,
user: (id: string) => `user-${id}`,
};
export const EVENTS = {
NEW_MESSAGE: "new-message",
CONSULTATION_STARTED: "consultation-started",
CONSULTATION_ENDED: "consultation-ended",
TYPING: "typing",
};
~/medconsult-ai/src/lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function formatDateTime(date: Date | string): string {
return new Date(date).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function formatTime(date: Date | string): string {
return new Date(date).toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
export function getInitials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
export function getSeverityColor(severity: string) {
const colors: Record<string, string> = {
LOW: "text-green-600 bg-green-50 border-green-200",
MODERATE: "text-yellow-600 bg-yellow-50 border-yellow-200",
HIGH: "text-orange-600 bg-orange-50 border-orange-200",
EMERGENCY: "text-red-600 bg-red-50 border-red-200",
};
return colors[severity] ?? "text-gray-600 bg-gray-50 border-gray-200";
}
export function getStatusColor(status: string) {
const colors: Record<string, string> = {
PENDING: "text-yellow-600 bg-yellow-50",
ACTIVE: "text-blue-600 bg-blue-50",
COMPLETED: "text-green-600 bg-green-50",
CANCELLED: "text-gray-600 bg-gray-50",
SCHEDULED: "text-blue-600 bg-blue-50",
CONFIRMED: "text-green-600 bg-green-50",
NO_SHOW: "text-red-600 bg-red-50",
};
return colors[status] ?? "text-gray-600 bg-gray-50";
}
~/medconsult-ai/src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/components/layout/AuthProvider";
import { Toaster } from "@/components/ui/toaster";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "MedConsult AI — Online Doctor Consultation",
description:
"Connect with licensed doctors online. AI-powered symptom checking, real-time consultations, and smart medical summaries.",
keywords: ["telemedicine", "online doctor", "AI health", "medical consultation"],
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</body>
</html>
);
}
~/medconsult-ai/src/app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Medical-themed scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
}
::-webkit-scrollbar-thumb {
background: hsl(var(--primary));
border-radius: 3px;
}
/* Chat bubble animations */
.message-in {
animation: slideInLeft 0.2s ease-out;
}
.message-out {
animation: slideInRight 0.2s ease-out;
}
@keyframes slideInLeft {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(0); }
}
~/medconsult-ai/src/app/page.tsx
import Link from "next/link";
import {
Stethoscope,
Brain,
MessageSquare,
Calendar,
Shield,
Star,
ChevronRight,
Activity,
Clock,
Users,
} from "lucide-react";
export default function HomePage() {
const features = [
{
icon: Brain,
title: "AI Symptom Checker",
desc: "Describe your symptoms and get an instant AI-powered triage and differential diagnosis before seeing a doctor.",
color: "text-purple-600",
bg: "bg-purple-50",
},
{
icon: MessageSquare,
title: "Live Doctor Chat",
desc: "Real-time messaging with licensed physicians. Get answers within minutes, not days.",
color: "text-blue-600",
bg: "bg-blue-50",
},
{
icon: Brain,
title: "AI Consultation Summaries",
desc: "Every consultation is automatically summarized with diagnosis, plan, and follow-up notes.",
color: "text-green-600",
bg: "bg-green-50",
},
{
icon: Calendar,
title: "Appointment Scheduling",
desc: "Book appointments with specialists. View real-time doctor availability.",
color: "text-orange-600",
bg: "bg-orange-50",
},
{
icon: Shield,
title: "Secure & HIPAA-Ready",
desc: "End-to-end encrypted consultations. Your health data stays private.",
color: "text-red-600",
bg: "bg-red-50",
},
{
icon: Activity,
title: "Health History",
desc: "Track all consultations, prescriptions, and diagnoses in one place.",
color: "text-teal-600",
bg: "bg-teal-50",
},
];
const stats = [
{ value: "500+", label: "Licensed Doctors", icon: Stethoscope },
{ value: "50k+", label: "Consultations Done", icon: MessageSquare },
{ value: "< 5 min", label: "Average Response", icon: Clock },
{ value: "98%", label: "Patient Satisfaction", icon: Star },
];
const specialties = [
"General Medicine", "Cardiology", "Dermatology", "Pediatrics",
"Mental Health", "Orthopedics", "Gynecology", "Neurology",
];
return (
<div className="min-h-screen bg-white">
{/* Nav */}
<nav className="fixed top-0 inset-x-0 z-50 bg-white/95 backdrop-blur border-b border-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<Stethoscope className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-gray-900">MedConsult AI</span>
</div>
<div className="hidden md:flex items-center gap-6 text-sm text-gray-600">
<Link href="#features" className="hover:text-blue-600 transition-colors">Features</Link>
<Link href="#specialties" className="hover:text-blue-600 transition-colors">Specialties</Link>
<Link href="#how" className="hover:text-blue-600 transition-colors">How it works</Link>
</div>
<div className="flex items-center gap-3">
<Link
href="/login"
className="px-4 py-2 text-sm text-gray-700 hover:text-blue-600 font-medium"
>
Sign in
</Link>
<Link
href="/register"
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors"
>
Get Started
</Link>
</div>
</div>
</div>
</nav>
{/* Hero */}
<section className="pt-24 pb-16 bg-gradient-to-br from-blue-50 via-white to-indigo-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-4xl mx-auto">
<div className="inline-flex items-center gap-2 px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium mb-6">
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
AI-Powered Telemedicine Platform
</div>
<h1 className="text-5xl sm:text-6xl font-bold text-gray-900 leading-tight mb-6">
See a Doctor <span className="text-blue-600">Online</span>,
<br />Powered by AI
</h1>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
Get instant AI symptom triage, connect with licensed doctors via real-time chat,
and receive AI-generated consultation summaries — all in one secure platform.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/register"
className="flex items-center justify-center gap-2 px-8 py-4 bg-blue-600 text-white rounded-xl font-semibold text-lg hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200"
>
Start Free Consultation <ChevronRight className="w-5 h-5" />
</Link>
<Link
href="/symptom-checker"
className="flex items-center justify-center gap-2 px-8 py-4 bg-white text-gray-900 rounded-xl font-semibold text-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors"
>
<Brain className="w-5 h-5 text-blue-600" /> Check Symptoms
</Link>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mt-16">
{stats.map((s) => (
<div key={s.label} className="bg-white rounded-2xl p-6 text-center shadow-sm border border-gray-100">
<s.icon className="w-6 h-6 text-blue-600 mx-auto mb-2" />
<div className="text-3xl font-bold text-gray-900">{s.value}</div>
<div className="text-sm text-gray-500 mt-1">{s.label}</div>
</div>
))}
</div>
</div>
</section>
{/* Features */}
<section id="features" className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-14">
<h2 className="text-4xl font-bold text-gray-900 mb-4">Everything you need</h2>
<p className="text-xl text-gray-500">A complete telemedicine platform with cutting-edge AI</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{features.map((f) => (
<div key={f.title} className="p-6 rounded-2xl border border-gray-100 hover:border-blue-200 hover:shadow-md transition-all">
<div className={`w-12 h-12 ${f.bg} rounded-xl flex items-center justify-center mb-4`}>
<f.icon className={`w-6 h-6 ${f.color}`} />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{f.title}</h3>
<p className="text-gray-500">{f.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* Specialties */}
<section id="specialties" className="py-20 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-4xl font-bold text-gray-900 mb-4">Medical Specialties</h2>
<p className="text-gray-500 mb-10">Find specialists across all major medical fields</p>
<div className="flex flex-wrap justify-center gap-3">
{specialties.map((s) => (
<span
key={s}
className="px-5 py-2.5 bg-white border border-gray-200 rounded-full text-gray-700 font-medium hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50 cursor-pointer transition-all"
>
{s}
</span>
))}
</div>
</div>
</section>
{/* How it works */}
<section id="how" className="py-20">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-14">
<h2 className="text-4xl font-bold text-gray-900 mb-4">How it works</h2>
<p className="text-gray-500">Three simple steps to get medical care</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{[
{ step: "1", title: "Check Symptoms", desc: "Use our AI symptom checker to get an instant triage and understand your condition before your consultation.", icon: Brain },
{ step: "2", title: "Choose a Doctor", desc: "Browse verified doctors by specialty, availability, and rating. Book instantly or consult right now.", icon: Users },
{ step: "3", title: "Get Treatment", desc: "Chat in real-time with your doctor. Receive AI-summarized notes, prescriptions, and follow-up plans.", icon: Stethoscope },
].map((item) => (
<div key={item.step} className="text-center">
<div className="w-16 h-16 bg-blue-600 text-white rounded-2xl flex items-center justify-center text-2xl font-bold mx-auto mb-4">
{item.step}
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{item.title}</h3>
<p className="text-gray-500">{item.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-20 bg-blue-600">
<div className="max-w-3xl mx-auto px-4 text-center">
<h2 className="text-4xl font-bold text-white mb-4">Ready to see a doctor?</h2>
<p className="text-blue-100 text-xl mb-8">
Join thousands of patients getting quality healthcare online.
</p>
<Link
href="/register"
className="inline-flex items-center gap-2 px-8 py-4 bg-white text-blue-700 rounded-xl font-semibold text-lg hover:bg-blue-50 transition-colors shadow-lg"
>
Create Free Account <ChevronRight className="w-5 h-5" />
</Link>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-gray-400 py-10">
<div className="max-w-7xl mx-auto px-4 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-blue-600 rounded-lg flex items-center justify-center">
<Stethoscope className="w-4 h-4 text-white" />
</div>
<span className="text-white font-semibold">MedConsult AI</span>
</div>
<p className="text-sm">© {new Date().getFullYear()} MedConsult AI. Not a replacement for emergency medical care.</p>
</div>
</footer>
</div>
);
}
~/medconsult-ai/src/app/login/page.tsx
"use client";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Stethoscope, Eye, EyeOff, Loader2 } from "lucide-react";
const schema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type FormData = z.infer<typeof schema>;
export default function LoginPage() {
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) });
async function onSubmit(data: FormData) {
setLoading(true);
setError("");
const res = await signIn("credentials", {
email: data.email,
password: data.password,
redirect: false,
});
setLoading(false);
if (res?.error) {
setError("Invalid email or password");
} else {
router.push("/dashboard");
router.refresh();
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-14 h-14 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Stethoscope className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
<p className="text-gray-500 mt-1">Sign in to your MedConsult AI account</p>
</div>
<div className="bg-white rounded-2xl shadow-xl p-8">
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Google SSO */}
<button
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors mb-6 font-medium text-gray-700"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Continue with Google
</button>
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-400">or sign in with email</span>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
{...register("email")}
type="email"
placeholder="you@example.com"
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<div className="relative">
<input
{...register("password")}
type={showPassword ? "text" : "password"}
placeholder="••••••••"
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>}
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-blue-600 text-white rounded-xl font-semibold hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
Sign In
</button>
</form>
<p className="text-center text-sm text-gray-500 mt-6">
Don't have an account?{" "}
<Link href="/register" className="text-blue-600 hover:underline font-medium">
Sign up free
</Link>
</p>
</div>
</div>
</div>
);
}
~/medconsult-ai/src/app/register/page.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Stethoscope, Loader2 } from "lucide-react";
const schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
role: z.enum(["PATIENT", "DOCTOR"]),
});
type FormData = z.infer<typeof schema>;
export default function RegisterPage() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { role: "PATIENT" },
});
const role = watch("role");
async function onSubmit(data: FormData) {
setLoading(true);
setError("");
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const body = await res.json();
if (!res.ok) {
setError(body.error || "Registration failed");
} else {
router.push("/login?registered=1");
}
} catch {
setError("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-14 h-14 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Stethoscope className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900">Create your account</h1>
<p className="text-gray-500 mt-1">Join MedConsult AI today</p>
</div>
<div className="bg-white rounded-2xl shadow-xl p-8">
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Role selector */}
<div className="flex rounded-xl border border-gray-200 p-1 mb-6">
{(["PATIENT", "DOCTOR"] as const).map((r) => (
<label
key={r}
className={`flex-1 text-center py-2.5 rounded-lg text-sm font-medium cursor-pointer transition-all ${
role === r
? "bg-blue-600 text-white shadow"
: "text-gray-500 hover:text-gray-700"
}`}
>
<input {...register("role")} type="radio" value={r} className="sr-only" />
{r === "PATIENT" ? "I'm a Patient" : "I'm a Doctor"}
</label>
))}
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
<input
{...register("name")}
type="text"
placeholder="Dr. Jane Smith"
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
{...register("email")}
type="email"
placeholder="you@example.com"
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
{...register("password")}
type="password"
placeholder="••••••••"
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>}
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-blue-600 text-white rounded-xl font-semibold hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
Create Account
</button>
</form>
<p className="text-center text-sm text-gray-500 mt-6">
Already have an account?{" "}
<Link href="/login" className="text-blue-600 hover:underline font-medium">
Sign in
</Link>
</p>
</div>
</div>
</div>
);
}
cat > /home/daytona/workspace/medconsult-ai/src/app/api/auth/register/route.ts << 'ENDOFFILE' import { NextRequest, NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { prisma } from "@/lib/prisma"; import { z } from "zod"; const schema = z.object({ name: z.string().min(2), email: z.string().email(), password: z.string().min(8), role: z.enum(["PATIENT", "DOCTOR"]), }); export async function POST(req: NextRequest) { try { const body = await req.json(); const { name, email, password, role } = schema.parse(body); const exists = await prisma.user.findUnique({ where: { email } }); if (exists) { return NextResponse.json({ error: "Email already in use" }, { status: 409 }); } const hashed = await bcrypt.hash(password, 12); const user = await prisma.user.create({ data: { name, email, password: hashed, role, }, }); // Create the matching profile if (role === "PATIENT") { await prisma.patientProfile.create({ data: { userId: user.id } }); } else if (role === "DOCTOR") { await prisma.doctorProfile.create({ data: { userId: user.id, specialty: "General Medicine", licenseNumber: `TEMP-${user.id.slice(0, 8).toUpperCase()}`, }, }); } return NextResponse.json({ success: true }, { status: 201 }); } catch (err: any) { if (err instanceof z.ZodError) { return NextResponse.json({ error: err.errors[0].message }, { status: 400 }); } console.error(err); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } ENDOFFILE echo "Done register route"
continue
mkdir -p /home/daytona/workspace/medconsult-ai/src/app/api/auth/register \ /home/daytona/workspace/medconsult-ai/src/app/api/auth/\[...nextauth\] \ /home/daytona/workspace/medconsult-ai/src/app/api/consultations/\[id\] \ /home/daytona/workspace/medconsult-ai/src/app/api/messages \ /home/daytona/workspace/medconsult-ai/src/app/api/appointments \ /home/daytona/workspace/medconsult-ai/src/app/api/ai/symptom-check \ /home/daytona/workspace/medconsult-ai/src/app/api/ai/chat \ /home/daytona/workspace/medconsult-ai/src/app/api/ai/summarize \ /home/daytona/workspace/medconsult-ai/src/app/api/doctors \ /home/daytona/workspace/medconsult-ai/src/app/dashboard \ /home/daytona/workspace/medconsult-ai/src/app/symptom-checker \ /home/daytona/workspace/medconsult-ai/src/app/consultation/\[id\] \ /home/daytona/workspace/medconsult-ai/src/app/appointments \ /home/daytona/workspace/medconsult-ai/src/app/doctors \ /home/daytona/workspace/medconsult-ai/src/app/profile \ /home/daytona/workspace/medconsult-ai/src/components/layout \ /home/daytona/workspace/medconsult-ai/src/components/chat \ /home/daytona/workspace/medconsult-ai/src/components/forms \ /home/daytona/workspace/medconsult-ai/src/components/ui \ /home/daytona/workspace/medconsult-ai/src/types \ /home/daytona/workspace/medconsult-ai/src/hooks \ /home/daytona/workspace/medconsult-ai/prisma echo "Directories created"
~/medconsult-ai/src/app/api/auth/register/route.ts
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(["PATIENT", "DOCTOR"]),
});
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { name, email, password, role } = schema.parse(body);
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) {
return NextResponse.json({ error: "Email already in use" }, { status: 409 });
}
const hashed = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { name, email, password: hashed, role },
});
if (role === "PATIENT") {
await prisma.patientProfile.create({ data: { userId: user.id } });
} else {
await prisma.doctorProfile.create({
data: {
userId: user.id,
specialty: "General Medicine",
licenseNumber: `TEMP-${user.id.slice(0, 8).toUpperCase()}`,
},
});
}
return NextResponse.json({ success: true }, { status: 201 });
} catch (err: any) {
if (err instanceof z.ZodError) {
return NextResponse.json({ error: err.errors[0].message }, { status: 400 });
}
console.error(err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
~/medconsult-ai/src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
~/medconsult-ai/src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const { nextUrl, auth: session } = req as any;
const isLoggedIn = !!session?.user;
const protectedPaths = ["/dashboard", "/consultation", "/appointments", "/profile", "/doctors"];
const isProtected = protectedPaths.some((p) => nextUrl.pathname.startsWith(p));
const isAuthPage = ["/login", "/register"].includes(nextUrl.pathname);
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", nextUrl));
}
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
~/medconsult-ai/src/components/layout/AuthProvider.tsx
"use client";
import { SessionProvider } from "next-auth/react";
export function AuthProvider({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
~/medconsult-ai/src/components/layout/Sidebar.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession, signOut } from "next-auth/react";
import {
LayoutDashboard,
MessageSquare,
Calendar,
Brain,
Stethoscope,
Users,
Settings,
LogOut,
Bell,
ChevronDown,
} from "lucide-react";
import { getInitials, cn } from "@/lib/utils";
const patientNav = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Symptom Checker", href: "/symptom-checker", icon: Brain },
{ label: "Find Doctors", href: "/doctors", icon: Stethoscope },
{ label: "My Appointments", href: "/appointments", icon: Calendar },
{ label: "Consultations", href: "/consultation", icon: MessageSquare },
{ label: "Profile", href: "/profile", icon: Settings },
];
const doctorNav = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "My Patients", href: "/doctors/patients", icon: Users },
{ label: "Appointments", href: "/appointments", icon: Calendar },
{ label: "Consultations", href: "/consultation", icon: MessageSquare },
{ label: "Profile", href: "/profile", icon: Settings },
];
export function Sidebar() {
const { data: session } = useSession();
const pathname = usePathname();
const isDoctor = session?.user?.role === "DOCTOR";
const nav = isDoctor ? doctorNav : patientNav;
return (
<aside className="hidden md:flex flex-col w-64 bg-white border-r border-gray-100 min-h-screen">
{/* Logo */}
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-100">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<Stethoscope className="w-5 h-5 text-white" />
</div>
<span className="font-bold text-gray-900">MedConsult AI</span>
</div>
{/* Nav */}
<nav className="flex-1 px-3 py-4 space-y-1">
{nav.map((item) => {
const active = pathname === item.href || pathname.startsWith(item.href + "/");
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all",
active
? "bg-blue-600 text-white shadow-sm"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
)}
>
<item.icon className="w-4 h-4" />
{item.label}
</Link>
);
})}
</nav>
{/* User */}
<div className="px-3 py-4 border-t border-gray-100">
<div className="flex items-center gap-3 px-3 py-2 rounded-xl hover:bg-gray-50">
<div className="w-8 h-8 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-sm font-bold">
{getInitials(session?.user?.name || "U")}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{session?.user?.name}</p>
<p className="text-xs text-gray-400 capitalize">{session?.user?.role?.toLowerCase()}</p>
</div>
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="text-gray-400 hover:text-red-500 transition-colors"
title="Sign out"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</aside>
);
}
export function Topbar({ title }: { title: string }) {
const { data: session } = useSession();
return (
<header className="flex items-center justify-between px-6 py-4 bg-white border-b border-gray-100">
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
<div className="flex items-center gap-3">
<button className="relative p-2 rounded-xl hover:bg-gray-100 text-gray-500">
<Bell className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
</button>
<div className="flex items-center gap-2 px-3 py-2 rounded-xl hover:bg-gray-100 cursor-pointer">
<div className="w-7 h-7 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold">
{getInitials(session?.user?.name || "U")}
</div>
<span className="text-sm font-medium text-gray-700 hidden sm:block">{session?.user?.name}</span>
<ChevronDown className="w-4 h-4 text-gray-400" />
</div>
</div>
</header>
);
}
~/medconsult-ai/src/app/dashboard/layout.tsx
import { Sidebar, Topbar } from "@/components/layout/Sidebar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen bg-gray-50 overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
</div>
);
}
~/medconsult-ai/src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import Link from "next/link";
import {
MessageSquare, Calendar, Brain, Clock, CheckCircle,
AlertCircle, TrendingUp, Stethoscope, Users, Star,
} from "lucide-react";
import { formatDate, getStatusColor } from "@/lib/utils";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) redirect("/login");
const isDoctor = session.user.role === "DOCTOR";
if (isDoctor) {
// Doctor dashboard
const doctor = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
doctorProfile: true,
consultationsAsDoctor: {
where: { status: { in: ["PENDING", "ACTIVE"] } },
include: { patient: { select: { name: true, email: true } } },
orderBy: { createdAt: "desc" },
take: 5,
},
appointmentsAsDoctor: {
where: { status: { in: ["SCHEDULED", "CONFIRMED"] } },
include: { patient: { select: { name: true } } },
orderBy: { scheduledAt: "asc" },
take: 5,
},
},
});
const totalConsultations = await prisma.consultation.count({
where: { doctorId: session.user.id, status: "COMPLETED" },
});
const totalPatients = await prisma.consultation.groupBy({
by: ["patientId"],
where: { doctorId: session.user.id },
});
return (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">
Good morning, Dr. {session.user.name?.split(" ")[0]} 👋
</h1>
<p className="text-gray-500 mt-1">Here's your practice overview</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Total Consultations", value: totalConsultations, icon: MessageSquare, color: "text-blue-600 bg-blue-50" },
{ label: "Total Patients", value: totalPatients.length, icon: Users, color: "text-green-600 bg-green-50" },
{ label: "Pending Requests", value: doctor?.consultationsAsDoctor.filter(c => c.status === "PENDING").length ?? 0, icon: Clock, color: "text-orange-600 bg-orange-50" },
{ label: "Rating", value: `${doctor?.doctorProfile?.rating?.toFixed(1) ?? "N/A"}★`, icon: Star, color: "text-yellow-600 bg-yellow-50" },
].map((s) => (
<div key={s.label} className="bg-white rounded-2xl p-5 border border-gray-100">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center mb-3 ${s.color}`}>
<s.icon className="w-5 h-5" />
</div>
<p className="text-2xl font-bold text-gray-900">{s.value}</p>
<p className="text-sm text-gray-500 mt-0.5">{s.label}</p>
</div>
))}
</div>
<div className="grid lg:grid-cols-2 gap-6">
{/* Pending consultations */}
<div className="bg-white rounded-2xl border border-gray-100 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Pending Consultations</h2>
<Link href="/consultation" className="text-sm text-blue-600 hover:underline">View all</Link>
</div>
{doctor?.consultationsAsDoctor.length === 0 ? (
<p className="text-gray-400 text-sm text-center py-6">No pending consultations</p>
) : (
<div className="space-y-3">
{doctor?.consultationsAsDoctor.map((c) => (
<Link
key={c.id}
href={`/consultation/${c.id}`}
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors border border-gray-100"
>
<div className="w-9 h-9 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-sm font-bold">
{c.patient.name?.[0] ?? "P"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{c.patient.name}</p>
<p className="text-xs text-gray-500 truncate">{c.chief}</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${getStatusColor(c.status)}`}>
{c.status}
</span>
</Link>
))}
</div>
)}
</div>
{/* Upcoming appointments */}
<div className="bg-white rounded-2xl border border-gray-100 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Upcoming Appointments</h2>
<Link href="/appointments" className="text-sm text-blue-600 hover:underline">View all</Link>
</div>
{doctor?.appointmentsAsDoctor.length === 0 ? (
<p className="text-gray-400 text-sm text-center py-6">No upcoming appointments</p>
) : (
<div className="space-y-3">
{doctor?.appointmentsAsDoctor.map((a) => (
<div key={a.id} className="flex items-center gap-3 p-3 rounded-xl border border-gray-100">
<Calendar className="w-5 h-5 text-blue-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{a.patient.name}</p>
<p className="text-xs text-gray-500">{formatDate(a.scheduledAt)}</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${getStatusColor(a.status)}`}>
{a.status}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
// ── Patient dashboard ─────────────────────────────────────────────────────
const patient = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
consultationsAsPatient: {
include: { doctor: { select: { name: true, doctorProfile: { select: { specialty: true } } } } },
orderBy: { createdAt: "desc" },
take: 5,
},
appointmentsAsPatient: {
where: { status: { in: ["SCHEDULED", "CONFIRMED"] } },
include: { doctor: { select: { name: true, doctorProfile: { select: { specialty: true } } } } },
orderBy: { scheduledAt: "asc" },
take: 3,
},
symptomChecks: { orderBy: { createdAt: "desc" }, take: 3 },
},
});
return (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">
Good morning, {session.user.name?.split(" ")[0]} 👋
</h1>
<p className="text-gray-500 mt-1">How are you feeling today?</p>
</div>
{/* Quick actions */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: "Check Symptoms", href: "/symptom-checker", icon: Brain, color: "bg-purple-600", desc: "AI triage" },
{ label: "Find a Doctor", href: "/doctors", icon: Stethoscope, color: "bg-blue-600", desc: "Browse specialists" },
{ label: "Book Appointment", href: "/appointments", icon: Calendar, color: "bg-green-600", desc: "Schedule a visit" },
{ label: "My Consultations", href: "/consultation", icon: MessageSquare, color: "bg-orange-500", desc: "View history" },
].map((a) => (
<Link
key={a.label}
href={a.href}
className="bg-white rounded-2xl border border-gray-100 p-5 hover:shadow-md transition-shadow group"
>
<div className={`w-11 h-11 ${a.color} rounded-xl flex items-center justify-center mb-3 group-hover:scale-105 transition-transform`}>
<a.icon className="w-5 h-5 text-white" />
</div>
<p className="font-semibold text-gray-900 text-sm">{a.label}</p>
<p className="text-xs text-gray-400 mt-0.5">{a.desc}</p>
</Link>
))}
</div>
<div className="grid lg:grid-cols-2 gap-6">
{/* Recent consultations */}
<div className="bg-white rounded-2xl border border-gray-100 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Recent Consultations</h2>
<Link href="/consultation" className="text-sm text-blue-600 hover:underline">View all</Link>
</div>
{patient?.consultationsAsPatient.length === 0 ? (
<div className="text-center py-8">
<MessageSquare className="w-10 h-10 text-gray-200 mx-auto mb-2" />
<p className="text-gray-400 text-sm">No consultations yet</p>
<Link href="/doctors" className="text-blue-600 text-sm hover:underline mt-1 block">
Find a doctor to start
</Link>
</div>
) : (
<div className="space-y-3">
{patient?.consultationsAsPatient.map((c) => (
<Link
key={c.id}
href={`/consultation/${c.id}`}
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors border border-gray-100"
>
<div className="w-9 h-9 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-sm font-bold">
{c.doctor.name?.[0] ?? "D"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">Dr. {c.doctor.name}</p>
<p className="text-xs text-gray-500 truncate">{c.chief}</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${getStatusColor(c.status)}`}>
{c.status}
</span>
</Link>
))}
</div>
)}
</div>
{/* Upcoming appointments */}
<div className="bg-white rounded-2xl border border-gray-100 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Upcoming Appointments</h2>
<Link href="/appointments" className="text-sm text-blue-600 hover:underline">View all</Link>
</div>
{patient?.appointmentsAsPatient.length === 0 ? (
<div className="text-center py-8">
<Calendar className="w-10 h-10 text-gray-200 mx-auto mb-2" />
<p className="text-gray-400 text-sm">No upcoming appointments</p>
<Link href="/appointments" className="text-blue-600 text-sm hover:underline mt-1 block">
Book an appointment
</Link>
</div>
) : (
<div className="space-y-3">
{patient?.appointmentsAsPatient.map((a) => (
<div key={a.id} className="flex items-center gap-3 p-3 rounded-xl border border-gray-100">
<Calendar className="w-5 h-5 text-blue-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">Dr. {a.doctor.name}</p>
<p className="text-xs text-gray-500">
{a.doctor.doctorProfile?.specialty} · {formatDate(a.scheduledAt)}
</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Last symptom check */}
{(patient?.symptomChecks ?? []).length > 0 && (
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-2xl border border-purple-100 p-5">
<div className="flex items-center gap-2 mb-3">
<Brain className="w-5 h-5 text-purple-600" />
<h2 className="font-semibold text-gray-900">Last Symptom Check</h2>
</div>
<p className="text-sm text-gray-600">{patient?.symptomChecks[0].aiAnalysis}</p>
<Link href="/symptom-checker" className="text-purple-600 text-sm font-medium hover:underline mt-2 block">
Run a new check →
</Link>
</div>
)}
</div>
);
}
~/medconsult-ai/src/app/symptom-checker/page.tsx
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { Brain, Plus, X, Loader2, AlertTriangle, CheckCircle, AlertCircle, Zap } from "lucide-react";
import { getSeverityColor } from "@/lib/utils";
import { Sidebar } from "@/components/layout/Sidebar";
interface AnalysisResult {
analysis: string;
topConditions: string[];
triageLevel: "LOW" | "MODERATE" | "HIGH" | "EMERGENCY";
recommended: string;
warningSigns: string[];
}
const DURATION_OPTIONS = ["< 1 day", "1–3 days", "4–7 days", "1–2 weeks", "> 2 weeks"];
const SEVERITY_OPTIONS = ["Mild", "Moderate", "Severe", "Very severe"];
const TRIAGE_META: Record<string, { icon: React.ElementType; label: string; desc: string }> = {
LOW: { icon: CheckCircle, label: "Low Priority", desc: "Home care may be sufficient. Monitor your symptoms." },
MODERATE: { icon: AlertCircle, label: "Moderate Priority", desc: "Schedule an appointment with a doctor within 1–2 days." },
HIGH: { icon: AlertTriangle, label: "High Priority", desc: "See a doctor today or visit an urgent care clinic." },
EMERGENCY: { icon: Zap, label: "Emergency", desc: "Call 911 or go to the nearest emergency room immediately." },
};
export default function SymptomCheckerPage() {
const { data: session } = useSession();
const [symptoms, setSymptoms] = useState<string[]>([]);
const [input, setInput] = useState("");
const [duration, setDuration] = useState(DURATION_OPTIONS[1]);
const [severity, setSeverity] = useState(SEVERITY_OPTIONS[1]);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(null);
const [error, setError] = useState("");
function addSymptom() {
const s = input.trim();
if (s && !symptoms.includes(s)) setSymptoms((p) => [...p, s]);
setInput("");
}
function removeSymptom(s: string) {
setSymptoms((p) => p.filter((x) => x !== s));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (symptoms.length === 0) {
setError("Please add at least one symptom.");
return;
}
setLoading(true);
setError("");
setResult(null);
try {
const res = await fetch("/api/ai/symptom-check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ symptoms, duration, severity }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed");
setResult(data);
} catch (err: any) {
setError(err.message || "Something went wrong. Please try again.");
} finally {
setLoading(false);
}
}
const triageMeta = result ? TRIAGE_META[result.triageLevel] : null;
return (
<div className="flex h-screen bg-gray-50 overflow-hidden">
<Sidebar />
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-purple-100 rounded-xl flex items-center justify-center">
<Brain className="w-5 h-5 text-purple-600" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">AI Symptom Checker</h1>
<p className="text-sm text-gray-500">Powered by GPT-4o · Not a replacement for medical advice</p>
</div>
</div>
{/* Input form */}
{!result && (
<form onSubmit={handleSubmit} className="bg-white rounded-2xl border border-gray-100 p-6 space-y-5">
{/* Symptoms input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Your Symptoms
</label>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addSymptom())}
placeholder="e.g. headache, fever, sore throat..."
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
/>
<button
type="button"
onClick={addSymptom}
className="px-4 py-2.5 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
{symptoms.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{symptoms.map((s) => (
<span
key={s}
className="flex items-center gap-1.5 px-3 py-1 bg-purple-50 text-purple-700 border border-purple-200 rounded-full text-sm"
>
{s}
<button type="button" onClick={() => removeSymptom(s)}>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
{/* Duration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Duration</label>
<div className="flex flex-wrap gap-2">
{DURATION_OPTIONS.map((d) => (
<button
key={d}
type="button"
onClick={() => setDuration(d)}
className={`px-3 py-1.5 rounded-lg text-sm border transition-all ${
duration === d
? "bg-purple-600 text-white border-purple-600"
: "border-gray-200 text-gray-600 hover:border-purple-300"
}`}
>
{d}
</button>
))}
</div>
</div>
{/* Severity */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">How bad do you feel?</label>
<div className="flex flex-wrap gap-2">
{SEVERITY_OPTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSeverity(s)}
className={`px-3 py-1.5 rounded-lg text-sm border transition-all ${
severity === s
? "bg-purple-600 text-white border-purple-600"
: "border-gray-200 text-gray-600 hover:border-purple-300"
}`}
>
{s}
</button>
))}
</div>
</div>
{error && (
<p className="text-red-500 text-sm">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-purple-600 text-white rounded-xl font-semibold hover:bg-purple-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Analyzing symptoms...
</>
) : (
<>
<Brain className="w-4 h-4" /> Analyze with AI
</>
)}
</button>
</form>
)}
{/* Results */}
{result && triageMeta && (
<div className="space-y-4">
{/* Triage banner */}
<div className={`rounded-2xl border-2 p-5 ${getSeverityColor(result.triageLevel)}`}>
<div className="flex items-center gap-3">
<triageMeta.icon className="w-6 h-6 flex-shrink-0" />
<div>
<p className="font-bold text-base">{triageMeta.label}</p>
<p className="text-sm mt-0.5">{triageMeta.desc}</p>
</div>
</div>
</div>
{/* AI Analysis */}
<div className="bg-white rounded-2xl border border-gray-100 p-5">
<h3 className="font-semibold text-gray-900 mb-2">AI Analysis</h3>
<p className="text-gray-600 text-sm leading-relaxed">{result.analysis}</p>
</div>
{/* Top conditions */}
<div className="bg-white rounded-2xl border border-gray-100 p-5">
<h3 className="font-semibold text-gray-900 mb-3">Possible Conditions</h3>
<div className="space-y-2">
{result.topConditions.map((c, i) => (
<div key={c} className="flex items-center gap-3">
<span className="w-6 h-6 bg-blue-100 text-blue-700 rounded-full text-xs font-bold flex items-center justify-center flex-shrink-0">
{i + 1}
</span>
<p className="text-sm text-gray-700">{c}</p>
</div>
))}
</div>
</div>
{/* Warning signs */}
{result.warningSigns.length > 0 && (
<div className="bg-red-50 border border-red-100 rounded-2xl p-5">
<h3 className="font-semibold text-red-700 mb-3 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" /> Warning Signs — Seek Emergency Care If:
</h3>
<ul className="space-y-1">
{result.warningSigns.map((w) => (
<li key={w} className="text-sm text-red-600 flex items-start gap-2">
<span className="mt-1 w-1 h-1 bg-red-400 rounded-full flex-shrink-0" />
{w}
</li>
))}
</ul>
</div>
)}
{/* Recommendation */}
<div className="bg-blue-50 border border-blue-100 rounded-2xl p-5">
<h3 className="font-semibold text-blue-800 mb-1">Recommended Next Step</h3>
<p className="text-sm text-blue-700">{result.recommended}</p>
</div>
{/* CTA */}
<div className="flex gap-3">
<button
onClick={() => setResult(null)}
className="flex-1 py-3 border border-gray-200 text-gray-700 rounded-xl font-medium hover:bg-gray-50 transition-colors text-sm"
>
Check Again
</button>
<a
href="/doctors"
className="flex-1 py-3 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 transition-colors text-sm text-center"
>
Find a Doctor
</a>
</div>
<p className="text-center text-xs text-gray-400 pb-4">
This AI analysis is for informational purposes only and does not constitute medical advice.
</p>
</div>
)}
</div>
</div>
</div>
);
}
~/medconsult-ai/src/app/api/ai/symptom-check/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { analyzeSymptoms } from "@/lib/openai";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const schema = z.object({
symptoms: z.array(z.string()).min(1),
duration: z.string(),
severity: z.string(),
});
export async function POST(req: NextRequest) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { symptoms, duration, severity } = schema.parse(body);
const result = await analyzeSymptoms(symptoms, duration, severity);
// Persist to DB
await prisma.symptomCheck.create({
data: {
userId: session.user.id,
symptoms,
duration,
severity: result.triageLevel as any,
aiAnalysis: result.analysis,
triage: result.triageLevel,
recommended: result.recommended,
},
});
return NextResponse.json(result);
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err.message || "Failed" }, { status: 500 });
}
}
~/medconsult-ai/src/app/api/ai/chat/route.ts
import { openai, CHAT_ASSISTANT_SYSTEM } from "@/lib/openai";
import { auth } from "@/lib/auth";
import { NextRequest } from "next/server";
import { OpenAIStream, StreamingTextResponse } from "ai";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return new Response("Unauthorized", { status: 401 });
}
const { messages } = await req.json();
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
stream: true,
messages: [
{ role: "system", content: CHAT_ASSISTANT_SYSTEM },
...messages,
],
max_tokens: 600,
});
const stream = OpenAIStream(response);
return new StreamingTextResponse(stream);
}
~/medconsult-ai/src/app/api/ai/summarize/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { generateConsultationSummary } from "@/lib/openai";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { consultationId } = await req.json();
if (!consultationId) return NextResponse.json({ error: "consultationId required" }, { status: 400 });
const consultation = await prisma.consultation.findUnique({
where: { id: consultationId },
include: {
messages: {
include: { sender: { select: { name: true, role: true } } },
orderBy: { createdAt: "asc" },
},
},
});
if (!consultation) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Build transcript
const transcript = consultation.messages
.map((m) => `[${m.isAI ? "AI" : m.sender.name} (${m.sender.role})]: ${m.content}`)
.join("\n");
const summary = await generateConsultationSummary(transcript);
// Save summary
await prisma.consultation.update({
where: { id: consultationId },
data: { aiSummary: summary },
});
return NextResponse.json({ summary });
}
~/medconsult-ai/src/app/api/consultations/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// GET /api/consultations — list user's consultations
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const isDoctor = session.user.role === "DOCTOR";
const consultations = await prisma.consultation.findMany({
where: isDoctor
? { doctorId: session.user.id }
: { patientId: session.user.id },
include: {
patient: { select: { id: true, name: true, image: true } },
doctor: {
select: {
id: true, name: true, image: true,
doctorProfile: { select: { specialty: true } },
},
},
messages: { orderBy: { createdAt: "desc" }, take: 1 },
},
orderBy: { updatedAt: "desc" },
});
return NextResponse.json(consultations);
}
// POST /api/consultations — create new consultation
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.user.role !== "PATIENT") {
return NextResponse.json({ error: "Only patients can start consultations" }, { status: 403 });
}
const { doctorId, chief } = await req.json();
if (!doctorId || !chief) {
return NextResponse.json({ error: "doctorId and chief complaint required" }, { status: 400 });
}
const consultation = await prisma.consultation.create({
data: {
patientId: session.user.id,
doctorId,
chief,
status: "PENDING",
},
});
return NextResponse.json(consultation, { status: 201 });
}
~/medconsult-ai/src/app/api/consultations/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// GET /api/consultations/[id]
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const consultation = await prisma.consultation.findUnique({
where: { id: params.id },
include: {
patient: { select: { id: true, name: true, image: true, patientProfile: true } },
doctor: {
select: {
id: true, name: true, image: true,
doctorProfile: { select: { specialty: true, hospital: true } },
},
},
messages: {
include: { sender: { select: { id: true, name: true, role: true } } },
orderBy: { createdAt: "asc" },
},
},
});
if (!consultation) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Access check
const userId = session.user.id;
if (consultation.patientId !== userId && consultation.doctorId !== userId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return NextResponse.json(consultation);
}
// PATCH /api/consultations/[id] — update status / add diagnosis / notes
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const allowedFields = ["status", "diagnosis", "prescription", "notes"];
const data: Record<string, any> = {};
for (const f of allowedFields) {
if (body[f] !== undefined) data[f] = body[f];
}
if (body.status === "ACTIVE") data.startedAt = new Date();
if (body.status === "COMPLETED") data.endedAt = new Date();
const updated = await prisma.consultation.update({
where: { id: params.id },
data,
});
return NextResponse.json(updated);
}
~/medconsult-ai/src/app/api/messages/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { pusherServer, CHANNELS, EVENTS } from "@/lib/pusher";
// POST /api/messages — send a message
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { consultationId, content } = await req.json();
if (!consultationId || !content?.trim()) {
return NextResponse.json({ error: "consultationId and content required" }, { status: 400 });
}
// Verify sender is part of this consultation
const consultation = await prisma.consultation.findUnique({
where: { id: consultationId },
});
if (!consultation) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
consultation.patientId !== session.user.id &&
consultation.doctorId !== session.user.id
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const message = await prisma.message.create({
data: {
consultationId,
senderId: session.user.id,
content: content.trim(),
},
include: {
sender: { select: { id: true, name: true, role: true } },
},
});
// Push real-time event via Pusher
await pusherServer.trigger(
CHANNELS.consultation(consultationId),
EVENTS.NEW_MESSAGE,
message
);
return NextResponse.json(message, { status: 201 });
}
~/medconsult-ai/src/app/api/doctors/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// GET /api/doctors — list verified doctors
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const specialty = searchParams.get("specialty");
const q = searchParams.get("q");
const doctors = await prisma.user.findMany({
where: {
role: "DOCTOR",
doctorProfile: {
verified: true,
...(specialty ? { specialty } : {}),
},
...(q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ doctorProfile: { specialty: { contains: q, mode: "insensitive" } } },
],
}
: {}),
},
include: {
doctorProfile: true,
},
orderBy: { doctorProfile: { rating: "desc" } },
take: 20,
});
return NextResponse.json(doctors);
}
# Write appointments API route mkdir -p /home/daytona/workspace/medconsult-ai/src/app/api/appointments cat > /home/daytona/workspace/medconsult-ai/src/app/api/appointments/route.ts << 'EOF' import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; export async function GET(req: NextRequest) { const session = await auth(); if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const isDoctor = session.user.role === "DOCTOR"; const appointments = await prisma.appointment.findMany({ where: isDoctor ? { doctorId: session.user.id } : { patientId: session.user.id }, include: { patient: { select: { id: true, name: true, image: true } }, doctor: { select: { id: true, name: true, image: true, doctorProfile: { select: { specialty: true } }, }, }, }, orderBy: { scheduledAt: "asc" }, }); return NextResponse.json(appointments); } export async function POST(req: NextRequest) { const session = await auth(); if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { doctorId, scheduledAt, reason, duration } = await req.json(); if (!doctorId || !scheduledAt) { return NextResponse.json({ error: "doctorId and scheduledAt required" }, { status: 400 }); } const appointment = await prisma.appointment.create({ data: { patientId: session.user.id, doctorId, scheduledAt: new Date(scheduledAt), reason, duration: duration ?? 30, }, }); return NextResponse.json(appointment, { status: 201 }); } EOF echo "appointments route done"
continue
# Write all remaining files in one big batch using execute (no model calls) # ── Consultation chat page ──────────────────────────────────────────────────── mkdir -p /home/daytona/workspace/medconsult-ai/src/app/consultation/\[id\] cat > "/home/daytona/workspace/medconsult-ai/src/app/consultation/[id]/page.tsx" << 'ENDOFFILE' "use client"; import { useEffect, useRef, useState } from "react"; import { useSession } from "next-auth/react"; import { useParams, useRouter } from "next/navigation"; import { Send, Bot, Loader2, FileText, CheckCircle, Sidebar as SidebarIcon } from "lucide-react"; import { formatTime, getInitials, cn } from "@/lib/utils"; import { getPusherClient, CHANNELS, EVENTS } from "@/lib/pusher"; import { Sidebar } from "@/components/layout/Sidebar"; interface Message { id: string; content: string; isAI: boolean; createdAt: string; sender: { id: string; name: string; role: string }; } interface Consultation { id: string; chief: string; status: string; diagnosis?: string; prescription?: string; aiSummary?: string; patient: { id: string; name: string }; doctor: { id: string; name: string; doctorProfile?: { specialty: string } }; messages: Message[]; } export default function ConsultationPage() { const { data: session } = useSession(); const params = useParams(); const router = useRouter(); const id = params.id as string; const [consultation, setConsultation] = useState<Consultation | null>(null); const [messages, setMessages] = useState<Message[]>([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [summarizing, setSummarizing] = useState(false); const [showSummary, setShowSummary] = useState(false); const [loading, setLoading] = useState(true); const bottomRef = useRef<HTMLDivElement>(null); // Load consultation useEffect(() => { if (!id) return; fetch(`/api/consultations/${id}`) .then((r) => r.json()) .then((data) => { setConsultation(data); setMessages(data.messages ?? []); setLoading(false); }); }, [id]); // Pusher real-time useEffect(() => { if (!id) return; const pusher = getPusherClient(); const channel = pusher.subscribe(CHANNELS.consultation(id)); channel.bind(EVENTS.NEW_MESSAGE, (msg: Message) => { setMessages((prev) => { if (prev.find((m) => m.id === msg.id)) return prev; return [...prev, msg]; }); }); return () => { pusher.unsubscribe(CHANNELS.consultation(id)); }; }, [id]); // Auto-scroll useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); async function sendMessage(e: React.FormEvent) { e.preventDefault(); if (!input.trim() || sending) return; setSending(true); const content = input.trim(); setInput(""); await fetch("/api/messages", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ consultationId: id, content }), }); setSending(false); } async function endConsultation() { setSummarizing(true); // Generate AI summary first await fetch("/api/ai/summarize", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ consultationId: id }), }); // Mark completed await fetch(`/api/consultations/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "COMPLETED" }), }); // Reload const r = await fetch(`/api/consultations/${id}`); const data = await r.json(); setConsultation(data); setSummarizing(false); setShowSummary(true); } const isDoctor = session?.user?.role === "DOCTOR"; const userId = session?.user?.id; if (loading) { return ( <div className="flex h-screen items-center justify-center bg-gray-50"> <Loader2 className="w-8 h-8 animate-spin text-blue-600" /> </div> ); } if (!consultation) { return ( <div className="flex h-screen items-center justify-center"> <p className="text-gray-500">Consultation not found.</p> </div> ); } const other = isDoctor ? consultation.patient : consultation.doctor; return ( <div className="flex h-screen bg-gray-50 overflow-hidden"> <Sidebar /> <div className="flex-1 flex flex-col overflow-hidden"> {/* Top bar */} <div className="bg-white border-b border-gray-100 px-5 py-3 flex items-center justify-between flex-shrink-0"> <div className="flex items-center gap-3"> <div className="w-9 h-9 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center font-bold text-sm"> {getInitials(other.name ?? "U")} </div> <div> <p className="text-sm font-semibold text-gray-900"> {isDoctor ? other.name : `Dr. ${other.name}`} </p> <p className="text-xs text-gray-400"> {consultation.chief} ·{" "} <span className={cn( "font-medium", consultation.status === "ACTIVE" ? "text-green-600" : consultation.status === "COMPLETED" ? "text-gray-500" : "text-yellow-600" )}> {consultation.status} </span> </p> </div> </div> <div className="flex items-center gap-2"> {consultation.aiSummary && ( <button onClick={() => setShowSummary(!showSummary)} className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors" > <FileText className="w-4 h-4" /> Summary </button> )} {isDoctor && consultation.status !== "COMPLETED" && ( <button onClick={endConsultation} disabled={summarizing} className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-green-50 text-green-700 rounded-lg hover:bg-green-100 transition-colors disabled:opacity-50" > {summarizing ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />} End & Summarize </button> )} </div> </div> <div className="flex-1 flex overflow-hidden"> {/* Messages */} <div className="flex-1 overflow-y-auto p-4 space-y-3"> {messages.length === 0 && ( <div className="flex flex-col items-center justify-center h-full text-center"> <div className="w-14 h-14 bg-blue-50 rounded-2xl flex items-center justify-center mb-3"> <Send className="w-6 h-6 text-blue-400" /> </div> <p className="text-gray-500 text-sm">No messages yet. Start the conversation.</p> </div> )} {messages.map((msg) => { const isMine = msg.sender.id === userId; return ( <div key={msg.id} className={cn( "flex gap-2 max-w-[75%]", isMine ? "ml-auto flex-row-reverse" : "", msg.isAI ? "max-w-full" : "" )} > {!isMine && ( <div className={cn( "w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0", msg.isAI ? "bg-purple-100 text-purple-700" : "bg-blue-100 text-blue-700" )}> {msg.isAI ? <Bot className="w-4 h-4" /> : getInitials(msg.sender.name)} </div> )} <div className={cn( "rounded-2xl px-4 py-2.5 text-sm", msg.isAI ? "bg-purple-50 border border-purple-100 text-gray-700 w-full" : isMine ? "bg-blue-600 text-white rounded-br-sm message-out" : "bg-white border border-gray-100 text-gray-800 rounded-bl-sm message-in" )}> {msg.isAI && ( <p className="text-xs font-semibold text-purple-600 mb-1">🤖 AI Assistant</p> )} <p className="whitespace-pre-wrap">{msg.content}</p> <p className={cn( "text-xs mt-1", isMine ? "text-blue-200" : "text-gray-400" )}> {formatTime(msg.createdAt)} </p> </div> </div> ); })} <div ref={bottomRef} /> </div> {/* AI Summary panel */} {showSummary && consultation.aiSummary && ( <div className="w-80 border-l border-gray-100 bg-white overflow-y-auto p-5 flex-shrink-0"> <div className="flex items-center gap-2 mb-4"> <FileText className="w-5 h-5 text-purple-600" /> <h3 className="font-semibold text-gray-900">AI Clinical Summary</h3> </div> <div className="prose prose-sm text-gray-600 whitespace-pre-wrap text-sm leading-relaxed"> {consultation.aiSummary} </div> </div> )} </div> {/* Message input */} {consultation.status !== "COMPLETED" && ( <div className="bg-white border-t border-gray-100 px-4 py-3 flex-shrink-0"> <form onSubmit={sendMessage} className="flex items-center gap-2"> <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Type a message..." className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> <button type="submit" disabled={sending || !input.trim()} className="w-10 h-10 bg-blue-600 text-white rounded-xl flex items-center justify-center hover:bg-blue-700 transition-colors disabled:opacity-50" > {sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />} </button> </form> </div> )} </div> </div> ); } ENDOFFILE echo "Consultation chat page done"
# Consultations list page mkdir -p /home/daytona/workspace/medconsult-ai/src/app/consultation cat > /home/daytona/workspace/medconsult-ai/src/app/consultation/page.tsx << 'ENDOFFILE' import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { redirect } from "next/navigation"; import Link from "next/link"; import { MessageSquare } from "lucide-react"; import { formatDate, getStatusColor, getInitials } from "@/lib/utils"; import { Sidebar } from "@/components/layout/Sidebar"; export default async function ConsultationsPage() { const session = await auth(); if (!session?.user) redirect("/login"); const isDoctor = session.user.role === "DOCTOR"; const consultations = await prisma.consultation.findMany({ where: isDoctor ? { doctorId: session.user.id } : { patientId: session.user.id }, include: { patient: { select: { id: true, name: true } }, doctor: { select: { id: true, name: true, doctorProfile: { select: { specialty: true } } } }, messages: { orderBy: { createdAt: "desc" }, take: 1 }, }, orderBy: { updatedAt: "desc" }, }); return ( <div className="flex h-screen bg-gray-50 overflow-hidden"> <Sidebar /> <div className="flex-1 overflow-y-auto p-6"> <div className="mb-6 flex items-center justify-between"> <h1 className="text-2xl font-bold text-gray-900">Consultations</h1> {!isDoctor && ( <Link href="/doctors" className="px-4 py-2 bg-blue-600 text-white text-sm rounded-xl hover:bg-blue-700 font-medium transition-colors" > + New Consultation </Link> )} </div> {consultations.length === 0 ? ( <div className="flex flex-col items-center justify-center py-24 text-center"> <MessageSquare className="w-16 h-16 text-gray-200 mb-4" /> <h3 className="text-lg font-semibold text-gray-700 mb-1">No consultations yet</h3> <p className="text-gray-400 text-sm mb-4">Start by finding a doctor</p> <Link href="/doctors" className="text-blue-600 hover:underline text-sm">Browse doctors →</Link> </div> ) : ( <div className="space-y-3"> {consultations.map((c) => { const other = isDoctor ? c.patient : c.doctor; const lastMsg = c.messages[0]; return ( <Link key={c.id} href={`/consultation/${c.id}`} className="flex items-center gap-4 bg-white rounded-2xl border border-gray-100 p-4 hover:shadow-md transition-shadow" > <div className="w-11 h-11 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center font-bold flex-shrink-0"> {getInitials(other.name ?? "U")} </div> <div className="flex-1 min-w-0"> <div className="flex items-center gap-2"> <p className="font-semibold text-gray-900 text-sm"> {isDoctor ? other.name : `Dr. ${other.name}`} </p> <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${getStatusColor(c.status)}`}> {c.status} </span> </div> <p className="text-sm text-gray-500 truncate mt-0.5"> {lastMsg ? lastMsg.content : c.chief} </p> </div> <p className="text-xs text-gray-400 flex-shrink-0">{formatDate(c.updatedAt)}</p> </Link> ); })} </div> )} </div> </div> ); } ENDOFFILE echo "Consultations list page done"
# Doctors browse page mkdir -p /home/daytona/workspace/medconsult-ai/src/app/doctors cat > /home/daytona/workspace/medconsult-ai/src/app/doctors/page.tsx << 'ENDOFFILE' "use client"; import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Search, Star, Clock, MapPin, Stethoscope, Loader2, MessageSquare } from "lucide-react"; import { getInitials } from "@/lib/utils"; import { Sidebar } from "@/components/layout/Sidebar"; interface Doctor { id: string; name: string; image?: string; doctorProfile: { specialty: string; hospital?: string; experience?: number; rating: number; reviewCount: number; consultationFee: number; isAvailable: boolean; bio?: string; }; } const SPECIALTIES = [ "All", "General Medicine", "Cardiology", "Dermatology", "Pediatrics", "Mental Health", "Orthopedics", "Gynecology", "Neurology", ]; export default function DoctorsPage() { const router = useRouter(); const [doctors, setDoctors] = useState<Doctor[]>([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [specialty, setSpecialty] = useState("All"); const [showModal, setShowModal] = useState<Doctor | null>(null); const [chief, setChief] = useState(""); const [starting, setStarting] = useState(false); useEffect(() => { const params = new URLSearchParams(); if (search) params.set("q", search); if (specialty !== "All") params.set("specialty", specialty); setLoading(true); fetch(`/api/doctors?${params}`) .then((r) => r.json()) .then((data) => { setDoctors(data); setLoading(false); }); }, [search, specialty]); async function startConsultation() { if (!showModal || !chief.trim()) return; setStarting(true); const res = await fetch("/api/consultations", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doctorId: showModal.id, chief }), }); const data = await res.json(); setStarting(false); setShowModal(null); router.push(`/consultation/${data.id}`); } return ( <div className="flex h-screen bg-gray-50 overflow-hidden"> <Sidebar /> <div className="flex-1 overflow-y-auto p-6"> <div className="mb-6"> <h1 className="text-2xl font-bold text-gray-900 mb-4">Find a Doctor</h1> {/* Search */} <div className="relative mb-4"> <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search by name or specialty..." className="w-full pl-10 pr-4 py-3 bg-white border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> </div> {/* Specialty filter */} <div className="flex gap-2 overflow-x-auto pb-1"> {SPECIALTIES.map((s) => ( <button key={s} onClick={() => setSpecialty(s)} className={`flex-shrink-0 px-4 py-1.5 rounded-full text-sm font-medium border transition-all ${ specialty === s ? "bg-blue-600 text-white border-blue-600" : "bg-white text-gray-600 border-gray-200 hover:border-blue-300" }`} > {s} </button> ))} </div> </div> {loading ? ( <div className="flex items-center justify-center py-24"> <Loader2 className="w-8 h-8 animate-spin text-blue-600" /> </div> ) : doctors.length === 0 ? ( <div className="text-center py-24"> <Stethoscope className="w-16 h-16 text-gray-200 mx-auto mb-3" /> <p className="text-gray-500">No doctors found matching your search.</p> </div> ) : ( <div className="grid md:grid-cols-2 xl:grid-cols-3 gap-4"> {doctors.map((doc) => ( <div key={doc.id} className="bg-white rounded-2xl border border-gray-100 p-5 hover:shadow-md transition-shadow"> <div className="flex items-start gap-3 mb-3"> <div className="w-12 h-12 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center font-bold flex-shrink-0"> {getInitials(doc.name ?? "D")} </div> <div className="min-w-0"> <p className="font-semibold text-gray-900">Dr. {doc.name}</p> <p className="text-sm text-blue-600">{doc.doctorProfile.specialty}</p> {doc.doctorProfile.hospital && ( <p className="text-xs text-gray-400 flex items-center gap-1 mt-0.5"> <MapPin className="w-3 h-3" /> {doc.doctorProfile.hospital} </p> )} </div> </div> {doc.doctorProfile.bio && ( <p className="text-xs text-gray-500 mb-3 line-clamp-2">{doc.doctorProfile.bio}</p> )} <div className="flex items-center justify-between text-xs text-gray-500 mb-4"> <span className="flex items-center gap-1"> <Star className="w-3.5 h-3.5 text-yellow-400 fill-yellow-400" /> {doc.doctorProfile.rating.toFixed(1)} ({doc.doctorProfile.reviewCount}) </span> {doc.doctorProfile.experience && ( <span className="flex items-center gap-1"> <Clock className="w-3.5 h-3.5" /> {doc.doctorProfile.experience} yrs exp </span> )} <span className={`font-medium ${doc.doctorProfile.isAvailable ? "text-green-600" : "text-gray-400"}`}> {doc.doctorProfile.isAvailable ? "● Available" : "○ Unavailable"} </span> </div> <div className="flex items-center justify-between"> <span className="text-sm font-bold text-gray-900"> {doc.doctorProfile.consultationFee === 0 ? "Free" : `$${doc.doctorProfile.consultationFee}`} </span> <button onClick={() => setShowModal(doc)} disabled={!doc.doctorProfile.isAvailable} className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white text-sm rounded-xl hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors font-medium" > <MessageSquare className="w-4 h-4" /> Consult Now </button> </div> </div> ))} </div> )} {/* Start consultation modal */} {showModal && ( <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl"> <h3 className="text-lg font-bold text-gray-900 mb-1">Start Consultation</h3> <p className="text-sm text-gray-500 mb-4"> with Dr. {showModal.name} · {showModal.doctorProfile.specialty} </p> <label className="block text-sm font-medium text-gray-700 mb-2"> What's your main concern? </label> <textarea value={chief} onChange={(e) => setChief(e.target.value)} rows={3} placeholder="e.g. I have had a persistent headache for 3 days..." className="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-none" /> <div className="flex gap-3 mt-4"> <button onClick={() => setShowModal(null)} className="flex-1 py-2.5 border border-gray-200 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-50 transition-colors" > Cancel </button> <button onClick={startConsultation} disabled={starting || !chief.trim()} className="flex-1 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2" > {starting && <Loader2 className="w-4 h-4 animate-spin" />} Start Chat </button> </div> </div> </div> )} </div> </div> ); } ENDOFFILE echo "Doctors page done"
# Appointments page mkdir -p /home/daytona/workspace/medconsult-ai/src/app/appointments cat > /home/daytona/workspace/medconsult-ai/src/app/appointments/page.tsx << 'ENDOFFILE' "use client"; import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; import { Calendar, Clock, Loader2, Plus } from "lucide-react"; import { formatDateTime, getStatusColor, getInitials } from "@/lib/utils"; import { Sidebar } from "@/components/layout/Sidebar"; interface Appointment { id: string; scheduledAt: string; duration: number; status: string; reason?: string; patient: { id: string; name: string }; doctor: { id: string; name: string; doctorProfile?: { specialty: string } }; } export default function AppointmentsPage() { const { data: session } = useSession(); const [appointments, setAppointments] = useState<Appointment[]>([]); const [loading, setLoading] = useState(true); const isDoctor = session?.user?.role === "DOCTOR"; useEffect(() => { fetch("/api/appointments") .then((r) => r.json()) .then((data) => { setAppointments(data); setLoading(false); }); }, []); const upcoming = appointments.filter((a) => ["SCHEDULED", "CONFIRMED"].includes(a.status) && new Date(a.scheduledAt) >= new Date() ); const past = appointments.filter((a) => a.status === "COMPLETED" || new Date(a.scheduledAt) < new Date() ); return ( <div className="flex h-screen bg-gray-50 overflow-hidden"> <Sidebar /> <div className="flex-1 overflow-y-auto p-6"> <div className="flex items-center justify-between mb-6"> <h1 className="text-2xl font-bold text-gray-900">Appointments</h1> {!isDoctor && ( <a href="/doctors" className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-xl hover:bg-blue-700 font-medium transition-colors" > <Plus className="w-4 h-4" /> Book Appointment </a> )} </div> {loading ? ( <div className="flex items-center justify-center py-24"> <Loader2 className="w-8 h-8 animate-spin text-blue-600" /> </div> ) : ( <div className="space-y-8"> {/* Upcoming */} <div> <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Upcoming</h2> {upcoming.length === 0 ? ( <div className="bg-white rounded-2xl border border-gray-100 p-8 text-center"> <Calendar className="w-10 h-10 text-gray-200 mx-auto mb-2" /> <p className="text-gray-400 text-sm">No upcoming appointments</p> </div> ) : ( <div className="space-y-3"> {upcoming.map((a) => { const other = isDoctor ? a.patient : a.doctor; return ( <div key={a.id} className="bg-white rounded-2xl border border-gray-100 p-4 flex items-center gap-4"> <div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center flex-shrink-0"> <Calendar className="w-6 h-6 text-blue-600" /> </div> <div className="flex-1 min-w-0"> <p className="font-semibold text-gray-900 text-sm"> {isDoctor ? other.name : `Dr. ${other.name}`} </p> {!isDoctor && (a.doctor as any).doctorProfile?.specialty && ( <p className="text-xs text-blue-600">{(a.doctor as any).doctorProfile.specialty}</p> )} {a.reason && <p className="text-xs text-gray-400 mt-0.5 truncate">{a.reason}</p>} </div> <div className="text-right flex-shrink-0"> <p className="text-sm font-medium text-gray-900">{formatDateTime(a.scheduledAt)}</p> <p className="text-xs text-gray-400 flex items-center gap-1 mt-0.5 justify-end"> <Clock className="w-3 h-3" /> {a.duration} min </p> <span className={`text-xs px-2 py-0.5 rounded-full font-medium mt-1 inline-block ${getStatusColor(a.status)}`}> {a.status} </span> </div> </div> ); })} </div> )} </div> {/* Past */} {past.length > 0 && ( <div> <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Past</h2> <div className="space-y-2"> {past.map((a) => { const other = isDoctor ? a.patient : a.doctor; return ( <div key={a.id} className="bg-white rounded-xl border border-gray-100 p-3 flex items-center gap-3 opacity-70"> <div className="w-9 h-9 bg-gray-100 rounded-lg flex items-center justify-center text-xs font-bold text-gray-500"> {getInitials(other.name ?? "U")} </div> <div className="flex-1 min-w-0"> <p className="text-sm font-medium text-gray-700"> {isDoctor ? other.name : `Dr. ${other.name}`} </p> <p className="text-xs text-gray-400">{formatDateTime(a.scheduledAt)}</p> </div> <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${getStatusColor(a.status)}`}> {a.status} </span> </div> ); })} </div> </div> )} </div> )} </div> </div> ); } ENDOFFILE echo "Appointments page done"
# Profile page mkdir -p /home/daytona/workspace/medconsult-ai/src/app/profile cat > /home/daytona/workspace/medconsult-ai/src/app/profile/page.tsx << 'ENDOFFILE' "use client"; import { useSession } from "next-auth/react"; import { useState } from "react"; import { Loader2, Save, User } from "lucide-react"; import { getInitials } from "@/lib/utils"; import { Sidebar } from "@/components/layout/Sidebar"; export default function ProfilePage() { const { data: session, update } = useSession(); const [name, setName] = useState(session?.user?.name ?? ""); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); async function handleSave() { setSaving(true); // In production: PATCH /api/users/me await new Promise((r) => setTimeout(r, 800)); setSaving(false); setSaved(true); setTimeout(() => setSaved(false), 2000); } return ( <div className="flex h-screen bg-gray-50 overflow-hidden"> <Sidebar /> <div className="flex-1 overflow-y-auto p-6"> <h1 className="text-2xl font-bold text-gray-900 mb-6">Profile Settings</h1> <div className="max-w-lg space-y-6"> {/* Avatar */} <div className="bg-white rounded-2xl border border-gray-100 p-6 flex items-center gap-4"> <div className="w-16 h-16 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xl font-bold"> {getInitials(session?.user?.name ?? "U")} </div> <div> <p className="font-semibold text-gray-900">{session?.user?.name}</p> <p className="text-sm text-gray-400">{session?.user?.email}</p> <span className="text-xs px-2 py-0.5 bg-blue-50 text-blue-700 rounded-full font-medium capitalize mt-1 inline-block"> {session?.user?.role?.toLowerCase()} </span> </div> </div> {/* Edit fields */} <div className="bg-white rounded-2xl border border-gray-100 p-6 space-y-4"> <h2 className="font-semibold text-gray-900">Personal Information</h2> <div> <label className="block text-sm font-medium text-gray-700 mb-1">Full Name</label> <input value={name} onChange={(e) => setName(e.target.value)} className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">Email</label> <input value={session?.user?.email ?? ""} disabled className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" /> </div> <button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors" > {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} {saved ? "Saved!" : "Save Changes"} </button> </div> </div> </div> </div> ); } ENDOFFILE echo "Profile page done"
# Toaster UI component mkdir -p /home/daytona/workspace/medconsult-ai/src/components/ui cat > /home/daytona/workspace/medconsult-ai/src/components/ui/toaster.tsx << 'ENDOFFILE' "use client"; import { useState, useEffect, createContext, useContext, useCallback } from "react"; import { X, CheckCircle, AlertCircle, Info } from "lucide-react"; import { cn } from "@/lib/utils"; type ToastType = "success" | "error" | "info"; interface Toast { id: string; message: string; type: ToastType; } interface ToastContextValue { toast: (message: string, type?: ToastType) => void; } const ToastContext = createContext<ToastContextValue>({ toast: () => {}, }); export function useToast() { return useContext(ToastContext); } export function Toaster() { const [toasts, setToasts] = useState<Toast[]>([]); const toast = useCallback((message: string, type: ToastType = "info") => { const id = Math.random().toString(36).slice(2); setToasts((p) => [...p, { id, message, type }]); setTimeout(() => { setToasts((p) => p.filter((t) => t.id !== id)); }, 4000); }, []); const icons = { success: <CheckCircle className="w-4 h-4 text-green-500" />, error: <AlertCircle className="w-4 h-4 text-red-500" />, info: <Info className="w-4 h-4 text-blue-500" />, }; return ( <ToastContext.Provider value={{ toast }}> <div className="fixed bottom-4 right-4 z-[100] space-y-2 pointer-events-none"> {toasts.map((t) => ( <div key={t.id} className={cn( "flex items-center gap-3 px-4 py-3 bg-white rounded-xl shadow-lg border text-sm pointer-events-auto", t.type === "error" && "border-red-200", t.type === "success" && "border-green-200", t.type === "info" && "border-blue-200" )} > {icons[t.type]} <span className="text-gray-700">{t.message}</span> <button onClick={() => setToasts((p) => p.filter((x) => x.id !== t.id))} className="ml-2 text-gray-400 hover:text-gray-600" > <X className="w-3 h-3" /> </button> </div> ))} </div> </ToastContext.Provider> ); } ENDOFFILE echo "Toaster done"
# Seed file cat > /home/daytona/workspace/medconsult-ai/prisma/seed.ts << 'ENDOFFILE' import { PrismaClient } from "@prisma/client"; import bcrypt from "bcryptjs"; const prisma = new PrismaClient(); async function main() { console.log("🌱 Seeding database..."); // Create demo patient const patientPw = await bcrypt.hash("password123", 12); const patient = await prisma.user.upsert({ where: { email: "patient@demo.com" }, update: {}, create: { name: "Alice Johnson", email: "patient@demo.com", password: patientPw, role: "PATIENT", patientProfile: { create: { dateOfBirth: new Date("1990-05-15"), gender: "Female", bloodType: "O+", allergies: ["Penicillin"], medications: ["Metformin 500mg"], conditions: ["Type 2 Diabetes"], phone: "+1-555-0100", }, }, }, }); // Create demo doctors const doctorPw = await bcrypt.hash("password123", 12); const doctors = [ { name: "James Carter", email: "dr.carter@demo.com", specialty: "General Medicine", hospital: "City General Hospital", bio: "Board-certified internist with 15 years of experience in primary care and preventive medicine.", experience: 15, fee: 50, }, { name: "Sarah Chen", email: "dr.chen@demo.com", specialty: "Cardiology", hospital: "Heart & Vascular Center", bio: "Interventional cardiologist specializing in heart failure and coronary artery disease.", experience: 12, fee: 120, }, { name: "Michael Torres", email: "dr.torres@demo.com", specialty: "Dermatology", hospital: "Skin Health Clinic", bio: "Dermatologist with expertise in acne, psoriasis, and skin cancer screening.", experience: 8, fee: 80, }, { name: "Emily Park", email: "dr.park@demo.com", specialty: "Mental Health", hospital: "MindCare Wellness", bio: "Psychiatrist specializing in anxiety, depression, and cognitive behavioral therapy.", experience: 10, fee: 100, }, { name: "David Okonkwo", email: "dr.okonkwo@demo.com", specialty: "Pediatrics", hospital: "Children's Medical Center", bio: "Pediatrician dedicated to child development and adolescent medicine.", experience: 14, fee: 60, }, ]; for (const d of doctors) { await prisma.user.upsert({ where: { email: d.email }, update: {}, create: { name: d.name, email: d.email, password: doctorPw, role: "DOCTOR", doctorProfile: { create: { specialty: d.specialty, hospital: d.hospital, bio: d.bio, experience: d.experience, consultationFee: d.fee, licenseNumber: `MD-${Math.random().toString(36).slice(2, 10).toUpperCase()}`, rating: 4.0 + Math.random() * 0.9, reviewCount: Math.floor(50 + Math.random() * 200), isAvailable: true, verified: true, languages: ["English"], workingDays: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], }, }, }, }); } console.log("✅ Seed complete!"); console.log("\nDemo accounts:"); console.log(" Patient: patient@demo.com / password123"); console.log(" Doctor: dr.carter@demo.com / password123"); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect()); ENDOFFILE echo "Seed file done"
# postcss.config.js cat > /home/daytona/workspace/medconsult-ai/postcss.config.js << 'ENDOFFILE' module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ENDOFFILE # next-auth types augmentation mkdir -p /home/daytona/workspace/medconsult-ai/src/types cat > /home/daytona/workspace/medconsult-ai/src/types/next-auth.d.ts << 'ENDOFFILE' import "next-auth"; import "next-auth/jwt"; declare module "next-auth" { interface Session { user: { id: string; name?: string | null; email?: string | null; image?: string | null; role: string; }; } interface User { role: string; } } declare module "next-auth/jwt" { interface JWT { role?: string; id?: string; } } ENDOFFILE echo "Config files done"
cat > /home/daytona/workspace/medconsult-ai/README.md << 'ENDOFFILE' # MedConsult AI 🏥 A full-stack, production-ready online doctor consultation platform with AI-powered features built with **Next.js 14**, **Prisma**, **PostgreSQL**, **OpenAI GPT-4o**, and **Pusher** real-time. --- ## Features | Feature | Description | |---|---| | 🔐 Auth | Email/password + Google OAuth via NextAuth v5 | | 👤 Roles | Patient & Doctor roles with dedicated dashboards | | 🧠 AI Symptom Checker | GPT-4o triage: differential diagnosis + severity level | | 💬 Real-time Chat | Doctor-patient consultation via Pusher WebSockets | | 🤖 AI Chat Assistant | Streaming AI medical assistant in consultations | | 📋 AI Clinical Summary | Auto-generated SOAP-style note at consultation end | | 📅 Appointments | Book, track, and manage appointments | | 👨⚕️ Doctor Directory | Browse/search verified doctors by specialty | | 🗄️ Database | PostgreSQL via Prisma ORM with full schema | --- ## Tech Stack - **Frontend/Backend**: Next.js 14 App Router (TypeScript) - **Database**: PostgreSQL + Prisma ORM - **Auth**: NextAuth v5 (JWT sessions, Prisma adapter) - **AI**: OpenAI GPT-4o-mini (symptom analysis, chat, summaries) - **Real-time**: Pusher (WebSocket channels per consultation) - **Styling**: Tailwind CSS + Radix UI primitives - **Forms**: React Hook Form + Zod validation --- ## Project Structure ``` src/ ├── app/ │ ├── page.tsx # Landing page │ ├── login/page.tsx # Sign in │ ├── register/page.tsx # Sign up (patient/doctor) │ ├── dashboard/page.tsx # Role-aware dashboard │ ├── symptom-checker/page.tsx # AI symptom checker │ ├── consultation/ │ │ ├── page.tsx # Consultation list │ │ └── [id]/page.tsx # Real-time chat room │ ├── doctors/page.tsx # Doctor directory │ ├── appointments/page.tsx # Appointment manager │ ├── profile/page.tsx # User profile │ └── api/ │ ├── auth/[...nextauth]/ # NextAuth handler │ ├── auth/register/ # User registration │ ├── consultations/ # CRUD consultations │ ├── messages/ # Send messages + Pusher │ ├── appointments/ # CRUD appointments │ ├── doctors/ # List/search doctors │ └── ai/ │ ├── symptom-check/ # Symptom triage │ ├── chat/ # Streaming AI chat │ └── summarize/ # Consultation summary ├── components/ │ ├── layout/ │ │ ├── Sidebar.tsx # Navigation sidebar + topbar │ │ └── AuthProvider.tsx # NextAuth session provider │ └── ui/ │ └── toaster.tsx # Toast notifications ├── lib/ │ ├── auth.ts # NextAuth config │ ├── prisma.ts # Prisma client singleton │ ├── openai.ts # OpenAI helpers + prompts │ ├── pusher.ts # Pusher server/client │ └── utils.ts # Utilities ├── types/ │ └── next-auth.d.ts # Type augmentations └── middleware.ts # Route protection prisma/ ├── schema.prisma # Full DB schema └── seed.ts # Demo data seed ``` --- ## Quick Start ### 1. Clone & Install ```bash git clone <repo-url> cd medconsult-ai npm install ``` ### 2. Environment Variables ```bash cp .env.example .env.local ``` Fill in `.env.local`: ```env # PostgreSQL DATABASE_URL="postgresql://user:pass@localhost:5432/medconsult" # NextAuth (generate with: openssl rand -base64 32) NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="your-secret-here" # Google OAuth (optional) GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" # OpenAI OPENAI_API_KEY="sk-..." # Pusher (create free project at pusher.com) PUSHER_APP_ID="" PUSHER_KEY="" PUSHER_SECRET="" PUSHER_CLUSTER="us2" NEXT_PUBLIC_PUSHER_KEY="" NEXT_PUBLIC_PUSHER_CLUSTER="us2" ``` ### 3. Database Setup ```bash # Generate Prisma client npm run db:generate # Push schema to DB npm run db:push # Seed demo data npm run db:seed ``` ### 4. Run ```bash npm run dev ``` Open [http://localhost:3000](http://localhost:3000) --- ## Demo Accounts (after seeding) | Role | Email | Password | |------|-------|----------| | Patient | patient@demo.com | password123 | | Doctor | dr.carter@demo.com | password123 | | Doctor (Cardiologist) | dr.chen@demo.com | password123 | | Doctor (Psychiatrist) | dr.park@demo.com | password123 | --- ## AI Features Detail ### Symptom Checker - Patient enters symptoms, duration, self-rated severity - GPT-4o-mini returns: differential diagnosis (top 3), triage level (LOW/MODERATE/HIGH/EMERGENCY), recommended action, warning signs - Result saved to `SymptomCheck` table per user ### AI Chat Assistant - Streaming chat endpoint (`/api/ai/chat`) with medical assistant persona - Warns about emergency symptoms, stays within safe messaging guidelines ### Consultation Summary - Triggered when doctor ends consultation - Full transcript fed to GPT-4o-mini → SOAP-style clinical note - Saved to consultation record, viewable in chat sidebar --- ## Production Checklist - [ ] Set `NEXTAUTH_SECRET` to a strong random value - [ ] Use a managed PostgreSQL (e.g., Supabase, Neon, Railway) - [ ] Enable Pusher SSL - [ ] Add rate limiting to AI routes (e.g., `@upstash/ratelimit`) - [ ] Add email verification for doctors - [ ] Add video call support (Daily.co / Agora) - [ ] HIPAA compliance review before storing PHI - [ ] Enable row-level security in PostgreSQL --- ## License MIT ENDOFFILE echo "README done"
# Final verification — list all project files find /home/daytona/workspace/medconsult-ai/src -type f | sort echo "---" find /home/daytona/workspace/medconsult-ai/prisma -type f | sort echo "---" ls /home/daytona/workspace/medconsult-ai/
file
File ·
| File | Purpose |
|---|---|
package.json | All dependencies (Next.js 14, Prisma, NextAuth v5, OpenAI, Pusher, Zod, Tailwind) |
prisma/schema.prisma | Full DB schema — 10 models |
prisma/seed.ts | 5 demo doctors + 1 patient |
.env.example | All required environment variables |
tailwind.config.js, tsconfig.json, postcss.config.js | Project config |
User → PatientProfile / DoctorProfile → Consultation → Message → Appointment → SymptomCheck → Notification| Route | What it does |
|---|---|
/ | Marketing landing page with stats, features, CTA |
/login | Email + Google OAuth sign in |
/register | Register as Patient or Doctor |
/dashboard | Role-aware — doctor sees queue/patients; patient sees quick actions |
/symptom-checker | AI symptom triage — tags, duration, severity → GPT-4o analysis |
/doctors | Browse/search doctors, filter by specialty, start consultation |
/consultation | List all consultations |
/consultation/[id] | Real-time chat room + AI summary panel |
/appointments | Upcoming/past appointments |
/profile | User profile settings |
| Route | What it does |
|---|---|
POST /api/auth/register | Create patient or doctor account |
GET/POST /api/consultations | List or create consultations |
GET/PATCH /api/consultations/[id] | Fetch chat + update status/diagnosis |
POST /api/messages | Send message + trigger Pusher real-time event |
GET/POST /api/appointments | Manage appointments |
GET /api/doctors | Search verified doctors |
POST /api/ai/symptom-check | GPT-4o triage + save to DB |
POST /api/ai/chat | Streaming AI medical assistant |
POST /api/ai/summarize | Generate SOAP clinical note from transcript |
cd medconsult-ai
cp .env.example .env.local # fill in DB, OpenAI, Pusher keys
npm install
npm run db:push
npm run db:seed
npm run dev
patient@demo.com / password123dr.carter@demo.com / password123send code
cd /home/daytona/workspace && zip -r medconsult-ai.zip medconsult-ai/ && echo "Done: $(du -sh medconsult-ai.zip | cut -f1)"
cd /home/daytona/workspace && tar -czf medconsult-ai.tar.gz medconsult-ai/ && echo "Done: $(du -sh medconsult-ai.tar.gz | cut -f1)"
medconsult ai.tar
Archive · GZ
tar -xzf medconsult-ai.tar.gz
cd medconsult-ai
cp .env.example .env.local # fill in your keys
npm install
npm run db:push
npm run db:seed
npm run dev
how to. open it on Mac?
.tar.gz file in Finder — it auto-extracts to a folder called medconsult-ai.tar -xzf ~/Downloads/medconsult-ai.tar.gz -C ~/Downloads
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install node
node -v → should show v18+brew install postgresql@15
brew services start postgresql@15
createdb medconsult
cd ~/Downloads/medconsult-ai
code .
Ifcodecommand not found: open VS Code →Cmd+Shift+P→ type "Shell Command: Install 'code' command"
cp .env.example .env.local
.env.local and fill in:DATABASE_URL="postgresql://YOUR_MAC_USERNAME@localhost:5432/medconsult"
NEXTAUTH_SECRET="any-random-string-here"
NEXTAUTH_URL="http://localhost:3000"
OPENAI_API_KEY="sk-..." # from platform.openai.com
PUSHER_APP_ID="" # free account at pusher.com
PUSHER_KEY=""
PUSHER_SECRET=""
PUSHER_CLUSTER="us2"
NEXT_PUBLIC_PUSHER_KEY=""
NEXT_PUBLIC_PUSHER_CLUSTER="us2"
To find your Mac username: runwhoamiin Terminal
npm install
npm run db:generate
npm run db:push
npm run db:seed
npm run dev
| Step | Command |
|---|---|
| Extract | Double-click the file |
| Install Node | brew install node |
| Install Postgres | brew install postgresql@15 |
| Create DB | createdb medconsult |
| Install packages | npm install |
| Setup DB | npm run db:push && npm run db:seed |
| Start app | npm run dev |
patient@demo.com / password123dr.carter@demo.com / password123